22 Commits

Author SHA1 Message Date
pr-daaif
a79551a4a3 docs(r6): recette Demandeur confirmée sur iPhone physique
Accueil (liste des demandes + statut) et création (sélection dans la
liste ou scan QR) vérifiés en conditions réelles par le référent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 09:43:02 +01:00
pr-daaif
39320d081e fix(mobile): R6.8 — la connexion routait toujours vers "Ma journée"
connexion.tsx faisait router.replace('/(tabs)/journee') en dur — trouvé
en recette : Karim (Demandeur) atterrissait sur l'écran du Technicien
après avoir basculé de compte démo. La redirection par rôle
(ongletAccueil) n'était branchée qu'à l'aiguillage initial (app/index.tsx,
R6.1), pas au retour de connexion — le chemin réellement emprunté à
chaque bascule de compte (pas de sélecteur de rôle en direct sur mobile,
changer de compte = se déconnecter puis se reconnecter).

useLogin()/useDemoLogin() (auth/session.tsx) renvoient maintenant la
réponse complète (contient user.role.name) au lieu de la jeter après en
avoir extrait le jeton. connexion.tsx : entrer(role) route vers
/(tabs)/${ongletAccueil(role)} pour les deux chemins (connexion classique
et sélecteur démo).

Au passage : sous-titre "Technicien" et accroche "sur le terrain — même
sans réseau" (reste de R4, mobile alors réservé au Technicien) devenus
trompeurs pour les autres rôles depuis R6 — généricisés.

Typecheck/tests/lint verts sur les 4 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 09:37:12 +01:00
pr-daaif
19076a91d3 docs(r6): périmètre WORK_ORDERS du Gestionnaire confirmé acceptable
Le référent tranche : pas de resserrement demandé, l'accès complet
hérité de R2/R3 reste en l'état.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 20:48:31 +01:00
pr-daaif
ed9833e56d fix(mobile): R6.7 — assignation à un technicien lors de l'approbation
Le Gestionnaire approuvait une demande sur mobile sans pouvoir assigner
l'OT à un technicien (l'OT partait non assigné) — remonté en recette :
"ce n'est pas à lui d'agir comme un technicien". Le web le fait déjà
(PanneauApprobation, demandes.tsx).

PanneauDemandes.tsx : "Approuver → OT" ouvre un panneau avec un ChoixTel
"Assigner à" (techniciens actifs, même filtre que le web —
status active && role.name.startsWith('Technicien')) avant de confirmer.
assigneeIds déjà supporté par l'API et le contrat — aucun changement
serveur nécessaire, seule l'UI mobile manquait cette étape.

Vérifié de bout en bout : demande → approuvée avec technicien assigné →
OT confirmé avec le bon assignee. Typecheck/tests/lint verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 20:44:21 +01:00
pr-daaif
c9168ece16 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>
2026-08-02 20:22:50 +01:00
pr-daaif
1ae0529a6a docs(r6): confirmation référent — dictée Assistant validée sur iPhone
Trois corrections successives (expo-file-system legacy, zone de relecture
multiligne, chevauchement ScrollView/TextInput) validées en conditions
réelles — consignées, aucune n'aurait été détectée par
typecheck/tests/lint seuls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 13:30:23 +01:00
pr-daaif
9aaee4e358 fix(mobile): Assistant — bouton Envoyer chevauchait encore le texte dicté
Deux causes cumulées : le ScrollView du chat n'avait pas style={flex:1}
(seul contentContainerStyle était posé), donc pas correctement borné dans
son parent flex — pouvait empiéter sur le composeur du dessous. Et le
TextInput multiligne utilisait maxHeight seul, qu'iOS ignore parfois en
laissant le champ grandir avec le contenu au lieu de défiler en interne.

Fix : ScrollView proprement borné (style={flex:1}) ; TextInput à hauteur
FIXE (96) + scrollEnabled, qui garantit le défilement interne au-delà
plutôt qu'une dépendance à un maxHeight pas toujours respecté.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 13:23:54 +01:00
pr-daaif
63656565f7 fix(mobile): Assistant — question dictée pleinement visible avant envoi
Retour de recette (02/08) : la barre de saisie sur une ligne masquait le
texte transcrit dès qu'il dépassait quelques mots, empêchant la relecture
avant envoi (D5 de la maquette Assistant). Remplacée par une zone
multiligne pleine largeur (jusqu'à 160px, défilement interne au-delà) —
même patron que la description de clôture (cloture.tsx) — avec "Envoyer"
comme geste séparé et délibéré en dessous, pas accolé au champ.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 13:21:38 +01:00
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
pr-daaif
be5dfb6bc7 fix(r6): recette Gestionnaire — Menu non scrollable, BC sans confirmation
Deux bugs réels trouvés sur iPhone physique par le référent :

1. app/(tabs)/menu.tsx n'était pas scrollable (View, pas ScrollView) —
   avec les 4 groupes désormais pleinement câblés (12 liens + 4 en-têtes),
   le contenu dépasse la hauteur de l'écran et le dernier groupe
   (Pilotage, dont Personnes & équipes) était strictement inaccessible.
   Présent depuis R6.1, seulement révélé maintenant. Fix : ScrollView
   autour de la liste des groupes, en-tête fixe au-dessus.

2. app/stock/[id]/commander.tsx naviguait silencieusement en arrière
   après succès — le référent a créé deux BC (vérifiés corrects côté
   serveur) sans aucun retour dans l'app. Fix : écran de confirmation
   explicite (référence, fournisseur, total, statut) avant de revenir.

Amélioration sur retour direct : Tiers gagne une fiche détail
(app/tiers/[id].tsx — identité, contact, BC en cours, sites rattachés,
aucune nouvelle requête) — le lecture seule sans réaction au tap se
lisait comme un écran cassé plutôt que délibérément simple.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 12:59:40 +01:00
pr-daaif
ac8b92e9c5 feat(r6): R6.5 — Assistant mobile (chat sourcé + dictée)
Maquette dédiée validée (docs/02-design/maquettes/maquette-mobile-assistant.html,
3 écrans) — la seule sous-étape de R6 à avoir eu son propre tour de
design-first plutôt qu'une exécution directe de la maquette R6 initiale,
un chat sourcé étant un patron d'écran neuf. D1 un échange à la fois ; D2
"Voir le document" → métadonnées seules (R6.3), "Ouvrir l'OT" → fiche R4 ;
D3 refus chiffré + avertissement permanents, identiques au web ; D4
Assistant rejoint Pilotage pour tout rôle avec WORK_ORDERS.view ; D5
(ajoutée en revue) — la question peut être tapée OU dictée, même pipeline
que la dictée déjà livrée en clôture (ADR-004 §5, faster-whisper local,
audio jamais persisté), la transcription remplit le champ, jamais d'envoi
automatique.

api/assistant.ts (useAskAssistant, 503 géré comme le web). Écran Assistant :
chat un échange à la fois, citations numérotées, sources avec extrait
exact, refus honnête chiffré + Reformuler. Micro dans la barre de saisie :
réutilise telle quelle la mécanique de cloture.tsx (useAudioRecorder,
setAudioModeAsync, useTranscription, purge quoi qu'il arrive) — pipeline
déjà recetté sur iPhone physique (01/08), aucun nouveau bug à découvrir.

Fiche document (bibliotheque/[id].tsx, nouveau) — la liste R6.3 y mène
aussi désormais. Menu : Assistant route réellement — le Menu R6 n'a plus
d'entrée "à venir" dans les 4 groupes (Catégories exceptée, admin, hors
périmètre mobile).

R6 fonctionnellement complet (Exploitation/Parc/Ressources/Pilotage,
Assistant inclus, pour tous les rôles) — reste une recette complète avant
de considérer R6 close au sens R0→R5.

Typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 12:18:25 +01:00
pr-daaif
700bd4e7ff docs(r6): confirmation référent — nav adaptative + déconnexion sur iPhone
Dernier point ouvert depuis le début de R6 (R6.1), clos. Reste avant de
considérer R6 close au même sens que R0→R5 : une recette plus complète
des sous-étapes R6.2/R6.3/R6.4, pour l'instant seulement vérifiées par
typecheck/tests/lint, jamais en usage réel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:56:44 +01:00
pr-daaif
4c1a2d67a0 feat(r6): R6.4 — Pilotage sur mobile (Statistiques, Personnes)
EXPO_PUBLIC_WEB_URL/WEB_URL pour le lien d'activation (pointe la page web
/activation?token=…, aucun équivalent mobile). api/pilotage.ts
(useAnalyticsSummary, useUsers, useRoles, useInviteUser).

Écran Statistiques (maquette écran 7) : période 3/6/12 mois, coût du mois,
taux préventif, OT clôturés, pannes par organe, top équipements — cartes
plutôt que graphes denses (D4).

Écran Personnes & équipes : liste + statut, Inviter (nom/e-mail/rôle),
lien d'activation en texte sélectionnable (Text selectable, RN natif) —
expo-clipboard est une dépendance native absente, différée comme
expo-sharing en R6.3 plutôt qu'ajoutée à la légère. Taux horaire/rôles/
équipes restent gérés au web (D4).

Assistant reste "à venir" dans le Menu : un chat sourcé est un nouveau
patron d'écran jamais maquetté sur mobile, contrairement aux autres
familles qui réutilisaient des patrons déjà validés — mérite son propre
tour de design-first plutôt qu'être exécuté par réflexe en fin de release.

R6 quasi close (Exploitation/Parc/Ressources/Pilotage livrés pour tous les
rôles, hors Assistant) — reste la confirmation du référent sur iPhone
(R6.1 + correctif déconnexion) avant clôture réelle.

Typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:43:57 +01:00
pr-daaif
f210d4f801 feat(r6): R6.3 — Ressources sur mobile (Stock, Tiers, Fichiers)
api/ressources.ts (usePartners, useParts/usePart, useCreatePurchaseOrder,
useDocuments). Écran Stock (sous-seuil en tête) + fiche pièce + "Commander"
pré-rempli en une ligne (fournisseur figé, quantité = manquant jusqu'au
seuil, prix = dernier connu — BC multi-lignes détaillé réservé au web, D4).
Tiers en lecture seule (création/édition réservées au web). Fichiers en
métadonnées seules — l'ouverture demande expo-sharing (dépendance native
absente, donc un nouveau build natif) : différée explicitement plutôt
qu'ajoutée à la légère au milieu de cette passe.

Menu : Stock & achats / Tiers / Fichiers routent réellement.

Typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché,
toutes les opérations utilisées existaient déjà depuis R3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:35:55 +01:00
pr-daaif
21dd6cf034 feat(r6): R6.2 — Parc sur mobile (Sites, Ascenseurs)
useLocations() mobile ; écran Sites (premier niveau) ; fiche site
(identité, zones, ascenseurs du site — consultation seule, D4, pas de
carte ni d'édition sur mobile pour l'instant) ; écran Ascenseurs (parc
complet déjà préchargé, D1) ouvrant la fiche appareil R4 telle quelle
(déjà générique, aucune modification nécessaire). Menu : Ascenseurs/Sites
routent réellement au lieu de « à venir » ; Catégories reste à venir
(admin, hors périmètre R6.2).

Exécution directe de la maquette R6 déjà validée, patrons visuels
réutilisés (Carte/LigneInfo/EnteteFiche) — pas de nouveau tour de design.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:27:50 +01:00
pr-daaif
ab0f260d0f feat(r6): mobile ouvert à tous les rôles — maquette + socle R6.1
R4 avait délibérément fermé le mobile au Technicien. Le référent demande
l'ouverture à tous les rôles avec les mêmes droits qu'au web — nouvelle
release R6 (R4 est close, R4.1/4.2/4.3 déjà pris par le socle mobile
technicien d'origine, pas de réouverture ni de collision de numérotation).

Maquette (docs/02-design/maquettes/maquette-mobile-tous-roles.html, 7
écrans) validée par le référent : D1 barre d'onglets adaptative par rôle
(Technicien/Technicien limité inchangés + onglet Menu ajouté) ; D2 le Menu
reprend à l'identique les 4 groupes du web, même matrice de permissions ;
D3 groupe sans lien visible masqué en entier ; D4 le mobile porte les
actions courantes par famille, pas les flux de gestion les plus denses ;
D5 aucune logique de permission propre au mobile.

R6.1 — socle : usePermissions() mobile calqué sur le web ; barre d'onglets
adaptative (ongletsVisibles par rôle, href:null masque sans retirer du
navigateur) ; Accueil (dashboard réel pour Administrateur/Gestionnaire/
Dispatcher/Vue seule, aucun chiffre inventé) ; OT (liste complète,
viewOther déjà géré serveur, réutilise la fiche OT R4 telle quelle) ; Menu
(groupes filtrés, vide masqué) ; Demandes (PanneauDemandes — un seul
composant pour tous les rôles : création/suivi Demandeur, approbation/
rejet Gestionnaire/Dispatcher/Administrateur, lecture Vue seule).
Familles pas encore portées : écran « à venir » plutôt qu'un lien mort.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:19:44 +01:00
pr-daaif
3e9e708116 fix(mobile): déconnexion accessible depuis tous les onglets
Bug remonté par le référent : une fois authentifié, impossible de revenir en
arrière ou de se déconnecter. Seule « Ma journée » portait ce contrôle
(onLongPress non découvrable) ; les 3 autres onglets n'avaient rien.

EnteteTabs() factorise l'entête (composants/ui.tsx) : tap simple + confirmation
Alert, rôle affiché = celui du compte connecté (plus de libellé "Technicien"
figé — le mobile n'est plus réservé aux techniciens). Réutilisée dans les 4
onglets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:09:17 +01:00
pr-daaif
4af3f5668a fix(mobile): dictée validée sur iPhone physique — 2 bugs réels corrigés
setAudioModeAsync({allowsRecording:true}) manquant avant recorder.record()
(RecordingDisabledException iOS) ; EXPO_USE_PRECOMPILED_MODULES/
RCT_USE_PREBUILT_RNCORE jamais persistés dans .env (documentés dans ADR-005
mais seulement exportés en shell ad-hoc) — corrigé pour que tout rebuild
depuis zéro ne reproduise plus le crash dyld. Recette terrain complète
vérifiée : dictée → note → clôture → réindexation → recherche sémantique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:09:04 +01:00
pr-daaif
59ed6f3952 feat(r5): dictée — audio local (faster-whisper) → note → corpus (ADR-004 §5)
Écran Voix R5 (maquetté, jamais construit) implémenté sur décision du
référent : open-source et local, pas d'API externe.

- apps/ai : faster-whisper (CTranslate2, CPU, MIT) opt-in
  (AI_TRANSCRIPTION=off|locale|deterministe, défaut off) ; endpoint
  /internal/transcrire — l'audio ne survit JAMAIS à l'appel (fichier
  temporaire supprimé quoi qu'il arrive) ; indexer_bilans inclut
  désormais InterventionReport.note anonymisée (champ existant depuis
  R2, jamais eu d'écran jusqu'ici) ; 29 pytest.
- Contrat (77 opérations) : POST /assistant/transcribe (multipart).
- API : proxy multipart vers siop2-ai (WORK_ORDERS.edit — même droit
  que la saisie du bilan) ; 2 tests e2e (80 tests API au total).
- Mobile : expo-audio + expo-file-system, bouton dicter/terminer sur
  l'écran de clôture, purge locale après transcription, « Joindre la
  description à l'OT » (corrige un bug latent : enfilerBilan ignorait
  silencieusement les mises à jour de note).
- Docker : siop2-ai embarque le modèle Whisper au build (1,54→2,19 Go),
  construit et vérifié (transcription réelle en conteneur, non-root).
- Vérifié réellement : transcription fidèle (voix de synthèse
  française) en direct, bout en bout via l'API, dans le conteneur
  Docker construit, et chaîne corpus complète (note → clôture →
  réindexation → recherche sémantique).
- Base de dev locale réinitialisée avec accord explicite du référent
  (prisma migrate reset, bloqué par défaut pour un agent IA) après
  pollution par les tests manuels de la recette terrain précédente.

Reste : test tactile sur iPhone physique (bouton dicter) — bloqué par
une connexion USB qui ne s'est pas rétablie malgré câble/port/
redémarrage essayés à plusieurs reprises, reporté comme la recette
Android.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:55:47 +01:00
pr-daaif
0730bf9dad docs(r5): amender l'écran Voix — la transcription alimente aussi le corpus
L'écran 6 (dictée, jamais construite — option « si budget temps ») dit
maintenant explicitement que la description relue et jointe à l'OT
rejoint le corpus de l'assistant à la clôture, anonymisée comme les
bilans codés. Idée discutée avec le référent (transcription locale via
faster-whisper envisagée, moteur/calibrage tranchés dans le détail),
maquette amendée et validée — implémentation non demandée pour
l'instant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:44:03 +01:00
pr-daaif
189e8e8162 feat(mobile): recette terrain iPhone 15 Pro — 7/7 validés (ADR-005)
Expo Go (App Store) bloqué par le retard d'approbation Apple (SDK 54
contre notre SDK 57) — décision : builds natifs locaux (Xcode/Android
Studio, signature Apple ID personnel gratuite) pour la vraie recette
terrain, Expo Go conservé pour l'aperçu sans installation ailleurs.

- ADR-005 : deux canaux de distribution, managed workflow conservé.
- apps/mobile/.env : EXPO_PUBLIC_USE_RN_FETCH=1 — fix permanent d'un
  bug SDK 57 (fetch global expo/fetch incompatible avec l'upload
  multipart natif {uri,name,type}, classé à tort « réseau
  indisponible » par notre propre gestion d'erreur).
- package.json/app.json : scripts ios/android pointés sur les builds
  natifs (expo run:*) plutôt que expo start (Expo Go), identifiants
  de bundle générés par le prebuild.
- Recette iOS (iPhone 15 Pro physique) : connexion démo, scan caméra
  réel (résolution locale + rejet QR étranger), vrai mode avion (D1),
  conflit de version tranché par l'humain avec photo réellement
  téléversée (D2/D5), persistance de la file à travers fermeture et
  reconstruction de l'app, suggestions R5 au vrai modèle, purge de
  sécurité au changement de compte (ADR-003) — 7/7 validés, la
  validation terrain due depuis release/r4 est levée pour iOS.
- Android : build natif sur émulateur, passage santé complet
  (connexion, navigation, écran Scanner, résolution de référence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:12:48 +01:00
pr-daaif
557435b81a docs(r5): clôture — journal et état d'avancement pour le tag release/r5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:57:56 +01:00
84 changed files with 5922 additions and 116 deletions

3
.gitignore vendored
View File

@@ -2,6 +2,9 @@
node_modules/
dist/
.env
# apps/mobile/.env ne porte aucun secret — un réglage de build (fetch RN
# classique, cf. ADR-005) qui doit être le même pour tout le monde.
!apps/mobile/.env
.turbo/
coverage/
*.tsbuildinfo

View File

@@ -10,7 +10,7 @@ Projet **réel** (SPELEV, maintenance d'ascenseurs, Maroc) mené par le Pr. Daai
1. **Design-first** : aucune ligne de code applicatif avant validation par le référent de la charte graphique, des tokens et des maquettes HD (`docs/02-design/`). Chaque release subit une revue « pixel » écrans ↔ maquettes.
2. **Playbook vivant** : chaque phase du cycle de vie a son dossier `docs/0X-*/` (template réutilisable + artefacts réels). Le journal quotidien vit dans `docs/journal/`**jamais** dans le livre.
3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn.
3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA → R6 Mobile tous rôles. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn.
4. **Déployer tôt** : chaque release part sur le serveur de production (Dokploy) dès sa recette.
5. **Le développeur est le premier utilisateur** : `DEMO_MODE=true` active un **sélecteur de compte démo** (connexion 1 clic sur les comptes seedés, endpoint `POST /auth/demo-login` strictement absent si l'env ne l'active pas). Critère : changer de rôle en < 3 s sans mot de passe.
@@ -65,5 +65,24 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **R5.3 — écrans IA** : page web Assistant (chat sourcé, refus honnête chiffré, « Ouvrir » vers PDF/OT), Bibliothèque = corpus administrable (statut d'indexation par document, interrupteur d'exclusion, « Réindexer tout », bandeau 09-08), suggestions fiche OT Appliquer » = geste humain, liseré « suggéré » retiré au choix manuel) et clôture mobile (chips, « réseau requis » hors-ligne) ; contrat 76 opérations (corpus sur Document, `PATCH /documents/{id}/corpus`, `POST /assistant/reindex` ci-contract vérifie aussi le client mobile) ; seuils `AI_SEUIL_*` par env ; job e2e CI avec `siop2-ai` (embeddeur déterministe, seuils calibrés sur mesures réelles), 16/16 Playwright, 78 tests API ; chaîne vérifiée au vrai modèle ONNX (web 7/7, mobile Expo web 6/6, 0 erreur console).
- **Recette R5 sans clé API + durcissement** (17/07) : la recette a invalidé MiniLM-384 (page-réponse classée derrière des passages sans rapport, 0,24 vs 0,41) **bascule mesurée vers `paraphrase-multilingual-mpnet-base-v2` 768 d** (ADR-004 amendé, migration `r5_embeddings_mpnet`, découpage ~350 car., seuils 0,45/0,40/0,55) ; recette type (réponse sourcée p. 2, refus honnête, D1-D5, tout en extractif) ; revue pixel publiée (artefact, 3 arbitrages) ; Dockerfile `siop2-ai` (modèle au build, non-root), compose Dokploy (service interne, `AI_SERVICE_TOKEN` requis, génération opt-in), runbook §5-6 (service IA, calibrage seuils client, réindexation post-déploiement).
- **Revue pixel R5 validée par le référent (17/07)** 1 correction appliquée sur arbitrage : la Bibliothèque-corpus passe en **tableau** (Document/Rattaché à/Indexation/Corpus + actions ; interrupteur éteint pour les non-indexables), vignettes conservées sur les cartes Documents des fiches ; « Appliquer » direct et calibrage au runbook validés tels quels. 16/16 Playwright rejoués.
- 🔄 **Reprise ici** : tag `release/r5` (sur le mot du référent). Restes : recette R4 sur téléphone (Expo Go), redéploiement Dokploy (`release/r3` puis r5 avec `AI_SERVICE_TOKEN`), secret `DOKPLOY_WEBHOOK_URL`, calibrage `AI_SEUIL_*` sur corpus SPELEV réel.
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5.
- 🏁 **R5 CLOSE (17/07/2026, tag `release/r5`)** : recettée par le référent (revue pixel + arbitrage tableau), recette passée SANS clé API, durcissement répété en local conteneurisé. **R0 → R5 : périmètre v1 couvert.**
- **Recette terrain mobile sur iPhone 15 Pro — 7/7 validés (19/07, ADR-005)** : Expo Go bloqué par le retard d'approbation Apple (SDK 54 vs notre SDK 57) **décision : builds natifs locaux (Xcode/Android Studio, signature gratuite) pour la vraie recette, Expo Go conservé pour l'aperçu sans installation**. 5 obstacles techniques réels corrigés et documentés (UDID plutôt que nom, ne jamais contourner `expo run:*` par un xcodebuild manuel, modules Expo/RN précompilés SDK 56/57 incompatibles avec la liaison statique du projet, `fetch` global incompatible avec l'upload multipart natif fix permanent `EXPO_PUBLIC_USE_RN_FETCH=1` dans `apps/mobile/.env`, découverte réseau du dev client peu fiable). Recette iOS : connexion, scan caméra réel (résolution + rejet QR étranger), vrai mode avion, conflit D2 tranché par l'humain avec photo réellement téléversée, persistance à travers fermeture/reconstruction de l'app, suggestions R5 au vrai modèle, purge de sécurité au changement de compte tout validé. Android : build natif sur émulateur, passage santé complet.
- **Dictée implémentée (22/07, ADR-004 §5)** : écran Voix R5 (jamais construit) `faster-whisper` local (CTranslate2, CPU, opt-in `AI_TRANSCRIPTION=off|locale`, défaut off), audio jamais persisté (purge serveur ET mobile quoi qu'il arrive), transcription relecture humaine suggestion immédiate et/ou sauvegarde dans `InterventionReport.note` (champ existant depuis R2, jamais eu d'écran jusqu'ici) `note` rejoint le corpus à la clôture comme les bilans codés. Mobile : `expo-audio`/`expo-file-system`, bouton dicter sur l'écran de clôture. Docker `siop2-ai` embarque le modèle (1,542,19 Go). Vérifié réellement (voix de synthèse texte fidèle, bout en bout API, conteneur Docker construit, chaîne corpus complète noteclôtureréindexationrecherche). 80 tests API, 29 pytest ai, mobile vert.
- **Dictée validée sur iPhone physique (01/08, ADR-004 §5)** : le blocage USB du 22/07 était un faux négatif de `system_profiler` (outil de diagnostic en panne, pas le câble). Deux bugs réels trouvés et corrigés : `expo-audio` iOS exige `setAudioModeAsync({ allowsRecording: true })` avant `record()` (absent, jamais détecté hors appareil réel) ; le correctif des modules précompilés (ADR-005) n'avait jamais été rendu permanent dans `apps/mobile/.env` corrigé. Chaîne complète vérifiée en vrai : micro dictée transcription fidèle note clôture réindexation **retrouvé par la recherche sémantique**.
- fix(mobile) **déconnexion accessible depuis tous les onglets (02/08)** : bug remonté par le référent (une fois authentifié, aucun moyen de revenir en arrière ou de se déconnecter seule « Ma journée » portait ce contrôle, via un `onLongPress` non découvrable). `EnteteTabs` (`composants/ui.tsx`) factorise une entête commune aux 4 onglets terrain, tap simple + confirmation, rôle affiché dynamique.
- **R6 — maquette validée (02/08)** : `maquette-mobile-tous-roles.html`, 7 écrans (synthèse par rôle, Accueil adaptatif, Menu groupé, OT côté Dispatcher, Demandes, Stock, Personnes & statistiques) + 5 décisions actées D1 barre d'onglets adaptative (Technicien/Technicien limité inchangés + onglet Menu) ; D2 le Menu reprend à l'identique les 4 groupes du web (même matrice, aucune règle nouvelle) ; D3 groupe sans lien visible masqué en entier ; D4 le mobile porte les actions courantes par famille, pas les flux de gestion les plus denses (réservés au web) ; D5 aucune logique de permission propre au mobile. Nouvelle release (R4 est close et R4.1/4.2/4.3 déjà pris par le socle mobile technicien d'origine pas de réouverture, pas de collision de numérotation).
- **R6.1 — socle** : `usePermissions()` mobile (calqué sur le web, lit `me.permissions`) ; barre d'onglets adaptative par rôle (`ongletsVisibles`, `href: null` masque sans retirer du navigateur) ; écrans Accueil (dashboard réel pour Administrateur/Gestionnaire/Dispatcher/Vue seule, aucun chiffre inventé), OT (liste complète `viewOther`, réutilise la fiche OT R4 telle quelle), Menu (groupes filtrés par la matrice, groupe vide masqué), Demandes (`PanneauDemandes` un seul composant pour tous les rôles : création/suivi pour le Demandeur, approbation/rejet à motif pour Gestionnaire/Dispatcher/Administrateur, lecture seule pour Vue seule ; `GET /assets/options`, déjà ouvert à tout rôle authentifié depuis R2.3, réutilisé pour le sélecteur d'équipement). Familles pas encore portées (Sites, Ascenseurs, Stock, Tiers, Fichiers, Statistiques, Assistant, Personnes) : écran « à venir » honnête plutôt qu'un lien mort. Typecheck propre, 17 tests Jest verts, lint 5/5 paquets pas de vérification visuelle en navigateur de mon côté (aucun outil de ce type dans cet environnement), à confirmer par le référent sur l'iPhone déjà connecté au serveur Metro.
- **R6.2 — Parc (Sites, Ascenseurs)** : `useLocations()` mobile ; écran Sites (premier niveau, `parentId===null`) ; fiche site (identité, zones, ascenseurs du site consultation seule, D4, pas de carte ni d'édition sur mobile) ; écran Ascenseurs (parc complet déjà préchargé, D1) ouvrant la fiche appareil R4 telle quelle (générique, aucune modification nécessaire). Menu : Ascenseurs/Sites routent réellement, Catégories reste à venir (admin, hors périmètre). Typecheck propre, 17 tests Jest, lint 5/5 exécution directe de la maquette déjà validée, pas de nouveau tour de design.
- **R6.3 — Ressources (Stock, Tiers, Fichiers)** : `api/ressources.ts` (usePartners/useParts/usePart/useCreatePurchaseOrder/useDocuments) ; écran Stock (sous-seuil en tête) + fiche pièce + « Commander » pré-rempli en une ligne (fournisseur figé, quantité = manquant jusqu'au seuil, prix = dernier connu le BC multi-lignes détaillé reste au web, D4) ; Tiers en lecture seule (création/édition réservées au web) ; Fichiers en métadonnées seules l'ouverture/téléchargement demande `expo-sharing` (dépendance native absente, donc un nouveau build) et est explicitement différée plutôt qu'ajoutée à la légère. Menu branché. Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché (toutes les opérations existaient déjà depuis R3).
- **R6.4 — Pilotage (Statistiques, Personnes)** : `EXPO_PUBLIC_WEB_URL`/`WEB_URL` (lien d'activation vers la page web, pas d'équivalent mobile) ; `api/pilotage.ts` ; écran Statistiques (période 3/6/12, coût du mois, taux préventif, pannes par organe, top équipements cartes plutôt que graphes, D4) ; écran Personnes & équipes (liste + statut, Inviter, lien d'activation en texte sélectionnable `expo-clipboard` différée, même raisonnement que `expo-sharing` en R6.3 ; taux horaire/rôles/équipes restent au web). **Assistant reste à venir** dans le Menu un chat sourcé est un nouveau patron d'écran jamais maquetté sur mobile, contrairement aux autres familles qui réutilisaient Carte/LigneInfo/EnteteFiche déjà validés ; mérite son propre tour de design-first. Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché.
- **Nav adaptative + correctif déconnexion confirmés par le référent sur iPhone physique** dernier point ouvert depuis le début de R6, clos.
- **R6.5 — Assistant mobile (chat sourcé + dictée)** : maquette dédiée validée (3 écrans, D1-D5 D5 ajoutée en revue : question tapée OU dictée, même pipeline que la dictée déjà livrée en clôture, transcription remplit le champ, jamais d'envoi automatique). `api/assistant.ts` (503 géré comme le web). Écran Assistant : chat un échange à la fois, citations numérotées, sources avec extrait exact Voir le document » métadonnées seules, R6.3 ; « Ouvrir l'OT » fiche R4), refus honnête chiffré + Reformuler, avertissement permanent. Fiche document (`bibliotheque/[id].tsx`, nouveau) la liste R6.3 y mène aussi désormais. **Le Menu R6 n'a plus d'entrée « à venir »** dans les 4 groupes (Catégories exceptée, admin, hors périmètre mobile). Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché.
- 🔄 **Recette R6 en cours (Gestionnaire, partie 1)** 2 bugs réels trouvés et corrigés sur iPhone physique : Menu non scrollable (le dernier groupe, Pilotage, était strictement inaccessible une fois les 4 groupes pleinement câblés présent depuis R6.1, révélé seulement maintenant) ; BC créé sans confirmation visible dans l'app (vérifié côté serveur : les BC étaient bien créés, juste aucun retour affiché). Amélioration sur retour direct : Tiers gagne une fiche détail (identité/contact/BC en cours/sites rattachés) le lecture-seule sans aucune réaction au tap se lisait comme cassé. Trois signalements vérifiés et écartés (faux positifs) : approbation de demande (a fonctionné), bibliothèque vide (confirmé côté serveur aucun document sur cette instance, pas un bug mobile), période statistiques (transmise et traitée correctement, les indicateurs affichés ne varient juste pas avec ce jeu de données).
- **Assistant mobile — dictée confirmée sur iPhone physique**, après 3 corrections trouvées en conditions réelles (aucune n'aurait été vue par typecheck/tests/lint) : `expo-file-system` deleteAsync déprécié SDK 57 (import `/legacy`, appliqué aussi à `cloture.tsx`) ; texte transcrit pas entièrement visible (zone multiligne pleine largeur, Envoyer en geste séparé) ; bouton Envoyer chevauchant encore le texte (ScrollView du chat sans `style={flex:1}`, TextInput à hauteur fixe plutôt que `maxHeight` seul, pas fiable sur iOS).
- **R6.6 — Demandeur restreint à son site** : trou trouvé en recette `GET /assets/options` n'avait aucun filtre (web ET mobile touchés, pas seulement mobile). Corrigé à la racine : relation `User↔Location` (`assignedSites`, migration `r6_demandeur_sites`, vide = aucune restriction pour les autres rôles) ; `AssetsService.allowedLocationIds()` réutilisée par `options()` et par `RequestsService.create` (défense en profondeur, 400 si hors périmètre) ; gestion des sites d'un Demandeur côté web (`personnes.tsx`, invitation + modale dédiée) ; raccourci scan QR côté mobile (`formulaire-demande.tsx`, résout uniquement contre les options déjà filtrées, jamais de repli sur le parc complet). Karim Doukkali (démo) rattaché à Tour Atlas. Bug trouvé en vérification avant tout commit (comparaison id-de-site vs id-d'appareil dans `create()`, aurait rejeté à tort tout signalement d'un Demandeur affecté) et corrigé, méthode renommée `allowedAssetIds` `allowedLocationIds` pour que le nom dise ce qu'elle retourne. 79/80 tests API (le seul échec est le flake `monthCost` déjà connu, sans rapport) ; typecheck/tests/lint verts sur les 4 paquets.
- **R6.7 — assignation à l'approbation (mobile)** : le Gestionnaire approuvait une demande mobile sans pouvoir assigner de technicien (l'OT partait non assigné) « ce n'est pas à lui d'agir comme un technicien ». `PanneauDemandes` gagne un panneau d'approbation avec `ChoixTel` « Assigner à » (techniciens actifs, même filtre que le web), avant confirmation. Rien à changer côté API (`assigneeIds` déjà supporté). Vérifié de bout en bout (demande approuvée avec assigné OT avec `assignees` correct). Typecheck/tests/lint verts.
- **Périmètre `WORK_ORDERS` du Gestionnaire confirmé acceptable tel quel par le référent** l'accès complet hérité de R2/R3 (peut techniquement démarrer/clôturer n'importe quel OT) reste en l'état, aucun resserrement demandé.
- **R6.8 — la connexion routait toujours vers « Ma journée »** : `connexion.tsx` faisait `router.replace('/(tabs)/journee')` en dur la redirection par rôle (`ongletAccueil`) n'était branchée qu'à l'aiguillage initial (R6.1), pas au retour de connexion, le chemin réellement emprunté à chaque bascule de compte démo (pas de sélecteur de rôle en direct sur mobile, changer de compte = se déconnecter puis se reconnecter). Karim (Demandeur) atterrissait sur l'écran du Technicien. `useLogin()`/`useDemoLogin()` renvoient maintenant la réponse complète (rôle inclus) ; `entrer(role)` route vers le bon onglet. Sous-titre « Technicien »/accroche terrain de l'écran de connexion (reste de R4) généricisés au passage. Typecheck/tests/lint verts.
- **Recette R6 confirmée sur iPhone physique** Gestionnaire (Accueil, OT, Menu complet, Assistant+dictée) et Demandeur (Accueil, Nouvelle demande filtrée à son site, scan QR) tous deux vérifiés en conditions réelles, au-delà du typecheck/tests/lint.
- 🔄 **Reprise ici** : à trancher avec le référent considérer R6 close (tag `release/r6`, comme R0R5) ou poursuivre sur un reste identifié (Vue seule/Dispatcher/Administrateur non explicitement recettés sur iPhone, `expo-sharing`/`expo-clipboard` en réserve, recette Android sur appareil physique). Restes non bloquants inchangés : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer runbook §2 puis « Réindexer tout »), calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire).
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5 (v1) + R6 en cours.

View File

@@ -8,29 +8,35 @@
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder
WORKDIR /app
ENV UV_LINK_MODE=copy \
FASTEMBED_CACHE_PATH=/opt/fastembed
FASTEMBED_CACHE_PATH=/opt/fastembed \
HF_HOME=/opt/whisper
# Manifestes d'abord (cache de couche), puis le code. L'installation du projet
# reste éditable (.pth → /app/src) : src est donc copié dans l'image finale.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev \
--extra embeddings --extra generation
--extra embeddings --extra generation --extra transcription
COPY src src
RUN uv sync --frozen --no-dev \
--extra embeddings --extra generation
--extra embeddings --extra generation --extra transcription
# Le modèle d'embeddings est EMBARQUÉ dans l'image : pas de téléchargement au
# boot (démarrage prévisible, marche sans accès à Hugging Face en production).
# Les modèles locaux (embeddings + dictée) sont EMBARQUÉS dans l'image : pas
# de téléchargement au boot (démarrage prévisible, marche sans accès à
# Hugging Face en production). La dictée reste opt-in (AI_TRANSCRIPTION=off
# par défaut) — le modèle est prêt si elle est activée un jour, sans rebuild.
RUN uv run python -c "from siop_ai.embeddings import EmbeddeurLocal; EmbeddeurLocal()"
RUN uv run python -c "from siop_ai.transcription import TranscripteurLocal; TranscripteurLocal('small')"
FROM python:3.11-slim-bookworm
WORKDIR /app
ENV PATH=/app/.venv/bin:$PATH \
FASTEMBED_CACHE_PATH=/opt/fastembed
FASTEMBED_CACHE_PATH=/opt/fastembed \
HF_HOME=/opt/whisper
RUN useradd --system --create-home siop
COPY --from=builder --chown=siop:siop /app/.venv /app/.venv
COPY --from=builder --chown=siop:siop /app/src /app/src
COPY --from=builder --chown=siop:siop /opt/fastembed /opt/fastembed
COPY --from=builder --chown=siop:siop /opt/whisper /opt/whisper
USER siop
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \

View File

@@ -10,6 +10,7 @@ dependencies = [
"asyncpg>=0.30",
"pypdf>=5.1",
"minio>=7.2",
"python-multipart>=0.0.20",
]
[project.optional-dependencies]
@@ -17,6 +18,9 @@ dependencies = [
embeddings = ["fastembed>=0.4"]
# Génération opt-in (ADR-004 §3) — absente des tests/CI (repli extractif).
generation = ["anthropic>=0.75"]
# Transcription opt-in (dictée R5, ADR-004 §5) — absente des tests/CI
# (transcripteur déterministe). CTranslate2/CPU, licence MIT, local.
transcription = ["faster-whisper>=1.1"]
[dependency-groups]
dev = [

View File

@@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
from dataclasses import asdict
import asyncpg
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi import Depends, FastAPI, Header, HTTPException, UploadFile
from pydantic import BaseModel, Field
from .assistant import repondre, suggerer_bilan
@@ -16,6 +16,7 @@ from .embeddings import construire_embeddeur
from .generation import construire_generateur
from .ingestion import reindexer_tout
from .recherche import chercher
from .transcription import construire_transcripteur
@asynccontextmanager
@@ -26,6 +27,9 @@ async def cycle_de_vie(app: FastAPI):
app.state.generateur = construire_generateur(
reglages.ai_generation, reglages.ai_api_key, reglages.ai_model
)
app.state.transcripteur = construire_transcripteur(
reglages.ai_transcription, reglages.ai_transcription_model
)
app.state.pool = await asyncpg.create_pool(reglages.database_url, min_size=1, max_size=5)
yield
await app.state.pool.close()
@@ -50,6 +54,7 @@ async def sante() -> dict:
"status": "ok",
"service": "siop2-ai",
"generation": reglages.ai_generation, # « off » = extractif — jamais la clé
"transcription": reglages.ai_transcription,
}
@@ -113,3 +118,16 @@ async def suggerer(corps: RequeteSuggestion) -> dict:
seuil_confiance_forte=app.state.reglages.ai_seuil_confiance_forte,
)
return {"suggestions": [asdict(s) for s in suggestions]}
@app.post("/internal/transcrire", dependencies=[Depends(verifier_jeton)])
async def transcrire(fichier: UploadFile) -> dict:
"""Dictée opt-in (R5 D5, loi 09-08) : l'audio ne persiste JAMAIS — un
fichier temporaire le temps de l'inférence, supprimé aussitôt (voir
transcription.py). 503 propre si AI_TRANSCRIPTION=off (défaut)."""
if app.state.transcripteur is None:
raise HTTPException(status_code=503, detail="Transcription non activée (AI_TRANSCRIPTION=off)")
audio = await fichier.read()
extension = (fichier.filename or "audio.m4a").rsplit(".", 1)[-1]
texte = app.state.transcripteur.transcrire(audio, extension)
return {"texte": texte}

View File

@@ -29,6 +29,10 @@ class Reglages(BaseSettings):
ai_seuil_pertinence: float = 0.45
ai_seuil_suggestion: float = 0.40
ai_seuil_confiance_forte: float = 0.55
# Dictée opt-in (R5 D5, ADR-004 §5) : « off » (défaut — endpoint refuse
# proprement), « locale » (faster-whisper, CPU) ou « deterministe » (CI).
ai_transcription: str = "off"
ai_transcription_model: str = "small" # tiny|base|small|medium|large-v3
model_config = {"env_prefix": "", "case_sensitive": False}
@@ -43,4 +47,6 @@ def charger_reglages() -> Reglages:
raise ValueError("AI_GENERATION doit valoir « off » ou « api »")
if reglages.ai_generation == "api" and not reglages.ai_api_key:
raise ValueError("AI_GENERATION=api exige AI_API_KEY (voir ADR-004 §3)")
if reglages.ai_transcription not in ("off", "locale", "deterministe"):
raise ValueError("AI_TRANSCRIPTION doit valoir « off », « locale » ou « deterministe »")
return reglages

View File

@@ -119,7 +119,7 @@ async def indexer_bilans(
await cnx.execute('DELETE FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\'')
bilans = await cnx.fetch(
'''
SELECT wo.id, wo.reference, wo.title, wo."completedAt",
SELECT wo.id, wo.reference, wo.title, wo."completedAt", ir.note,
a.reference AS asset_ref, a.brand, a.model,
(SELECT json_object_agg(rv.field, rv.label)
FROM "InterventionReport" ir2
@@ -137,10 +137,13 @@ async def indexer_bilans(
for bilan in bilans:
champs = json.loads(bilan["bilan"]) if bilan["bilan"] else {}
codes = " ; ".join(f"{champ} : {label}" for champ, label in champs.items())
# note (D5) : dictée ou saisie libre relue par l'humain — même pipeline
# d'anonymisation que le reste, aucun traitement à part.
note = f" Description libre : {bilan['note']}." if bilan["note"] else ""
contenu = anonymiser(
f"Intervention {bilan['reference']}{bilan['title']}. "
f"Appareil {bilan['asset_ref']} ({bilan['brand']} {bilan['model'] or ''}). "
f"Bilan codé : {codes}.",
f"Bilan codé : {codes}.{note}",
noms,
)
quand = bilan["completedAt"].date().isoformat() if bilan["completedAt"] else "date inconnue"

View File

@@ -0,0 +1,54 @@
"""Transcription audio (dictée R5, ADR-004 §5) : l'audio ne survit JAMAIS à
cet appel — un fichier temporaire le temps de l'inférence, supprimé aussitôt,
quoi qu'il arrive (D5, loi 09-08). Seul le texte transcrit est retourné ; la
relecture humaine reste le seul contenu conservé, dans l'OT (D1).
"""
import os
import tempfile
from typing import Protocol
class Transcripteur(Protocol):
def transcrire(self, audio: bytes, extension: str) -> str: ...
class TranscripteurLocal:
"""faster-whisper (CTranslate2, CPU, licence MIT) — chargé paresseusement,
jamais importé en test. Modèle et langue forcée en français : le mélange
français/darija du terrain reste un point de vigilance non calibré (pas
de mesure préalable demandée par le référent, 22/07/2026) — à revoir sur
échantillons réels si la qualité déçoit en recette."""
def __init__(self, modele: str) -> None:
from faster_whisper import WhisperModel # import différé (dépendance optionnelle)
self._modele = WhisperModel(modele, device="cpu", compute_type="int8")
def transcrire(self, audio: bytes, extension: str) -> str:
fd, chemin = tempfile.mkstemp(suffix=f".{extension}")
try:
with os.fdopen(fd, "wb") as f:
f.write(audio)
segments, _ = self._modele.transcribe(chemin, language="fr", vad_filter=True)
return " ".join(segment.text.strip() for segment in segments).strip()
finally:
os.remove(chemin) # D5 — l'audio ne doit JAMAIS survivre à l'appel
class TranscripteurDeterministe:
"""Tests/CI : pas de modèle, pas de dépendance audio réelle — renvoie un
texte stable dérivé de la taille du fichier (même interface)."""
def transcrire(self, audio: bytes, extension: str) -> str:
return f"transcription déterministe ({len(audio)} octets, .{extension})"
def construire_transcripteur(mode: str, modele: str) -> Transcripteur | None:
"""None si la dictée n'est pas activée (AI_TRANSCRIPTION=off, défaut) —
l'appelant doit alors refuser proprement (503), pas planter."""
if mode == "off":
return None
if mode == "deterministe":
return TranscripteurDeterministe()
return TranscripteurLocal(modele)

View File

@@ -16,6 +16,33 @@ def test_endpoints_internes_refuses_sans_jeton():
client = _client_sans_db()
assert client.post("/internal/reindex").status_code == 401
assert client.post("/internal/search", json={"question": "couple de serrage ?"}).status_code == 401
assert client.post("/internal/transcrire", files={"fichier": ("a.m4a", b"x")}).status_code == 401
def test_transcription_off_par_defaut_503():
"""Dictée pas activée (AI_TRANSCRIPTION=off, défaut) : refus propre,
jamais un 500 — même contrat que le service IA éteint côté NestJS."""
reponse = _client_sans_db().post(
"/internal/transcrire",
files={"fichier": ("note.m4a", b"faux-audio")},
headers={"X-Service-Token": "dev-only-ai-token"},
)
assert reponse.status_code == 503
def test_transcription_deterministe_retourne_un_texte():
from siop_ai.transcription import TranscripteurDeterministe
client = _client_sans_db()
app.state.transcripteur = TranscripteurDeterministe()
reponse = client.post(
"/internal/transcrire",
files={"fichier": ("note.m4a", b"faux-audio")},
headers={"X-Service-Token": "dev-only-ai-token"},
)
assert reponse.status_code == 200
assert "10 octets" in reponse.json()["texte"]
assert ".m4a" in reponse.json()["texte"]
def test_question_trop_courte_rejetee_avant_tout():
@@ -32,6 +59,11 @@ def _client_sans_db() -> TestClient:
modèle d'embeddings) ne tourne pas — on pose l'état minimal. Les tests
d'intégration DB se font en local (recette), pas en CI (convention R5.1)."""
from siop_ai.config import charger_reglages
from siop_ai.transcription import construire_transcripteur
app.state.reglages = charger_reglages()
reglages = charger_reglages()
app.state.reglages = reglages
app.state.transcripteur = construire_transcripteur(
reglages.ai_transcription, reglages.ai_transcription_model
)
return TestClient(app, raise_server_exceptions=False)

View File

@@ -0,0 +1,36 @@
"""Dictée opt-in (R5 D5, ADR-004 §5) : off par défaut (aucune dépendance
audio requise), déterministe en CI/tests. Le vrai moteur (faster-whisper)
se vérifie en recette réelle (convention R5.1 — pas de mesure préalable
demandée par le référent, 22/07/2026)."""
import pytest
from siop_ai.config import charger_reglages
from siop_ai.transcription import TranscripteurDeterministe, construire_transcripteur
def test_off_par_defaut_ne_construit_rien():
assert construire_transcripteur("off", "small") is None
def test_deterministe_stable_et_derive_la_taille():
transcripteur = construire_transcripteur("deterministe", "small")
assert isinstance(transcripteur, TranscripteurDeterministe)
texte = transcripteur.transcrire(b"12345", "m4a")
assert "5 octets" in texte
assert ".m4a" in texte
assert transcripteur.transcrire(b"12345", "m4a") == texte # stable
def test_mode_inconnu_refuse_au_boot(monkeypatch):
monkeypatch.setenv("AI_TRANSCRIPTION", "toujours")
with pytest.raises(ValueError, match="AI_TRANSCRIPTION"):
charger_reglages()
def test_modele_configurable(monkeypatch):
monkeypatch.setenv("AI_TRANSCRIPTION", "deterministe")
monkeypatch.setenv("AI_TRANSCRIPTION_MODEL", "medium")
reglages = charger_reglages()
assert reglages.ai_transcription == "deterministe"
assert reglages.ai_transcription_model == "medium"

106
apps/ai/uv.lock generated
View File

@@ -149,6 +149,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]]
name = "av"
version = "18.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba", size = 4340222, upload-time = "2026-07-02T06:37:58.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25", size = 22499354, upload-time = "2026-07-02T06:36:58.751Z" },
{ url = "https://files.pythonhosted.org/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484", size = 18175248, upload-time = "2026-07-02T06:37:01.741Z" },
{ url = "https://files.pythonhosted.org/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0", size = 33387843, upload-time = "2026-07-02T06:37:05.097Z" },
{ url = "https://files.pythonhosted.org/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017", size = 35536910, upload-time = "2026-07-02T06:37:08.806Z" },
{ url = "https://files.pythonhosted.org/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f", size = 38984619, upload-time = "2026-07-02T06:37:11.861Z" },
{ url = "https://files.pythonhosted.org/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1", size = 34451176, upload-time = "2026-07-02T06:37:15.154Z" },
{ url = "https://files.pythonhosted.org/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44", size = 36619869, upload-time = "2026-07-02T06:37:18.495Z" },
{ url = "https://files.pythonhosted.org/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629", size = 27556236, upload-time = "2026-07-02T06:37:21.388Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6", size = 20221133, upload-time = "2026-07-02T06:37:23.88Z" },
{ url = "https://files.pythonhosted.org/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5", size = 22740741, upload-time = "2026-07-02T06:37:26.659Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8", size = 18384189, upload-time = "2026-07-02T06:37:29.518Z" },
{ url = "https://files.pythonhosted.org/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e", size = 36749881, upload-time = "2026-07-02T06:37:33.096Z" },
{ url = "https://files.pythonhosted.org/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe", size = 38645927, upload-time = "2026-07-02T06:37:37.086Z" },
{ url = "https://files.pythonhosted.org/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b", size = 40454783, upload-time = "2026-07-02T06:37:40.904Z" },
{ url = "https://files.pythonhosted.org/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f", size = 37573117, upload-time = "2026-07-02T06:37:44.856Z" },
{ url = "https://files.pythonhosted.org/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b", size = 39669026, upload-time = "2026-07-02T06:37:48.761Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b", size = 28448336, upload-time = "2026-07-02T06:37:52.477Z" },
{ url = "https://files.pythonhosted.org/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c", size = 21377289, upload-time = "2026-07-02T06:37:55.935Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
@@ -351,6 +377,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "ctranslate2"
version = "4.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "pyyaml" },
{ name = "setuptools" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/c4/0e450796f90e54f3325697fc67db4f4ecd397aef96d7b3924e26fb8bd04b/ctranslate2-4.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c2db633a06e3b34bbfb72fd26eee58053d9df1f9c1610ac4df3a6a1e25af7d7", size = 1270559, upload-time = "2026-07-03T12:39:01.154Z" },
{ url = "https://files.pythonhosted.org/packages/b7/54/7b6db16470d0788fb8ab43a99e3e18ba9d41a9b50b7fef7dec353eafbe20/ctranslate2-4.8.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:079976cbce3a68de04bf9948d08c96beb86df44e5cd2974e4187bc9c9bb388f3", size = 11928069, upload-time = "2026-07-03T12:39:02.6Z" },
{ url = "https://files.pythonhosted.org/packages/37/66/8fee1366631d224bf26b34db9063a0c88ce358d58331c2393689b0ea27ff/ctranslate2-4.8.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74bae0a8dc9f98c5a6100bf1c17a91782b384ea53b83e2606030ebf9f25318fe", size = 16707971, upload-time = "2026-07-03T12:39:05.09Z" },
{ url = "https://files.pythonhosted.org/packages/30/84/f610e90bb419707632b9b668476b9fd4cdb090c9b53c119ce017699b58ca/ctranslate2-4.8.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a584c17f21779eb9035bcbc1ec280998f90b36725b70a5ff911f33e343199a", size = 39351971, upload-time = "2026-07-03T12:39:08.555Z" },
{ url = "https://files.pythonhosted.org/packages/76/6c/7230ecbdd23ab867715e1b6ffe99211c39c11cae8ec2d6c3ec9208c38ee2/ctranslate2-4.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:82982f07a7d615d2248d17d6ec4c43cd50e534b094aa27cda62125a5e3a6e3fc", size = 19219248, upload-time = "2026-07-03T12:39:11.329Z" },
{ url = "https://files.pythonhosted.org/packages/6d/09/9a50eeab00db68aeac08f6ab7f98b5c36abd26b89cbd707ea39e70656500/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9de0dddd91ae68da0a7323441e90708d14b31d31cd443004dda0e1198b5bf11e", size = 1270522, upload-time = "2026-07-03T12:39:13.368Z" },
{ url = "https://files.pythonhosted.org/packages/2f/97/6c41c4d3ae539ec76b1943c362184677befd7c1d5290d2ec361182cdb1e0/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:82e0e6eb7d4301fd79a714495c8faf34242e09542cef04c9e9794c3fe90014a1", size = 11930367, upload-time = "2026-07-03T12:39:14.896Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d4/03428106134a0a58922461074f8942f92c5ed0bb3a8d018677ad64a9c476/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ca144b93035b9f53e6d67b7cdf5802c3fffca9aa0247940eecbd4592c68ce2f", size = 16882768, upload-time = "2026-07-03T12:39:17.425Z" },
{ url = "https://files.pythonhosted.org/packages/47/c9/976a565398a03fb2973cbe5edd5ca03c4332d86b634799e0ee562420d3bc/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dacc408f716ebc73b3b3c6ddd937700e776c4c68b6d9c81862990150ff0f6af6", size = 39529060, upload-time = "2026-07-03T12:39:20.468Z" },
{ url = "https://files.pythonhosted.org/packages/c0/82/0a5f7f2b03b4e10aacb3146715724e1b96bb993cc7d199be28c9825aa120/ctranslate2-4.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:49f96e861b57301f0b76a082109bde2cac8204a6b4fedc870883008271e82251", size = 19220789, upload-time = "2026-07-03T12:39:23.356Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a7/3101c3a0785253a8ef386f39744ad19c28c75b7f227e7c232aee7a5c416a/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba628835e6ad4ad399261ab6cb51bf152de563e6b122a9e8eb0c61e69f925931", size = 1270478, upload-time = "2026-07-03T12:39:25.401Z" },
{ url = "https://files.pythonhosted.org/packages/89/b9/e50c7558e96a054d6b1e6a6c5e729dda4a4f05584e065f2902aa5f1bc4c8/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:85ef15ce0b2172ec471975b8a30d5c5bc71e7cffcd163ad6c07ea32f1943d940", size = 11930241, upload-time = "2026-07-03T12:39:26.927Z" },
{ url = "https://files.pythonhosted.org/packages/1d/2f/ea7a19c6d7e949b731fb034664633184bbfc7882846d107f4d790693fb76/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0030670278a73cae09dff9bca72cdd248af61f9367257f18db9b3b94fbb3a50d", size = 16883512, upload-time = "2026-07-03T12:39:29.302Z" },
{ url = "https://files.pythonhosted.org/packages/99/4a/21f325a9d0925d8ad24b04249adf29bf9909442967603634f7f6d4acbb79/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4242a7f8e285f922525f4cffd5b1fb43cbacc61d0611cf54832e9c447d030840", size = 39529085, upload-time = "2026-07-03T12:39:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e7/37da1a7500b57496a5269318c4f57962ea0c26dcac06b85222d7831acf00/ctranslate2-4.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827", size = 19220784, upload-time = "2026-07-03T12:39:35.74Z" },
{ url = "https://files.pythonhosted.org/packages/c6/66/39111224e418400d97fd79fbc9e72329c51f91a3e7a9c9a1a182e4f88022/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b4c3246aa4a7f309109a841ca743a72cc4abad4f93c0bf7da691023323215621", size = 1271321, upload-time = "2026-07-03T12:39:37.907Z" },
{ url = "https://files.pythonhosted.org/packages/ef/89/13f827fae226eea51315729c00111f716813d7736ebb827fecb8f361fe0d/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:c989f747789e8619cbc2e06443b3674c31bc71bad0369652485bd894b627360a", size = 11930735, upload-time = "2026-07-03T12:39:39.534Z" },
{ url = "https://files.pythonhosted.org/packages/c9/94/4b73f9bbaba29df4227cc65114f11d83fe6d696ef3705cb1ade79eb118fd/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90eb0bd67b6bb183712cc3fd14bf01ec4f622cd625c5b33cc6c56be7d1c9c34", size = 16872460, upload-time = "2026-07-03T12:39:42.272Z" },
{ url = "https://files.pythonhosted.org/packages/ee/d0/9816494d5ff0745bdf9abe5af04e57a103a416444e604cbe83a6eb0aed7b/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3e3aef4670a6c8dcea367401675f82b49b02c18f5837221bcd7cca90b1707a8", size = 39494736, upload-time = "2026-07-03T12:39:45.733Z" },
{ url = "https://files.pythonhosted.org/packages/6c/dc/22a2c874ca8bb6caa7018dfefdff92dddd487db31cf169891c4c6d408091/ctranslate2-4.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:a2dcce0a57beee984a691d9daa8fc3fd389f5b6cada2644c34571011833bd5b1", size = 19477164, upload-time = "2026-07-03T12:39:48.952Z" },
{ url = "https://files.pythonhosted.org/packages/77/39/7b8d47bf49748ba73182742683eef74b46608beb879765d9d4efc46bc345/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a28c5889585cd17ee3649dfd46d9002ddf50204173f8bff476b9f76d6585795", size = 1293935, upload-time = "2026-07-03T12:39:50.924Z" },
{ url = "https://files.pythonhosted.org/packages/c1/20/434e30c752c433eaef5deccd4de54775bc1f205a6fe6c9e756b737018209/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:911a5cdef8a405c1804330613a1865f616eb9c092a0e932ee4648128eb20b627", size = 11951789, upload-time = "2026-07-03T12:39:52.886Z" },
{ url = "https://files.pythonhosted.org/packages/85/f2/d716426220b462bbb5bb354b9c6c8d9a41285f067203c860cc79f9f19917/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84723cae6f802551bbf2438e5e4810722631a2183b89a82c31df26566b54821d", size = 16860414, upload-time = "2026-07-03T12:39:55.54Z" },
{ url = "https://files.pythonhosted.org/packages/69/11/cdab0e7e2ad4e547f15ab227c09207569f1272abae05816900ecebb0797a/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1910752ec541980644191fa3b407bc61dee00e88070b0aed29b4cef75010b3ea", size = 39465200, upload-time = "2026-07-03T12:39:59.017Z" },
{ url = "https://files.pythonhosted.org/packages/c0/03/126e963fc3237a416f3085b8a663ebd8ab449ed6c37195b4e0b49597ba0c/ctranslate2-4.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dc9f1abef55579cc02cdc74b3a55df38491ec56d177d6e6039609d61d09ed30e", size = 19499597, upload-time = "2026-07-03T12:40:01.68Z" },
]
[[package]]
name = "distro"
version = "1.9.0"
@@ -407,6 +471,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/e8/26b7d78bb8972498c467ca34cb12ee2e60d26ba5eae6d8443189a1af37a5/fastembed-0.8.0-py3-none-any.whl", hash = "sha256:40bee672657574a1009e35ec50030a55f2b426842cb011845379817641bbbbd0", size = 116572, upload-time = "2026-03-23T16:34:40.69Z" },
]
[[package]]
name = "faster-whisper"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "av" },
{ name = "ctranslate2" },
{ name = "huggingface-hub" },
{ name = "onnxruntime" },
{ name = "tokenizers" },
{ name = "tqdm" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" },
]
[[package]]
name = "filelock"
version = "3.30.2"
@@ -1360,6 +1440,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -1455,6 +1544,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" },
]
[[package]]
name = "setuptools"
version = "83.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
]
[[package]]
name = "siop-ai"
version = "0.1.0"
@@ -1465,6 +1563,7 @@ dependencies = [
{ name = "minio" },
{ name = "pydantic-settings" },
{ name = "pypdf" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -1475,6 +1574,9 @@ embeddings = [
generation = [
{ name = "anthropic" },
]
transcription = [
{ name = "faster-whisper" },
]
[package.dev-dependencies]
dev = [
@@ -1490,12 +1592,14 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.30" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "fastembed", marker = "extra == 'embeddings'", specifier = ">=0.4" },
{ name = "faster-whisper", marker = "extra == 'transcription'", specifier = ">=1.1" },
{ name = "minio", specifier = ">=7.2" },
{ name = "pydantic-settings", specifier = ">=2.6" },
{ name = "pypdf", specifier = ">=5.1" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
]
provides-extras = ["embeddings", "generation"]
provides-extras = ["embeddings", "generation", "transcription"]
[package.metadata.requires-dev]
dev = [

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "_LocationToUser" (
"A" UUID NOT NULL,
"B" UUID NOT NULL,
CONSTRAINT "_LocationToUser_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "_LocationToUser_B_index" ON "_LocationToUser"("B");
-- AddForeignKey
ALTER TABLE "_LocationToUser" ADD CONSTRAINT "_LocationToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_LocationToUser" ADD CONSTRAINT "_LocationToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -49,6 +49,8 @@ model User {
activationToken String? @unique
activationExpiresAt DateTime?
teams Team[]
// R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction
assignedSites Location[]
// R2 — exploitation
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
@@ -111,6 +113,7 @@ model Location {
partnerId String? @db.Uuid
partner Partner? @relation(fields: [partnerId], references: [id])
assets Asset[]
assignedUsers User[] // reverse de User.assignedSites (R6.6)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -462,6 +462,19 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
const karim = await parEmail('demandeur@demo.siop.ma');
const annee = new Date().getFullYear();
// R6.6 — Karim (Demandeur) est rattaché à Tour Atlas (cohérent avec le
// guardianName déjà seedé pour ce site) : son signalement se limite à ce
// parc, ses demandes historiques sur d'autres sites restent visibles.
const tourAtlas = await prisma.location.findFirst({
where: { name: 'Tour Atlas', parentId: null },
});
if (tourAtlas) {
await prisma.user.update({
where: { id: karim },
data: { assignedSites: { connect: [{ id: tourAtlas.id }] } },
});
}
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
(t) => t.label,
);

View File

@@ -17,6 +17,7 @@ import {
type AssetCreate,
type AssetUpdate,
} from '@siop/shared';
import { type AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { AssetsService } from './assets.service';
@@ -32,10 +33,11 @@ export class AssetsController {
}
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
* doit pouvoir désigner l'appareil qu'il signale. */
* doit pouvoir désigner l'appareil qu'il signale (filtré à son site s'il
* en a un, R6.6). */
@Get('options')
options() {
return this.assetsService.options();
options(@CurrentUser() user: AuthenticatedUser) {
return this.assetsService.options(user);
}
@Get(':id')

View File

@@ -15,6 +15,7 @@ import type {
AssetsResponse,
AssetUpdate,
} from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
const assetInclude = {
@@ -29,9 +30,30 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
export class AssetsService {
constructor(private readonly prisma: PrismaService) {}
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
async options(): Promise<AssetOptionsResponse> {
/** Sites + zones autorisés pour le signalement de `user`, ou `null` si aucune
* restriction (comportement historique — tous les rôles sauf un Demandeur
* affecté à un site, R6.6). Réutilisée par `options()` ET par
* `RequestsService.create` pour que les deux filtres ne divergent jamais. */
async allowedLocationIds(user: AuthenticatedUser): Promise<string[] | null> {
const me = await this.prisma.user.findUnique({
where: { id: user.userId },
select: { assignedSites: { select: { id: true } } },
});
const siteIds = (me?.assignedSites ?? []).map((s) => s.id);
if (siteIds.length === 0) return null;
const zones = await this.prisma.location.findMany({
where: { parentId: { in: siteIds } },
select: { id: true },
});
return [...siteIds, ...zones.map((z) => z.id)];
}
/** Options minimales pour le signalement — ouvert à tout rôle authentifié,
* filtré au périmètre du Demandeur s'il est affecté à un site (R6.6). */
async options(user: AuthenticatedUser): Promise<AssetOptionsResponse> {
const allowed = await this.allowedLocationIds(user);
const rows = await this.prisma.asset.findMany({
where: allowed ? { locationId: { in: allowed } } : undefined,
include: { location: { include: { parent: true } } },
orderBy: { reference: 'asc' },
});

View File

@@ -1,4 +1,13 @@
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
HttpCode,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
AssistantAskSchema,
SuggestBilanSchema,
@@ -9,6 +18,10 @@ import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { AssistantService } from './assistant.service';
// Dictée (D5) : un enregistrement de quelques dizaines de secondes tient
// largement dans 15 Mo — pas besoin du plafond 20 Mo des documents R3.
const AUDIO_MAX_BYTES = 15 * 1024 * 1024;
@Controller('assistant')
export class AssistantController {
constructor(private readonly assistant: AssistantService) {}
@@ -36,4 +49,18 @@ export class AssistantController {
suggest(@Body(new ZodValidationPipe(SuggestBilanSchema)) body: SuggestBilan) {
return this.assistant.suggestBilan(body);
}
/** Dictée (D5) — même droit que la saisie du bilan qu'elle alimente. */
@Post('transcribe')
@HttpCode(200)
@RequirePermission('WORK_ORDERS', 'edit')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: AUDIO_MAX_BYTES } }))
transcribe(@UploadedFile() file: Express.Multer.File | undefined) {
if (!file) throw new BadRequestException('Aucun enregistrement reçu (champ « file »)');
return this.assistant.transcribe({
buffer: file.buffer,
originalName: file.originalname,
contentType: file.mimetype,
});
}
}

View File

@@ -6,6 +6,7 @@ import type {
BilanSuggestionsResponse,
ReindexResult,
SuggestBilan,
TranscriptionResult,
} from '@siop/shared';
import { loadEnv } from '../config/env';
@@ -97,6 +98,43 @@ export class AssistantService {
};
}
/** Dictée (R5 D5, opt-in) : l'audio ne transite qu'une fois vers `siop2-ai`
* — jamais écrit ni sur disque ni en base ici, transmis tel quel en
* multipart. Le service IA l'efface aussitôt transcrit (transcription.py). */
async transcribe(audio: {
buffer: Buffer;
originalName: string;
contentType: string;
}): Promise<TranscriptionResult> {
const form = new FormData();
form.append(
'fichier',
new Blob([new Uint8Array(audio.buffer)], { type: audio.contentType }),
audio.originalName,
);
let reponse: Response;
try {
reponse = await fetch(`${this.env.AI_SERVICE_URL}/internal/transcrire`, {
method: 'POST',
headers: { 'X-Service-Token': this.env.AI_SERVICE_TOKEN },
body: form,
});
} catch {
this.journal.warn('Service IA injoignable (/internal/transcrire)');
throw new ServiceUnavailableException(
'Dictée indisponible pour le moment — réessayez dans un instant.',
);
}
if (!reponse.ok) {
this.journal.warn(`Service IA a refusé /internal/transcrire (${reponse.status})`);
throw new ServiceUnavailableException(
'Dictée indisponible pour le moment — réessayez dans un instant.',
);
}
const brut = (await reponse.json()) as { texte: string };
return { text: brut.texte };
}
async suggestBilan(dto: SuggestBilan): Promise<BilanSuggestionsResponse> {
const brut = await this.appeler<{
suggestions: {

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AssetsModule } from '../assets/assets.module';
import { WorkOrdersModule } from '../work-orders/work-orders.module';
import { RequestsController } from './requests.controller';
import { RequestsService } from './requests.service';
@Module({
imports: [WorkOrdersModule],
imports: [WorkOrdersModule, AssetsModule],
controllers: [RequestsController],
providers: [RequestsService],
})

View File

@@ -16,6 +16,7 @@ import type {
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { AssetsService } from '../assets/assets.service';
import { WorkOrdersService } from '../work-orders/work-orders.service';
const requestInclude = {
@@ -32,6 +33,7 @@ export class RequestsService {
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly workOrders: WorkOrdersService,
private readonly assets: AssetsService,
) {}
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
@@ -52,6 +54,13 @@ export class RequestsService {
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
if (!asset) throw new BadRequestException('Équipement inconnu');
// Défense en profondeur (R6.6) : même filtre que /assets/options, pour
// qu'un Demandeur affecté à un site ne puisse pas contourner la liste
// en soumettant directement un assetId hors périmètre.
const allowed = await this.assets.allowedLocationIds(user);
if (allowed && !allowed.includes(asset.locationId)) {
throw new BadRequestException("Cet équipement n'est pas dans votre périmètre");
}
for (let essai = 0; ; essai++) {
try {
const created = await this.prisma.request.create({

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
@@ -20,6 +21,7 @@ import { PrismaService } from '../prisma/prisma.service';
const userInclude = {
role: true,
teams: { orderBy: { name: 'asc' } },
assignedSites: { orderBy: { name: 'asc' } },
} satisfies Prisma.UserInclude;
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
@@ -46,6 +48,7 @@ export class UsersService {
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
if (!role) throw new NotFoundException('Rôle inconnu');
if (dto.locationIds?.length) await this.assertTopLevelSites(dto.locationIds);
try {
const user = await this.prisma.user.create({
data: {
@@ -56,6 +59,9 @@ export class UsersService {
teams: dto.teamIds?.length
? { connect: dto.teamIds.map((id) => ({ id })) }
: undefined,
assignedSites: dto.locationIds?.length
? { connect: dto.locationIds.map((id) => ({ id })) }
: undefined,
...this.freshToken(),
},
});
@@ -90,6 +96,7 @@ export class UsersService {
}
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
if (dto.locationIds) await this.assertTopLevelSites(dto.locationIds);
try {
const updated = await this.prisma.user.update({
where: { id: userId },
@@ -102,6 +109,9 @@ export class UsersService {
teams: dto.teamIds
? { set: dto.teamIds.map((id) => ({ id })) }
: undefined,
assignedSites: dto.locationIds
? { set: dto.locationIds.map((id) => ({ id })) }
: undefined,
},
include: userInclude,
});
@@ -114,6 +124,16 @@ export class UsersService {
}
}
/** R6.6 : un Demandeur n'est affecté qu'à des sites racine, jamais des zones
* — même invariant que la hiérarchie site/zone (LocationsService.assertDepth). */
private async assertTopLevelSites(ids: string[]): Promise<void> {
const sites = await this.prisma.location.findMany({ where: { id: { in: ids } } });
if (sites.length !== ids.length) throw new BadRequestException('Site inconnu');
if (sites.some((s) => s.parentId)) {
throw new BadRequestException("L'affectation d'un Demandeur se fait à un site, pas à une zone");
}
}
private freshToken() {
return {
activationToken: randomBytes(32).toString('base64url'),
@@ -138,6 +158,7 @@ export class UsersService {
: 'invited',
isDemo: row.isDemo,
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
assignedSites: row.assignedSites.map((s) => ({ id: s.id, name: s.name })),
};
}
}

View File

@@ -49,6 +49,8 @@ const REPONSE_REINDEX = {
extraits: 180,
};
const REPONSE_TRANSCRIRE = { texte: 'Porte cabine qui rebondit, cellule encrassée.' };
describe('Assistant (e2e — stub du service IA)', () => {
let app: INestApplication;
let stub: Server;
@@ -71,6 +73,7 @@ describe('Assistant (e2e — stub du service IA)', () => {
if (req.url === '/internal/ask') res.end(JSON.stringify(REPONSE_ASK));
else if (req.url === '/internal/suggest') res.end(JSON.stringify(REPONSE_SUGGEST));
else if (req.url === '/internal/reindex') res.end(JSON.stringify(REPONSE_REINDEX));
else if (req.url === '/internal/transcrire') res.end(JSON.stringify(REPONSE_TRANSCRIRE));
else {
res.statusCode = 404;
res.end('{}');
@@ -169,6 +172,27 @@ describe('Assistant (e2e — stub du service IA)', () => {
await http().post('/assistant/reindex').set(auth(ahmed)).expect(403);
});
it('transcribe : dictée (D5) — texte transcrit, réservé à qui édite les OT', async () => {
const res = await http()
.post('/assistant/transcribe')
.set(auth(ahmed))
.attach('file', Buffer.from('faux-audio'), { filename: 'note.m4a', contentType: 'audio/m4a' })
.expect(200);
expect(res.body).toEqual({ text: 'Porte cabine qui rebondit, cellule encrassée.' });
expect(requetesRecues.at(-1)!.url).toBe('/internal/transcrire');
expect(requetesRecues.at(-1)!.jeton).toBe('jeton-de-test');
// Karim (Demandeur) n'a pas WORK_ORDERS.edit
await http()
.post('/assistant/transcribe')
.set(auth(karim))
.attach('file', Buffer.from('faux-audio'), { filename: 'note.m4a', contentType: 'audio/m4a' })
.expect(403);
});
it('transcribe : refuse proprement sans fichier joint', async () => {
await http().post('/assistant/transcribe').set(auth(ahmed)).expect(400);
});
it('question trop courte : 400 avant tout appel au service IA', async () => {
const avant = requetesRecues.length;
await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400);

View File

@@ -55,9 +55,10 @@ describe('Exploitation (e2e)', () => {
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
// 1. Karim (gardien) signale
// 1. Karim (gardien) signale — sur A2, dans son site rattaché (Tour
// Atlas, R6.6) : un Demandeur ne peut plus signaler hors périmètre.
const { body: assets } = await http().get('/assets').set(auth(salma));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A2');
const demande = await http()
.post('/requests')
.set(auth(karim))
@@ -234,8 +235,9 @@ describe('Exploitation (e2e)', () => {
});
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
// B1 : dans le site rattaché de Karim (Tour Atlas, R6.6).
const { body: assets } = await http().get('/assets').set(auth(salma));
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'C1');
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'B1');
const demande = await http()
.post('/requests')
.set(auth(karim))

21
apps/mobile/.env Normal file
View File

@@ -0,0 +1,21 @@
# Depuis Expo SDK 57, le `fetch` global est `expo/fetch` (WinterCG) — qui ne
# comprend pas le raccourci FormData natif `{uri, name, type}` utilisé pour
# joindre un fichier au multipart (photos d'intervention, D5). Sans ce
# réglage, l'upload échoue avec `Unsupported FormDataPart implementation`,
# systématiquement classé à tort comme une coupure réseau (D1) et mis en
# boucle de retry silencieuse — trouvé en recette terrain iOS (17/07/2026).
# Voir ADR-005 et docs/journal/journal.md.
EXPO_PUBLIC_USE_RN_FETCH=1
# Modules Expo/RN précompilés (XCFrameworks, SDK 56/57) incompatibles avec la
# liaison statique de ce projet (pas de use_frameworks!) : sans ce réglage,
# l'app compile mais CRASHE au lancement
# (dyld: Library not loaded: @rpath/React.framework/React, signal 6).
# Force la recompilation depuis les sources — voir ADR-005.
EXPO_USE_PRECOMPILED_MODULES=0
RCT_USE_PREBUILT_RNCORE=0
# Origine du web — R6.4, lien d'activation « Personnes » (/activation?token=…,
# il n'existe pas d'équivalent mobile). À ajuster par environnement comme
# EXPO_PUBLIC_API_URL (IP LAN ou domaine de prod).
EXPO_PUBLIC_WEB_URL=http://localhost:5173

View File

@@ -7,7 +7,8 @@
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "com.anonymous.siop2-mobile"
},
"android": {
"adaptiveIcon": {
@@ -16,7 +17,8 @@
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
"predictiveBackGestureEnabled": false,
"package": "com.anonymous.siop2mobile"
},
"web": {
"favicon": "./assets/favicon.png"
@@ -24,7 +26,8 @@
"plugins": [
"expo-router",
"expo-font",
"expo-secure-store"
"expo-secure-store",
"expo-audio"
],
"scheme": "siop"
}

View File

@@ -1,12 +1,19 @@
import { Tabs } from 'expo-router';
import { Text, type ColorValue } from 'react-native';
import { useAssets, useReferenceValues } from '@/api/exploitation';
import { useMe } from '@/auth/session';
import { ongletsVisibles } from '@/auth/roles';
import { usePermissions } from '@/auth/use-permissions';
import { useFile } from '@/file/store';
import { useTokens } from '@/theme/tokens';
/** La tabbar de la maquette R4. Les onglets à venir restent visibles
* (périmètre annoncé, même patron que la sidebar web) mais mènent à un
* écran « disponible en R4.x ». */
/** Barre d'onglets ADAPTATIVE (maquette « mobile ouvert à tous les rôles »,
* D1) : le Technicien/Technicien limité gardent exactement leurs 4 onglets
* terrain R4, inchangés. Les autres rôles reçoivent Accueil/OT/Menu (ou une
* variante plus étroite — Demandeur, Vue seule — selon leurs droits,
* `ongletsVisibles`). Tous les écrans sont toujours déclarés : `href: null`
* masque un onglet de la barre sans le retirer du navigateur (le Menu peut
* toujours y pousser directement). */
function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) {
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
@@ -16,10 +23,19 @@ export default function CoquilleTabs() {
const t = useTokens();
const file = useFile();
const conflits = file.some((s) => s.statut === 'CONFLIT');
const { data: me } = useMe();
const { can } = usePermissions();
const visibles = ongletsVisibles(me?.role.name);
// D1 « lecture locale » : le parc (scan D4) et les référentiels du bilan
// se préchargent dès l'entrée — le sous-sol n'attend pas qu'on y pense.
useAssets();
useReferenceValues();
// Le Demandeur n'a pas ASSETS.view (seulement /assets/options) : inutile
// d'appeler /assets pour lui, l'API répondrait 403.
const peutAssets = can('ASSETS', 'view');
useAssets({ enabled: peutAssets });
useReferenceValues({ enabled: peutAssets });
const cacher = (nom: string) => (visibles.has(nom) ? undefined : null);
return (
<Tabs
screenOptions={{
@@ -34,6 +50,7 @@ export default function CoquilleTabs() {
<Tabs.Screen
name="journee"
options={{
href: cacher('journee'),
title: 'Ma journée',
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
}}
@@ -41,6 +58,7 @@ export default function CoquilleTabs() {
<Tabs.Screen
name="scanner"
options={{
href: cacher('scanner'),
title: 'Scanner',
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
}}
@@ -48,6 +66,7 @@ export default function CoquilleTabs() {
<Tabs.Screen
name="preventif"
options={{
href: cacher('preventif'),
title: 'Préventif',
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
}}
@@ -55,6 +74,7 @@ export default function CoquilleTabs() {
<Tabs.Screen
name="synchro"
options={{
href: cacher('synchro'),
title: 'Synchro',
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
tabBarBadge: file.length || undefined,
@@ -66,6 +86,38 @@ export default function CoquilleTabs() {
},
}}
/>
<Tabs.Screen
name="accueil"
options={{
href: cacher('accueil'),
title: 'Accueil',
tabBarIcon: ({ color }) => <Pic glyphe="⌂" couleur={color} />,
}}
/>
<Tabs.Screen
name="ot"
options={{
href: cacher('ot'),
title: 'OT',
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
}}
/>
<Tabs.Screen
name="nouvelle-demande"
options={{
href: cacher('nouvelle-demande'),
title: 'Nouvelle demande',
tabBarIcon: ({ color }) => <Pic glyphe="+" couleur={color} />,
}}
/>
<Tabs.Screen
name="menu"
options={{
href: cacher('menu'),
title: 'Menu',
tabBarIcon: ({ color }) => <Pic glyphe="☷" couleur={color} />,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,179 @@
import { router } from 'expo-router';
import { Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRequests, useWorkOrders } from '@/api/exploitation';
import { useMe } from '@/auth/session';
import { usePermissions } from '@/auth/use-permissions';
import { EnteteTabs } from '@/composants/ui';
import { PanneauDemandes } from '@/composants/panneau-demandes';
import { useTokens } from '@/theme/tokens';
/** Accueil — maquette « mobile ouvert à tous les rôles », écran 2 : le
* Demandeur y voit directement ses demandes (son seul geste) ; les rôles
* de gestion (Administrateur/Gestionnaire/Dispatcher/Vue seule) y trouvent
* leurs signaux réels (urgence, OT en cours, demandes à traiter) — aucun
* chiffre inventé : seuls WORK_ORDERS/REQUESTS sont déjà câblés sur mobile,
* le reste (stock, préventif du mois…) attend son propre écran (Menu). */
export default function PageAccueil() {
const { data: me } = useMe();
if (me?.role.name === 'Demandeur') {
return (
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteTabs />
<PanneauDemandes />
</View>
</SafeAreaView>
);
}
return <TableauDeBordGestion />;
}
function TableauDeBordGestion() {
const t = useTokens();
const { can } = usePermissions();
const peutOT = can('WORK_ORDERS', 'view');
const peutDemandes = can('REQUESTS', 'view');
const { data: workOrders } = useWorkOrders();
const { data: requests } = useRequests();
const ots = workOrders ?? [];
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED' && o.status !== 'DONE' && o.status !== 'CANCELLED');
const enCours = ots.filter((o) => o.status === 'OPEN' || o.status === 'IN_PROGRESS').length;
const aTraiter = (requests ?? []).filter((r) => r.status === 'RECEIVED').length;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Accueil
</Text>
{urgences.length ? (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/ot/${urgences[0]!.id}`)}
style={{
backgroundColor: t.prioBloqueFond,
borderRadius: 10,
padding: 9,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
{urgences.length === 1
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
: `${urgences.length} personnes bloquées`}
</Text>
</Pressable>
) : null}
{peutOT || peutDemandes ? (
<View style={{ flexDirection: 'row', gap: 8 }}>
{peutOT ? (
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.primaire }}>
{enCours}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
OT en cours
</Text>
</View>
) : null}
{peutDemandes ? (
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
{aTraiter}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
Demandes reçues
</Text>
</View>
) : null}
</View>
) : null}
{peutDemandes && aTraiter > 0 ? (
<Pressable
accessibilityRole="button"
onPress={() => router.push('/demandes')}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 11,
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: t.primaireDoux,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 14, color: t.primaire }}></Text>
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
Demandes en attente
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre2 }}>
à approuver ou rejeter
</Text>
</View>
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 10.5,
color: t.stAttente,
backgroundColor: t.stAttenteFond,
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{aTraiter}
</Text>
<Text style={{ color: t.encre3, fontSize: 13 }}></Text>
</Pressable>
) : null}
{!peutOT && !peutDemandes ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', textAlign: 'center', padding: 24 }}>
Rien à afficher ici pour votre rôle voir le Menu.
</Text>
) : null}
</View>
</SafeAreaView>
);
}

View File

@@ -10,7 +10,8 @@ import {
type WorkOrderStatus,
} from '@siop/shared';
import { api, unwrap } from '@/api/client';
import { useHorsLigne, useLogout, useMe } from '@/auth/session';
import { useHorsLigne } from '@/auth/session';
import { EnteteTabs } from '@/composants/ui';
import { triJournee } from '@/lib/journee';
import { useTokens, type Tokens } from '@/theme/tokens';
@@ -43,9 +44,7 @@ const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
export default function PageJournee() {
const t = useTokens();
const horsLigne = useHorsLigne();
const { data: me } = useMe();
const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
const logout = useLogout();
const ots = triJournee(workOrders ?? []);
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
@@ -55,57 +54,7 @@ export default function PageJournee() {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}>
{/* Entête app : marque + pastille synchro + compte */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ color: t.safran, fontSize: 15 }}></Text>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
SIOP
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre3 }}>
Technicien
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginLeft: 'auto' }}>
<Text
accessibilityLabel="État de synchronisation"
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: horsLigne ? t.stAttente : t.stTermine,
backgroundColor: horsLigne ? t.stAttenteFond : t.stTermineFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{horsLigne ? 'Hors-ligne' : 'Synchro à jour'}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Se déconnecter"
onLongPress={() => {
void logout().then(() => router.replace('/connexion'));
}}
style={{
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: t.stEncours,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 10 }}>
{(me?.displayName ?? '·')
.split(' ')
.map((m) => m[0])
.join('')
.slice(0, 2)
.toUpperCase()}
</Text>
</Pressable>
</View>
</View>
<EnteteTabs />
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>

View File

@@ -0,0 +1,141 @@
import { router } from 'expo-router';
import { Pressable, ScrollView, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { ObjectCategory, PermissionRight } from '@siop/shared';
import { useMe } from '@/auth/session';
import { estRoleTerrain } from '@/auth/roles';
import { usePermissions } from '@/auth/use-permissions';
import { EnteteTabs } from '@/composants/ui';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Menu — maquette « mobile ouvert à tous les rôles », écran 3 : reprend à
* l'identique les 4 groupes et l'ordre de la sidebar web (Exploitation /
* Parc / Ressources / Pilotage, coquille.tsx), filtrés par LA MÊME matrice
* (D2 — aucune règle de droit nouvelle). Un groupe sans aucun lien visible
* est masqué en entier (D3) : l'écran est petit, un intitulé vide n'aide
* personne. Ce que « Ma journée »/« Préventif » couvrent déjà pour le
* Technicien n'est pas dupliqué ici. */
interface LienMenu {
libelle: string;
permission: [ObjectCategory, PermissionRight];
ico: string;
cacheEnTerrain?: boolean; // déjà couvert par un onglet terrain dédié
route?: string;
aVenir?: string; // detail affiché sur l'écran « à venir »
}
const GROUPES: { titre: string; liens: LienMenu[] }[] = [
{
titre: 'Exploitation',
liens: [
{ libelle: 'Ordres de travail', ico: '☰', permission: ['WORK_ORDERS', 'view'], cacheEnTerrain: true, route: '/(tabs)/ot' },
{ libelle: 'Demandes', ico: '✎', permission: ['REQUESTS', 'view'], route: '/demandes' },
{ libelle: 'Préventif', ico: '✓', permission: ['WORK_ORDERS', 'view'], cacheEnTerrain: true, aVenir: 'Les grilles préventives pour les rôles de gestion arrivent dans une prochaine étape — le Technicien les a déjà dans son onglet dédié.' },
],
},
{
titre: 'Parc',
liens: [
{ libelle: 'Ascenseurs', ico: '▣', permission: ['ASSETS', 'view'], route: '/ascenseurs' },
{ libelle: 'Sites', ico: '◫', permission: ['LOCATIONS', 'view'], route: '/sites' },
{ libelle: 'Catégories', ico: '▤', permission: ['SETTINGS', 'view'], aVenir: 'Ladministration des catégories reste sur le grand écran pour linstant.' },
],
},
{
titre: 'Ressources',
liens: [
{ libelle: 'Stock & achats', ico: '◔', permission: ['PARTS', 'view'], route: '/stock' },
{ libelle: 'Tiers', ico: '⇄', permission: ['PURCHASE_ORDERS', 'view'], route: '/tiers' },
{ libelle: 'Fichiers', ico: '▧', permission: ['ASSETS', 'view'], route: '/bibliotheque' },
],
},
{
titre: 'Pilotage',
liens: [
{ libelle: 'Statistiques', ico: '◈', permission: ['ANALYTICS', 'view'], route: '/statistiques' },
{ libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], route: '/assistant' },
{ libelle: 'Personnes & équipes', ico: '◎', permission: ['PEOPLE_TEAMS', 'view'], route: '/personnes' },
],
},
];
export default function PageMenu() {
const t = useTokens();
const { data: me } = useMe();
const { can } = usePermissions();
const terrain = estRoleTerrain(me?.role.name);
const groupesVisibles = GROUPES.map((g) => ({
...g,
liens: g.liens.filter(
({ permission, cacheEnTerrain }) => can(...permission) && !(cacheEnTerrain && terrain),
),
})).filter((g) => g.liens.length > 0);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>Menu</Text>
<ScrollView contentContainerStyle={{ gap: 14, paddingBottom: 16 }} showsVerticalScrollIndicator={false}>
{groupesVisibles.length === 0 ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', textAlign: 'center', padding: 24 }}>
Rien d'autre accessible à votre rôle ici.
</Text>
) : null}
{groupesVisibles.map((g) => (
<View key={g.titre} style={{ gap: 6 }}>
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 10.5,
letterSpacing: 0.8,
textTransform: 'uppercase',
color: t.encre3,
}}
>
{g.titre}
</Text>
{g.liens.map((lien) => (
<LigneMenu key={lien.libelle} lien={lien} t={t} />
))}
</View>
))}
</ScrollView>
</View>
</SafeAreaView>
);
}
function LigneMenu({ lien, t }: { lien: LienMenu; t: Tokens }) {
const aller = () => {
if (lien.route) {
router.push(lien.route as never);
} else {
router.push({ pathname: '/a-venir', params: { titre: lien.libelle, detail: lien.aVenir ?? '' } });
}
};
return (
<Pressable
accessibilityRole="button"
onPress={aller}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 11,
}}
>
<Text style={{ width: 22, textAlign: 'center', fontSize: 14, color: t.primaire }}>{lien.ico}</Text>
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
{lien.libelle}
</Text>
<Text style={{ color: t.encre3, fontSize: 13 }}></Text>
</Pressable>
);
}

View File

@@ -0,0 +1,24 @@
import { router } from 'expo-router';
import { ScrollView, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { FormulaireDemande } from '@/composants/formulaire-demande';
import { EnteteTabs } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Onglet du Demandeur (maquette « mobile ouvert à tous les rôles »,
* écran 5) : le geste qu'il fait le plus mérite son propre onglet plutôt
* qu'un bouton caché dans l'Accueil. */
export default function PageNouvelleDemande() {
const t = useTokens();
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Nouvelle demande
</Text>
<FormulaireDemande surSucces={() => router.replace('/(tabs)/accueil')} />
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,154 @@
import { router } from 'expo-router';
import { FlatList, Pressable, RefreshControl, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
WORK_ORDER_PRIORITY_LABELS,
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_TYPE_LABELS,
type WorkOrderPriority,
type WorkOrderStatus,
} from '@siop/shared';
import { useWorkOrders } from '@/api/exploitation';
import { EnteteTabs } from '@/composants/ui';
import { triJournee } from '@/lib/journee';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Ordres de travail — maquette « mobile ouvert à tous les rôles », écran 4 :
* à la différence de « Ma journée » (Technicien, ses OT uniquement),
* Administrateur/Gestionnaire/Dispatcher ont `viewOther` — l'API renvoie
* déjà TOUS les OT (ADR-003, rien à filtrer ici). Même tri priorité puis
* échéance que « Ma journée » ; ouvrir un OT réutilise la fiche R4 telle
* quelle (elle affiche déjà les actions permises via `allowedTransitions`). */
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],
};
const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
PERSON_TRAPPED: (t) => t.prioBloque,
HIGH: (t) => t.prioHaute,
MEDIUM: (t) => t.prioMoyenne,
LOW: (t) => t.prioBasse,
NONE: () => 'transparent',
};
export default function PageOT() {
const t = useTokens();
const { data: workOrders, refetch, isFetching } = useWorkOrders();
const ots = triJournee(workOrders ?? []);
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}>
<EnteteTabs />
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Ordres de travail
</Text>
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
{ots.length}
</Text>
</View>
{urgences.length ? (
<View
style={{
backgroundColor: t.prioBloqueFond,
borderRadius: 10,
padding: 9,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
{urgences.length === 1
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
: `${urgences.length} personnes bloquées`}
</Text>
</View>
) : null}
<FlatList
data={ots}
keyExtractor={(o) => o.id}
refreshControl={
<RefreshControl refreshing={isFetching} onRefresh={() => void refetch()} tintColor={t.primaire} />
}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun OT tirez pour rafraîchir.
</Text>
}
renderItem={({ item: o }) => {
const [enc, fond] = STYLE_STATUT[o.status](t);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${o.reference}${o.title}`}
onPress={() => router.push(`/ot/${o.id}`)}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
borderLeftWidth: 4,
borderLeftColor: STRIE_PRIORITE[o.priority](t),
padding: 11,
gap: 2,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: t.encre2,
flex: 1,
}}
>
{o.reference} · {WORK_ORDER_TYPE_LABELS[o.type]}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enc,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{WORK_ORDER_STATUS_LABELS[o.status]}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{o.title}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
Asc. {o.assetReference} {o.siteName}
{o.dueDate
? ` · échéance ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(o.dueDate))}`
: ''}
{o.priority !== 'NONE' && o.priority !== 'PERSON_TRAPPED'
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
: ''}
</Text>
</Pressable>
);
}}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -2,7 +2,7 @@ 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 { ChipStatut, EnteteTabs } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Onglet Préventif : mes grilles du moment — chaque tuile mène à la
@@ -18,6 +18,7 @@ export default function PagePreventif() {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Préventif
</Text>

View File

@@ -5,7 +5,7 @@ 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 { BoutonTel, EnteteTabs } from '@/composants/ui';
import { analyseScan } from '@/lib/scan';
import { useTokens } from '@/theme/tokens';
@@ -59,6 +59,7 @@ export default function PageScanner() {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Scanner
</Text>

View File

@@ -3,7 +3,7 @@ import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useHorsLigne } from '@/auth/session';
import { BoutonTel } from '@/composants/ui';
import { BoutonTel, EnteteTabs } from '@/composants/ui';
import { useFile, type Saisie } from '@/file/store';
import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro';
import { useTokens } from '@/theme/tokens';
@@ -33,6 +33,7 @@ export default function PageSynchro() {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteTabs />
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Synchro

View File

@@ -0,0 +1,21 @@
import { useLocalSearchParams } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { AVenir } from '@/composants/a-venir';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Destination générique du Menu pour les familles pas encore portées sur
* mobile (Sites, Stock, Tiers, Personnes, Statistiques, Assistant…) — même
* patron que la sidebar web (release annoncée, jamais un cul-de-sac
* silencieux). `AVenir` existait déjà (a-venir.tsx) mais n'était encore
* câblé nulle part. */
export default function PageAVenir() {
const t = useTokens();
const { titre, detail } = useLocalSearchParams<{ titre: string; detail: string }>();
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<EnteteFiche titre={titre ?? '—'} />
<AVenir titre={titre ?? '—'} release="une prochaine étape" detail={detail ?? ''} />
</SafeAreaView>
);
}

View File

@@ -0,0 +1,90 @@
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ASSET_STATUS_LABELS, type AssetStatus } from '@siop/shared';
import { useAssets } from '@/api/exploitation';
import { EnteteFiche } from '@/composants/ui';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Ascenseurs — R6.2 (Parc). Le parc complet, déjà préchargé (D1 lecture
* locale) — même donnée que le Scanner, présentée en liste plutôt qu'en
* résolution QR. Ouvre la fiche R4 telle quelle (écran 5). */
export default function PageAscenseurs() {
const t = useTokens();
const { data: assets } = useAssets();
const appareils = [...(assets ?? [])].sort((a, b) => a.reference.localeCompare(b.reference));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Ascenseurs" />
<FlatList
data={appareils}
keyExtractor={(a) => a.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun appareil accessible.
</Text>
}
renderItem={({ item: a }) => (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/ascenseur/${a.id}`)}
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={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: t.encre2,
flex: 1,
}}
>
{a.reference}
</Text>
<ChipStatutAppareil statut={a.status} t={t} />
</View>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{a.brand} {a.model ?? ''}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
{a.siteName} {a.locationName}
</Text>
</Pressable>
)}
/>
</View>
</SafeAreaView>
);
}
function ChipStatutAppareil({ statut, t }: { statut: AssetStatus; t: Tokens }) {
const enService = statut === 'IN_SERVICE';
return (
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enService ? t.stTermine : t.stAttente,
backgroundColor: enService ? t.stTermineFond : t.stAttenteFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{ASSET_STATUS_LABELS[statut]}
</Text>
);
}

View File

@@ -0,0 +1,380 @@
// 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 } from 'expo-router';
import { useRef, 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 type { AssistantAnswer, AssistantExcerpt } from '@siop/shared';
import { useAskAssistant } from '@/api/assistant';
import { useTranscription } from '@/api/exploitation';
import { BoutonTel, EnteteFiche } from '@/composants/ui';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Assistant — maquette « Assistant mobile » (validée 02/08) : même contenu
* et mêmes règles que le web (D2 « sourcé ou silencieux », ADR-004 §4 —
* le service IA n'est jamais appelé directement, toujours via l'API).
* D5 : la question peut être tapée OU dictée — même pipeline que la
* dictée déjà livrée en clôture (ADR-004 §5), la transcription REMPLIT le
* champ (éditable), aucun envoi automatique. */
interface Echange {
question: string;
reponse?: AssistantAnswer;
erreur?: string;
}
export default function PageAssistant() {
const t = useTokens();
const ask = useAskAssistant();
const transcrire = useTranscription();
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const [question, setQuestion] = useState('');
const [echanges, setEchanges] = useState<Echange[]>([]);
const [enregistrement, setEnregistrement] = useState(false);
const saisieRef = useRef<TextInput>(null);
const demarrerDictee = async () => {
const { granted } = await requestRecordingPermissionsAsync();
if (!granted) return;
// iOS refuse recorder.record() sans autorisation explicite de la session
// audio (RecordingDisabledException) — même correctif qu'en clôture.
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
await recorder.prepareToRecordAsync();
recorder.record();
setEnregistrement(true);
};
const terminerDictee = async () => {
await recorder.stop();
await setAudioModeAsync({ allowsRecording: false });
setEnregistrement(false);
const uri = recorder.uri;
if (!uri) return;
transcrire.mutate(
{ uri, nom: 'question.m4a', mime: 'audio/m4a' },
{
onSuccess: (resultat) => setQuestion(resultat.text),
// Loi 09-08 (D5) : l'audio ne survit jamais à l'appel, succès ou pas.
onSettled: () => void FileSystem.deleteAsync(uri, { idempotent: true }),
},
);
};
const envoyer = () => {
const q = question.trim();
if (q.length < 3 || ask.isPending) return;
setQuestion('');
setEchanges((liste) => [...liste, { question: q }]);
ask.mutate(
{ question: q },
{
onSuccess: (reponse) =>
setEchanges((liste) =>
liste.map((e, i) => (i === liste.length - 1 ? { ...e, reponse } : e)),
),
onError: (erreur) =>
setEchanges((liste) =>
liste.map((e, i) => (i === liste.length - 1 ? { ...e, erreur: erreur.message } : e)),
),
},
);
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Assistant" />
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 10, paddingBottom: 8 }}>
{echanges.length === 0 ? (
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Posez une question sur vos notices, vos historiques d'intervention ou vos
procédures — chaque réponse cite ses sources. Quand le corpus ne porte pas la
réponse, l'assistant le dit au lieu d'inventer.
</Text>
) : null}
{echanges.map((e, i) => (
<View key={i} style={{ gap: 8 }}>
<Text
style={{
alignSelf: 'flex-end',
backgroundColor: t.primaire,
color: '#fff',
borderRadius: 13,
borderBottomRightRadius: 3,
paddingHorizontal: 12,
paddingVertical: 9,
maxWidth: '85%',
fontFamily: 'Manrope_600SemiBold',
fontSize: 12.5,
}}
>
{e.question}
</Text>
{e.reponse ? (
<Reponse reponse={e.reponse} t={t} surReformuler={() => saisieRef.current?.focus()} />
) : e.erreur ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{e.erreur}
</Text>
) : (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Recherche dans le corpus…
</Text>
)}
</View>
))}
</ScrollView>
{enregistrement ? (
<View
style={{
alignItems: 'center',
gap: 8,
borderWidth: 1.5,
borderColor: t.bordure,
borderRadius: 13,
backgroundColor: t.surface,
padding: 16,
}}
>
<View
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: t.prioBloque,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 18 }}>🎙</Text>
</View>
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
Enregistrement en cours…
</Text>
<BoutonTel
libelle="■ Terminer la dictée"
surAppui={() => void terminerDictee()}
/>
</View>
) : (
<View
style={{
gap: 8,
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 10,
backgroundColor: t.surface,
padding: 10,
}}
>
{/* Zone pleine largeur, multiligne : la question dictée doit se
* lire EN ENTIER avant d'envoyer (trouvé en recette, 02/08
* une barre d'une ligne masquait le texte transcrit long). */}
<TextInput
ref={saisieRef}
accessibilityLabel="Poser une question"
multiline
scrollEnabled
value={transcrire.isPending ? 'Transcription' : question}
editable={!transcrire.isPending}
onChangeText={setQuestion}
maxLength={500}
placeholder="Poser une question, ou dictez avec 🎙…"
placeholderTextColor={t.encre3}
style={{
// Hauteur FIXE plutôt que maxHeight seul : sur iOS, un
// TextInput multiligne ignore parfois maxHeight et grandit
// avec le contenu, poussant/recouvrant les boutons du
// dessous (trouvé en recette, 02/08). Hauteur fixe +
// scrollEnabled = défilement interne garanti au-delà.
height: 96,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13.5,
textAlignVertical: 'top',
}}
/>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Dicter la question"
disabled={transcrire.isPending}
onPress={() => void demarrerDictee()}
style={{
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: t.primaireDoux,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 14 }}>🎙</Text>
</Pressable>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Envoyer"
desactive={question.trim().length < 3 || ask.isPending || transcrire.isPending}
surAppui={envoyer}
/>
</View>
</View>
</View>
)}
{transcrire.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 11.5 }}>
{transcrire.error.message}
</Text>
) : null}
</View>
</SafeAreaView>
);
}
function Reponse({
reponse,
t,
surReformuler,
}: {
reponse: AssistantAnswer;
t: Tokens;
surReformuler: () => void;
}) {
if (reponse.mode === 'REFUSAL') {
return (
<View
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderStyle: 'dashed',
borderRadius: 13,
padding: 12,
gap: 8,
backgroundColor: t.surface,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 12.5, color: t.encre }}>
Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer.
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre2 }}>
J'ai cherché dans {reponse.corpus.documents} document
{reponse.corpus.documents > 1 ? 's' : ''} indexé
{reponse.corpus.documents > 1 ? 's' : ''} et {reponse.corpus.reports} bilan
{reponse.corpus.reports > 1 ? 's' : ''} d'intervention : rien d'assez proche de votre
question.
</Text>
<BoutonTel libelle="Reformuler ma question" variante="contour" surAppui={surReformuler} />
</View>
);
}
return (
<View
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 13,
borderBottomLeftRadius: 3,
padding: 11,
gap: 9,
}}
>
{reponse.mode === 'GENERATED' && reponse.answer ? (
<Text style={{ fontFamily: 'Manrope_500Medium', fontSize: 12.5, color: t.encre }}>
{reponse.answer}
</Text>
) : (
<Text style={{ fontFamily: 'Manrope_500Medium', fontSize: 12.5, color: t.encre }}>
Voici ce que portent vos sources les extraits sont cités tels quels.
</Text>
)}
<View
style={{ gap: 7, borderTopWidth: 1, borderTopColor: t.bordure, paddingTop: 9 }}
>
{reponse.excerpts.map((ex, i) => (
<Source key={`${ex.locator}-${i}`} ex={ex} no={i + 1} t={t} />
))}
</View>
<View
style={{
flexDirection: 'row',
gap: 6,
backgroundColor: t.safranDoux,
borderRadius: 9,
padding: 8,
}}
>
<Text style={{ color: t.alerte, fontSize: 10.5, fontFamily: 'Manrope_600SemiBold', flex: 1 }}>
L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.
</Text>
</View>
</View>
);
}
function Source({ ex, no, t }: { ex: AssistantExcerpt; no: number; t: Tokens }) {
const ouvrir = () => {
if (ex.sourceType === 'DOCUMENT' && ex.documentId) router.push(`/bibliotheque/${ex.documentId}`);
else if (ex.workOrderId) router.push(`/ot/${ex.workOrderId}`);
};
return (
<Pressable
accessibilityRole="button"
onPress={ouvrir}
style={{ backgroundColor: t.surface2, borderRadius: 9, padding: 9, gap: 3 }}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 7 }}>
<Text
style={{
width: 16,
height: 16,
borderRadius: 5,
backgroundColor: t.primaireDoux,
color: t.primaire,
fontSize: 10,
fontFamily: 'Manrope_800ExtraBold',
textAlign: 'center',
lineHeight: 16,
}}
>
{no}
</Text>
<Text
numberOfLines={1}
style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 11.5, color: t.encre }}
>
{ex.title}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 10.5, color: t.encre2, marginLeft: 23 }}>
{ex.sourceType === 'DOCUMENT' ? 'Bibliothèque' : 'Historique'} · {ex.locator}
</Text>
<Text
style={{
fontFamily: 'Manrope_500Medium',
fontStyle: 'italic',
fontSize: 11,
color: t.encre2,
marginLeft: 23,
borderLeftWidth: 2,
borderLeftColor: t.safran,
paddingLeft: 7,
}}
>
« {ex.content} »
</Text>
<Text style={{ marginLeft: 23, color: t.primaire, fontFamily: 'Manrope_700Bold', fontSize: 11 }}>
{ex.sourceType === 'DOCUMENT' ? 'Voir le document' : "Ouvrir l'OT"}
</Text>
</Pressable>
);
}

View File

@@ -0,0 +1,52 @@
import { useLocalSearchParams } from 'expo-router';
import { ScrollView, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useDocuments } from '@/api/ressources';
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
const KIND_LABEL: Record<string, string> = {
NOTICE: 'Notice',
CERTIFICATE: 'Certificat',
PHOTO: 'Photo',
OTHER: 'Autre',
};
function taille(octets: number): string {
if (octets < 1024) return `${octets} o`;
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
return `${(octets / (1024 * 1024)).toFixed(1)} Mo`;
}
/** Fiche document — R6.5. Métadonnées seules (D2, comme la bibliothèque
* R6.3) : l'ouverture du fichier lui-même demande `expo-sharing`, pas
* encore une dépendance du projet — ouvrir depuis le web pour l'instant.
* Destination de « Voir le document » depuis les sources de l'Assistant. */
export default function PageFicheDocument() {
const t = useTokens();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: documents } = useDocuments();
const doc = (documents ?? []).find((d) => d.id === id);
if (!doc) return null;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={doc.fileName} />
<Carte titre="Document">
<LigneInfo nom="Type" valeur={KIND_LABEL[doc.kind] ?? doc.kind} />
<LigneInfo nom="Taille" valeur={taille(doc.size)} />
<LigneInfo nom="Rattaché à" valeur={doc.assetReference ?? doc.workOrderReference ?? '—'} />
<LigneInfo nom="Ajouté par" valeur={doc.uploadedByName ?? '—'} />
<LigneInfo
nom="Ajouté le"
valeur={new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(doc.createdAt))}
/>
</Carte>
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5, textAlign: 'center' }}>
Ouvrir le fichier se fait depuis le web pour l'instant.
</Text>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,83 @@
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useDocuments } from '@/api/ressources';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
const ICONE_TYPE: Record<string, string> = {
NOTICE: '📘',
CERTIFICATE: '📜',
PHOTO: '🖼',
OTHER: '📄',
};
function taille(octets: number): string {
if (octets < 1024) return `${octets} o`;
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
return `${(octets / (1024 * 1024)).toFixed(1)} Mo`;
}
/** Fichiers — R6.3 (Ressources). Consultation des métadonnées seulement
* (D4) : l'ouverture/téléchargement d'un document sur mobile demande un
* flux authentifié (l'API le streame, MinIO n'est jamais exposé) suivi
* d'un partage natif — nécessite `expo-sharing`, pas encore une dépendance
* du projet ; ajoutée dans une prochaine étape plutôt que d'introduire une
* nouvelle dépendance native (et donc un nouveau build) dans cette passe.
* En attendant : ouvrir depuis le web. */
export default function PageBibliotheque() {
const t = useTokens();
const { data: documents } = useDocuments();
const fichiers = [...(documents ?? [])].sort(
(a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt),
);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Fichiers" />
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 11.5, color: t.encre3 }}>
Consultation seulement l'ouverture se fait depuis le web pour l'instant.
</Text>
<FlatList
data={fichiers}
keyExtractor={(d) => d.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun document accessible.
</Text>
}
renderItem={({ item: d }) => (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/bibliotheque/${d.id}`)}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
padding: 12,
}}
>
<Text style={{ fontSize: 20 }}>{ICONE_TYPE[d.kind] ?? '📄'}</Text>
<View style={{ flex: 1, gap: 1 }}>
<Text numberOfLines={1} style={{ fontFamily: 'Manrope_700Bold', fontSize: 13, color: t.encre }}>
{d.fileName}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre2 }}>
{[d.assetReference && `Asc. ${d.assetReference}`, d.workOrderReference, taille(d.size)]
.filter(Boolean)
.join(' · ')}
</Text>
</View>
</Pressable>
)}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -9,12 +9,18 @@ import {
TextInput,
View,
} from 'react-native';
import type { RoleName } from '@siop/shared';
import { ongletAccueil } from '@/auth/roles';
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session';
import { useTokens } from '@/theme/tokens';
/** Connexion mobile — mêmes règles que le web : formulaire e-mail/mot de
* passe, et le sélecteur démo (< 3 s pour changer de rôle, ADR-002)
* UNIQUEMENT si l'API l'expose. */
* UNIQUEMENT si l'API l'expose. Depuis R6, l'app n'est plus réservée au
* Technicien : on route vers l'onglet d'accueil du RÔLE réel (« Ma
* journée » pour le terrain, « Accueil » sinon) — jamais un onglet figé
* (trouvé en recette : un Demandeur atterrissait sur « Ma journée »,
* l'écran du Technicien, faute de lire le rôle retourné par la connexion). */
export default function PageConnexion() {
const t = useTokens();
const [email, setEmail] = useState('');
@@ -23,7 +29,7 @@ export default function PageConnexion() {
const login = useLogin();
const demo = useDemoLogin();
const entrer = () => router.replace('/(tabs)/journee');
const entrer = (role: RoleName) => router.replace(`/(tabs)/${ongletAccueil(role)}`);
return (
<KeyboardAvoidingView
@@ -36,12 +42,9 @@ export default function PageConnexion() {
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}>
SIOP
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre3 }}>
Technicien
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', color: t.encre2, marginBottom: 8 }}>
Vos ordres de travail, sur le terrain même sans réseau.
Vos interventions et votre suivi, que vous soyez.
</Text>
<View style={{ gap: 10 }}>
@@ -84,7 +87,10 @@ export default function PageConnexion() {
accessibilityRole="button"
disabled={!email || !motDePasse || login.isPending}
onPress={() =>
login.mutate({ email, password: motDePasse }, { onSuccess: entrer })
login.mutate(
{ email, password: motDePasse },
{ onSuccess: (res) => entrer(res.user.role.name) },
)
}
style={{
backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire,
@@ -122,7 +128,9 @@ export default function PageConnexion() {
key={c.id}
accessibilityRole="button"
disabled={demo.isPending}
onPress={() => demo.mutate(c.id, { onSuccess: entrer })}
onPress={() =>
demo.mutate(c.id, { onSuccess: (res) => entrer(res.user.role.name) })
}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,

View File

@@ -0,0 +1,19 @@
import { View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { PanneauDemandes } from '@/composants/panneau-demandes';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Écran poussé depuis le Menu (Administrateur/Gestionnaire/Dispatcher/Vue
* seule — pas d'onglet dédié pour eux, contrairement au Demandeur). */
export default function PageDemandesPoussee() {
const t = useTokens();
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Demandes" />
<PanneauDemandes />
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,18 @@
import { router } from 'expo-router';
import { ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { FormulaireDemande } from '@/composants/formulaire-demande';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
export default function PageNouvelleDemandePoussee() {
const t = useTokens();
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre="Nouvelle demande" />
<FormulaireDemande surSucces={() => (router.canGoBack() ? router.back() : router.replace('/demandes'))} />
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -1,9 +1,11 @@
import { Redirect } from 'expo-router';
import { ActivityIndicator, View } from 'react-native';
import { ongletAccueil } from '@/auth/roles';
import { useMe } from '@/auth/session';
import { useTokens } from '@/theme/tokens';
/** Aiguillage : session valide → Ma journée ; sinon → connexion.
/** Aiguillage : session valide → l'onglet d'accueil du rôle (Ma journée
* pour le terrain, Accueil pour les autres) ; sinon → connexion.
* (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */
export default function Aiguillage() {
const t = useTokens();
@@ -16,5 +18,9 @@ export default function Aiguillage() {
</View>
);
}
return me ? <Redirect href="/(tabs)/journee" /> : <Redirect href="/connexion" />;
return me ? (
<Redirect href={`/(tabs)/${ongletAccueil(me.role.name)}`} />
) : (
<Redirect href="/connexion" />
);
}

View File

@@ -1,6 +1,16 @@
// 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,
@@ -9,9 +19,15 @@ import {
type BilanField,
type BilanSuggestion,
type ReportUpsert,
type WorkOrderDetail,
} from '@siop/shared';
import { useQueryClient } from '@tanstack/react-query';
import { useReferenceValues, useSuggestionBilan, useWorkOrder } from '@/api/exploitation';
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';
@@ -97,6 +113,7 @@ export default function PageCloture() {
<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 }))}
/>
@@ -160,28 +177,71 @@ export default function PageCloture() {
/** É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. */
* — 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={setDescription}
onChangeText={(v) => {
setDescription(v);
setJointe(false);
}}
maxLength={2000}
placeholder="Décrivez la panne et ce que vous avez fait…"
placeholder="Décrivez la panne et ce que vous avez fait, ou dictez avec 🎙…"
placeholderTextColor={t.encre3}
accessibilityLabel="Décrire pour suggérer"
style={{
@@ -197,6 +257,43 @@ function CarteSuggestion({
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

View File

@@ -0,0 +1,94 @@
import { router } from 'expo-router';
import { FlatList, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useUsers } from '@/api/pilotage';
import { usePermissions } from '@/auth/use-permissions';
import { BoutonTel, EnteteFiche } from '@/composants/ui';
import { useTokens, type Tokens } from '@/theme/tokens';
const LABEL_STATUT: Record<'active' | 'invited' | 'disabled', string> = {
active: 'Actif',
invited: 'Invitation envoyée',
disabled: 'Désactivé',
};
/** Personnes & équipes — R6.4 (Pilotage). Consultation + « Inviter » (D4) —
* taux horaire, équipes, rôles restent gérés depuis le web pour l'instant. */
export default function PagePersonnes() {
const t = useTokens();
const { data: users } = useUsers();
const { can } = usePermissions();
const personnes = [...(users ?? [])].sort((a, b) => a.displayName.localeCompare(b.displayName));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Personnes" />
{can('PEOPLE_TEAMS', 'create') ? (
<BoutonTel libelle="+ Inviter" surAppui={() => router.push('/personnes/inviter')} />
) : null}
<FlatList
data={personnes}
keyExtractor={(u) => u.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Personne accessible.
</Text>
}
renderItem={({ item: u }) => <LignePersonne utilisateur={u} t={t} />}
/>
</View>
</SafeAreaView>
);
}
function LignePersonne({
utilisateur: u,
t,
}: {
utilisateur: { id: string; displayName: string; email: string; role: { name: string }; teams: { name: string }[]; status: 'active' | 'invited' | 'disabled' };
t: Tokens;
}) {
const [enc, fond] =
u.status === 'active'
? [t.stTermine, t.stTermineFond]
: u.status === 'invited'
? [t.stAttente, t.stAttenteFond]
: [t.stAnnule, t.stAnnuleFond];
return (
<View
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: 14, color: t.encre }}>
{u.displayName}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enc,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
}}
>
{LABEL_STATUT[u.status]}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
{u.role.name}
{u.teams.length ? ` · ${u.teams.map((eq) => eq.name).join(', ')}` : ''}
</Text>
</View>
);
}

View File

@@ -0,0 +1,99 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { ScrollView, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useInviteUser, useRoles } from '@/api/pilotage';
import { BoutonTel, ChoixTel, EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Invitation — R6.4. Jamais de mot de passe créé pour autrui (décision R1) :
* seul un lien d'activation valable 7 j est émis, à transmettre à la main. */
export default function PageInviter() {
const t = useTokens();
const { data: roles } = useRoles();
const invitation = useInviteUser();
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [roleId, setRoleId] = useState<string | null>(null);
const role = (roles ?? []).find((r) => r.id === roleId) ?? null;
const valide = displayName.trim().length >= 2 && /\S+@\S+\.\S+/.test(email) && !!roleId;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
<EnteteFiche titre="Inviter" />
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
Nom complet <Text style={{ color: t.danger }}>*</Text>
</Text>
<TextInput
accessibilityLabel="Nom complet"
value={displayName}
onChangeText={setDisplayName}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
</View>
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
E-mail <Text style={{ color: t.danger }}>*</Text>
</Text>
<TextInput
accessibilityLabel="E-mail"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
</View>
<ChoixTel
libelle="Rôle"
requis
valeur={role ? { id: role.id, label: role.name } : null}
options={(roles ?? []).map((r) => ({ id: r.id, label: r.name }))}
surChoix={setRoleId}
/>
{invitation.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{invitation.error.message}
</Text>
) : null}
<BoutonTel
libelle="Émettre le lien d'activation"
desactive={!valide || invitation.isPending}
surAppui={() =>
invitation.mutate(
{ displayName: displayName.trim(), email: email.trim(), roleId: roleId! },
{
onSuccess: (r) =>
router.replace({
pathname: '/personnes/lien',
params: { token: r.activationToken, expiresAt: r.expiresAt },
}),
},
)
}
/>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,43 @@
import { router, useLocalSearchParams } from 'expo-router';
import { ScrollView, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { WEB_URL } from '@/api/client';
import { BoutonTel, Carte, EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Lien d'activation émis — R6.4. Pas de presse-papiers natif sur mobile
* pour l'instant (`expo-clipboard` demanderait un nouveau build natif,
* même raisonnement que l'ouverture de document en Ressources) : le texte
* est sélectionnable (`Text selectable`, RN natif, aucune dépendance) —
* appui long → copier, comme partout sur le téléphone. */
export default function PageLienActivation() {
const t = useTokens();
const { token, expiresAt } = useLocalSearchParams<{ token: string; expiresAt: string }>();
const url = `${WEB_URL}/activation?token=${token}`;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
<EnteteFiche titre="Lien d'activation émis" />
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre2 }}>
Transmettez ce lien à la personne (valable jusqu'au{' '}
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(expiresAt))}) :
</Text>
<Carte>
<Text
selectable
style={{ fontFamily: 'Manrope_700Bold', fontSize: 13, color: t.primaire }}
>
{url}
</Text>
</Carte>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre3 }}>
Appui long sur le lien pour le sélectionner et le copier.
</Text>
<View style={{ marginTop: 8 }}>
<BoutonTel libelle="Terminé" surAppui={() => router.replace('/personnes')} />
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,99 @@
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, type AssetStatus } from '@siop/shared';
import { useAssets, useLocations } from '@/api/exploitation';
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Fiche site — R6.2. Consultation seule (D4) : identité, zones, ascenseurs
* du site. Pas de carte interactive ni d'édition sur mobile pour l'instant
* (réservé au web) — même filtre appareils↔site que fiche-site.tsx (web) :
* par nom, la liste étant déjà plate côté serveur. */
export default function PageFicheSite() {
const t = useTokens();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: locations } = useLocations();
const { data: assets } = useAssets();
const site = (locations ?? []).find((l) => l.id === id);
if (!site) return null;
const zones = (locations ?? []).filter((l) => l.parentId === site.id);
const appareils = (assets ?? []).filter((a) => a.siteName === site.name);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={site.name} />
<Carte titre="Identité">
<LigneInfo nom="Adresse" valeur={[site.address, site.city].filter(Boolean).join(' — ') || '—'} />
<LigneInfo nom="Gardien" valeur={site.guardianName ?? '—'} />
{site.guardianPhone ? <LigneInfo nom="Téléphone" valeur={site.guardianPhone} /> : null}
<LigneInfo nom="Client / syndic" valeur={site.partnerName ?? '—'} />
<LigneInfo nom="Appareils" valeur={String(site.assetCount)} />
</Carte>
{zones.length ? (
<Carte titre="Emplacements">
{zones.map((z) => (
<LigneInfo
key={z.id}
nom={z.name}
valeur={appareils.filter((a) => a.locationId === z.id).map((a) => a.reference).join(' · ') || '—'}
/>
))}
</Carte>
) : null}
<Carte titre={`Ascenseurs du site (${appareils.length})`}>
{appareils.map((a) => (
<Pressable
key={a.id}
accessibilityRole="button"
onPress={() => router.push(`/ascenseur/${a.id}`)}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 3 }}
>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.primaire }}>
{a.reference}
</Text>
<Text
numberOfLines={1}
style={{ flex: 1, fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre }}
>
{a.brand} {a.model ?? ''}
</Text>
<ChipStatutAppareil statut={a.status} t={t} />
</Pressable>
))}
{!appareils.length ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Aucun appareil rattaché.
</Text>
) : null}
</Carte>
</ScrollView>
</SafeAreaView>
);
}
function ChipStatutAppareil({ statut, t }: { statut: AssetStatus; t: Tokens }) {
const enService = statut === 'IN_SERVICE';
return (
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enService ? t.stTermine : t.stAttente,
backgroundColor: enService ? t.stTermineFond : t.stAttenteFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{ASSET_STATUS_LABELS[statut]}
</Text>
);
}

View File

@@ -0,0 +1,69 @@
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocations } from '@/api/exploitation';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Sites — R6.2 (Parc). Liste plate → sites de premier niveau seulement
* (une zone n'a jamais de fiche propre, elle suit son site — même règle
* que le web, sites.tsx). */
export default function PageSites() {
const t = useTokens();
const { data: locations } = useLocations();
const sites = (locations ?? []).filter((l) => l.parentId === null);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Sites" />
<FlatList
data={sites}
keyExtractor={(s) => s.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun site accessible.
</Text>
}
renderItem={({ item: s }) => (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/sites/${s.id}`)}
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: 14, color: t.encre }}>
{s.name}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: t.encre2,
backgroundColor: t.surface2,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
}}
>
{s.assetCount} appareil{s.assetCount > 1 ? 's' : ''}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
{[s.address, s.city].filter(Boolean).join(' — ') || '—'}
</Text>
</Pressable>
)}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,130 @@
import { useState } from 'react';
import { Pressable, ScrollView, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAnalyticsSummary } from '@/api/pilotage';
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
const fmtMAD = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'MAD', maximumFractionDigits: 0 });
const PERIODES = [3, 6, 12] as const;
/** Statistiques — R6.4 (Pilotage). Maquette écran 7 : les mêmes chiffres
* que `/analytics/summary` (web), en cartes plutôt qu'en graphes denses
* (D4) — coût du mois, taux préventif, pannes par organe. */
export default function PageStatistiques() {
const t = useTokens();
const [periode, setPeriode] = useState<(typeof PERIODES)[number]>(12);
const { data } = useAnalyticsSummary(periode);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre="Statistiques" />
<View style={{ flexDirection: 'row', gap: 8 }}>
{PERIODES.map((p) => (
<Pressable
key={p}
accessibilityRole="button"
onPress={() => setPeriode(p)}
style={{
flex: 1,
paddingVertical: 8,
borderRadius: 8,
alignItems: 'center',
backgroundColor: periode === p ? t.primaire : t.surface,
borderWidth: 1,
borderColor: periode === p ? t.primaire : t.bordure,
}}
>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 12.5,
color: periode === p ? '#fff' : t.encre2,
}}
>
{p} mois
</Text>
</Pressable>
))}
</View>
{data ? (
<>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.primaire }}>
{fmtMAD.format(data.monthCost)}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
Coût du mois
</Text>
</View>
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
{data.preventiveRate != null ? `${Math.round(data.preventiveRate * 100)} %` : '—'}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
Taux préventif
</Text>
</View>
</View>
<Carte titre={`OT clôturés · ${data.months} mois`}>
<LigneInfo nom="Total" valeur={String(data.closed.total)} />
<LigneInfo nom="Dont préventif" valeur={String(data.closed.preventive)} />
<LigneInfo
nom="Délai moyen"
valeur={data.avgResolutionDays != null ? `${data.avgResolutionDays.toFixed(1)} j` : '—'}
/>
</Carte>
<Carte titre={`Pannes par organe · ${data.months} mois`}>
{data.failuresByComponent.slice(0, 5).map((f) => (
<LigneInfo key={f.label} nom={f.label} valeur={String(f.count)} />
))}
{!data.failuresByComponent.length ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Aucune panne codée sur la période.
</Text>
) : null}
</Carte>
<Carte titre="Top équipements en coût">
{data.topAssets.slice(0, 5).map((a) => (
<LigneInfo
key={a.reference}
nom={`Asc. ${a.reference}${a.siteName}`}
valeur={fmtMAD.format(a.total)}
/>
))}
{!data.topAssets.length ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Aucune donnée sur la période.
</Text>
) : null}
</Carte>
</>
) : null}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,65 @@
import { router, useLocalSearchParams } from 'expo-router';
import { ScrollView, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePart } from '@/api/ressources';
import { usePermissions } from '@/auth/use-permissions';
import { BoutonTel, Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
const fmt = new Intl.NumberFormat('fr-FR');
const fmtMAD = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'MAD' });
/** Fiche pièce — R6.3. Consultation + une action courante (D4) : commander,
* pré-rempli depuis cette fiche, comme la maquette (écran 6). Les
* mouvements de stock (entrée manuelle, ajustement) restent au web. */
export default function PageFichePiece() {
const t = useTokens();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: piece } = usePart(id);
const { can } = usePermissions();
if (!piece) return null;
const peutCommander = can('PURCHASE_ORDERS', 'create') && !!piece.supplierId;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={piece.designation} />
<Carte titre="Identité">
<LigneInfo nom="Référence" valeur={piece.reference} />
<LigneInfo
nom="Stock"
valeur={
<Text
style={{
color: piece.belowThreshold ? t.danger : t.encre,
fontFamily: 'Manrope_700Bold',
fontSize: 12.5,
}}
>
{fmt.format(piece.stock)}
</Text>
}
/>
<LigneInfo nom="Seuil d'alerte" valeur={fmt.format(piece.threshold)} />
<LigneInfo nom="Fournisseur" valeur={piece.supplierName ?? '—'} />
<LigneInfo
nom="Dernier prix"
valeur={piece.lastUnitPrice != null ? fmtMAD.format(piece.lastUnitPrice) : '—'}
/>
{piece.compatible ? <LigneInfo nom="Compatible" valeur={piece.compatible} /> : null}
</Carte>
{peutCommander ? (
<BoutonTel libelle="Commander" surAppui={() => router.push(`/stock/${piece.id}/commander`)} />
) : null}
{!piece.supplierId && can('PURCHASE_ORDERS', 'create') ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12, textAlign: 'center' }}>
Aucun fournisseur associé commande depuis le web.
</Text>
) : null}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,130 @@
import { router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { ScrollView, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { PurchaseOrderDto } from '@siop/shared';
import { useCreatePurchaseOrder, usePart } from '@/api/ressources';
import { BoutonTel, Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
const fmtMAD = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'MAD' });
/** Nouveau BC pré-rempli depuis l'alerte stock — maquette écran 6. Une
* seule ligne (cette pièce) : le bon de commande multi-lignes détaillé
* reste au web (D4). Confirmation explicite après création (trouvé en
* recette 02/08 : la navigation instantanée en cas de succès ne donnait
* aucun retour visible — deux BC créés côté serveur sans que le référent
* s'en aperçoive dans l'app). */
export default function PageCommander() {
const t = useTokens();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: piece } = usePart(id);
const creation = useCreatePurchaseOrder();
const manquant = piece ? Math.max(piece.threshold - piece.stock, 1) : 1;
const [quantite, setQuantite] = useState(String(manquant));
const [prix, setPrix] = useState('');
const [cree, setCree] = useState<PurchaseOrderDto | null>(null);
if (!piece || !piece.supplierId) return null;
if (cree) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
<EnteteFiche titre="BC créé" />
<Carte titre="✓ Bon de commande enregistré">
<LigneInfo nom="Référence" valeur={cree.reference} />
<LigneInfo nom="Fournisseur" valeur={cree.supplierName} />
<LigneInfo nom="Total" valeur={fmtMAD.format(cree.total)} />
<LigneInfo nom="Statut" valeur="Brouillon" />
</Carte>
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5, textAlign: 'center' }}>
L'envoi au fournisseur se fait depuis le web pour l'instant.
</Text>
<BoutonTel
libelle="Terminé"
surAppui={() => (router.canGoBack() ? router.back() : router.replace('/stock'))}
/>
</ScrollView>
</SafeAreaView>
);
}
const prixDefaut = piece.lastUnitPrice != null ? String(piece.lastUnitPrice) : '';
const prixSaisi = prix || prixDefaut;
const qte = Number(quantite);
const pu = Number(prixSaisi);
const valide = qte > 0 && pu >= 0 && Number.isFinite(qte) && Number.isFinite(pu);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre="Nouveau BC" />
<Carte titre="Ligne">
<LigneInfo nom="Fournisseur" valeur={piece.supplierName ?? '—'} />
<LigneInfo nom="Pièce" valeur={`${piece.designation} (${piece.reference})`} />
</Carte>
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>Quantité</Text>
<TextInput
accessibilityLabel="Quantité"
keyboardType="numeric"
value={quantite}
onChangeText={setQuantite}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
</View>
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
Prix unitaire (MAD)
</Text>
<TextInput
accessibilityLabel="Prix unitaire"
keyboardType="numeric"
placeholder={prixDefaut || '0'}
placeholderTextColor={t.encre3}
value={prix}
onChangeText={setPrix}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
</View>
{creation.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{creation.error.message}
</Text>
) : null}
<BoutonTel
libelle="Créer le BC"
desactive={!valide || creation.isPending}
surAppui={() =>
creation.mutate(
{
supplierId: piece.supplierId!,
lines: [{ partId: piece.id, quantity: qte, unitPrice: pu }],
},
{ onSuccess: (bc) => setCree(bc) },
)
}
/>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,90 @@
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useParts } from '@/api/ressources';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
const fmt = new Intl.NumberFormat('fr-FR');
/** Stock — R6.3 (Ressources). Sous-seuil en tête, comme le web (stock.tsx) —
* ce qui demande une action passe avant le reste. */
export default function PageStock() {
const t = useTokens();
const { data: parts } = useParts();
const pieces = [...(parts ?? [])].sort((a, b) => {
if (a.belowThreshold !== b.belowThreshold) return a.belowThreshold ? -1 : 1;
return a.designation.localeCompare(b.designation);
});
const sousSeuil = pieces.filter((p) => p.belowThreshold).length;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche
titre="Stock & achats"
apres={
sousSeuil ? (
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 10.5,
color: t.danger,
backgroundColor: t.prioBloqueFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
}}
>
{sousSeuil} sous seuil
</Text>
) : undefined
}
/>
<FlatList
data={pieces}
keyExtractor={(p) => p.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucune pièce accessible.
</Text>
}
renderItem={({ item: p }) => (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/stock/${p.id}`)}
style={{
backgroundColor: t.surface,
borderColor: p.belowThreshold ? t.danger : t.bordure,
borderWidth: p.belowThreshold ? 1.5 : 1,
borderRadius: 12,
padding: 12,
gap: 2,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{p.designation}
</Text>
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 13,
color: p.belowThreshold ? t.danger : t.encre,
}}
>
{fmt.format(p.stock)}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
{p.reference} · seuil {fmt.format(p.threshold)}
{p.supplierName ? ` · ${p.supplierName}` : ''}
</Text>
</Pressable>
)}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,48 @@
import { useLocalSearchParams } from 'expo-router';
import { ScrollView, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePartners } from '@/api/ressources';
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Fiche tiers — R6.3, ajoutée en recette (02/08). Consultation seule (D4) :
* identité, contact, activité (BC en cours pour un fournisseur, sites
* rattachés pour un client/syndic) — déjà connu de la liste, pas de
* requête supplémentaire. Création/édition restent au web. */
export default function PageFicheTiers() {
const t = useTokens();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: partners } = usePartners();
const p = (partners ?? []).find((x) => x.id === id);
if (!p) return null;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={p.name} />
<Carte titre="Identité">
<LigneInfo nom="Type" valeur={p.kind === 'SUPPLIER' ? 'Fournisseur' : 'Client'} />
<LigneInfo nom="Statut" valeur={p.isActive ? 'Actif' : 'Désactivé'} />
<LigneInfo nom="Contact" valeur={p.contactName ?? '—'} />
<LigneInfo nom="Téléphone" valeur={p.phone ?? '—'} />
<LigneInfo nom="E-mail" valeur={p.email ?? '—'} />
<LigneInfo nom="Ville" valeur={p.city ?? '—'} />
</Carte>
{p.kind === 'SUPPLIER' ? (
<Carte titre="Achats">
<LigneInfo nom="BC en cours" valeur={String(p.openOrders)} />
</Carte>
) : null}
{p.siteNames.length ? (
<Carte titre="Sites rattachés">
{p.siteNames.map((s) => (
<Text key={s} style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 12.5, color: t.encre }}>
{s}
</Text>
))}
</Carte>
) : null}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,72 @@
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePartners } from '@/api/ressources';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Tiers — R6.3 (Ressources). Consultation seule sur mobile (D4) : la
* création/édition de fournisseurs et clients reste au web pour l'instant.
* Fiche détail ajoutée en recette (02/08) : le tap sans réaction se lisait
* comme un écran cassé plutôt que délibérément simple. */
export default function PageTiers() {
const t = useTokens();
const { data: partners } = usePartners();
const tiers = [...(partners ?? [])].sort((a, b) => a.name.localeCompare(b.name));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Tiers" />
<FlatList
data={tiers}
keyExtractor={(p) => p.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun tiers accessible.
</Text>
}
renderItem={({ item: p }) => (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/tiers/${p.id}`)}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
padding: 12,
gap: 2,
opacity: p.isActive ? 1 : 0.55,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{p.name}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: p.kind === 'SUPPLIER' ? t.stOuvert : t.stEncours,
backgroundColor: p.kind === 'SUPPLIER' ? t.stOuvertFond : t.stEncoursFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
}}
>
{p.kind === 'SUPPLIER' ? 'Fournisseur' : 'Client'}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
{[p.contactName, p.phone, p.city].filter(Boolean).join(' · ') || '—'}
{p.kind === 'SUPPLIER' && p.openOrders ? ` · ${p.openOrders} BC en cours` : ''}
</Text>
</Pressable>
)}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -11,8 +11,10 @@
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-persist-client": "^5.101.2",
"expo": "~57.0.6",
"expo-audio": "~57.0.2",
"expo-camera": "~57.0.3",
"expo-constants": "~57.0.5",
"expo-file-system": "~57.0.1",
"expo-font": "~57.0.1",
"expo-image-manipulator": "~57.0.4",
"expo-image-picker": "~57.0.4",
@@ -39,8 +41,8 @@
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "jest",

View File

@@ -0,0 +1,26 @@
import { useMutation } from '@tanstack/react-query';
import type { AssistantAsk } from '@siop/shared';
import { api } from './client';
/** Hook R6.5 — l'assistant passe par l'API NestJS (le service IA n'est
* jamais appelé depuis l'app, ADR-004 §4). Le 503 est un état ATTENDU du
* contrat (service éteint ou pas encore déployé) — même message que le web. */
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
if (res.response.status === 503) {
throw new Error('Assistant indisponible pour le moment — réessayez dans un instant.');
}
if (res.error || res.data === undefined) {
const message =
(res.error as { message?: string } | undefined)?.message ??
`Le serveur a répondu ${res.response.status}`;
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
}
return res.data;
}
export function useAskAssistant() {
return useMutation({
mutationFn: async (body: AssistantAsk) => unwrap(await api.POST('/assistant/ask', { body })),
});
}

View File

@@ -7,6 +7,11 @@ import { lireJeton } from './jeton';
* EXPO_PUBLIC_API_URL pointe l'API (IP LAN pour Expo Go sur téléphone). */
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
/** Origine du web (R6.4 — lien d'activation « Personnes », même page
* `/activation?token=…` que le web, il n'existe pas d'équivalent mobile).
* À définir par environnement comme EXPO_PUBLIC_API_URL. */
export const WEB_URL = process.env.EXPO_PUBLIC_WEB_URL ?? 'http://localhost:5173';
export const api = createClient<paths>({ baseUrl: API_URL });
api.use({

View File

@@ -1,6 +1,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared';
import { api, unwrap } from './client';
import type {
ChecklistState,
ReportUpsert,
RequestApprove,
RequestCreate,
RequestReject,
WorkOrderStatus,
} from '@siop/shared';
import { api, API_URL, unwrap } from './client';
import { lireJeton } from './jeton';
/** 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). */
@@ -20,9 +28,17 @@ export function useWorkOrder(id: string) {
});
}
export function useAssets() {
export function useLocations() {
return useQuery({
queryKey: ['locations'],
queryFn: async () => (await unwrap(await api.GET('/locations'))).locations,
});
}
export function useAssets(opts?: { enabled?: boolean }) {
return useQuery({
queryKey: ['assets'],
enabled: opts?.enabled ?? true,
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
});
}
@@ -46,14 +62,70 @@ export function useDocumentsOT(workOrderId: string) {
});
}
export function useReferenceValues() {
export function useReferenceValues(opts?: { enabled?: boolean }) {
return useQuery({
queryKey: ['reference-values'],
enabled: opts?.enabled ?? true,
staleTime: 3600_000, // référentiels administrables : stables en journée
queryFn: async () => (await unwrap(await api.GET('/reference-values'))).referenceValues,
});
}
// ————— Demandes (mobile ouvert à tous les rôles) —————
// Même endpoint pour tous : l'API scope déjà « les siennes » (Demandeur) ou
// « toutes » (viewOther, ADR-003) — rien à filtrer côté app.
export function useRequests() {
return useQuery({
queryKey: ['requests'],
queryFn: async () => (await unwrap(await api.GET('/requests'))).requests,
});
}
/** Options minimales (signalement) — accessibles à tout rôle authentifié,
* y compris Demandeur qui n'a pas ASSETS.view (même trou corrigé qu'au web). */
export function useAssetOptions() {
return useQuery({
queryKey: ['asset-options'],
queryFn: async () => (await unwrap(await api.GET('/assets/options'))).options,
});
}
function useInvalideRequests() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: ['requests'] });
}
export function useCreateRequest() {
const invalide = useInvalideRequests();
return useMutation({
mutationFn: async (body: RequestCreate) => unwrap(await api.POST('/requests', { body })),
onSuccess: invalide,
});
}
export function useApproveRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...body }: RequestApprove & { id: string }) =>
unwrap(await api.POST('/requests/{id}/approve', { params: { path: { id } }, body })),
onSuccess: () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ['requests'] }),
queryClient.invalidateQueries({ queryKey: ['work-orders'] }),
]),
});
}
export function useRejectRequest() {
const invalide = useInvalideRequests();
return useMutation({
mutationFn: async ({ id, ...body }: RequestReject & { id: string }) =>
unwrap(await api.POST('/requests/{id}/reject', { params: { path: { id } }, body })),
onSuccess: invalide,
});
}
function useInvalideOT(id: string) {
const queryClient = useQueryClient();
return () =>
@@ -100,6 +172,33 @@ export function useSuggestionBilan() {
});
}
/** Dictée (R5 D5, opt-in) : multipart hors client typé, même raison que la
* photo (D5) — le fichier natif {uri, name, type} ne passe pas par `api.*`.
* L'audio ne transite qu'une fois ; rien n'est stocké côté app après. */
export function useTranscription() {
return useMutation({
mutationFn: async (fichier: { uri: string; nom: string; mime: string }) => {
const form = new FormData();
form.append('file', {
uri: fichier.uri,
name: fichier.nom,
type: fichier.mime,
} as unknown as Blob);
const jeton = await lireJeton();
const res = await fetch(`${API_URL}/assistant/transcribe`, {
method: 'POST',
headers: jeton ? { Authorization: `Bearer ${jeton}` } : undefined,
body: form,
});
if (!res.ok) {
const corps = (await res.json().catch(() => null)) as { message?: string } | null;
throw new Error(corps?.message ?? `Dictée indisponible (${res.status})`);
}
return (await res.json()) as { text: string };
},
});
}
export function useBilan(otId: string) {
const invalide = useInvalideOT(otId);
return useMutation({

View File

@@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { InvitationCreate } from '@siop/shared';
import { api, unwrap } from './client';
/** Hooks R6.4 — Pilotage (Statistiques, Personnes & équipes) : mêmes
* opérations que le web (gestion.ts/referentiel.ts), lecture + une action
* courante (inviter) sur mobile — pas la gestion complète des rôles/
* équipes/taux horaires, réservée au web (D4). */
export function useAnalyticsSummary(months: 3 | 6 | 12 = 12) {
return useQuery({
queryKey: ['analytics', months],
queryFn: async () =>
unwrap(await api.GET('/analytics/summary', { params: { query: { months: String(months) } } })),
});
}
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => (await unwrap(await api.GET('/users'))).users,
});
}
export function useRoles() {
return useQuery({
queryKey: ['roles'],
queryFn: async () => (await unwrap(await api.GET('/roles'))).roles,
});
}
export function useInviteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: InvitationCreate) =>
unwrap(await api.POST('/users/invitations', { body })),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}

View File

@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { PurchaseOrderCreate } from '@siop/shared';
import { api, unwrap } from './client';
/** Hooks R6.3 — Ressources (Stock, Tiers, Fichiers) : mêmes opérations que
* le web (gestion.ts), périmètre mobile plus étroit (D4 — consultation +
* action courante « commander », pas les flux de gestion les plus denses :
* pas de création/édition de pièce ou de tiers sur mobile pour l'instant). */
export function usePartners() {
return useQuery({
queryKey: ['partners'],
queryFn: async () => (await unwrap(await api.GET('/partners'))).partners,
});
}
export function useParts() {
return useQuery({
queryKey: ['parts'],
queryFn: async () => (await unwrap(await api.GET('/parts'))).parts,
});
}
export function usePart(id: string) {
return useQuery({
queryKey: ['parts', id],
queryFn: async () => unwrap(await api.GET('/parts/{id}', { params: { path: { id } } })),
});
}
export function useCreatePurchaseOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: PurchaseOrderCreate) =>
unwrap(await api.POST('/purchase-orders', { body })),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['purchase-orders'] }),
});
}
/** Bibliothèque complète (sans filtre appareil/OT — voir aussi
* `useDocumentsOT` dans exploitation.ts, scopée à un OT). */
export function useDocuments() {
return useQuery({
queryKey: ['documents'],
queryFn: async () => (await unwrap(await api.GET('/documents'))).documents,
});
}

View File

@@ -509,6 +509,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/assistant/transcribe": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Dictée (R5 D5, opt-in) — laudio est transcrit puis JAMAIS conservé, à relire avant tout usage */
post: operations["transcribeAudio"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/search": {
parameters: {
query?: never;
@@ -1399,6 +1416,11 @@ export interface components {
status: "active" | "invited" | "disabled";
isDemo: boolean;
hourlyRate: number | null;
assignedSites: {
/** Format: uuid */
id: string;
name: string;
}[];
}[];
};
RolesResponse: {
@@ -1424,6 +1446,7 @@ export interface components {
roleId: string;
teamIds?: string[];
phone?: string;
locationIds?: string[];
};
UserAdmin: {
/** Format: uuid */
@@ -1447,6 +1470,11 @@ export interface components {
status: "active" | "invited" | "disabled";
isDemo: boolean;
hourlyRate: number | null;
assignedSites: {
/** Format: uuid */
id: string;
name: string;
}[];
};
UserUpdate: {
displayName?: string;
@@ -1456,6 +1484,7 @@ export interface components {
teamIds?: string[];
isActive?: boolean;
hourlyRate?: number | null;
locationIds?: string[];
};
DocumentsResponse: {
documents: {
@@ -1567,6 +1596,9 @@ export interface components {
reportsIndexed: number;
chunks: number;
};
TranscriptionResult: {
text: string;
};
SearchResponse: {
workOrders: {
/** Format: uuid */
@@ -3210,6 +3242,40 @@ export interface operations {
};
};
};
transcribeAudio: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": {
/** Format: binary */
file: string;
};
};
};
responses: {
/** @description Texte transcrit — à relire (D1) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TranscriptionResult"];
};
};
/** @description Dictée non activée ou service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
globalSearch: {
parameters: {
query: {

View File

@@ -0,0 +1,26 @@
import type { RoleName } from '@siop/shared';
/** Barre d'onglets ADAPTATIVE (maquette « mobile ouvert à tous les rôles »,
* D1) : le Technicien/Technicien limité gardent exactement leurs onglets
* terrain R4 ; les autres rôles reçoivent Accueil/OT/Menu (ou une variante
* plus étroite selon leurs droits). Décidé une fois ici, pas éparpillé. */
const ROLES_TERRAIN: RoleName[] = ['Technicien', 'Technicien limité'];
export function estRoleTerrain(role: RoleName | undefined): boolean {
return !role || ROLES_TERRAIN.includes(role);
}
export function ongletsVisibles(role: RoleName | undefined): ReadonlySet<string> {
if (estRoleTerrain(role)) {
return new Set(['journee', 'scanner', 'preventif', 'synchro', 'menu']);
}
if (role === 'Demandeur') return new Set(['accueil', 'nouvelle-demande']);
if (role === 'Vue seule') return new Set(['accueil', 'menu']);
return new Set(['accueil', 'ot', 'menu']); // Administrateur, Gestionnaire, Dispatcher
}
/** Onglet sur lequel router.replace() après connexion (voir app/index.tsx). */
export function ongletAccueil(role: RoleName | undefined): 'journee' | 'accueil' {
return estRoleTerrain(role) ? 'journee' : 'accueil';
}

View File

@@ -42,6 +42,7 @@ export function useLogin() {
mutationFn: async (input: { email: string; password: string }) => {
const res = await unwrap(await api.POST('/auth/login', { body: input }));
await apres(res.accessToken);
return res; // le rôle sert à router vers le bon onglet d'accueil
},
});
}
@@ -52,6 +53,7 @@ export function useDemoLogin() {
mutationFn: async (userId: string) => {
const res = await unwrap(await api.POST('/auth/demo-login', { body: { userId } }));
await apres(res.accessToken);
return res; // le rôle sert à router vers le bon onglet d'accueil
},
});
}

View File

@@ -0,0 +1,26 @@
import type { ObjectCategory, PermissionRight } from '@siop/shared';
import { useMe } from './session';
/** Lecture UI de la matrice — même patron que le web (use-permissions.ts).
* L'API re-vérifie chaque requête (ADR-003) : ceci ne fait QUE piloter
* l'affichage (onglets, groupes du Menu, boutons d'action). */
export function usePermissions() {
const { data: me } = useMe();
const can = (category: ObjectCategory, right: PermissionRight): boolean => {
const entry = me?.permissions.find((p) => p.objectCategory === category);
if (!entry) return false;
switch (right) {
case 'view':
return entry.canView;
case 'viewOther':
return entry.canViewOther;
case 'create':
return entry.canCreate;
case 'edit':
return entry.canEdit;
case 'delete':
return entry.canDelete;
}
};
return { can };
}

View File

@@ -0,0 +1,189 @@
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.
* 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();
const creation = useCreateRequest();
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;
creation.mutate(
{ assetId, description: description.trim(), isPersonTrapped: personneBloquee },
{ onSuccess: (r) => surSucces(r.id) },
);
};
return (
<View style={{ gap: 12 }}>
<ChoixTel
libelle="Équipement"
requis
valeur={asset ? { id: asset.id, label: `${asset.reference}${asset.siteName}` } : null}
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>
</Text>
<TextInput
accessibilityLabel="Description du problème"
placeholder="Ex. : la porte ne se ferme plus au 3ᵉ étage"
placeholderTextColor={t.encre3}
multiline
value={description}
onChangeText={setDescription}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
minHeight: 64,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
textAlignVertical: 'top',
}}
/>
</View>
<Pressable
accessibilityRole="switch"
accessibilityLabel="Une personne est-elle bloquée ?"
accessibilityState={{ checked: personneBloquee }}
onPress={() => setPersonneBloquee((v) => !v)}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: personneBloquee ? t.prioBloqueFond : t.surface,
borderColor: personneBloquee ? t.danger : t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 12,
}}
>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 13,
color: personneBloquee ? t.danger : t.encre,
}}
>
Une personne est-elle bloquée ?
</Text>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 13, color: personneBloquee ? t.danger : t.encre3 }}>
{personneBloquee ? 'OUI' : 'NON'}
</Text>
</Pressable>
{creation.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{creation.error.message}
</Text>
) : null}
<BoutonTel
libelle="Envoyer la demande"
desactive={!valide || creation.isPending}
surAppui={envoyer}
/>
</View>
);
}

View File

@@ -0,0 +1,238 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { FlatList, Text, TextInput, View } from 'react-native';
import type { RequestSummary } from '@siop/shared';
import {
useApproveRequest,
useRejectRequest,
useRequests,
} from '@/api/exploitation';
import { useUsers } from '@/api/pilotage';
import { usePermissions } from '@/auth/use-permissions';
import { useTokens, type Tokens } from '@/theme/tokens';
import { BoutonTel, ChoixTel } from './ui';
/** Panneau Demandes — UN SEUL composant pour tous les rôles (maquette
* « mobile ouvert à tous les rôles », écran 5) : le Demandeur y crée et
* suit SES demandes ; Gestionnaire/Dispatcher/Administrateur y approuvent
* ou rejettent (motif obligatoire, comme au web) ; Vue seule consulte sans
* bouton. L'API renvoie déjà la liste correctement scopée (ADR-003) — ce
* composant affiche ce qu'on lui donne et ne propose que les actions
* permises par `can(...)`. */
export function PanneauDemandes() {
const t = useTokens();
const { can } = usePermissions();
const { data: requests } = useRequests();
const peutTraiter = can('REQUESTS', 'edit');
const peutCreer = can('REQUESTS', 'create');
const toutes = requests ?? [];
const aTraiter = toutes.filter((r) => r.status === 'RECEIVED').length;
return (
<View style={{ flex: 1, gap: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Demandes
</Text>
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
{aTraiter} à traiter · {toutes.length} au total
</Text>
</View>
{peutCreer ? (
<BoutonTel libelle="+ Nouvelle demande" surAppui={() => router.push('/demandes/nouvelle')} />
) : null}
<FlatList
data={toutes}
keyExtractor={(r) => r.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucune demande pour l'instant.
</Text>
}
renderItem={({ item: r }) => <CarteDemande demande={r} peutTraiter={peutTraiter} />}
/>
</View>
);
}
const STYLE_STATUT: Record<RequestSummary['status'], (t: Tokens) => [string, string]> = {
RECEIVED: (t) => [t.stAttente, t.stAttenteFond],
APPROVED: (t) => [t.stTermine, t.stTermineFond],
REJECTED: (t) => [t.stAnnule, t.stAnnuleFond],
};
const LABEL_STATUT: Record<RequestSummary['status'], string> = {
RECEIVED: 'Reçue',
APPROVED: 'Approuvée',
REJECTED: 'Rejetée',
};
function CarteDemande({ demande: r, peutTraiter }: { demande: RequestSummary; peutTraiter: boolean }) {
const t = useTokens();
const { data: users } = useUsers();
const approbation = useApproveRequest();
const rejet = useRejectRequest();
// Un seul panneau ouvert à la fois : approbation (avec assignation — ce
// n'est pas au Gestionnaire d'agir comme un technicien, il délègue) ou
// rejet (motif obligatoire). null = fermé, les deux boutons côte à côte.
const [panneau, setPanneau] = useState<'approuver' | 'rejeter' | null>(null);
const [motif, setMotif] = useState('');
const [assigneId, setAssigneId] = useState<string | null>(null);
const techniciens = (users ?? []).filter(
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
);
const technicien = techniciens.find((tt) => tt.id === assigneId) ?? null;
const [enc, fond] = STYLE_STATUT[r.status](t);
const enCours = r.status === 'RECEIVED';
return (
<View
style={{
backgroundColor: t.surface,
borderColor: r.isPersonTrapped && enCours ? t.danger : t.bordure,
borderWidth: r.isPersonTrapped && enCours ? 1.5 : 1,
borderRadius: 12,
padding: 12,
gap: 6,
}}
>
<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,
}}
>
{r.reference}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enc,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{LABEL_STATUT[r.status]}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{r.isPersonTrapped ? ' ' : ''}
{r.description}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
Asc. {r.assetReference} — {r.siteName} · {r.requesterLabel}
</Text>
{r.rejectionReason ? (
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 12, color: t.danger }}>
Motif du rejet : {r.rejectionReason}
</Text>
) : null}
{peutTraiter && enCours ? (
panneau === null ? (
<View style={{ flexDirection: 'row', gap: 8, marginTop: 4 }}>
<View style={{ flex: 1 }}>
<BoutonTel libelle="Approuver → OT" variante="vert" surAppui={() => setPanneau('approuver')} />
</View>
<View style={{ flex: 1 }}>
<BoutonTel libelle="Rejeter" variante="gris" surAppui={() => setPanneau('rejeter')} />
</View>
</View>
) : panneau === 'approuver' ? (
<View style={{ gap: 6, marginTop: 4 }}>
<ChoixTel
libelle="Assigner à"
valeur={technicien ? { id: technicien.id, label: technicien.displayName } : null}
options={techniciens.map((tt) => ({ id: tt.id, label: tt.displayName }))}
surChoix={setAssigneId}
/>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Annuler"
variante="contour"
surAppui={() => {
setPanneau(null);
setAssigneId(null);
}}
/>
</View>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Confirmer l'approbation"
variante="vert"
desactive={approbation.isPending}
surAppui={() =>
approbation.mutate(
{
id: r.id,
priority: r.isPersonTrapped ? 'PERSON_TRAPPED' : 'HIGH',
assigneeIds: assigneId ? [assigneId] : undefined,
},
{ onSuccess: () => setPanneau(null) },
)
}
/>
</View>
</View>
</View>
) : (
<View style={{ gap: 6, marginTop: 4 }}>
<TextInput
accessibilityLabel="Motif du rejet"
placeholder="Motif (lisible par le demandeur) *"
placeholderTextColor={t.encre3}
value={motif}
onChangeText={setMotif}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Annuler"
variante="contour"
surAppui={() => {
setPanneau(null);
setMotif('');
}}
/>
</View>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Confirmer le rejet"
desactive={motif.trim().length < 3}
surAppui={() =>
rejet.mutate(
{ id: r.id, reason: motif.trim() },
{ onSuccess: () => setPanneau(null) },
)
}
/>
</View>
</View>
</View>
)
) : null}
</View>
);
}

View File

@@ -1,7 +1,8 @@
import { router } from 'expo-router';
import { useState, type ReactNode } from 'react';
import { Modal, Pressable, ScrollView, Text, View } from 'react-native';
import { Alert, Modal, Pressable, ScrollView, Text, View } from 'react-native';
import { WORK_ORDER_STATUS_LABELS, type WorkOrderStatus } from '@siop/shared';
import { useHorsLigne, useLogout, useMe } from '@/auth/session';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Briques d'écran de la maquette R4 — cartes, chips, boutons, sélecteur. */
@@ -263,3 +264,76 @@ export function ChoixTel({
</View>
);
}
/** Entête commune à tous les onglets (D'où qu'on parte, on peut se
* déconnecter — trouvée manquante en recette : seule « Ma journée » avait
* ce contrôle, ailleurs l'utilisateur se sentait bloqué). Le rôle affiché
* est CELUI du compte connecté, pas un libellé figé — le mobile n'est plus
* réservé aux techniciens (voir décision élargissant le périmètre). */
export function EnteteTabs() {
const t = useTokens();
const horsLigne = useHorsLigne();
const { data: me } = useMe();
const logout = useLogout();
const seDeconnecter = () => {
Alert.alert('Se déconnecter ?', me?.displayName ? `Compte : ${me.displayName}` : undefined, [
{ text: 'Annuler', style: 'cancel' },
{
text: 'Se déconnecter',
style: 'destructive',
onPress: () => void logout().then(() => router.replace('/connexion')),
},
]);
};
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ color: t.safran, fontSize: 15 }}></Text>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>SIOP</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre3 }}>
{me?.role.name ?? '·'}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginLeft: 'auto' }}>
<Text
accessibilityLabel="État de synchronisation"
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: horsLigne ? t.stAttente : t.stTermine,
backgroundColor: horsLigne ? t.stAttenteFond : t.stTermineFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{horsLigne ? 'Hors-ligne' : 'Synchro à jour'}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Compte et déconnexion"
onPress={seDeconnecter}
hitSlop={8}
style={{
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: t.stEncours,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 10 }}>
{(me?.displayName ?? '·')
.split(' ')
.map((m) => m[0])
.join('')
.slice(0, 2)
.toUpperCase()}
</Text>
</Pressable>
</View>
</View>
);
}

View File

@@ -85,7 +85,7 @@ export function enfilerBilan(
patchDetail(queryClient, ot.id, (c) => ({
...c,
report: {
note: c.report?.note ?? null,
note: corps.note !== undefined ? corps.note : (c.report?.note ?? null),
doorState: labels.doorStateId !== undefined ? (labels.doorStateId ?? null) : (c.report?.doorState ?? null),
cabinPosition:
labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null),

View File

@@ -509,6 +509,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/assistant/transcribe": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Dictée (R5 D5, opt-in) — laudio est transcrit puis JAMAIS conservé, à relire avant tout usage */
post: operations["transcribeAudio"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/search": {
parameters: {
query?: never;
@@ -1399,6 +1416,11 @@ export interface components {
status: "active" | "invited" | "disabled";
isDemo: boolean;
hourlyRate: number | null;
assignedSites: {
/** Format: uuid */
id: string;
name: string;
}[];
}[];
};
RolesResponse: {
@@ -1424,6 +1446,7 @@ export interface components {
roleId: string;
teamIds?: string[];
phone?: string;
locationIds?: string[];
};
UserAdmin: {
/** Format: uuid */
@@ -1447,6 +1470,11 @@ export interface components {
status: "active" | "invited" | "disabled";
isDemo: boolean;
hourlyRate: number | null;
assignedSites: {
/** Format: uuid */
id: string;
name: string;
}[];
};
UserUpdate: {
displayName?: string;
@@ -1456,6 +1484,7 @@ export interface components {
teamIds?: string[];
isActive?: boolean;
hourlyRate?: number | null;
locationIds?: string[];
};
DocumentsResponse: {
documents: {
@@ -1567,6 +1596,9 @@ export interface components {
reportsIndexed: number;
chunks: number;
};
TranscriptionResult: {
text: string;
};
SearchResponse: {
workOrders: {
/** Format: uuid */
@@ -3210,6 +3242,40 @@ export interface operations {
};
};
};
transcribeAudio: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": {
/** Format: binary */
file: string;
};
};
};
responses: {
/** @description Texte transcrit — à relire (D1) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TranscriptionResult"];
};
};
/** @description Dictée non activée ou service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
globalSearch: {
parameters: {
query: {

View File

@@ -1,8 +1,9 @@
import { useState, type FormEvent } from 'react';
import type { InvitationResponse } from '@siop/shared';
import type { InvitationResponse, UserAdmin } from '@siop/shared';
import {
useCreateTeam,
useInviteUser,
useLocations,
useResendInvitation,
useRoles,
useTeams,
@@ -46,6 +47,7 @@ export default function PagePersonnes() {
<th>Rôle</th>
<th>Équipe</th>
<th>Taux horaire</th>
<th>Sites</th>
<th>Statut</th>
<th></th>
</tr>
@@ -75,6 +77,13 @@ export default function PagePersonnes() {
editable={can('PEOPLE_TEAMS', 'edit')}
/>
</td>
<td>
{u.role.name === 'Demandeur' ? (
<CelluleSites utilisateur={u} editable={can('PEOPLE_TEAMS', 'edit')} />
) : (
'—'
)}
</td>
<td>
{u.status === 'active' ? (
<span className="st termine">Actif</span>
@@ -206,6 +215,90 @@ function CelluleTaux({
);
}
/** Sites autorisés en signalement (R6.6) — un Demandeur ne choisit son
* équipement que parmi ceux de ses sites rattachés (web + mobile, même
* filtre côté API : `/assets/options`). */
function CelluleSites({
utilisateur,
editable,
}: {
utilisateur: UserAdmin;
editable: boolean;
}) {
const [modale, setModale] = useState(false);
return (
<>
<span className="num">
{utilisateur.assignedSites.map((s) => s.name).join(', ') || '—'}
{editable ? (
<Button
variant="ghost"
className="ml-1 px-2 py-0.5 text-[12px]"
aria-label="Modifier les sites rattachés"
onClick={() => setModale(true)}
>
</Button>
) : null}
</span>
<ModaleSites utilisateur={utilisateur} ouverte={modale} surFermeture={setModale} />
</>
);
}
function ModaleSites({
utilisateur,
ouverte,
surFermeture,
}: {
utilisateur: UserAdmin;
ouverte: boolean;
surFermeture: (o: boolean) => void;
}) {
const { data: locations } = useLocations();
const sites = (locations ?? []).filter((l) => l.parentId === null);
const maj = useUpdateUser();
const [coches, setCoches] = useState<string[]>(() => utilisateur.assignedSites.map((s) => s.id));
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
maj.mutate(
{ id: utilisateur.id, locationIds: coches },
{ onSuccess: () => surFermeture(false) },
);
};
return (
<Modale titre={`Sites de ${utilisateur.displayName}`} ouverte={ouverte} surFermeture={surFermeture}>
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
{sites.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-[13px]">
<input
type="checkbox"
checked={coches.includes(s.id)}
onChange={(e) =>
setCoches((c) => (e.target.checked ? [...c, s.id] : c.filter((id) => id !== s.id)))
}
/>
{s.name}
</label>
))}
</div>
<p className="note-douce">
Le signalement se limite aux sites cochés ci-dessus. Aucun site coché = aucune
restriction (accès au parc complet, comportement historique).
</p>
{maj.isError ? <p className="erreur-form" role="alert">{maj.error.message}</p> : null}
<div className="pied">
<Button onClick={() => surFermeture(false)}>Annuler</Button>
<Button type="submit" variant="prim" disabled={maj.isPending}>Enregistrer</Button>
</div>
</form>
</Modale>
);
}
function ModaleInvitation({
ouverte,
surFermeture,
@@ -217,7 +310,12 @@ function ModaleInvitation({
}) {
const { data: roles } = useRoles();
const { data: teams } = useTeams();
const { data: locations } = useLocations();
const invitation = useInviteUser();
const [roleId, setRoleId] = useState('');
const [sitesCoches, setSitesCoches] = useState<string[]>([]);
const roleName = roles?.find((r) => r.id === roleId)?.name;
const sites = (locations ?? []).filter((l) => l.parentId === null);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -229,10 +327,13 @@ function ModaleInvitation({
displayName: String(donnees.get('displayName')),
roleId: String(donnees.get('roleId')),
teamIds: teamId ? [teamId] : undefined,
locationIds: sitesCoches.length ? sitesCoches : undefined,
},
{
onSuccess: (reponse) => {
surFermeture(false);
setRoleId('');
setSitesCoches([]);
surLien(reponse);
},
},
@@ -253,7 +354,13 @@ function ModaleInvitation({
<div className="form-grille">
<div className="champ">
<label htmlFor="inv-role">Rôle *</label>
<select id="inv-role" name="roleId" required defaultValue="">
<select
id="inv-role"
name="roleId"
required
value={roleId}
onChange={(e) => setRoleId(e.target.value)}
>
<option value="" disabled>Choisir</option>
{(roles ?? []).map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
@@ -270,6 +377,30 @@ function ModaleInvitation({
</select>
</div>
</div>
{roleName === 'Demandeur' ? (
<div className="champ">
<label>Sites autorisés en signalement (optionnel)</label>
<div className="flex flex-col gap-1">
{sites.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-[13px]">
<input
type="checkbox"
checked={sitesCoches.includes(s.id)}
onChange={(e) =>
setSitesCoches((c) =>
e.target.checked ? [...c, s.id] : c.filter((id) => id !== s.id),
)
}
/>
{s.name}
</label>
))}
</div>
<p className="note-douce">
Aucun site coché = aucune restriction (accès au parc complet).
</p>
</div>
) : null}
<p className="note-douce">
La personne recevra un <b>lien d'activation valable 7 jours</b> pour choisir
son mot de passe. Aucun compte n'est actif avant cela.

View File

@@ -0,0 +1,355 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SIOP V2 — Maquette HD · Assistant mobile</title>
</head>
<body>
<!--
SIOP V2 — Maquette HD « Assistant mobile » (design-first).
À VALIDER PAR LE RÉFÉRENT AVANT TOUT CODE EXPO (principe n°1).
Suite de R6 (mobile ouvert à tous les rôles) : le Menu avait laissé
« Assistant » en « à venir » — un chat sourcé est un patron d'écran neuf,
jamais maquetté sur mobile (contrairement à Sites/Stock/Personnes qui
réutilisaient Carte/LigneInfo/EnteteFiche déjà validés). Reprend le
contenu et les règles de l'Assistant web (maquette-r5.html, écrans 1-2,
D2 « sourcé ou silencieux ») porté au format téléphone. Tokens et gabarit
téléphone repris tels quels de maquette-mobile-tous-roles.html.
Décisions proposées à validation :
D1 — Un échange à la fois (question → réponse), comme le web ; pas
d'historique de conversation multi-tours conservé entre les sessions
(même limite que le web aujourd'hui).
D2 — « Ouvrir » une source d'historique (OT) mène à la fiche OT R4 telle
quelle (déjà générique, aucune dépendance nouvelle). « Ouvrir » une
source document mène à ses MÉTADONNÉES seulement (même écran que la
bibliothèque R6.3) — l'ouverture du fichier lui-même reste au web
tant qu'`expo-sharing` n'est pas ajouté (même arbitrage que R6.3/6.4,
pas de nouvelle dépendance native au milieu d'une passe).
D3 — Le refus chiffré (« cherché dans N documents, M bilans ») et
l'avertissement « l'IA propose, vous validez » sont permanents,
identiques au web — aucune règle nouvelle, juste portée au pouce.
D4 — Assistant rejoint le groupe Pilotage du Menu pour tous les rôles
ayant WORK_ORDERS.view (même garde que le web).
D5 — La question peut être TAPÉE OU DICTÉE (référent, 02/08) : réutilise
EXACTEMENT le pipeline déjà validé (ADR-004 §5, faster-whisper local,
opt-in, audio jamais persisté — même écran Voix que la clôture R4/R5).
La transcription REMPLIT le champ de saisie, éditable — jamais
d'envoi automatique après dictée : l'humain relit sa question comme
il relirait n'importe quel texte tapé, puis appuie sur Envoyer.
-->
<style>
/* ═══ Tokens (répliques de ../tokens.css) ═══ */
:root{
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
--safran:#dd8a0b;--safran-doux:#fdf3e3;
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
--st-annule:#68788f;--st-annule-fond:#edf0f4;
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
--police-ui:"Manrope","Avenir Next","Segoe UI Variable",system-ui,sans-serif;
--rayon:10px;--rayon-controle:8px;--ombre-menu:0 8px 24px rgb(16 30 54/.14);
--transition:140ms ease-out;
}
@media (prefers-color-scheme:dark){:root{
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
--safran:#e89a1f;--safran-doux:#33270f;
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
--bordure:#2b3850;--bordure-forte:#3d4d6b;
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
}}
:root[data-theme="light"]{
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
--safran:#dd8a0b;--safran-doux:#fdf3e3;
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
--st-annule:#68788f;--st-annule-fond:#edf0f4;
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
--ombre-menu:0 8px 24px rgb(16 30 54/.14);
}
:root[data-theme="dark"]{
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
--safran:#e89a1f;--safran-doux:#33270f;
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
--bordure:#2b3850;--bordure-forte:#3d4d6b;
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
}
/* ═══ Base ═══ */
*{box-sizing:border-box;margin:0}
html,body{background:var(--fond)}
body{font-family:var(--police-ui);color:var(--encre);font-size:14px;line-height:1.5}
button{font:inherit;cursor:pointer;border:0;background:none;color:inherit}
button:focus-visible,a:focus-visible{outline:2px solid var(--primaire);outline-offset:2px;border-radius:4px}
/* ═══ Barre de revue (outil maquette, pas l'app) ═══ */
.revue{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:12px;flex-wrap:wrap;
padding:10px 16px;background:var(--nav-fond);color:var(--nav-encre);font-size:13px}
.revue b{color:var(--nav-encre-vif);letter-spacing:.02em}
.revue .onglets{display:flex;gap:4px;flex-wrap:wrap}
.revue .onglets button{padding:6px 10px;border-radius:6px;color:var(--nav-encre)}
.revue .onglets button:hover{background:rgb(255 255 255/.08);color:var(--nav-encre-vif)}
.revue .onglets button[aria-selected="true"]{background:rgb(255 255 255/.12);color:var(--nav-encre-vif);
box-shadow:inset 3px 0 0 var(--safran)}
.revue .droite{margin-left:auto;display:flex;align-items:center;gap:8px}
.revue .theme{padding:6px 10px;border:1px solid var(--nav-bordure);border-radius:6px}
.note-ecran{max-width:1120px;margin:14px auto 0;padding:0 20px;color:var(--encre-2);font-size:13px}
.note-ecran b{color:var(--encre)}
/* ═══ Écrans / téléphone (repris de maquette-mobile-tous-roles) ═══ */
.ecran{display:none;max-width:1120px;margin:18px auto 44px;padding:0 20px}
.ecran.actif{display:block}
.tels{display:flex;gap:28px;justify-content:center;flex-wrap:wrap}
.tel{width:310px;border:1px solid var(--bordure-forte);border-radius:28px;overflow:hidden;background:var(--fond);
box-shadow:var(--ombre-menu);display:flex;flex-direction:column}
.tel .encoche{height:26px;background:var(--nav-fond);display:flex;justify-content:center;align-items:center;flex:none}
.tel .encoche i{width:70px;height:7px;border-radius:999px;background:rgb(255 255 255/.18)}
.tel .legende{flex:none;text-align:center;font-size:11.5px;font-weight:700;letter-spacing:.05em;
text-transform:uppercase;color:var(--encre-3);padding:8px;background:var(--surface-2)}
.ecran-tel{padding:14px;display:flex;flex-direction:column;gap:10px;min-height:560px}
.entete-tel{display:flex;align-items:center;gap:8px}
.entete-tel .retour{font-size:20px;color:var(--primaire);font-weight:700}
.entete-tel b{font-size:16px;letter-spacing:.02em}
.btn-tel{display:flex;justify-content:center;align-items:center;gap:8px;padding:11px;border-radius:10px;
font-weight:700;font-size:13.5px;background:var(--primaire);color:#fff;width:100%}
.btn-tel.contour{background:var(--surface);color:var(--primaire);border:1.5px solid var(--primaire)}
.btn-tel.gris{background:var(--bordure-forte);color:var(--surface)}
/* ═══ Nouveautés : chat au format téléphone ═══ */
.chat-tel{display:flex;flex-direction:column;gap:10px;flex:1}
.msg-q-tel{align-self:flex-end;background:var(--primaire);color:#fff;border-radius:13px 13px 3px 13px;
padding:9px 12px;max-width:85%;font-size:12.5px;font-family:'Manrope',sans-serif}
.msg-r-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:13px 13px 13px 3px;
padding:11px 12px;display:flex;flex-direction:column;gap:9px;font-size:12.5px}
.cite-tel{display:inline-flex;align-items:center;justify-content:center;min-width:15px;height:15px;
border-radius:5px;background:var(--primaire-doux);color:var(--primaire);font-size:10px;font-weight:800;
vertical-align:1px;margin:0 1px}
.sources-tel{display:flex;flex-direction:column;gap:7px;border-top:1px dashed var(--bordure);padding-top:9px}
.source-tel{display:flex;flex-direction:column;gap:4px;background:var(--surface-2);border-radius:9px;padding:8px 9px}
.source-tel .entete-source{display:flex;align-items:center;gap:7px}
.source-tel .no{flex:none;width:16px;height:16px;border-radius:5px;background:var(--primaire-doux);
color:var(--primaire);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:800}
.source-tel b{font-size:11.5px;flex:1}
.source-tel .ou{color:var(--encre-2);font-size:10.5px;margin-left:23px}
.source-tel .extrait{color:var(--encre-2);font-size:11px;font-style:italic;border-left:2px solid var(--safran);
padding-left:7px;margin-left:23px}
.source-tel .ouvrir{margin-left:23px;align-self:flex-start;color:var(--primaire);font-weight:700;font-size:11px}
.avert-tel{display:flex;gap:6px;align-items:flex-start;background:var(--safran-doux);color:var(--alerte);
border-radius:9px;padding:8px 9px;font-size:10.5px;font-weight:600;line-height:1.4}
.refus-tel{background:var(--surface);border:1.5px dashed var(--bordure-forte);border-radius:13px;
padding:11px 12px;display:flex;flex-direction:column;gap:8px;font-size:12.5px}
.refus-tel b{color:var(--encre)}
.refus-tel .pourquoi{color:var(--encre-2);font-size:11.5px}
.saisie-chat-tel{margin-top:auto;display:flex;gap:8px;align-items:center;border:1.5px solid var(--bordure-forte);
border-radius:10px;background:var(--surface);padding:9px 10px}
.saisie-chat-tel span.txt{flex:1;color:var(--encre-3);font-size:12px}
.saisie-chat-tel span.txt.rempli{color:var(--encre);font-weight:600}
.saisie-chat-tel .envoyer{flex:none;color:var(--primaire);font-weight:800;font-size:12.5px}
.micro-mini{flex:none;width:26px;height:26px;border-radius:50%;background:var(--primaire-doux);color:var(--primaire);
display:flex;align-items:center;justify-content:center;font-size:13px}
.rappel-tel{color:var(--encre-3);font-size:11px;text-align:center;padding:8px}
/* Dictée de la question (D5) — même patron que l'écran Voix R5 */
.dictee-tel{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:10px;
border:1.5px solid var(--bordure);border-radius:13px;background:var(--surface);padding:18px 12px}
.micro-gd{width:48px;height:48px;border-radius:50%;background:var(--prio-bloque);color:#fff;display:flex;
align-items:center;justify-content:center;font-size:19px}
.onde-tel{display:flex;gap:3px;align-items:center;justify-content:center;height:22px}
.onde-tel i{width:3px;border-radius:2px;background:var(--primaire)}
.dictee-tel .provisoire{font-size:12px;color:var(--encre-2);text-align:center;font-style:italic}
.purge-tel{display:flex;align-items:center;gap:6px;font-size:10.5px;font-weight:700;color:var(--st-termine)}
</style>
<header class="revue">
<b>SIOP · Maquette — Assistant mobile</b>
<nav class="onglets" role="tablist" aria-label="Écrans">
<button role="tab" aria-selected="true" data-cible="sourcee">1 · Réponse sourcée</button>
<button role="tab" aria-selected="false" data-cible="refus">2 · Sans source = refus</button>
<button role="tab" aria-selected="false" data-cible="dictee">3 · Dictée de la question</button>
</nav>
<div class="droite"><button class="theme" id="btn-theme">Thème : auto</button></div>
</header>
<!-- ═══ 1 · RÉPONSE SOURCÉE ═══ -->
<p class="note-ecran" data-note="sourcee"><b>Intention :</b> même contenu, mêmes règles que
l'Assistant web (maquette-r5, D2) — chaque affirmation porte ses <b>citations numérotées</b>,
chaque source montre le document ou l'historique, la localisation précise et l'<b>extrait
exact</b>. « Ouvrir l'OT » mène à la fiche R4 (déjà générique) ; « Voir le document » mène à ses
métadonnées (bibliothèque R6.3) — l'ouverture du fichier lui-même reste au web pour l'instant
(D2, `expo-sharing` non ajouté à la légère). L'avertissement est permanent, pas un disclaimer
caché.</p>
<section class="ecran actif" id="sourcee">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Gestionnaire — question posée</div>
<div class="ecran-tel">
<div class="entete-tel"><span class="retour"></span><b>Assistant</b></div>
<div class="chat-tel">
<div class="msg-q-tel">Quel couple de serrage pour les coulisseaux de guides du Gen2 ?</div>
<div class="rappel-tel">Recherche dans le corpus…</div>
</div>
<div class="saisie-chat-tel"><span class="micro-mini">🎙</span><span class="txt">Poser une question…</span><span class="envoyer">Envoyer</span></div>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Réponse sourcée + citations</div>
<div class="ecran-tel">
<div class="entete-tel"><span class="retour"></span><b>Assistant</b></div>
<div class="chat-tel">
<div class="msg-q-tel">Quel couple de serrage pour les coulisseaux de guides du Gen2 ?</div>
<div class="msg-r-tel">
<p>Pour l'Otis <b>Gen2 Premier</b>, la notice prescrit <b>25 N·m</b> pour les vis de
fixation des coulisseaux<span class="cite-tel">1</span>, jeu latéral max. 0,5 mm
<span class="cite-tel">1</span>. Appliqué sur l'ascenseur A1 le 17 juillet
<span class="cite-tel">2</span>.</p>
<div class="sources-tel">
<div class="source-tel">
<div class="entete-source"><span class="no">1</span><b>Notice Otis Gen2 Premier.pdf</b></div>
<div class="ou">Bibliothèque · Asc. A1 · page 42</div>
<div class="extrait">« Serrer au couple de 25 N·m ; jeu latéral maximal : 0,5 mm. »</div>
<div class="ouvrir">Voir le document</div>
</div>
<div class="source-tel">
<div class="entete-source"><span class="no">2</span><b>OT-2026-0341 — Bruit anormal en gaine</b></div>
<div class="ou">Historique · clôturé le 17/07/2026</div>
<div class="extrait">« Coulisseau de guide 16 mm × 2 — remplacement effectué. »</div>
<div class="ouvrir">Ouvrir l'OT</div>
</div>
</div>
<div class="avert-tel">⚠ L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.</div>
</div>
</div>
<div class="saisie-chat-tel"><span class="micro-mini">🎙</span><span class="txt">Poser une question…</span><span class="envoyer">Envoyer</span></div>
</div>
</div>
</div>
</section>
<!-- ═══ 2 · SANS SOURCE = REFUS ═══ -->
<p class="note-ecran" data-note="refus" hidden><b>Intention :</b> l'anti-hallucination rendue
visible, comme au web — quand le corpus ne porte pas la réponse, l'assistant le dit, CHIFFRE ce
qu'il a cherché, et propose l'action utile. Jamais de réponse inventée « pour faire plaisir ».</p>
<section class="ecran" id="refus">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Refus honnête et chiffré</div>
<div class="ecran-tel">
<div class="entete-tel"><span class="retour"></span><b>Assistant</b></div>
<div class="chat-tel">
<div class="msg-q-tel">Procédure de remplacement du variateur KONE ?</div>
<div class="refus-tel">
<b>Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer.</b>
<div class="pourquoi">J'ai cherché dans 6 documents indexés et 214 bilans d'intervention :
aucune notice KONE, aucun historique de variateur sur ce parc.</div>
<div class="btn-tel contour">Reformuler ma question</div>
</div>
</div>
<div class="saisie-chat-tel"><span class="micro-mini">🎙</span><span class="txt">Poser une question…</span><span class="envoyer">Envoyer</span></div>
</div>
</div>
</div>
</section>
<!-- ═══ 3 · DICTÉE DE LA QUESTION ═══ -->
<p class="note-ecran" data-note="dictee" hidden><b>Intention :</b> même pipeline que la dictée déjà
livrée en clôture (ADR-004 §5) — appui sur le micro du champ de saisie, transcription locale
(faster-whisper), audio purgé immédiatement. La transcription REMPLIT le champ, éditable :
l'humain relit sa question avant d'appuyer sur Envoyer, exactement comme s'il l'avait tapée.</p>
<section class="ecran" id="dictee">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Pendant l'enregistrement</div>
<div class="ecran-tel">
<div class="entete-tel"><span class="retour"></span><b>Assistant</b></div>
<div class="chat-tel">
<div class="rappel-tel">Posez votre question, notice ou historique…</div>
</div>
<div class="dictee-tel">
<div class="micro-gd">🎙</div>
<div class="onde-tel">
<i style="height:7px"></i><i style="height:16px"></i><i style="height:11px"></i>
<i style="height:20px"></i><i style="height:9px"></i><i style="height:14px"></i>
<i style="height:6px"></i>
</div>
<div class="provisoire">« …quel couple de serrage pour les coulisseaux… »</div>
<div class="btn-tel" style="background:var(--danger)">■ Terminer la dictée</div>
</div>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Transcrite — relecture avant envoi</div>
<div class="ecran-tel">
<div class="entete-tel"><span class="retour"></span><b>Assistant</b></div>
<div class="chat-tel">
<div class="rappel-tel">Posez votre question, notice ou historique…</div>
</div>
<div class="saisie-chat-tel" style="align-items:flex-start">
<span class="micro-mini">🎙</span>
<span class="txt rempli">Quel couple de serrage pour les coulisseaux de guides du Gen2 ?</span>
<span class="envoyer">Envoyer</span>
</div>
<div class="purge-tel">✓ Audio purgé (2,1 s après transcription) — relisez avant d'envoyer</div>
</div>
</div>
</div>
</section>
<script>
(function(){
var onglets=document.querySelectorAll('.revue .onglets button');
var ecrans=document.querySelectorAll('.ecran');
var notes=document.querySelectorAll('.note-ecran');
onglets.forEach(function(b){b.addEventListener('click',function(){
onglets.forEach(function(x){x.setAttribute('aria-selected','false')});
b.setAttribute('aria-selected','true');
var cible=b.dataset.cible;
ecrans.forEach(function(e){e.classList.toggle('actif',e.id===cible)});
notes.forEach(function(n){n.hidden=(n.dataset.note!==cible)});
window.scrollTo({top:0});
})});
var ordre=['auto','light','dark'];var i=0;
var bt=document.getElementById('btn-theme');
bt.addEventListener('click',function(){
i=(i+1)%3;var m=ordre[i];
if(m==='auto'){document.documentElement.removeAttribute('data-theme')}
else{document.documentElement.setAttribute('data-theme',m)}
bt.textContent='Thème : '+(m==='auto'?'auto':(m==='light'?'clair':'sombre'));
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,764 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SIOP V2 — Maquettes HD · Mobile ouvert à tous les rôles</title>
</head>
<body>
<!--
SIOP V2 — Maquette HD « Mobile ouvert à tous les rôles » (design-first).
À VALIDER PAR LE RÉFÉRENT AVANT TOUT CODE EXPO (principe n°1).
R4 avait délibérément fermé le mobile au Technicien (périmètre R4 D3). Ici :
ouvrir l'app à TOUS les rôles, avec les MÊMES droits qu'au web (aucune
nouvelle logique de permission — le mobile lit la même matrice, ADR-003).
Tokens et gabarit téléphone repris tels quels de maquette-r4.html.
Les 4 écrans terrain déjà livrés et recettés (Ma journée, Scanner, Préventif,
Synchro) restent INCHANGÉS pour le Technicien — cette maquette ajoute une
couche autour, elle ne les remplace pas.
Décisions proposées à validation (récapitulées aussi dans le journal) :
D1 — Barre d'onglets ADAPTATIVE selon le rôle connecté (pas un seul jeu
d'onglets pour tout le monde) ; Technicien/Technicien limité gardent
exactement leurs onglets terrain actuels + un nouvel onglet « Menu ».
D2 — Le Menu mobile reprend EXACTEMENT les 4 groupes du web (Exploitation /
Parc / Ressources / Pilotage), dans le même ordre, filtrés par la même
matrice de permissions déjà en base — aucune règle de droit nouvelle.
D3 — Contrairement au web (le groupe reste affiché même vide), un groupe
SANS AUCUN lien visible est masqué en entier sur mobile — l'écran est
petit, un intitulé de groupe vide n'aide personne.
D4 — Le mobile porte les actions COURANTES de chaque famille (consulter,
créer une pièce/BC simple, approuver/rejeter, inviter) mais pas les
flux de gestion les plus denses (BC multi-lignes détaillé, revue pixel
de document) qui restent réservés au grand écran pour l'instant.
D5 — Le mobile n'acquiert AUCUNE logique de permission propre : un hook
`usePermissions()` calqué sur celui du web, lisant `me.permissions`
(déjà renvoyé par `/users/me`, déjà mis en cache par l'app).
-->
<style>
/* ═══ Tokens (répliques de ../tokens.css) ═══ */
:root{
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
--safran:#dd8a0b;--safran-doux:#fdf3e3;
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
--st-annule:#68788f;--st-annule-fond:#edf0f4;
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
--graf-1:#3b69d6;--graf-2:#dd8a0b;--graf-3:#1f9e8e;--graf-4:#8a63d2;--graf-grille:#e6ebf2;
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
--police-ui:"Manrope","Avenir Next","Segoe UI Variable",system-ui,sans-serif;
--rayon:10px;--rayon-controle:8px;--ombre-menu:0 8px 24px rgb(16 30 54/.14);
--transition:140ms ease-out;
}
@media (prefers-color-scheme:dark){:root{
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
--safran:#e89a1f;--safran-doux:#33270f;
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
--bordure:#2b3850;--bordure-forte:#3d4d6b;
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
--graf-1:#5580e4;--graf-2:#c7820a;--graf-3:#26a896;--graf-4:#9673de;--graf-grille:#24304a;
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
}}
:root[data-theme="light"]{
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
--safran:#dd8a0b;--safran-doux:#fdf3e3;
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
--st-annule:#68788f;--st-annule-fond:#edf0f4;
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
--graf-1:#3b69d6;--graf-2:#dd8a0b;--graf-3:#1f9e8e;--graf-4:#8a63d2;--graf-grille:#e6ebf2;
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
--ombre-menu:0 8px 24px rgb(16 30 54/.14);
}
:root[data-theme="dark"]{
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
--safran:#e89a1f;--safran-doux:#33270f;
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
--bordure:#2b3850;--bordure-forte:#3d4d6b;
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
--graf-1:#5580e4;--graf-2:#c7820a;--graf-3:#26a896;--graf-4:#9673de;--graf-grille:#24304a;
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
}
/* ═══ Base ═══ */
*{box-sizing:border-box;margin:0}
html,body{background:var(--fond)}
body{font-family:var(--police-ui);color:var(--encre);font-size:14px;line-height:1.5}
button{font:inherit;cursor:pointer;border:0;background:none;color:inherit}
button:focus-visible,a:focus-visible{outline:2px solid var(--primaire);outline-offset:2px;border-radius:4px}
.num{font-variant-numeric:tabular-nums}
/* ═══ Barre de revue (outil maquette, pas l'app) ═══ */
.revue{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:12px;flex-wrap:wrap;
padding:10px 16px;background:var(--nav-fond);color:var(--nav-encre);font-size:13px}
.revue b{color:var(--nav-encre-vif);letter-spacing:.02em}
.revue .onglets{display:flex;gap:4px;flex-wrap:wrap}
.revue .onglets button{padding:6px 10px;border-radius:6px;color:var(--nav-encre)}
.revue .onglets button:hover{background:rgb(255 255 255/.08);color:var(--nav-encre-vif)}
.revue .onglets button[aria-selected="true"]{background:rgb(255 255 255/.12);color:var(--nav-encre-vif);
box-shadow:inset 3px 0 0 var(--safran)}
.revue .droite{margin-left:auto;display:flex;align-items:center;gap:8px}
.revue .theme{padding:6px 10px;border:1px solid var(--nav-bordure);border-radius:6px}
.note-ecran{max-width:1120px;margin:14px auto 0;padding:0 20px;color:var(--encre-2);font-size:13px}
.note-ecran b{color:var(--encre)}
/* ═══ Écrans ═══ */
.ecran{display:none;max-width:1120px;margin:18px auto 44px;padding:0 20px}
.ecran.actif{display:block}
.tels{display:flex;gap:28px;justify-content:center;flex-wrap:wrap}
.tel{width:310px;border:1px solid var(--bordure-forte);border-radius:28px;overflow:hidden;background:var(--fond);
box-shadow:var(--ombre-menu);display:flex;flex-direction:column}
.tel .encoche{height:26px;background:var(--nav-fond);display:flex;justify-content:center;align-items:center;flex:none}
.tel .encoche i{width:70px;height:7px;border-radius:999px;background:rgb(255 255 255/.18)}
.tel .legende{flex:none;text-align:center;font-size:11.5px;font-weight:700;letter-spacing:.05em;
text-transform:uppercase;color:var(--encre-3);padding:8px;background:var(--surface-2)}
.ecran-tel{padding:14px;display:flex;flex-direction:column;gap:10px;min-height:560px}
/* Entête app */
.entete-tel{display:flex;align-items:center;gap:8px}
.entete-tel .flamme{color:var(--safran);font-size:16px}
.entete-tel b{font-size:16px;letter-spacing:.03em}
.entete-tel small{color:var(--encre-3);font-size:10.5px;margin-top:3px}
.entete-tel .fin{margin-left:auto;display:flex;align-items:center;gap:6px}
.avatar-tel{width:26px;height:26px;border-radius:50%;background:var(--st-encours);color:#fff;
display:flex;align-items:center;justify-content:center;font-size:10.5px;font-weight:700}
.sync{font-size:10.5px;font-weight:700;color:var(--st-termine);background:var(--st-termine-fond);
padding:2px 8px;border-radius:999px;white-space:nowrap}
.sync.off{color:var(--st-attente);background:var(--st-attente-fond)}
.titre-tel{font-size:17px;font-weight:800;display:flex;align-items:center;gap:8px}
.titre-tel .filet{margin-left:auto;font-size:11px;color:var(--encre-3);font-weight:600}
/* Bandeaux */
.bandeau-off{display:flex;align-items:center;gap:8px;background:var(--st-attente-fond);color:var(--st-attente);
border:1px solid currentColor;border-radius:10px;padding:8px 10px;font-size:12px;font-weight:700}
.bandeau-urgence{display:flex;align-items:center;gap:8px;background:var(--prio-bloque-fond);color:var(--prio-bloque);
border-radius:10px;padding:8px 10px;font-size:12px;font-weight:800}
.point-pulse{width:8px;height:8px;border-radius:50%;background:currentColor;flex:none}
/* Cartes OT */
.carte-ot-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:12px;padding:11px 12px;
border-left:4px solid transparent}
.carte-ot-tel.bloque{border-left-color:var(--prio-bloque)}
.carte-ot-tel.haute{border-left-color:var(--prio-haute)}
.carte-ot-tel.moyenne{border-left-color:var(--prio-moyenne)}
.carte-ot-tel.basse{border-left-color:var(--prio-basse)}
.carte-ot-tel .type-t{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--encre-2);
font-weight:700;display:flex;justify-content:space-between;align-items:center;gap:6px}
.carte-ot-tel b{display:block;margin:3px 0 2px;font-size:14px}
.carte-ot-tel .ou{color:var(--encre-2);font-size:12px}
.st{font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px;white-space:nowrap}
.st.ouvert{color:var(--st-ouvert);background:var(--st-ouvert-fond)}
.st.encours{color:var(--st-encours);background:var(--st-encours-fond)}
.st.attente{color:var(--st-attente);background:var(--st-attente-fond)}
.st.termine{color:var(--st-termine);background:var(--st-termine-fond)}
.st.annule{color:var(--st-annule);background:var(--st-annule-fond)}
.pastille-file{font-size:10.5px;font-weight:700;color:var(--st-attente);background:var(--st-attente-fond);
padding:2px 8px;border-radius:999px}
/* Boutons */
.btn-tel{display:flex;justify-content:center;align-items:center;gap:8px;padding:12px;border-radius:10px;
font-weight:700;font-size:14.5px;background:var(--primaire);color:#fff;width:100%}
.btn-tel.vert{background:var(--succes)}
.btn-tel.rouge{background:var(--danger)}
.btn-tel.gris{background:var(--bordure-forte);color:var(--surface)}
.btn-tel.contour{background:var(--surface);color:var(--primaire);border:1.5px solid var(--primaire)}
.paire-boutons{display:flex;gap:8px}
.paire-boutons .btn-tel{padding:10px;font-size:13px}
/* Fiche : cartes d'info */
.carte-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:12px;padding:12px;
display:flex;flex-direction:column;gap:8px}
.carte-tel h4{font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--encre-2)}
.ligne-info{display:flex;justify-content:space-between;gap:10px;font-size:12.5px}
.ligne-info span:first-child{color:var(--encre-2)}
.ligne-info b{text-align:right}
.garde{font-size:11.5px;color:var(--alerte);font-weight:600}
/* Tabbar */
.tabbar{margin-top:auto;display:flex;border-top:1px solid var(--bordure);background:var(--surface);
padding:6px 4px 8px}
.tabbar a{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;font-size:9.5px;
font-weight:700;color:var(--encre-3);text-decoration:none;position:relative}
.tabbar a.actif{color:var(--primaire)}
.tabbar a .pic{font-size:17px;line-height:1.2}
.tabbar .badge-tab{position:absolute;top:-3px;right:calc(50% - 16px);background:var(--st-attente);
color:#fff;font-size:9px;font-weight:800;padding:0 5px;border-radius:999px}
.tabbar .badge-tab.rouge{background:var(--prio-bloque)}
/* ═══ Nouveautés propres à cette maquette ═══ */
/* KPI mini-cartes (Accueil / Statistiques) */
.kpis-tel{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.kpi-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:10px;padding:9px 10px}
.kpi-tel .val{font-size:19px;font-weight:800;color:var(--encre)}
.kpi-tel .lbl{font-size:10.5px;color:var(--encre-2);font-weight:600}
.kpi-tel.accent .val{color:var(--primaire)}
/* Raccourcis (Accueil) */
.raccourci-tel{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--bordure);
border-radius:10px;padding:10px 11px}
.raccourci-tel .ico{width:28px;height:28px;border-radius:8px;background:var(--primaire-doux);color:var(--primaire);
display:flex;align-items:center;justify-content:center;font-size:14px;flex:none}
.raccourci-tel .txt{flex:1;display:flex;flex-direction:column}
.raccourci-tel b{font-size:12.5px}
.raccourci-tel small{color:var(--encre-2);font-size:11px}
.raccourci-tel .chev{color:var(--encre-3)}
.raccourci-tel .num{font-size:10.5px;font-weight:800;color:var(--st-attente);background:var(--st-attente-fond);
padding:2px 7px;border-radius:999px}
/* Menu groupé (mirroir de la nav web) */
.menu-groupe{display:flex;flex-direction:column;gap:6px}
.menu-groupe .titre-groupe{font-size:10.5px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;
color:var(--encre-3);padding:4px 2px 0}
.menu-item{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--bordure);
border-radius:10px;padding:10px 11px}
.menu-item .ico{width:24px;text-align:center;font-size:14px;color:var(--primaire);flex:none}
.menu-item b{flex:1;font-size:12.5px;font-weight:700}
.menu-item .chev{color:var(--encre-3);font-size:13px}
.menu-item .num{font-size:10.5px;font-weight:800;color:var(--st-attente);background:var(--st-attente-fond);
padding:2px 7px;border-radius:999px}
.menu-vide{color:var(--encre-3);font-size:12px;text-align:center;padding:20px 8px;font-weight:600}
/* Formulaire mobile */
.champ-tel{display:flex;flex-direction:column;gap:4px}
.champ-tel label{font-size:11px;font-weight:700;color:var(--encre-2)}
.champ-tel .valeur-champ{border:1.5px solid var(--bordure-forte);border-radius:9px;background:var(--surface);
padding:9px 10px;font-size:13px;font-weight:600;color:var(--encre)}
.champ-tel textarea.valeur-champ{min-height:56px}
/* Tableau de synthèse (hors gabarit téléphone) */
.table-roles{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--bordure);
border-radius:12px;overflow:hidden;font-size:12.5px}
.table-roles th,.table-roles td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--bordure)}
.table-roles thead th{background:var(--surface-2);font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;
color:var(--encre-2)}
.table-roles tbody tr:last-child td{border-bottom:0}
.table-roles td.role{font-weight:800}
.table-roles .oui{color:var(--st-termine);font-weight:800}
.table-roles .non{color:var(--encre-3)}
.chip-onglet{display:inline-block;background:var(--primaire-doux);color:var(--primaire);font-weight:700;
font-size:11px;padding:2px 8px;border-radius:999px;margin:2px 3px 2px 0}
.chip-onglet.terrain{background:var(--safran-doux);color:var(--safran)}
</style>
<header class="revue">
<b>SIOP · Maquette — Mobile ouvert à tous les rôles</b>
<nav class="onglets" role="tablist" aria-label="Écrans">
<button role="tab" aria-selected="true" data-cible="synthese">1 · Synthèse par rôle</button>
<button role="tab" aria-selected="false" data-cible="accueil">2 · Accueil adaptatif</button>
<button role="tab" aria-selected="false" data-cible="menu">3 · Menu groupé</button>
<button role="tab" aria-selected="false" data-cible="ot-dispatch">4 · OT — Dispatcher</button>
<button role="tab" aria-selected="false" data-cible="demandes">5 · Demandes</button>
<button role="tab" aria-selected="false" data-cible="stock">6 · Stock & achats</button>
<button role="tab" aria-selected="false" data-cible="pilotage">7 · Personnes & statistiques</button>
</nav>
<div class="droite"><button class="theme" id="btn-theme">Thème : auto</button></div>
</header>
<!-- ═══ 1 · SYNTHÈSE PAR RÔLE ═══ -->
<p class="note-ecran" data-note="synthese"><b>Intention :</b> avant le détail écran par écran,
le tableau ci-dessous résume la <b>décision D1/D2</b> pour les 7 rôles : quels onglets la barre
adaptative affiche, et quels groupes du Menu restent visibles (mêmes droits qu'au web, rien de
nouveau dans la matrice). Les 4 onglets terrain du Technicien (encadré safran) sont ceux déjà
livrés en R4 — inchangés.</p>
<section class="ecran actif" id="synthese">
<div style="overflow-x:auto">
<table class="table-roles">
<thead><tr><th>Rôle (compte démo)</th><th>Onglets de la barre</th><th>Groupes du Menu visibles</th></tr></thead>
<tbody>
<tr>
<td class="role">Administrateur<br><small style="font-weight:500;color:var(--encre-3)">Yasmine Alaoui</small></td>
<td><span class="chip-onglet">Accueil</span><span class="chip-onglet">OT</span><span class="chip-onglet">Menu</span></td>
<td>Exploitation · Parc · Ressources · Pilotage <span style="color:var(--encre-3)">(tout)</span></td>
</tr>
<tr>
<td class="role">Gestionnaire<br><small style="font-weight:500;color:var(--encre-3)">Nadia Berrada</small></td>
<td><span class="chip-onglet">Accueil</span><span class="chip-onglet">OT</span><span class="chip-onglet">Menu</span></td>
<td>Exploitation · Parc · Ressources · Pilotage <span style="color:var(--encre-3)">(stats en lecture)</span></td>
</tr>
<tr>
<td class="role">Dispatcher<br><small style="font-weight:500;color:var(--encre-3)">Salma Idrissi</small></td>
<td><span class="chip-onglet">Accueil</span><span class="chip-onglet">OT</span><span class="chip-onglet">Menu</span></td>
<td>Exploitation · Parc (lecture) <span style="color:var(--encre-3)">— Ressources/Pilotage restreints</span></td>
</tr>
<tr style="box-shadow:inset 3px 0 0 var(--safran)">
<td class="role">Technicien<br><small style="font-weight:500;color:var(--encre-3)">Ahmed Benali</small></td>
<td><span class="chip-onglet terrain">Ma journée</span><span class="chip-onglet terrain">Scanner</span><span class="chip-onglet terrain">Préventif</span><span class="chip-onglet terrain">Synchro</span><span class="chip-onglet">Menu</span></td>
<td>Menu quasi vide (ses OT + parc en lecture) — <b>inchangé depuis R4</b></td>
</tr>
<tr style="box-shadow:inset 3px 0 0 var(--safran)">
<td class="role">Technicien limité<br><small style="font-weight:500;color:var(--encre-3)">Youssef Tazi</small></td>
<td><span class="chip-onglet terrain">Ma journée</span><span class="chip-onglet terrain">Scanner</span><span class="chip-onglet terrain">Préventif</span><span class="chip-onglet terrain">Synchro</span><span class="chip-onglet">Menu</span></td>
<td>Menu quasi vide (ses OT uniquement) — <b>inchangé depuis R4</b></td>
</tr>
<tr>
<td class="role">Demandeur<br><small style="font-weight:500;color:var(--encre-3)">Karim Doukkali</small></td>
<td><span class="chip-onglet">Accueil</span><span class="chip-onglet">Nouvelle demande</span></td>
<td>Exploitation → Demandes uniquement <span style="color:var(--encre-3)">(Menu quasi vide, D3)</span></td>
</tr>
<tr>
<td class="role">Vue seule<br><small style="font-weight:500;color:var(--encre-3)">Rachid Bennis</small></td>
<td><span class="chip-onglet">Accueil</span><span class="chip-onglet">Menu</span></td>
<td>Exploitation · Parc · Ressources · Pilotage <span style="color:var(--encre-3)">(lecture seule, aucun bouton d'action)</span></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- ═══ 2 · ACCUEIL ADAPTATIF ═══ -->
<p class="note-ecran" data-note="accueil" hidden><b>Intention :</b> le Technicien garde « Ma
journée » telle quelle (rien à revalider). Les autres rôles reçoivent un <b>Accueil</b> propre à
leur travail : le Gestionnaire voit ses signaux de pilotage et des raccourcis vers ce qui demande
une action ; le Demandeur voit directement ses demandes et le geste qu'il fait le plus (en
créer une nouvelle) — jamais un écran vide générique.</p>
<section class="ecran" id="accueil">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Technicien — inchangé (R4)</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Technicien</small>
<span class="fin"><span class="sync">Synchro à jour</span><span class="avatar-tel">AB</span></span>
</div>
<div class="titre-tel">Ma journée <span class="filet">jeudi 1 août</span></div>
<div class="bandeau-urgence"><span class="point-pulse"></span>1 personne bloquée — B2 · Tour Atlas</div>
<div class="carte-ot-tel bloque">
<div class="type-t">OT-2026-0342 · Dépannage <span class="st ouvert">Ouvert</span></div>
<b>Personne bloquée en cabine</b>
<div class="ou">Asc. B2 — Tour Atlas · Aïn Diab</div>
</div>
<div class="carte-ot-tel haute">
<div class="type-t">OT-2026-0341 · Dépannage <span class="st encours">En cours</span></div>
<b>Bruit anormal en gaine</b>
<div class="ou">Asc. A1 — Résidence Al Manar</div>
</div>
<nav class="tabbar">
<a class="actif" href="#"><span class="pic"></span>Ma journée</a>
<a href="#"><span class="pic"></span>Scanner</a>
<a href="#"><span class="pic"></span>Préventif</a>
<a href="#"><span class="pic"></span>Synchro</a>
<a href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Gestionnaire — nouveau</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Gestionnaire</small>
<span class="fin"><span class="sync">Synchro à jour</span><span class="avatar-tel">NB</span></span>
</div>
<div class="titre-tel">Accueil <span class="filet">jeudi 1 août</span></div>
<div class="kpis-tel">
<div class="kpi-tel accent"><div class="val">6</div><div class="lbl">OT en cours</div></div>
<div class="kpi-tel"><div class="val">3</div><div class="lbl">Stock sous seuil</div></div>
<div class="kpi-tel"><div class="val">78 %</div><div class="lbl">Préventif du mois</div></div>
<div class="kpi-tel"><div class="val">2</div><div class="lbl">Demandes reçues</div></div>
</div>
<div class="raccourci-tel"><span class="ico"></span>
<span class="txt"><b>Stock sous seuil</b><small>3 pièces à commander</small></span>
<span class="num">3</span><span class="chev"></span></div>
<div class="raccourci-tel"><span class="ico"></span>
<span class="txt"><b>Demandes reçues</b><small>en attente d'approbation</small></span>
<span class="num">2</span><span class="chev"></span></div>
<div class="raccourci-tel"><span class="ico"></span>
<span class="txt"><b>Invitations en attente</b><small>lien envoyé, pas encore activé</small></span>
<span class="chev"></span></div>
<nav class="tabbar">
<a class="actif" href="#"><span class="pic"></span>Accueil</a>
<a href="#"><span class="pic"></span>OT</a>
<a href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Demandeur — nouveau</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Demandeur</small>
<span class="fin"><span class="sync">Synchro à jour</span><span class="avatar-tel">KD</span></span>
</div>
<div class="titre-tel">Mes demandes</div>
<div class="carte-ot-tel">
<div class="type-t">DEM-2026-0089 <span class="st attente">Reçue</span></div>
<b>Éclairage cabine défaillant</b>
<div class="ou">Asc. A2 — Résidence Al Manar</div>
</div>
<div class="carte-ot-tel">
<div class="type-t">DEM-2026-0081 <span class="st termine">Traitée</span></div>
<b>Bruit au démarrage</b>
<div class="ou">Asc. A2 — Résidence Al Manar → OT-2026-0336</div>
</div>
<div style="margin-top:auto">
<button class="btn-tel">+ Nouvelle demande</button>
</div>
<nav class="tabbar">
<a class="actif" href="#"><span class="pic"></span>Accueil</a>
<a href="#"><span class="pic">+</span>Nouvelle demande</a>
</nav>
</div>
</div>
</div>
</section>
<!-- ═══ 3 · MENU GROUPÉ ═══ -->
<p class="note-ecran" data-note="menu" hidden><b>Intention :</b> le Menu reprend, à l'identique,
les 4 groupes et l'ordre du sidebar web — <b>Exploitation / Parc / Ressources / Pilotage</b> — et
les mêmes permissions (`can(catégorie, 'view')`). Un rôle qui n'a droit à rien dans un groupe ne
voit pas le groupe du tout (D3) : comparez Administrateur (tout) et Demandeur (presque rien) sur
le MÊME écran, sans aucune règle de plus dans le code.</p>
<section class="ecran" id="menu">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Administrateur — tout visible</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Administrateur</small>
<span class="fin"><span class="avatar-tel">YA</span></span>
</div>
<div class="titre-tel">Menu</div>
<div class="menu-groupe">
<div class="titre-groupe">Exploitation</div>
<div class="menu-item"><span class="ico"></span><b>Tableau de bord</b><span class="chev"></span></div>
<div class="menu-item"><span class="ico"></span><b>Ordres de travail</b><span class="num">1</span><span class="chev"></span></div>
<div class="menu-item"><span class="ico"></span><b>Demandes</b><span class="num">2</span><span class="chev"></span></div>
<div class="menu-item"><span class="ico"></span><b>Préventif</b><span class="chev"></span></div>
</div>
<div class="menu-groupe">
<div class="titre-groupe">Parc</div>
<div class="menu-item"><span class="ico"></span><b>Ascenseurs</b><span class="chev"></span></div>
<div class="menu-item"><span class="ico"></span><b>Sites</b><span class="chev"></span></div>
</div>
<div class="menu-groupe">
<div class="titre-groupe">Ressources</div>
<div class="menu-item"><span class="ico"></span><b>Stock & achats</b><span class="num">3</span><span class="chev"></span></div>
<div class="menu-item"><span class="ico"></span><b>Tiers</b><span class="chev"></span></div>
</div>
<div class="menu-groupe">
<div class="titre-groupe">Pilotage</div>
<div class="menu-item"><span class="ico"></span><b>Statistiques</b><span class="chev"></span></div>
<div class="menu-item"><span class="ico"></span><b>Personnes & équipes</b><span class="chev"></span></div>
</div>
<nav class="tabbar">
<a href="#"><span class="pic"></span>Accueil</a>
<a href="#"><span class="pic"></span>OT</a>
<a class="actif" href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Demandeur — presque vide (D3)</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Demandeur</small>
<span class="fin"><span class="avatar-tel">KD</span></span>
</div>
<div class="titre-tel">Menu</div>
<div class="menu-groupe">
<div class="titre-groupe">Exploitation</div>
<div class="menu-item"><span class="ico"></span><b>Mes demandes</b><span class="chev"></span></div>
</div>
<div class="menu-vide">Parc, Ressources et Pilotage ne sont pas accessibles à ce rôle —<br>groupes masqués, pas de section vide affichée.</div>
<nav class="tabbar">
<a class="actif" href="#"><span class="pic"></span>Accueil</a>
<a href="#"><span class="pic">+</span>Nouvelle demande</a>
</nav>
</div>
</div>
</div>
</section>
<!-- ═══ 4 · OT — DISPATCHER ═══ -->
<p class="note-ecran" data-note="ot-dispatch" hidden><b>Intention :</b> contrairement au
Technicien (qui ne voit que SES OT), le Dispatcher a `viewOther` — il voit tous les OT et peut en
créer/assigner. Le mobile porte cette différence : liste complète, bouton « + » et une action
rapide « Assigner » par carte (bottom-sheet, même pattern que le sélecteur de bilan codé de R4).</p>
<section class="ecran" id="ot-dispatch">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Dispatcher — tous les OT</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Dispatcher</small>
<span class="fin"><span class="sync">Synchro à jour</span><span class="avatar-tel">SI</span></span>
</div>
<div class="titre-tel">Ordres de travail <span class="filet">14 en cours</span></div>
<div class="carte-ot-tel bloque">
<div class="type-t">OT-2026-0342 · Dépannage <span class="st ouvert">Ouvert</span></div>
<b>Personne bloquée en cabine</b>
<div class="ou">Asc. B2 — Tour Atlas · non assigné</div>
</div>
<div class="carte-ot-tel haute">
<div class="type-t">OT-2026-0341 · Dépannage <span class="st encours">En cours</span></div>
<b>Bruit anormal en gaine</b>
<div class="ou">Asc. A1 — Résidence Al Manar · Ahmed Benali</div>
</div>
<div class="carte-ot-tel moyenne">
<div class="type-t">OT-2026-0336 · Préventif <span class="st ouvert">Ouvert</span></div>
<b>Contrôle mensuel — juillet</b>
<div class="ou">Asc. A2 — Résidence Al Manar · non assigné</div>
</div>
<div style="margin-top:auto">
<button class="btn-tel">+ Nouvel OT</button>
</div>
<nav class="tabbar">
<a href="#"><span class="pic"></span>Accueil</a>
<a class="actif" href="#"><span class="pic"></span>OT</a>
<a href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Assigner — feuille au pouce</div>
<div class="ecran-tel" style="justify-content:flex-end;padding:0">
<div style="flex:1;background:rgb(9 14 22/.45)"></div>
<div style="background:var(--surface);border-radius:16px 16px 0 0;padding:14px;display:flex;flex-direction:column;gap:10px">
<div style="font-weight:800;font-size:15px">Assigner OT-2026-0342</div>
<div class="champ-tel">
<label>Technicien</label>
<div class="valeur-champ">Ahmed Benali ▾</div>
</div>
<button class="btn-tel">Assigner</button>
<button class="btn-tel contour">Annuler</button>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ 5 · DEMANDES ═══ -->
<p class="note-ecran" data-note="demandes" hidden><b>Intention :</b> le Demandeur crée sa demande
en mobilité (site, ascenseur si connu, description, urgence) exactement comme au web mais sur un
formulaire au pouce ; le Gestionnaire/Dispatcher approuve ou rejette (motif obligatoire au rejet,
comme au web) directement depuis la carte.</p>
<section class="ecran" id="demandes">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Demandeur — nouvelle demande</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Demandeur</small>
<span class="fin"><span class="avatar-tel">KD</span></span>
</div>
<div class="titre-tel">Nouvelle demande</div>
<div class="champ-tel"><label>Site</label><div class="valeur-champ">Résidence Al Manar ▾</div></div>
<div class="champ-tel"><label>Ascenseur (si connu)</label><div class="valeur-champ">Asc. A2 ▾</div></div>
<div class="champ-tel"><label>Description</label>
<div class="valeur-champ" style="min-height:56px">Odeur de brûlé au démarrage, depuis ce matin.</div>
</div>
<div class="champ-tel"><label>Urgence</label><div class="valeur-champ">Personne bloquée — Non</div></div>
<div style="margin-top:auto">
<button class="btn-tel">Envoyer la demande</button>
</div>
<nav class="tabbar">
<a href="#"><span class="pic"></span>Accueil</a>
<a class="actif" href="#"><span class="pic">+</span>Nouvelle demande</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Gestionnaire — approbation</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Gestionnaire</small>
<span class="fin"><span class="avatar-tel">NB</span></span>
</div>
<div class="titre-tel">Demandes reçues <span class="filet">2</span></div>
<div class="carte-ot-tel">
<div class="type-t">DEM-2026-0089 <span class="st attente">Reçue</span></div>
<b>Éclairage cabine défaillant</b>
<div class="ou">Asc. A2 — Résidence Al Manar · Karim Doukkali</div>
<div class="paire-boutons" style="margin-top:8px">
<button class="btn-tel vert">Approuver</button>
<button class="btn-tel rouge">Rejeter</button>
</div>
</div>
<div class="carte-ot-tel">
<div class="type-t">DEM-2026-0090 <span class="st attente">Reçue</span></div>
<b>Porte palière qui force</b>
<div class="ou">Asc. C1 — Anfa Place · Karim Doukkali</div>
<div class="paire-boutons" style="margin-top:8px">
<button class="btn-tel vert">Approuver</button>
<button class="btn-tel rouge">Rejeter</button>
</div>
</div>
<nav class="tabbar">
<a href="#"><span class="pic"></span>Accueil</a>
<a class="actif" href="#"><span class="pic"></span>OT</a>
<a href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
</div>
</section>
<!-- ═══ 6 · STOCK & ACHATS ═══ -->
<p class="note-ecran" data-note="stock" hidden><b>Intention :</b> le Gestionnaire consulte le
stock en mobilité et déclenche l'action courante — commander une pièce sous seuil — sans passer
par le grand écran. La fiche pièce reprend les lignes déjà connues (quantité, seuil, dernier
prix, fournisseur). Le bon de commande détaillé multi-lignes reste au web (D4).</p>
<section class="ecran" id="stock">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Gestionnaire — stock</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Gestionnaire</small>
<span class="fin"><span class="avatar-tel">NB</span></span>
</div>
<div class="titre-tel">Stock & achats <span class="filet">3 sous seuil</span></div>
<div class="carte-tel">
<h4>Câble de traction 8 mm</h4>
<div class="ligne-info"><span>Quantité</span><b style="color:var(--danger)">2</b></div>
<div class="ligne-info"><span>Seuil</span><b>5</b></div>
<div class="ligne-info"><span>Fournisseur</span><b>Ascentech Maroc</b></div>
<button class="btn-tel" style="margin-top:4px">Commander</button>
</div>
<div class="carte-tel">
<h4>Galet de guidage</h4>
<div class="ligne-info"><span>Quantité</span><b style="color:var(--danger)">1</b></div>
<div class="ligne-info"><span>Seuil</span><b>4</b></div>
<div class="ligne-info"><span>Fournisseur</span><b>SchindlerParts</b></div>
<button class="btn-tel" style="margin-top:4px">Commander</button>
</div>
<nav class="tabbar">
<a href="#"><span class="pic"></span>Accueil</a>
<a href="#"><span class="pic"></span>OT</a>
<a class="actif" href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">BC pré-rempli depuis l'alerte</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Gestionnaire</small>
<span class="fin"><span class="avatar-tel">NB</span></span>
</div>
<div class="titre-tel">Nouveau BC</div>
<div class="champ-tel"><label>Fournisseur</label><div class="valeur-champ">Ascentech Maroc</div></div>
<div class="champ-tel"><label>Pièce</label><div class="valeur-champ">Câble de traction 8 mm</div></div>
<div class="champ-tel"><label>Quantité</label><div class="valeur-champ">10</div></div>
<div class="garde">Le détail multi-lignes se termine sur le grand écran (D4).</div>
<div style="margin-top:auto">
<button class="btn-tel">Créer le BC</button>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ 7 · PERSONNES & STATISTIQUES ═══ -->
<p class="note-ecran" data-note="pilotage" hidden><b>Intention :</b> deux écrans « Pilotage » côte
à côte — les Statistiques en lecture (mêmes chiffres que `/analytics/summary`, en cartes plutôt
qu'en graphes denses) et Personnes & équipes, avec l'action d'invitation déjà connue du web
(lien 7 jours) portée sur mobile.</p>
<section class="ecran" id="pilotage">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Administrateur — statistiques</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Administrateur</small>
<span class="fin"><span class="avatar-tel">YA</span></span>
</div>
<div class="titre-tel">Statistiques <span class="filet">12 derniers mois</span></div>
<div class="kpis-tel">
<div class="kpi-tel accent"><div class="val">505 MAD</div><div class="lbl">Coût du mois</div></div>
<div class="kpi-tel"><div class="val">78 %</div><div class="lbl">Taux préventif</div></div>
</div>
<div class="carte-tel">
<h4>Pannes par organe — top 3</h4>
<div class="ligne-info"><span>Portes palières</span><b>9</b></div>
<div class="ligne-info"><span>Câblage</span><b>6</b></div>
<div class="ligne-info"><span>Éclairage cabine</span><b>4</b></div>
</div>
<nav class="tabbar">
<a href="#"><span class="pic"></span>Accueil</a>
<a href="#"><span class="pic"></span>OT</a>
<a class="actif" href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Gestionnaire — personnes & équipes</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Gestionnaire</small>
<span class="fin"><span class="avatar-tel">NB</span></span>
</div>
<div class="titre-tel">Personnes <span class="filet">12</span></div>
<div class="menu-item"><span class="ico"></span><b>Ahmed Benali</b><span style="color:var(--encre-3);font-size:11px">Technicien</span></div>
<div class="menu-item"><span class="ico"></span><b>Youssef Tazi</b><span style="color:var(--encre-3);font-size:11px">Technicien limité</span></div>
<div class="menu-item"><span class="ico"></span><b>Salma Idrissi</b><span style="color:var(--encre-3);font-size:11px">Dispatcher</span></div>
<div style="margin-top:auto">
<button class="btn-tel">+ Inviter</button>
</div>
</div>
</div>
</div>
</section>
<script>
(function(){
var onglets=document.querySelectorAll('.revue .onglets button');
var ecrans=document.querySelectorAll('.ecran');
var notes=document.querySelectorAll('.note-ecran');
onglets.forEach(function(b){b.addEventListener('click',function(){
onglets.forEach(function(x){x.setAttribute('aria-selected','false')});
b.setAttribute('aria-selected','true');
var cible=b.dataset.cible;
ecrans.forEach(function(e){e.classList.toggle('actif',e.id===cible)});
notes.forEach(function(n){n.hidden=(n.dataset.note!==cible)});
window.scrollTo({top:0});
})});
var ordre=['auto','light','dark'];var i=0;
var bt=document.getElementById('btn-theme');
bt.addEventListener('click',function(){
i=(i+1)%3;var m=ordre[i];
if(m==='auto'){document.documentElement.removeAttribute('data-theme')}
else{document.documentElement.setAttribute('data-theme',m)}
bt.textContent='Thème : '+(m==='auto'?'auto':(m==='light'?'clair':'sombre'));
});
})();
</script>
</body>
</html>

View File

@@ -496,7 +496,10 @@ loi 09-08 : l'anonymisation à l'ingestion est dite, pas cachée dans une CGU.</
<p class="note-ecran" data-note="voix" hidden><b>Intention :</b> l'option « si budget temps » du
cadrage : dicter au lieu de taper, mains sales sur le palier. L'audio est transcrit puis
<b>PURGÉ immédiatement</b> (loi 09-08) — le bandeau le dit au moment même. La transcription devient la
description libre de l'écran 4 : même circuit, même validation humaine.</p>
description libre de l'écran 4 : même circuit, même validation humaine. <b>Amendement (22/07) :</b>
une fois relue et jointe à l'OT, cette description vit dans le bilan (champ note) — et, à la clôture,
rejoint le corpus de l'assistant comme les autres bilans, anonymisée par le même pipeline (D4). Le
technicien le sait dès l'écran de relecture : rien de cité n'est caché a posteriori.</p>
<section class="ecran" id="voix">
<div class="tels">
<div class="tel">
@@ -543,6 +546,9 @@ description libre de l'écran 4 : même circuit, même validation humaine.</p>
<div class="btn-tel contour">✨ Suggérer les codes depuis ce texte</div>
<div class="btn-tel">Joindre la description à l'OT</div>
</div>
<div style="font-size:11px;color:var(--encre-3)">🛡 À la clôture de l'OT, ce texte (anonymisé)
rejoindra le corpus de l'assistant — comme les bilans déjà codés, pour aider les prochaines
interventions sur ce parc.</div>
</div>
</div>
</div>

View File

@@ -51,6 +51,23 @@
réseau privé — la règle d'or ESLint d'ADR-001 concerne le code TypeScript de l'API,
la topologie « MinIO jamais exposé » du runbook reste respectée.
5. **Transcription (dictée, R5 D5) : locale, opt-in, jamais de persistance de l'audio.**
Amendement du 22/07/2026 — le référent choisit `faster-whisper` (CTranslate2, CPU,
licence MIT) plutôt qu'une API externe, même philosophie que les embeddings : « open
source et local ». `AI_TRANSCRIPTION=off` par défaut (endpoint refuse proprement,
503) ; `locale` charge le modèle (taille configurable par `AI_TRANSCRIPTION_MODEL`,
défaut `small`) ; `deterministe` en tests/CI (aucune dépendance audio). Le flux :
l'audio ne transite qu'une fois vers `siop2-ai` (multipart, jamais écrit sur disque
par `siop2-api`), un fichier temporaire le temps de l'inférence côté `siop2-ai`,
**supprimé aussitôt quoi qu'il arrive** — succès ou erreur (loi 09-08, D5). Seul le
texte transcrit revient, à relire par l'humain (D1) avant tout usage : suggestion de
bilan immédiate, et/ou sauvegarde dans `InterventionReport.note` — qui, à la clôture
de l'OT, rejoint le corpus comme les libellés codés (même pipeline d'anonymisation,
D4). **Point de vigilance non calibré** (décision explicite du référent : pas de
prototype de mesure préalable, contrairement au choix du modèle d'embeddings) :
Whisper transcrit mal le darija, probable en mélange avec le français sur le
terrain — à mesurer sur des enregistrements réels si la qualité déçoit en recette.
## Conséquences
- L'anonymisation (D4) s'applique **à l'ingestion** — les index ne contiennent jamais
@@ -58,3 +75,23 @@
- Le mode extractif est le contrat de base : toute recette R5 doit passer SANS clé API.
- Si le partenaire veut un jour une génération 100 % locale (llama.cpp…), seul le point 3
change — interface `Generateur` prévue pour ça.
- La dictée (point 5) est opt-in et sans mesure de qualité préalable : à recetter avec
attention avant toute promesse de qualité au client, en particulier en darija.
- Implémentée et vérifiée le 22/07/2026 : transcription réelle (français, voix de
synthèse) juste et rapide en local, bout en bout via l'API NestJS, et dans l'image
Docker de production construite pour l'occasion (modèle embarqué, aucun téléchargement
au démarrage). Le test tactile sur iPhone physique n'avait pas pu se jouer ce jour-là
(blocage USB), reporté.
- **Recette terrain sur iPhone physique complétée le 01/08/2026** : micro, dictée réelle,
transcription fidèle, sauvegarde dans le bilan, clôture, puis retrouvée par la recherche
sémantique après réindexation — la chaîne complète (D5) est validée sur vrai matériel.
Deux bugs réels trouvés et corrigés en route :
1. `expo-audio` sur iOS refuse `recorder.record()` tant que la session audio n'a pas
été explicitement autorisée à enregistrer — `RecordingDisabledException`, corrigée
par un appel à `setAudioModeAsync({ allowsRecording: true })` avant l'enregistrement
(et remis à `false` après, par hygiène — le micro ne reste pas « armé »).
2. Le contournement des modules Expo/RN précompilés (`EXPO_USE_PRECOMPILED_MODULES=0`,
`RCT_USE_PREBUILT_RNCORE=0` — ADR-005) n'avait **jamais été rendu permanent** dans
`apps/mobile/.env`, contrairement au correctif FormData voisin : une reconstruction
ultérieure sans ces variables aurait reproduit le crash au lancement déjà documenté.
Désormais committé aux côtés de `EXPO_PUBLIC_USE_RN_FETCH`.

View File

@@ -0,0 +1,91 @@
# ADR-005 — Distribution mobile : builds natifs + Expo Go pour l'aperçu
- **Statut** : acceptée (recette terrain R4/R5, 19 juillet 2026)
- **Contexte** : la recette « mode avion » sur téléphone (due depuis `release/r4`) a buté
sur Expo Go dès la première tentative — l'app installée depuis l'App Store affiche
« supported SDK 54 » et refuse le projet (SDK 57). Apple a un retard d'approbation
structurel sur les nouvelles versions d'Expo Go (constaté : seul SDK 54 installable sur
iPhone physique début mai 2026, alors qu'Expo publiait déjà SDK 57) — ce n'est pas un
problème côté projet, et rétrograder le SDK du projet pour suivre Expo Go serait
disproportionné pour un aléa hors de notre contrôle.
## Décision
**Deux canaux de distribution mobile, en parallèle :**
1. **Builds natifs locaux** (Xcode pour iOS, Android Studio pour Android) — la voie pour
tester *vraiment* sur un appareil (mode avion réel, caméra réelle, persistance),
indépendante des aléas de version d'Expo Go. Signature gratuite (Apple ID personnel →
Personal Team Xcode, sans compte payeur Apple Developer Program) pour les builds de
recette ; à revoir si une distribution à plusieurs testeurs (étudiants) est nécessaire.
2. **Expo Go conservé** comme canal léger « sans installation » pour prévisualiser
rapidement sur d'autres appareils, quand la version d'Expo Go de l'appareil correspond
au SDK du projet — rien à changer, c'est le fonctionnement actuel de `expo start`.
Le **managed workflow est conservé** : `ios/` et `android/` restent générés à la demande
par `expo prebuild`/`expo run:*` (déjà gitignorés), jamais committés.
## Obstacles rencontrés en recette — et leurs correctifs
Cinq heures de recette terrain ont débusqué des problèmes réels, chacun avec un
correctif ou une limite désormais documentés pour ne pas être redécouverts à froid :
1. **Nom d'appareil avec apostrophe** rejeté par `expo run:ios --device "iPhone d'aziz"`
→ cibler par **UDID** (`xcrun xctrace list devices`), jamais par nom.
2. **Ne jamais court-circuiter `expo run:ios`/`expo run:android` par un `xcodebuild`
manuel** : un contournement de signature via `xcodebuild -allowProvisioningUpdates`
a produit une app qui compilait mais **crashait au lancement**
(`dyld: Library not loaded: @rpath/React.framework/React`, signal 6) — la commande
officielle inclut des étapes (embed frameworks, configuration Expo) qu'un appel manuel
saute silencieusement.
3. **Incompatibilité des modules précompilés (SDK 56/57)** : Expo distribue par défaut
des XCFrameworks précompilés pour ses modules, et React Native 0.86 précompile aussi
son cœur (`RCT_USE_PREBUILT_RNCORE`) — ce projet, en liaison statique (pas de
`use_frameworks!`), n'est pas compatible avec ces frameworks dynamiques. C'est la
cause RÉELLE du crash ci-dessus (le contournement xcodebuild n'a fait que le révéler
plus tôt). **Fix** : `EXPO_USE_PRECOMPILED_MODULES=0` et `RCT_USE_PREBUILT_RNCORE=0`
avant `pod install`/`expo run:ios` — tout se recompile depuis les sources.
4. **`fetch` global incompatible avec l'upload multipart natif** : depuis SDK 57, `fetch`
pointe vers `expo/fetch` (WinterCG), qui ne comprend pas le raccourci FormData
`{uri, name, type}` utilisé pour joindre une photo (D5) — échec
`Unsupported FormDataPart implementation`, **classé à tort comme une coupure réseau**
par notre gestion d'erreur (`catch` générique de `envoyerSaisie`), donc invisible et
mis en boucle de retry silencieuse. **Fix permanent** : `EXPO_PUBLIC_USE_RN_FETCH=1`
dans `apps/mobile/.env` (committé — ce n'est pas un secret), qui restaure le `fetch`
classique de React Native, compatible avec ce raccourci.
5. **Découverte réseau de Metro par le dev client peu fiable** : un `git clone` de
dépendance (`ZXingObjC`) a échoué deux fois sur timeout réseau (retry + réglages git
`http.postBuffer`/`http.lowSpeedTime` ont suffi) ; l'IP LAN du Mac a changé quatre fois
pendant la session ; et **rouvrir l'app depuis son icône après une fermeture complète
échoue souvent** (`No script URL provided`, RCTFatal) même quand Metro tourne et est
joignable — la découverte Bonjour du dev client n'est pas fiable sur ce Wi-Fi. **Fix
fiable** : relancer via `npx expo run:ios --device <udid>` (ou `run:android`), qui
transmet l'adresse de Metro explicitement par deep link
(`siop://expo-development-client/?url=...`) au lieu de compter sur la découverte
automatique.
6. **Limite de fond, pas un bug** : un build **dev client** n'a aucun bundle JS embarqué —
il ne peut PAS démarrer à froid en étant déjà hors-ligne (aucun fallback possible). La
persistance de la file (D1) reste validée par un scénario réaliste et suffisant :
app **déjà ouverte**, saisie mise en file hors-ligne, **process tué puis relancé** avec
le réseau qui revient — la file survit et se rejoue (vérifié : un OT passé `ON_HOLD`
après fermeture complète + reconstruction de l'app). Valider un vrai démarrage à froid
totalement hors-ligne exigerait un build **Release** (bundle embarqué) — hors périmètre
d'une recette de développement, à envisager si ce cas précis devient un critère de
recette client.
7. **Détail UX, pas un bug** : le badge de compte (avatar) répond à un **appui long**
(`onLongPress`), pas un tap simple — c'est le déclencheur de déconnexion
(`app/(tabs)/journee.tsx`), volontairement sans confirmation puisqu'il est purement
local (jeton, file, cache — jamais le réseau, ADR-003). Vaut d'être rendu plus
visible si un retour utilisateur le demande.
## Conséquences
- La recette terrain iOS (7 points : connexion, scan caméra réel avec rejet de QR
étranger, vrai mode avion, conflit D2 tranché par l'humain, persistance, suggestions R5
au vrai modèle, purge de sécurité au changement de compte) est **validée sur iPhone 15
Pro physique** — la validation due depuis `release/r4` est levée.
- Android : build natif installé et passage santé fait sur **émulateur**
(`Pixel_3a_API_34`, connexion démo, navigation, écran Scanner sans plantage, résolution
de référence confirmée via la saisie manuelle — équivalent du scan sans caméra réelle).
La recette terrain sur **appareil Android physique** (vrai mode avion, vraie caméra)
reste un reste, comme pour iOS avant aujourd'hui, en l'absence d'appareil disponible.

View File

@@ -48,6 +48,8 @@ domaine (Traefik/Dokploy) ──▶ siop2-web :80 (nginx, statique)
| `AI_API_KEY` | — | clé API Anthropic — **uniquement** si `AI_GENERATION=api` ; le service refuse de démarrer si elle manque en mode api |
| `AI_MODEL` | — | défaut `claude-opus-4-8` |
| `AI_SEUIL_PERTINENCE` / `AI_SEUIL_SUGGESTION` / `AI_SEUIL_CONFIANCE_FORTE` | — | défauts 0,45 / 0,40 / 0,55 — **à calibrer sur le corpus client** (voir §7) |
| `AI_TRANSCRIPTION` | — | `off` (défaut) ou `locale` (dictée R5 D5, `faster-whisper` — le modèle est déjà dans l'image, l'activer ne demande pas de rebuild) |
| `AI_TRANSCRIPTION_MODEL` | — | défaut `small``tiny`/`base`/`small`/`medium`/`large-v3`, compromis qualité/vitesse CPU non calibré sur le darija (voir ADR-004 §5) |
Profils types :
@@ -125,6 +127,13 @@ version reste visible côté web).
- Mode génératif : ajouter `AI_GENERATION=api` + `AI_API_KEY` dans l'environnement
Dokploy puis redéployer le service `siop2-ai` seul. La clé ne transite jamais par
le dépôt ni par les journaux (`/healthz` n'expose que le mode).
- Dictée (D5) : ajouter `AI_TRANSCRIPTION=locale` dans l'environnement Dokploy puis
redéployer `siop2-ai` — le modèle Whisper est déjà dans l'image (§ci-dessus), pas
de rebuild ni de téléchargement au boot. L'audio n'est **jamais** stocké, ni par
`siop2-api` ni par `siop2-ai` : transcrit à la volée, effacé aussitôt (voir
ADR-004 §5). Point de vigilance non calibré : la qualité en darija (fréquent sur
le terrain, mélangé au français) — à mesurer sur de vrais enregistrements si la
dictée déçoit en recette.
## 6. Calibrage des seuils sur le corpus client

View File

@@ -4,6 +4,641 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
---
## 2026-08-03 — Pr. Daaif (+ Claude) — Recette R6 : Demandeur confirmé sur iPhone
**Actions**
- Suite du correctif R6.8 : le référent confirme sur iPhone physique, en Karim Doukkali
(Demandeur) — Accueil affiche correctement la liste de ses demandes avec leur statut, et une
nouvelle demande peut être ajoutée aussi bien par sélection dans la liste que par scan QR.
- Recette R6 sur ce point désormais complète : Gestionnaire (Accueil, OT, Menu → Demandes/Sites/
Ascenseurs/Stock/Tiers/Fichiers/Statistiques/Personnes, Assistant+dictée) et Demandeur (Accueil,
Nouvelle demande filtrée à son site, scan QR) tous deux vérifiés en conditions réelles sur
iPhone, au-delà du typecheck/tests/lint.
**Décisions**
- Aucune.
**Prochaine étape** : à trancher avec le référent — considérer R6 close (tag `release/r6`,
comme R0→R5) ou poursuivre sur un reste identifié (Vue seule/Dispatcher/Administrateur non
explicitement recettés, `expo-sharing`/`expo-clipboard`, Android physique, etc.).
---
## 2026-08-03 — Pr. Daaif (+ Claude) — R6.8 : connexion routait toujours vers « Ma journée »
**Contexte** — en recette, le référent a basculé sur Karim Doukkali (Demandeur) et a atterri sur
« Ma journée » (l'écran du Technicien, « Aucun OT en cours ») au lieu d'« Accueil ». Cause :
`app/connexion.tsx` — la fonction `entrer()` faisait `router.replace('/(tabs)/journee')` **en
dur**, quel que soit le rôle du compte choisi. La redirection par rôle (`ongletAccueil`) n'avait
été branchée qu'à l'aiguillage initial (`app/index.tsx`, R6.1) — pas au retour de connexion, le
chemin réellement emprunté à chaque bascule de compte démo (changer de compte = se déconnecter
puis se reconnecter, il n'y a pas de sélecteur de rôle en direct sur mobile).
**Actions**
- `useLogin()`/`useDemoLogin()` (`auth/session.tsx`) renvoient maintenant la réponse complète
(contient `user.role.name`) au lieu de la jeter après en avoir extrait le jeton.
- `connexion.tsx` : `entrer(role)` route vers `/(tabs)/${ongletAccueil(role)}` — les deux
boutons (connexion classique et sélecteur démo) passent le rôle réellement retourné par l'API,
jamais un onglet figé.
- Au passage : le sous-titre « Technicien » et l'accroche « sur le terrain — même sans réseau »
de l'écran de connexion étaient un reste de R4 (mobile alors réservé au Technicien) — devenus
trompeurs pour les autres rôles depuis R6, généricisés.
- Typecheck/tests/lint verts sur les 4 paquets.
**Décisions**
- Aucune — correction directe, aucun changement de comportement voulu au-delà du routage.
**Prochaine étape** : reprendre la recette Demandeur (Accueil, Nouvelle demande filtrée à Tour
Atlas, scan QR) une fois ce correctif rechargé sur l'iPhone.
---
## 2026-08-02 — Pr. Daaif (+ Claude) — R6.7 : assignation à l'approbation (mobile)
**Contexte** — en recette, le référent a signalé que le Gestionnaire doit pouvoir affecter un OT
à un technicien au moment où il approuve une demande — « ce n'est pas à lui d'agir comme un
technicien ». Vérification : le web le fait déjà (`PanneauApprobation`, `demandes.tsx`
sélecteur « Assigner à » parmi les techniciens actifs). Le mobile (`PanneauDemandes`, construit
en R6.1) approuvait directement avec la priorité seule, sans écran d'assignation — l'OT partait
non assigné, obligeant quelqu'un à revenir dessus ensuite pour le confier à un technicien.
**Actions**
- `apps/mobile/src/composants/panneau-demandes.tsx` : le bouton « Approuver → OT » ouvre
désormais un panneau (même patron que le rejet à motif) avec un `ChoixTel` « Assigner à »,
peuplé des techniciens actifs (`useUsers()` de `api/pilotage.ts`, filtre
`status === 'active' && role.name.startsWith('Technicien')` — identique au web), avant de
confirmer l'approbation (`assigneeIds` transmis à `POST /requests/{id}/approve`, déjà supporté
côté API et par le contrat — rien à changer côté serveur, seule l'UI mobile manquait cette
étape).
- Vérifié de bout en bout : demande créée par Karim → approuvée par Nadia (Gestionnaire) avec
Ahmed (Technicien) assigné → OT confirmé avec `assignees: [Ahmed Benali]`. Typecheck/tests/lint
verts sur les 4 paquets.
**Décisions**
- Le périmètre plus large — le Gestionnaire garde par la matrice R2/R3 `WORK_ORDERS` en accès
complet (`view/viewOther/create/edit/delete`), ce qui lui permet TECHNIQUEMENT de démarrer/
clôturer n'importe quel OT comme le ferait un technicien — n'est pas touché ici : c'est un
choix de matrice établi depuis R2/R3, identique sur le web, pas une régression du mobile R6.
Signalé au référent comme question ouverte séparée si un resserrement est souhaité.
**Prochaine étape** : reste de la recette R6 (Sites/Ascenseurs/Fichiers, puis passage Demandeur
avec le nouveau périmètre de site et le scan QR) ; question ouverte sur le périmètre d'édition OT
du Gestionnaire à trancher si besoin.
---
## 2026-08-02 — Pr. Daaif (+ Claude) — R6.6 : Demandeur restreint à son site
**Contexte** — en recette, le référent a remarqué que Karim Doukkali (Demandeur) voit tout le
parc dans le sélecteur d'équipement de « Nouvelle demande » : il pourrait accidentellement
signaler une panne sur un ascenseur qu'il ne gère pas. Investigation : le trou est dans
l'**API** — `GET /assets/options` (`assets.service.ts`) n'avait aucun filtre, et cet endpoint
est utilisé aussi bien par le web que par le mobile. Décision retenue : les deux à la fois —
association Demandeur → site (corrige web ET mobile) et scan QR comme raccourci mobile.
**Actions**
- 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é).
- Contrat (`packages/shared/schemas/users-admin.ts`) : `InvitationCreate`/`UserUpdate` gagnent
`locationIds?` (remplace l'affectation, comme `teamIds`) ; `UserAdmin` gagne `assignedSites`.
- `AssetsService.allowedLocationIds(user)` : site(s) + zones filles affecté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) pour que les deux
filtres ne divergent jamais.
- `UsersService` : `assertTopLevelSites` (un Demandeur est affecté à un site, jamais une zone —
même invariant que la hiérarchie site/zone) ; `invite()`/`update()` branchent `locationIds`.
- Web (`personnes.tsx`) : `ModaleInvitation` affiche une liste de sites à cocher quand le rôle
choisi est Demandeur ; colonne « Sites » dans le tableau, éditable via une petite modale
dédiée (`ModaleSites`, même hook `useUpdateUser` que le taux horaire).
- Mobile (`formulaire-demande.tsx`) : bouton « 📷 Scanner l'étiquette » sous le sélecteur
d'équipement — réutilise `analyseScan`/`expo-camera` du Scanner R4, mais résout **uniquement**
contre les options déjà chargées (déjà filtrées par site) — jamais de repli sur le parc
complet, ce qui annulerait la restriction. Aucun changement à `useAssetOptions()` : le
filtrage serveur profite automatiquement au formulaire.
- Seed : Karim Doukkali rattaché à **Tour Atlas** (cohérent avec le `guardianName` déjà présent
dans le seed pour ce site) ; ses demandes historiques sur d'autres sites restent visibles en
lecture, seule la création est désormais restreinte.
- **Bug trouvé en vérification (pas en recette, avant tout commit)** : la première version de
`RequestsService.create` comparait `allowed.includes(dto.assetId)` — mais `allowed` est une
liste d'**ids de sites/zones**, pas d'ids d'appareils. Comparaison apples-to-oranges, aurait
rejeté TOUT signalement d'un Demandeur affecté, y compris dans son propre périmètre (repéré en
testant B2/Tour Atlas avec Karim, qui échouait à tort). Corrigé : comparaison sur
`asset.locationId`. Méthode renommée `allowedAssetIds``allowedLocationIds` pour que le nom
dise ce qu'elle retourne réellement.
- Vérifié : filtrage confirmé côté serveur (Dispatcher = 8 appareils inchangés, Karim = 4
appareils de Tour Atlas seulement) ; défense en profondeur confirmée (asset hors périmètre →
400, asset dans le périmètre → 201). Suite de tests API : 79/80 verts — le seul échec
(`documents-analytics.e2e-spec.ts`, `monthCost`) est le flake déjà identifié cette session
(fenêtre calendaire réelle vs données de seed), sans rapport avec R6.6 ; `exploitation.e2e-spec.ts`
mis à jour (A1/C1 → A2/B1, dans le site de Karim, sinon rejetés par la nouvelle règle — c'est
le comportement voulu). Typecheck/tests/lint verts sur les 4 paquets.
**Décisions**
- Affectation au niveau **site** (pas zone, pas appareil individuel) — même granularité que
`Partner.sites`, cohérent avec le modèle existant.
- Gestion des sites d'un Demandeur reste une action **web uniquement** (admin) — le mobile
n'a que le raccourci scan, aucune UI de gestion.
**Prochaine étape** : reste de la recette R6 (Sites/Ascenseurs/Fichiers côté Gestionnaire, puis
passage Demandeur avec le nouveau périmètre) ; `expo-sharing`/`expo-clipboard` en réserve ;
restes non bloquants inchangés (Dokploy, Android physique, `AI_SEUIL_*`/darija,
`DOKPLOY_WEBHOOK_URL`).
---
## 2026-08-02 — Pr. Daaif (+ Claude) — Assistant mobile : dictée confirmée sur iPhone
**Actions**
- Suite directe de la recette Gestionnaire : trois allers-retours sur l'écran Assistant.
1. Crash au premier essai (`expo-file-system` deleteAsync déprécié, SDK 57) — corrigé par
l'import du sous-chemin `expo-file-system/legacy` (même fonction, comportement inchangé),
appliqué aussi à la clôture (`cloture.tsx`) qui utilisait le même appel sans avoir été
re-exercée depuis.
2. Texte transcrit pas entièrement visible pour la relecture (D5) — la barre de saisie sur une
ligne a été remplacée par une zone multiligne pleine largeur, « Envoyer » en geste séparé.
3. Le bouton « Envoyer » chevauchait encore le texte — deux causes cumulées : le `ScrollView`
du chat sans `style={flex:1}` (mal borné dans son parent), et le champ multiligne qui
s'appuyait sur `maxHeight` seul (ignoré par iOS dans ce cas, le champ grandissait avec le
contenu). Hauteur fixe + défilement interne garanti.
- **Confirmé par le référent sur iPhone physique : la dictée de la question dans l'Assistant
fonctionne** (relecture complète, envoi, réponse sourcée).
**Décisions**
- Aucune — trois corrections successives sur un même problème remonté en conditions réelles,
aucune n'aurait été trouvée par typecheck/tests/lint seuls (aucun test n'exerce le rendu RN
réel ni le comportement spécifique iOS de `TextInput`/`ScrollView`).
**Prochaine étape** : reste de la recette Gestionnaire (Sites/Ascenseurs/Fichiers à confirmer
explicitement), puis passage Demandeur.
---
## 2026-08-02 — Pr. Daaif (+ Claude) — Recette R6 (Gestionnaire, partie 1) — 2 bugs corrigés
**Actions**
- Début de la recette complète promise en fin de R6.5 : parcours du référent en Gestionnaire
(Nadia Berrada) sur iPhone physique, verrouillé côté serveur après chaque étape utile.
- **Bug réel 1 — Menu inaccessible en bas** : `app/(tabs)/menu.tsx` n'était PAS scrollable (un
simple `View`, pas de `ScrollView`) — avec les 4 groupes maintenant pleinement câblés (12 liens
et 4 en-têtes), le contenu dépasse la hauteur de l'écran et « Personnes & équipes » (dernier
groupe) était strictement hors d'atteinte, sans aucun moyen d'y accéder. Présent depuis R6.1,
seulement révélé une fois tous les groupes remplis. **Corrigé** : `ScrollView` autour de la
liste des groupes, en-tête (EnteteTabs + titre) fixe au-dessus.
- **Bug réel 2 — BC créé sans confirmation visible** : `app/stock/[id]/commander.tsx` naviguait
silencieusement en arrière après succès — le référent a créé DEUX bons de commande
(BC-2026-1009, BC-2026-1010, vérifiés côté serveur, tous deux corrects) sans aucun retour dans
l'app, doute légitime sur ce qui s'était vraiment passé. **Corrigé** : écran de confirmation
explicite (référence, fournisseur, total, statut) avant de revenir, même patron que
`personnes/lien.tsx`.
- **Amélioration Tiers** : le référent a signalé qu'on ne pouvait « ni ajouter ni consulter » —
l'absence de fiche détail (délibérée, D4 — lecture seule) se lisait comme un écran cassé plutôt
que simple. Arbitrage : fiche détail ajoutée (`app/tiers/[id].tsx` — identité, contact, BC en
cours pour un fournisseur, sites rattachés pour un client, aucune nouvelle requête, tout déjà
dans `usePartners()`), création/édition restent au web.
- **Faux positifs écartés, vérifiés côté serveur** :
- Approbation de DEM-2026-0110 (« Voyant étage éteint ») : a fonctionné (statut `APPROVED`,
OT-2026-1200 créé) — le message du référent citait juste le libellé de la demande.
- Fichiers « Aucun document accessible » : confirmé — `GET /documents` renvoie `[]` sur cette
instance. Pas un bug mobile : aucun document n'existe sur cette base locale (README/seed à
vérifier séparément si un jeu de documents de démo est attendu).
- Statistiques « les chiffres ne changent pas » entre 3/6/12 mois : confirmé côté serveur que la
période EST bien transmise et traitée (`costsByMonth` varie en nombre de points), mais les
indicateurs affichés (coût du mois, taux préventif, OT clôturés, pannes) ne varient pas parce
que l'activité codée sur ce jeu de données est concentrée sur une fenêtre récente — réalité
des données de démo, pas un bug du sélecteur.
- Vérifié après correctifs : typecheck propre, 17 tests Jest, lint 5/5 paquets.
**Décisions**
- Tiers gagne une fiche détail en lecture seule (voir ci-dessus) — seul écart au périmètre D4
initial de R6.3, sur retour direct de recette.
**Prochaine étape** : suite de la recette Gestionnaire (Sites/Ascenseurs/Fichiers/Assistant+dictée
restent à confirmer), puis passage Demandeur (permissions les plus étroites).
---
## 2026-08-02 — Pr. Daaif (+ Claude) — R6.5 : Assistant mobile (chat sourcé + dictée)
**Actions**
- Maquette dédiée (`docs/02-design/maquettes/maquette-mobile-assistant.html`, 3 écrans) rédigée et
validée par le référent : réponse sourcée, refus honnête chiffré, et — demande ajoutée en cours
de revue — **dictée de la question**. Décisions actées : D1 un échange à la fois (pas
d'historique multi-tours conservé, même limite que le web) ; D2 « Ouvrir l'OT » → fiche R4
telle quelle, « Voir le document » → métadonnées seules (bibliothèque R6.3, pas d'ouverture de
fichier tant qu'`expo-sharing` n'est pas ajouté) ; D3 refus chiffré + avertissement permanents,
identiques au web ; D4 Assistant rejoint le groupe Pilotage pour tout rôle avec
`WORK_ORDERS.view` ; **D5** la question peut être tapée OU dictée — même pipeline que la
dictée déjà livrée en clôture (ADR-004 §5, faster-whisper local, audio jamais persisté) — la
transcription REMPLIT le champ de saisie, éditable, jamais d'envoi automatique.
- `api/assistant.ts` (`useAskAssistant`, 503 géré comme le web — état attendu du contrat, pas une
erreur).
- Écran **Assistant** (`app/assistant/index.tsx`) : chat à un échange à la fois, citations
numérotées, cartes sources (extrait exact + « Voir le document »/« Ouvrir l'OT »), refus honnête
chiffré avec « Reformuler », avertissement permanent. Micro dans la barre de saisie : réutilise
telle quelle la mécanique de `cloture.tsx` (`useAudioRecorder`, `setAudioModeAsync`,
`useTranscription`, purge `FileSystem.deleteAsync` quoi qu'il arrive) — aucun nouveau bug à
découvrir, le pipeline est déjà recetté sur iPhone physique (01/08).
- Fiche document (`app/bibliotheque/[id].tsx`, nouveau) : métadonnées seules, destination de
« Voir le document » — la liste `bibliotheque/index.tsx` (R6.3) y mène aussi désormais (les
cartes étaient jusque-là décoratives, sans action).
- Menu : Assistant route réellement — **le Menu R6 n'a plus aucune entrée « à venir » dans les 4
groupes** (Catégories excepté, admin, toujours hors périmètre mobile).
- Vérifié : typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché.
**Décisions**
- Voir D1-D5 ci-dessus (maquette dédiée, seule sous-étape de R6 à avoir eu son propre tour de
design-first plutôt qu'une exécution directe de la maquette R6 initiale).
**Prochaine étape** : **R6 fonctionnellement complet** (Exploitation/Parc/Ressources/Pilotage,
Assistant inclus, pour tous les rôles). Reste la recette complète avant de considérer R6 close au
sens R0→R5 (chaque sous-étape n'a été vérifiée que par typecheck/tests/lint jusqu'ici, jamais en
usage réel au-delà de la confirmation R6.1) ; `expo-sharing`/`expo-clipboard` en réserve pour une
prochaine étape native ; restes non bloquants inchangés (Dokploy, Android physique,
`AI_SEUIL_*`/darija, `DOKPLOY_WEBHOOK_URL`).
---
## 2026-08-02 — Pr. Daaif (+ Claude) — Confirmation référent : nav adaptative + déconnexion
**Actions**
- Le référent confirme sur son iPhone (app déjà connectée au serveur Metro) : la barre d'onglets
adaptative (R6.1) et le correctif de déconnexion (`EnteteTabs`, tap simple + confirmation
depuis n'importe quel onglet) fonctionnent tels que livrés. Dernier point resté ouvert depuis le
début de R6 — clos.
**Décisions**
- Aucune.
**Prochaine étape** : une recette plus complète (parcourir Sites/Ascenseurs/Stock/Tiers/
Statistiques/Personnes sur au moins 2 rôles non-technicien) reste à faire avant de considérer R6
close au même sens que R0→R5 (chacune avait sa recette chiffrée avant tag) — pour l'instant
chaque sous-étape n'a été vérifiée que par typecheck/tests/lint, jamais en usage réel au-delà de
ce point précis. Sinon : maquette dédiée pour l'Assistant mobile, `expo-sharing`/`expo-clipboard`
en réserve.
---
## 2026-08-02 — Pr. Daaif (+ Claude) — R6.4 : Pilotage sur mobile (Statistiques, Personnes)
**Actions**
- `EXPO_PUBLIC_WEB_URL` (nouveau, `.env`) + `WEB_URL` (`api/client.ts`) : le lien d'activation
« Personnes » pointe la page web `/activation?token=…` (il n'existe pas d'équivalent mobile,
même page que R1) — variable par environnement comme `EXPO_PUBLIC_API_URL`.
- `apps/mobile/src/api/pilotage.ts` (nouveau) : `useAnalyticsSummary`, `useUsers`, `useRoles`,
`useInviteUser`.
- Écran **Statistiques** (`app/statistiques/index.tsx`) : maquette écran 7 — sélecteur de période
3/6/12 mois, coût du mois, taux préventif, OT clôturés, pannes par organe (top 5), top
équipements en coût. Mêmes chiffres que `/analytics/summary` (web), en cartes plutôt qu'en
graphes denses (D4).
- Écran **Personnes & équipes** (`app/personnes/index.tsx`) : liste avec statut (actif/invitation
envoyée/désactivé) ; **Inviter** (`app/personnes/inviter.tsx` — nom, e-mail, rôle) ; **lien
d'activation émis** (`app/personnes/lien.tsx`) affiché en texte **sélectionnable** (`Text
selectable`, RN natif) plutôt que via presse-papiers — `expo-clipboard` est une dépendance
native absente du projet, même raisonnement que `expo-sharing` en R6.3 : différée plutôt
qu'ajoutée à la légère, un appui long suffit pour copier. Taux horaire, rôles, équipes restent
gérés depuis le web (D4).
- Menu : Statistiques et Personnes & équipes routent réellement. **Assistant reste à venir** — un
chat sourcé (citations, refus honnête) est un nouveau patron d'écran, jamais maquetté sur
mobile (contrairement à Sites/Ascenseurs/Stock/Tiers/Personnes qui réutilisaient des patrons
déjà validés Carte/LigneInfo/EnteteFiche) : mérite son propre tour de design-first plutôt que
d'être exécuté par réflexe en fin de release.
- Vérifié : typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché.
**Décisions**
- Aucune nouvelle sur le fond — exécution de la maquette R6 déjà validée. Deux mêmes arbitrages
que R6.3 reconduits explicitement : pas de nouvelle dépendance native au milieu d'une passe
(`expo-clipboard` comme `expo-sharing`), et l'Assistant IA mobile est repoussé à un maquettage
dédié plutôt que forcé dans le périmètre « actions courantes » de R6.
**Prochaine étape****R6 quasi close** : Exploitation/Parc/Ressources/Pilotage (hors Assistant)
livrés pour tous les rôles. Restes : confirmation du référent sur iPhone (R6.1 + correctif
déconnexion, toujours en attente — condition de la clôture réelle de R6) ; maquette dédiée pour
l'Assistant mobile (question ouverte : l'ajouter à R6 ou une release à part) ; `expo-sharing` et
`expo-clipboard` pour les flux mobiles restés en lecture seule ; restes non bloquants inchangés
(Dokploy, Android physique, `AI_SEUIL_*`/darija, `DOKPLOY_WEBHOOK_URL`).
---
## 2026-08-02 — Pr. Daaif (+ Claude) — R6.3 : Ressources sur mobile (Stock, Tiers, Fichiers)
**Actions**
- `apps/mobile/src/api/ressources.ts` (nouveau fichier, mêmes opérations que `gestion.ts` côté
web) : `usePartners`, `useParts`/`usePart`, `useCreatePurchaseOrder`, `useDocuments` (bibliothèque
complète, sans filtre — distincte de `useDocumentsOT` déjà scopée à un OT).
- Écran **Stock** (`app/stock/index.tsx`) : pièces sous seuil en tête (même logique que `stock.tsx`
web), badge compteur. **Fiche pièce** (`app/stock/[id].tsx`) : identité + « Commander » si
`PURCHASE_ORDERS.create` ET fournisseur associé — sinon message explicite plutôt qu'un bouton
mort. **Nouveau BC** (`app/stock/[id]/commander.tsx`) : une seule ligne pré-remplie depuis
l'alerte (fournisseur figé, quantité par défaut = manquant jusqu'au seuil, prix par défaut =
dernier prix connu) — le bon de commande multi-lignes détaillé reste au web (D4).
- Écran **Tiers** (`app/tiers/index.tsx`) : liste fournisseurs/clients, **lecture seule** sur
mobile pour cette passe (D4 — création/édition réservées au web).
- Écran **Fichiers** (`app/bibliotheque/index.tsx`) : métadonnées seulement (nom, taille,
rattachement), **pas d'ouverture/téléchargement** — ce flux demande `expo-sharing` (dépendance
native absente du projet) et donc un nouveau cycle de build natif ; reporté plutôt
qu'ajouté à la légère au milieu de cette passe. Le bandeau de l'écran le dit explicitement.
- Menu : Stock & achats / Tiers / Fichiers routent réellement.
- Vérifié : typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat (`schema.d.ts`) non touché
— toutes les opérations utilisées existaient déjà (R3).
**Décisions**
- Aucune nouvelle — Ressources exécute la maquette R6 déjà validée (écran 6, D4) ; Tiers/Fichiers
n'y étaient pas maquettés en détail mais suivent la même logique de consultation déjà actée,
avec les patrons visuels déjà validés (Carte/LigneInfo/EnteteFiche).
- L'ouverture de document sur mobile est explicitement différée à une prochaine étape (nouvelle
dépendance `expo-sharing` = nouveau build natif à valider, pas à empiler sur cette passe).
**Prochaine étape** : R6.4 (Pilotage — Statistiques, Personnes, Assistant) ; confirmation du
référent sur iPhone pour R6.1/logout, toujours en attente.
---
## 2026-08-02 — Pr. Daaif (+ Claude) — R6.2 : Parc sur mobile (Sites, Ascenseurs)
**Actions**
- `useLocations()` mobile (`GET /locations`, même liste plate que le web — le client construit
l'arbre site → zone).
- Écran **Sites** (`app/sites/index.tsx`) : sites de premier niveau (`parentId === null`), nom,
ville/adresse, nombre d'appareils.
- **Fiche site** (`app/sites/[id].tsx`) : identité (adresse, gardien, client/syndic), zones avec
leurs appareils, liste des ascenseurs du site — même filtre `siteName` que `fiche-site.tsx`
(web). Pas de carte interactive ni d'édition sur mobile (D4 — consultation seule pour cette
famille, réservé au web pour l'instant).
- Écran **Ascenseurs** (`app/ascenseurs/index.tsx`) : le parc complet, déjà préchargé (D1 lecture
locale, même donnée que le Scanner), trié par référence. Ouvre la fiche appareil R4 telle
quelle (`ascenseur/[id].tsx`, déjà générique — aucune modification nécessaire, elle dérive déjà
ce qu'elle affiche du rôle appelant côté serveur).
- Menu : « Ascenseurs » et « Sites » routent maintenant vers ces écrans réels (retirés de la
liste « à venir ») ; « Catégories » reste à venir (admin, hors périmètre R6.2).
- Vérifié : typecheck propre, 17 tests Jest, lint 5/5 paquets.
**Décisions**
- Aucune nouvelle — exécution directe de la maquette R6 déjà validée (Parc = D4, consultation
seule) ; pas de nouveau tour de maquette pour cette sous-étape, les patrons visuels réutilisés
(Carte/LigneInfo/EnteteFiche) sont déjà validés depuis R4.
**Prochaine étape** : R6.3 (Ressources — Stock, Tiers, Fichiers) ; confirmation du référent sur
iPhone pour R6.1 et le correctif déconnexion, toujours en attente.
---
## 2026-08-02 — Pr. Daaif (+ Claude) — R6 lancée : mobile ouvert à tous les rôles
**Contexte** — deux retours du référent en test réel sur iPhone : (1) une fois authentifié,
impossible de revenir en arrière ou de se déconnecter ; (2) l'app mobile devrait être accessible
à tous les rôles, pas seulement au Technicien, avec les mêmes actions que le web. Le premier est
un bug, corrigé dans la foulée ; le second est un changement de fond — R4 avait délibérément
fermé le périmètre au Technicien (décision actée dans ses maquettes). Ouvrir à tous les rôles
veut dire porter sur mobile une bonne partie de ce que fait le web (référentiel, ressources,
personnes, statistiques…) : plus gros que R4 dans son ensemble, donc une **nouvelle release,
R6** (R4 est close depuis le 17/07, R4.1/R4.2/R4.3 déjà pris par le socle mobile technicien
d'origine — pas question de les réutiliser et de brouiller l'historique).
**Actions**
- **Correctif déconnexion** : `EnteteTabs` (`composants/ui.tsx`) factorise une entête commune aux
4 onglets terrain — badge de compte + confirmation `Alert` au tap simple (l'ancien geste,
`onLongPress` sur « Ma journée » seulement, n'était pas découvrable). Le rôle affiché vient du
compte connecté, plus de libellé « Technicien » figé.
- **Maquette R6** (`docs/02-design/maquettes/maquette-mobile-tous-roles.html`, 7 écrans) rédigée
et validée par le référent le jour même : synthèse par rôle, Accueil adaptatif, Menu groupé
filtré par la matrice, OT côté Dispatcher, Demandes (création + approbation), Stock, Personnes
& statistiques. Cinq décisions actées : D1 barre d'onglets adaptative (Technicien/Technicien
limité inchangés + onglet Menu ajouté) ; D2 le Menu reprend à l'identique les 4 groupes du web
(Exploitation/Parc/Ressources/Pilotage), même matrice, aucune règle de droit nouvelle ; D3 un
groupe sans lien visible est masqué en entier (contrairement au web) ; D4 le mobile porte les
actions courantes par famille, pas les flux de gestion les plus denses (réservés au web pour
l'instant) ; D5 aucune logique de permission propre au mobile — un hook `usePermissions()`
calqué sur celui du web.
- **R6.1 — socle** : `usePermissions()` mobile (lit `me.permissions`, déjà renvoyé par
`/users/me`) ; barre d'onglets adaptative (`ongletsVisibles` par rôle, `href: null` pour masquer
sans retirer du navigateur — le Menu peut toujours y pousser) ; écrans Accueil (dashboard réel
pour Administrateur/Gestionnaire/Dispatcher/Vue seule — aucun chiffre inventé, seuls
WORK_ORDERS/REQUESTS sont câblés), OT (liste complète, `viewOther` déjà géré serveur, réutilise
la fiche OT R4 telle quelle — elle dérive déjà ses actions de `allowedTransitions`), Menu
(groupes filtrés, groupe vide masqué), Demandes (`PanneauDemandes`, un seul composant pour tous
les rôles : création + suivi pour le Demandeur, approbation/rejet à motif pour
Gestionnaire/Dispatcher/Administrateur, lecture seule pour Vue seule — même patron que le web,
découverte au passage : `GET /assets/options` déjà ouvert à tout rôle authentifié, réutilisé
tel quel). Familles pas encore portées (Sites, Ascenseurs, Stock, Tiers, Fichiers, Statistiques,
Assistant, Personnes) : écran « à venir » honnête (`composants/a-venir.tsx`, déjà présent mais
jamais câblé jusqu'ici) plutôt qu'un lien mort — chacune mérite son propre passage, sur le
modèle R4.1→R4.3.
- Vérifié : typecheck propre, 17 tests Jest verts, lint 5/5 paquets. Pas de vérification visuelle
en navigateur de mon côté (aucun outil de ce type dans cet environnement) — à confirmer par le
référent sur l'iPhone déjà connecté au serveur Metro.
**Décisions**
- Nouvelle release **R6** (pas R4.1/4.2/4.3, déjà pris ; pas de réouverture de R4, clos) pour tout
le chantier « mobile ouvert à tous les rôles ». Sous-étapes à venir : R6.2 (Parc — Sites,
Ascenseurs), R6.3 (Ressources — Stock, Tiers, Fichiers), R6.4 (Pilotage — Statistiques,
Personnes, Assistant), sur le modèle des sous-releases R4.1→R4.3.
**Prochaine étape** : R6.2 (Parc sur mobile) ; confirmation du référent sur iPhone (nav adaptative
et correctif déconnexion) ; restes non bloquants inchangés (recette Android physique,
redéploiement Dokploy, calibrage `AI_SEUIL_*`/darija, `DOKPLOY_WEBHOOK_URL`).
---
## 2026-08-01 — Pr. Daaif (+ Claude) — Dictée validée sur iPhone physique (ADR-004 §5)
**Actions**
- Reprise du reste laissé le 22/07 : le test tactile de la dictée sur iPhone, alors bloqué par une connexion
USB qui ne se rétablissait pas. Diagnostic cette fois : `system_profiler` (l'outil utilisé pour vérifier la
détection USB) était lui-même en panne et renvoyait un faux négatif silencieux — `ioreg` a révélé que
l'iPhone était en réalité détecté. Le vrai câble/port n'a jamais été le problème.
- Une fois l'app reconstruite (certificat gratuit expiré après plus d'une semaine — régénéré via le pipeline
officiel `expo run:ios`, jamais par un contournement manuel), **deux bugs réels trouvés et corrigés** :
1. `expo-audio` sur iOS refuse d'enregistrer sans un appel explicite à `setAudioModeAsync({ allowsRecording:
true })` avant `recorder.record()` — absent du code initial, jamais détecté hors d'un vrai appareil.
2. Le correctif des modules Expo/RN précompilés (ADR-005, `EXPO_USE_PRECOMPILED_MODULES=0` /
`RCT_USE_PREBUILT_RNCORE=0`) n'avait **jamais été committé** dans `apps/mobile/.env`, contrairement au
correctif FormData voisin déjà permanent — un vrai trou qui aurait refait planter l'app à la prochaine
reconstruction depuis zéro. Corrigé : les deux variables vivent maintenant côte à côte dans `.env`.
- **Recette réelle bout en bout** : micro → dictée d'une phrase → transcription fidèle (`faster-whisper` réel)
→ relecture → sauvegarde dans le bilan → clôture de l'OT → réindexation du corpus → **le contenu dicté
retrouvé par la recherche sémantique** (score 0,476). La boucle complète de l'idée d'origine (« cette
transcription devrait alimenter le corpus ») est validée sur vrai matériel, vraie voix, vrai réseau.
- Infra locale relancée depuis zéro après une semaine d'inactivité (Docker, conteneurs `siop2-*`, API,
service IA) — occasion de vérifier que le redémarrage à froid fonctionne proprement.
**Décisions**
- Aucune — cette session referme un reste déjà décidé, sans nouvelle décision de conception.
**Prochaine étape** : recette Android sur appareil physique (toujours en attente d'un appareil) ; redéploiement
Dokploy (`AI_SERVICE_TOKEN`) ; calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel ; secret
`DOKPLOY_WEBHOOK_URL`.
---
## 2026-07-22 — Pr. Daaif (+ Claude) — Dictée implémentée : audio → transcription locale → corpus (ADR-004 §5)
**Actions**
- Suite du feu vert « on passe à l'audio » (maquette Voix amendée le 22/07) : implémentation complète de la
dictée (R5 D5) — écran maquetté jamais construit jusqu'ici, ni open source ni local en LLM externe, choix du
référent réaffirmé (« toujours pour l'open source et le local »).
- **`apps/ai`** : `faster-whisper` (CTranslate2, CPU, MIT), `AI_TRANSCRIPTION=off|locale|deterministe` (défaut
off), `AI_TRANSCRIPTION_MODEL` (défaut `small`) ; endpoint `/internal/transcrire` — l'audio ne survit
JAMAIS à l'appel (fichier temporaire supprimé quoi qu'il arrive, succès ou erreur) ; `indexer_bilans` inclut
désormais `InterventionReport.note` (anonymisée par le même pipeline D4) — le champ existait en base et au
contrat depuis R2 mais **n'avait jamais eu d'écran** ; 29 pytest (dont le transcripteur déterministe pour CI).
- **Contrat** (77 opérations) : `POST /assistant/transcribe` (multipart, `TranscriptionResult`).
- **API NestJS** : proxy multipart vers `siop2-ai` (`AssistantService.transcribe`), `WORK_ORDERS.edit` — même
droit que la saisie du bilan qu'elle alimente ; 2 tests e2e ajoutés (80 tests API au total).
- **Mobile** : `expo-audio` (enregistrement) + `expo-file-system` (purge locale) ; carte « Décrire pour
suggérer » de l'écran de clôture gagne un bouton dicter/terminer, une confirmation de purge, et
« Joindre la description à l'OT » (sauvegarde dans `note` via la file existante — corrige au passage un bug
latent : `enfilerBilan` ignorait silencieusement toute mise à jour de `note`).
- **Docker** : `siop2-ai` embarque désormais aussi le modèle Whisper au build (image 1,54 Go → 2,19 Go) —
construit et vérifié réellement (transcription en conteneur, non-root, 0 téléchargement au démarrage).
- **Vérification réelle** (voix de synthèse macOS `say`, français) : transcription fidèle en direct
(`TranscripteurLocal`), bout en bout via l'API NestJS, et dans le conteneur Docker construit — puis chaîne
complète corpus confirmée : note sauvegardée → OT clôturé → réindexation → contenu retrouvé par recherche
sémantique avec un bon score de pertinence.
- **Nettoyage** : la base de dev locale, polluée par les tests manuels de la recette terrain (deux OT seedés
clôturés en dehors de leur état d'origine), a été réinitialisée avec l'accord explicite du référent
(`prisma migrate reset --force`, bloqué par défaut pour un agent IA — consentement demandé et obtenu avant
exécution).
**Décisions**
- Pas de prototype de mesure français/darija avant l'implémentation (confirmé une seconde fois) — seuls des
contrôles d'ingénierie de base (le code tourne, avec de la vraie parole) ont été faits, pas un calibrage.
- **Reste** : le test tactile sur iPhone physique (bouton dicter, permission micro) n'a pas pu se jouer —
blocage USB persistant malgré câble/port/redémarrage/mode développeur essayés à plusieurs reprises. Reporté
comme la recette Android, sur décision du référent — ne bloque pas la suite.
**Prochaine étape** : test tactile de la dictée sur iPhone dès que la connexion USB fonctionnera ; recette
Android sur appareil physique ; redéploiement Dokploy (`AI_SERVICE_TOKEN`) ; calibrage `AI_SEUIL_*` et qualité
darija sur corpus SPELEV réel.
---
## 2026-07-22 — Pr. Daaif (+ Claude) — Idée backlog : transcription audio → corpus (maquette amendée, pas codée)
**Actions**
- Discussion de conception (aucun code) : étendre l'écran « Voix » de R5 (dictée, jamais construite — option
« si budget temps ») pour que la transcription relue, une fois jointe à l'OT, **alimente aussi le corpus** de
l'assistant à la clôture — pas seulement la description libre ponctuelle de la suggestion.
- Écarté : Ollama local pour les embeddings du corpus (déjà 100 % locaux via `fastembed`, aucun gain — pertinent
plutôt pour remplacer l'API Claude opt-in de génération, piste non creusée).
- Moteur retenu pour une future transcription locale : `faster-whisper` (CTranslate2, MIT, CPU) — même
philosophie que les embeddings. Point de vigilance identifié : Whisper transcrit mal le darija, probable en
mélange avec le français sur le terrain.
- Point d'accroche trouvé dans le schéma existant : `InterventionReport.note` (texte libre) existe déjà en base
et en UI mais n'est **pas encore indexé** dans le corpus (`indexer_bilans` ne reprend que les libellés codés).
- Gap identifié dans `anonymisation.py` : reconnaît seulement les noms CONNUS de la base, pas de NER générale —
risque de fuite plus élevé sur de la parole libre que sur des libellés codés.
- **Maquette amendée et validée** (`maquette-r5.html`, écran 6 « Voix ») : bandeau d'intention et carte
« Et ensuite » disent maintenant explicitement que le texte (anonymisé) rejoint le corpus à la clôture de l'OT.
**Décisions**
- Pas de prototype de mesure préalable (contrairement au calibrage des embeddings R5) — le calibrage
français/darija se fera plus tard si besoin.
- **Implémentation non demandée pour l'instant** — la maquette est validée, le code attend un prochain feu vert.
**Prochaine étape** : reprendre sur demande du référent — implémentation cohérente avec le flux esquissé (audio
jamais persisté, transcription → `InterventionReport.note` → corpus à la clôture), moteur et calibrage déjà
tranchés, pas à rediscuter.
---
## 2026-07-19 — Pr. Daaif (+ Claude) — Recette terrain mobile sur iPhone 15 Pro : les 7 points validés (ADR-005)
**Actions**
- **La recette « mode avion » due depuis `release/r4`** a enfin pu se jouer sur un vrai appareil (iPhone 15 Pro
du référent). Premier obstacle immédiat : Expo Go (App Store, dernière version) refuse le projet — « supported
SDK 54 » contre notre SDK 57, retard d'approbation Apple structurel, hors de notre contrôle. **Décision du
référent** : construire des builds natifs locaux (Xcode/Android Studio, signature gratuite via Apple ID
personnel) pour la vraie recette terrain, tout en conservant Expo Go comme canal léger pour un aperçu sans
installation ailleurs — actée dans **ADR-005**.
- **Cinq obstacles techniques réels** rencontrés et corrigés en route (détaillés dans ADR-005) : ciblage par UDID
plutôt que nom d'appareil (apostrophe) ; ne jamais contourner `expo run:ios` par un `xcodebuild` manuel (a
produit un crash `dyld: Library not loaded React.framework`) ; **incompatibilité des modules Expo/RN
précompilés (SDK 56/57) avec la liaison statique du projet** — fix `EXPO_USE_PRECOMPILED_MODULES=0` +
`RCT_USE_PREBUILT_RNCORE=0` ; **`fetch` global (`expo/fetch`) incompatible avec l'upload multipart natif**
(`Unsupported FormDataPart implementation`, classé à tort « réseau indisponible » par notre propre gestion
d'erreur) — fix permanent `EXPO_PUBLIC_USE_RN_FETCH=1` committé dans `apps/mobile/.env` ; découverte réseau du
dev client peu fiable sur ce Wi-Fi (IP du Mac changée 4 fois, reprises via `expo run:ios --device <udid>` qui
transmet l'adresse de Metro par deep link plutôt que par découverte automatique).
- **Recette terrain iOS — 7/7 points validés sur iPhone physique** : (1) connexion démo ; (2) scan caméra réel —
résolution locale d'une étiquette réelle (D4) + rejet propre d'un QR étranger ; (3) vrai mode avion (D1) — file
d'écriture visible ; (4) verrou optimiste (D2) — conflit provoqué en modifiant l'OT côté serveur pendant la
coupure, écran Synchro affichant le conflit, tranché par l'humain, OT clos avec bilan complet et **photo
réellement téléversée** (885 Ko, compression D5 confirmée) ; (5) persistance — saisie en file survivant à une
fermeture complète + reconstruction de l'app, resynchronisée au retour réseau (limite documentée : un build dev
client ne peut PAS démarrer à froid hors-ligne, aucun bundle embarqué — un vrai test « cold start offline »
demanderait un build Release, hors périmètre aujourd'hui) ; (6) suggestions IA R5 au vrai modèle sur le
téléphone, chips appliqués pré-remplissant les sélecteurs ; (7) sécurité ADR-003 — déconnexion (appui long,
purement locale) puis bascule de compte démo, file et cache vides, aucune fuite entre comptes.
- **Android** : build natif installé sur l'émulateur (`Pixel_3a_API_34`, Android Studio déjà en place) ; passage
santé complet — connexion démo, navigation Ma journée → fiche appareil (résolution de référence manuelle,
équivalent du scan sans caméra d'émulateur, confirmée fonctionnelle), écran Scanner sans plantage.
**Décisions**
- ADR-005 actée : deux canaux de distribution mobile (builds natifs pour la vraie recette, Expo Go pour l'aperçu
sans installation), managed workflow conservé (`ios/`/`android/` jamais committés).
- La validation terrain due depuis `release/r4` est **levée pour iOS**. La recette sur **appareil Android
physique** (vrai mode avion, vraie caméra) reste un reste, faute d'appareil disponible aujourd'hui — même
situation qu'iOS avant cette session.
**Prochaine étape** : recette Android sur appareil physique quand disponible ; redéploiement Dokploy
(`release/r3` puis r5 avec `AI_SERVICE_TOKEN`) ; calibrage `AI_SEUIL_*` sur corpus SPELEV réel ; secret
`DOKPLOY_WEBHOOK_URL`.
---
## 2026-07-17 — Pr. Daaif (+ Claude) — 🏁 R5 CLOSE : tag `release/r5`
**Actions**
- Tag annoté `release/r5` posé sur `cccfaaa` (CI verte, 8/8 jobs) et poussé — sur décision du référent,
après recette sans clé API et revue pixel validée (arbitrage tableau appliqué).
- R0 → R5 : le périmètre v1 du plan de releases est couvert. La suite est de l'exploitation :
déploiements, calibrage, recettes différées.
**Décisions**
- R5 est la dernière release du plan — les évolutions suivantes (voix opt-in « si budget temps »,
backlog v2) s'ouvriront par de nouvelles maquettes, même méthode.
**Prochaine étape** : redéploiement Dokploy de l'instance ENSET (main = release/r5 ; nouvelles variables
`AI_SERVICE_TOKEN` obligatoire — runbook §2 — puis « Réindexer tout »), recette R4 sur téléphone (Expo Go,
due avant production client mobile), calibrage `AI_SEUIL_*` sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`.
---
## 2026-07-17 — Pr. Daaif (+ Claude) — Arbitrages de recette R5 rendus : le corpus passe en tableau
**Actions**

View File

@@ -1322,6 +1322,54 @@
}
}
},
"/assistant/transcribe": {
"post": {
"operationId": "transcribeAudio",
"summary": "Dictée (R5 D5, opt-in) — laudio est transcrit puis JAMAIS conservé, à relire avant tout usage",
"tags": [
"assistant"
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"file": {
"type": "string",
"format": "binary"
}
},
"required": [
"file"
]
}
}
}
},
"responses": {
"200": {
"description": "Texte transcrit — à relire (D1)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TranscriptionResult"
}
}
}
},
"503": {
"description": "Dictée non activée ou service IA indisponible"
}
}
}
},
"/search": {
"get": {
"operationId": "globalSearch",
@@ -4539,6 +4587,27 @@
"type": "null"
}
]
},
"assignedSites": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
],
"additionalProperties": false
}
}
},
"required": [
@@ -4550,7 +4619,8 @@
"teams",
"status",
"isDemo",
"hourlyRate"
"hourlyRate",
"assignedSites"
],
"additionalProperties": false
}
@@ -4656,6 +4726,14 @@
"phone": {
"type": "string",
"maxLength": 40
},
"locationIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
}
}
},
"required": [
@@ -4760,6 +4838,27 @@
"type": "null"
}
]
},
"assignedSites": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
],
"additionalProperties": false
}
}
},
"required": [
@@ -4771,7 +4870,8 @@
"teams",
"status",
"isDemo",
"hourlyRate"
"hourlyRate",
"assignedSites"
],
"additionalProperties": false
},
@@ -4814,6 +4914,14 @@
"type": "null"
}
]
},
"locationIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
}
}
},
"additionalProperties": false
@@ -5427,6 +5535,19 @@
],
"additionalProperties": false
},
"TranscriptionResult": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"text": {
"type": "string"
}
},
"required": [
"text"
],
"additionalProperties": false
},
"SearchResponse": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",

View File

@@ -78,6 +78,10 @@ services:
AI_SEUIL_PERTINENCE: ${AI_SEUIL_PERTINENCE:-0.45}
AI_SEUIL_SUGGESTION: ${AI_SEUIL_SUGGESTION:-0.40}
AI_SEUIL_CONFIANCE_FORTE: ${AI_SEUIL_CONFIANCE_FORTE:-0.55}
# Dictée opt-in (R5 D5, ADR-004 §5) : off par défaut ; le modèle est déjà
# dans l'image (§ci-dessus), l'activer ne demande pas de rebuild.
AI_TRANSCRIPTION: ${AI_TRANSCRIPTION:-off}
AI_TRANSCRIPTION_MODEL: ${AI_TRANSCRIPTION_MODEL:-small}
depends_on:
siop2-postgres:
condition: service_healthy

View File

@@ -57,6 +57,7 @@ import {
BilanSuggestionsResponseSchema,
ReindexResultSchema,
SuggestBilanSchema,
TranscriptionResultSchema,
} from './schemas/assistant';
import {
ConsumePartSchema,
@@ -564,6 +565,18 @@ export const API_CONTRACT: ApiOperation[] = [
503: { description: 'Service IA indisponible' },
},
},
{
operationId: 'transcribeAudio',
method: 'post',
path: '/assistant/transcribe',
summary: 'Dictée (R5 D5, opt-in) — laudio est transcrit puis JAMAIS conservé, à relire avant tout usage',
tags: ['assistant'],
multipartFields: { file: 'file' },
responses: {
200: { description: 'Texte transcrit — à relire (D1)', name: 'TranscriptionResult', schema: TranscriptionResultSchema },
503: { description: 'Dictée non activée ou service IA indisponible' },
},
},
{
operationId: 'globalSearch',
method: 'get',

View File

@@ -71,3 +71,11 @@ export const ReindexResultSchema = z.object({
chunks: z.number().int(),
});
export type ReindexResult = z.infer<typeof ReindexResultSchema>;
// ————— Dictée (R5 D5, opt-in) — l'audio ne transite qu'une fois, jamais stocké —————
export const TranscriptionResultSchema = z.object({
/** À relire avant tout usage (D1) — jamais enregistré tel quel. */
text: z.string(),
});
export type TranscriptionResult = z.infer<typeof TranscriptionResultSchema>;

View File

@@ -16,6 +16,8 @@ export const UserAdminSchema = z.object({
isDemo: z.boolean(),
/** Taux horaire COURANT (MAD/h) — chaque saisie de main-d'œuvre fige le sien. */
hourlyRate: z.number().nullable(),
/** R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction. */
assignedSites: z.array(z.object({ id: z.uuid(), name: z.string() })),
});
export type UserAdmin = z.infer<typeof UserAdminSchema>;
@@ -34,6 +36,7 @@ export const UserUpdateSchema = z.object({
teamIds: z.array(z.uuid()).optional(), // remplace l'affectation
isActive: z.boolean().optional(),
hourlyRate: z.number().positive().nullable().optional(), // R3 — taux courant
locationIds: z.array(z.uuid()).optional(), // R6.6 — remplace l'affectation (sites racine)
});
export type UserUpdate = z.infer<typeof UserUpdateSchema>;
@@ -45,6 +48,7 @@ export const InvitationCreateSchema = z.object({
roleId: z.uuid(),
teamIds: z.array(z.uuid()).optional(),
phone: z.string().max(40).optional(),
locationIds: z.array(z.uuid()).optional(), // R6.6 — sites racine (Demandeur)
});
export type InvitationCreate = z.infer<typeof InvitationCreateSchema>;

21
pnpm-lock.yaml generated
View File

@@ -135,12 +135,18 @@ importers:
expo:
specifier: ~57.0.6
version: 57.0.6(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.5)(expo-router@57.0.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3)
expo-audio:
specifier: ~57.0.2
version: 57.0.2(expo-asset@57.0.5(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(typescript@6.0.3))(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)
expo-camera:
specifier: ~57.0.3
version: 57.0.3(@types/emscripten@1.41.5)(expo@57.0.6)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)
expo-constants:
specifier: ~57.0.5
version: 57.0.5(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))
expo-file-system:
specifier: ~57.0.1
version: 57.0.1(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))
expo-font:
specifier: ~57.0.1
version: 57.0.1(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)
@@ -3816,6 +3822,14 @@ packages:
react: '*'
react-native: '*'
expo-audio@57.0.2:
resolution: {integrity: sha512-KCXS0jUgJm+rAzafdHczjrvFdsSDmH+0RYAvTWG8LyT4s8ERk4P8tYHH9/ByOwiczsLVCVAMKz8nGKQyuT4pMQ==}
peerDependencies:
expo: '*'
expo-asset: '*'
react: '*'
react-native: '*'
expo-camera@57.0.3:
resolution: {integrity: sha512-Q+3aZ63eQCkdB6/FZrO/lfacNAg/j8JCeKQL2nBdf6vBeOo1Y2PKYx1/vK+U5LaRnIo/0tMGmCOzZ1JGhTeMIw==}
peerDependencies:
@@ -10851,6 +10865,13 @@ snapshots:
- supports-color
- typescript
expo-audio@57.0.2(expo-asset@57.0.5(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(typescript@6.0.3))(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3):
dependencies:
expo: 57.0.6(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.5)(expo-router@57.0.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3)
expo-asset: 57.0.5(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(typescript@6.0.3)
react: 19.2.3
react-native: 0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2)
expo-camera@57.0.3(@types/emscripten@1.41.5)(expo@57.0.6)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3):
dependencies:
barcode-detector: 3.2.1(@types/emscripten@1.41.5)