Files
siop2/apps/mobile/app/ot/[id]/cloture.tsx
pr-daaif 6f74fd4206 fix(mobile): expo-file-system deleteAsync — import legacy subpath
SDK 57 deprecates the module-level functions (deleteAsync included) in
favor of the File/Directory classes, and now throws at runtime in dev
instead of just warning — surfaced as a red-box crash on the Assistant
screen right after transcription (found in recette, 02/08). The dictée
purge in cloture.tsx uses the exact same call and was silently affected
too. Fix: import from 'expo-file-system/legacy' in both — same function
signature, no behavior change, just the non-deprecated entry point.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 13:12:44 +01:00

371 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SDK 57 : les fonctions module-level (dont deleteAsync) sont dépréciées au
// profit des classes File/Directory et lèvent désormais en développement —
// import explicite du sous-chemin legacy, comportement inchangé sinon.
import * as FileSystem from 'expo-file-system/legacy';
import { router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
import {
RecordingPresets,
requestRecordingPermissionsAsync,
setAudioModeAsync,
useAudioRecorder,
} from 'expo-audio';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
BILAN_FIELD_LABELS,
BILAN_FIELDS,
REQUIRED_BILAN_FIELDS,
type BilanField,
type BilanSuggestion,
type ReportUpsert,
type WorkOrderDetail,
} from '@siop/shared';
import { useQueryClient } from '@tanstack/react-query';
import {
useReferenceValues,
useSuggestionBilan,
useTranscription,
useWorkOrder,
} from '@/api/exploitation';
import { useHorsLigne } from '@/auth/session';
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
import { enfilerBilan, enfilerTransition } from '@/file/actions';
import { useTokens } from '@/theme/tokens';
/** Écran 3 de la maquette R4 : le bilan codé R2 au pouce — 3 champs requis,
* garde visible et bloquante. Depuis R4.3, bilan et clôture partent EN
* FILE (D1) : hors-ligne, l'OT passe localement à « Terminé (en file) »
* et le serveur tranchera au rejeu (verrou D2 si l'OT a bougé). */
type ChampBilanId =
| 'doorStateId'
| 'cabinPositionId'
| 'anomalyId'
| 'externalCauseId'
| 'actionTakenId'
| 'componentConcernedId';
const CHAMP_VERS_ID: Record<BilanField, ChampBilanId> = {
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 queryClient = useQueryClient();
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 = () => {
const corps: ReportUpsert = {};
const labels: Parameters<typeof enfilerBilan>[3] = {};
for (const champ of BILAN_FIELDS) {
if (champ in choix) {
corps[CHAMP_VERS_ID[champ]] = choix[champ] ?? null;
labels[CHAMP_VERS_ID[champ]] = valeurDe(champ);
}
}
enfilerBilan(queryClient, ot, corps, labels);
};
const cloturer = () => {
enregistrer();
enfilerTransition(queryClient, ot, 'DONE');
router.replace(`/ot/${id}`);
};
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}`} />
<CarteSuggestion
ot={ot}
horsLigne={horsLigne}
surApplication={(s) => setChoix((c) => ({ ...c, [s.field]: s.valueId }))}
/>
<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="Enregistrer le bilan"
variante="contour"
surAppui={() => {
enregistrer();
router.replace(`/ot/${id}`);
}}
/>
<BoutonTel
libelle={
manquants.length
? 'Clôturer (bilan incomplet)'
: horsLigne
? 'Clôturer — partira à la synchro'
: "Clôturer l'intervention"
}
variante={manquants.length ? 'gris' : 'vert'}
desactive={manquants.length > 0}
surAppui={cloturer}
/>
{horsLigne ? (
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
Hors-ligne : la clôture est enregistrée sur le téléphone et sera rejouée telle quelle
au retour du réseau. Si lOT a changé entre-temps, VOUS trancherez (écran Synchro).
</Text>
) : null}
</ScrollView>
</SafeAreaView>
);
}
/** Écran 4 des maquettes R5 : décrire au pouce → chips suggérées (D1 — un
* appui = un choix humain, les chips ne font que pré-remplir les sélecteurs).
* L'IA est un service serveur : hors-ligne, la suggestion attend le réseau
* — la clôture en file R4, elle, n'en a pas besoin.
* Écran 6 (Voix, R5 D5, amendé 22/07) : dicter au lieu de taper — l'audio
* n'est JAMAIS conservé (transcrit puis effacé côté serveur ET localement),
* seule la transcription relue compte. Une fois jointe à l'OT, elle rejoint
* le corpus de l'assistant à la clôture, comme les bilans déjà codés. */
function CarteSuggestion({
ot,
horsLigne,
surApplication,
}: {
ot: WorkOrderDetail;
horsLigne: boolean;
surApplication: (s: BilanSuggestion) => void;
}) {
const t = useTokens();
const queryClient = useQueryClient();
const suggerer = useSuggestionBilan();
const transcrire = useTranscription();
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const [description, setDescription] = useState('');
const [appliquees, setAppliquees] = useState<Set<BilanField>>(new Set());
const [enregistrement, setEnregistrement] = useState(false);
const [jointe, setJointe] = useState(false);
const suggestions = suggerer.data?.suggestions ?? [];
const demarrerDictee = async () => {
const { granted } = await requestRecordingPermissionsAsync();
if (!granted) return;
// iOS refuse recorder.record() tant que la session audio n'a pas été
// explicitement autorisée à enregistrer (RecordingDisabledException).
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
await recorder.prepareToRecordAsync();
recorder.record();
setEnregistrement(true);
setJointe(false);
};
const terminerDictee = async () => {
await recorder.stop();
await setAudioModeAsync({ allowsRecording: false }); // n'arme le micro que le temps de dicter
setEnregistrement(false);
const uri = recorder.uri;
if (!uri) return;
transcrire.mutate(
{ uri, nom: 'dictee.m4a', mime: 'audio/m4a' },
{
onSuccess: (resultat) => setDescription(resultat.text),
// Loi 09-08 (D5) : l'audio ne survit JAMAIS à l'appel, succès ou pas —
// le fichier local suit le même sort que sur le serveur.
onSettled: () => void FileSystem.deleteAsync(uri, { idempotent: true }),
},
);
};
return (
<Carte titre="Décrire pour suggérer (optionnel)">
<TextInput
multiline
value={description}
onChangeText={(v) => {
setDescription(v);
setJointe(false);
}}
maxLength={2000}
placeholder="Décrivez la panne et ce que vous avez fait, ou dictez avec 🎙…"
placeholderTextColor={t.encre3}
accessibilityLabel="Décrire pour suggérer"
style={{
minHeight: 64,
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
backgroundColor: t.surface,
padding: 9,
color: t.encre,
fontFamily: 'Manrope_500Medium',
fontSize: 13,
textAlignVertical: 'top',
}}
/>
<BoutonTel
libelle={
horsLigne
? '🎙 Dictée — réseau requis'
: enregistrement
? '■ Terminer la dictée'
: transcrire.isPending
? 'Transcription…'
: '🎙 Dicter la description'
}
variante={enregistrement ? undefined : 'contour'}
desactive={horsLigne || transcrire.isPending}
surAppui={() => void (enregistrement ? terminerDictee() : demarrerDictee())}
/>
{transcrire.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
{transcrire.error.message}
</Text>
) : null}
{transcrire.isSuccess && !enregistrement ? (
<Text style={{ color: t.stTermine, fontFamily: 'Manrope_600SemiBold', fontSize: 11 }}>
Audio transcrit et supprimé relisez avant dappliquer.
</Text>
) : null}
<BoutonTel
libelle={jointe ? '✓ Description jointe à lOT' : 'Joindre la description à lOT'}
variante="contour"
desactive={jointe || description.trim().length === 0}
surAppui={() => {
enfilerBilan(queryClient, ot, { note: description.trim() }, {});
setJointe(true);
}}
/>
<Text style={{ color: t.encre3, fontFamily: 'Manrope_500Medium', fontSize: 11 }}>
🛡 Une fois jointe et lOT clôturé, cette description (anonymisée) rejoint le corpus de
lassistant comme les bilans déjà codés.
</Text>
<BoutonTel
libelle={
horsLigne
? 'Suggérer — réseau requis'
: suggerer.isPending
? 'Analyse…'
: '✨ Suggérer les codes'
}
variante="contour"
desactive={horsLigne || suggerer.isPending || description.trim().length < 10}
surAppui={() => {
setAppliquees(new Set());
suggerer.mutate(description.trim());
}}
/>
{suggerer.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
{suggerer.error.message}
</Text>
) : null}
{suggerer.isSuccess && suggestions.length === 0 ? (
<Text style={{ color: t.encre2, fontFamily: 'Manrope_500Medium', fontSize: 12 }}>
Aucun code assez proche lIA ne devine pas : choisissez dans les sélecteurs.
</Text>
) : null}
{suggestions.length > 0 ? (
<>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
{suggestions.map((s) => {
const faite = appliquees.has(s.field);
return (
<Pressable
key={s.field}
accessibilityRole="button"
accessibilityLabel={`Appliquer ${BILAN_FIELD_LABELS[s.field]} : ${s.label}`}
disabled={faite}
onPress={() => {
surApplication(s);
setAppliquees((avant) => new Set([...avant, s.field]));
}}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 5,
borderWidth: 1.5,
borderColor: t.primaire,
backgroundColor: faite ? t.primaire : t.primaireDoux,
borderRadius: 999,
paddingVertical: 6,
paddingHorizontal: 10,
}}
>
<Text
style={{
color: faite ? '#fff' : t.primaire,
fontFamily: 'Manrope_700Bold',
fontSize: 12,
}}
>
{faite ? '✓' : '✨'} {BILAN_FIELD_LABELS[s.field]} : {s.label}
</Text>
</Pressable>
);
})}
</View>
<Text style={{ color: t.encre3, fontFamily: 'Manrope_500Medium', fontSize: 11 }}>
Rien ne senregistre sans votre geste les chips pré-remplissent les sélecteurs
ci-dessous.
</Text>
</>
) : null}
</Carte>
);
}