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

@@ -177,8 +177,10 @@ export default function PageJournee() {
renderItem={({ item: o }) => {
const [enc, fond] = STYLE_STATUT[o.status](t);
return (
<View
<Pressable
accessibilityRole="button"
accessibilityLabel={`${o.reference}${o.title}`}
onPress={() => 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]}`
: ''}
</Text>
</View>
</Pressable>
);
}}
/>

View File

@@ -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 (
<AVenir
titre="Préventif"
release="R4.2"
detail="La grille du mois, cochable au pouce et hors-ligne — chaque coche partira en file individuellement."
/>
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
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>
);
}

View File

@@ -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<string | null>(null);
const dernierScan = useRef(0);
const resoudre = async (brut: string) => {
const reference = analyseScan(brut);
if (!reference) {
setErreur('Ce code nest 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 (
<AVenir
titre="Scanner"
release="R4.2"
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é."
/>
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
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.'
: 'Lappareil 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>
);
}

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>
);
}

View 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 linstant, revenez au réseau.
</Text>
) : null}
</ScrollView>
</SafeAreaView>
);
}

View 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 linstant 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>
);
}

View 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 linstant : 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>
);
}