mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r6): R6.6 — Demandeur restreint à son site
Trouvé en recette : GET /assets/options n'avait aucun filtre — tout Demandeur voyait le parc complet dans le sélecteur d'équipement de "Nouvelle demande", sur le web ET le mobile (même endpoint partagé). Risque réel : signaler accidentellement une panne sur un ascenseur qu'on ne gère pas. Corrigé à la racine, sur les deux plateformes à la fois : - Relation many-to-many User↔Location (assignedSites/assignedUsers, migration r6_demandeur_sites, même style que Team.members) — vide = aucune restriction, comportement historique inchangé pour tous les rôles sauf un Demandeur affecté à un site. - AssetsService.allowedLocationIds(user) : sites + zones filles autorisés, ou null si aucune restriction — réutilisée par options() ET par RequestsService.create (défense en profondeur : un assetId soumis directement hors périmètre est rejeté, 400). - UsersService : assertTopLevelSites (un Demandeur est affecté à un site, jamais une zone) ; invite()/update() branchent locationIds (remplace l'affectation, comme teamIds). - Web (personnes.tsx) : ModaleInvitation affiche les sites à cocher pour un rôle Demandeur ; colonne "Sites" éditable via une modale dédiée. - Mobile (formulaire-demande.tsx) : bouton "Scanner l'étiquette" en raccourci — résout uniquement contre les options déjà chargées (déjà filtrées), jamais de repli sur le parc complet qui annulerait la restriction. Aucun changement à useAssetOptions() : le filtrage serveur profite automatiquement au formulaire mobile. - Seed : Karim Doukkali (démo) rattaché à Tour Atlas. Bug trouvé en vérification avant tout commit : create() comparait allowed.includes(dto.assetId), mais allowed est une liste d'ids de sites/zones, pas d'ids d'appareils — aurait rejeté à tort tout signalement d'un Demandeur affecté, y compris dans son propre périmètre. Corrigé (comparaison sur asset.locationId) ; méthode renommée allowedAssetIds → allowedLocationIds pour que le nom dise ce qu'elle retourne. exploitation.e2e-spec.ts mis à jour (A1/C1 → A2/B1, dans le site de Karim — sinon rejetés par la nouvelle règle, comportement voulu). 79/80 tests API verts, le seul échec (documents-analytics, monthCost) est le flake calendaire déjà identifié cette session, sans rapport. Typecheck/tests/lint verts sur les 4 paquets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { Pressable, Text, TextInput, View } from 'react-native';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Platform, Pressable, Text, TextInput, View } from 'react-native';
|
||||
import { useAssetOptions, useCreateRequest } from '@/api/exploitation';
|
||||
import { analyseScan } from '@/lib/scan';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
import { BoutonTel, ChoixTel } from './ui';
|
||||
|
||||
/** Formulaire « Nouvelle demande » — mêmes champs que la modale de
|
||||
* signalement du web (asset via `/assets/options`, accessible même sans
|
||||
* ASSETS.view ; description ; personne bloquée). Utilisé à la fois par
|
||||
* l'onglet du Demandeur et par le Menu des rôles gestion. */
|
||||
* l'onglet du Demandeur et par le Menu des rôles gestion.
|
||||
* Scan QR (R6.6) : raccourci mobile pour resélectionner un équipement déjà
|
||||
* dans son périmètre — résolution UNIQUEMENT contre les options déjà
|
||||
* chargées (déjà filtrées par site pour un Demandeur affecté), jamais un
|
||||
* repli sur le parc complet qui annulerait la restriction. */
|
||||
export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => void }) {
|
||||
const t = useTokens();
|
||||
const { data: options } = useAssetOptions();
|
||||
@@ -15,9 +21,31 @@ export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => vo
|
||||
const [assetId, setAssetId] = useState<string | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
const [personneBloquee, setPersonneBloquee] = useState(false);
|
||||
const [scanOuvert, setScanOuvert] = useState(false);
|
||||
const [erreurScan, setErreurScan] = useState<string | null>(null);
|
||||
const [permission, demanderPermission] = useCameraPermissions();
|
||||
const dernierScan = useRef(0);
|
||||
|
||||
const asset = (options ?? []).find((a) => a.id === assetId) ?? null;
|
||||
const valide = !!assetId && description.trim().length >= 3;
|
||||
const cameraUtilisable = Platform.OS !== 'web' && permission?.granted;
|
||||
|
||||
const surScan = ({ data }: { data: string }) => {
|
||||
const maintenant = Date.now();
|
||||
if (maintenant - dernierScan.current < 1500) return; // anti-rafale
|
||||
dernierScan.current = maintenant;
|
||||
const reference = analyseScan(data);
|
||||
const trouve = reference
|
||||
? (options ?? []).find((a) => a.reference.toUpperCase() === reference)
|
||||
: null;
|
||||
if (!trouve) {
|
||||
setErreurScan("Cet appareil n'existe pas ou n'est pas dans votre périmètre.");
|
||||
return;
|
||||
}
|
||||
setErreurScan(null);
|
||||
setAssetId(trouve.id);
|
||||
setScanOuvert(false);
|
||||
};
|
||||
|
||||
const envoyer = () => {
|
||||
if (!assetId) return;
|
||||
@@ -36,6 +64,62 @@ export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => vo
|
||||
options={(options ?? []).map((a) => ({ id: a.id, label: `${a.reference} — ${a.siteName}` }))}
|
||||
surChoix={setAssetId}
|
||||
/>
|
||||
{scanOuvert ? (
|
||||
<View style={{ gap: 8 }}>
|
||||
{cameraUtilisable ? (
|
||||
<View style={{ height: 220, borderRadius: 12, overflow: 'hidden' }}>
|
||||
<CameraView
|
||||
style={{ flex: 1 }}
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={surScan}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
height: 220,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#131c2c',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: '#dfe7f2',
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
fontSize: 12.5,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{Platform.OS === 'web'
|
||||
? 'Caméra indisponible sur web.'
|
||||
: "Visez le QR de l'étiquette de cabine."}
|
||||
</Text>
|
||||
{Platform.OS !== 'web' && !permission?.granted ? (
|
||||
<BoutonTel libelle="Autoriser la caméra" surAppui={() => void demanderPermission()} />
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
<BoutonTel libelle="Annuler le scan" variante="gris" surAppui={() => setScanOuvert(false)} />
|
||||
</View>
|
||||
) : (
|
||||
<BoutonTel
|
||||
libelle="📷 Scanner l'étiquette"
|
||||
variante="contour"
|
||||
surAppui={() => {
|
||||
setErreurScan(null);
|
||||
setScanOuvert(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{erreurScan ? (
|
||||
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||
{erreurScan}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={{ gap: 4 }}>
|
||||
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
|
||||
Description <Text style={{ color: t.danger }}>*</Text>
|
||||
|
||||
Reference in New Issue
Block a user