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:
pr-daaif
2026-07-17 03:47:04 +01:00
parent 514fc7c391
commit a22ea60f83
18 changed files with 1326 additions and 15 deletions

View 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 lOT en cours ({enCours[0]!.reference})
</Text>
</Pressable>
) : null}
</ScrollView>
</SafeAreaView>
);
}