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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user