mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Compare commits
36 Commits
release/r3
...
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 | ||
|
|
cccfaaabc9 | ||
|
|
b69c54ed0f | ||
|
|
28eecc1fb9 | ||
|
|
45ae491827 | ||
|
|
76c2ccdfb1 | ||
|
|
d4d73a6f76 | ||
|
|
837dcba1db | ||
|
|
199fce69d0 | ||
|
|
a5e8ca6d62 | ||
|
|
c8b3c1769a | ||
|
|
a22ea60f83 | ||
|
|
514fc7c391 | ||
|
|
5c8d5aac92 | ||
|
|
2736a2f3a1 |
103
.github/workflows/ci.yml
vendored
103
.github/workflows/ci.yml
vendored
@@ -44,14 +44,15 @@ jobs:
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @siop/shared build
|
||||
- name: Régénérer la spec et le client typé
|
||||
- name: Régénérer la spec et les clients typés
|
||||
run: |
|
||||
pnpm --filter @siop/shared contract
|
||||
pnpm --filter @siop/web generate:client
|
||||
pnpm --filter @siop/mobile generate:client
|
||||
- name: Vérifier qu'aucun artefact ne dérive du contrat
|
||||
run: |
|
||||
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts; then
|
||||
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client)."
|
||||
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts apps/mobile/src/api/schema.d.ts; then
|
||||
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client && pnpm --filter @siop/mobile generate:client)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -59,18 +60,8 @@ jobs:
|
||||
name: api (tests + couverture ≥ 70 %)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
# R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography).
|
||||
# pgvector arrivera en R5 (bascule alors sur l’image infra/postgres).
|
||||
postgres:
|
||||
image: postgis/postgis:18-3.6
|
||||
env:
|
||||
POSTGRES_USER: siop
|
||||
POSTGRES_PASSWORD: siop
|
||||
POSTGRES_DB: siop
|
||||
ports: ['5432:5432']
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U siop -d siop"
|
||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
|
||||
# services ne savent pas builder, il démarre donc par étape ci-dessous.
|
||||
redis:
|
||||
image: redis:7.4-alpine
|
||||
ports: ['6379:6379']
|
||||
@@ -86,6 +77,15 @@ jobs:
|
||||
MINIO_SECRET_KEY: siop-minio
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Démarrer PostgreSQL (image infra — pgvector + PostGIS, comme partout)
|
||||
run: |
|
||||
docker build -t siop2/postgres infra/postgres
|
||||
docker run -d --name postgres -p 5432:5432 \
|
||||
-e POSTGRES_USER=siop -e POSTGRES_PASSWORD=siop -e POSTGRES_DB=siop \
|
||||
siop2/postgres
|
||||
for i in $(seq 1 30); do
|
||||
docker exec postgres pg_isready -U siop -d siop && break; sleep 2;
|
||||
done
|
||||
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
||||
run: |
|
||||
docker run -d --name minio -p 9000:9000 \
|
||||
@@ -118,20 +118,40 @@ jobs:
|
||||
- run: pnpm --filter @siop/web test
|
||||
- run: pnpm --filter @siop/web build
|
||||
|
||||
mobile:
|
||||
name: mobile (typecheck + tests jest-expo)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @siop/shared build
|
||||
- run: pnpm --filter @siop/mobile typecheck
|
||||
- run: pnpm --filter @siop/mobile test
|
||||
|
||||
ai:
|
||||
name: ai (pytest + ruff — uv)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
- run: uv sync
|
||||
working-directory: apps/ai
|
||||
- run: uv run ruff check src tests
|
||||
working-directory: apps/ai
|
||||
- run: uv run pytest
|
||||
working-directory: apps/ai
|
||||
|
||||
e2e:
|
||||
name: e2e (parcours démo Playwright)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:18-3.6
|
||||
env:
|
||||
POSTGRES_USER: siop
|
||||
POSTGRES_PASSWORD: siop
|
||||
POSTGRES_DB: siop
|
||||
ports: ['5432:5432']
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U siop -d siop"
|
||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
|
||||
# services ne savent pas builder, il démarre donc par étape ci-dessous.
|
||||
redis:
|
||||
image: redis:7.4-alpine
|
||||
ports: ['6379:6379']
|
||||
@@ -147,6 +167,15 @@ jobs:
|
||||
MINIO_SECRET_KEY: siop-minio
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Démarrer PostgreSQL (image infra — pgvector + PostGIS, comme partout)
|
||||
run: |
|
||||
docker build -t siop2/postgres infra/postgres
|
||||
docker run -d --name postgres -p 5432:5432 \
|
||||
-e POSTGRES_USER=siop -e POSTGRES_PASSWORD=siop -e POSTGRES_DB=siop \
|
||||
siop2/postgres
|
||||
for i in $(seq 1 30); do
|
||||
docker exec postgres pg_isready -U siop -d siop && break; sleep 2;
|
||||
done
|
||||
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
||||
run: |
|
||||
docker run -d --name minio -p 9000:9000 \
|
||||
@@ -162,8 +191,30 @@ jobs:
|
||||
- run: pnpm --filter @siop/api prisma:generate
|
||||
- run: pnpm --filter @siop/api exec prisma migrate deploy
|
||||
- run: pnpm --filter @siop/api build
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
- name: Démarrer le service IA (R5 — embeddeur déterministe, seuils CI)
|
||||
working-directory: apps/ai
|
||||
env:
|
||||
AI_EMBEDDINGS: deterministe
|
||||
AI_SERVICE_TOKEN: dev-only-ai-token
|
||||
# L'embeddeur déterministe (tri-grammes hachés) produit des scores
|
||||
# bien plus bas que le vrai modèle : seuils calibrés sur mesures
|
||||
# réelles (vrai match ≈ 0,66 ; bruit de collisions ≈ 0,11 ; codes
|
||||
# de bilan pertinents ≈ 0,14-0,16 ; parasites ≈ 0,08).
|
||||
AI_SEUIL_PERTINENCE: '0.20'
|
||||
AI_SEUIL_SUGGESTION: '0.10'
|
||||
AI_SEUIL_CONFIANCE_FORTE: '0.30'
|
||||
run: |
|
||||
uv sync
|
||||
nohup uv run uvicorn siop_ai.app:app --host 127.0.0.1 --port 8000 > /tmp/siop-ai.log 2>&1 &
|
||||
for i in $(seq 1 30); do
|
||||
curl -fsS http://127.0.0.1:8000/healthz && break; sleep 1;
|
||||
done
|
||||
- run: pnpm --filter @siop/web exec playwright install --with-deps chromium
|
||||
- run: pnpm --filter @siop/web e2e
|
||||
- name: Journal du service IA en cas d'échec
|
||||
if: failure()
|
||||
run: cat /tmp/siop-ai.log || true
|
||||
- name: Traces Playwright en cas d'échec
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -180,7 +231,7 @@ jobs:
|
||||
deploy:
|
||||
name: deploy (Dokploy — siop2.apps.enset.top)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs: [lint, contract, api, web, e2e]
|
||||
needs: [lint, contract, api, web, mobile, ai, e2e]
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -2,8 +2,17 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
# apps/mobile/.env ne porte aucun secret — un réglage de build (fetch RN
|
||||
# classique, cf. ADR-005) qui doit être le même pour tout le monde.
|
||||
!apps/mobile/.env
|
||||
.turbo/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Python (apps/ai)
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
36
CLAUDE.md
36
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.
|
||||
2. **Playbook vivant** : chaque phase du cycle de vie a son dossier `docs/0X-*/` (template réutilisable + artefacts réels). Le journal quotidien vit dans `docs/journal/` — **jamais** dans le livre.
|
||||
3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn.
|
||||
3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA → R6 Mobile tous rôles. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn.
|
||||
4. **Déployer tôt** : chaque release part sur le serveur de production (Dokploy) dès sa recette.
|
||||
5. **Le développeur est le premier utilisateur** : `DEMO_MODE=true` active un **sélecteur de compte démo** (connexion 1 clic sur les comptes seedés, endpoint `POST /auth/demo-login` strictement absent si l'env ne l'active pas). Critère : changer de rôle en < 3 s sans mot de passe.
|
||||
|
||||
@@ -54,5 +54,35 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
||||
- ✅ **R3.3** : 7 écrans web fidèles à maquette-r3 (stock/alertes, fiche pièce, BC + réception, création BC préremplie depuis l'alerte, tiers, bibliothèque, statistiques) + fiche OT complète (coûts réels : consommer à prix figé / saisir temps à taux figé ; carte Documents) + fiche ascenseur (Documents) + taux horaire dans Personnes + nav Ressources/Statistiques activée par la matrice. Recette R3 automatisée (13/13 e2e Playwright).
|
||||
- ✅ **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) → 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndic→site via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright.
|
||||
- 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne.
|
||||
- 🔄 **Reprise ici** : redéployer sur Dokploy et vérifier en ligne (migration `r3_recette_fixes` + seed au boot) → ouverture R4 Mobile (Expo, design d'abord — maquettes avant tout code).
|
||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||
- ✅ **R4 — maquettes rédigées** (17/07) : `maquette-r4.html`, 7 écrans mobile technicien offline-first (Ma journée bi-état, fiche OT, clôture terrain avec garde, scan QR des étiquettes R1, fiche ascenseur, checklist en file, synchro & conflits à verrou optimiste) + 5 décisions à acter (offline-first en file, verrou optimiste tranché par l'humain, périmètre fermé technicien, scan local, photos en file — ni audio ni géoloc en R4).
|
||||
- ✅ **R4 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R4.1 socle mobile** : `apps/mobile` (Expo SDK 57, TS strict), connexion + sélecteur démo ADR-002, tabbar (onglets futurs marqués), « Ma journée » triée priorité/échéance, cache TanStack persisté (lecture hors-ligne D1), jeton SecureStore, client typé du contrat, `CORS_ORIGINS` opt-in côté API (Expo web/debug seulement), 6 tests jest-expo + job CI `mobile`. Vérifié 10/10 en Expo web (connexion démo → Ma journée → hors-ligne/retour).
|
||||
- ✅ **R4.2 — terrain en ligne** : scanner QR réel (expo-camera, `analyseScan` testée, résolution locale D4, QR étrangers refusés), fiche ascenseur (D3, historique scopé), fiche OT (machine à états, coûts figés, garde par `closureBlockers` API), clôture terrain (bilan 6 champs au pouce), préventif → grille cochable (appui long = N/A, `aria-checked`). Écritures en ligne assumées (bandeaux) — la file est R4.3. Vérifié 12/12 en Expo web (dont clôture complète d'un OT de test), 12 tests jest-expo.
|
||||
- ✅ **R4.3 — file & verrou** : verrou optimiste serveur (`baseUpdatedAt` → 409 contextualisé, toute écriture avance la version, web inchangé, ADR-003 sécurité/routage mobile) ; file persistée rejouée dans l'ordre (propagation de version intra-lot, arrêt sur conflit, patchs optimistes, photos D5 compressées en file), écran Synchro & conflits (rejouer sur version à jour / abandonner), préchargement parc+référentiels, purge complète à la déconnexion. Recette « mode avion » 13/13 en Expo web (conflit tranché par l'humain, serveur DONE + bilan), 17 tests jest-expo, 74 tests API.
|
||||
- 🏁 **R4 CLOSE (17/07/2026, tag `release/r4`)** : posé sur décision du référent avec la recette « mode avion » validée 13/13 en Expo web piloté ; **la recette sur téléphone (Expo Go — vrai mode avion, scan caméra) est reportée et reste due avant toute production client mobile**.
|
||||
- ✅ **R5 — maquettes rédigées** (17/07) : `maquette-r5.html`, 6 écrans (assistant RAG à citations sources document/page/extrait, refus explicite hors corpus, suggestion de bilan web+mobile à validation humaine, corpus & ingestion administrable, voix opt-in avec purge) + 5 décisions à acter (aucune écriture automatique, sourcé ou silencieux, corpus fermé, anonymisation 09-08, voix purgée).
|
||||
- ✅ **R5 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R5.1 socle `apps/ai`** : ADR-004 (embeddings locaux fastembed 384d, pgvector via migration Prisma `r5_ia` (`RagChunk` + corpus sur Document), génération opt-in — mode extractif par défaut, service jamais exposé joint par l'API seule), pipeline d'ingestion anonymisé D4 (fonction pure testée, PDF paginés + bilans codés), `/internal/reindex` + `/internal/search` sous jeton de service, 14 pytest + ruff + job CI `ai` (embeddeur déterministe en CI). Vérifié en réel : corpus seedé indexé en 7 s, recherche sémantique concluante, 0 identité dans les chunks.
|
||||
- ✅ **R5.1+ génération opt-in** : `Generateur` ADR-004 §3 — extractif par défaut, `AI_GENERATION=api` + `AI_API_KEY` (exigée au boot, jamais loguée ; Dokploy secrets) + `AI_MODEL` (défaut claude-opus-4-8), SDK anthropic en extra optionnel, citations [n] obligatoires, repli extractif sur tout échec (dont `stop_reason=refusal`). **R5.2 assistant au contrat** : `POST /assistant/ask` (WORK_ORDERS.view) + `POST /assistant/suggest-bilan` (WORK_ORDERS.edit) proxifiés par NestJS (traduction dialecte interne → contrat, 503 propre), suggestion = codes EXISTANTS seulement (confiance + « N bilans similaires »), normalisation fastembed corrigée (débusquée en chaîne réelle), 23 pytest, tests API sur stub HTTP.
|
||||
- ✅ **R5.3 — écrans IA** : page web Assistant (chat sourcé, refus honnête chiffré, « Ouvrir » vers PDF/OT), Bibliothèque = corpus administrable (statut d'indexation par document, interrupteur d'exclusion, « Réindexer tout », bandeau 09-08), suggestions fiche OT (« Appliquer » = geste humain, liseré « suggéré » retiré au choix manuel) et clôture mobile (chips, « réseau requis » hors-ligne) ; contrat 76 opérations (corpus sur Document, `PATCH /documents/{id}/corpus`, `POST /assistant/reindex` — ci-contract vérifie aussi le client mobile) ; seuils `AI_SEUIL_*` par env ; job e2e CI avec `siop2-ai` (embeddeur déterministe, seuils calibrés sur mesures réelles), 16/16 Playwright, 78 tests API ; chaîne vérifiée au vrai modèle ONNX (web 7/7, mobile Expo web 6/6, 0 erreur console).
|
||||
- ✅ **Recette R5 sans clé API + durcissement** (17/07) : la recette a invalidé MiniLM-384 (page-réponse classée derrière des passages sans rapport, 0,24 vs 0,41) → **bascule mesurée vers `paraphrase-multilingual-mpnet-base-v2` 768 d** (ADR-004 amendé, migration `r5_embeddings_mpnet`, découpage ~350 car., seuils 0,45/0,40/0,55) ; recette type ✓ (réponse sourcée p. 2, refus honnête, D1-D5, tout en extractif) ; revue pixel publiée (artefact, 3 arbitrages) ; Dockerfile `siop2-ai` (modèle au build, non-root), compose Dokploy (service interne, `AI_SERVICE_TOKEN` requis, génération opt-in), runbook §5-6 (service IA, calibrage seuils client, réindexation post-déploiement).
|
||||
- ✅ **Revue pixel R5 validée par le référent (17/07)** — 1 correction appliquée sur arbitrage : la Bibliothèque-corpus passe en **tableau** (Document/Rattaché à/Indexation/Corpus + actions ; interrupteur éteint pour les non-indexables), vignettes conservées sur les cartes Documents des fiches ; « Appliquer » direct et calibrage au runbook validés tels quels. 16/16 Playwright rejoués.
|
||||
- 🏁 **R5 CLOSE (17/07/2026, tag `release/r5`)** : recettée par le référent (revue pixel + arbitrage tableau), recette passée SANS clé API, durcissement répété en local conteneurisé. **R0 → R5 : périmètre v1 couvert.**
|
||||
- ✅ **Recette terrain mobile sur iPhone 15 Pro — 7/7 validés (19/07, ADR-005)** : Expo Go bloqué par le retard d'approbation Apple (SDK 54 vs notre SDK 57) → **décision : builds natifs locaux (Xcode/Android Studio, signature gratuite) pour la vraie recette, Expo Go conservé pour l'aperçu sans installation**. 5 obstacles techniques réels corrigés et documentés (UDID plutôt que nom, ne jamais contourner `expo run:*` par un xcodebuild manuel, modules Expo/RN précompilés SDK 56/57 incompatibles avec la liaison statique du projet, `fetch` global incompatible avec l'upload multipart natif — fix permanent `EXPO_PUBLIC_USE_RN_FETCH=1` dans `apps/mobile/.env`, découverte réseau du dev client peu fiable). Recette iOS : connexion, scan caméra réel (résolution + rejet QR étranger), vrai mode avion, conflit D2 tranché par l'humain avec photo réellement téléversée, persistance à travers fermeture/reconstruction de l'app, suggestions R5 au vrai modèle, purge de sécurité au changement de compte — tout validé. Android : build natif sur émulateur, passage santé complet.
|
||||
- ✅ **Dictée implémentée (22/07, ADR-004 §5)** : écran Voix R5 (jamais construit) — `faster-whisper` local (CTranslate2, CPU, opt-in `AI_TRANSCRIPTION=off|locale`, défaut off), audio jamais persisté (purge serveur ET mobile quoi qu'il arrive), transcription → relecture humaine → suggestion immédiate et/ou sauvegarde dans `InterventionReport.note` (champ existant depuis R2, jamais eu d'écran jusqu'ici) → `note` rejoint le corpus à la clôture comme les bilans codés. Mobile : `expo-audio`/`expo-file-system`, bouton dicter sur l'écran de clôture. Docker `siop2-ai` embarque le modèle (1,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.
|
||||
|
||||
9
apps/ai/.dockerignore
Normal file
9
apps/ai/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.venv
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
__pycache__
|
||||
tests
|
||||
README.md
|
||||
.env
|
||||
.env.example
|
||||
Dockerfile
|
||||
20
apps/ai/.env.example
Normal file
20
apps/ai/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# Copier en .env pour le dev local (défauts alignés sur infra/docker-compose.yml).
|
||||
DATABASE_URL=postgresql://siop:siop@localhost:5432/siop
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_ACCESS_KEY=siop
|
||||
MINIO_SECRET_KEY=siop-minio
|
||||
MINIO_BUCKET=siop2
|
||||
|
||||
# Seule l'API NestJS connaît ce secret (ADR-004 §4 — service jamais public).
|
||||
AI_SERVICE_TOKEN=dev-only-ai-token
|
||||
|
||||
# Embeddings : locale (fastembed ONNX, CPU) | deterministe (tests/CI)
|
||||
AI_EMBEDDINGS=locale
|
||||
|
||||
# Génération (ADR-004 §3) : off = mode extractif (défaut — la recette passe
|
||||
# sans clé) | api = rédaction par le LLM sur textes déjà anonymisés (D4).
|
||||
# AI_GENERATION=api exige AI_API_KEY (le boot refuse sinon).
|
||||
AI_GENERATION=off
|
||||
AI_API_KEY=
|
||||
AI_MODEL=claude-opus-4-8
|
||||
44
apps/ai/Dockerfile
Normal file
44
apps/ai/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# SIOP V2 — image du service IA (siop2-ai, ADR-004).
|
||||
# Contexte de build : apps/ai (le service est autonome, pas de dépendance au
|
||||
# monorepo). Étage 1 : uv sync + téléchargement du modèle ONNX AU BUILD
|
||||
# (ADR-004 §1 — jamais au démarrage) ; étage 2 : runtime minimal non-root.
|
||||
# Ce service n'est JAMAIS exposé publiquement : seul siop2-api le contacte,
|
||||
# porteur du secret AI_SERVICE_TOKEN (ADR-004 §4).
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
ENV UV_LINK_MODE=copy \
|
||||
FASTEMBED_CACHE_PATH=/opt/fastembed \
|
||||
HF_HOME=/opt/whisper
|
||||
|
||||
# Manifestes d'abord (cache de couche), puis le code. L'installation du projet
|
||||
# reste éditable (.pth → /app/src) : src est donc copié dans l'image finale.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev \
|
||||
--extra embeddings --extra generation --extra transcription
|
||||
COPY src src
|
||||
RUN uv sync --frozen --no-dev \
|
||||
--extra embeddings --extra generation --extra transcription
|
||||
|
||||
# Les modèles locaux (embeddings + dictée) sont EMBARQUÉS dans l'image : pas
|
||||
# de téléchargement au boot (démarrage prévisible, marche sans accès à
|
||||
# Hugging Face en production). La dictée reste opt-in (AI_TRANSCRIPTION=off
|
||||
# par défaut) — le modèle est prêt si elle est activée un jour, sans rebuild.
|
||||
RUN uv run python -c "from siop_ai.embeddings import EmbeddeurLocal; EmbeddeurLocal()"
|
||||
RUN uv run python -c "from siop_ai.transcription import TranscripteurLocal; TranscripteurLocal('small')"
|
||||
|
||||
FROM python:3.11-slim-bookworm
|
||||
WORKDIR /app
|
||||
ENV PATH=/app/.venv/bin:$PATH \
|
||||
FASTEMBED_CACHE_PATH=/opt/fastembed \
|
||||
HF_HOME=/opt/whisper
|
||||
RUN useradd --system --create-home siop
|
||||
COPY --from=builder --chown=siop:siop /app/.venv /app/.venv
|
||||
COPY --from=builder --chown=siop:siop /app/src /app/src
|
||||
COPY --from=builder --chown=siop:siop /opt/fastembed /opt/fastembed
|
||||
COPY --from=builder --chown=siop:siop /opt/whisper /opt/whisper
|
||||
USER siop
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz', timeout=4).status==200 else 1)"
|
||||
CMD ["uvicorn", "siop_ai.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
33
apps/ai/README.md
Normal file
33
apps/ai/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @siop/ai — service IA (R5)
|
||||
|
||||
FastAPI + uv (ADR-001), **jamais exposé** : seule l'API NestJS le contacte avec
|
||||
`X-Service-Token` (ADR-004 §4). Maquettes et décisions D1-D5 validées le 17/07/2026.
|
||||
|
||||
## Lancer (dev)
|
||||
|
||||
```bash
|
||||
cd apps/ai
|
||||
uv sync --extra embeddings # le vrai modèle ONNX (CPU, ~120 Mo au premier run)
|
||||
uv run uvicorn siop_ai.app:app --port 8000
|
||||
# CI / tests : uv sync && uv run pytest (embeddeur déterministe, aucun téléchargement)
|
||||
```
|
||||
|
||||
Variables (défauts dev dans `config.py`, gabarit dans `.env.example`) :
|
||||
`DATABASE_URL`, `MINIO_*`, `AI_SERVICE_TOKEN`, `AI_EMBEDDINGS=locale|deterministe`,
|
||||
et la génération opt-in (ADR-004 §3) : `AI_GENERATION=off|api`, **`AI_API_KEY`**
|
||||
(exigée en mode api — le boot refuse sinon, jamais loguée ni exposée),
|
||||
`AI_MODEL` (défaut `claude-opus-4-8`). Mode api : `uv sync --extra generation`
|
||||
(SDK officiel `anthropic`) ; tout échec du LLM retombe sur le mode extractif.
|
||||
|
||||
## Ce que porte R5.1 (socle)
|
||||
|
||||
- **Ingestion anonymisée (D4)** : PDF de la bibliothèque (MinIO) page par page +
|
||||
bilans codés clôturés → anonymisation (e-mails, téléphones, noms connus de la
|
||||
base) → découpage → embeddings locaux → `RagChunk` (pgvector, schéma Prisma).
|
||||
- **Recherche sémantique** `/internal/search` : extraits sourcés (document + page
|
||||
ou bilan daté) avec score — la brique de « sourcé ou silencieux » (D2).
|
||||
- `/internal/reindex` idempotent ; l'exclusion de corpus (`Document.inCorpus`,
|
||||
D3) s'applique à l'ingestion ET à la lecture.
|
||||
|
||||
La suite : R5.2 assistant (mode extractif puis génération opt-in) + suggestion de
|
||||
bilan ; R5.3 écrans ; durcissement : Dockerfile + compose Dokploy (`siop2-ai`).
|
||||
46
apps/ai/pyproject.toml
Normal file
46
apps/ai/pyproject.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[project]
|
||||
name = "siop-ai"
|
||||
version = "0.1.0"
|
||||
description = "SIOP V2 — service IA (R5) : ingestion anonymisée, recherche sémantique, assistant sourcé (ADR-004)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"pydantic-settings>=2.6",
|
||||
"asyncpg>=0.30",
|
||||
"pypdf>=5.1",
|
||||
"minio>=7.2",
|
||||
"python-multipart>=0.0.20",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Le vrai modèle (ONNX, CPU) — absent des tests/CI (embeddeur déterministe).
|
||||
embeddings = ["fastembed>=0.4"]
|
||||
# Génération opt-in (ADR-004 §3) — absente des tests/CI (repli extractif).
|
||||
generation = ["anthropic>=0.75"]
|
||||
# Transcription opt-in (dictée R5, ADR-004 §5) — absente des tests/CI
|
||||
# (transcripteur déterministe). CTranslate2/CPU, licence MIT, local.
|
||||
transcription = ["faster-whisper>=1.1"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
"httpx>=0.27",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/siop_ai"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
src = ["src", "tests"]
|
||||
1
apps/ai/src/siop_ai/__init__.py
Normal file
1
apps/ai/src/siop_ai/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""SIOP V2 — service IA (R5). L'IA propose, l'humain valide (D1)."""
|
||||
62
apps/ai/src/siop_ai/anonymisation.py
Normal file
62
apps/ai/src/siop_ai/anonymisation.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Anonymisation à l'ingestion (D4, loi 09-08) : identités et coordonnées ne
|
||||
partent JAMAIS dans les index vectoriels ni dans les prompts.
|
||||
|
||||
Fonction pure, testée : e-mails, téléphones (formats marocains et
|
||||
internationaux), et les noms de personnes CONNUS de la base (utilisateurs,
|
||||
gardiens, contacts tiers) fournis par l'appelant.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
JETON_PERSONNE = "⟨personne⟩"
|
||||
JETON_CONTACT = "⟨contact⟩"
|
||||
|
||||
_EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
|
||||
# 06 12 34 56 78 · 0612345678 · +212 6 12 34 56 78 · 05 22-34-56-78…
|
||||
_TELEPHONE = re.compile(r"(?:\+?\d{1,3}[\s.-]?)?(?:0|\(0\))?\d(?:[\s.-]?\d{2}){4}")
|
||||
|
||||
|
||||
def _sans_accents(texte: str) -> str:
|
||||
return "".join(
|
||||
c for c in unicodedata.normalize("NFD", texte) if unicodedata.category(c) != "Mn"
|
||||
)
|
||||
|
||||
|
||||
def anonymiser(texte: str, noms_connus: list[str] | None = None) -> str:
|
||||
"""Remplace coordonnées et noms connus par des jetons neutres.
|
||||
|
||||
Les noms sont remplacés insensiblement à la casse ET aux accents
|
||||
(« Idrissi » attrape « idrissi »), prénom seul compris quand il est
|
||||
assez long pour ne pas mutiler le texte technique.
|
||||
"""
|
||||
resultat = _EMAIL.sub(JETON_CONTACT, texte)
|
||||
resultat = _TELEPHONE.sub(JETON_CONTACT, resultat)
|
||||
|
||||
for nom in sorted(noms_connus or [], key=len, reverse=True):
|
||||
nom = nom.strip()
|
||||
if len(nom) < 3:
|
||||
continue
|
||||
morceaux = [nom] + [m for m in nom.split() if len(m) >= 4]
|
||||
for morceau in morceaux:
|
||||
motif = re.compile(
|
||||
r"\b" + re.escape(_sans_accents(morceau)) + r"\b", re.IGNORECASE
|
||||
)
|
||||
# on cherche sur une copie sans accents mais on remplace l'original
|
||||
copie = _sans_accents(resultat)
|
||||
sortie: list[str] = []
|
||||
position = 0
|
||||
for correspondance in motif.finditer(copie):
|
||||
sortie.append(resultat[position : correspondance.start()])
|
||||
sortie.append(JETON_PERSONNE)
|
||||
position = correspondance.end()
|
||||
sortie.append(resultat[position:])
|
||||
resultat = "".join(sortie)
|
||||
|
||||
# jetons collés en double (« prénom nom » remplacés séparément)
|
||||
resultat = re.sub(
|
||||
rf"{re.escape(JETON_PERSONNE)}(\s+{re.escape(JETON_PERSONNE)})+",
|
||||
JETON_PERSONNE,
|
||||
resultat,
|
||||
)
|
||||
return resultat
|
||||
133
apps/ai/src/siop_ai/app.py
Normal file
133
apps/ai/src/siop_ai/app.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Service IA — JAMAIS exposé publiquement (ADR-004 §4) : seule l'API NestJS
|
||||
le contacte, avec le secret partagé `X-Service-Token`. Les permissions des
|
||||
utilisateurs restent l'affaire de l'API — ici, un seul appelant de confiance.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import asdict
|
||||
|
||||
import asyncpg
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .assistant import repondre, suggerer_bilan
|
||||
from .config import Reglages, charger_reglages
|
||||
from .embeddings import construire_embeddeur
|
||||
from .generation import construire_generateur
|
||||
from .ingestion import reindexer_tout
|
||||
from .recherche import chercher
|
||||
from .transcription import construire_transcripteur
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def cycle_de_vie(app: FastAPI):
|
||||
reglages = charger_reglages()
|
||||
app.state.reglages = reglages
|
||||
app.state.embeddeur = construire_embeddeur(reglages.ai_embeddings)
|
||||
app.state.generateur = construire_generateur(
|
||||
reglages.ai_generation, reglages.ai_api_key, reglages.ai_model
|
||||
)
|
||||
app.state.transcripteur = construire_transcripteur(
|
||||
reglages.ai_transcription, reglages.ai_transcription_model
|
||||
)
|
||||
app.state.pool = await asyncpg.create_pool(reglages.database_url, min_size=1, max_size=5)
|
||||
yield
|
||||
await app.state.pool.close()
|
||||
|
||||
|
||||
app = FastAPI(title="SIOP V2 — service IA (R5)", lifespan=cycle_de_vie)
|
||||
|
||||
|
||||
def verifier_jeton(
|
||||
x_service_token: str = Header(default=""),
|
||||
) -> None:
|
||||
reglages: Reglages = app.state.reglages
|
||||
if x_service_token != reglages.ai_service_token:
|
||||
raise HTTPException(status_code=401, detail="Jeton de service invalide")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def sante() -> dict:
|
||||
"""Sonde interne (compose/Dokploy) — ne révèle ni corpus ni secret."""
|
||||
reglages = getattr(app.state, "reglages", None) or charger_reglages()
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "siop2-ai",
|
||||
"generation": reglages.ai_generation, # « off » = extractif — jamais la clé
|
||||
"transcription": reglages.ai_transcription,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/internal/reindex", dependencies=[Depends(verifier_jeton)])
|
||||
async def reindexer() -> dict:
|
||||
async with app.state.pool.acquire() as cnx:
|
||||
resultat = await reindexer_tout(cnx, app.state.reglages, app.state.embeddeur)
|
||||
return asdict(resultat)
|
||||
|
||||
|
||||
class RequeteRecherche(BaseModel):
|
||||
question: str = Field(min_length=3, max_length=500)
|
||||
limite: int = Field(default=5, ge=1, le=10)
|
||||
|
||||
|
||||
@app.post("/internal/search", dependencies=[Depends(verifier_jeton)])
|
||||
async def rechercher(corps: RequeteRecherche) -> dict:
|
||||
async with app.state.pool.acquire() as cnx:
|
||||
extraits = await chercher(cnx, app.state.embeddeur, corps.question, corps.limite)
|
||||
return {"extraits": [asdict(e) for e in extraits]}
|
||||
|
||||
|
||||
@app.post("/internal/ask", dependencies=[Depends(verifier_jeton)])
|
||||
async def demander(corps: RequeteRecherche) -> dict:
|
||||
"""L'assistant D2 : extraits sourcés au-dessus du seuil, ou refus honnête
|
||||
(ce qui a été cherché) — la rédaction n'existe qu'en mode génératif."""
|
||||
async with app.state.pool.acquire() as cnx:
|
||||
reponse = await repondre(
|
||||
cnx,
|
||||
app.state.embeddeur,
|
||||
app.state.generateur,
|
||||
corps.question,
|
||||
corps.limite,
|
||||
seuil_pertinence=app.state.reglages.ai_seuil_pertinence,
|
||||
)
|
||||
return {
|
||||
"mode": reponse.mode,
|
||||
"answer": reponse.answer,
|
||||
"extraits": [asdict(e) for e in reponse.extraits],
|
||||
"corpus": {
|
||||
"documents": reponse.documents_corpus,
|
||||
"bilans": reponse.bilans_corpus,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class RequeteSuggestion(BaseModel):
|
||||
description: str = Field(min_length=10, max_length=2000)
|
||||
|
||||
|
||||
@app.post("/internal/suggest", dependencies=[Depends(verifier_jeton)])
|
||||
async def suggerer(corps: RequeteSuggestion) -> dict:
|
||||
"""Suggestion de codes de bilan (D1) : uniquement des codes EXISTANTS,
|
||||
avec confiance et « N bilans similaires » — l'humain applique, ou pas."""
|
||||
async with app.state.pool.acquire() as cnx:
|
||||
suggestions = await suggerer_bilan(
|
||||
cnx,
|
||||
app.state.embeddeur,
|
||||
corps.description,
|
||||
seuil_suggestion=app.state.reglages.ai_seuil_suggestion,
|
||||
seuil_confiance_forte=app.state.reglages.ai_seuil_confiance_forte,
|
||||
)
|
||||
return {"suggestions": [asdict(s) for s in suggestions]}
|
||||
|
||||
|
||||
@app.post("/internal/transcrire", dependencies=[Depends(verifier_jeton)])
|
||||
async def transcrire(fichier: UploadFile) -> dict:
|
||||
"""Dictée opt-in (R5 D5, loi 09-08) : l'audio ne persiste JAMAIS — un
|
||||
fichier temporaire le temps de l'inférence, supprimé aussitôt (voir
|
||||
transcription.py). 503 propre si AI_TRANSCRIPTION=off (défaut)."""
|
||||
if app.state.transcripteur is None:
|
||||
raise HTTPException(status_code=503, detail="Transcription non activée (AI_TRANSCRIPTION=off)")
|
||||
audio = await fichier.read()
|
||||
extension = (fichier.filename or "audio.m4a").rsplit(".", 1)[-1]
|
||||
texte = app.state.transcripteur.transcrire(audio, extension)
|
||||
return {"texte": texte}
|
||||
153
apps/ai/src/siop_ai/assistant.py
Normal file
153
apps/ai/src/siop_ai/assistant.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""L'assistant (D2 — « sourcé ou silencieux ») et la suggestion de bilan.
|
||||
|
||||
- `repondre` : recherche sémantique → extraits au-dessus du seuil de
|
||||
pertinence, ou refus HONNÊTE qui dit ce qui a été cherché (écran 2 des
|
||||
maquettes). La rédaction est déléguée au `Generateur` (opt-in ADR-004 §3) ;
|
||||
sans lui, le mode extractif est la réponse.
|
||||
- `suggerer_bilan` : similarité sémantique entre la description libre et les
|
||||
libellés ACTIFS des référentiels (l'IA ne peut suggérer que des codes
|
||||
existants) + comptage des bilans similaires du parc. Sans LLM : rapide,
|
||||
déterministe, explicable.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import asyncpg
|
||||
|
||||
from .embeddings import Embeddeur
|
||||
from .generation import Generateur
|
||||
from .recherche import ExtraitTrouve, chercher
|
||||
|
||||
# Répliques des libellés français de @siop/shared (BILAN_FIELD_LABELS) —
|
||||
# utilisés pour contextualiser les embeddings des codes.
|
||||
CHAMPS_BILAN = {
|
||||
"DOOR_STATE": "état des portes",
|
||||
"CABIN_POSITION": "position cabine",
|
||||
"ANOMALY": "anomalie constatée",
|
||||
"EXTERNAL_CAUSE": "cause extérieure",
|
||||
"ACTION_TAKEN": "action réalisée",
|
||||
"COMPONENT_CONCERNED": "élément concerné",
|
||||
}
|
||||
|
||||
# Défauts — surchargés par la config (AI_SEUIL_*) : calibrage en recette.
|
||||
SEUIL_PERTINENCE = 0.45 # en dessous : le corpus ne porte pas la réponse
|
||||
SEUIL_SUGGESTION = 0.40
|
||||
SEUIL_CONFIANCE_FORTE = 0.55
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReponseAssistant:
|
||||
mode: str # « extractif » | « genere » | « refus »
|
||||
answer: str | None
|
||||
extraits: list[ExtraitTrouve]
|
||||
documents_corpus: int
|
||||
bilans_corpus: int
|
||||
|
||||
|
||||
async def _taille_corpus(cnx: asyncpg.Connection) -> tuple[int, int]:
|
||||
ligne = await cnx.fetchrow(
|
||||
'''
|
||||
SELECT
|
||||
(SELECT count(DISTINCT "documentId") FROM "RagChunk"
|
||||
WHERE "sourceType" = 'DOCUMENT') AS documents,
|
||||
(SELECT count(*) FROM "RagChunk" WHERE "sourceType" = 'WORK_ORDER') AS bilans
|
||||
'''
|
||||
)
|
||||
return ligne["documents"], ligne["bilans"]
|
||||
|
||||
|
||||
async def repondre(
|
||||
cnx: asyncpg.Connection,
|
||||
embeddeur: Embeddeur,
|
||||
generateur: Generateur,
|
||||
question: str,
|
||||
limite: int = 5,
|
||||
seuil_pertinence: float = SEUIL_PERTINENCE,
|
||||
) -> ReponseAssistant:
|
||||
documents, bilans = await _taille_corpus(cnx)
|
||||
extraits = await chercher(cnx, embeddeur, question, limite)
|
||||
pertinents = [e for e in extraits if e.score >= seuil_pertinence]
|
||||
|
||||
if not pertinents:
|
||||
# D2 : refus explicite — on dit ce qu'on a cherché, on n'invente rien.
|
||||
return ReponseAssistant(
|
||||
mode="refus",
|
||||
answer=None,
|
||||
extraits=[],
|
||||
documents_corpus=documents,
|
||||
bilans_corpus=bilans,
|
||||
)
|
||||
|
||||
redige = generateur.rediger(question, pertinents)
|
||||
return ReponseAssistant(
|
||||
mode="genere" if redige else "extractif",
|
||||
answer=redige,
|
||||
extraits=pertinents,
|
||||
documents_corpus=documents,
|
||||
bilans_corpus=bilans,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SuggestionBilan:
|
||||
field: str
|
||||
value_id: str
|
||||
label: str
|
||||
confidence: str # « FORTE » | « MOYENNE »
|
||||
similar_reports: int # bilans du parc portant déjà ce code (« 9 bilans similaires »)
|
||||
score: float
|
||||
|
||||
|
||||
def _cosinus(a: list[float], b: list[float]) -> float:
|
||||
return sum(x * y for x, y in zip(a, b)) # vecteurs déjà normés
|
||||
|
||||
|
||||
async def suggerer_bilan(
|
||||
cnx: asyncpg.Connection,
|
||||
embeddeur: Embeddeur,
|
||||
description: str,
|
||||
seuil_suggestion: float = SEUIL_SUGGESTION,
|
||||
seuil_confiance_forte: float = SEUIL_CONFIANCE_FORTE,
|
||||
) -> list[SuggestionBilan]:
|
||||
valeurs = await cnx.fetch(
|
||||
'SELECT id, field, label FROM "ReferenceValue" WHERE "isActive" ORDER BY field, label'
|
||||
)
|
||||
if not valeurs:
|
||||
return []
|
||||
|
||||
textes = [description] + [
|
||||
f"{CHAMPS_BILAN.get(v['field'], v['field'])} : {v['label']}" for v in valeurs
|
||||
]
|
||||
vecteurs = embeddeur.encoder(textes)
|
||||
v_description, v_valeurs = vecteurs[0], vecteurs[1:]
|
||||
|
||||
# Le meilleur code par champ, au-dessus du seuil — jamais plus d'une
|
||||
# suggestion par champ, jamais un code inventé.
|
||||
meilleurs: dict[str, tuple[asyncpg.Record, float]] = {}
|
||||
for valeur, vecteur in zip(valeurs, v_valeurs):
|
||||
score = _cosinus(v_description, vecteur)
|
||||
if score < seuil_suggestion:
|
||||
continue
|
||||
champ = valeur["field"]
|
||||
if champ not in meilleurs or score > meilleurs[champ][1]:
|
||||
meilleurs[champ] = (valeur, score)
|
||||
|
||||
suggestions: list[SuggestionBilan] = []
|
||||
for valeur, score in meilleurs.values():
|
||||
# « 9 bilans similaires sur ce parc » : les bilans clôturés portant ce code
|
||||
similaires = await cnx.fetchval(
|
||||
'SELECT count(*) FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\' AND content ILIKE $1',
|
||||
f"%{valeur['label']}%",
|
||||
)
|
||||
suggestions.append(
|
||||
SuggestionBilan(
|
||||
field=valeur["field"],
|
||||
value_id=str(valeur["id"]),
|
||||
label=valeur["label"],
|
||||
confidence="FORTE" if score >= seuil_confiance_forte else "MOYENNE",
|
||||
similar_reports=similaires,
|
||||
score=round(score, 4),
|
||||
)
|
||||
)
|
||||
suggestions.sort(key=lambda s: s.score, reverse=True)
|
||||
return suggestions
|
||||
52
apps/ai/src/siop_ai/config.py
Normal file
52
apps/ai/src/siop_ai/config.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Configuration — validée au démarrage, comme l'API NestJS (même philosophie)."""
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Reglages(BaseSettings):
|
||||
# Postgres partagé (schéma possédé par Prisma — apps/api)
|
||||
database_url: str = "postgresql://siop:siop@localhost:5432/siop"
|
||||
# MinIO en direct sur le réseau privé (ADR-004 §4 — jamais exposé)
|
||||
minio_endpoint: str = "localhost"
|
||||
minio_port: int = 9000
|
||||
minio_use_ssl: bool = False
|
||||
minio_access_key: str = "siop"
|
||||
minio_secret_key: str = "siop-minio"
|
||||
minio_bucket: str = "siop2"
|
||||
# Le service n'est JAMAIS public : seul l'API NestJS le contacte,
|
||||
# porteuse de ce secret partagé (ADR-004 §4).
|
||||
ai_service_token: str = "dev-only-ai-token"
|
||||
# Embeddings : « locale » (fastembed ONNX) ou « deterministe » (tests/CI)
|
||||
ai_embeddings: str = "locale"
|
||||
# Génération (ADR-004 §3) : « off » = mode extractif (défaut honnête,
|
||||
# la recette passe sans clé) ; « api » = rédaction par le LLM externe,
|
||||
# sur textes DÉJÀ anonymisés (D4), citations obligatoires.
|
||||
ai_generation: str = "off"
|
||||
ai_api_key: str = "" # requise seulement si ai_generation=api — jamais loguée
|
||||
ai_model: str = "claude-opus-4-8"
|
||||
# Seuils de similarité — constantes de départ, calibrables par env
|
||||
# (recette sur corpus réel ; abaissés en CI e2e — embeddeur déterministe).
|
||||
ai_seuil_pertinence: float = 0.45
|
||||
ai_seuil_suggestion: float = 0.40
|
||||
ai_seuil_confiance_forte: float = 0.55
|
||||
# Dictée opt-in (R5 D5, ADR-004 §5) : « off » (défaut — endpoint refuse
|
||||
# proprement), « locale » (faster-whisper, CPU) ou « deterministe » (CI).
|
||||
ai_transcription: str = "off"
|
||||
ai_transcription_model: str = "small" # tiny|base|small|medium|large-v3
|
||||
|
||||
model_config = {"env_prefix": "", "case_sensitive": False}
|
||||
|
||||
|
||||
def charger_reglages() -> Reglages:
|
||||
reglages = Reglages()
|
||||
# asyncpg ne comprend pas le paramètre ?schema= de Prisma
|
||||
if "?" in reglages.database_url:
|
||||
reglages.database_url = reglages.database_url.split("?")[0]
|
||||
# Même philosophie que l'API NestJS : une config invalide refuse de booter.
|
||||
if reglages.ai_generation not in ("off", "api"):
|
||||
raise ValueError("AI_GENERATION doit valoir « off » ou « api »")
|
||||
if reglages.ai_generation == "api" and not reglages.ai_api_key:
|
||||
raise ValueError("AI_GENERATION=api exige AI_API_KEY (voir ADR-004 §3)")
|
||||
if reglages.ai_transcription not in ("off", "locale", "deterministe"):
|
||||
raise ValueError("AI_TRANSCRIPTION doit valoir « off », « locale » ou « deterministe »")
|
||||
return reglages
|
||||
57
apps/ai/src/siop_ai/decoupage.py
Normal file
57
apps/ai/src/siop_ai/decoupage.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Découpage du texte en extraits indexables — pur et testé.
|
||||
|
||||
Paragraphes regroupés jusqu'à ~350 caractères, avec un chevauchement de
|
||||
queue pour ne pas couper une prescription en deux. Un extrait trop long est
|
||||
scindé sur les phrases.
|
||||
|
||||
Le grain est court À DESSEIN (recette R5) : sur des pages entières, la phrase
|
||||
qui répond se noie dans son contexte et les scores question→passage ne
|
||||
séparent plus le pertinent du voisin de domaine ; à ~350 caractères, la marge
|
||||
revient — et l'extrait cité à l'écran reste lisible d'un coup d'œil.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
TAILLE_CIBLE = 350
|
||||
CHEVAUCHEMENT = 80
|
||||
TAILLE_MINIMALE = 40 # en deçà : bruit (titres orphelins, numéros de page)
|
||||
|
||||
|
||||
def _phrases(texte: str) -> list[str]:
|
||||
return [p.strip() for p in re.split(r"(?<=[.!?;])\s+", texte) if p.strip()]
|
||||
|
||||
|
||||
def decouper(texte: str) -> list[str]:
|
||||
paragraphes = [p.strip() for p in re.split(r"\n\s*\n", texte) if p.strip()]
|
||||
extraits: list[str] = []
|
||||
courant = ""
|
||||
|
||||
def pousser() -> None:
|
||||
nonlocal courant
|
||||
nettoye = courant.strip()
|
||||
if len(nettoye) >= TAILLE_MINIMALE:
|
||||
extraits.append(nettoye)
|
||||
courant = ""
|
||||
|
||||
for paragraphe in paragraphes:
|
||||
paragraphe = re.sub(r"\s+", " ", paragraphe)
|
||||
if len(courant) + len(paragraphe) + 1 > TAILLE_CIBLE and courant:
|
||||
queue = courant[-CHEVAUCHEMENT:]
|
||||
pousser()
|
||||
courant = queue + " "
|
||||
while len(paragraphe) > TAILLE_CIBLE:
|
||||
phrases = _phrases(paragraphe)
|
||||
if len(phrases) <= 1:
|
||||
courant += paragraphe[:TAILLE_CIBLE]
|
||||
paragraphe = paragraphe[TAILLE_CIBLE - CHEVAUCHEMENT :]
|
||||
pousser()
|
||||
continue
|
||||
morceau = ""
|
||||
while phrases and len(morceau) + len(phrases[0]) + 1 <= TAILLE_CIBLE:
|
||||
morceau += phrases.pop(0) + " "
|
||||
courant += morceau
|
||||
pousser()
|
||||
paragraphe = " ".join(phrases)
|
||||
courant += paragraphe + " "
|
||||
pousser()
|
||||
return extraits
|
||||
65
apps/ai/src/siop_ai/embeddings.py
Normal file
65
apps/ai/src/siop_ai/embeddings.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Embeddeurs (ADR-004) : le vrai modèle local ONNX, et un déterministe pour
|
||||
tests/CI — même interface, mêmes dimensions (DIMENSIONS), aucun téléchargement en test.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
from typing import Protocol
|
||||
|
||||
DIMENSIONS = 768
|
||||
# mpnet remplace MiniLM-384 : décision de recette R5 (journal 17/07) — sur le
|
||||
# banc français, MiniLM classait la page-réponse DERRIÈRE des passages sans
|
||||
# rapport (0,24 vs 0,41) ; mpnet rétablit le classement et une marge
|
||||
# signal/bruit exploitable (≥ 0,46 vs ≤ 0,42).
|
||||
MODELE_LOCAL = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
|
||||
|
||||
|
||||
class Embeddeur(Protocol):
|
||||
def encoder(self, textes: list[str]) -> list[list[float]]: ...
|
||||
|
||||
|
||||
class EmbeddeurDeterministe:
|
||||
"""Sac de tri-grammes haché puis normalisé : stable, sans réseau, et les
|
||||
textes proches partagent des composantes — assez pour tester le circuit
|
||||
complet (ingestion → pgvector → similarité)."""
|
||||
|
||||
def encoder(self, textes: list[str]) -> list[list[float]]:
|
||||
return [self._un(t) for t in textes]
|
||||
|
||||
def _un(self, texte: str) -> list[float]:
|
||||
vecteur = [0.0] * DIMENSIONS
|
||||
mots = texte.lower().split()
|
||||
grammes = mots + [" ".join(mots[i : i + 3]) for i in range(max(0, len(mots) - 2))]
|
||||
for gramme in grammes:
|
||||
empreinte = hashlib.sha256(gramme.encode()).digest()
|
||||
indice = int.from_bytes(empreinte[:4], "big") % DIMENSIONS
|
||||
signe = 1.0 if empreinte[4] % 2 == 0 else -1.0
|
||||
vecteur[indice] += signe
|
||||
norme = math.sqrt(sum(v * v for v in vecteur)) or 1.0
|
||||
return [v / norme for v in vecteur]
|
||||
|
||||
|
||||
class EmbeddeurLocal:
|
||||
"""fastembed (ONNX, CPU) — chargé paresseusement, jamais importé en test.
|
||||
Sortie NORMÉE : fastembed ne garantit pas des vecteurs unitaires, or la
|
||||
similarité par produit scalaire (suggestions) l'exige — pgvector, lui,
|
||||
normalise dans son opérateur cosinus, ce qui masquait l'écart."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
from fastembed import TextEmbedding # import différé (dépendance optionnelle)
|
||||
|
||||
self._modele = TextEmbedding(model_name=MODELE_LOCAL)
|
||||
|
||||
def encoder(self, textes: list[str]) -> list[list[float]]:
|
||||
vecteurs = []
|
||||
for vecteur in self._modele.embed(textes):
|
||||
liste = vecteur.tolist()
|
||||
norme = math.sqrt(sum(v * v for v in liste)) or 1.0
|
||||
vecteurs.append([v / norme for v in liste])
|
||||
return vecteurs
|
||||
|
||||
|
||||
def construire_embeddeur(mode: str) -> Embeddeur:
|
||||
if mode == "deterministe":
|
||||
return EmbeddeurDeterministe()
|
||||
return EmbeddeurLocal()
|
||||
94
apps/ai/src/siop_ai/generation.py
Normal file
94
apps/ai/src/siop_ai/generation.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Génération des réponses rédigées (ADR-004 §3) — OPT-IN par configuration.
|
||||
|
||||
- « off » (défaut) : mode extractif — pas de LLM, l'assistant montrera les
|
||||
extraits sourcés tels quels. La recette R5 passe entièrement dans ce mode.
|
||||
- « api » : rédaction par Claude (SDK officiel), sur des extraits DÉJÀ
|
||||
anonymisés (D4), avec l'obligation de ne rien affirmer hors extraits (D2).
|
||||
Tout échec (refus, réseau, quota) retombe sur le mode extractif — jamais
|
||||
d'erreur utilisateur à cause du LLM.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
from .recherche import ExtraitTrouve
|
||||
|
||||
journal = logging.getLogger("siop_ai.generation")
|
||||
|
||||
CONSIGNE = """Tu es l'assistant SIOP d'une société de maintenance d'ascenseurs.
|
||||
Tu réponds en français, UNIQUEMENT à partir des extraits fournis (notices et
|
||||
historiques d'intervention du parc, déjà anonymisés).
|
||||
Règles absolues :
|
||||
- chaque affirmation porte sa citation [n] renvoyant à un extrait fourni ;
|
||||
- si les extraits ne portent pas la réponse, dis-le et n'invente RIEN ;
|
||||
- reste bref et opérationnel : un technicien te lit sur le terrain ;
|
||||
- termine toujours par le rappel que l'humain vérifie avant d'agir."""
|
||||
|
||||
|
||||
class Generateur(Protocol):
|
||||
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None: ...
|
||||
|
||||
|
||||
class GenerateurExtractif:
|
||||
"""Le contrat de base : pas de rédaction — l'appelant montre les extraits.
|
||||
`None` signifie « pas de texte généré », jamais une erreur."""
|
||||
|
||||
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def construire_invite(question: str, extraits: list[ExtraitTrouve]) -> str:
|
||||
"""L'invite utilisateur — fonction pure, testée : la question et les
|
||||
extraits numérotés, rien d'autre (les textes sont déjà anonymisés)."""
|
||||
blocs = [
|
||||
f"[{rang}] {e.titre} · {e.locator}\n{e.content}"
|
||||
for rang, e in enumerate(extraits, start=1)
|
||||
]
|
||||
return "Extraits du corpus :\n\n" + "\n\n".join(blocs) + f"\n\nQuestion : {question}"
|
||||
|
||||
|
||||
class GenerateurAPI:
|
||||
"""Rédaction par Claude — import différé : la dépendance `anthropic` est
|
||||
optionnelle (groupe `generation`), absente des tests/CI."""
|
||||
|
||||
def __init__(self, api_key: str, modele: str) -> None:
|
||||
from anthropic import Anthropic
|
||||
|
||||
self._client = Anthropic(api_key=api_key)
|
||||
self._modele = modele
|
||||
|
||||
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None:
|
||||
import anthropic
|
||||
|
||||
if not extraits:
|
||||
return None # sourcé ou silencieux (D2) : rien à citer = rien à rédiger
|
||||
try:
|
||||
reponse = self._client.messages.create(
|
||||
model=self._modele,
|
||||
max_tokens=2048, # réponses courtes et sourcées, par conception
|
||||
thinking={"type": "adaptive"},
|
||||
system=CONSIGNE,
|
||||
messages=[
|
||||
{"role": "user", "content": construire_invite(question, extraits)}
|
||||
],
|
||||
)
|
||||
if reponse.stop_reason == "refusal":
|
||||
journal.warning("Génération refusée par le modèle — repli extractif")
|
||||
return None
|
||||
texte = "".join(b.text for b in reponse.content if b.type == "text").strip()
|
||||
return texte or None
|
||||
except anthropic.RateLimitError:
|
||||
journal.warning("Quota API atteint — repli extractif")
|
||||
return None
|
||||
except anthropic.APIStatusError as e:
|
||||
journal.warning("API génération %s — repli extractif", e.status_code)
|
||||
return None
|
||||
except anthropic.APIConnectionError:
|
||||
journal.warning("API génération injoignable — repli extractif")
|
||||
return None
|
||||
|
||||
|
||||
def construire_generateur(mode: str, api_key: str, modele: str) -> Generateur:
|
||||
if mode == "api":
|
||||
return GenerateurAPI(api_key, modele)
|
||||
return GenerateurExtractif()
|
||||
178
apps/ai/src/siop_ai/ingestion.py
Normal file
178
apps/ai/src/siop_ai/ingestion.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Ingestion du corpus (D3) : bibliothèque R3 (PDF, MinIO) + bilans codés.
|
||||
Chaque texte passe par l'anonymisation (D4) AVANT découpage et embeddings.
|
||||
Les chunks vivent dans `RagChunk` (pgvector, schéma possédé par Prisma).
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
import asyncpg
|
||||
from minio import Minio
|
||||
from pypdf import PdfReader
|
||||
|
||||
from .anonymisation import anonymiser
|
||||
from .config import Reglages
|
||||
from .decoupage import decouper
|
||||
from .embeddings import Embeddeur
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResultatIngestion:
|
||||
documents_indexes: int
|
||||
documents_ignores: int
|
||||
bilans_indexes: int
|
||||
extraits: int
|
||||
|
||||
|
||||
def _vecteur_sql(vecteur: list[float]) -> str:
|
||||
return "[" + ",".join(f"{v:.6f}" for v in vecteur) + "]"
|
||||
|
||||
|
||||
async def noms_a_anonymiser(cnx: asyncpg.Connection) -> list[str]:
|
||||
"""Toutes les identités connues de la base (D4) : utilisateurs, gardiens,
|
||||
contacts tiers, demandeurs du portail."""
|
||||
lignes = await cnx.fetch(
|
||||
'''
|
||||
SELECT "displayName" AS nom FROM "User"
|
||||
UNION SELECT "guardianName" FROM "Location" WHERE "guardianName" IS NOT NULL
|
||||
UNION SELECT "contactName" FROM "Partner" WHERE "contactName" IS NOT NULL
|
||||
'''
|
||||
)
|
||||
return [ligne["nom"] for ligne in lignes if ligne["nom"]]
|
||||
|
||||
|
||||
def extraire_texte_pdf(octets: bytes) -> list[tuple[str, str]]:
|
||||
"""[(texte, localisation)] par page — la citation doit pointer la page."""
|
||||
lecteur = PdfReader(io.BytesIO(octets))
|
||||
pages: list[tuple[str, str]] = []
|
||||
for numero, page in enumerate(lecteur.pages, start=1):
|
||||
texte = page.extract_text() or ""
|
||||
if texte.strip():
|
||||
pages.append((texte, f"p. {numero}"))
|
||||
return pages
|
||||
|
||||
|
||||
async def indexer_documents(
|
||||
cnx: asyncpg.Connection,
|
||||
reglages: Reglages,
|
||||
embeddeur: Embeddeur,
|
||||
noms: list[str],
|
||||
) -> tuple[int, int, int]:
|
||||
minio = Minio(
|
||||
f"{reglages.minio_endpoint}:{reglages.minio_port}",
|
||||
access_key=reglages.minio_access_key,
|
||||
secret_key=reglages.minio_secret_key,
|
||||
secure=reglages.minio_use_ssl,
|
||||
)
|
||||
documents = await cnx.fetch(
|
||||
'SELECT id, "fileName", "storageKey", "contentType", "inCorpus" FROM "Document"'
|
||||
)
|
||||
indexes, ignores, total_extraits = 0, 0, 0
|
||||
for doc in documents:
|
||||
# réindexation idempotente : on repart de zéro pour ce document
|
||||
await cnx.execute('DELETE FROM "RagChunk" WHERE "documentId" = $1', doc["id"])
|
||||
if not doc["inCorpus"] or doc["contentType"] != "application/pdf":
|
||||
await cnx.execute(
|
||||
'UPDATE "Document" SET "indexedAt" = NULL, "chunkCount" = 0 WHERE id = $1',
|
||||
doc["id"],
|
||||
)
|
||||
ignores += 1
|
||||
continue
|
||||
reponse = minio.get_object(reglages.minio_bucket, doc["storageKey"])
|
||||
try:
|
||||
octets = reponse.read()
|
||||
finally:
|
||||
reponse.close()
|
||||
reponse.release_conn()
|
||||
extraits: list[tuple[str, str]] = []
|
||||
for texte_page, localisation in extraire_texte_pdf(octets):
|
||||
texte_sur = anonymiser(texte_page, noms)
|
||||
extraits += [(morceau, localisation) for morceau in decouper(texte_sur)]
|
||||
if extraits:
|
||||
vecteurs = embeddeur.encoder([contenu for contenu, _ in extraits])
|
||||
await cnx.executemany(
|
||||
'''
|
||||
INSERT INTO "RagChunk"
|
||||
(id, "sourceType", "documentId", locator, content, embedding)
|
||||
VALUES (gen_random_uuid(), 'DOCUMENT', $1, $2, $3, $4::vector)
|
||||
''',
|
||||
[
|
||||
(doc["id"], localisation, contenu, _vecteur_sql(vecteur))
|
||||
for (contenu, localisation), vecteur in zip(extraits, vecteurs)
|
||||
],
|
||||
)
|
||||
await cnx.execute(
|
||||
'UPDATE "Document" SET "indexedAt" = now(), "chunkCount" = $2 WHERE id = $1',
|
||||
doc["id"],
|
||||
len(extraits),
|
||||
)
|
||||
indexes += 1
|
||||
total_extraits += len(extraits)
|
||||
return indexes, ignores, total_extraits
|
||||
|
||||
|
||||
async def indexer_bilans(
|
||||
cnx: asyncpg.Connection, embeddeur: Embeddeur, noms: list[str]
|
||||
) -> tuple[int, int]:
|
||||
"""Les bilans codés clôturés — « sur votre parc, ce réglage a déjà… »."""
|
||||
await cnx.execute('DELETE FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\'')
|
||||
bilans = await cnx.fetch(
|
||||
'''
|
||||
SELECT wo.id, wo.reference, wo.title, wo."completedAt", ir.note,
|
||||
a.reference AS asset_ref, a.brand, a.model,
|
||||
(SELECT json_object_agg(rv.field, rv.label)
|
||||
FROM "InterventionReport" ir2
|
||||
JOIN "ReferenceValue" rv ON rv.id IN (
|
||||
ir2."doorStateId", ir2."cabinPositionId", ir2."anomalyId",
|
||||
ir2."externalCauseId", ir2."actionTakenId", ir2."componentConcernedId")
|
||||
WHERE ir2."workOrderId" = wo.id) AS bilan
|
||||
FROM "WorkOrder" wo
|
||||
JOIN "InterventionReport" ir ON ir."workOrderId" = wo.id
|
||||
JOIN "Asset" a ON a.id = wo."assetId"
|
||||
WHERE wo.status = 'DONE'
|
||||
'''
|
||||
)
|
||||
lignes = []
|
||||
for bilan in bilans:
|
||||
champs = json.loads(bilan["bilan"]) if bilan["bilan"] else {}
|
||||
codes = " ; ".join(f"{champ} : {label}" for champ, label in champs.items())
|
||||
# note (D5) : dictée ou saisie libre relue par l'humain — même pipeline
|
||||
# d'anonymisation que le reste, aucun traitement à part.
|
||||
note = f" Description libre : {bilan['note']}." if bilan["note"] else ""
|
||||
contenu = anonymiser(
|
||||
f"Intervention {bilan['reference']} — {bilan['title']}. "
|
||||
f"Appareil {bilan['asset_ref']} ({bilan['brand']} {bilan['model'] or ''}). "
|
||||
f"Bilan codé : {codes}.{note}",
|
||||
noms,
|
||||
)
|
||||
quand = bilan["completedAt"].date().isoformat() if bilan["completedAt"] else "date inconnue"
|
||||
lignes.append((bilan["id"], f"bilan du {quand}", contenu))
|
||||
if lignes:
|
||||
vecteurs = embeddeur.encoder([contenu for _, _, contenu in lignes])
|
||||
await cnx.executemany(
|
||||
'''
|
||||
INSERT INTO "RagChunk"
|
||||
(id, "sourceType", "workOrderId", locator, content, embedding)
|
||||
VALUES (gen_random_uuid(), 'WORK_ORDER', $1, $2, $3, $4::vector)
|
||||
''',
|
||||
[
|
||||
(wo_id, localisation, contenu, _vecteur_sql(vecteur))
|
||||
for (wo_id, localisation, contenu), vecteur in zip(lignes, vecteurs)
|
||||
],
|
||||
)
|
||||
return len(lignes), len(lignes)
|
||||
|
||||
|
||||
async def reindexer_tout(
|
||||
cnx: asyncpg.Connection, reglages: Reglages, embeddeur: Embeddeur
|
||||
) -> ResultatIngestion:
|
||||
noms = await noms_a_anonymiser(cnx)
|
||||
docs_ok, docs_non, extraits_docs = await indexer_documents(cnx, reglages, embeddeur, noms)
|
||||
bilans, extraits_bilans = await indexer_bilans(cnx, embeddeur, noms)
|
||||
return ResultatIngestion(
|
||||
documents_indexes=docs_ok,
|
||||
documents_ignores=docs_non,
|
||||
bilans_indexes=bilans,
|
||||
extraits=extraits_docs + extraits_bilans,
|
||||
)
|
||||
57
apps/ai/src/siop_ai/recherche.py
Normal file
57
apps/ai/src/siop_ai/recherche.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Recherche sémantique dans le corpus (pgvector, distance cosinus).
|
||||
Ne renvoie QUE des extraits sourcés — la brique de « sourcé ou silencieux ».
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import asyncpg
|
||||
|
||||
from .embeddings import Embeddeur
|
||||
from .ingestion import _vecteur_sql
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtraitTrouve:
|
||||
source_type: str
|
||||
document_id: str | None
|
||||
work_order_id: str | None
|
||||
titre: str # nom de fichier ou référence d'OT
|
||||
locator: str
|
||||
content: str
|
||||
score: float # similarité cosinus (0..1)
|
||||
|
||||
|
||||
async def chercher(
|
||||
cnx: asyncpg.Connection,
|
||||
embeddeur: Embeddeur,
|
||||
question: str,
|
||||
limite: int = 5,
|
||||
) -> list[ExtraitTrouve]:
|
||||
vecteur = _vecteur_sql(embeddeur.encoder([question])[0])
|
||||
lignes = await cnx.fetch(
|
||||
'''
|
||||
SELECT c."sourceType", c."documentId", c."workOrderId", c.locator, c.content,
|
||||
1 - (c.embedding <=> $1::vector) AS score,
|
||||
COALESCE(d."fileName", wo.reference, '?') AS titre
|
||||
FROM "RagChunk" c
|
||||
LEFT JOIN "Document" d ON d.id = c."documentId"
|
||||
LEFT JOIN "WorkOrder" wo ON wo.id = c."workOrderId"
|
||||
WHERE c."documentId" IS NULL OR d."inCorpus" -- l'exclusion D3 s'applique aussi à la lecture
|
||||
ORDER BY c.embedding <=> $1::vector
|
||||
LIMIT $2
|
||||
''',
|
||||
vecteur,
|
||||
limite,
|
||||
)
|
||||
return [
|
||||
ExtraitTrouve(
|
||||
source_type=ligne["sourceType"],
|
||||
document_id=str(ligne["documentId"]) if ligne["documentId"] else None,
|
||||
work_order_id=str(ligne["workOrderId"]) if ligne["workOrderId"] else None,
|
||||
titre=ligne["titre"],
|
||||
locator=ligne["locator"],
|
||||
content=ligne["content"],
|
||||
score=float(ligne["score"]),
|
||||
)
|
||||
for ligne in lignes
|
||||
]
|
||||
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)
|
||||
33
apps/ai/tests/test_anonymisation.py
Normal file
33
apps/ai/tests/test_anonymisation.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""D4 (loi 09-08) : rien d'identifiant ne doit survivre à l'ingestion."""
|
||||
|
||||
from siop_ai.anonymisation import JETON_CONTACT, JETON_PERSONNE, anonymiser
|
||||
|
||||
|
||||
def test_emails_et_telephones_marocains():
|
||||
texte = "Appeler M. Alami au 06 12 34 56 78 ou +212 5 22 34 56 78, sinon gardien@residence.ma"
|
||||
resultat = anonymiser(texte, ["M. Alami"])
|
||||
assert "06 12" not in resultat
|
||||
assert "+212" not in resultat
|
||||
assert "gardien@residence.ma" not in resultat
|
||||
assert resultat.count(JETON_CONTACT) == 3
|
||||
|
||||
|
||||
def test_noms_connus_meme_sans_accents_ni_casse():
|
||||
texte = "Intervention validée par salma idrissi puis contrôlée par AHMED BENALI."
|
||||
resultat = anonymiser(texte, ["Salma Idrissi", "Ahmed Benali"])
|
||||
assert "idrissi" not in resultat.lower()
|
||||
assert "benali" not in resultat.lower()
|
||||
assert resultat.count(JETON_PERSONNE) == 2
|
||||
|
||||
|
||||
def test_prenom_seul_est_attrape_mais_pas_les_mots_courts():
|
||||
resultat = anonymiser("Vu avec Ahmed sur site.", ["Ahmed Benali"])
|
||||
assert "Ahmed" not in resultat
|
||||
# « NC-31 » ou « vis » ne doivent jamais être mutilés par un nom court
|
||||
resultat2 = anonymiser("Contact NC-31 réglé, vis serrées.", ["N. C."])
|
||||
assert "NC-31" in resultat2
|
||||
|
||||
|
||||
def test_le_texte_technique_reste_intact():
|
||||
texte = "Serrer les coulisseaux au couple de 25 N·m ; jeu latéral 0,5 mm."
|
||||
assert anonymiser(texte, ["Salma Idrissi"]) == texte
|
||||
69
apps/ai/tests/test_app.py
Normal file
69
apps/ai/tests/test_app.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Le service n'est jamais public : sans le jeton de service, 401 partout
|
||||
(la santé exceptée — sonde d'infra qui ne révèle rien)."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from siop_ai.app import app
|
||||
|
||||
|
||||
def test_sante_publique_interne():
|
||||
reponse = _client_sans_db().get("/healthz")
|
||||
assert reponse.status_code == 200
|
||||
assert reponse.json()["service"] == "siop2-ai"
|
||||
|
||||
|
||||
def test_endpoints_internes_refuses_sans_jeton():
|
||||
client = _client_sans_db()
|
||||
assert client.post("/internal/reindex").status_code == 401
|
||||
assert client.post("/internal/search", json={"question": "couple de serrage ?"}).status_code == 401
|
||||
assert client.post("/internal/transcrire", files={"fichier": ("a.m4a", b"x")}).status_code == 401
|
||||
|
||||
|
||||
def test_transcription_off_par_defaut_503():
|
||||
"""Dictée pas activée (AI_TRANSCRIPTION=off, défaut) : refus propre,
|
||||
jamais un 500 — même contrat que le service IA éteint côté NestJS."""
|
||||
reponse = _client_sans_db().post(
|
||||
"/internal/transcrire",
|
||||
files={"fichier": ("note.m4a", b"faux-audio")},
|
||||
headers={"X-Service-Token": "dev-only-ai-token"},
|
||||
)
|
||||
assert reponse.status_code == 503
|
||||
|
||||
|
||||
def test_transcription_deterministe_retourne_un_texte():
|
||||
from siop_ai.transcription import TranscripteurDeterministe
|
||||
|
||||
client = _client_sans_db()
|
||||
app.state.transcripteur = TranscripteurDeterministe()
|
||||
reponse = client.post(
|
||||
"/internal/transcrire",
|
||||
files={"fichier": ("note.m4a", b"faux-audio")},
|
||||
headers={"X-Service-Token": "dev-only-ai-token"},
|
||||
)
|
||||
assert reponse.status_code == 200
|
||||
assert "10 octets" in reponse.json()["texte"]
|
||||
assert ".m4a" in reponse.json()["texte"]
|
||||
|
||||
|
||||
def test_question_trop_courte_rejetee_avant_tout():
|
||||
reponse = _client_sans_db().post(
|
||||
"/internal/search",
|
||||
json={"question": "ab"},
|
||||
headers={"X-Service-Token": "dev-only-ai-token"},
|
||||
)
|
||||
assert reponse.status_code == 422
|
||||
|
||||
|
||||
def _client_sans_db() -> TestClient:
|
||||
"""TestClient HORS gestionnaire de contexte : le lifespan (pool DB,
|
||||
modèle d'embeddings) ne tourne pas — on pose l'état minimal. Les tests
|
||||
d'intégration DB se font en local (recette), pas en CI (convention R5.1)."""
|
||||
from siop_ai.config import charger_reglages
|
||||
from siop_ai.transcription import construire_transcripteur
|
||||
|
||||
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)
|
||||
51
apps/ai/tests/test_assistant.py
Normal file
51
apps/ai/tests/test_assistant.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Logique de l'assistant testée sans base : le seuil « sourcé ou silencieux »
|
||||
et la sélection des suggestions — la partie SQL est couverte par la recette
|
||||
réelle (convention R5.1 : pytest purs en CI)."""
|
||||
|
||||
from siop_ai.assistant import (
|
||||
CHAMPS_BILAN,
|
||||
SEUIL_CONFIANCE_FORTE,
|
||||
SEUIL_PERTINENCE,
|
||||
SEUIL_SUGGESTION,
|
||||
_cosinus,
|
||||
)
|
||||
from siop_ai.embeddings import EmbeddeurDeterministe
|
||||
|
||||
|
||||
def test_les_seuils_sont_ordonnes():
|
||||
# Pertinence et suggestion vivent dans des pipelines distincts (question →
|
||||
# passages vs description → libellés) : pas d'ordre imposé entre eux.
|
||||
# L'invariant : une suggestion tout juste retenue n'est jamais « forte ».
|
||||
assert 0 < SEUIL_PERTINENCE < 1
|
||||
assert 0 < SEUIL_SUGGESTION < SEUIL_CONFIANCE_FORTE < 1
|
||||
|
||||
|
||||
def test_champs_bilan_couvrent_les_six_champs_du_contrat():
|
||||
assert set(CHAMPS_BILAN) == {
|
||||
"DOOR_STATE",
|
||||
"CABIN_POSITION",
|
||||
"ANOMALY",
|
||||
"EXTERNAL_CAUSE",
|
||||
"ACTION_TAKEN",
|
||||
"COMPONENT_CONCERNED",
|
||||
}
|
||||
|
||||
|
||||
def test_similarite_discrimine_le_bon_code():
|
||||
"""Le cœur de la suggestion : une description de panne de porte doit être
|
||||
plus proche du code « portes » que d'un code sans rapport."""
|
||||
embeddeur = EmbeddeurDeterministe()
|
||||
description, porte, treuil = embeddeur.encoder(
|
||||
[
|
||||
"la porte cabine rebondit, cellule encrassée, nettoyage barrière porte",
|
||||
"anomalie constatée : cellule ou barrière de porte encrassée",
|
||||
"élément concerné : treuil et moteur de traction",
|
||||
]
|
||||
)
|
||||
assert _cosinus(description, porte) > _cosinus(description, treuil)
|
||||
|
||||
|
||||
def test_cosinus_de_vecteurs_normes():
|
||||
embeddeur = EmbeddeurDeterministe()
|
||||
[v] = embeddeur.encoder(["contrôle mensuel des portes palières"])
|
||||
assert abs(_cosinus(v, v) - 1.0) < 1e-6
|
||||
24
apps/ai/tests/test_decoupage.py
Normal file
24
apps/ai/tests/test_decoupage.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from siop_ai.decoupage import CHEVAUCHEMENT, TAILLE_CIBLE, decouper
|
||||
|
||||
|
||||
def test_texte_court_un_seul_extrait():
|
||||
extraits = decouper("Serrer les vis de fixation des coulisseaux au couple de 25 N·m.")
|
||||
assert len(extraits) == 1
|
||||
|
||||
|
||||
def test_les_miettes_sont_ecartees():
|
||||
assert decouper("p. 3\n\n7\n\n") == []
|
||||
|
||||
|
||||
def test_long_texte_decoupe_avec_chevauchement():
|
||||
paragraphe = "La procédure de maintenance impose un contrôle mensuel des organes. " * 40
|
||||
extraits = decouper(paragraphe)
|
||||
assert len(extraits) >= 2
|
||||
assert all(len(e) <= TAILLE_CIBLE + CHEVAUCHEMENT for e in extraits)
|
||||
|
||||
|
||||
def test_paragraphes_courts_regroupes():
|
||||
texte = "\n\n".join(f"Étape {i} : vérifier le verrouillage de la porte palière." for i in range(6))
|
||||
extraits = decouper(texte)
|
||||
assert len(extraits) == 1
|
||||
assert "Étape 0" in extraits[0] and "Étape 5" in extraits[0]
|
||||
26
apps/ai/tests/test_embeddings.py
Normal file
26
apps/ai/tests/test_embeddings.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from siop_ai.embeddings import DIMENSIONS, EmbeddeurDeterministe
|
||||
|
||||
|
||||
def test_dimensions_et_normalisation():
|
||||
[vecteur] = EmbeddeurDeterministe().encoder(["couple de serrage des guides"])
|
||||
assert len(vecteur) == DIMENSIONS
|
||||
assert abs(sum(v * v for v in vecteur) - 1.0) < 1e-6
|
||||
|
||||
|
||||
def test_stable_et_discriminant():
|
||||
embeddeur = EmbeddeurDeterministe()
|
||||
a1 = embeddeur.encoder(["couple de serrage des coulisseaux de guides"])[0]
|
||||
a2 = embeddeur.encoder(["couple de serrage des coulisseaux de guides"])[0]
|
||||
b = embeddeur.encoder(["planning des congés du personnel administratif"])[0]
|
||||
cosinus = lambda x, y: sum(p * q for p, q in zip(x, y)) # noqa: E731 — vecteurs normés
|
||||
assert a1 == a2
|
||||
assert cosinus(a1, b) < 0.35 < cosinus(a1, a1)
|
||||
|
||||
|
||||
def test_textes_proches_plus_similaires_que_textes_eloignes():
|
||||
embeddeur = EmbeddeurDeterministe()
|
||||
question = embeddeur.encoder(["quel couple de serrage pour les guides ?"])[0]
|
||||
notice = embeddeur.encoder(["serrer les coulisseaux de guides au couple de 25 N·m"])[0]
|
||||
horsujet = embeddeur.encoder(["le syndic organise une assemblée générale annuelle"])[0]
|
||||
cosinus = lambda x, y: sum(p * q for p, q in zip(x, y)) # noqa: E731
|
||||
assert cosinus(question, notice) > cosinus(question, horsujet)
|
||||
62
apps/ai/tests/test_generation.py
Normal file
62
apps/ai/tests/test_generation.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""ADR-004 §3 — la génération est OPT-IN et configurable : clé, modèle, mode.
|
||||
Sans clé, le mode extractif est le contrat ; en mode api sans clé, le boot
|
||||
refuse (config validée au démarrage, comme l'API NestJS)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from siop_ai.config import charger_reglages
|
||||
from siop_ai.generation import GenerateurExtractif, construire_generateur, construire_invite
|
||||
from siop_ai.recherche import ExtraitTrouve
|
||||
|
||||
|
||||
def _extrait(titre: str, locator: str, content: str) -> ExtraitTrouve:
|
||||
return ExtraitTrouve(
|
||||
source_type="DOCUMENT",
|
||||
document_id="d-1",
|
||||
work_order_id=None,
|
||||
titre=titre,
|
||||
locator=locator,
|
||||
content=content,
|
||||
score=0.9,
|
||||
)
|
||||
|
||||
|
||||
def test_defaut_extractif_sans_cle():
|
||||
generateur = construire_generateur("off", "", "claude-opus-4-8")
|
||||
assert isinstance(generateur, GenerateurExtractif)
|
||||
assert generateur.rediger("couple de serrage ?", [_extrait("n", "p. 1", "x")]) is None
|
||||
|
||||
|
||||
def test_mode_api_sans_cle_refuse_au_boot(monkeypatch):
|
||||
monkeypatch.setenv("AI_GENERATION", "api")
|
||||
monkeypatch.delenv("AI_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError, match="AI_API_KEY"):
|
||||
charger_reglages()
|
||||
|
||||
|
||||
def test_mode_inconnu_refuse_au_boot(monkeypatch):
|
||||
monkeypatch.setenv("AI_GENERATION", "toujours")
|
||||
with pytest.raises(ValueError, match="AI_GENERATION"):
|
||||
charger_reglages()
|
||||
|
||||
|
||||
def test_cle_et_modele_configurables(monkeypatch):
|
||||
monkeypatch.setenv("AI_GENERATION", "api")
|
||||
monkeypatch.setenv("AI_API_KEY", "sk-test-123")
|
||||
monkeypatch.setenv("AI_MODEL", "claude-opus-4-8")
|
||||
reglages = charger_reglages()
|
||||
assert reglages.ai_api_key == "sk-test-123"
|
||||
assert reglages.ai_model == "claude-opus-4-8"
|
||||
|
||||
|
||||
def test_invite_numerote_les_extraits_et_rien_d_autre():
|
||||
invite = construire_invite(
|
||||
"quel couple de serrage pour les guides ?",
|
||||
[
|
||||
_extrait("Notice Gen2.pdf", "p. 42", "Serrer à 25 N·m."),
|
||||
_extrait("OT-2026-0341", "bilan du 2026-07-17", "Coulisseaux remplacés."),
|
||||
],
|
||||
)
|
||||
assert "[1] Notice Gen2.pdf · p. 42" in invite
|
||||
assert "[2] OT-2026-0341 · bilan du 2026-07-17" in invite
|
||||
assert invite.endswith("Question : quel couple de serrage pour les guides ?")
|
||||
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"
|
||||
1974
apps/ai/uv.lock
generated
Normal file
1974
apps/ai/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,3 +18,6 @@ MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_ACCESS_KEY=siop
|
||||
MINIO_SECRET_KEY=siop-minio
|
||||
|
||||
# R4 — origines navigateur autorisées (Expo web / debug mobile). Vide = pas de CORS.
|
||||
CORS_ORIGINS=http://localhost:8081
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- R5 (ADR-004) : pgvector — même patron que PostGIS en r1_referentiel
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RagSourceType" AS ENUM ('DOCUMENT', 'WORK_ORDER');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" ADD COLUMN "chunkCount" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "inCorpus" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "indexedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RagChunk" (
|
||||
"id" UUID NOT NULL,
|
||||
"sourceType" "RagSourceType" NOT NULL,
|
||||
"documentId" UUID,
|
||||
"workOrderId" UUID,
|
||||
"locator" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"embedding" vector(384) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RagChunk_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RagChunk_documentId_idx" ON "RagChunk"("documentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RagChunk_workOrderId_idx" ON "RagChunk"("workOrderId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RagChunk" ADD CONSTRAINT "RagChunk_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RagChunk" ADD CONSTRAINT "RagChunk_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Décision de recette R5 (journal 17/07) : le modèle d'embeddings passe de
|
||||
-- MiniLM (384 dims) à paraphrase-multilingual-mpnet-base-v2 (768 dims) —
|
||||
-- MiniLM classait la page-réponse derrière des passages sans rapport.
|
||||
-- Les chunks sont re-dérivables : on vide l'index et on change la dimension ;
|
||||
-- une réindexation (bouton « Réindexer tout » ou /assistant/reindex) reconstruit tout.
|
||||
TRUNCATE "RagChunk";
|
||||
ALTER TABLE "RagChunk" DROP COLUMN "embedding";
|
||||
ALTER TABLE "RagChunk" ADD COLUMN "embedding" vector(768) NOT NULL;
|
||||
@@ -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
|
||||
activationExpiresAt DateTime?
|
||||
teams Team[]
|
||||
// R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction
|
||||
assignedSites Location[]
|
||||
// R2 — exploitation
|
||||
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
||||
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
||||
@@ -111,6 +113,7 @@ model Location {
|
||||
partnerId String? @db.Uuid
|
||||
partner Partner? @relation(fields: [partnerId], references: [id])
|
||||
assets Asset[]
|
||||
assignedUsers User[] // reverse de User.assignedSites (R6.6)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -233,6 +236,7 @@ model WorkOrder {
|
||||
completedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
events WorkOrderEvent[]
|
||||
ragChunks RagChunk[]
|
||||
checklist ChecklistItem[]
|
||||
report InterventionReport?
|
||||
request Request?
|
||||
@@ -513,7 +517,36 @@ model Document {
|
||||
uploadedById String? @db.Uuid
|
||||
uploadedBy User? @relation(fields: [uploadedById], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
// R5 (D3) : le corpus est visible et réversible, document par document.
|
||||
inCorpus Boolean @default(true)
|
||||
indexedAt DateTime? // null = jamais indexé
|
||||
chunkCount Int @default(0)
|
||||
chunks RagChunk[]
|
||||
|
||||
@@index([assetId])
|
||||
@@index([workOrderId])
|
||||
}
|
||||
|
||||
/// R5 — extraits indexés du corpus (ADR-004) : bibliothèque + bilans codés,
|
||||
/// ANONYMISÉS À L'INGESTION (D4). Écrit par apps/ai, schéma possédé par Prisma.
|
||||
model RagChunk {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
sourceType RagSourceType
|
||||
documentId String? @db.Uuid
|
||||
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
workOrderId String? @db.Uuid
|
||||
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||
/// Repère humain de la source : « p. 42 », « bilan du 17/07/2026 »…
|
||||
locator String
|
||||
content String
|
||||
embedding Unsupported("vector(768)")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([documentId])
|
||||
@@index([workOrderId])
|
||||
}
|
||||
|
||||
enum RagSourceType {
|
||||
DOCUMENT
|
||||
WORK_ORDER
|
||||
}
|
||||
|
||||
@@ -462,6 +462,19 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
|
||||
const karim = await parEmail('demandeur@demo.siop.ma');
|
||||
const annee = new Date().getFullYear();
|
||||
|
||||
// R6.6 — Karim (Demandeur) est rattaché à Tour Atlas (cohérent avec le
|
||||
// guardianName déjà seedé pour ce site) : son signalement se limite à ce
|
||||
// parc, ses demandes historiques sur d'autres sites restent visibles.
|
||||
const tourAtlas = await prisma.location.findFirst({
|
||||
where: { name: 'Tour Atlas', parentId: null },
|
||||
});
|
||||
if (tourAtlas) {
|
||||
await prisma.user.update({
|
||||
where: { id: karim },
|
||||
data: { assignedSites: { connect: [{ id: tourAtlas.id }] } },
|
||||
});
|
||||
}
|
||||
|
||||
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
||||
(t) => t.label,
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AnalyticsModule } from './analytics/analytics.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { AssistantModule } from './assistant/assistant.module';
|
||||
import { AssetsModule } from './assets/assets.module';
|
||||
import { DocumentsModule } from './documents/documents.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
@@ -63,6 +64,7 @@ export class AppModule {
|
||||
DocumentsModule,
|
||||
AnalyticsModule,
|
||||
SearchModule,
|
||||
AssistantModule,
|
||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||
],
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type AssetCreate,
|
||||
type AssetUpdate,
|
||||
} from '@siop/shared';
|
||||
import { type AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { AssetsService } from './assets.service';
|
||||
@@ -32,10 +33,11 @@ export class AssetsController {
|
||||
}
|
||||
|
||||
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
|
||||
* doit pouvoir désigner l'appareil qu'il signale. */
|
||||
* doit pouvoir désigner l'appareil qu'il signale (filtré à son site s'il
|
||||
* en a un, R6.6). */
|
||||
@Get('options')
|
||||
options() {
|
||||
return this.assetsService.options();
|
||||
options(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.assetsService.options(user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
AssetsResponse,
|
||||
AssetUpdate,
|
||||
} from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const assetInclude = {
|
||||
@@ -29,9 +30,30 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
||||
export class AssetsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
|
||||
async options(): Promise<AssetOptionsResponse> {
|
||||
/** Sites + zones autorisés pour le signalement de `user`, ou `null` si aucune
|
||||
* restriction (comportement historique — tous les rôles sauf un Demandeur
|
||||
* affecté à un site, R6.6). Réutilisée par `options()` ET par
|
||||
* `RequestsService.create` pour que les deux filtres ne divergent jamais. */
|
||||
async allowedLocationIds(user: AuthenticatedUser): Promise<string[] | null> {
|
||||
const me = await this.prisma.user.findUnique({
|
||||
where: { id: user.userId },
|
||||
select: { assignedSites: { select: { id: true } } },
|
||||
});
|
||||
const siteIds = (me?.assignedSites ?? []).map((s) => s.id);
|
||||
if (siteIds.length === 0) return null;
|
||||
const zones = await this.prisma.location.findMany({
|
||||
where: { parentId: { in: siteIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
return [...siteIds, ...zones.map((z) => z.id)];
|
||||
}
|
||||
|
||||
/** Options minimales pour le signalement — ouvert à tout rôle authentifié,
|
||||
* filtré au périmètre du Demandeur s'il est affecté à un site (R6.6). */
|
||||
async options(user: AuthenticatedUser): Promise<AssetOptionsResponse> {
|
||||
const allowed = await this.allowedLocationIds(user);
|
||||
const rows = await this.prisma.asset.findMany({
|
||||
where: allowed ? { locationId: { in: allowed } } : undefined,
|
||||
include: { location: { include: { parent: true } } },
|
||||
orderBy: { reference: 'asc' },
|
||||
});
|
||||
|
||||
66
apps/api/src/assistant/assistant.controller.ts
Normal file
66
apps/api/src/assistant/assistant.controller.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
AssistantAskSchema,
|
||||
SuggestBilanSchema,
|
||||
type AssistantAsk,
|
||||
type SuggestBilan,
|
||||
} from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { AssistantService } from './assistant.service';
|
||||
|
||||
// Dictée (D5) : un enregistrement de quelques dizaines de secondes tient
|
||||
// largement dans 15 Mo — pas besoin du plafond 20 Mo des documents R3.
|
||||
const AUDIO_MAX_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
@Controller('assistant')
|
||||
export class AssistantController {
|
||||
constructor(private readonly assistant: AssistantService) {}
|
||||
|
||||
/** Poser une question — qui lit les OT peut interroger le corpus. */
|
||||
@Post('ask')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('WORK_ORDERS', 'view')
|
||||
ask(@Body(new ZodValidationPipe(AssistantAskSchema)) body: AssistantAsk) {
|
||||
return this.assistant.ask(body);
|
||||
}
|
||||
|
||||
/** Réindexer le corpus — même droit que la gestion du référentiel (D3). */
|
||||
@Post('reindex')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('ASSETS', 'edit')
|
||||
reindex() {
|
||||
return this.assistant.reindex();
|
||||
}
|
||||
|
||||
/** Suggérer des codes — réservé à qui remplit des bilans (D1). */
|
||||
@Post('suggest-bilan')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('WORK_ORDERS', 'edit')
|
||||
suggest(@Body(new ZodValidationPipe(SuggestBilanSchema)) body: SuggestBilan) {
|
||||
return this.assistant.suggestBilan(body);
|
||||
}
|
||||
|
||||
/** Dictée (D5) — même droit que la saisie du bilan qu'elle alimente. */
|
||||
@Post('transcribe')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('WORK_ORDERS', 'edit')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: AUDIO_MAX_BYTES } }))
|
||||
transcribe(@UploadedFile() file: Express.Multer.File | undefined) {
|
||||
if (!file) throw new BadRequestException('Aucun enregistrement reçu (champ « file »)');
|
||||
return this.assistant.transcribe({
|
||||
buffer: file.buffer,
|
||||
originalName: file.originalname,
|
||||
contentType: file.mimetype,
|
||||
});
|
||||
}
|
||||
}
|
||||
9
apps/api/src/assistant/assistant.module.ts
Normal file
9
apps/api/src/assistant/assistant.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssistantController } from './assistant.controller';
|
||||
import { AssistantService } from './assistant.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AssistantController],
|
||||
providers: [AssistantService],
|
||||
})
|
||||
export class AssistantModule {}
|
||||
161
apps/api/src/assistant/assistant.service.ts
Normal file
161
apps/api/src/assistant/assistant.service.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import type {
|
||||
AssistantAnswer,
|
||||
AssistantAsk,
|
||||
BilanField,
|
||||
BilanSuggestionsResponse,
|
||||
ReindexResult,
|
||||
SuggestBilan,
|
||||
TranscriptionResult,
|
||||
} from '@siop/shared';
|
||||
import { loadEnv } from '../config/env';
|
||||
|
||||
/** Proxy vers `siop2-ai` (ADR-004 §4) : le service IA n'est JAMAIS public —
|
||||
* l'API porte l'auth utilisateur (matrice) et le jeton de service interne.
|
||||
* Il traduit aussi le dialecte interne (français, snake_case) vers le
|
||||
* contrat (@siop/shared) — une seule vérité côté clients. */
|
||||
|
||||
interface ExtraitInterne {
|
||||
source_type: 'DOCUMENT' | 'WORK_ORDER';
|
||||
document_id: string | null;
|
||||
work_order_id: string | null;
|
||||
titre: string;
|
||||
locator: string;
|
||||
content: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
const MODES = { extractif: 'EXTRACTIVE', genere: 'GENERATED', refus: 'REFUSAL' } as const;
|
||||
const CONFIANCES = { FORTE: 'HIGH', MOYENNE: 'MEDIUM' } as const;
|
||||
|
||||
@Injectable()
|
||||
export class AssistantService {
|
||||
private readonly journal = new Logger(AssistantService.name);
|
||||
private readonly env = loadEnv();
|
||||
|
||||
private async appeler<T>(chemin: string, corps: unknown): Promise<T> {
|
||||
let reponse: Response;
|
||||
try {
|
||||
reponse = await fetch(`${this.env.AI_SERVICE_URL}${chemin}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Service-Token': this.env.AI_SERVICE_TOKEN,
|
||||
},
|
||||
body: JSON.stringify(corps),
|
||||
});
|
||||
} catch {
|
||||
this.journal.warn(`Service IA injoignable (${chemin})`);
|
||||
throw new ServiceUnavailableException(
|
||||
'Assistant indisponible pour le moment — réessayez dans un instant.',
|
||||
);
|
||||
}
|
||||
if (!reponse.ok) {
|
||||
this.journal.warn(`Service IA a refusé ${chemin} (${reponse.status})`);
|
||||
throw new ServiceUnavailableException(
|
||||
'Assistant indisponible pour le moment — réessayez dans un instant.',
|
||||
);
|
||||
}
|
||||
return (await reponse.json()) as T;
|
||||
}
|
||||
|
||||
async ask(dto: AssistantAsk): Promise<AssistantAnswer> {
|
||||
const brut = await this.appeler<{
|
||||
mode: keyof typeof MODES;
|
||||
answer: string | null;
|
||||
extraits: ExtraitInterne[];
|
||||
corpus: { documents: number; bilans: number };
|
||||
}>('/internal/ask', { question: dto.question });
|
||||
|
||||
return {
|
||||
mode: MODES[brut.mode],
|
||||
answer: brut.answer,
|
||||
excerpts: brut.extraits.map((e) => ({
|
||||
sourceType: e.source_type,
|
||||
documentId: e.document_id,
|
||||
workOrderId: e.work_order_id,
|
||||
title: e.titre,
|
||||
locator: e.locator,
|
||||
content: e.content,
|
||||
score: e.score,
|
||||
})),
|
||||
corpus: { documents: brut.corpus.documents, reports: brut.corpus.bilans },
|
||||
};
|
||||
}
|
||||
|
||||
async reindex(): Promise<ReindexResult> {
|
||||
const brut = await this.appeler<{
|
||||
documents_indexes: number;
|
||||
documents_ignores: number;
|
||||
bilans_indexes: number;
|
||||
extraits: number;
|
||||
}>('/internal/reindex', {});
|
||||
return {
|
||||
documentsIndexed: brut.documents_indexes,
|
||||
documentsSkipped: brut.documents_ignores,
|
||||
reportsIndexed: brut.bilans_indexes,
|
||||
chunks: brut.extraits,
|
||||
};
|
||||
}
|
||||
|
||||
/** Dictée (R5 D5, opt-in) : l'audio ne transite qu'une fois vers `siop2-ai`
|
||||
* — jamais écrit ni sur disque ni en base ici, transmis tel quel en
|
||||
* multipart. Le service IA l'efface aussitôt transcrit (transcription.py). */
|
||||
async transcribe(audio: {
|
||||
buffer: Buffer;
|
||||
originalName: string;
|
||||
contentType: string;
|
||||
}): Promise<TranscriptionResult> {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'fichier',
|
||||
new Blob([new Uint8Array(audio.buffer)], { type: audio.contentType }),
|
||||
audio.originalName,
|
||||
);
|
||||
let reponse: Response;
|
||||
try {
|
||||
reponse = await fetch(`${this.env.AI_SERVICE_URL}/internal/transcrire`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Service-Token': this.env.AI_SERVICE_TOKEN },
|
||||
body: form,
|
||||
});
|
||||
} catch {
|
||||
this.journal.warn('Service IA injoignable (/internal/transcrire)');
|
||||
throw new ServiceUnavailableException(
|
||||
'Dictée indisponible pour le moment — réessayez dans un instant.',
|
||||
);
|
||||
}
|
||||
if (!reponse.ok) {
|
||||
this.journal.warn(`Service IA a refusé /internal/transcrire (${reponse.status})`);
|
||||
throw new ServiceUnavailableException(
|
||||
'Dictée indisponible pour le moment — réessayez dans un instant.',
|
||||
);
|
||||
}
|
||||
const brut = (await reponse.json()) as { texte: string };
|
||||
return { text: brut.texte };
|
||||
}
|
||||
|
||||
async suggestBilan(dto: SuggestBilan): Promise<BilanSuggestionsResponse> {
|
||||
const brut = await this.appeler<{
|
||||
suggestions: {
|
||||
field: BilanField;
|
||||
value_id: string;
|
||||
label: string;
|
||||
confidence: keyof typeof CONFIANCES;
|
||||
similar_reports: number;
|
||||
score: number;
|
||||
}[];
|
||||
}>('/internal/suggest', { description: dto.description });
|
||||
|
||||
return {
|
||||
suggestions: brut.suggestions.map((s) => ({
|
||||
field: s.field,
|
||||
valueId: s.value_id,
|
||||
label: s.label,
|
||||
confidence: CONFIANCES[s.confidence],
|
||||
similarReports: s.similar_reports,
|
||||
score: s.score,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,13 @@ const EnvSchema = z.object({
|
||||
MINIO_ACCESS_KEY: z.string().default('siop'),
|
||||
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
||||
MINIO_BUCKET: z.string().default('siop2'),
|
||||
// R4 : origines navigateur autorisées en CORS (Expo web / debug mobile).
|
||||
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx
|
||||
// même-origine, les apps natives n'envoient pas d'Origin.
|
||||
CORS_ORIGINS: z.string().default(''),
|
||||
// R5 (ADR-004 §4) : le service IA interne — seul l'API le contacte.
|
||||
AI_SERVICE_URL: z.string().default('http://localhost:8000'),
|
||||
AI_SERVICE_TOKEN: z.string().default('dev-only-ai-token'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
StreamableFile,
|
||||
@@ -14,11 +15,17 @@ import {
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { DOCUMENT_MAX_BYTES } from '@siop/shared';
|
||||
import {
|
||||
DOCUMENT_MAX_BYTES,
|
||||
DocumentCorpusUpdateSchema,
|
||||
type DocumentCorpusUpdate,
|
||||
} from '@siop/shared';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
CurrentUser,
|
||||
} from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { DocumentsService } from './documents.service';
|
||||
|
||||
@Controller('documents')
|
||||
@@ -69,6 +76,16 @@ export class DocumentsController {
|
||||
});
|
||||
}
|
||||
|
||||
/** Corpus IA (D3) : réservé aux gestionnaires du référentiel. */
|
||||
@Patch(':id/corpus')
|
||||
@RequirePermission('ASSETS', 'edit')
|
||||
setCorpus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(DocumentCorpusUpdateSchema)) body: DocumentCorpusUpdate,
|
||||
) {
|
||||
return this.documents.setCorpus(id, body.inCorpus);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
||||
|
||||
@@ -155,6 +155,21 @@ export class DocumentsService {
|
||||
workOrderReference: row.workOrder?.reference ?? null,
|
||||
uploadedByName: row.uploadedBy?.displayName ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
inCorpus: row.inCorpus,
|
||||
indexedAt: row.indexedAt?.toISOString() ?? null,
|
||||
chunkCount: row.chunkCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Interrupteur corpus (D3) — effectif à la prochaine réindexation. */
|
||||
async setCorpus(id: string, inCorpus: boolean): Promise<DocumentDto> {
|
||||
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException('Document inconnu');
|
||||
const updated = await this.prisma.document.update({
|
||||
where: { id },
|
||||
data: { inCorpus },
|
||||
include: documentInclude,
|
||||
});
|
||||
return this.toDto(updated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ async function bootstrap() {
|
||||
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
|
||||
|
||||
const app = await NestFactory.create(AppModule.forRoot());
|
||||
const origines = env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean);
|
||||
if (origines.length) app.enableCors({ origin: origines });
|
||||
app.enableShutdownHooks();
|
||||
await app.listen(env.PORT);
|
||||
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssetsModule } from '../assets/assets.module';
|
||||
import { WorkOrdersModule } from '../work-orders/work-orders.module';
|
||||
import { RequestsController } from './requests.controller';
|
||||
import { RequestsService } from './requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [WorkOrdersModule],
|
||||
imports: [WorkOrdersModule, AssetsModule],
|
||||
controllers: [RequestsController],
|
||||
providers: [RequestsService],
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AssetsService } from '../assets/assets.service';
|
||||
import { WorkOrdersService } from '../work-orders/work-orders.service';
|
||||
|
||||
const requestInclude = {
|
||||
@@ -32,6 +33,7 @@ export class RequestsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly workOrders: WorkOrdersService,
|
||||
private readonly assets: AssetsService,
|
||||
) {}
|
||||
|
||||
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
|
||||
@@ -52,6 +54,13 @@ export class RequestsService {
|
||||
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
|
||||
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
||||
if (!asset) throw new BadRequestException('Équipement inconnu');
|
||||
// Défense en profondeur (R6.6) : même filtre que /assets/options, pour
|
||||
// qu'un Demandeur affecté à un site ne puisse pas contourner la liste
|
||||
// en soumettant directement un assetId hors périmètre.
|
||||
const allowed = await this.assets.allowedLocationIds(user);
|
||||
if (allowed && !allowed.includes(asset.locationId)) {
|
||||
throw new BadRequestException("Cet équipement n'est pas dans votre périmètre");
|
||||
}
|
||||
for (let essai = 0; ; essai++) {
|
||||
try {
|
||||
const created = await this.prisma.request.create({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -20,6 +21,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
const userInclude = {
|
||||
role: true,
|
||||
teams: { orderBy: { name: 'asc' } },
|
||||
assignedSites: { orderBy: { name: 'asc' } },
|
||||
} satisfies Prisma.UserInclude;
|
||||
|
||||
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
||||
@@ -46,6 +48,7 @@ export class UsersService {
|
||||
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
||||
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
||||
if (!role) throw new NotFoundException('Rôle inconnu');
|
||||
if (dto.locationIds?.length) await this.assertTopLevelSites(dto.locationIds);
|
||||
try {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
@@ -56,6 +59,9 @@ export class UsersService {
|
||||
teams: dto.teamIds?.length
|
||||
? { connect: dto.teamIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
assignedSites: dto.locationIds?.length
|
||||
? { connect: dto.locationIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
...this.freshToken(),
|
||||
},
|
||||
});
|
||||
@@ -90,6 +96,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
||||
if (dto.locationIds) await this.assertTopLevelSites(dto.locationIds);
|
||||
try {
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
@@ -102,6 +109,9 @@ export class UsersService {
|
||||
teams: dto.teamIds
|
||||
? { set: dto.teamIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
assignedSites: dto.locationIds
|
||||
? { set: dto.locationIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
},
|
||||
include: userInclude,
|
||||
});
|
||||
@@ -114,6 +124,16 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
/** R6.6 : un Demandeur n'est affecté qu'à des sites racine, jamais des zones
|
||||
* — même invariant que la hiérarchie site/zone (LocationsService.assertDepth). */
|
||||
private async assertTopLevelSites(ids: string[]): Promise<void> {
|
||||
const sites = await this.prisma.location.findMany({ where: { id: { in: ids } } });
|
||||
if (sites.length !== ids.length) throw new BadRequestException('Site inconnu');
|
||||
if (sites.some((s) => s.parentId)) {
|
||||
throw new BadRequestException("L'affectation d'un Demandeur se fait à un site, pas à une zone");
|
||||
}
|
||||
}
|
||||
|
||||
private freshToken() {
|
||||
return {
|
||||
activationToken: randomBytes(32).toString('base64url'),
|
||||
@@ -138,6 +158,7 @@ export class UsersService {
|
||||
: 'invited',
|
||||
isDemo: row.isDemo,
|
||||
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
|
||||
assignedSites: row.assignedSites.map((s) => ({ id: s.id, name: s.name })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ export class WorkOrdersService {
|
||||
user: AuthenticatedUser,
|
||||
): Promise<WorkOrderDetail> {
|
||||
const detail = await this.get(id, user);
|
||||
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
|
||||
throw new ConflictException(
|
||||
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}`,
|
||||
@@ -245,6 +246,7 @@ export class WorkOrdersService {
|
||||
await this.prisma.workOrderEvent.create({
|
||||
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
|
||||
});
|
||||
await this.toucher(id);
|
||||
return this.get(id, user);
|
||||
}
|
||||
|
||||
@@ -284,6 +286,7 @@ export class WorkOrdersService {
|
||||
user: AuthenticatedUser,
|
||||
): Promise<WorkOrderDetail> {
|
||||
await this.get(id, user);
|
||||
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
|
||||
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
|
||||
const valeur = dto[colonne as keyof ReportUpsert];
|
||||
@@ -310,6 +313,7 @@ export class WorkOrdersService {
|
||||
update: donnees,
|
||||
create: { workOrderId: id, ...donnees },
|
||||
});
|
||||
await this.toucher(id);
|
||||
return this.get(id, user);
|
||||
}
|
||||
|
||||
@@ -320,6 +324,7 @@ export class WorkOrdersService {
|
||||
user: AuthenticatedUser,
|
||||
): Promise<ChecklistItemDto> {
|
||||
await this.get(id, user);
|
||||
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||
const { count } = await this.prisma.checklistItem.updateMany({
|
||||
where: { id: itemId, workOrderId: id },
|
||||
data: {
|
||||
@@ -329,6 +334,7 @@ export class WorkOrdersService {
|
||||
},
|
||||
});
|
||||
if (count === 0) throw new NotFoundException('Tâche inconnue');
|
||||
await this.toucher(id);
|
||||
const item = await this.prisma.checklistItem.findUniqueOrThrow({
|
||||
where: { id: itemId },
|
||||
include: { doneBy: true },
|
||||
@@ -368,6 +374,7 @@ export class WorkOrdersService {
|
||||
byId: user.userId,
|
||||
},
|
||||
});
|
||||
await this.toucher(id);
|
||||
return this.get(id, user);
|
||||
}
|
||||
|
||||
@@ -398,6 +405,7 @@ export class WorkOrdersService {
|
||||
note: dto.note,
|
||||
},
|
||||
});
|
||||
await this.toucher(id);
|
||||
return this.get(id, user);
|
||||
}
|
||||
|
||||
@@ -522,6 +530,43 @@ export class WorkOrdersService {
|
||||
},
|
||||
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
|
||||
closureBlockers: blockers,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Verrou optimiste (D2 mobile) : si l'appelant fournit la version lue et
|
||||
* que l'OT a changé depuis, 409 avec le contexte (qui, quand) — rien
|
||||
* n'est écrasé en silence. Sans version fournie (web), comportement
|
||||
* inchangé. */
|
||||
private async assertVersion(id: string, baseUpdatedAt?: string): Promise<void> {
|
||||
if (!baseUpdatedAt) return;
|
||||
const wo = await this.prisma.workOrder.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: {
|
||||
updatedAt: true,
|
||||
events: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
include: { by: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (wo.updatedAt.getTime() > new Date(baseUpdatedAt).getTime()) {
|
||||
const dernier = wo.events[0];
|
||||
const qui = dernier?.by?.displayName ?? 'quelqu’un';
|
||||
const quand = new Intl.DateTimeFormat('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(wo.updatedAt);
|
||||
throw new ConflictException(
|
||||
`Conflit de version : cet OT a été modifié par ${qui} à ${quand} — votre saisie n'a pas été appliquée.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Toute écriture « secondaire » (coche, bilan, commentaire, coûts) fait
|
||||
* avancer la version de l'OT — sinon le verrou D2 ne verrait rien. */
|
||||
private async toucher(id: string): Promise<void> {
|
||||
await this.prisma.workOrder.update({ where: { id }, data: { updatedAt: new Date() } });
|
||||
}
|
||||
}
|
||||
|
||||
210
apps/api/test/assistant.e2e-spec.ts
Normal file
210
apps/api/test/assistant.e2e-spec.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* E2E R5.2 — assistant au contrat : le service IA reste interne, l'API porte
|
||||
* l'auth et la matrice ; le dialecte interne est traduit vers @siop/shared.
|
||||
* Le service IA est joué par un STUB HTTP local (la vraie chaîne se vérifie
|
||||
* en recette réelle — convention R5.1).
|
||||
*/
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
const REPONSE_ASK = {
|
||||
mode: 'extractif',
|
||||
answer: null,
|
||||
extraits: [
|
||||
{
|
||||
source_type: 'DOCUMENT',
|
||||
document_id: '7d7bfa5c-2f43-4f9e-9e59-3c1f0a5df001',
|
||||
work_order_id: null,
|
||||
titre: 'Notice Gen2.pdf',
|
||||
locator: 'p. 42',
|
||||
content: 'Serrer les coulisseaux au couple de 25 N·m.',
|
||||
score: 0.61,
|
||||
},
|
||||
],
|
||||
corpus: { documents: 6, bilans: 214 },
|
||||
};
|
||||
|
||||
const REPONSE_SUGGEST = {
|
||||
suggestions: [
|
||||
{
|
||||
field: 'ANOMALY',
|
||||
value_id: '7d7bfa5c-2f43-4f9e-9e59-3c1f0a5df002',
|
||||
label: 'Cellule/barrière encrassée',
|
||||
confidence: 'FORTE',
|
||||
similar_reports: 9,
|
||||
score: 0.62,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const REPONSE_REINDEX = {
|
||||
documents_indexes: 6,
|
||||
documents_ignores: 2,
|
||||
bilans_indexes: 214,
|
||||
extraits: 180,
|
||||
};
|
||||
|
||||
const REPONSE_TRANSCRIRE = { texte: 'Porte cabine qui rebondit, cellule encrassée.' };
|
||||
|
||||
describe('Assistant (e2e — stub du service IA)', () => {
|
||||
let app: INestApplication;
|
||||
let stub: Server;
|
||||
let ahmed: string; // Technicien : view + edit sur WORK_ORDERS, ASSETS en lecture
|
||||
let karim: string; // Demandeur : aucun droit WORK_ORDERS
|
||||
let rachid: string; // Vue seule : view sans edit
|
||||
let nadia: string; // Gestionnaire : ASSETS.edit — administre le corpus
|
||||
const requetesRecues: { url: string; jeton: string | undefined }[] = [];
|
||||
const http = () => request(app.getHttpServer());
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
|
||||
beforeAll(async () => {
|
||||
// Stub du service IA sur un port éphémère
|
||||
stub = createServer((req, res) => {
|
||||
requetesRecues.push({
|
||||
url: req.url ?? '',
|
||||
jeton: req.headers['x-service-token'] as string | undefined,
|
||||
});
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (req.url === '/internal/ask') res.end(JSON.stringify(REPONSE_ASK));
|
||||
else if (req.url === '/internal/suggest') res.end(JSON.stringify(REPONSE_SUGGEST));
|
||||
else if (req.url === '/internal/reindex') res.end(JSON.stringify(REPONSE_REINDEX));
|
||||
else if (req.url === '/internal/transcrire') res.end(JSON.stringify(REPONSE_TRANSCRIRE));
|
||||
else {
|
||||
res.statusCode = 404;
|
||||
res.end('{}');
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => stub.listen(0, '127.0.0.1', resolve));
|
||||
const adresse = stub.address();
|
||||
const port = typeof adresse === 'object' && adresse ? adresse.port : 0;
|
||||
process.env.AI_SERVICE_URL = `http://127.0.0.1:${port}`;
|
||||
process.env.AI_SERVICE_TOKEN = 'jeton-de-test';
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot()],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
const { body } = await http().get('/auth/demo-accounts');
|
||||
const login = async (roleName: string) => {
|
||||
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||
.accessToken as string;
|
||||
};
|
||||
ahmed = await login('Technicien');
|
||||
karim = await login('Demandeur');
|
||||
rachid = await login('Vue seule');
|
||||
nadia = await login('Gestionnaire');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await new Promise<void>((resolve) => stub.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('ask : traduit le dialecte interne vers le contrat, avec le jeton de service', async () => {
|
||||
const res = await http()
|
||||
.post('/assistant/ask')
|
||||
.set(auth(ahmed))
|
||||
.send({ question: 'quel couple de serrage pour les guides ?' })
|
||||
.expect(200);
|
||||
expect(res.body.mode).toBe('EXTRACTIVE');
|
||||
expect(res.body.excerpts[0]).toMatchObject({
|
||||
sourceType: 'DOCUMENT',
|
||||
title: 'Notice Gen2.pdf',
|
||||
locator: 'p. 42',
|
||||
});
|
||||
expect(res.body.corpus).toEqual({ documents: 6, reports: 214 });
|
||||
const derniere = requetesRecues.at(-1)!;
|
||||
expect(derniere.url).toBe('/internal/ask');
|
||||
expect(derniere.jeton).toBe('jeton-de-test'); // ADR-004 §4
|
||||
});
|
||||
|
||||
it('suggest-bilan : codes existants traduits (FORTE → HIGH, snake → camel)', async () => {
|
||||
const res = await http()
|
||||
.post('/assistant/suggest-bilan')
|
||||
.set(auth(ahmed))
|
||||
.send({ description: 'porte cabine qui rebondit, cellule encrassée, nettoyage fait' })
|
||||
.expect(200);
|
||||
expect(res.body.suggestions[0]).toMatchObject({
|
||||
field: 'ANOMALY',
|
||||
confidence: 'HIGH',
|
||||
similarReports: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('la matrice s’applique : demandeur sans WORK_ORDERS → 403 sur ask', async () => {
|
||||
await http()
|
||||
.post('/assistant/ask')
|
||||
.set(auth(karim))
|
||||
.send({ question: 'où sont les notices ?' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('vue seule : ask autorisé (view), suggest refusé (edit requis — D1)', async () => {
|
||||
await http()
|
||||
.post('/assistant/ask')
|
||||
.set(auth(rachid))
|
||||
.send({ question: 'historique du parc ?' })
|
||||
.expect(200);
|
||||
await http()
|
||||
.post('/assistant/suggest-bilan')
|
||||
.set(auth(rachid))
|
||||
.send({ description: 'une description suffisamment longue ici' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('reindex : traduit le bilan d’indexation, réservé à ASSETS.edit (D3)', async () => {
|
||||
const res = await http().post('/assistant/reindex').set(auth(nadia)).expect(200);
|
||||
expect(res.body).toEqual({
|
||||
documentsIndexed: 6,
|
||||
documentsSkipped: 2,
|
||||
reportsIndexed: 214,
|
||||
chunks: 180,
|
||||
});
|
||||
expect(requetesRecues.at(-1)!.url).toBe('/internal/reindex');
|
||||
// Ahmed (Technicien) lit le parc mais n'administre pas le corpus
|
||||
await http().post('/assistant/reindex').set(auth(ahmed)).expect(403);
|
||||
});
|
||||
|
||||
it('transcribe : dictée (D5) — texte transcrit, réservé à qui édite les OT', async () => {
|
||||
const res = await http()
|
||||
.post('/assistant/transcribe')
|
||||
.set(auth(ahmed))
|
||||
.attach('file', Buffer.from('faux-audio'), { filename: 'note.m4a', contentType: 'audio/m4a' })
|
||||
.expect(200);
|
||||
expect(res.body).toEqual({ text: 'Porte cabine qui rebondit, cellule encrassée.' });
|
||||
expect(requetesRecues.at(-1)!.url).toBe('/internal/transcrire');
|
||||
expect(requetesRecues.at(-1)!.jeton).toBe('jeton-de-test');
|
||||
// Karim (Demandeur) n'a pas WORK_ORDERS.edit
|
||||
await http()
|
||||
.post('/assistant/transcribe')
|
||||
.set(auth(karim))
|
||||
.attach('file', Buffer.from('faux-audio'), { filename: 'note.m4a', contentType: 'audio/m4a' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('transcribe : refuse proprement sans fichier joint', async () => {
|
||||
await http().post('/assistant/transcribe').set(auth(ahmed)).expect(400);
|
||||
});
|
||||
|
||||
it('question trop courte : 400 avant tout appel au service IA', async () => {
|
||||
const avant = requetesRecues.length;
|
||||
await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400);
|
||||
expect(requetesRecues.length).toBe(avant);
|
||||
});
|
||||
|
||||
it('service IA éteint : 503 propre, jamais un 500', async () => {
|
||||
await new Promise<void>((resolve) => stub.close(() => resolve()));
|
||||
await http()
|
||||
.post('/assistant/ask')
|
||||
.set(auth(ahmed))
|
||||
.send({ question: 'le service est-il là ?' })
|
||||
.expect(503);
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,36 @@ describe('Bibliothèque & analytics (e2e)', () => {
|
||||
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404);
|
||||
});
|
||||
|
||||
it('corpus (R5, D3) : nouveau document inclus par défaut, bascule réversible et gardée', async () => {
|
||||
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
const envoye = await http()
|
||||
.post('/documents')
|
||||
.set(auth(nadia))
|
||||
.field('kind', 'NOTICE')
|
||||
.field('assetId', a1.id)
|
||||
.attach('file', PNG_1PX, { filename: `corpus-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(201);
|
||||
// le contrat expose l'état d'indexation — jamais indexé à la naissance
|
||||
expect(envoye.body).toMatchObject({ inCorpus: true, indexedAt: null, chunkCount: 0 });
|
||||
|
||||
const exclu = await http()
|
||||
.patch(`/documents/${envoye.body.id}/corpus`)
|
||||
.set(auth(nadia))
|
||||
.send({ inCorpus: false })
|
||||
.expect(200);
|
||||
expect(exclu.body.inCorpus).toBe(false);
|
||||
|
||||
// Karim (Demandeur) n'administre pas le corpus
|
||||
await http()
|
||||
.patch(`/documents/${envoye.body.id}/corpus`)
|
||||
.set(auth(karim))
|
||||
.send({ inCorpus: true })
|
||||
.expect(403);
|
||||
|
||||
await http().delete(`/documents/${envoye.body.id}`).set(auth(nadia)).expect(204);
|
||||
});
|
||||
|
||||
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
|
||||
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
|
||||
@@ -55,9 +55,10 @@ describe('Exploitation (e2e)', () => {
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
|
||||
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
|
||||
// 1. Karim (gardien) signale
|
||||
// 1. Karim (gardien) signale — sur A2, dans son site rattaché (Tour
|
||||
// Atlas, R6.6) : un Demandeur ne peut plus signaler hors périmètre.
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A2');
|
||||
const demande = await http()
|
||||
.post('/requests')
|
||||
.set(auth(karim))
|
||||
@@ -234,8 +235,9 @@ describe('Exploitation (e2e)', () => {
|
||||
});
|
||||
|
||||
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
|
||||
// B1 : dans le site rattaché de Karim (Tour Atlas, R6.6).
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'C1');
|
||||
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'B1');
|
||||
const demande = await http()
|
||||
.post('/requests')
|
||||
.set(auth(karim))
|
||||
|
||||
@@ -139,3 +139,109 @@ describe('Recherche globale & corrections de recette R3 (e2e)', () => {
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verrou optimiste D2 (e2e) — la version protège les saisies mobiles', () => {
|
||||
let app: INestApplication;
|
||||
let salma: string;
|
||||
let ahmed: string;
|
||||
let otId: string;
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
const suffix = `verrou-${Date.now().toString(36)}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot()],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
const { body } = await http().get('/auth/demo-accounts');
|
||||
const login = async (roleName: string) => {
|
||||
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||
.accessToken as string;
|
||||
};
|
||||
salma = await login('Dispatcher');
|
||||
ahmed = await login('Technicien');
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
const technicien = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
|
||||
const cree = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({
|
||||
title: `Verrou E2E ${suffix}`,
|
||||
type: 'CORRECTIVE',
|
||||
assetId: a1.id,
|
||||
assigneeIds: [technicien.id],
|
||||
})
|
||||
.expect(201);
|
||||
otId = cree.body.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('version à jour : la transition passe ; le détail expose updatedAt', async () => {
|
||||
const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
|
||||
expect(detail.body.updatedAt).toBeTruthy();
|
||||
await http()
|
||||
.post(`/work-orders/${otId}/transition`)
|
||||
.set(auth(ahmed))
|
||||
.send({ to: 'IN_PROGRESS', baseUpdatedAt: detail.body.updatedAt })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('OT modifié entre-temps (commentaire) : 409 « Conflit de version » contextualisé', async () => {
|
||||
const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
|
||||
const versionLue = detail.body.updatedAt as string;
|
||||
// Salma commente pendant qu'Ahmed est « hors-ligne » — la version avance
|
||||
await http()
|
||||
.post(`/work-orders/${otId}/comments`)
|
||||
.set(auth(salma))
|
||||
.send({ message: 'Réassigné après appel du syndic' })
|
||||
.expect(201);
|
||||
const refus = await http()
|
||||
.post(`/work-orders/${otId}/transition`)
|
||||
.set(auth(ahmed))
|
||||
.send({ to: 'ON_HOLD', baseUpdatedAt: versionLue })
|
||||
.expect(409);
|
||||
expect(refus.body.message).toContain('Conflit de version');
|
||||
expect(refus.body.message).toContain('Salma');
|
||||
// L'OT n'a PAS bougé — rien d'écrasé en silence
|
||||
const apres = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
|
||||
expect(apres.body.status).toBe('IN_PROGRESS');
|
||||
});
|
||||
|
||||
it('le bilan et la coche font avancer la version (sinon le verrou est aveugle)', async () => {
|
||||
const avant = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt;
|
||||
const { body: refs } = await http().get('/reference-values').set(auth(ahmed));
|
||||
const porte = refs.referenceValues.find(
|
||||
(v: { field: string; isActive: boolean }) => v.field === 'DOOR_STATE' && v.isActive,
|
||||
);
|
||||
await http()
|
||||
.put(`/work-orders/${otId}/report`)
|
||||
.set(auth(ahmed))
|
||||
.send({ doorStateId: porte.id })
|
||||
.expect(200);
|
||||
const apres = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt;
|
||||
expect(new Date(apres).getTime()).toBeGreaterThan(new Date(avant).getTime());
|
||||
});
|
||||
|
||||
it('sans baseUpdatedAt (web) : comportement inchangé, même après modification', async () => {
|
||||
await http()
|
||||
.post(`/work-orders/${otId}/comments`)
|
||||
.set(auth(salma))
|
||||
.send({ message: 'Nouvelle note' })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post(`/work-orders/${otId}/transition`)
|
||||
.set(auth(ahmed))
|
||||
.send({ to: 'ON_HOLD' })
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
5
apps/mobile/.claude/settings.json
Normal file
5
apps/mobile/.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
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
|
||||
41
apps/mobile/.gitignore
vendored
Normal file
41
apps/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
3
apps/mobile/AGENTS.md
Normal file
3
apps/mobile/AGENTS.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Expo HAS CHANGED
|
||||
|
||||
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
|
||||
33
apps/mobile/README.md
Normal file
33
apps/mobile/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @siop/mobile — l'app du technicien (R4)
|
||||
|
||||
Expo (SDK 57), offline-first, périmètre fermé : **Ma journée, scan, préventif, synchro** —
|
||||
la gestion reste web, le demandeur reste sur le portail QR public. Maquettes validées :
|
||||
`docs/02-design/maquettes/maquette-r4.html` (+ décisions D1-D5 au journal du 17/07/2026).
|
||||
|
||||
## Lancer
|
||||
|
||||
```bash
|
||||
# API locale d'abord (apps/api : pnpm dev), puis :
|
||||
pnpm --filter @siop/mobile start # QR Expo Go (téléphone sur le MÊME réseau)
|
||||
EXPO_PUBLIC_API_URL=http://192.168.x.y:3000 pnpm --filter @siop/mobile start
|
||||
```
|
||||
|
||||
- `EXPO_PUBLIC_API_URL` : URL de l'API vue **depuis le téléphone** (IP LAN, pas localhost).
|
||||
Défaut : `http://localhost:3000` (suffisant pour `expo start --web`).
|
||||
- Vérification navigateur : `expo start --web` + `CORS_ORIGINS=http://localhost:8081`
|
||||
côté API (les apps natives n'envoient pas d'Origin — CORS ne concerne que le web).
|
||||
|
||||
## Ce que porte R4.1 (socle)
|
||||
|
||||
- Connexion e-mail/mot de passe + **sélecteur démo** (ADR-002 : n'existe que si l'API l'expose).
|
||||
- Coquille tabbar (onglets à venir marqués R4.2/R4.3) + **Ma journée** : OT triés
|
||||
priorité puis échéance (`src/lib/journee.ts`, testé), urgence en tête, pastille de synchro.
|
||||
- **Lecture hors-ligne (D1)** : cache TanStack persisté dans AsyncStorage (7 jours),
|
||||
NetInfo pilote `onlineManager` + bandeau. L'écriture en file arrive en R4.3.
|
||||
- Client typé généré depuis `docs/openapi.json` (`pnpm generate:client`) — règle d'or ADR-001.
|
||||
- Jeton dans SecureStore (trousseau) ; AsyncStorage en repli web de dev.
|
||||
|
||||
## Tests
|
||||
|
||||
`pnpm --filter @siop/mobile test` (jest-expo — logique pure : tri, tokens) ;
|
||||
`typecheck` et le lint racine s'appliquent (job CI `mobile`).
|
||||
34
apps/mobile/app.json
Normal file
34
apps/mobile/app.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "SIOP",
|
||||
"slug": "siop2-mobile",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.anonymous.siop2-mobile"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.anonymous.siop2mobile"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"expo-secure-store",
|
||||
"expo-audio"
|
||||
],
|
||||
"scheme": "siop"
|
||||
}
|
||||
}
|
||||
123
apps/mobile/app/(tabs)/_layout.tsx
Normal file
123
apps/mobile/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Text, type ColorValue } from 'react-native';
|
||||
import { useAssets, useReferenceValues } from '@/api/exploitation';
|
||||
import { useMe } from '@/auth/session';
|
||||
import { ongletsVisibles } from '@/auth/roles';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { useFile } from '@/file/store';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Barre d'onglets ADAPTATIVE (maquette « mobile ouvert à tous les rôles »,
|
||||
* D1) : le Technicien/Technicien limité gardent exactement leurs 4 onglets
|
||||
* terrain R4, inchangés. Les autres rôles reçoivent Accueil/OT/Menu (ou une
|
||||
* variante plus étroite — Demandeur, Vue seule — selon leurs droits,
|
||||
* `ongletsVisibles`). Tous les écrans sont toujours déclarés : `href: null`
|
||||
* masque un onglet de la barre sans le retirer du navigateur (le Menu peut
|
||||
* toujours y pousser directement). */
|
||||
|
||||
function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) {
|
||||
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
|
||||
}
|
||||
|
||||
export default function CoquilleTabs() {
|
||||
const t = useTokens();
|
||||
const file = useFile();
|
||||
const conflits = file.some((s) => s.statut === 'CONFLIT');
|
||||
const { data: me } = useMe();
|
||||
const { can } = usePermissions();
|
||||
const visibles = ongletsVisibles(me?.role.name);
|
||||
// D1 « lecture locale » : le parc (scan D4) et les référentiels du bilan
|
||||
// se préchargent dès l'entrée — le sous-sol n'attend pas qu'on y pense.
|
||||
// Le Demandeur n'a pas ASSETS.view (seulement /assets/options) : inutile
|
||||
// d'appeler /assets pour lui, l'API répondrait 403.
|
||||
const peutAssets = can('ASSETS', 'view');
|
||||
useAssets({ enabled: peutAssets });
|
||||
useReferenceValues({ enabled: peutAssets });
|
||||
|
||||
const cacher = (nom: string) => (visibles.has(nom) ? undefined : null);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: t.primaire,
|
||||
tabBarInactiveTintColor: t.encre3,
|
||||
tabBarStyle: { backgroundColor: t.surface, borderTopColor: t.bordure },
|
||||
tabBarLabelStyle: { fontFamily: 'Manrope_700Bold', fontSize: 10 },
|
||||
sceneStyle: { backgroundColor: t.fond },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="journee"
|
||||
options={{
|
||||
href: cacher('journee'),
|
||||
title: 'Ma journée',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="scanner"
|
||||
options={{
|
||||
href: cacher('scanner'),
|
||||
title: 'Scanner',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="preventif"
|
||||
options={{
|
||||
href: cacher('preventif'),
|
||||
title: 'Préventif',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="synchro"
|
||||
options={{
|
||||
href: cacher('synchro'),
|
||||
title: 'Synchro',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
|
||||
tabBarBadge: file.length || undefined,
|
||||
tabBarBadgeStyle: {
|
||||
backgroundColor: conflits ? t.prioBloque : t.stAttente,
|
||||
color: '#fff',
|
||||
fontFamily: 'Manrope_800ExtraBold',
|
||||
fontSize: 10,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
191
apps/mobile/app/(tabs)/journee.tsx
Normal file
191
apps/mobile/app/(tabs)/journee.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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 { api, unwrap } from '@/api/client';
|
||||
import { useHorsLigne } from '@/auth/session';
|
||||
import { EnteteTabs } from '@/composants/ui';
|
||||
import { triJournee } from '@/lib/journee';
|
||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||
|
||||
/** Écran 1 de la maquette R4 : les OT du technicien, priorité puis
|
||||
* échéance, « personne bloquée » en tête. Hors-ligne : le cache persisté
|
||||
* sert la liste, le bandeau l'assume (D1). */
|
||||
|
||||
function useWorkOrders() {
|
||||
return useQuery({
|
||||
queryKey: ['work-orders'],
|
||||
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
|
||||
});
|
||||
}
|
||||
|
||||
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 PageJournee() {
|
||||
const t = useTokens();
|
||||
const horsLigne = useHorsLigne();
|
||||
const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
|
||||
|
||||
const ots = triJournee(workOrders ?? []);
|
||||
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
|
||||
const jour = new Intl.DateTimeFormat('fr-FR', { weekday: 'long', day: 'numeric', month: 'short' })
|
||||
.format(new Date());
|
||||
|
||||
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 }}>
|
||||
Ma journée
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
fontSize: 11,
|
||||
color: t.encre3,
|
||||
}}
|
||||
>
|
||||
{jour}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{horsLigne ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: t.stAttenteFond,
|
||||
borderColor: t.stAttente,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 9,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||
⚠ Hors-ligne — liste du{' '}
|
||||
{dataUpdatedAt
|
||||
? new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(dataUpdatedAt)
|
||||
: '…'}
|
||||
. Vos saisies partiront en file (R4.3).
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{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 en cours — 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
77
apps/mobile/app/(tabs)/preventif.tsx
Normal file
77
apps/mobile/app/(tabs)/preventif.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { router } from 'expo-router';
|
||||
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useWorkOrders } from '@/api/exploitation';
|
||||
import { ChipStatut, EnteteTabs } from '@/composants/ui';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Onglet Préventif : mes grilles du moment — chaque tuile mène à la
|
||||
* checklist cochable (écran 6). Les OT viennent déjà scopés par l'API. */
|
||||
export default function PagePreventif() {
|
||||
const t = useTokens();
|
||||
const { data: workOrders } = useWorkOrders();
|
||||
|
||||
const grilles = (workOrders ?? [])
|
||||
.filter((w) => w.type === 'PREVENTIVE' && w.status !== 'DONE' && w.status !== 'CANCELLED')
|
||||
.sort((a, b) => (a.dueDate ?? '9999').localeCompare(b.dueDate ?? '9999'));
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||
<EnteteTabs />
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||
Préventif
|
||||
</Text>
|
||||
<FlatList
|
||||
data={grilles}
|
||||
keyExtractor={(w) => w.id}
|
||||
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||
ListEmptyComponent={
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||
Aucune grille préventive en cours pour vous.
|
||||
</Text>
|
||||
}
|
||||
renderItem={({ item: w }) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.push(`/ot/${w.id}/grille`)}
|
||||
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: 10.5,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
color: t.encre2,
|
||||
}}
|
||||
>
|
||||
{w.reference}
|
||||
</Text>
|
||||
<ChipStatut statut={w.status} />
|
||||
</View>
|
||||
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||
{w.title}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||
Asc. {w.assetReference} — {w.siteName}
|
||||
{w.dueDate
|
||||
? ` · pour le ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(w.dueDate))}`
|
||||
: ''}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
159
apps/mobile/app/(tabs)/scanner.tsx
Normal file
159
apps/mobile/app/(tabs)/scanner.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { router } from 'expo-router';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Platform, Text, TextInput, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useAssets } from '@/api/exploitation';
|
||||
import { useHorsLigne } from '@/auth/session';
|
||||
import { BoutonTel, EnteteTabs } from '@/composants/ui';
|
||||
import { analyseScan } from '@/lib/scan';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Écran 4 de la maquette R4 (D4) : le QR de l'étiquette A6 contient l'URL
|
||||
* portail `…/q/REF` — la résolution se fait D'ABORD dans le parc déjà en
|
||||
* cache (donc en sous-sol aussi). Repli : saisie de la référence. */
|
||||
export default function PageScanner() {
|
||||
const t = useTokens();
|
||||
const horsLigne = useHorsLigne();
|
||||
const [permission, demanderPermission] = useCameraPermissions();
|
||||
const { data: assets, refetch } = useAssets();
|
||||
const [manuel, setManuel] = useState('');
|
||||
const [erreur, setErreur] = useState<string | null>(null);
|
||||
const dernierScan = useRef(0);
|
||||
|
||||
const resoudre = async (brut: string) => {
|
||||
const reference = analyseScan(brut);
|
||||
if (!reference) {
|
||||
setErreur('Ce code n’est pas une étiquette SIOP.');
|
||||
return;
|
||||
}
|
||||
// 1 · le parc en cache (fonctionne hors-ligne)
|
||||
let appareil = (assets ?? []).find((a) => a.reference.toUpperCase() === reference);
|
||||
// 2 · sinon, un rafraîchissement si le réseau est là
|
||||
if (!appareil && !horsLigne) {
|
||||
const frais = await refetch();
|
||||
appareil = (frais.data ?? []).find((a) => a.reference.toUpperCase() === reference);
|
||||
}
|
||||
if (!appareil) {
|
||||
setErreur(
|
||||
horsLigne
|
||||
? `« ${reference} » n'est pas dans le parc synchronisé — réessayez au retour du réseau.`
|
||||
: `Aucun appareil « ${reference} » dans le parc.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setErreur(null);
|
||||
setManuel('');
|
||||
router.push(`/ascenseur/${appareil.id}`);
|
||||
};
|
||||
|
||||
const surScan = ({ data }: { data: string }) => {
|
||||
const maintenant = Date.now();
|
||||
if (maintenant - dernierScan.current < 1500) return; // anti-rafale
|
||||
dernierScan.current = maintenant;
|
||||
void resoudre(data);
|
||||
};
|
||||
|
||||
const cameraUtilisable = Platform.OS !== 'web' && permission?.granted;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||
<EnteteTabs />
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||
Scanner
|
||||
</Text>
|
||||
{cameraUtilisable ? (
|
||||
<View style={{ flex: 1, borderRadius: 12, overflow: 'hidden' }}>
|
||||
<CameraView
|
||||
style={{ flex: 1 }}
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={surScan}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
alignSelf: 'center',
|
||||
color: '#dfe7f2',
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
fontSize: 12.5,
|
||||
}}
|
||||
>
|
||||
Visez le QR de l’étiquette de cabine
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#131c2c',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 150,
|
||||
height: 150,
|
||||
borderRadius: 14,
|
||||
borderWidth: 2.5,
|
||||
borderColor: t.safran,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: '#dfe7f2',
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
fontSize: 12.5,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{Platform.OS === 'web'
|
||||
? 'Caméra indisponible sur web — saisissez la référence ci-dessous.'
|
||||
: 'L’appareil photo sert uniquement à lire les étiquettes du parc.'}
|
||||
</Text>
|
||||
{Platform.OS !== 'web' && !permission?.granted ? (
|
||||
<BoutonTel libelle="Autoriser la caméra" surAppui={() => void demanderPermission()} />
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
<TextInput
|
||||
accessibilityLabel="Référence de l'appareil"
|
||||
placeholder="Référence (A1, B2…)"
|
||||
placeholderTextColor={t.encre3}
|
||||
autoCapitalize="characters"
|
||||
value={manuel}
|
||||
onChangeText={(v) => {
|
||||
setManuel(v);
|
||||
setErreur(null);
|
||||
}}
|
||||
onSubmitEditing={() => void resoudre(manuel)}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordureForte,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
color: t.encre,
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
}}
|
||||
/>
|
||||
<BoutonTel libelle="Ouvrir" desactive={!manuel.trim()} surAppui={() => void resoudre(manuel)} />
|
||||
</View>
|
||||
{erreur ? (
|
||||
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||
{erreur}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
183
apps/mobile/app/(tabs)/synchro.tsx
Normal file
183
apps/mobile/app/(tabs)/synchro.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { router } from 'expo-router';
|
||||
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useHorsLigne } from '@/auth/session';
|
||||
import { BoutonTel, EnteteTabs } from '@/composants/ui';
|
||||
import { useFile, type Saisie } from '@/file/store';
|
||||
import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Écran 7 de la maquette R4 : la file VISIBLE et honnête. Rejeu dans
|
||||
* l'ordre ; un conflit (verrou D2) arrête la file sur l'élément, montre le
|
||||
* message de l'API (qui, quand) et VOUS tranchez — rien n'est écrasé ni
|
||||
* perdu en silence. */
|
||||
|
||||
const ICONES: Record<Saisie['type'], string> = {
|
||||
TRANSITION: '🏁',
|
||||
COCHE: '✓',
|
||||
BILAN: '📋',
|
||||
PHOTO: '🖼',
|
||||
};
|
||||
|
||||
export default function PageSynchro() {
|
||||
const t = useTokens();
|
||||
const horsLigne = useHorsLigne();
|
||||
const file = useFile();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const conflit = file.find((s) => s.statut === 'CONFLIT');
|
||||
const heure = (iso: string) =>
|
||||
new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(new Date(iso));
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||
<EnteteTabs />
|
||||
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||
Synchro
|
||||
</Text>
|
||||
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
|
||||
{conflit ? 'arrêtée sur le conflit' : file.length ? 'rejouée dans l’ordre' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{conflit ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: t.prioBloqueFond,
|
||||
borderColor: t.danger,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: t.danger, fontFamily: 'Manrope_800ExtraBold', fontSize: 13 }}>
|
||||
⚠ Conflit sur {conflit.otReference}
|
||||
</Text>
|
||||
<Text style={{ color: t.encre, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||
{conflit.erreur ?? 'L’OT a été modifié pendant que vous étiez hors-ligne.'}
|
||||
</Text>
|
||||
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
|
||||
Votre saisie (« {conflit.libelle} », {heure(conflit.creeA)}) est conservée telle
|
||||
quelle dans la file — rien n’est perdu.
|
||||
</Text>
|
||||
<BoutonTel
|
||||
libelle="Voir l’OT à jour"
|
||||
variante="contour"
|
||||
surAppui={() => router.push(`/ot/${conflit.otId}`)}
|
||||
/>
|
||||
<BoutonTel
|
||||
libelle="Rejouer ma saisie sur la version à jour"
|
||||
surAppui={() => void rejouerSurVersionAJour(conflit.id, queryClient)}
|
||||
/>
|
||||
<BoutonTel
|
||||
libelle="Abandonner ma saisie"
|
||||
variante="gris"
|
||||
surAppui={() => void abandonnerSaisie(conflit.id, queryClient)}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<FlatList
|
||||
data={file}
|
||||
keyExtractor={(s) => s.id}
|
||||
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||
ListEmptyComponent={
|
||||
<View style={{ padding: 24, alignItems: 'center', gap: 6 }}>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 15, color: t.encre }}>
|
||||
Rien en attente
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_400Regular',
|
||||
fontSize: 12.5,
|
||||
color: t.encre2,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Toutes vos saisies sont sur le serveur. Les gestes faits hors-ligne apparaîtront
|
||||
ici, rejoués dans l’ordre au retour du réseau.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item: s }) => (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
backgroundColor: t.surface,
|
||||
borderColor: s.statut === 'CONFLIT' ? t.danger : t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
backgroundColor: t.surface2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 13 }}>{ICONES[s.type]}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text numberOfLines={1} style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
|
||||
{s.libelle}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre2 }}>
|
||||
{s.otReference} · {heure(s.creeA)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 10.5,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
color:
|
||||
s.statut === 'CONFLIT' ? t.danger : s.statut === 'ENVOI' ? t.stOuvert : t.stAttente,
|
||||
backgroundColor:
|
||||
s.statut === 'CONFLIT'
|
||||
? t.prioBloqueFond
|
||||
: s.statut === 'ENVOI'
|
||||
? t.stOuvertFond
|
||||
: t.stAttenteFond,
|
||||
}}
|
||||
>
|
||||
{s.statut === 'CONFLIT' ? 'conflit' : s.statut === 'ENVOI' ? 'envoi…' : 'en attente'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
{file.length && !horsLigne ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => void rejouer(queryClient)}
|
||||
style={{ backgroundColor: t.primaire, borderRadius: 10, padding: 12, alignItems: 'center' }}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 14 }}>
|
||||
Rejouer la file maintenant
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{horsLigne ? (
|
||||
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_600SemiBold', fontSize: 12, textAlign: 'center' }}>
|
||||
Hors-ligne — la file repartira seule au retour du réseau.
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
80
apps/mobile/app/_layout.tsx
Normal file
80
apps/mobile/app/_layout.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Manrope_400Regular,
|
||||
Manrope_600SemiBold,
|
||||
Manrope_700Bold,
|
||||
Manrope_800ExtraBold,
|
||||
useFonts,
|
||||
} from '@expo-google-fonts/manrope';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
|
||||
import { onlineManager, QueryClient } from '@tanstack/react-query';
|
||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HorsLigneProvider } from '@/auth/session';
|
||||
import { chargerFile } from '@/file/store';
|
||||
import { rejouer } from '@/file/synchro';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Racine de l'app (D1 validée) : le cache TanStack est PERSISTÉ dans
|
||||
* AsyncStorage — les OT lus restent lisibles hors-ligne, y compris après
|
||||
* redémarrage. NetInfo pilote onlineManager (pas de retry dans le vide)
|
||||
* et le drapeau hors-ligne des écrans. */
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60_000,
|
||||
gcTime: 7 * 24 * 3600_000, // une semaine de lecture hors-ligne
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const persister = createAsyncStoragePersister({
|
||||
storage: AsyncStorage,
|
||||
key: 'siop.cache',
|
||||
});
|
||||
|
||||
export default function RacineApp() {
|
||||
const t = useTokens();
|
||||
const [horsLigne, setHorsLigne] = useState(false);
|
||||
const [polices] = useFonts({
|
||||
Manrope_400Regular,
|
||||
Manrope_600SemiBold,
|
||||
Manrope_700Bold,
|
||||
Manrope_800ExtraBold,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// D1 : la file survit au redémarrage — on la recharge avant tout.
|
||||
void chargerFile().then(() => rejouer(queryClient));
|
||||
return NetInfo.addEventListener((etat) => {
|
||||
const enLigne = !!etat.isConnected;
|
||||
onlineManager.setOnline(enLigne);
|
||||
setHorsLigne(!enLigne);
|
||||
if (enLigne) void rejouer(queryClient); // le retour du réseau vide la file
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!polices) return null;
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister, maxAge: 7 * 24 * 3600_000 }}
|
||||
>
|
||||
<HorsLigneProvider valeur={horsLigne}>
|
||||
<StatusBar style="auto" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: t.fond },
|
||||
}}
|
||||
/>
|
||||
</HorsLigneProvider>
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
129
apps/mobile/app/ascenseur/[id].tsx
Normal file
129
apps/mobile/app/ascenseur/[id].tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
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 } from '@siop/shared';
|
||||
import { useAsset, useWorkOrders } from '@/api/exploitation';
|
||||
import { Carte, ChipStatut, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||
import { triJournee } from '@/lib/journee';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Écran 5 de la maquette R4 : tout ce qu'il faut DEVANT la machine.
|
||||
* Consultation seule (D3) — l'historique montre ce que le rôle a le droit
|
||||
* de voir (invariant « voir autre », même règle que partout). */
|
||||
export default function PageAscenseur() {
|
||||
const t = useTokens();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { data: appareil } = useAsset(id);
|
||||
const { data: workOrders } = useWorkOrders();
|
||||
|
||||
if (!appareil) return null;
|
||||
|
||||
const interventions = (workOrders ?? [])
|
||||
.filter((w) => w.assetId === appareil.id)
|
||||
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||
const enCours = triJournee(interventions);
|
||||
const statut = ASSET_STATUS_LABELS[appareil.status];
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||
<EnteteFiche
|
||||
titre={`Asc. ${appareil.reference}`}
|
||||
apres={
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 10.5,
|
||||
color: appareil.status === 'IN_SERVICE' ? t.stTermine : t.stAttente,
|
||||
backgroundColor: appareil.status === 'IN_SERVICE' ? t.stTermineFond : t.stAttenteFond,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{statut}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 16, color: t.encre }}>
|
||||
{appareil.brand}
|
||||
{appareil.model ? ` ${appareil.model}` : ''} — {appareil.siteName}
|
||||
</Text>
|
||||
|
||||
<Carte titre="Identité">
|
||||
<LigneInfo nom="Emplacement" valeur={appareil.locationName} />
|
||||
<LigneInfo nom="Catégorie" valeur={appareil.categoryName} />
|
||||
<LigneInfo
|
||||
nom="Mise en service"
|
||||
valeur={
|
||||
appareil.commissionedAt
|
||||
? new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric' }).format(
|
||||
new Date(appareil.commissionedAt),
|
||||
)
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<LigneInfo
|
||||
nom="Charge / niveaux"
|
||||
valeur={`${appareil.loadKg != null ? `${appareil.loadKg} kg` : '—'} · ${appareil.floors != null ? `${appareil.floors} niveaux` : '—'}`}
|
||||
/>
|
||||
{appareil.serialNumber ? (
|
||||
<LigneInfo nom="N° de série" valeur={appareil.serialNumber} />
|
||||
) : null}
|
||||
</Carte>
|
||||
|
||||
<Carte titre="Organes suivis">
|
||||
{appareil.components.length ? (
|
||||
appareil.components.map((c) => (
|
||||
<LigneInfo key={c.id} nom={c.typeName} valeur={c.designation ?? '—'} />
|
||||
))
|
||||
) : (
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||
Aucun organe déclaré.
|
||||
</Text>
|
||||
)}
|
||||
</Carte>
|
||||
|
||||
<Carte titre={`Interventions visibles (${interventions.length})`}>
|
||||
{interventions.slice(0, 6).map((w) => (
|
||||
<Pressable
|
||||
key={w.id}
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.push(`/ot/${w.id}`)}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 3 }}
|
||||
>
|
||||
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.primaire }}>
|
||||
{w.reference}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ flex: 1, fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre }}
|
||||
>
|
||||
{w.title}
|
||||
</Text>
|
||||
<ChipStatut statut={w.status} />
|
||||
</Pressable>
|
||||
))}
|
||||
{!interventions.length ? (
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||
Aucune intervention visible pour votre rôle.
|
||||
</Text>
|
||||
) : null}
|
||||
</Carte>
|
||||
|
||||
{enCours.length ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.push(`/ot/${enCours[0]!.id}`)}
|
||||
style={{ backgroundColor: t.primaire, borderRadius: 10, padding: 12, alignItems: 'center' }}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 14 }}>
|
||||
Ouvrir l’OT en cours ({enCours[0]!.reference})
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
158
apps/mobile/app/connexion.tsx
Normal file
158
apps/mobile/app/connexion.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { RoleName } from '@siop/shared';
|
||||
import { ongletAccueil } from '@/auth/roles';
|
||||
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Connexion mobile — mêmes règles que le web : formulaire e-mail/mot de
|
||||
* passe, et le sélecteur démo (< 3 s pour changer de rôle, ADR-002)
|
||||
* UNIQUEMENT si l'API l'expose. Depuis R6, l'app n'est plus réservée au
|
||||
* Technicien : on route vers l'onglet d'accueil du RÔLE réel (« Ma
|
||||
* journée » pour le terrain, « Accueil » sinon) — jamais un onglet figé
|
||||
* (trouvé en recette : un Demandeur atterrissait sur « Ma journée »,
|
||||
* l'écran du Technicien, faute de lire le rôle retourné par la connexion). */
|
||||
export default function PageConnexion() {
|
||||
const t = useTokens();
|
||||
const [email, setEmail] = useState('');
|
||||
const [motDePasse, setMotDePasse] = useState('');
|
||||
const { data: comptes } = useDemoAccounts();
|
||||
const login = useLogin();
|
||||
const demo = useDemoLogin();
|
||||
|
||||
const entrer = (role: RoleName) => router.replace(`/(tabs)/${ongletAccueil(role)}`);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: t.fond }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView contentContainerStyle={{ padding: 24, paddingTop: 80, gap: 12 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
||||
<Text style={{ color: t.safran, fontSize: 22 }}>♦</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}>
|
||||
SIOP
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', color: t.encre2, marginBottom: 8 }}>
|
||||
Vos interventions et votre suivi, où que vous soyez.
|
||||
</Text>
|
||||
|
||||
<View style={{ gap: 10 }}>
|
||||
<TextInput
|
||||
accessibilityLabel="E-mail"
|
||||
placeholder="E-mail"
|
||||
placeholderTextColor={t.encre3}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
style={{
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordureForte,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 10,
|
||||
padding: 13,
|
||||
color: t.encre,
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
accessibilityLabel="Mot de passe"
|
||||
placeholder="Mot de passe"
|
||||
placeholderTextColor={t.encre3}
|
||||
secureTextEntry
|
||||
value={motDePasse}
|
||||
onChangeText={setMotDePasse}
|
||||
style={{
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordureForte,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 10,
|
||||
padding: 13,
|
||||
color: t.encre,
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
}}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={!email || !motDePasse || login.isPending}
|
||||
onPress={() =>
|
||||
login.mutate(
|
||||
{ email, password: motDePasse },
|
||||
{ onSuccess: (res) => entrer(res.user.role.name) },
|
||||
)
|
||||
}
|
||||
style={{
|
||||
backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire,
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 15 }}>
|
||||
{login.isPending ? 'Connexion…' : 'Se connecter'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{login.isError ? (
|
||||
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 13 }}>
|
||||
{login.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{comptes?.length ? (
|
||||
<View style={{ marginTop: 18, gap: 8 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 11,
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
color: t.encre3,
|
||||
}}
|
||||
>
|
||||
Mode démo — connexion 1 clic
|
||||
</Text>
|
||||
{comptes.map((c) => (
|
||||
<Pressable
|
||||
key={c.id}
|
||||
accessibilityRole="button"
|
||||
disabled={demo.isPending}
|
||||
onPress={() =>
|
||||
demo.mutate(c.id, { onSuccess: (res) => entrer(res.user.role.name) })
|
||||
}
|
||||
style={{
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: 'Manrope_700Bold', color: t.encre, fontSize: 14 }}>
|
||||
{c.displayName}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', color: t.encre3, fontSize: 12 }}>
|
||||
{c.roleName}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
26
apps/mobile/app/index.tsx
Normal file
26
apps/mobile/app/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
import { ongletAccueil } from '@/auth/roles';
|
||||
import { useMe } from '@/auth/session';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Aiguillage : session valide → l'onglet d'accueil du rôle (Ma journée
|
||||
* pour le terrain, Accueil pour les autres) ; sinon → connexion.
|
||||
* (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */
|
||||
export default function Aiguillage() {
|
||||
const t = useTokens();
|
||||
const { data: me, isLoading } = useMe();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: t.fond }}>
|
||||
<ActivityIndicator color={t.primaire} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return me ? (
|
||||
<Redirect href={`/(tabs)/${ongletAccueil(me.role.name)}`} />
|
||||
) : (
|
||||
<Redirect href="/connexion" />
|
||||
);
|
||||
}
|
||||
370
apps/mobile/app/ot/[id]/cloture.tsx
Normal file
370
apps/mobile/app/ot/[id]/cloture.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
// SDK 57 : les fonctions module-level (dont deleteAsync) sont dépréciées au
|
||||
// profit des classes File/Directory et lèvent désormais en développement —
|
||||
// import explicite du sous-chemin legacy, comportement inchangé sinon.
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
||||
import {
|
||||
RecordingPresets,
|
||||
requestRecordingPermissionsAsync,
|
||||
setAudioModeAsync,
|
||||
useAudioRecorder,
|
||||
} from 'expo-audio';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
BILAN_FIELD_LABELS,
|
||||
BILAN_FIELDS,
|
||||
REQUIRED_BILAN_FIELDS,
|
||||
type BilanField,
|
||||
type BilanSuggestion,
|
||||
type ReportUpsert,
|
||||
type WorkOrderDetail,
|
||||
} from '@siop/shared';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
useReferenceValues,
|
||||
useSuggestionBilan,
|
||||
useTranscription,
|
||||
useWorkOrder,
|
||||
} from '@/api/exploitation';
|
||||
import { useHorsLigne } from '@/auth/session';
|
||||
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
|
||||
import { enfilerBilan, enfilerTransition } from '@/file/actions';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Écran 3 de la maquette R4 : le bilan codé R2 au pouce — 3 champs requis,
|
||||
* garde visible et bloquante. Depuis R4.3, bilan et clôture partent EN
|
||||
* FILE (D1) : hors-ligne, l'OT passe localement à « Terminé (en file) »
|
||||
* et le serveur tranchera au rejeu (verrou D2 si l'OT a bougé). */
|
||||
|
||||
type ChampBilanId =
|
||||
| 'doorStateId'
|
||||
| 'cabinPositionId'
|
||||
| 'anomalyId'
|
||||
| 'externalCauseId'
|
||||
| 'actionTakenId'
|
||||
| 'componentConcernedId';
|
||||
|
||||
const CHAMP_VERS_ID: Record<BilanField, ChampBilanId> = {
|
||||
DOOR_STATE: 'doorStateId',
|
||||
CABIN_POSITION: 'cabinPositionId',
|
||||
ANOMALY: 'anomalyId',
|
||||
EXTERNAL_CAUSE: 'externalCauseId',
|
||||
ACTION_TAKEN: 'actionTakenId',
|
||||
COMPONENT_CONCERNED: 'componentConcernedId',
|
||||
};
|
||||
const CHAMP_VERS_VALEUR = {
|
||||
DOOR_STATE: 'doorState',
|
||||
CABIN_POSITION: 'cabinPosition',
|
||||
ANOMALY: 'anomaly',
|
||||
EXTERNAL_CAUSE: 'externalCause',
|
||||
ACTION_TAKEN: 'actionTaken',
|
||||
COMPONENT_CONCERNED: 'componentConcerned',
|
||||
} as const;
|
||||
|
||||
export default function PageCloture() {
|
||||
const t = useTokens();
|
||||
const horsLigne = useHorsLigne();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { data: ot } = useWorkOrder(id);
|
||||
const { data: valeurs } = useReferenceValues();
|
||||
const queryClient = useQueryClient();
|
||||
const [choix, setChoix] = useState<Partial<Record<BilanField, string | null>>>({});
|
||||
|
||||
if (!ot) return null;
|
||||
|
||||
const optionsDe = (champ: BilanField) =>
|
||||
(valeurs ?? []).filter((v: { field: string; isActive: boolean }) => v.field === champ && v.isActive);
|
||||
|
||||
/** Valeur affichée : le choix local s'il existe, sinon le bilan serveur. */
|
||||
const valeurDe = (champ: BilanField): { id: string; label: string } | null => {
|
||||
if (champ in choix) {
|
||||
const vid = choix[champ];
|
||||
if (!vid) return null;
|
||||
const v = (valeurs ?? []).find((x) => x.id === vid);
|
||||
return v ? { id: v.id, label: v.label } : null;
|
||||
}
|
||||
return ot.report?.[CHAMP_VERS_VALEUR[champ]] ?? null;
|
||||
};
|
||||
|
||||
const enregistrer = () => {
|
||||
const corps: ReportUpsert = {};
|
||||
const labels: Parameters<typeof enfilerBilan>[3] = {};
|
||||
for (const champ of BILAN_FIELDS) {
|
||||
if (champ in choix) {
|
||||
corps[CHAMP_VERS_ID[champ]] = choix[champ] ?? null;
|
||||
labels[CHAMP_VERS_ID[champ]] = valeurDe(champ);
|
||||
}
|
||||
}
|
||||
enfilerBilan(queryClient, ot, corps, labels);
|
||||
};
|
||||
|
||||
const cloturer = () => {
|
||||
enregistrer();
|
||||
enfilerTransition(queryClient, ot, 'DONE');
|
||||
router.replace(`/ot/${id}`);
|
||||
};
|
||||
|
||||
const manquants = REQUIRED_BILAN_FIELDS.filter((c) => !valeurDe(c));
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
|
||||
<CarteSuggestion
|
||||
ot={ot}
|
||||
horsLigne={horsLigne}
|
||||
surApplication={(s) => setChoix((c) => ({ ...c, [s.field]: s.valueId }))}
|
||||
/>
|
||||
<Carte titre="Bilan d'intervention — requis pour clôturer">
|
||||
<View style={{ gap: 10 }}>
|
||||
{[0, 2, 4].map((rang) => (
|
||||
<View key={rang} style={{ flexDirection: 'row', gap: 8 }}>
|
||||
{[BILAN_FIELDS[rang]!, BILAN_FIELDS[rang + 1]!].map((champ) => (
|
||||
<ChoixTel
|
||||
key={champ}
|
||||
libelle={BILAN_FIELD_LABELS[champ]}
|
||||
requis={REQUIRED_BILAN_FIELDS.includes(champ)}
|
||||
valeur={valeurDe(champ)}
|
||||
options={optionsDe(champ)}
|
||||
surChoix={(vid) => setChoix((c) => ({ ...c, [champ]: vid }))}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{manquants.length ? (
|
||||
<Text style={{ color: t.alerte, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||
⚠ {manquants.map((c) => `« ${BILAN_FIELD_LABELS[c]} »`).join(', ')}{' '}
|
||||
{manquants.length > 1 ? 'manquent' : 'manque'} — la clôture restera bloquée (même garde
|
||||
que le web).
|
||||
</Text>
|
||||
) : null}
|
||||
</Carte>
|
||||
|
||||
<BoutonTel
|
||||
libelle="Enregistrer le bilan"
|
||||
variante="contour"
|
||||
surAppui={() => {
|
||||
enregistrer();
|
||||
router.replace(`/ot/${id}`);
|
||||
}}
|
||||
/>
|
||||
<BoutonTel
|
||||
libelle={
|
||||
manquants.length
|
||||
? 'Clôturer (bilan incomplet)'
|
||||
: horsLigne
|
||||
? 'Clôturer — partira à la synchro'
|
||||
: "Clôturer l'intervention"
|
||||
}
|
||||
variante={manquants.length ? 'gris' : 'vert'}
|
||||
desactive={manquants.length > 0}
|
||||
surAppui={cloturer}
|
||||
/>
|
||||
{horsLigne ? (
|
||||
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||
Hors-ligne : la clôture est enregistrée sur le téléphone et sera rejouée telle quelle
|
||||
au retour du réseau. Si l’OT a changé entre-temps, VOUS trancherez (écran Synchro).
|
||||
</Text>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
/** Écran 4 des maquettes R5 : décrire au pouce → chips suggérées (D1 — un
|
||||
* appui = un choix humain, les chips ne font que pré-remplir les sélecteurs).
|
||||
* L'IA est un service serveur : hors-ligne, la suggestion attend le réseau
|
||||
* — la clôture en file R4, elle, n'en a pas besoin.
|
||||
* Écran 6 (Voix, R5 D5, amendé 22/07) : dicter au lieu de taper — l'audio
|
||||
* n'est JAMAIS conservé (transcrit puis effacé côté serveur ET localement),
|
||||
* seule la transcription relue compte. Une fois jointe à l'OT, elle rejoint
|
||||
* le corpus de l'assistant à la clôture, comme les bilans déjà codés. */
|
||||
function CarteSuggestion({
|
||||
ot,
|
||||
horsLigne,
|
||||
surApplication,
|
||||
}: {
|
||||
ot: WorkOrderDetail;
|
||||
horsLigne: boolean;
|
||||
surApplication: (s: BilanSuggestion) => void;
|
||||
}) {
|
||||
const t = useTokens();
|
||||
const queryClient = useQueryClient();
|
||||
const suggerer = useSuggestionBilan();
|
||||
const transcrire = useTranscription();
|
||||
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||
const [description, setDescription] = useState('');
|
||||
const [appliquees, setAppliquees] = useState<Set<BilanField>>(new Set());
|
||||
const [enregistrement, setEnregistrement] = useState(false);
|
||||
const [jointe, setJointe] = useState(false);
|
||||
const suggestions = suggerer.data?.suggestions ?? [];
|
||||
|
||||
const demarrerDictee = async () => {
|
||||
const { granted } = await requestRecordingPermissionsAsync();
|
||||
if (!granted) return;
|
||||
// iOS refuse recorder.record() tant que la session audio n'a pas été
|
||||
// explicitement autorisée à enregistrer (RecordingDisabledException).
|
||||
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
|
||||
await recorder.prepareToRecordAsync();
|
||||
recorder.record();
|
||||
setEnregistrement(true);
|
||||
setJointe(false);
|
||||
};
|
||||
|
||||
const terminerDictee = async () => {
|
||||
await recorder.stop();
|
||||
await setAudioModeAsync({ allowsRecording: false }); // n'arme le micro que le temps de dicter
|
||||
setEnregistrement(false);
|
||||
const uri = recorder.uri;
|
||||
if (!uri) return;
|
||||
transcrire.mutate(
|
||||
{ uri, nom: 'dictee.m4a', mime: 'audio/m4a' },
|
||||
{
|
||||
onSuccess: (resultat) => setDescription(resultat.text),
|
||||
// Loi 09-08 (D5) : l'audio ne survit JAMAIS à l'appel, succès ou pas —
|
||||
// le fichier local suit le même sort que sur le serveur.
|
||||
onSettled: () => void FileSystem.deleteAsync(uri, { idempotent: true }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Carte titre="Décrire pour suggérer (optionnel)">
|
||||
<TextInput
|
||||
multiline
|
||||
value={description}
|
||||
onChangeText={(v) => {
|
||||
setDescription(v);
|
||||
setJointe(false);
|
||||
}}
|
||||
maxLength={2000}
|
||||
placeholder="Décrivez la panne et ce que vous avez fait, ou dictez avec 🎙…"
|
||||
placeholderTextColor={t.encre3}
|
||||
accessibilityLabel="Décrire pour suggérer"
|
||||
style={{
|
||||
minHeight: 64,
|
||||
borderWidth: 1.5,
|
||||
borderColor: t.bordureForte,
|
||||
borderRadius: 9,
|
||||
backgroundColor: t.surface,
|
||||
padding: 9,
|
||||
color: t.encre,
|
||||
fontFamily: 'Manrope_500Medium',
|
||||
fontSize: 13,
|
||||
textAlignVertical: 'top',
|
||||
}}
|
||||
/>
|
||||
<BoutonTel
|
||||
libelle={
|
||||
horsLigne
|
||||
? '🎙 Dictée — réseau requis'
|
||||
: enregistrement
|
||||
? '■ Terminer la dictée'
|
||||
: transcrire.isPending
|
||||
? 'Transcription…'
|
||||
: '🎙 Dicter la description'
|
||||
}
|
||||
variante={enregistrement ? undefined : 'contour'}
|
||||
desactive={horsLigne || transcrire.isPending}
|
||||
surAppui={() => void (enregistrement ? terminerDictee() : demarrerDictee())}
|
||||
/>
|
||||
{transcrire.isError ? (
|
||||
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||
{transcrire.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
{transcrire.isSuccess && !enregistrement ? (
|
||||
<Text style={{ color: t.stTermine, fontFamily: 'Manrope_600SemiBold', fontSize: 11 }}>
|
||||
✓ Audio transcrit et supprimé — relisez avant 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
|
||||
libelle={
|
||||
horsLigne
|
||||
? 'Suggérer — réseau requis'
|
||||
: suggerer.isPending
|
||||
? 'Analyse…'
|
||||
: '✨ Suggérer les codes'
|
||||
}
|
||||
variante="contour"
|
||||
desactive={horsLigne || suggerer.isPending || description.trim().length < 10}
|
||||
surAppui={() => {
|
||||
setAppliquees(new Set());
|
||||
suggerer.mutate(description.trim());
|
||||
}}
|
||||
/>
|
||||
{suggerer.isError ? (
|
||||
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||
{suggerer.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
{suggerer.isSuccess && suggestions.length === 0 ? (
|
||||
<Text style={{ color: t.encre2, fontFamily: 'Manrope_500Medium', fontSize: 12 }}>
|
||||
Aucun code assez proche — l’IA ne devine pas : choisissez dans les sélecteurs.
|
||||
</Text>
|
||||
) : null}
|
||||
{suggestions.length > 0 ? (
|
||||
<>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
|
||||
{suggestions.map((s) => {
|
||||
const faite = appliquees.has(s.field);
|
||||
return (
|
||||
<Pressable
|
||||
key={s.field}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Appliquer ${BILAN_FIELD_LABELS[s.field]} : ${s.label}`}
|
||||
disabled={faite}
|
||||
onPress={() => {
|
||||
surApplication(s);
|
||||
setAppliquees((avant) => new Set([...avant, s.field]));
|
||||
}}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
borderWidth: 1.5,
|
||||
borderColor: t.primaire,
|
||||
backgroundColor: faite ? t.primaire : t.primaireDoux,
|
||||
borderRadius: 999,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 10,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: faite ? '#fff' : t.primaire,
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{faite ? '✓' : '✨'} {BILAN_FIELD_LABELS[s.field]} : {s.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_500Medium', fontSize: 11 }}>
|
||||
Rien ne s’enregistre sans votre geste — les chips pré-remplissent les sélecteurs
|
||||
ci-dessous.
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Carte>
|
||||
);
|
||||
}
|
||||
145
apps/mobile/app/ot/[id]/grille.tsx
Normal file
145
apps/mobile/app/ot/[id]/grille.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useWorkOrder } from '@/api/exploitation';
|
||||
import { useHorsLigne } from '@/auth/session';
|
||||
import { EnteteFiche } from '@/composants/ui';
|
||||
import { enfilerCoche } from '@/file/actions';
|
||||
import { useFile } from '@/file/store';
|
||||
import { prochainEtat, progression } from '@/lib/checklist';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Écran 6 de la maquette R4 : la grille au pouce. Appui simple = coche,
|
||||
* appui long = non-applicable. Chaque coche part en file INDIVIDUELLEMENT
|
||||
* (D1) : 7 coches faites = 7 coches sauvées, réseau ou pas. */
|
||||
export default function PageGrille() {
|
||||
const t = useTokens();
|
||||
const horsLigne = useHorsLigne();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { data: ot } = useWorkOrder(id);
|
||||
const queryClient = useQueryClient();
|
||||
const file = useFile();
|
||||
|
||||
if (!ot) return null;
|
||||
const { faits, total, pct } = progression(ot.checklist);
|
||||
const enFile = new Set(
|
||||
file.flatMap((s) => (s.otId === ot.id && s.type === 'COCHE' ? [s.payload.itemId] : [])),
|
||||
);
|
||||
|
||||
const basculer = (item: { id: string; label: string }, etat: Parameters<typeof prochainEtat>[0], versNA = false) => {
|
||||
enfilerCoche(
|
||||
queryClient,
|
||||
ot,
|
||||
item,
|
||||
versNA ? (etat === 'NA' ? 'PENDING' : 'NA') : prochainEtat(etat),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||
<EnteteFiche
|
||||
titre={`Grille — ${ot.assetReference}`}
|
||||
apres={
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 13, color: t.encre2 }}>
|
||||
{faits}/{total}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<View style={{ height: 8, borderRadius: 999, backgroundColor: t.surface2, overflow: 'hidden' }}>
|
||||
<View
|
||||
style={{ width: `${Math.round(pct * 100)}%`, height: '100%', backgroundColor: t.succes }}
|
||||
/>
|
||||
</View>
|
||||
{horsLigne ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: t.stAttenteFond,
|
||||
borderColor: t.stAttente,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 9,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||
⚠ Hors-ligne — chaque coche part en file et se synchronisera au retour du réseau.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{ot.checklist.map((item) => (
|
||||
<Pressable
|
||||
key={item.id}
|
||||
accessibilityRole="checkbox"
|
||||
aria-checked={item.state === 'DONE'}
|
||||
accessibilityLabel={item.label}
|
||||
onPress={() => basculer(item, item.state)}
|
||||
onLongPress={() => basculer(item, item.state, true)}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 11,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 6,
|
||||
borderWidth: 2,
|
||||
borderColor: item.state === 'DONE' ? t.succes : t.bordureForte,
|
||||
backgroundColor: item.state === 'DONE' ? t.succes : 'transparent',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{item.state === 'DONE' ? (
|
||||
<Text style={{ color: '#fff', fontSize: 13, fontFamily: 'Manrope_800ExtraBold' }}>✓</Text>
|
||||
) : item.state === 'NA' ? (
|
||||
<Text style={{ color: t.encre3, fontSize: 10, fontFamily: 'Manrope_800ExtraBold' }}>NA</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
fontSize: 13,
|
||||
color: item.state === 'PENDING' ? t.encre : t.encre2,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{enFile.has(item.id) ? (
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_800ExtraBold',
|
||||
fontSize: 9,
|
||||
color: t.stAttente,
|
||||
backgroundColor: t.stAttenteFond,
|
||||
paddingHorizontal: 6,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
en file
|
||||
</Text>
|
||||
) : item.doneBy ? (
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 10.5, color: t.encre3 }}>
|
||||
{item.doneBy.displayName.split(' ')[0]}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
))}
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
|
||||
Appui simple : cocher / décocher · appui long : non-applicable.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
196
apps/mobile/app/ot/[id]/index.tsx
Normal file
196
apps/mobile/app/ot/[id]/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { ScrollView, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
WORK_ORDER_PRIORITY_LABELS,
|
||||
WORK_ORDER_TYPE_LABELS,
|
||||
} from '@siop/shared';
|
||||
import { useWorkOrder } from '@/api/exploitation';
|
||||
import { useHorsLigne } from '@/auth/session';
|
||||
import { BoutonTel, Carte, ChipStatut, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||
import { CartePhotos } from '@/composants/carte-photos';
|
||||
import { enfilerTransition } from '@/file/actions';
|
||||
import { useFile } from '@/file/store';
|
||||
import { progression } from '@/lib/checklist';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
const fmt = new Intl.NumberFormat('fr-FR');
|
||||
|
||||
/** Écran 2 de la maquette R4 : la même machine à états que le web — UN
|
||||
* bouton principal selon l'état. Depuis R4.3, chaque geste part en file
|
||||
* (D1) : l'écran montre l'état local tout de suite, la synchro suit. */
|
||||
export default function PageFicheOT() {
|
||||
const t = useTokens();
|
||||
const horsLigne = useHorsLigne();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { data: ot } = useWorkOrder(id);
|
||||
const queryClient = useQueryClient();
|
||||
const file = useFile();
|
||||
|
||||
if (!ot) return null;
|
||||
|
||||
const grille = progression(ot.checklist);
|
||||
const enFile = file.filter((s) => s.otId === ot.id);
|
||||
const peutDemarrer = ot.allowedTransitions.includes('IN_PROGRESS');
|
||||
const peutCloturer = ot.allowedTransitions.includes('DONE');
|
||||
const peutSuspendre = ot.allowedTransitions.includes('ON_HOLD');
|
||||
|
||||
const demarrer = () => enfilerTransition(queryClient, ot, 'IN_PROGRESS');
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||
<EnteteFiche
|
||||
titre={ot.reference}
|
||||
apres={
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
{enFile.length ? (
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_800ExtraBold',
|
||||
fontSize: 10,
|
||||
color: t.stAttente,
|
||||
backgroundColor: t.stAttenteFond,
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{enFile.length} en file
|
||||
</Text>
|
||||
) : null}
|
||||
<ChipStatut statut={ot.status} />
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
|
||||
{ot.title}
|
||||
</Text>
|
||||
{horsLigne ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: t.stAttenteFond,
|
||||
borderColor: t.stAttente,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 9,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||
⚠ Hors-ligne — vos gestes partent en file et se synchroniseront au retour du réseau.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Carte titre="Intervention">
|
||||
<LigneInfo nom="Type" valeur={WORK_ORDER_TYPE_LABELS[ot.type]} />
|
||||
<LigneInfo
|
||||
nom="Équipement"
|
||||
valeur={`Asc. ${ot.assetReference} — ${ot.siteName}`}
|
||||
/>
|
||||
<LigneInfo
|
||||
nom="Priorité"
|
||||
valeur={
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 12.5,
|
||||
color: ot.priority === 'PERSON_TRAPPED' || ot.priority === 'HIGH' ? t.prioHaute : t.encre,
|
||||
}}
|
||||
>
|
||||
{WORK_ORDER_PRIORITY_LABELS[ot.priority]}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<LigneInfo
|
||||
nom="Échéance"
|
||||
valeur={
|
||||
ot.dueDate
|
||||
? new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(ot.dueDate))
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
{ot.request ? (
|
||||
<LigneInfo nom="Demande liée" valeur={`${ot.request.reference} · ${ot.request.requesterLabel}`} />
|
||||
) : null}
|
||||
</Carte>
|
||||
|
||||
<Carte titre={`Pièces & main-d'œuvre — ${fmt.format(ot.costs.total)} MAD`}>
|
||||
{ot.costs.parts.map((p) => (
|
||||
<LigneInfo key={p.id} nom={`${p.designation} × ${p.quantity}`} valeur={`${fmt.format(p.total)}`} />
|
||||
))}
|
||||
{ot.costs.labor.map((l) => (
|
||||
<LigneInfo
|
||||
key={l.id}
|
||||
nom={`${l.displayName} · ${Math.floor(l.minutes / 60)} h ${String(l.minutes % 60).padStart(2, '0')} × ${fmt.format(l.hourlyRate)}/h`}
|
||||
valeur={`${fmt.format(l.total)}`}
|
||||
/>
|
||||
))}
|
||||
{!ot.costs.parts.length && !ot.costs.labor.length ? (
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||
Aucune consommation ni temps saisi. (Saisies pièces/temps : depuis le web en R4.2.)
|
||||
</Text>
|
||||
) : null}
|
||||
</Carte>
|
||||
|
||||
{ot.checklist.length ? (
|
||||
<Carte titre={`Checklist liée — ${grille.faits}/${grille.total}`}>
|
||||
<BoutonTel
|
||||
libelle={grille.faits === grille.total ? 'Grille complète ✓ — revoir' : 'Cocher la grille'}
|
||||
variante="contour"
|
||||
surAppui={() => router.push(`/ot/${ot.id}/grille`)}
|
||||
/>
|
||||
</Carte>
|
||||
) : null}
|
||||
|
||||
{ot.closureBlockers.length && (peutCloturer || ot.status === 'IN_PROGRESS') ? (
|
||||
<Text style={{ color: t.alerte, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||
⚠ {ot.closureBlockers.join(' · ')}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{peutDemarrer ? (
|
||||
<BoutonTel
|
||||
libelle={ot.status === 'ON_HOLD' ? 'Reprendre' : 'Démarrer'}
|
||||
surAppui={demarrer}
|
||||
/>
|
||||
) : null}
|
||||
{peutCloturer ? (
|
||||
<BoutonTel
|
||||
libelle="Clôturer l'intervention"
|
||||
variante="vert"
|
||||
surAppui={() => router.push(`/ot/${ot.id}/cloture`)}
|
||||
/>
|
||||
) : null}
|
||||
{peutSuspendre ? (
|
||||
<BoutonTel
|
||||
libelle="Mettre en attente"
|
||||
variante="contour"
|
||||
surAppui={() => enfilerTransition(queryClient, ot, 'ON_HOLD')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<CartePhotos ot={ot} />
|
||||
|
||||
<Carte titre="Activité">
|
||||
{ot.events.slice(0, 5).map((e) => (
|
||||
<View key={e.id} style={{ gap: 1 }}>
|
||||
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12, color: t.encre }}>
|
||||
{e.kind}
|
||||
{e.message ? ` — ${e.message}` : ''}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre3 }}>
|
||||
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||||
new Date(e.createdAt),
|
||||
)}
|
||||
{e.by ? ` · ${e.by.displayName}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Carte>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
BIN
apps/mobile/assets/android-icon-background.png
Normal file
BIN
apps/mobile/assets/android-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/assets/android-icon-foreground.png
Normal file
BIN
apps/mobile/assets/android-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/mobile/assets/android-icon-monochrome.png
Normal file
BIN
apps/mobile/assets/android-icon-monochrome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/mobile/assets/favicon.png
Normal file
BIN
apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/mobile/assets/icon.png
Normal file
BIN
apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
apps/mobile/assets/splash-icon.png
Normal file
BIN
apps/mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
11
apps/mobile/jest.config.js
Normal file
11
apps/mobile/jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** jest-expo (convention CLAUDE.md) — R4.1 teste la logique pure (tri de
|
||||
* Ma journée, tokens bi-thème) ; les parcours complets passeront par la
|
||||
* recette sur appareil. */
|
||||
module.exports = {
|
||||
preset: 'jest-expo',
|
||||
setupFiles: ['./jest.setup.js'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testMatch: ['**/*.test.ts'],
|
||||
};
|
||||
4
apps/mobile/jest.setup.js
Normal file
4
apps/mobile/jest.setup.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/* AsyncStorage : mock officiel du paquet (pas de natif sous jest). */
|
||||
jest.mock('@react-native-async-storage/async-storage', () =>
|
||||
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
|
||||
);
|
||||
53
apps/mobile/package.json
Normal file
53
apps/mobile/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@siop/mobile",
|
||||
"version": "0.1.0",
|
||||
"main": "expo-router/entry",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/manrope": "^0.4.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/netinfo": "12.0.1",
|
||||
"@siop/shared": "workspace:*",
|
||||
"@tanstack/query-async-storage-persister": "^5.101.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-persist-client": "^5.101.2",
|
||||
"expo": "~57.0.6",
|
||||
"expo-audio": "~57.0.2",
|
||||
"expo-camera": "~57.0.3",
|
||||
"expo-constants": "~57.0.5",
|
||||
"expo-file-system": "~57.0.1",
|
||||
"expo-font": "~57.0.1",
|
||||
"expo-image-manipulator": "~57.0.4",
|
||||
"expo-image-picker": "~57.0.4",
|
||||
"expo-linking": "~57.0.3",
|
||||
"expo-router": "~57.0.6",
|
||||
"expo-secure-store": "~57.0.1",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"openapi-fetch": "^0.13.8",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-web": "^0.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~19.2.2",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "^57.0.2",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"react-test-renderer": "^19.2.7",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "jest",
|
||||
"generate:client": "openapi-typescript ../../docs/openapi.json -o src/api/schema.d.ts",
|
||||
"lint": "eslint app src"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user