mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Compare commits
22 Commits
release/r5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a79551a4a3 | ||
|
|
39320d081e | ||
|
|
19076a91d3 | ||
|
|
ed9833e56d | ||
|
|
c9168ece16 | ||
|
|
1ae0529a6a | ||
|
|
9aaee4e358 | ||
|
|
63656565f7 | ||
|
|
6f74fd4206 | ||
|
|
be5dfb6bc7 | ||
|
|
ac8b92e9c5 | ||
|
|
700bd4e7ff | ||
|
|
4c1a2d67a0 | ||
|
|
f210d4f801 | ||
|
|
21dd6cf034 | ||
|
|
ab0f260d0f | ||
|
|
3e9e708116 | ||
|
|
4af3f5668a | ||
|
|
59ed6f3952 | ||
|
|
0730bf9dad | ||
|
|
189e8e8162 | ||
|
|
557435b81a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,9 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.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/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
25
CLAUDE.md
25
CLAUDE.md
@@ -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.
|
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.
|
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.
|
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.
|
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).
|
- ✅ **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).
|
- ✅ **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.
|
- ✅ **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.
|
- 🏁 **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.**
|
||||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
- ✅ **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,54→2,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 note→clôture→réindexation→recherche). 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 R0→R5) 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 R0→R5 (v1) + R6 en cours.
|
||||||
|
|||||||
@@ -8,29 +8,35 @@
|
|||||||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder
|
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV UV_LINK_MODE=copy \
|
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
|
# 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.
|
# reste éditable (.pth → /app/src) : src est donc copié dans l'image finale.
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
RUN uv sync --frozen --no-install-project --no-dev \
|
RUN uv sync --frozen --no-install-project --no-dev \
|
||||||
--extra embeddings --extra generation
|
--extra embeddings --extra generation --extra transcription
|
||||||
COPY src src
|
COPY src src
|
||||||
RUN uv sync --frozen --no-dev \
|
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
|
# Les modèles locaux (embeddings + dictée) sont EMBARQUÉS dans l'image : pas
|
||||||
# boot (démarrage prévisible, marche sans accès à Hugging Face en production).
|
# 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.embeddings import EmbeddeurLocal; EmbeddeurLocal()"
|
||||||
|
RUN uv run python -c "from siop_ai.transcription import TranscripteurLocal; TranscripteurLocal('small')"
|
||||||
|
|
||||||
FROM python:3.11-slim-bookworm
|
FROM python:3.11-slim-bookworm
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PATH=/app/.venv/bin:$PATH \
|
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
|
RUN useradd --system --create-home siop
|
||||||
COPY --from=builder --chown=siop:siop /app/.venv /app/.venv
|
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 /app/src /app/src
|
||||||
COPY --from=builder --chown=siop:siop /opt/fastembed /opt/fastembed
|
COPY --from=builder --chown=siop:siop /opt/fastembed /opt/fastembed
|
||||||
|
COPY --from=builder --chown=siop:siop /opt/whisper /opt/whisper
|
||||||
USER siop
|
USER siop
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ dependencies = [
|
|||||||
"asyncpg>=0.30",
|
"asyncpg>=0.30",
|
||||||
"pypdf>=5.1",
|
"pypdf>=5.1",
|
||||||
"minio>=7.2",
|
"minio>=7.2",
|
||||||
|
"python-multipart>=0.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -17,6 +18,9 @@ dependencies = [
|
|||||||
embeddings = ["fastembed>=0.4"]
|
embeddings = ["fastembed>=0.4"]
|
||||||
# Génération opt-in (ADR-004 §3) — absente des tests/CI (repli extractif).
|
# Génération opt-in (ADR-004 §3) — absente des tests/CI (repli extractif).
|
||||||
generation = ["anthropic>=0.75"]
|
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]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
|
|||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
from fastapi import Depends, FastAPI, Header, HTTPException, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .assistant import repondre, suggerer_bilan
|
from .assistant import repondre, suggerer_bilan
|
||||||
@@ -16,6 +16,7 @@ from .embeddings import construire_embeddeur
|
|||||||
from .generation import construire_generateur
|
from .generation import construire_generateur
|
||||||
from .ingestion import reindexer_tout
|
from .ingestion import reindexer_tout
|
||||||
from .recherche import chercher
|
from .recherche import chercher
|
||||||
|
from .transcription import construire_transcripteur
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -26,6 +27,9 @@ async def cycle_de_vie(app: FastAPI):
|
|||||||
app.state.generateur = construire_generateur(
|
app.state.generateur = construire_generateur(
|
||||||
reglages.ai_generation, reglages.ai_api_key, reglages.ai_model
|
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)
|
app.state.pool = await asyncpg.create_pool(reglages.database_url, min_size=1, max_size=5)
|
||||||
yield
|
yield
|
||||||
await app.state.pool.close()
|
await app.state.pool.close()
|
||||||
@@ -50,6 +54,7 @@ async def sante() -> dict:
|
|||||||
"status": "ok",
|
"status": "ok",
|
||||||
"service": "siop2-ai",
|
"service": "siop2-ai",
|
||||||
"generation": reglages.ai_generation, # « off » = extractif — jamais la clé
|
"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,
|
seuil_confiance_forte=app.state.reglages.ai_seuil_confiance_forte,
|
||||||
)
|
)
|
||||||
return {"suggestions": [asdict(s) for s in suggestions]}
|
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}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ class Reglages(BaseSettings):
|
|||||||
ai_seuil_pertinence: float = 0.45
|
ai_seuil_pertinence: float = 0.45
|
||||||
ai_seuil_suggestion: float = 0.40
|
ai_seuil_suggestion: float = 0.40
|
||||||
ai_seuil_confiance_forte: float = 0.55
|
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}
|
model_config = {"env_prefix": "", "case_sensitive": False}
|
||||||
|
|
||||||
@@ -43,4 +47,6 @@ def charger_reglages() -> Reglages:
|
|||||||
raise ValueError("AI_GENERATION doit valoir « off » ou « api »")
|
raise ValueError("AI_GENERATION doit valoir « off » ou « api »")
|
||||||
if reglages.ai_generation == "api" and not reglages.ai_api_key:
|
if reglages.ai_generation == "api" and not reglages.ai_api_key:
|
||||||
raise ValueError("AI_GENERATION=api exige AI_API_KEY (voir ADR-004 §3)")
|
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
|
return reglages
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ async def indexer_bilans(
|
|||||||
await cnx.execute('DELETE FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\'')
|
await cnx.execute('DELETE FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\'')
|
||||||
bilans = await cnx.fetch(
|
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,
|
a.reference AS asset_ref, a.brand, a.model,
|
||||||
(SELECT json_object_agg(rv.field, rv.label)
|
(SELECT json_object_agg(rv.field, rv.label)
|
||||||
FROM "InterventionReport" ir2
|
FROM "InterventionReport" ir2
|
||||||
@@ -137,10 +137,13 @@ async def indexer_bilans(
|
|||||||
for bilan in bilans:
|
for bilan in bilans:
|
||||||
champs = json.loads(bilan["bilan"]) if bilan["bilan"] else {}
|
champs = json.loads(bilan["bilan"]) if bilan["bilan"] else {}
|
||||||
codes = " ; ".join(f"{champ} : {label}" for champ, label in champs.items())
|
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(
|
contenu = anonymiser(
|
||||||
f"Intervention {bilan['reference']} — {bilan['title']}. "
|
f"Intervention {bilan['reference']} — {bilan['title']}. "
|
||||||
f"Appareil {bilan['asset_ref']} ({bilan['brand']} {bilan['model'] or ''}). "
|
f"Appareil {bilan['asset_ref']} ({bilan['brand']} {bilan['model'] or ''}). "
|
||||||
f"Bilan codé : {codes}.",
|
f"Bilan codé : {codes}.{note}",
|
||||||
noms,
|
noms,
|
||||||
)
|
)
|
||||||
quand = bilan["completedAt"].date().isoformat() if bilan["completedAt"] else "date inconnue"
|
quand = bilan["completedAt"].date().isoformat() if bilan["completedAt"] else "date inconnue"
|
||||||
|
|||||||
54
apps/ai/src/siop_ai/transcription.py
Normal file
54
apps/ai/src/siop_ai/transcription.py
Normal 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)
|
||||||
@@ -16,6 +16,33 @@ def test_endpoints_internes_refuses_sans_jeton():
|
|||||||
client = _client_sans_db()
|
client = _client_sans_db()
|
||||||
assert client.post("/internal/reindex").status_code == 401
|
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/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():
|
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
|
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)."""
|
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.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)
|
return TestClient(app, raise_server_exceptions=False)
|
||||||
|
|||||||
36
apps/ai/tests/test_transcription.py
Normal file
36
apps/ai/tests/test_transcription.py
Normal 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
106
apps/ai/uv.lock
generated
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.6.17"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "distro"
|
name = "distro"
|
||||||
version = "1.9.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.30.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "siop-ai"
|
name = "siop-ai"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1465,6 +1563,7 @@ dependencies = [
|
|||||||
{ name = "minio" },
|
{ name = "minio" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pypdf" },
|
{ name = "pypdf" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1475,6 +1574,9 @@ embeddings = [
|
|||||||
generation = [
|
generation = [
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
]
|
]
|
||||||
|
transcription = [
|
||||||
|
{ name = "faster-whisper" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -1490,12 +1592,14 @@ requires-dist = [
|
|||||||
{ name = "asyncpg", specifier = ">=0.30" },
|
{ name = "asyncpg", specifier = ">=0.30" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "fastembed", marker = "extra == 'embeddings'", specifier = ">=0.4" },
|
{ name = "fastembed", marker = "extra == 'embeddings'", specifier = ">=0.4" },
|
||||||
|
{ name = "faster-whisper", marker = "extra == 'transcription'", specifier = ">=1.1" },
|
||||||
{ name = "minio", specifier = ">=7.2" },
|
{ name = "minio", specifier = ">=7.2" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.6" },
|
{ name = "pydantic-settings", specifier = ">=2.6" },
|
||||||
{ name = "pypdf", specifier = ">=5.1" },
|
{ name = "pypdf", specifier = ">=5.1" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
|
||||||
]
|
]
|
||||||
provides-extras = ["embeddings", "generation"]
|
provides-extras = ["embeddings", "generation", "transcription"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -49,6 +49,8 @@ model User {
|
|||||||
activationToken String? @unique
|
activationToken String? @unique
|
||||||
activationExpiresAt DateTime?
|
activationExpiresAt DateTime?
|
||||||
teams Team[]
|
teams Team[]
|
||||||
|
// R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction
|
||||||
|
assignedSites Location[]
|
||||||
// R2 — exploitation
|
// R2 — exploitation
|
||||||
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
||||||
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
||||||
@@ -111,6 +113,7 @@ model Location {
|
|||||||
partnerId String? @db.Uuid
|
partnerId String? @db.Uuid
|
||||||
partner Partner? @relation(fields: [partnerId], references: [id])
|
partner Partner? @relation(fields: [partnerId], references: [id])
|
||||||
assets Asset[]
|
assets Asset[]
|
||||||
|
assignedUsers User[] // reverse de User.assignedSites (R6.6)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -462,6 +462,19 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
|
|||||||
const karim = await parEmail('demandeur@demo.siop.ma');
|
const karim = await parEmail('demandeur@demo.siop.ma');
|
||||||
const annee = new Date().getFullYear();
|
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(
|
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
||||||
(t) => t.label,
|
(t) => t.label,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type AssetCreate,
|
type AssetCreate,
|
||||||
type AssetUpdate,
|
type AssetUpdate,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import { type AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
import { AssetsService } from './assets.service';
|
import { AssetsService } from './assets.service';
|
||||||
@@ -32,10 +33,11 @@ export class AssetsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
|
/** 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')
|
@Get('options')
|
||||||
options() {
|
options(@CurrentUser() user: AuthenticatedUser) {
|
||||||
return this.assetsService.options();
|
return this.assetsService.options(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
AssetsResponse,
|
AssetsResponse,
|
||||||
AssetUpdate,
|
AssetUpdate,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
const assetInclude = {
|
const assetInclude = {
|
||||||
@@ -29,9 +30,30 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
|||||||
export class AssetsService {
|
export class AssetsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
|
/** Sites + zones autorisés pour le signalement de `user`, ou `null` si aucune
|
||||||
async options(): Promise<AssetOptionsResponse> {
|
* 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({
|
const rows = await this.prisma.asset.findMany({
|
||||||
|
where: allowed ? { locationId: { in: allowed } } : undefined,
|
||||||
include: { location: { include: { parent: true } } },
|
include: { location: { include: { parent: true } } },
|
||||||
orderBy: { reference: 'asc' },
|
orderBy: { reference: 'asc' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
AssistantAskSchema,
|
AssistantAskSchema,
|
||||||
SuggestBilanSchema,
|
SuggestBilanSchema,
|
||||||
@@ -9,6 +18,10 @@ import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
|||||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
import { AssistantService } from './assistant.service';
|
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')
|
@Controller('assistant')
|
||||||
export class AssistantController {
|
export class AssistantController {
|
||||||
constructor(private readonly assistant: AssistantService) {}
|
constructor(private readonly assistant: AssistantService) {}
|
||||||
@@ -36,4 +49,18 @@ export class AssistantController {
|
|||||||
suggest(@Body(new ZodValidationPipe(SuggestBilanSchema)) body: SuggestBilan) {
|
suggest(@Body(new ZodValidationPipe(SuggestBilanSchema)) body: SuggestBilan) {
|
||||||
return this.assistant.suggestBilan(body);
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
BilanSuggestionsResponse,
|
BilanSuggestionsResponse,
|
||||||
ReindexResult,
|
ReindexResult,
|
||||||
SuggestBilan,
|
SuggestBilan,
|
||||||
|
TranscriptionResult,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
import { loadEnv } from '../config/env';
|
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> {
|
async suggestBilan(dto: SuggestBilan): Promise<BilanSuggestionsResponse> {
|
||||||
const brut = await this.appeler<{
|
const brut = await this.appeler<{
|
||||||
suggestions: {
|
suggestions: {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AssetsModule } from '../assets/assets.module';
|
||||||
import { WorkOrdersModule } from '../work-orders/work-orders.module';
|
import { WorkOrdersModule } from '../work-orders/work-orders.module';
|
||||||
import { RequestsController } from './requests.controller';
|
import { RequestsController } from './requests.controller';
|
||||||
import { RequestsService } from './requests.service';
|
import { RequestsService } from './requests.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkOrdersModule],
|
imports: [WorkOrdersModule, AssetsModule],
|
||||||
controllers: [RequestsController],
|
controllers: [RequestsController],
|
||||||
providers: [RequestsService],
|
providers: [RequestsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
import { PermissionsService } from '../permissions/permissions.service';
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AssetsService } from '../assets/assets.service';
|
||||||
import { WorkOrdersService } from '../work-orders/work-orders.service';
|
import { WorkOrdersService } from '../work-orders/work-orders.service';
|
||||||
|
|
||||||
const requestInclude = {
|
const requestInclude = {
|
||||||
@@ -32,6 +33,7 @@ export class RequestsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
private readonly workOrders: WorkOrdersService,
|
private readonly workOrders: WorkOrdersService,
|
||||||
|
private readonly assets: AssetsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
|
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
|
||||||
@@ -52,6 +54,13 @@ export class RequestsService {
|
|||||||
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
|
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
|
||||||
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
||||||
if (!asset) throw new BadRequestException('Équipement inconnu');
|
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++) {
|
for (let essai = 0; ; essai++) {
|
||||||
try {
|
try {
|
||||||
const created = await this.prisma.request.create({
|
const created = await this.prisma.request.create({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -20,6 +21,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
const userInclude = {
|
const userInclude = {
|
||||||
role: true,
|
role: true,
|
||||||
teams: { orderBy: { name: 'asc' } },
|
teams: { orderBy: { name: 'asc' } },
|
||||||
|
assignedSites: { orderBy: { name: 'asc' } },
|
||||||
} satisfies Prisma.UserInclude;
|
} satisfies Prisma.UserInclude;
|
||||||
|
|
||||||
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
||||||
@@ -46,6 +48,7 @@ export class UsersService {
|
|||||||
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
||||||
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
||||||
if (!role) throw new NotFoundException('Rôle inconnu');
|
if (!role) throw new NotFoundException('Rôle inconnu');
|
||||||
|
if (dto.locationIds?.length) await this.assertTopLevelSites(dto.locationIds);
|
||||||
try {
|
try {
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -56,6 +59,9 @@ export class UsersService {
|
|||||||
teams: dto.teamIds?.length
|
teams: dto.teamIds?.length
|
||||||
? { connect: dto.teamIds.map((id) => ({ id })) }
|
? { connect: dto.teamIds.map((id) => ({ id })) }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
assignedSites: dto.locationIds?.length
|
||||||
|
? { connect: dto.locationIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
...this.freshToken(),
|
...this.freshToken(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -90,6 +96,7 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
||||||
|
if (dto.locationIds) await this.assertTopLevelSites(dto.locationIds);
|
||||||
try {
|
try {
|
||||||
const updated = await this.prisma.user.update({
|
const updated = await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -102,6 +109,9 @@ export class UsersService {
|
|||||||
teams: dto.teamIds
|
teams: dto.teamIds
|
||||||
? { set: dto.teamIds.map((id) => ({ id })) }
|
? { set: dto.teamIds.map((id) => ({ id })) }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
assignedSites: dto.locationIds
|
||||||
|
? { set: dto.locationIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
include: userInclude,
|
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() {
|
private freshToken() {
|
||||||
return {
|
return {
|
||||||
activationToken: randomBytes(32).toString('base64url'),
|
activationToken: randomBytes(32).toString('base64url'),
|
||||||
@@ -138,6 +158,7 @@ export class UsersService {
|
|||||||
: 'invited',
|
: 'invited',
|
||||||
isDemo: row.isDemo,
|
isDemo: row.isDemo,
|
||||||
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
|
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
|
||||||
|
assignedSites: row.assignedSites.map((s) => ({ id: s.id, name: s.name })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const REPONSE_REINDEX = {
|
|||||||
extraits: 180,
|
extraits: 180,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REPONSE_TRANSCRIRE = { texte: 'Porte cabine qui rebondit, cellule encrassée.' };
|
||||||
|
|
||||||
describe('Assistant (e2e — stub du service IA)', () => {
|
describe('Assistant (e2e — stub du service IA)', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let stub: Server;
|
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));
|
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/suggest') res.end(JSON.stringify(REPONSE_SUGGEST));
|
||||||
else if (req.url === '/internal/reindex') res.end(JSON.stringify(REPONSE_REINDEX));
|
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 {
|
else {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.end('{}');
|
res.end('{}');
|
||||||
@@ -169,6 +172,27 @@ describe('Assistant (e2e — stub du service IA)', () => {
|
|||||||
await http().post('/assistant/reindex').set(auth(ahmed)).expect(403);
|
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 () => {
|
it('question trop courte : 400 avant tout appel au service IA', async () => {
|
||||||
const avant = requetesRecues.length;
|
const avant = requetesRecues.length;
|
||||||
await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400);
|
await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400);
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ describe('Exploitation (e2e)', () => {
|
|||||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
|
||||||
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
|
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 { 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()
|
const demande = await http()
|
||||||
.post('/requests')
|
.post('/requests')
|
||||||
.set(auth(karim))
|
.set(auth(karim))
|
||||||
@@ -234,8 +235,9 @@ describe('Exploitation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
|
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 { 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()
|
const demande = await http()
|
||||||
.post('/requests')
|
.post('/requests')
|
||||||
.set(auth(karim))
|
.set(auth(karim))
|
||||||
|
|||||||
21
apps/mobile/.env
Normal file
21
apps/mobile/.env
Normal 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
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.anonymous.siop2-mobile"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
@@ -16,7 +17,8 @@
|
|||||||
"backgroundImage": "./assets/android-icon-background.png",
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "com.anonymous.siop2mobile"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
@@ -24,7 +26,8 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"expo-secure-store"
|
"expo-secure-store",
|
||||||
|
"expo-audio"
|
||||||
],
|
],
|
||||||
"scheme": "siop"
|
"scheme": "siop"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
import { Text, type ColorValue } from 'react-native';
|
import { Text, type ColorValue } from 'react-native';
|
||||||
import { useAssets, useReferenceValues } from '@/api/exploitation';
|
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 { useFile } from '@/file/store';
|
||||||
import { useTokens } from '@/theme/tokens';
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
/** La tabbar de la maquette R4. Les onglets à venir restent visibles
|
/** Barre d'onglets ADAPTATIVE (maquette « mobile ouvert à tous les rôles »,
|
||||||
* (périmètre annoncé, même patron que la sidebar web) mais mènent à un
|
* D1) : le Technicien/Technicien limité gardent exactement leurs 4 onglets
|
||||||
* écran « disponible en R4.x ». */
|
* 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 }) {
|
function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) {
|
||||||
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
|
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
|
||||||
@@ -16,10 +23,19 @@ export default function CoquilleTabs() {
|
|||||||
const t = useTokens();
|
const t = useTokens();
|
||||||
const file = useFile();
|
const file = useFile();
|
||||||
const conflits = file.some((s) => s.statut === 'CONFLIT');
|
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
|
// 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.
|
// se préchargent dès l'entrée — le sous-sol n'attend pas qu'on y pense.
|
||||||
useAssets();
|
// Le Demandeur n'a pas ASSETS.view (seulement /assets/options) : inutile
|
||||||
useReferenceValues();
|
// 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 (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
@@ -34,6 +50,7 @@ export default function CoquilleTabs() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="journee"
|
name="journee"
|
||||||
options={{
|
options={{
|
||||||
|
href: cacher('journee'),
|
||||||
title: 'Ma journée',
|
title: 'Ma journée',
|
||||||
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
|
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
|
||||||
}}
|
}}
|
||||||
@@ -41,6 +58,7 @@ export default function CoquilleTabs() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="scanner"
|
name="scanner"
|
||||||
options={{
|
options={{
|
||||||
|
href: cacher('scanner'),
|
||||||
title: 'Scanner',
|
title: 'Scanner',
|
||||||
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
|
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
|
||||||
}}
|
}}
|
||||||
@@ -48,6 +66,7 @@ export default function CoquilleTabs() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="preventif"
|
name="preventif"
|
||||||
options={{
|
options={{
|
||||||
|
href: cacher('preventif'),
|
||||||
title: 'Préventif',
|
title: 'Préventif',
|
||||||
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
|
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
|
||||||
}}
|
}}
|
||||||
@@ -55,6 +74,7 @@ export default function CoquilleTabs() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="synchro"
|
name="synchro"
|
||||||
options={{
|
options={{
|
||||||
|
href: cacher('synchro'),
|
||||||
title: 'Synchro',
|
title: 'Synchro',
|
||||||
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
|
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
|
||||||
tabBarBadge: file.length || undefined,
|
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>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
179
apps/mobile/app/(tabs)/accueil.tsx
Normal file
179
apps/mobile/app/(tabs)/accueil.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
type WorkOrderStatus,
|
type WorkOrderStatus,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
import { api, unwrap } from '@/api/client';
|
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 { triJournee } from '@/lib/journee';
|
||||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
@@ -43,9 +44,7 @@ const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
|
|||||||
export default function PageJournee() {
|
export default function PageJournee() {
|
||||||
const t = useTokens();
|
const t = useTokens();
|
||||||
const horsLigne = useHorsLigne();
|
const horsLigne = useHorsLigne();
|
||||||
const { data: me } = useMe();
|
|
||||||
const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
|
const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
|
||||||
const logout = useLogout();
|
|
||||||
|
|
||||||
const ots = triJournee(workOrders ?? []);
|
const ots = triJournee(workOrders ?? []);
|
||||||
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
|
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
|
||||||
@@ -55,57 +54,7 @@ export default function PageJournee() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
||||||
{/* Entête app : marque + pastille synchro + compte */}
|
<EnteteTabs />
|
||||||
<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>
|
|
||||||
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
|
||||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
|||||||
141
apps/mobile/app/(tabs)/menu.tsx
Normal file
141
apps/mobile/app/(tabs)/menu.tsx
Normal 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: 'L’administration des catégories reste sur le grand écran pour l’instant.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/mobile/app/(tabs)/nouvelle-demande.tsx
Normal file
24
apps/mobile/app/(tabs)/nouvelle-demande.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
apps/mobile/app/(tabs)/ot.tsx
Normal file
154
apps/mobile/app/(tabs)/ot.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { router } from 'expo-router';
|
|||||||
import { FlatList, Pressable, Text, View } from 'react-native';
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useWorkOrders } from '@/api/exploitation';
|
import { useWorkOrders } from '@/api/exploitation';
|
||||||
import { ChipStatut } from '@/composants/ui';
|
import { ChipStatut, EnteteTabs } from '@/composants/ui';
|
||||||
import { useTokens } from '@/theme/tokens';
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
/** Onglet Préventif : mes grilles du moment — chaque tuile mène à la
|
/** Onglet Préventif : mes grilles du moment — chaque tuile mène à la
|
||||||
@@ -18,6 +18,7 @@ export default function PagePreventif() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteTabs />
|
||||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
Préventif
|
Préventif
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Platform, Text, TextInput, View } from 'react-native';
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useAssets } from '@/api/exploitation';
|
import { useAssets } from '@/api/exploitation';
|
||||||
import { useHorsLigne } from '@/auth/session';
|
import { useHorsLigne } from '@/auth/session';
|
||||||
import { BoutonTel } from '@/composants/ui';
|
import { BoutonTel, EnteteTabs } from '@/composants/ui';
|
||||||
import { analyseScan } from '@/lib/scan';
|
import { analyseScan } from '@/lib/scan';
|
||||||
import { useTokens } from '@/theme/tokens';
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
@@ -59,6 +59,7 @@ export default function PageScanner() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteTabs />
|
||||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
Scanner
|
Scanner
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { router } from 'expo-router';
|
|||||||
import { FlatList, Pressable, Text, View } from 'react-native';
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useHorsLigne } from '@/auth/session';
|
import { useHorsLigne } from '@/auth/session';
|
||||||
import { BoutonTel } from '@/composants/ui';
|
import { BoutonTel, EnteteTabs } from '@/composants/ui';
|
||||||
import { useFile, type Saisie } from '@/file/store';
|
import { useFile, type Saisie } from '@/file/store';
|
||||||
import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro';
|
import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro';
|
||||||
import { useTokens } from '@/theme/tokens';
|
import { useTokens } from '@/theme/tokens';
|
||||||
@@ -33,6 +33,7 @@ export default function PageSynchro() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteTabs />
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
||||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
Synchro
|
Synchro
|
||||||
|
|||||||
21
apps/mobile/app/a-venir.tsx
Normal file
21
apps/mobile/app/a-venir.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/mobile/app/ascenseurs/index.tsx
Normal file
90
apps/mobile/app/ascenseurs/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
380
apps/mobile/app/assistant/index.tsx
Normal file
380
apps/mobile/app/assistant/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/mobile/app/bibliotheque/[id].tsx
Normal file
52
apps/mobile/app/bibliotheque/[id].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/mobile/app/bibliotheque/index.tsx
Normal file
83
apps/mobile/app/bibliotheque/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,12 +9,18 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import type { RoleName } from '@siop/shared';
|
||||||
|
import { ongletAccueil } from '@/auth/roles';
|
||||||
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session';
|
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session';
|
||||||
import { useTokens } from '@/theme/tokens';
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
/** Connexion mobile — mêmes règles que le web : formulaire e-mail/mot de
|
/** 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)
|
* 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() {
|
export default function PageConnexion() {
|
||||||
const t = useTokens();
|
const t = useTokens();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -23,7 +29,7 @@ export default function PageConnexion() {
|
|||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const demo = useDemoLogin();
|
const demo = useDemoLogin();
|
||||||
|
|
||||||
const entrer = () => router.replace('/(tabs)/journee');
|
const entrer = (role: RoleName) => router.replace(`/(tabs)/${ongletAccueil(role)}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@@ -36,12 +42,9 @@ export default function PageConnexion() {
|
|||||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}>
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}>
|
||||||
SIOP
|
SIOP
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre3 }}>
|
|
||||||
Technicien
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={{ fontFamily: 'Manrope_400Regular', color: t.encre2, marginBottom: 8 }}>
|
<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, où que vous soyez.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={{ gap: 10 }}>
|
<View style={{ gap: 10 }}>
|
||||||
@@ -84,7 +87,10 @@ export default function PageConnexion() {
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
disabled={!email || !motDePasse || login.isPending}
|
disabled={!email || !motDePasse || login.isPending}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
login.mutate({ email, password: motDePasse }, { onSuccess: entrer })
|
login.mutate(
|
||||||
|
{ email, password: motDePasse },
|
||||||
|
{ onSuccess: (res) => entrer(res.user.role.name) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire,
|
backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire,
|
||||||
@@ -122,7 +128,9 @@ export default function PageConnexion() {
|
|||||||
key={c.id}
|
key={c.id}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
disabled={demo.isPending}
|
disabled={demo.isPending}
|
||||||
onPress={() => demo.mutate(c.id, { onSuccess: entrer })}
|
onPress={() =>
|
||||||
|
demo.mutate(c.id, { onSuccess: (res) => entrer(res.user.role.name) })
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: t.surface,
|
backgroundColor: t.surface,
|
||||||
borderColor: t.bordure,
|
borderColor: t.bordure,
|
||||||
|
|||||||
19
apps/mobile/app/demandes/index.tsx
Normal file
19
apps/mobile/app/demandes/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/mobile/app/demandes/nouvelle.tsx
Normal file
18
apps/mobile/app/demandes/nouvelle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Redirect } from 'expo-router';
|
import { Redirect } from 'expo-router';
|
||||||
import { ActivityIndicator, View } from 'react-native';
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
|
import { ongletAccueil } from '@/auth/roles';
|
||||||
import { useMe } from '@/auth/session';
|
import { useMe } from '@/auth/session';
|
||||||
import { useTokens } from '@/theme/tokens';
|
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.) */
|
* (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */
|
||||||
export default function Aiguillage() {
|
export default function Aiguillage() {
|
||||||
const t = useTokens();
|
const t = useTokens();
|
||||||
@@ -16,5 +18,9 @@ export default function Aiguillage() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return me ? <Redirect href="/(tabs)/journee" /> : <Redirect href="/connexion" />;
|
return me ? (
|
||||||
|
<Redirect href={`/(tabs)/${ongletAccueil(me.role.name)}`} />
|
||||||
|
) : (
|
||||||
|
<Redirect href="/connexion" />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { router, useLocalSearchParams } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import {
|
import {
|
||||||
BILAN_FIELD_LABELS,
|
BILAN_FIELD_LABELS,
|
||||||
@@ -9,9 +19,15 @@ import {
|
|||||||
type BilanField,
|
type BilanField,
|
||||||
type BilanSuggestion,
|
type BilanSuggestion,
|
||||||
type ReportUpsert,
|
type ReportUpsert,
|
||||||
|
type WorkOrderDetail,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
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 { useHorsLigne } from '@/auth/session';
|
||||||
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
|
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
|
||||||
import { enfilerBilan, enfilerTransition } from '@/file/actions';
|
import { enfilerBilan, enfilerTransition } from '@/file/actions';
|
||||||
@@ -97,6 +113,7 @@ export default function PageCloture() {
|
|||||||
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
|
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
|
||||||
<CarteSuggestion
|
<CarteSuggestion
|
||||||
|
ot={ot}
|
||||||
horsLigne={horsLigne}
|
horsLigne={horsLigne}
|
||||||
surApplication={(s) => setChoix((c) => ({ ...c, [s.field]: s.valueId }))}
|
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
|
/** É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).
|
* 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
|
* 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({
|
function CarteSuggestion({
|
||||||
|
ot,
|
||||||
horsLigne,
|
horsLigne,
|
||||||
surApplication,
|
surApplication,
|
||||||
}: {
|
}: {
|
||||||
|
ot: WorkOrderDetail;
|
||||||
horsLigne: boolean;
|
horsLigne: boolean;
|
||||||
surApplication: (s: BilanSuggestion) => void;
|
surApplication: (s: BilanSuggestion) => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTokens();
|
const t = useTokens();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const suggerer = useSuggestionBilan();
|
const suggerer = useSuggestionBilan();
|
||||||
|
const transcrire = useTranscription();
|
||||||
|
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [appliquees, setAppliquees] = useState<Set<BilanField>>(new Set());
|
const [appliquees, setAppliquees] = useState<Set<BilanField>>(new Set());
|
||||||
|
const [enregistrement, setEnregistrement] = useState(false);
|
||||||
|
const [jointe, setJointe] = useState(false);
|
||||||
const suggestions = suggerer.data?.suggestions ?? [];
|
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 (
|
return (
|
||||||
<Carte titre="Décrire pour suggérer (optionnel)">
|
<Carte titre="Décrire pour suggérer (optionnel)">
|
||||||
<TextInput
|
<TextInput
|
||||||
multiline
|
multiline
|
||||||
value={description}
|
value={description}
|
||||||
onChangeText={setDescription}
|
onChangeText={(v) => {
|
||||||
|
setDescription(v);
|
||||||
|
setJointe(false);
|
||||||
|
}}
|
||||||
maxLength={2000}
|
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}
|
placeholderTextColor={t.encre3}
|
||||||
accessibilityLabel="Décrire pour suggérer"
|
accessibilityLabel="Décrire pour suggérer"
|
||||||
style={{
|
style={{
|
||||||
@@ -197,6 +257,43 @@ function CarteSuggestion({
|
|||||||
textAlignVertical: 'top',
|
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 d’appliquer.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<BoutonTel
|
||||||
|
libelle={jointe ? '✓ Description jointe à l’OT' : 'Joindre la description à l’OT'}
|
||||||
|
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 l’OT clôturé, cette description (anonymisée) rejoint le corpus de
|
||||||
|
l’assistant — comme les bilans déjà codés.
|
||||||
|
</Text>
|
||||||
<BoutonTel
|
<BoutonTel
|
||||||
libelle={
|
libelle={
|
||||||
horsLigne
|
horsLigne
|
||||||
|
|||||||
94
apps/mobile/app/personnes/index.tsx
Normal file
94
apps/mobile/app/personnes/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/mobile/app/personnes/inviter.tsx
Normal file
99
apps/mobile/app/personnes/inviter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/mobile/app/personnes/lien.tsx
Normal file
43
apps/mobile/app/personnes/lien.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/mobile/app/sites/[id].tsx
Normal file
99
apps/mobile/app/sites/[id].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/mobile/app/sites/index.tsx
Normal file
69
apps/mobile/app/sites/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/mobile/app/statistiques/index.tsx
Normal file
130
apps/mobile/app/statistiques/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
apps/mobile/app/stock/[id].tsx
Normal file
65
apps/mobile/app/stock/[id].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/mobile/app/stock/[id]/commander.tsx
Normal file
130
apps/mobile/app/stock/[id]/commander.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/mobile/app/stock/index.tsx
Normal file
90
apps/mobile/app/stock/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/mobile/app/tiers/[id].tsx
Normal file
48
apps/mobile/app/tiers/[id].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
apps/mobile/app/tiers/index.tsx
Normal file
72
apps/mobile/app/tiers/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,8 +11,10 @@
|
|||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@tanstack/react-query-persist-client": "^5.101.2",
|
"@tanstack/react-query-persist-client": "^5.101.2",
|
||||||
"expo": "~57.0.6",
|
"expo": "~57.0.6",
|
||||||
|
"expo-audio": "~57.0.2",
|
||||||
"expo-camera": "~57.0.3",
|
"expo-camera": "~57.0.3",
|
||||||
"expo-constants": "~57.0.5",
|
"expo-constants": "~57.0.5",
|
||||||
|
"expo-file-system": "~57.0.1",
|
||||||
"expo-font": "~57.0.1",
|
"expo-font": "~57.0.1",
|
||||||
"expo-image-manipulator": "~57.0.4",
|
"expo-image-manipulator": "~57.0.4",
|
||||||
"expo-image-picker": "~57.0.4",
|
"expo-image-picker": "~57.0.4",
|
||||||
@@ -39,8 +41,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
|
|||||||
26
apps/mobile/src/api/assistant.ts
Normal file
26
apps/mobile/src/api/assistant.ts
Normal 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 })),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ import { lireJeton } from './jeton';
|
|||||||
* EXPO_PUBLIC_API_URL pointe l'API (IP LAN pour Expo Go sur téléphone). */
|
* 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';
|
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 });
|
export const api = createClient<paths>({ baseUrl: API_URL });
|
||||||
|
|
||||||
api.use({
|
api.use({
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared';
|
import type {
|
||||||
import { api, unwrap } from './client';
|
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
|
/** 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). */
|
* 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({
|
return useQuery({
|
||||||
queryKey: ['assets'],
|
queryKey: ['assets'],
|
||||||
|
enabled: opts?.enabled ?? true,
|
||||||
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
|
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({
|
return useQuery({
|
||||||
queryKey: ['reference-values'],
|
queryKey: ['reference-values'],
|
||||||
|
enabled: opts?.enabled ?? true,
|
||||||
staleTime: 3600_000, // référentiels administrables : stables en journée
|
staleTime: 3600_000, // référentiels administrables : stables en journée
|
||||||
queryFn: async () => (await unwrap(await api.GET('/reference-values'))).referenceValues,
|
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) {
|
function useInvalideOT(id: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return () =>
|
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) {
|
export function useBilan(otId: string) {
|
||||||
const invalide = useInvalideOT(otId);
|
const invalide = useInvalideOT(otId);
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
39
apps/mobile/src/api/pilotage.ts
Normal file
39
apps/mobile/src/api/pilotage.ts
Normal 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'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
47
apps/mobile/src/api/ressources.ts
Normal file
47
apps/mobile/src/api/ressources.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
66
apps/mobile/src/api/schema.d.ts
vendored
66
apps/mobile/src/api/schema.d.ts
vendored
@@ -509,6 +509,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/assistant/transcribe": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Dictée (R5 D5, opt-in) — l’audio est transcrit puis JAMAIS conservé, à relire avant tout usage */
|
||||||
|
post: operations["transcribeAudio"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/search": {
|
"/search": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1399,6 +1416,11 @@ export interface components {
|
|||||||
status: "active" | "invited" | "disabled";
|
status: "active" | "invited" | "disabled";
|
||||||
isDemo: boolean;
|
isDemo: boolean;
|
||||||
hourlyRate: number | null;
|
hourlyRate: number | null;
|
||||||
|
assignedSites: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
RolesResponse: {
|
RolesResponse: {
|
||||||
@@ -1424,6 +1446,7 @@ export interface components {
|
|||||||
roleId: string;
|
roleId: string;
|
||||||
teamIds?: string[];
|
teamIds?: string[];
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
locationIds?: string[];
|
||||||
};
|
};
|
||||||
UserAdmin: {
|
UserAdmin: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1447,6 +1470,11 @@ export interface components {
|
|||||||
status: "active" | "invited" | "disabled";
|
status: "active" | "invited" | "disabled";
|
||||||
isDemo: boolean;
|
isDemo: boolean;
|
||||||
hourlyRate: number | null;
|
hourlyRate: number | null;
|
||||||
|
assignedSites: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
UserUpdate: {
|
UserUpdate: {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
@@ -1456,6 +1484,7 @@ export interface components {
|
|||||||
teamIds?: string[];
|
teamIds?: string[];
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
hourlyRate?: number | null;
|
hourlyRate?: number | null;
|
||||||
|
locationIds?: string[];
|
||||||
};
|
};
|
||||||
DocumentsResponse: {
|
DocumentsResponse: {
|
||||||
documents: {
|
documents: {
|
||||||
@@ -1567,6 +1596,9 @@ export interface components {
|
|||||||
reportsIndexed: number;
|
reportsIndexed: number;
|
||||||
chunks: number;
|
chunks: number;
|
||||||
};
|
};
|
||||||
|
TranscriptionResult: {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
SearchResponse: {
|
SearchResponse: {
|
||||||
workOrders: {
|
workOrders: {
|
||||||
/** Format: uuid */
|
/** 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: {
|
globalSearch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
26
apps/mobile/src/auth/roles.ts
Normal file
26
apps/mobile/src/auth/roles.ts
Normal 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';
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export function useLogin() {
|
|||||||
mutationFn: async (input: { email: string; password: string }) => {
|
mutationFn: async (input: { email: string; password: string }) => {
|
||||||
const res = await unwrap(await api.POST('/auth/login', { body: input }));
|
const res = await unwrap(await api.POST('/auth/login', { body: input }));
|
||||||
await apres(res.accessToken);
|
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) => {
|
mutationFn: async (userId: string) => {
|
||||||
const res = await unwrap(await api.POST('/auth/demo-login', { body: { userId } }));
|
const res = await unwrap(await api.POST('/auth/demo-login', { body: { userId } }));
|
||||||
await apres(res.accessToken);
|
await apres(res.accessToken);
|
||||||
|
return res; // le rôle sert à router vers le bon onglet d'accueil
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
26
apps/mobile/src/auth/use-permissions.ts
Normal file
26
apps/mobile/src/auth/use-permissions.ts
Normal 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 };
|
||||||
|
}
|
||||||
189
apps/mobile/src/composants/formulaire-demande.tsx
Normal file
189
apps/mobile/src/composants/formulaire-demande.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
apps/mobile/src/composants/panneau-demandes.tsx
Normal file
238
apps/mobile/src/composants/panneau-demandes.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useState, type ReactNode } from 'react';
|
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 { WORK_ORDER_STATUS_LABELS, type WorkOrderStatus } from '@siop/shared';
|
||||||
|
import { useHorsLigne, useLogout, useMe } from '@/auth/session';
|
||||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
/** Briques d'écran de la maquette R4 — cartes, chips, boutons, sélecteur. */
|
/** Briques d'écran de la maquette R4 — cartes, chips, boutons, sélecteur. */
|
||||||
@@ -263,3 +264,76 @@ export function ChoixTel({
|
|||||||
</View>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function enfilerBilan(
|
|||||||
patchDetail(queryClient, ot.id, (c) => ({
|
patchDetail(queryClient, ot.id, (c) => ({
|
||||||
...c,
|
...c,
|
||||||
report: {
|
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),
|
doorState: labels.doorStateId !== undefined ? (labels.doorStateId ?? null) : (c.report?.doorState ?? null),
|
||||||
cabinPosition:
|
cabinPosition:
|
||||||
labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null),
|
labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null),
|
||||||
|
|||||||
66
apps/web/src/api/schema.d.ts
vendored
66
apps/web/src/api/schema.d.ts
vendored
@@ -509,6 +509,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/assistant/transcribe": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Dictée (R5 D5, opt-in) — l’audio est transcrit puis JAMAIS conservé, à relire avant tout usage */
|
||||||
|
post: operations["transcribeAudio"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/search": {
|
"/search": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1399,6 +1416,11 @@ export interface components {
|
|||||||
status: "active" | "invited" | "disabled";
|
status: "active" | "invited" | "disabled";
|
||||||
isDemo: boolean;
|
isDemo: boolean;
|
||||||
hourlyRate: number | null;
|
hourlyRate: number | null;
|
||||||
|
assignedSites: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
RolesResponse: {
|
RolesResponse: {
|
||||||
@@ -1424,6 +1446,7 @@ export interface components {
|
|||||||
roleId: string;
|
roleId: string;
|
||||||
teamIds?: string[];
|
teamIds?: string[];
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
locationIds?: string[];
|
||||||
};
|
};
|
||||||
UserAdmin: {
|
UserAdmin: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1447,6 +1470,11 @@ export interface components {
|
|||||||
status: "active" | "invited" | "disabled";
|
status: "active" | "invited" | "disabled";
|
||||||
isDemo: boolean;
|
isDemo: boolean;
|
||||||
hourlyRate: number | null;
|
hourlyRate: number | null;
|
||||||
|
assignedSites: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
UserUpdate: {
|
UserUpdate: {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
@@ -1456,6 +1484,7 @@ export interface components {
|
|||||||
teamIds?: string[];
|
teamIds?: string[];
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
hourlyRate?: number | null;
|
hourlyRate?: number | null;
|
||||||
|
locationIds?: string[];
|
||||||
};
|
};
|
||||||
DocumentsResponse: {
|
DocumentsResponse: {
|
||||||
documents: {
|
documents: {
|
||||||
@@ -1567,6 +1596,9 @@ export interface components {
|
|||||||
reportsIndexed: number;
|
reportsIndexed: number;
|
||||||
chunks: number;
|
chunks: number;
|
||||||
};
|
};
|
||||||
|
TranscriptionResult: {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
SearchResponse: {
|
SearchResponse: {
|
||||||
workOrders: {
|
workOrders: {
|
||||||
/** Format: uuid */
|
/** 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: {
|
globalSearch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import type { InvitationResponse } from '@siop/shared';
|
import type { InvitationResponse, UserAdmin } from '@siop/shared';
|
||||||
import {
|
import {
|
||||||
useCreateTeam,
|
useCreateTeam,
|
||||||
useInviteUser,
|
useInviteUser,
|
||||||
|
useLocations,
|
||||||
useResendInvitation,
|
useResendInvitation,
|
||||||
useRoles,
|
useRoles,
|
||||||
useTeams,
|
useTeams,
|
||||||
@@ -46,6 +47,7 @@ export default function PagePersonnes() {
|
|||||||
<th>Rôle</th>
|
<th>Rôle</th>
|
||||||
<th>Équipe</th>
|
<th>Équipe</th>
|
||||||
<th>Taux horaire</th>
|
<th>Taux horaire</th>
|
||||||
|
<th>Sites</th>
|
||||||
<th>Statut</th>
|
<th>Statut</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -75,6 +77,13 @@ export default function PagePersonnes() {
|
|||||||
editable={can('PEOPLE_TEAMS', 'edit')}
|
editable={can('PEOPLE_TEAMS', 'edit')}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{u.role.name === 'Demandeur' ? (
|
||||||
|
<CelluleSites utilisateur={u} editable={can('PEOPLE_TEAMS', 'edit')} />
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{u.status === 'active' ? (
|
{u.status === 'active' ? (
|
||||||
<span className="st termine">Actif</span>
|
<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({
|
function ModaleInvitation({
|
||||||
ouverte,
|
ouverte,
|
||||||
surFermeture,
|
surFermeture,
|
||||||
@@ -217,7 +310,12 @@ function ModaleInvitation({
|
|||||||
}) {
|
}) {
|
||||||
const { data: roles } = useRoles();
|
const { data: roles } = useRoles();
|
||||||
const { data: teams } = useTeams();
|
const { data: teams } = useTeams();
|
||||||
|
const { data: locations } = useLocations();
|
||||||
const invitation = useInviteUser();
|
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>) => {
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -229,10 +327,13 @@ function ModaleInvitation({
|
|||||||
displayName: String(donnees.get('displayName')),
|
displayName: String(donnees.get('displayName')),
|
||||||
roleId: String(donnees.get('roleId')),
|
roleId: String(donnees.get('roleId')),
|
||||||
teamIds: teamId ? [teamId] : undefined,
|
teamIds: teamId ? [teamId] : undefined,
|
||||||
|
locationIds: sitesCoches.length ? sitesCoches : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: (reponse) => {
|
onSuccess: (reponse) => {
|
||||||
surFermeture(false);
|
surFermeture(false);
|
||||||
|
setRoleId('');
|
||||||
|
setSitesCoches([]);
|
||||||
surLien(reponse);
|
surLien(reponse);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -253,7 +354,13 @@ function ModaleInvitation({
|
|||||||
<div className="form-grille">
|
<div className="form-grille">
|
||||||
<div className="champ">
|
<div className="champ">
|
||||||
<label htmlFor="inv-role">Rôle *</label>
|
<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>
|
<option value="" disabled>Choisir…</option>
|
||||||
{(roles ?? []).map((r) => (
|
{(roles ?? []).map((r) => (
|
||||||
<option key={r.id} value={r.id}>{r.name}</option>
|
<option key={r.id} value={r.id}>{r.name}</option>
|
||||||
@@ -270,6 +377,30 @@ function ModaleInvitation({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<p className="note-douce">
|
||||||
La personne recevra un <b>lien d'activation valable 7 jours</b> pour choisir
|
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.
|
son mot de passe. Aucun compte n'est actif avant cela.
|
||||||
|
|||||||
355
docs/02-design/maquettes/maquette-mobile-assistant.html
Normal file
355
docs/02-design/maquettes/maquette-mobile-assistant.html
Normal 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>
|
||||||
764
docs/02-design/maquettes/maquette-mobile-tous-roles.html
Normal file
764
docs/02-design/maquettes/maquette-mobile-tous-roles.html
Normal 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>
|
||||||
@@ -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
|
<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
|
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
|
<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">
|
<section class="ecran" id="voix">
|
||||||
<div class="tels">
|
<div class="tels">
|
||||||
<div class="tel">
|
<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 contour">✨ Suggérer les codes depuis ce texte</div>
|
||||||
<div class="btn-tel">Joindre la description à l'OT</div>
|
<div class="btn-tel">Joindre la description à l'OT</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,23 @@
|
|||||||
réseau privé — la règle d'or ESLint d'ADR-001 concerne le code TypeScript de l'API,
|
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.
|
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
|
## Conséquences
|
||||||
|
|
||||||
- L'anonymisation (D4) s'applique **à l'ingestion** — les index ne contiennent jamais
|
- 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.
|
- 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
|
- 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.
|
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`.
|
||||||
|
|||||||
91
docs/03-architecture/adr/ADR-005-distribution-mobile.md
Normal file
91
docs/03-architecture/adr/ADR-005-distribution-mobile.md
Normal 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.
|
||||||
@@ -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_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_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_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 :
|
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
|
- 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
|
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).
|
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
|
## 6. Calibrage des seuils sur le corpus client
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## 2026-07-17 — Pr. Daaif (+ Claude) — Arbitrages de recette R5 rendus : le corpus passe en tableau
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
@@ -1322,6 +1322,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/assistant/transcribe": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "transcribeAudio",
|
||||||
|
"summary": "Dictée (R5 D5, opt-in) — l’audio 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": {
|
"/search": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "globalSearch",
|
"operationId": "globalSearch",
|
||||||
@@ -4539,6 +4587,27 @@
|
|||||||
"type": "null"
|
"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": [
|
"required": [
|
||||||
@@ -4550,7 +4619,8 @@
|
|||||||
"teams",
|
"teams",
|
||||||
"status",
|
"status",
|
||||||
"isDemo",
|
"isDemo",
|
||||||
"hourlyRate"
|
"hourlyRate",
|
||||||
|
"assignedSites"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
@@ -4656,6 +4726,14 @@
|
|||||||
"phone": {
|
"phone": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 40
|
"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": [
|
"required": [
|
||||||
@@ -4760,6 +4838,27 @@
|
|||||||
"type": "null"
|
"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": [
|
"required": [
|
||||||
@@ -4771,7 +4870,8 @@
|
|||||||
"teams",
|
"teams",
|
||||||
"status",
|
"status",
|
||||||
"isDemo",
|
"isDemo",
|
||||||
"hourlyRate"
|
"hourlyRate",
|
||||||
|
"assignedSites"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
@@ -4814,6 +4914,14 @@
|
|||||||
"type": "null"
|
"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
|
"additionalProperties": false
|
||||||
@@ -5427,6 +5535,19 @@
|
|||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"TranscriptionResult": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"SearchResponse": {
|
"SearchResponse": {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ services:
|
|||||||
AI_SEUIL_PERTINENCE: ${AI_SEUIL_PERTINENCE:-0.45}
|
AI_SEUIL_PERTINENCE: ${AI_SEUIL_PERTINENCE:-0.45}
|
||||||
AI_SEUIL_SUGGESTION: ${AI_SEUIL_SUGGESTION:-0.40}
|
AI_SEUIL_SUGGESTION: ${AI_SEUIL_SUGGESTION:-0.40}
|
||||||
AI_SEUIL_CONFIANCE_FORTE: ${AI_SEUIL_CONFIANCE_FORTE:-0.55}
|
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:
|
depends_on:
|
||||||
siop2-postgres:
|
siop2-postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
BilanSuggestionsResponseSchema,
|
BilanSuggestionsResponseSchema,
|
||||||
ReindexResultSchema,
|
ReindexResultSchema,
|
||||||
SuggestBilanSchema,
|
SuggestBilanSchema,
|
||||||
|
TranscriptionResultSchema,
|
||||||
} from './schemas/assistant';
|
} from './schemas/assistant';
|
||||||
import {
|
import {
|
||||||
ConsumePartSchema,
|
ConsumePartSchema,
|
||||||
@@ -564,6 +565,18 @@ export const API_CONTRACT: ApiOperation[] = [
|
|||||||
503: { description: 'Service IA indisponible' },
|
503: { description: 'Service IA indisponible' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
operationId: 'transcribeAudio',
|
||||||
|
method: 'post',
|
||||||
|
path: '/assistant/transcribe',
|
||||||
|
summary: 'Dictée (R5 D5, opt-in) — l’audio 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',
|
operationId: 'globalSearch',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|||||||
@@ -71,3 +71,11 @@ export const ReindexResultSchema = z.object({
|
|||||||
chunks: z.number().int(),
|
chunks: z.number().int(),
|
||||||
});
|
});
|
||||||
export type ReindexResult = z.infer<typeof ReindexResultSchema>;
|
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>;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const UserAdminSchema = z.object({
|
|||||||
isDemo: z.boolean(),
|
isDemo: z.boolean(),
|
||||||
/** Taux horaire COURANT (MAD/h) — chaque saisie de main-d'œuvre fige le sien. */
|
/** Taux horaire COURANT (MAD/h) — chaque saisie de main-d'œuvre fige le sien. */
|
||||||
hourlyRate: z.number().nullable(),
|
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>;
|
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
|
teamIds: z.array(z.uuid()).optional(), // remplace l'affectation
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
hourlyRate: z.number().positive().nullable().optional(), // R3 — taux courant
|
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>;
|
export type UserUpdate = z.infer<typeof UserUpdateSchema>;
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ export const InvitationCreateSchema = z.object({
|
|||||||
roleId: z.uuid(),
|
roleId: z.uuid(),
|
||||||
teamIds: z.array(z.uuid()).optional(),
|
teamIds: z.array(z.uuid()).optional(),
|
||||||
phone: z.string().max(40).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>;
|
export type InvitationCreate = z.infer<typeof InvitationCreateSchema>;
|
||||||
|
|
||||||
|
|||||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -135,12 +135,18 @@ importers:
|
|||||||
expo:
|
expo:
|
||||||
specifier: ~57.0.6
|
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)
|
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:
|
expo-camera:
|
||||||
specifier: ~57.0.3
|
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)
|
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:
|
expo-constants:
|
||||||
specifier: ~57.0.5
|
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))
|
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:
|
expo-font:
|
||||||
specifier: ~57.0.1
|
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)
|
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: '*'
|
||||||
react-native: '*'
|
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:
|
expo-camera@57.0.3:
|
||||||
resolution: {integrity: sha512-Q+3aZ63eQCkdB6/FZrO/lfacNAg/j8JCeKQL2nBdf6vBeOo1Y2PKYx1/vK+U5LaRnIo/0tMGmCOzZ1JGhTeMIw==}
|
resolution: {integrity: sha512-Q+3aZ63eQCkdB6/FZrO/lfacNAg/j8JCeKQL2nBdf6vBeOo1Y2PKYx1/vK+U5LaRnIo/0tMGmCOzZ1JGhTeMIw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -10851,6 +10865,13 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- 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):
|
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:
|
dependencies:
|
||||||
barcode-detector: 3.2.1(@types/emscripten@1.41.5)
|
barcode-detector: 3.2.1(@types/emscripten@1.41.5)
|
||||||
|
|||||||
Reference in New Issue
Block a user