mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r4.2): terrain mobile — scan QR, fiches ascenseur/OT, clôture, grille
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
- 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne.
|
||||||
- ✅ **R4 — maquettes rédigées** (17/07) : `maquette-r4.html`, 7 écrans mobile technicien offline-first (Ma journée bi-état, fiche OT, clôture terrain avec garde, scan QR des étiquettes R1, fiche ascenseur, checklist en file, synchro & conflits à verrou optimiste) + 5 décisions à acter (offline-first en file, verrou optimiste tranché par l'humain, périmètre fermé technicien, scan local, photos en file — ni audio ni géoloc en R4).
|
- ✅ **R4 — maquettes rédigées** (17/07) : `maquette-r4.html`, 7 écrans mobile technicien offline-first (Ma journée bi-état, fiche OT, clôture terrain avec garde, scan QR des étiquettes R1, fiche ascenseur, checklist en file, synchro & conflits à verrou optimiste) + 5 décisions à acter (offline-first en file, verrou optimiste tranché par l'humain, périmètre fermé technicien, scan local, photos en file — ni audio ni géoloc en R4).
|
||||||
- ✅ **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).
|
- ✅ **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.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -177,8 +177,10 @@ export default function PageJournee() {
|
|||||||
renderItem={({ item: o }) => {
|
renderItem={({ item: o }) => {
|
||||||
const [enc, fond] = STYLE_STATUT[o.status](t);
|
const [enc, fond] = STYLE_STATUT[o.status](t);
|
||||||
return (
|
return (
|
||||||
<View
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`${o.reference} — ${o.title}`}
|
accessibilityLabel={`${o.reference} — ${o.title}`}
|
||||||
|
onPress={() => router.push(`/ot/${o.id}`)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: t.surface,
|
backgroundColor: t.surface,
|
||||||
borderColor: t.bordure,
|
borderColor: t.bordure,
|
||||||
@@ -230,7 +232,7 @@ export default function PageJournee() {
|
|||||||
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
|
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
|
||||||
: ''}
|
: ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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() {
|
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 (
|
return (
|
||||||
<AVenir
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
titre="Préventif"
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
release="R4.2"
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
detail="La grille du mois, cochable au pouce et hors-ligne — chaque coche partira en file individuellement."
|
Préventif
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
data={grilles}
|
||||||
|
keyExtractor={(w) => w.id}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Aucune grille préventive en cours pour vous.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: w }) => (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/ot/${w.id}/grille`)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w.reference}
|
||||||
|
</Text>
|
||||||
|
<ChipStatut statut={w.status} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||||
|
{w.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||||
|
Asc. {w.assetReference} — {w.siteName}
|
||||||
|
{w.dueDate
|
||||||
|
? ` · pour le ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(w.dueDate))}`
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
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<string | null>(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 (
|
return (
|
||||||
<AVenir
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
titre="Scanner"
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
release="R4.2"
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
detail="Visez le QR de l'étiquette de cabine (posée en R1) — la fiche s'ouvrira même hors-ligne, sur le parc déjà synchronisé."
|
Scanner
|
||||||
|
</Text>
|
||||||
|
{cameraUtilisable ? (
|
||||||
|
<View style={{ flex: 1, borderRadius: 12, overflow: 'hidden' }}>
|
||||||
|
<CameraView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||||
|
onBarcodeScanned={surScan}
|
||||||
/>
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 12,
|
||||||
|
alignSelf: 'center',
|
||||||
|
color: '#dfe7f2',
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Visez le QR de l’étiquette de cabine
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#131c2c',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 150,
|
||||||
|
height: 150,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 2.5,
|
||||||
|
borderColor: t.safran,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#dfe7f2',
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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.'}
|
||||||
|
</Text>
|
||||||
|
{Platform.OS !== 'web' && !permission?.granted ? (
|
||||||
|
<BoutonTel libelle="Autoriser la caméra" surAppui={() => void demanderPermission()} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="Référence de l'appareil"
|
||||||
|
placeholder="Référence (A1, B2…)"
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
value={manuel}
|
||||||
|
onChangeText={(v) => {
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BoutonTel libelle="Ouvrir" desactive={!manuel.trim()} surAppui={() => void resoudre(manuel)} />
|
||||||
|
</View>
|
||||||
|
{erreur ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||||
|
{erreur}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
129
apps/mobile/app/ascenseur/[id].tsx
Normal file
129
apps/mobile/app/ascenseur/[id].tsx
Normal file
@@ -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 (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche
|
||||||
|
titre={`Asc. ${appareil.reference}`}
|
||||||
|
apres={
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: appareil.status === 'IN_SERVICE' ? t.stTermine : t.stAttente,
|
||||||
|
backgroundColor: appareil.status === 'IN_SERVICE' ? t.stTermineFond : t.stAttenteFond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statut}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 16, color: t.encre }}>
|
||||||
|
{appareil.brand}
|
||||||
|
{appareil.model ? ` ${appareil.model}` : ''} — {appareil.siteName}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Carte titre="Identité">
|
||||||
|
<LigneInfo nom="Emplacement" valeur={appareil.locationName} />
|
||||||
|
<LigneInfo nom="Catégorie" valeur={appareil.categoryName} />
|
||||||
|
<LigneInfo
|
||||||
|
nom="Mise en service"
|
||||||
|
valeur={
|
||||||
|
appareil.commissionedAt
|
||||||
|
? new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric' }).format(
|
||||||
|
new Date(appareil.commissionedAt),
|
||||||
|
)
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LigneInfo
|
||||||
|
nom="Charge / niveaux"
|
||||||
|
valeur={`${appareil.loadKg != null ? `${appareil.loadKg} kg` : '—'} · ${appareil.floors != null ? `${appareil.floors} niveaux` : '—'}`}
|
||||||
|
/>
|
||||||
|
{appareil.serialNumber ? (
|
||||||
|
<LigneInfo nom="N° de série" valeur={appareil.serialNumber} />
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<Carte titre="Organes suivis">
|
||||||
|
{appareil.components.length ? (
|
||||||
|
appareil.components.map((c) => (
|
||||||
|
<LigneInfo key={c.id} nom={c.typeName} valeur={c.designation ?? '—'} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Aucun organe déclaré.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<Carte titre={`Interventions visibles (${interventions.length})`}>
|
||||||
|
{interventions.slice(0, 6).map((w) => (
|
||||||
|
<Pressable
|
||||||
|
key={w.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/ot/${w.id}`)}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 3 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.primaire }}>
|
||||||
|
{w.reference}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ flex: 1, fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre }}
|
||||||
|
>
|
||||||
|
{w.title}
|
||||||
|
</Text>
|
||||||
|
<ChipStatut statut={w.status} />
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
{!interventions.length ? (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Aucune intervention visible pour votre rôle.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
{enCours.length ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/ot/${enCours[0]!.id}`)}
|
||||||
|
style={{ backgroundColor: t.primaire, borderRadius: 10, padding: 12, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 14 }}>
|
||||||
|
Ouvrir l’OT en cours ({enCours[0]!.reference})
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
apps/mobile/app/ot/[id]/cloture.tsx
Normal file
144
apps/mobile/app/ot/[id]/cloture.tsx
Normal file
@@ -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<BilanField, keyof ReportUpsert> = {
|
||||||
|
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<Partial<Record<BilanField, string | null>>>({});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
|
||||||
|
<Carte titre="Bilan d'intervention — requis pour clôturer">
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
{[0, 2, 4].map((rang) => (
|
||||||
|
<View key={rang} style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
{[BILAN_FIELDS[rang]!, BILAN_FIELDS[rang + 1]!].map((champ) => (
|
||||||
|
<ChoixTel
|
||||||
|
key={champ}
|
||||||
|
libelle={BILAN_FIELD_LABELS[champ]}
|
||||||
|
requis={REQUIRED_BILAN_FIELDS.includes(champ)}
|
||||||
|
valeur={valeurDe(champ)}
|
||||||
|
options={optionsDe(champ)}
|
||||||
|
surChoix={(vid) => setChoix((c) => ({ ...c, [champ]: vid }))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{manquants.length ? (
|
||||||
|
<Text style={{ color: t.alerte, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||||
|
⚠ {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).
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<BoutonTel
|
||||||
|
libelle={bilan.isPending ? 'Enregistrement…' : 'Enregistrer le bilan'}
|
||||||
|
variante="contour"
|
||||||
|
desactive={horsLigne || bilan.isPending}
|
||||||
|
surAppui={() => enregistrer()}
|
||||||
|
/>
|
||||||
|
<BoutonTel
|
||||||
|
libelle={
|
||||||
|
manquants.length
|
||||||
|
? 'Clôturer (bilan incomplet)'
|
||||||
|
: transition.isPending
|
||||||
|
? 'Clôture…'
|
||||||
|
: "Clôturer l'intervention"
|
||||||
|
}
|
||||||
|
variante={manquants.length ? 'gris' : 'vert'}
|
||||||
|
desactive={horsLigne || manquants.length > 0 || bilan.isPending || transition.isPending}
|
||||||
|
surAppui={cloturer}
|
||||||
|
/>
|
||||||
|
{horsLigne ? (
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||||
|
Hors-ligne : la clôture en file arrive en R4.3 — pour l’instant, revenez au réseau.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
apps/mobile/app/ot/[id]/grille.tsx
Normal file
125
apps/mobile/app/ot/[id]/grille.tsx
Normal file
@@ -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<typeof prochainEtat>[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 (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche
|
||||||
|
titre={`Grille — ${ot.assetReference}`}
|
||||||
|
apres={
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 13, color: t.encre2 }}>
|
||||||
|
{faits}/{total}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<View style={{ height: 8, borderRadius: 999, backgroundColor: t.surface2, overflow: 'hidden' }}>
|
||||||
|
<View
|
||||||
|
style={{ width: `${Math.round(pct * 100)}%`, height: '100%', backgroundColor: t.succes }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{horsLigne ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
borderColor: t.stAttente,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||||
|
⚠ Hors-ligne — les coches en file arrivent en R4.3 ; pour l’instant la grille est en
|
||||||
|
lecture.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{ot.checklist.map((item) => (
|
||||||
|
<Pressable
|
||||||
|
key={item.id}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
aria-checked={item.state === 'DONE'}
|
||||||
|
accessibilityLabel={item.label}
|
||||||
|
disabled={horsLigne || coche.isPending}
|
||||||
|
onPress={() => 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: item.state === 'DONE' ? t.succes : t.bordureForte,
|
||||||
|
backgroundColor: item.state === 'DONE' ? t.succes : 'transparent',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.state === 'DONE' ? (
|
||||||
|
<Text style={{ color: '#fff', fontSize: 13, fontFamily: 'Manrope_800ExtraBold' }}>✓</Text>
|
||||||
|
) : item.state === 'NA' ? (
|
||||||
|
<Text style={{ color: t.encre3, fontSize: 10, fontFamily: 'Manrope_800ExtraBold' }}>NA</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
color: item.state === 'PENDING' ? t.encre : t.encre2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{item.doneBy ? (
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 10.5, color: t.encre3 }}>
|
||||||
|
{item.doneBy.displayName.split(' ')[0]}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
|
||||||
|
Appui simple : cocher / décocher · appui long : non-applicable.
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
apps/mobile/app/ot/[id]/index.tsx
Normal file
177
apps/mobile/app/ot/[id]/index.tsx
Normal file
@@ -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 (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre={ot.reference} apres={<ChipStatut statut={ot.status} />} />
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
|
||||||
|
{ot.title}
|
||||||
|
</Text>
|
||||||
|
{horsLigne ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
borderColor: t.stAttente,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||||
|
⚠ Hors-ligne — lecture seule pour l’instant : les saisies hors réseau arrivent en R4.3 (file).
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Carte titre="Intervention">
|
||||||
|
<LigneInfo nom="Type" valeur={WORK_ORDER_TYPE_LABELS[ot.type]} />
|
||||||
|
<LigneInfo
|
||||||
|
nom="Équipement"
|
||||||
|
valeur={`Asc. ${ot.assetReference} — ${ot.siteName}`}
|
||||||
|
/>
|
||||||
|
<LigneInfo
|
||||||
|
nom="Priorité"
|
||||||
|
valeur={
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: ot.priority === 'PERSON_TRAPPED' || ot.priority === 'HIGH' ? t.prioHaute : t.encre,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WORK_ORDER_PRIORITY_LABELS[ot.priority]}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LigneInfo
|
||||||
|
nom="Échéance"
|
||||||
|
valeur={
|
||||||
|
ot.dueDate
|
||||||
|
? new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(ot.dueDate))
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{ot.request ? (
|
||||||
|
<LigneInfo nom="Demande liée" valeur={`${ot.request.reference} · ${ot.request.requesterLabel}`} />
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<Carte titre={`Pièces & main-d'œuvre — ${fmt.format(ot.costs.total)} MAD`}>
|
||||||
|
{ot.costs.parts.map((p) => (
|
||||||
|
<LigneInfo key={p.id} nom={`${p.designation} × ${p.quantity}`} valeur={`${fmt.format(p.total)}`} />
|
||||||
|
))}
|
||||||
|
{ot.costs.labor.map((l) => (
|
||||||
|
<LigneInfo
|
||||||
|
key={l.id}
|
||||||
|
nom={`${l.displayName} · ${Math.floor(l.minutes / 60)} h ${String(l.minutes % 60).padStart(2, '0')} × ${fmt.format(l.hourlyRate)}/h`}
|
||||||
|
valeur={`${fmt.format(l.total)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!ot.costs.parts.length && !ot.costs.labor.length ? (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Aucune consommation ni temps saisi. (Saisies pièces/temps : depuis le web en R4.2.)
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
{ot.checklist.length ? (
|
||||||
|
<Carte titre={`Checklist liée — ${grille.faits}/${grille.total}`}>
|
||||||
|
<BoutonTel
|
||||||
|
libelle={grille.faits === grille.total ? 'Grille complète ✓ — revoir' : 'Cocher la grille'}
|
||||||
|
variante="contour"
|
||||||
|
surAppui={() => router.push(`/ot/${ot.id}/grille`)}
|
||||||
|
/>
|
||||||
|
</Carte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ot.closureBlockers.length && (peutCloturer || ot.status === 'IN_PROGRESS') ? (
|
||||||
|
<Text style={{ color: t.alerte, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||||
|
⚠ {ot.closureBlockers.join(' · ')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{peutDemarrer ? (
|
||||||
|
<BoutonTel
|
||||||
|
libelle={ot.status === 'ON_HOLD' ? 'Reprendre' : 'Démarrer'}
|
||||||
|
desactive={horsLigne || transition.isPending}
|
||||||
|
surAppui={demarrer}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{peutCloturer ? (
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Clôturer l'intervention"
|
||||||
|
variante="vert"
|
||||||
|
desactive={horsLigne}
|
||||||
|
surAppui={() => router.push(`/ot/${ot.id}/cloture`)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{peutSuspendre ? (
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Mettre en attente"
|
||||||
|
variante="contour"
|
||||||
|
desactive={horsLigne || transition.isPending}
|
||||||
|
surAppui={() =>
|
||||||
|
transition.mutate(
|
||||||
|
{ to: 'ON_HOLD' },
|
||||||
|
{ onError: (e) => Alert.alert('Transition refusée', e.message) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Carte titre="Activité">
|
||||||
|
{ot.events.slice(0, 5).map((e) => (
|
||||||
|
<View key={e.id} style={{ gap: 1 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12, color: t.encre }}>
|
||||||
|
{e.kind}
|
||||||
|
{e.message ? ` — ${e.message}` : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre3 }}>
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||||||
|
new Date(e.createdAt),
|
||||||
|
)}
|
||||||
|
{e.by ? ` · ${e.by.displayName}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Carte>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@tanstack/react-query-persist-client": "^5.101.2",
|
"@tanstack/react-query-persist-client": "^5.101.2",
|
||||||
"expo": "~57.0.6",
|
"expo": "~57.0.6",
|
||||||
|
"expo-camera": "~57.0.3",
|
||||||
"expo-constants": "~57.0.5",
|
"expo-constants": "~57.0.5",
|
||||||
"expo-font": "~57.0.1",
|
"expo-font": "~57.0.1",
|
||||||
"expo-linking": "~57.0.3",
|
"expo-linking": "~57.0.3",
|
||||||
|
|||||||
91
apps/mobile/src/api/exploitation.ts
Normal file
91
apps/mobile/src/api/exploitation.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
265
apps/mobile/src/composants/ui.tsx
Normal file
265
apps/mobile/src/composants/ui.tsx
Normal file
@@ -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 (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{titre ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{titre}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LigneInfo({ nom, valeur }: { nom: string; valeur: ReactNode }) {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre2 }}>
|
||||||
|
{nom}
|
||||||
|
</Text>
|
||||||
|
{typeof valeur === 'string' ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: t.encre,
|
||||||
|
flexShrink: 1,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{valeur}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
valeur
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_STATUT: Record<WorkOrderStatus, (t: Tokens) => [string, string]> = {
|
||||||
|
OPEN: (t) => [t.stOuvert, t.stOuvertFond],
|
||||||
|
IN_PROGRESS: (t) => [t.stEncours, t.stEncoursFond],
|
||||||
|
ON_HOLD: (t) => [t.stAttente, t.stAttenteFond],
|
||||||
|
DONE: (t) => [t.stTermine, t.stTermineFond],
|
||||||
|
CANCELLED: (t) => [t.stAnnule, t.stAnnuleFond],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChipStatut({ statut }: { statut: WorkOrderStatus }) {
|
||||||
|
const t = useTokens();
|
||||||
|
const [encre, fond] = STYLE_STATUT[statut](t);
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: encre,
|
||||||
|
backgroundColor: fond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WORK_ORDER_STATUS_LABELS[statut]}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={desactive}
|
||||||
|
onPress={surAppui}
|
||||||
|
style={{
|
||||||
|
backgroundColor: desactive ? t.bordureForte : fonds[variante],
|
||||||
|
borderWidth: variante === 'contour' ? 1.5 : 0,
|
||||||
|
borderColor: t.primaire,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 14,
|
||||||
|
color: variante === 'contour' && !desactive ? t.primaire : '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{libelle}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnteteFiche({ titre, apres }: { titre: string; apres?: ReactNode }) {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Retour"
|
||||||
|
onPress={() => (router.canGoBack() ? router.back() : router.replace('/(tabs)/journee'))}
|
||||||
|
hitSlop={10}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 22, color: t.primaire, fontFamily: 'Manrope_700Bold' }}>‹</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre, flex: 1 }}
|
||||||
|
>
|
||||||
|
{titre}
|
||||||
|
</Text>
|
||||||
|
{apres}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sélecteur au pouce (RN n'a pas de <select>) : un champ qui ouvre une
|
||||||
|
* liste plein écran — utilisé par le bilan codé. */
|
||||||
|
export function ChoixTel({
|
||||||
|
libelle,
|
||||||
|
requis,
|
||||||
|
valeur,
|
||||||
|
options,
|
||||||
|
surChoix,
|
||||||
|
}: {
|
||||||
|
libelle: string;
|
||||||
|
requis?: boolean;
|
||||||
|
valeur: { id: string; label: string } | null;
|
||||||
|
options: { id: string; label: string }[];
|
||||||
|
surChoix: (id: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const t = useTokens();
|
||||||
|
const [ouvert, setOuvert] = useState(false);
|
||||||
|
return (
|
||||||
|
<View style={{ gap: 4, flex: 1 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
|
||||||
|
{libelle} {requis ? <Text style={{ color: t.danger }}>*</Text> : null}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={libelle}
|
||||||
|
onPress={() => setOuvert(true)}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
color: valeur ? t.encre : t.encre3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{valeur ? valeur.label : 'Sélectionner…'} ▾
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Modal visible={ouvert} animationType="slide" transparent onRequestClose={() => setOuvert(false)}>
|
||||||
|
<Pressable
|
||||||
|
style={{ flex: 1, backgroundColor: 'rgba(9,14,22,.55)' }}
|
||||||
|
onPress={() => setOuvert(false)}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: '70%',
|
||||||
|
padding: 14,
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 15, color: t.encre }}>
|
||||||
|
{libelle}
|
||||||
|
</Text>
|
||||||
|
<ScrollView>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => {
|
||||||
|
surChoix(null);
|
||||||
|
setOuvert(false);
|
||||||
|
}}
|
||||||
|
style={{ paddingVertical: 11 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 14, color: t.encre3 }}>
|
||||||
|
— (laisser vide)
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{options.map((o) => (
|
||||||
|
<Pressable
|
||||||
|
key={o.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => {
|
||||||
|
surChoix(o.id);
|
||||||
|
setOuvert(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
paddingVertical: 11,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: t.bordure,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 14,
|
||||||
|
color: o.id === valeur?.id ? t.primaire : t.encre,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o.label} {o.id === valeur?.id ? '✓' : ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/mobile/src/lib/checklist.test.ts
Normal file
25
apps/mobile/src/lib/checklist.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { prochainEtat, progression } from './checklist';
|
||||||
|
|
||||||
|
describe('checklist (écran 6 de la maquette R4)', () => {
|
||||||
|
it('progression : DONE et NA comptent comme traités (règle de la garde R2)', () => {
|
||||||
|
const { faits, total, pct } = progression([
|
||||||
|
{ state: 'DONE' },
|
||||||
|
{ state: 'NA' },
|
||||||
|
{ state: 'PENDING' },
|
||||||
|
{ state: 'PENDING' },
|
||||||
|
]);
|
||||||
|
expect(faits).toBe(2);
|
||||||
|
expect(total).toBe(4);
|
||||||
|
expect(pct).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grille vide : pas de division par zéro', () => {
|
||||||
|
expect(progression([]).pct).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appui simple : PENDING ⇄ DONE ; depuis NA on revient à DONE', () => {
|
||||||
|
expect(prochainEtat('PENDING')).toBe('DONE');
|
||||||
|
expect(prochainEtat('DONE')).toBe('PENDING');
|
||||||
|
expect(prochainEtat('NA')).toBe('DONE');
|
||||||
|
});
|
||||||
|
});
|
||||||
19
apps/mobile/src/lib/checklist.ts
Normal file
19
apps/mobile/src/lib/checklist.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ChecklistState } from '@siop/shared';
|
||||||
|
|
||||||
|
/** Progression d'une grille : cochée OU non-applicable = traitée (même
|
||||||
|
* règle que la garde de clôture R2). */
|
||||||
|
export function progression(items: readonly { state: ChecklistState }[]): {
|
||||||
|
faits: number;
|
||||||
|
total: number;
|
||||||
|
pct: number;
|
||||||
|
} {
|
||||||
|
const total = items.length;
|
||||||
|
const faits = items.filter((i) => i.state !== 'PENDING').length;
|
||||||
|
return { faits, total, pct: total ? faits / total : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le geste au pouce : appui simple PENDING ⇄ DONE ; le N/A passe par
|
||||||
|
* l'appui long (assumé à l'écran). Depuis NA, un appui revient à DONE. */
|
||||||
|
export function prochainEtat(actuel: ChecklistState): ChecklistState {
|
||||||
|
return actuel === 'PENDING' ? 'DONE' : actuel === 'DONE' ? 'PENDING' : 'DONE';
|
||||||
|
}
|
||||||
21
apps/mobile/src/lib/scan.test.ts
Normal file
21
apps/mobile/src/lib/scan.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { analyseScan } from './scan';
|
||||||
|
|
||||||
|
describe('analyseScan (D4 — QR des étiquettes A6)', () => {
|
||||||
|
it("extrait la référence de l'URL portail, quel que soit le domaine", () => {
|
||||||
|
expect(analyseScan('https://siop2.apps.enset.top/q/A1')).toBe('A1');
|
||||||
|
expect(analyseScan('http://localhost:5173/q/B2')).toBe('B2');
|
||||||
|
expect(analyseScan('https://client.spelev.ma/q/MC-1')).toBe('MC-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepte une référence tapée à la main, normalisée en majuscules', () => {
|
||||||
|
expect(analyseScan(' a1 ')).toBe('A1');
|
||||||
|
expect(analyseScan('b2')).toBe('B2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse ce qui n’est pas à nous — jamais d’écran blanc sur un QR étranger', () => {
|
||||||
|
expect(analyseScan('https://example.com/promo?x=1')).toBeNull();
|
||||||
|
expect(analyseScan('WIFI:T:WPA;S:box;P:secret;;')).toBeNull();
|
||||||
|
expect(analyseScan('')).toBeNull();
|
||||||
|
expect(analyseScan('référence avec espaces')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
16
apps/mobile/src/lib/scan.ts
Normal file
16
apps/mobile/src/lib/scan.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/** Analyse du scan (D4) : le QR des étiquettes A6 (R1) encode l'URL du
|
||||||
|
* portail public `…/q/REF`. On accepte aussi une référence tapée à la main.
|
||||||
|
* Retour : la référence normalisée, ou null si le code n'est pas un nôtre. */
|
||||||
|
|
||||||
|
const REF_VALIDE = /^[A-Z0-9][A-Z0-9-]{0,11}$/;
|
||||||
|
|
||||||
|
export function analyseScan(brut: string): string | null {
|
||||||
|
const texte = brut.trim();
|
||||||
|
if (!texte) return null;
|
||||||
|
|
||||||
|
// URL d'étiquette : http(s)://…/q/A1 (quel que soit le domaine d'origine)
|
||||||
|
const url = texte.match(/^https?:\/\/[^\s]+\/q\/([^\s/?#]+)$/i);
|
||||||
|
const candidat = decodeURIComponent(url?.[1] ?? texte).toUpperCase();
|
||||||
|
|
||||||
|
return REF_VALIDE.test(candidat) ? candidat : null;
|
||||||
|
}
|
||||||
@@ -4,6 +4,24 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-17 — Pr. Daaif (+ Claude) — R4.2 : scan QR, fiches terrain, grille cochable
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- **Scanner réel** (expo-camera, QR seulement) : analyse du code testée (`analyseScan` — URL portail `…/q/REF` de l'étiquette A6 quel que soit le domaine, ou référence tapée ; les QR étrangers sont refusés proprement, jamais d'écran blanc). **Résolution D4 : le parc en cache d'abord** (fonctionne en sous-sol), rafraîchissement seulement si le réseau est là ; référence inconnue → message honnête. Repli saisie manuelle (seul chemin sur web, assumé).
|
||||||
|
- **Fiche ascenseur** (consultation, D3) : identité, organes, interventions **visibles par le rôle** (invariant « voir autre »), raccourci vers l'OT en cours. **Fiche OT** : un bouton principal selon la machine à états, pièces & main-d'œuvre aux coûts figés, garde de clôture alimentée par les `closureBlockers` de l'API (une seule source de vérité). **Clôture terrain** : bilan codé 6 champs (sélecteur plein écran au pouce — RN n'a pas de `<select>`), 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)
|
## 2026-07-17 — Pr. Daaif (+ Claude) — R4.1 : socle mobile Expo (app du technicien)
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
63
pnpm-lock.yaml
generated
63
pnpm-lock.yaml
generated
@@ -135,6 +135,9 @@ importers:
|
|||||||
expo:
|
expo:
|
||||||
specifier: ~57.0.6
|
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)
|
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:
|
expo-constants:
|
||||||
specifier: ~57.0.5
|
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))
|
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':
|
'@types/deep-eql@4.0.2':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
|
'@types/emscripten@1.41.5':
|
||||||
|
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||||
|
|
||||||
@@ -3073,6 +3079,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
|
barcode-detector@3.2.1:
|
||||||
|
resolution: {integrity: sha512-zLL7AbT9uNJBUzYpKg9v5tUA4yQGReExSi60q0g660Mj0wjUhKmN0GrUF3qELo8ITW9THzyJXdZQkXLMnsieiw==}
|
||||||
|
|
||||||
base64-js@1.5.1:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
@@ -3801,6 +3810,17 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
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:
|
expo-constants@57.0.5:
|
||||||
resolution: {integrity: sha512-HVxPZc1uBdqrlcmNvdyO3L107vt/gsCRNGvXrYXWjZqmz1XOvGeUCR7S3MG4wUj4cQw4/WMCp+fcoDIhynJ80A==}
|
resolution: {integrity: sha512-HVxPZc1uBdqrlcmNvdyO3L107vt/gsCRNGvXrYXWjZqmz1XOvGeUCR7S3MG4wUj4cQw4/WMCp+fcoDIhynJ80A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -6031,6 +6051,10 @@ packages:
|
|||||||
symbol-tree@3.2.4:
|
symbol-tree@3.2.4:
|
||||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
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:
|
tailwind-merge@2.6.1:
|
||||||
resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
|
resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
|
||||||
|
|
||||||
@@ -6246,6 +6270,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
type-fest@5.8.0:
|
||||||
|
resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -6683,6 +6711,11 @@ packages:
|
|||||||
zod@4.4.3:
|
zod@4.4.3:
|
||||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||||
|
|
||||||
|
zxing-wasm@3.1.1:
|
||||||
|
resolution: {integrity: sha512-g0sPJBIubO6zLcJh1jftLPIN6xziaqLsvLgtpGKwDrEhyXXqla3E3yjFrznlr78UHIOMzbJPi0HDWKs/KgaB7A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/emscripten': '>=1.39.6'
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@adobe/css-tools@4.5.0': {}
|
'@adobe/css-tools@4.5.0': {}
|
||||||
@@ -9361,6 +9394,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/deep-eql@4.0.2': {}
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
|
'@types/emscripten@1.41.5': {}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint': 9.6.1
|
'@types/eslint': 9.6.1
|
||||||
@@ -10027,6 +10062,12 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@4.0.4: {}
|
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: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.10.43: {}
|
baseline-browser-mapping@2.10.43: {}
|
||||||
@@ -10789,6 +10830,17 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- 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)):
|
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:
|
dependencies:
|
||||||
'@expo/env': 2.4.2
|
'@expo/env': 2.4.2
|
||||||
@@ -13499,6 +13551,8 @@ snapshots:
|
|||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
|
|
||||||
|
tagged-tag@1.0.0: {}
|
||||||
|
|
||||||
tailwind-merge@2.6.1: {}
|
tailwind-merge@2.6.1: {}
|
||||||
|
|
||||||
tailwindcss@4.3.2: {}
|
tailwindcss@4.3.2: {}
|
||||||
@@ -13663,6 +13717,10 @@ snapshots:
|
|||||||
|
|
||||||
type-fest@4.41.0: {}
|
type-fest@4.41.0: {}
|
||||||
|
|
||||||
|
type-fest@5.8.0:
|
||||||
|
dependencies:
|
||||||
|
tagged-tag: 1.0.0
|
||||||
|
|
||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
media-typer: 0.3.0
|
media-typer: 0.3.0
|
||||||
@@ -14074,3 +14132,8 @@ snapshots:
|
|||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
zod@4.4.3: {}
|
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
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ allowBuilds:
|
|||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
'@scarf/scarf': false # télémétrie — bloquée
|
'@scarf/scarf': false # télémétrie — bloquée
|
||||||
'@prisma/client': true
|
'@prisma/client': true
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- expo-camera@57.0.3
|
||||||
|
|||||||
Reference in New Issue
Block a user