mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Compare commits
30 Commits
release/r4
...
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 |
88
.github/workflows/ci.yml
vendored
88
.github/workflows/ci.yml
vendored
@@ -44,14 +44,15 @@ jobs:
|
|||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm --filter @siop/shared build
|
- 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: |
|
run: |
|
||||||
pnpm --filter @siop/shared contract
|
pnpm --filter @siop/shared contract
|
||||||
pnpm --filter @siop/web generate:client
|
pnpm --filter @siop/web generate:client
|
||||||
|
pnpm --filter @siop/mobile generate:client
|
||||||
- name: Vérifier qu'aucun artefact ne dérive du contrat
|
- name: Vérifier qu'aucun artefact ne dérive du contrat
|
||||||
run: |
|
run: |
|
||||||
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts; then
|
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)."
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -59,18 +60,8 @@ jobs:
|
|||||||
name: api (tests + couverture ≥ 70 %)
|
name: api (tests + couverture ≥ 70 %)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
# R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography).
|
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
|
||||||
# pgvector arrivera en R5 (bascule alors sur l’image infra/postgres).
|
# services ne savent pas builder, il démarre donc par étape ci-dessous.
|
||||||
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
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7.4-alpine
|
image: redis:7.4-alpine
|
||||||
ports: ['6379:6379']
|
ports: ['6379:6379']
|
||||||
@@ -86,6 +77,15 @@ jobs:
|
|||||||
MINIO_SECRET_KEY: siop-minio
|
MINIO_SECRET_KEY: siop-minio
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- 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)
|
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name minio -p 9000:9000 \
|
docker run -d --name minio -p 9000:9000 \
|
||||||
@@ -133,20 +133,25 @@ jobs:
|
|||||||
- run: pnpm --filter @siop/mobile typecheck
|
- run: pnpm --filter @siop/mobile typecheck
|
||||||
- run: pnpm --filter @siop/mobile test
|
- 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:
|
e2e:
|
||||||
name: e2e (parcours démo Playwright)
|
name: e2e (parcours démo Playwright)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
postgres:
|
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
|
||||||
image: postgis/postgis:18-3.6
|
# services ne savent pas builder, il démarre donc par étape ci-dessous.
|
||||||
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
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7.4-alpine
|
image: redis:7.4-alpine
|
||||||
ports: ['6379:6379']
|
ports: ['6379:6379']
|
||||||
@@ -162,6 +167,15 @@ jobs:
|
|||||||
MINIO_SECRET_KEY: siop-minio
|
MINIO_SECRET_KEY: siop-minio
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- 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)
|
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name minio -p 9000:9000 \
|
docker run -d --name minio -p 9000:9000 \
|
||||||
@@ -177,8 +191,30 @@ jobs:
|
|||||||
- run: pnpm --filter @siop/api prisma:generate
|
- run: pnpm --filter @siop/api prisma:generate
|
||||||
- run: pnpm --filter @siop/api exec prisma migrate deploy
|
- run: pnpm --filter @siop/api exec prisma migrate deploy
|
||||||
- run: pnpm --filter @siop/api build
|
- 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 exec playwright install --with-deps chromium
|
||||||
- run: pnpm --filter @siop/web e2e
|
- 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
|
- name: Traces Playwright en cas d'échec
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -195,7 +231,7 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
name: deploy (Dokploy — siop2.apps.enset.top)
|
name: deploy (Dokploy — siop2.apps.enset.top)
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
needs: [lint, contract, api, web, mobile, e2e]
|
needs: [lint, contract, api, web, mobile, ai, e2e]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: production
|
environment: production
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -2,8 +2,17 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
|
# apps/mobile/.env ne porte aucun secret — un réglage de build (fetch RN
|
||||||
|
# classique, cf. ADR-005) qui doit être le même pour tout le monde.
|
||||||
|
!apps/mobile/.env
|
||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|
||||||
|
# Python (apps/ai)
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|||||||
31
CLAUDE.md
31
CLAUDE.md
@@ -10,7 +10,7 @@ Projet **réel** (SPELEV, maintenance d'ascenseurs, Maroc) mené par le Pr. Daai
|
|||||||
|
|
||||||
1. **Design-first** : aucune ligne de code applicatif avant validation par le référent de la charte graphique, des tokens et des maquettes HD (`docs/02-design/`). Chaque release subit une revue « pixel » écrans ↔ maquettes.
|
1. **Design-first** : aucune ligne de code applicatif avant validation par le référent de la charte graphique, des tokens et des maquettes HD (`docs/02-design/`). Chaque release subit une revue « pixel » écrans ↔ maquettes.
|
||||||
2. **Playbook vivant** : chaque phase du cycle de vie a son dossier `docs/0X-*/` (template réutilisable + artefacts réels). Le journal quotidien vit dans `docs/journal/` — **jamais** dans le livre.
|
2. **Playbook vivant** : chaque phase du cycle de vie a son dossier `docs/0X-*/` (template réutilisable + artefacts réels). Le journal quotidien vit dans `docs/journal/` — **jamais** dans le livre.
|
||||||
3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn.
|
3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA → R6 Mobile tous rôles. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn.
|
||||||
4. **Déployer tôt** : chaque release part sur le serveur de production (Dokploy) dès sa recette.
|
4. **Déployer tôt** : chaque release part sur le serveur de production (Dokploy) dès sa recette.
|
||||||
5. **Le développeur est le premier utilisateur** : `DEMO_MODE=true` active un **sélecteur de compte démo** (connexion 1 clic sur les comptes seedés, endpoint `POST /auth/demo-login` strictement absent si l'env ne l'active pas). Critère : changer de rôle en < 3 s sans mot de passe.
|
5. **Le développeur est le premier utilisateur** : `DEMO_MODE=true` active un **sélecteur de compte démo** (connexion 1 clic sur les comptes seedés, endpoint `POST /auth/demo-login` strictement absent si l'env ne l'active pas). Critère : changer de rôle en < 3 s sans mot de passe.
|
||||||
|
|
||||||
@@ -59,5 +59,30 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
|||||||
- ✅ **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.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.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**.
|
- 🏁 **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**.
|
||||||
- 🔄 **Reprise ici** : R5 IA — design d'abord : maquettes (assistant RAG sourcé, suggestion de codes de bilan) + décisions à acter, AUCUN code `apps/ai` avant validation. Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`.
|
- ✅ **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).
|
||||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
- ✅ **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
@@ -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
|
activationToken String? @unique
|
||||||
activationExpiresAt DateTime?
|
activationExpiresAt DateTime?
|
||||||
teams Team[]
|
teams Team[]
|
||||||
|
// R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction
|
||||||
|
assignedSites Location[]
|
||||||
// R2 — exploitation
|
// R2 — exploitation
|
||||||
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
||||||
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
||||||
@@ -111,6 +113,7 @@ model Location {
|
|||||||
partnerId String? @db.Uuid
|
partnerId String? @db.Uuid
|
||||||
partner Partner? @relation(fields: [partnerId], references: [id])
|
partner Partner? @relation(fields: [partnerId], references: [id])
|
||||||
assets Asset[]
|
assets Asset[]
|
||||||
|
assignedUsers User[] // reverse de User.assignedSites (R6.6)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -233,6 +236,7 @@ model WorkOrder {
|
|||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
cancelledAt DateTime?
|
cancelledAt DateTime?
|
||||||
events WorkOrderEvent[]
|
events WorkOrderEvent[]
|
||||||
|
ragChunks RagChunk[]
|
||||||
checklist ChecklistItem[]
|
checklist ChecklistItem[]
|
||||||
report InterventionReport?
|
report InterventionReport?
|
||||||
request Request?
|
request Request?
|
||||||
@@ -513,7 +517,36 @@ model Document {
|
|||||||
uploadedById String? @db.Uuid
|
uploadedById String? @db.Uuid
|
||||||
uploadedBy User? @relation(fields: [uploadedById], references: [id])
|
uploadedBy User? @relation(fields: [uploadedById], references: [id])
|
||||||
createdAt DateTime @default(now())
|
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([assetId])
|
||||||
@@index([workOrderId])
|
@@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 karim = await parEmail('demandeur@demo.siop.ma');
|
||||||
const annee = new Date().getFullYear();
|
const annee = new Date().getFullYear();
|
||||||
|
|
||||||
|
// R6.6 — Karim (Demandeur) est rattaché à Tour Atlas (cohérent avec le
|
||||||
|
// guardianName déjà seedé pour ce site) : son signalement se limite à ce
|
||||||
|
// parc, ses demandes historiques sur d'autres sites restent visibles.
|
||||||
|
const tourAtlas = await prisma.location.findFirst({
|
||||||
|
where: { name: 'Tour Atlas', parentId: null },
|
||||||
|
});
|
||||||
|
if (tourAtlas) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: karim },
|
||||||
|
data: { assignedSites: { connect: [{ id: tourAtlas.id }] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
||||||
(t) => t.label,
|
(t) => t.label,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DynamicModule, Module } from '@nestjs/common';
|
|||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { AnalyticsModule } from './analytics/analytics.module';
|
import { AnalyticsModule } from './analytics/analytics.module';
|
||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
|
import { AssistantModule } from './assistant/assistant.module';
|
||||||
import { AssetsModule } from './assets/assets.module';
|
import { AssetsModule } from './assets/assets.module';
|
||||||
import { DocumentsModule } from './documents/documents.module';
|
import { DocumentsModule } from './documents/documents.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
@@ -63,6 +64,7 @@ export class AppModule {
|
|||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
|
AssistantModule,
|
||||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type AssetCreate,
|
type AssetCreate,
|
||||||
type AssetUpdate,
|
type AssetUpdate,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import { type AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
import { AssetsService } from './assets.service';
|
import { AssetsService } from './assets.service';
|
||||||
@@ -32,10 +33,11 @@ export class AssetsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
|
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
|
||||||
* doit pouvoir désigner l'appareil qu'il signale. */
|
* doit pouvoir désigner l'appareil qu'il signale (filtré à son site s'il
|
||||||
|
* en a un, R6.6). */
|
||||||
@Get('options')
|
@Get('options')
|
||||||
options() {
|
options(@CurrentUser() user: AuthenticatedUser) {
|
||||||
return this.assetsService.options();
|
return this.assetsService.options(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
AssetsResponse,
|
AssetsResponse,
|
||||||
AssetUpdate,
|
AssetUpdate,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
const assetInclude = {
|
const assetInclude = {
|
||||||
@@ -29,9 +30,30 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
|||||||
export class AssetsService {
|
export class AssetsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
|
/** Sites + zones autorisés pour le signalement de `user`, ou `null` si aucune
|
||||||
async options(): Promise<AssetOptionsResponse> {
|
* restriction (comportement historique — tous les rôles sauf un Demandeur
|
||||||
|
* affecté à un site, R6.6). Réutilisée par `options()` ET par
|
||||||
|
* `RequestsService.create` pour que les deux filtres ne divergent jamais. */
|
||||||
|
async allowedLocationIds(user: AuthenticatedUser): Promise<string[] | null> {
|
||||||
|
const me = await this.prisma.user.findUnique({
|
||||||
|
where: { id: user.userId },
|
||||||
|
select: { assignedSites: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
const siteIds = (me?.assignedSites ?? []).map((s) => s.id);
|
||||||
|
if (siteIds.length === 0) return null;
|
||||||
|
const zones = await this.prisma.location.findMany({
|
||||||
|
where: { parentId: { in: siteIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return [...siteIds, ...zones.map((z) => z.id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options minimales pour le signalement — ouvert à tout rôle authentifié,
|
||||||
|
* filtré au périmètre du Demandeur s'il est affecté à un site (R6.6). */
|
||||||
|
async options(user: AuthenticatedUser): Promise<AssetOptionsResponse> {
|
||||||
|
const allowed = await this.allowedLocationIds(user);
|
||||||
const rows = await this.prisma.asset.findMany({
|
const rows = await this.prisma.asset.findMany({
|
||||||
|
where: allowed ? { locationId: { in: allowed } } : undefined,
|
||||||
include: { location: { include: { parent: true } } },
|
include: { location: { include: { parent: true } } },
|
||||||
orderBy: { reference: 'asc' },
|
orderBy: { reference: 'asc' },
|
||||||
});
|
});
|
||||||
|
|||||||
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ const EnvSchema = z.object({
|
|||||||
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx
|
// 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.
|
// même-origine, les apps natives n'envoient pas d'Origin.
|
||||||
CORS_ORIGINS: z.string().default(''),
|
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>;
|
export type Env = z.infer<typeof EnvSchema>;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
@@ -14,11 +15,17 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { DOCUMENT_MAX_BYTES } from '@siop/shared';
|
import {
|
||||||
|
DOCUMENT_MAX_BYTES,
|
||||||
|
DocumentCorpusUpdateSchema,
|
||||||
|
type DocumentCorpusUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
import {
|
import {
|
||||||
AuthenticatedUser,
|
AuthenticatedUser,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
} from '../auth/current-user.decorator';
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
import { DocumentsService } from './documents.service';
|
import { DocumentsService } from './documents.service';
|
||||||
|
|
||||||
@Controller('documents')
|
@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')
|
@Delete(':id')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
||||||
|
|||||||
@@ -155,6 +155,21 @@ export class DocumentsService {
|
|||||||
workOrderReference: row.workOrder?.reference ?? null,
|
workOrderReference: row.workOrder?.reference ?? null,
|
||||||
uploadedByName: row.uploadedBy?.displayName ?? null,
|
uploadedByName: row.uploadedBy?.displayName ?? null,
|
||||||
createdAt: row.createdAt.toISOString(),
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AssetsModule } from '../assets/assets.module';
|
||||||
import { WorkOrdersModule } from '../work-orders/work-orders.module';
|
import { WorkOrdersModule } from '../work-orders/work-orders.module';
|
||||||
import { RequestsController } from './requests.controller';
|
import { RequestsController } from './requests.controller';
|
||||||
import { RequestsService } from './requests.service';
|
import { RequestsService } from './requests.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkOrdersModule],
|
imports: [WorkOrdersModule, AssetsModule],
|
||||||
controllers: [RequestsController],
|
controllers: [RequestsController],
|
||||||
providers: [RequestsService],
|
providers: [RequestsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
import { PermissionsService } from '../permissions/permissions.service';
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AssetsService } from '../assets/assets.service';
|
||||||
import { WorkOrdersService } from '../work-orders/work-orders.service';
|
import { WorkOrdersService } from '../work-orders/work-orders.service';
|
||||||
|
|
||||||
const requestInclude = {
|
const requestInclude = {
|
||||||
@@ -32,6 +33,7 @@ export class RequestsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
private readonly workOrders: WorkOrdersService,
|
private readonly workOrders: WorkOrdersService,
|
||||||
|
private readonly assets: AssetsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
|
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
|
||||||
@@ -52,6 +54,13 @@ export class RequestsService {
|
|||||||
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
|
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
|
||||||
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
||||||
if (!asset) throw new BadRequestException('Équipement inconnu');
|
if (!asset) throw new BadRequestException('Équipement inconnu');
|
||||||
|
// Défense en profondeur (R6.6) : même filtre que /assets/options, pour
|
||||||
|
// qu'un Demandeur affecté à un site ne puisse pas contourner la liste
|
||||||
|
// en soumettant directement un assetId hors périmètre.
|
||||||
|
const allowed = await this.assets.allowedLocationIds(user);
|
||||||
|
if (allowed && !allowed.includes(asset.locationId)) {
|
||||||
|
throw new BadRequestException("Cet équipement n'est pas dans votre périmètre");
|
||||||
|
}
|
||||||
for (let essai = 0; ; essai++) {
|
for (let essai = 0; ; essai++) {
|
||||||
try {
|
try {
|
||||||
const created = await this.prisma.request.create({
|
const created = await this.prisma.request.create({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -20,6 +21,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
const userInclude = {
|
const userInclude = {
|
||||||
role: true,
|
role: true,
|
||||||
teams: { orderBy: { name: 'asc' } },
|
teams: { orderBy: { name: 'asc' } },
|
||||||
|
assignedSites: { orderBy: { name: 'asc' } },
|
||||||
} satisfies Prisma.UserInclude;
|
} satisfies Prisma.UserInclude;
|
||||||
|
|
||||||
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
||||||
@@ -46,6 +48,7 @@ export class UsersService {
|
|||||||
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
||||||
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
||||||
if (!role) throw new NotFoundException('Rôle inconnu');
|
if (!role) throw new NotFoundException('Rôle inconnu');
|
||||||
|
if (dto.locationIds?.length) await this.assertTopLevelSites(dto.locationIds);
|
||||||
try {
|
try {
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -56,6 +59,9 @@ export class UsersService {
|
|||||||
teams: dto.teamIds?.length
|
teams: dto.teamIds?.length
|
||||||
? { connect: dto.teamIds.map((id) => ({ id })) }
|
? { connect: dto.teamIds.map((id) => ({ id })) }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
assignedSites: dto.locationIds?.length
|
||||||
|
? { connect: dto.locationIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
...this.freshToken(),
|
...this.freshToken(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -90,6 +96,7 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
||||||
|
if (dto.locationIds) await this.assertTopLevelSites(dto.locationIds);
|
||||||
try {
|
try {
|
||||||
const updated = await this.prisma.user.update({
|
const updated = await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -102,6 +109,9 @@ export class UsersService {
|
|||||||
teams: dto.teamIds
|
teams: dto.teamIds
|
||||||
? { set: dto.teamIds.map((id) => ({ id })) }
|
? { set: dto.teamIds.map((id) => ({ id })) }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
assignedSites: dto.locationIds
|
||||||
|
? { set: dto.locationIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
include: userInclude,
|
include: userInclude,
|
||||||
});
|
});
|
||||||
@@ -114,6 +124,16 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** R6.6 : un Demandeur n'est affecté qu'à des sites racine, jamais des zones
|
||||||
|
* — même invariant que la hiérarchie site/zone (LocationsService.assertDepth). */
|
||||||
|
private async assertTopLevelSites(ids: string[]): Promise<void> {
|
||||||
|
const sites = await this.prisma.location.findMany({ where: { id: { in: ids } } });
|
||||||
|
if (sites.length !== ids.length) throw new BadRequestException('Site inconnu');
|
||||||
|
if (sites.some((s) => s.parentId)) {
|
||||||
|
throw new BadRequestException("L'affectation d'un Demandeur se fait à un site, pas à une zone");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private freshToken() {
|
private freshToken() {
|
||||||
return {
|
return {
|
||||||
activationToken: randomBytes(32).toString('base64url'),
|
activationToken: randomBytes(32).toString('base64url'),
|
||||||
@@ -138,6 +158,7 @@ export class UsersService {
|
|||||||
: 'invited',
|
: 'invited',
|
||||||
isDemo: row.isDemo,
|
isDemo: row.isDemo,
|
||||||
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
|
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
|
||||||
|
assignedSites: row.assignedSites.map((s) => ({ id: s.id, name: s.name })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
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 () => {
|
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
|
||||||
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
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}` });
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
|
||||||
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
|
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
|
||||||
// 1. Karim (gardien) signale
|
// 1. Karim (gardien) signale — sur A2, dans son site rattaché (Tour
|
||||||
|
// Atlas, R6.6) : un Demandeur ne peut plus signaler hors périmètre.
|
||||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A2');
|
||||||
const demande = await http()
|
const demande = await http()
|
||||||
.post('/requests')
|
.post('/requests')
|
||||||
.set(auth(karim))
|
.set(auth(karim))
|
||||||
@@ -234,8 +235,9 @@ describe('Exploitation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
|
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
|
||||||
|
// B1 : dans le site rattaché de Karim (Tour Atlas, R6.6).
|
||||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'C1');
|
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'B1');
|
||||||
const demande = await http()
|
const demande = await http()
|
||||||
.post('/requests')
|
.post('/requests')
|
||||||
.set(auth(karim))
|
.set(auth(karim))
|
||||||
|
|||||||
21
apps/mobile/.env
Normal file
21
apps/mobile/.env
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Depuis Expo SDK 57, le `fetch` global est `expo/fetch` (WinterCG) — qui ne
|
||||||
|
# comprend pas le raccourci FormData natif `{uri, name, type}` utilisé pour
|
||||||
|
# joindre un fichier au multipart (photos d'intervention, D5). Sans ce
|
||||||
|
# réglage, l'upload échoue avec `Unsupported FormDataPart implementation`,
|
||||||
|
# systématiquement classé à tort comme une coupure réseau (D1) et mis en
|
||||||
|
# boucle de retry silencieuse — trouvé en recette terrain iOS (17/07/2026).
|
||||||
|
# Voir ADR-005 et docs/journal/journal.md.
|
||||||
|
EXPO_PUBLIC_USE_RN_FETCH=1
|
||||||
|
|
||||||
|
# Modules Expo/RN précompilés (XCFrameworks, SDK 56/57) incompatibles avec la
|
||||||
|
# liaison statique de ce projet (pas de use_frameworks!) : sans ce réglage,
|
||||||
|
# l'app compile mais CRASHE au lancement
|
||||||
|
# (dyld: Library not loaded: @rpath/React.framework/React, signal 6).
|
||||||
|
# Force la recompilation depuis les sources — voir ADR-005.
|
||||||
|
EXPO_USE_PRECOMPILED_MODULES=0
|
||||||
|
RCT_USE_PREBUILT_RNCORE=0
|
||||||
|
|
||||||
|
# Origine du web — R6.4, lien d'activation « Personnes » (/activation?token=…,
|
||||||
|
# il n'existe pas d'équivalent mobile). À ajuster par environnement comme
|
||||||
|
# EXPO_PUBLIC_API_URL (IP LAN ou domaine de prod).
|
||||||
|
EXPO_PUBLIC_WEB_URL=http://localhost:5173
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.anonymous.siop2-mobile"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
@@ -16,7 +17,8 @@
|
|||||||
"backgroundImage": "./assets/android-icon-background.png",
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "com.anonymous.siop2mobile"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
@@ -24,7 +26,8 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"expo-secure-store"
|
"expo-secure-store",
|
||||||
|
"expo-audio"
|
||||||
],
|
],
|
||||||
"scheme": "siop"
|
"scheme": "siop"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
import { Text, type ColorValue } from 'react-native';
|
import { Text, type ColorValue } from 'react-native';
|
||||||
import { useAssets, useReferenceValues } from '@/api/exploitation';
|
import { useAssets, useReferenceValues } from '@/api/exploitation';
|
||||||
|
import { useMe } from '@/auth/session';
|
||||||
|
import { ongletsVisibles } from '@/auth/roles';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
import { useFile } from '@/file/store';
|
import { useFile } from '@/file/store';
|
||||||
import { useTokens } from '@/theme/tokens';
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
/** La tabbar de la maquette R4. Les onglets à venir restent visibles
|
/** Barre d'onglets ADAPTATIVE (maquette « mobile ouvert à tous les rôles »,
|
||||||
* (périmètre annoncé, même patron que la sidebar web) mais mènent à un
|
* D1) : le Technicien/Technicien limité gardent exactement leurs 4 onglets
|
||||||
* écran « disponible en R4.x ». */
|
* terrain R4, inchangés. Les autres rôles reçoivent Accueil/OT/Menu (ou une
|
||||||
|
* variante plus étroite — Demandeur, Vue seule — selon leurs droits,
|
||||||
|
* `ongletsVisibles`). Tous les écrans sont toujours déclarés : `href: null`
|
||||||
|
* masque un onglet de la barre sans le retirer du navigateur (le Menu peut
|
||||||
|
* toujours y pousser directement). */
|
||||||
|
|
||||||
function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) {
|
function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) {
|
||||||
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
|
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
|
||||||
@@ -16,10 +23,19 @@ export default function CoquilleTabs() {
|
|||||||
const t = useTokens();
|
const t = useTokens();
|
||||||
const file = useFile();
|
const file = useFile();
|
||||||
const conflits = file.some((s) => s.statut === 'CONFLIT');
|
const conflits = file.some((s) => s.statut === 'CONFLIT');
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const visibles = ongletsVisibles(me?.role.name);
|
||||||
// D1 « lecture locale » : le parc (scan D4) et les référentiels du bilan
|
// D1 « lecture locale » : le parc (scan D4) et les référentiels du bilan
|
||||||
// se préchargent dès l'entrée — le sous-sol n'attend pas qu'on y pense.
|
// se préchargent dès l'entrée — le sous-sol n'attend pas qu'on y pense.
|
||||||
useAssets();
|
// Le Demandeur n'a pas ASSETS.view (seulement /assets/options) : inutile
|
||||||
useReferenceValues();
|
// d'appeler /assets pour lui, l'API répondrait 403.
|
||||||
|
const peutAssets = can('ASSETS', 'view');
|
||||||
|
useAssets({ enabled: peutAssets });
|
||||||
|
useReferenceValues({ enabled: peutAssets });
|
||||||
|
|
||||||
|
const cacher = (nom: string) => (visibles.has(nom) ? undefined : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
@@ -34,6 +50,7 @@ export default function CoquilleTabs() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="journee"
|
name="journee"
|
||||||
options={{
|
options={{
|
||||||
|
href: cacher('journee'),
|
||||||
title: 'Ma journée',
|
title: 'Ma journée',
|
||||||
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
|
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
|
||||||
}}
|
}}
|
||||||
@@ -41,6 +58,7 @@ export default function CoquilleTabs() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="scanner"
|
name="scanner"
|
||||||
options={{
|
options={{
|
||||||
|
href: cacher('scanner'),
|
||||||
title: 'Scanner',
|
title: 'Scanner',
|
||||||
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
|
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
|
||||||
}}
|
}}
|
||||||
@@ -48,6 +66,7 @@ export default function CoquilleTabs() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="preventif"
|
name="preventif"
|
||||||
options={{
|
options={{
|
||||||
|
href: cacher('preventif'),
|
||||||
title: 'Préventif',
|
title: 'Préventif',
|
||||||
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
|
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
|
||||||
}}
|
}}
|
||||||
@@ -55,6 +74,7 @@ export default function CoquilleTabs() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="synchro"
|
name="synchro"
|
||||||
options={{
|
options={{
|
||||||
|
href: cacher('synchro'),
|
||||||
title: 'Synchro',
|
title: 'Synchro',
|
||||||
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
|
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
|
||||||
tabBarBadge: file.length || undefined,
|
tabBarBadge: file.length || undefined,
|
||||||
@@ -66,6 +86,38 @@ export default function CoquilleTabs() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="accueil"
|
||||||
|
options={{
|
||||||
|
href: cacher('accueil'),
|
||||||
|
title: 'Accueil',
|
||||||
|
tabBarIcon: ({ color }) => <Pic glyphe="⌂" couleur={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="ot"
|
||||||
|
options={{
|
||||||
|
href: cacher('ot'),
|
||||||
|
title: 'OT',
|
||||||
|
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="nouvelle-demande"
|
||||||
|
options={{
|
||||||
|
href: cacher('nouvelle-demande'),
|
||||||
|
title: 'Nouvelle demande',
|
||||||
|
tabBarIcon: ({ color }) => <Pic glyphe="+" couleur={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="menu"
|
||||||
|
options={{
|
||||||
|
href: cacher('menu'),
|
||||||
|
title: 'Menu',
|
||||||
|
tabBarIcon: ({ color }) => <Pic glyphe="☷" couleur={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
179
apps/mobile/app/(tabs)/accueil.tsx
Normal file
179
apps/mobile/app/(tabs)/accueil.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useRequests, useWorkOrders } from '@/api/exploitation';
|
||||||
|
import { useMe } from '@/auth/session';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { EnteteTabs } from '@/composants/ui';
|
||||||
|
import { PanneauDemandes } from '@/composants/panneau-demandes';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Accueil — maquette « mobile ouvert à tous les rôles », écran 2 : le
|
||||||
|
* Demandeur y voit directement ses demandes (son seul geste) ; les rôles
|
||||||
|
* de gestion (Administrateur/Gestionnaire/Dispatcher/Vue seule) y trouvent
|
||||||
|
* leurs signaux réels (urgence, OT en cours, demandes à traiter) — aucun
|
||||||
|
* chiffre inventé : seuls WORK_ORDERS/REQUESTS sont déjà câblés sur mobile,
|
||||||
|
* le reste (stock, préventif du mois…) attend son propre écran (Menu). */
|
||||||
|
export default function PageAccueil() {
|
||||||
|
const { data: me } = useMe();
|
||||||
|
if (me?.role.name === 'Demandeur') {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteTabs />
|
||||||
|
<PanneauDemandes />
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <TableauDeBordGestion />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableauDeBordGestion() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const peutOT = can('WORK_ORDERS', 'view');
|
||||||
|
const peutDemandes = can('REQUESTS', 'view');
|
||||||
|
const { data: workOrders } = useWorkOrders();
|
||||||
|
const { data: requests } = useRequests();
|
||||||
|
|
||||||
|
const ots = workOrders ?? [];
|
||||||
|
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED' && o.status !== 'DONE' && o.status !== 'CANCELLED');
|
||||||
|
const enCours = ots.filter((o) => o.status === 'OPEN' || o.status === 'IN_PROGRESS').length;
|
||||||
|
const aTraiter = (requests ?? []).filter((r) => r.status === 'RECEIVED').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
||||||
|
<EnteteTabs />
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
Accueil
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{urgences.length ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/ot/${urgences[0]!.id}`)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.prioBloqueFond,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 9,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
|
||||||
|
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
|
||||||
|
{urgences.length === 1
|
||||||
|
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
|
||||||
|
: `${urgences.length} personnes bloquées`}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{peutOT || peutDemandes ? (
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
{peutOT ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.primaire }}>
|
||||||
|
{enCours}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
|
||||||
|
OT en cours
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{peutDemandes ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
{aTraiter}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
|
||||||
|
Demandes reçues
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{peutDemandes && aTraiter > 0 ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push('/demandes')}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: t.primaireDoux,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 14, color: t.primaire }}>✎</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
|
||||||
|
Demandes en attente
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre2 }}>
|
||||||
|
à approuver ou rejeter
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: t.stAttente,
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
paddingHorizontal: 7,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{aTraiter}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: t.encre3, fontSize: 13 }}>›</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!peutOT && !peutDemandes ? (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', textAlign: 'center', padding: 24 }}>
|
||||||
|
Rien à afficher ici pour votre rôle — voir le Menu.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
type WorkOrderStatus,
|
type WorkOrderStatus,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
import { api, unwrap } from '@/api/client';
|
import { api, unwrap } from '@/api/client';
|
||||||
import { useHorsLigne, useLogout, useMe } from '@/auth/session';
|
import { useHorsLigne } from '@/auth/session';
|
||||||
|
import { EnteteTabs } from '@/composants/ui';
|
||||||
import { triJournee } from '@/lib/journee';
|
import { triJournee } from '@/lib/journee';
|
||||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
@@ -43,9 +44,7 @@ const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
|
|||||||
export default function PageJournee() {
|
export default function PageJournee() {
|
||||||
const t = useTokens();
|
const t = useTokens();
|
||||||
const horsLigne = useHorsLigne();
|
const horsLigne = useHorsLigne();
|
||||||
const { data: me } = useMe();
|
|
||||||
const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
|
const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
|
||||||
const logout = useLogout();
|
|
||||||
|
|
||||||
const ots = triJournee(workOrders ?? []);
|
const ots = triJournee(workOrders ?? []);
|
||||||
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
|
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
|
||||||
@@ -55,57 +54,7 @@ export default function PageJournee() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
||||||
{/* Entête app : marque + pastille synchro + compte */}
|
<EnteteTabs />
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
||||||
<Text style={{ color: t.safran, fontSize: 15 }}>♦</Text>
|
|
||||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
|
|
||||||
SIOP
|
|
||||||
</Text>
|
|
||||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre3 }}>
|
|
||||||
Technicien
|
|
||||||
</Text>
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginLeft: 'auto' }}>
|
|
||||||
<Text
|
|
||||||
accessibilityLabel="État de synchronisation"
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Manrope_700Bold',
|
|
||||||
fontSize: 10.5,
|
|
||||||
color: horsLigne ? t.stAttente : t.stTermine,
|
|
||||||
backgroundColor: horsLigne ? t.stAttenteFond : t.stTermineFond,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 2,
|
|
||||||
borderRadius: 999,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{horsLigne ? 'Hors-ligne' : 'Synchro à jour'}
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Se déconnecter"
|
|
||||||
onLongPress={() => {
|
|
||||||
void logout().then(() => router.replace('/connexion'));
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: 26,
|
|
||||||
height: 26,
|
|
||||||
borderRadius: 13,
|
|
||||||
backgroundColor: t.stEncours,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 10 }}>
|
|
||||||
{(me?.displayName ?? '·')
|
|
||||||
.split(' ')
|
|
||||||
.map((m) => m[0])
|
|
||||||
.join('')
|
|
||||||
.slice(0, 2)
|
|
||||||
.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
|
||||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
|||||||
141
apps/mobile/app/(tabs)/menu.tsx
Normal file
141
apps/mobile/app/(tabs)/menu.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import type { ObjectCategory, PermissionRight } from '@siop/shared';
|
||||||
|
import { useMe } from '@/auth/session';
|
||||||
|
import { estRoleTerrain } from '@/auth/roles';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { EnteteTabs } from '@/composants/ui';
|
||||||
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Menu — maquette « mobile ouvert à tous les rôles », écran 3 : reprend à
|
||||||
|
* l'identique les 4 groupes et l'ordre de la sidebar web (Exploitation /
|
||||||
|
* Parc / Ressources / Pilotage, coquille.tsx), filtrés par LA MÊME matrice
|
||||||
|
* (D2 — aucune règle de droit nouvelle). Un groupe sans aucun lien visible
|
||||||
|
* est masqué en entier (D3) : l'écran est petit, un intitulé vide n'aide
|
||||||
|
* personne. Ce que « Ma journée »/« Préventif » couvrent déjà pour le
|
||||||
|
* Technicien n'est pas dupliqué ici. */
|
||||||
|
|
||||||
|
interface LienMenu {
|
||||||
|
libelle: string;
|
||||||
|
permission: [ObjectCategory, PermissionRight];
|
||||||
|
ico: string;
|
||||||
|
cacheEnTerrain?: boolean; // déjà couvert par un onglet terrain dédié
|
||||||
|
route?: string;
|
||||||
|
aVenir?: string; // detail affiché sur l'écran « à venir »
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUPES: { titre: string; liens: LienMenu[] }[] = [
|
||||||
|
{
|
||||||
|
titre: 'Exploitation',
|
||||||
|
liens: [
|
||||||
|
{ libelle: 'Ordres de travail', ico: '☰', permission: ['WORK_ORDERS', 'view'], cacheEnTerrain: true, route: '/(tabs)/ot' },
|
||||||
|
{ libelle: 'Demandes', ico: '✎', permission: ['REQUESTS', 'view'], route: '/demandes' },
|
||||||
|
{ libelle: 'Préventif', ico: '✓', permission: ['WORK_ORDERS', 'view'], cacheEnTerrain: true, aVenir: 'Les grilles préventives pour les rôles de gestion arrivent dans une prochaine étape — le Technicien les a déjà dans son onglet dédié.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: 'Parc',
|
||||||
|
liens: [
|
||||||
|
{ libelle: 'Ascenseurs', ico: '▣', permission: ['ASSETS', 'view'], route: '/ascenseurs' },
|
||||||
|
{ libelle: 'Sites', ico: '◫', permission: ['LOCATIONS', 'view'], route: '/sites' },
|
||||||
|
{ libelle: 'Catégories', ico: '▤', permission: ['SETTINGS', 'view'], aVenir: 'L’administration des catégories reste sur le grand écran pour l’instant.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: 'Ressources',
|
||||||
|
liens: [
|
||||||
|
{ libelle: 'Stock & achats', ico: '◔', permission: ['PARTS', 'view'], route: '/stock' },
|
||||||
|
{ libelle: 'Tiers', ico: '⇄', permission: ['PURCHASE_ORDERS', 'view'], route: '/tiers' },
|
||||||
|
{ libelle: 'Fichiers', ico: '▧', permission: ['ASSETS', 'view'], route: '/bibliotheque' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: 'Pilotage',
|
||||||
|
liens: [
|
||||||
|
{ libelle: 'Statistiques', ico: '◈', permission: ['ANALYTICS', 'view'], route: '/statistiques' },
|
||||||
|
{ libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], route: '/assistant' },
|
||||||
|
{ libelle: 'Personnes & équipes', ico: '◎', permission: ['PEOPLE_TEAMS', 'view'], route: '/personnes' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PageMenu() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const terrain = estRoleTerrain(me?.role.name);
|
||||||
|
|
||||||
|
const groupesVisibles = GROUPES.map((g) => ({
|
||||||
|
...g,
|
||||||
|
liens: g.liens.filter(
|
||||||
|
({ permission, cacheEnTerrain }) => can(...permission) && !(cacheEnTerrain && terrain),
|
||||||
|
),
|
||||||
|
})).filter((g) => g.liens.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
||||||
|
<EnteteTabs />
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>Menu</Text>
|
||||||
|
<ScrollView contentContainerStyle={{ gap: 14, paddingBottom: 16 }} showsVerticalScrollIndicator={false}>
|
||||||
|
{groupesVisibles.length === 0 ? (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', textAlign: 'center', padding: 24 }}>
|
||||||
|
Rien d'autre accessible à votre rôle ici.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{groupesVisibles.map((g) => (
|
||||||
|
<View key={g.titre} style={{ gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.titre}
|
||||||
|
</Text>
|
||||||
|
{g.liens.map((lien) => (
|
||||||
|
<LigneMenu key={lien.libelle} lien={lien} t={t} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LigneMenu({ lien, t }: { lien: LienMenu; t: Tokens }) {
|
||||||
|
const aller = () => {
|
||||||
|
if (lien.route) {
|
||||||
|
router.push(lien.route as never);
|
||||||
|
} else {
|
||||||
|
router.push({ pathname: '/a-venir', params: { titre: lien.libelle, detail: lien.aVenir ?? '' } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={aller}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ width: 22, textAlign: 'center', fontSize: 14, color: t.primaire }}>{lien.ico}</Text>
|
||||||
|
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
|
||||||
|
{lien.libelle}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: t.encre3, fontSize: 13 }}>›</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/mobile/app/(tabs)/nouvelle-demande.tsx
Normal file
24
apps/mobile/app/(tabs)/nouvelle-demande.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { ScrollView, Text } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { FormulaireDemande } from '@/composants/formulaire-demande';
|
||||||
|
import { EnteteTabs } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Onglet du Demandeur (maquette « mobile ouvert à tous les rôles »,
|
||||||
|
* écran 5) : le geste qu'il fait le plus mérite son propre onglet plutôt
|
||||||
|
* qu'un bouton caché dans l'Accueil. */
|
||||||
|
export default function PageNouvelleDemande() {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteTabs />
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
Nouvelle demande
|
||||||
|
</Text>
|
||||||
|
<FormulaireDemande surSucces={() => router.replace('/(tabs)/accueil')} />
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
apps/mobile/app/(tabs)/ot.tsx
Normal file
154
apps/mobile/app/(tabs)/ot.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, RefreshControl, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import {
|
||||||
|
WORK_ORDER_PRIORITY_LABELS,
|
||||||
|
WORK_ORDER_STATUS_LABELS,
|
||||||
|
WORK_ORDER_TYPE_LABELS,
|
||||||
|
type WorkOrderPriority,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { useWorkOrders } from '@/api/exploitation';
|
||||||
|
import { EnteteTabs } from '@/composants/ui';
|
||||||
|
import { triJournee } from '@/lib/journee';
|
||||||
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Ordres de travail — maquette « mobile ouvert à tous les rôles », écran 4 :
|
||||||
|
* à la différence de « Ma journée » (Technicien, ses OT uniquement),
|
||||||
|
* Administrateur/Gestionnaire/Dispatcher ont `viewOther` — l'API renvoie
|
||||||
|
* déjà TOUS les OT (ADR-003, rien à filtrer ici). Même tri priorité puis
|
||||||
|
* échéance que « Ma journée » ; ouvrir un OT réutilise la fiche R4 telle
|
||||||
|
* quelle (elle affiche déjà les actions permises via `allowedTransitions`). */
|
||||||
|
|
||||||
|
const STYLE_STATUT: Record<WorkOrderStatus, (t: Tokens) => [string, string]> = {
|
||||||
|
OPEN: (t) => [t.stOuvert, t.stOuvertFond],
|
||||||
|
IN_PROGRESS: (t) => [t.stEncours, t.stEncoursFond],
|
||||||
|
ON_HOLD: (t) => [t.stAttente, t.stAttenteFond],
|
||||||
|
DONE: (t) => [t.stTermine, t.stTermineFond],
|
||||||
|
CANCELLED: (t) => [t.stAnnule, t.stAnnuleFond],
|
||||||
|
};
|
||||||
|
const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
|
||||||
|
PERSON_TRAPPED: (t) => t.prioBloque,
|
||||||
|
HIGH: (t) => t.prioHaute,
|
||||||
|
MEDIUM: (t) => t.prioMoyenne,
|
||||||
|
LOW: (t) => t.prioBasse,
|
||||||
|
NONE: () => 'transparent',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PageOT() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: workOrders, refetch, isFetching } = useWorkOrders();
|
||||||
|
const ots = triJournee(workOrders ?? []);
|
||||||
|
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
||||||
|
<EnteteTabs />
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
Ordres de travail
|
||||||
|
</Text>
|
||||||
|
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
|
||||||
|
{ots.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{urgences.length ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.prioBloqueFond,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 9,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
|
||||||
|
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
|
||||||
|
{urgences.length === 1
|
||||||
|
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
|
||||||
|
: `${urgences.length} personnes bloquées`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={ots}
|
||||||
|
keyExtractor={(o) => o.id}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={isFetching} onRefresh={() => void refetch()} tintColor={t.primaire} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Aucun OT — tirez pour rafraîchir.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: o }) => {
|
||||||
|
const [enc, fond] = STYLE_STATUT[o.status](t);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${o.reference} — ${o.title}`}
|
||||||
|
onPress={() => router.push(`/ot/${o.id}`)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderLeftColor: STRIE_PRIORITE[o.priority](t),
|
||||||
|
padding: 11,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre2,
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o.reference} · {WORK_ORDER_TYPE_LABELS[o.type]}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: enc,
|
||||||
|
backgroundColor: fond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WORK_ORDER_STATUS_LABELS[o.status]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||||
|
{o.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||||
|
Asc. {o.assetReference} — {o.siteName}
|
||||||
|
{o.dueDate
|
||||||
|
? ` · échéance ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(o.dueDate))}`
|
||||||
|
: ''}
|
||||||
|
{o.priority !== 'NONE' && o.priority !== 'PERSON_TRAPPED'
|
||||||
|
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { router } from 'expo-router';
|
|||||||
import { FlatList, Pressable, Text, View } from 'react-native';
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useWorkOrders } from '@/api/exploitation';
|
import { useWorkOrders } from '@/api/exploitation';
|
||||||
import { ChipStatut } from '@/composants/ui';
|
import { ChipStatut, EnteteTabs } from '@/composants/ui';
|
||||||
import { useTokens } from '@/theme/tokens';
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
/** Onglet Préventif : mes grilles du moment — chaque tuile mène à la
|
/** Onglet Préventif : mes grilles du moment — chaque tuile mène à la
|
||||||
@@ -18,6 +18,7 @@ export default function PagePreventif() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteTabs />
|
||||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
Préventif
|
Préventif
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Platform, Text, TextInput, View } from 'react-native';
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useAssets } from '@/api/exploitation';
|
import { useAssets } from '@/api/exploitation';
|
||||||
import { useHorsLigne } from '@/auth/session';
|
import { useHorsLigne } from '@/auth/session';
|
||||||
import { BoutonTel } from '@/composants/ui';
|
import { BoutonTel, EnteteTabs } from '@/composants/ui';
|
||||||
import { analyseScan } from '@/lib/scan';
|
import { analyseScan } from '@/lib/scan';
|
||||||
import { useTokens } from '@/theme/tokens';
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
@@ -59,6 +59,7 @@ export default function PageScanner() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteTabs />
|
||||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
Scanner
|
Scanner
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { router } from 'expo-router';
|
|||||||
import { FlatList, Pressable, Text, View } from 'react-native';
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useHorsLigne } from '@/auth/session';
|
import { useHorsLigne } from '@/auth/session';
|
||||||
import { BoutonTel } from '@/composants/ui';
|
import { BoutonTel, EnteteTabs } from '@/composants/ui';
|
||||||
import { useFile, type Saisie } from '@/file/store';
|
import { useFile, type Saisie } from '@/file/store';
|
||||||
import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro';
|
import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro';
|
||||||
import { useTokens } from '@/theme/tokens';
|
import { useTokens } from '@/theme/tokens';
|
||||||
@@ -33,6 +33,7 @@ export default function PageSynchro() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteTabs />
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
||||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
Synchro
|
Synchro
|
||||||
|
|||||||
21
apps/mobile/app/a-venir.tsx
Normal file
21
apps/mobile/app/a-venir.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { AVenir } from '@/composants/a-venir';
|
||||||
|
import { EnteteFiche } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Destination générique du Menu pour les familles pas encore portées sur
|
||||||
|
* mobile (Sites, Stock, Tiers, Personnes, Statistiques, Assistant…) — même
|
||||||
|
* patron que la sidebar web (release annoncée, jamais un cul-de-sac
|
||||||
|
* silencieux). `AVenir` existait déjà (a-venir.tsx) mais n'était encore
|
||||||
|
* câblé nulle part. */
|
||||||
|
export default function PageAVenir() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { titre, detail } = useLocalSearchParams<{ titre: string; detail: string }>();
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<EnteteFiche titre={titre ?? '—'} />
|
||||||
|
<AVenir titre={titre ?? '—'} release="une prochaine étape" detail={detail ?? ''} />
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/mobile/app/ascenseurs/index.tsx
Normal file
90
apps/mobile/app/ascenseurs/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { ASSET_STATUS_LABELS, type AssetStatus } from '@siop/shared';
|
||||||
|
import { useAssets } from '@/api/exploitation';
|
||||||
|
import { EnteteFiche } from '@/composants/ui';
|
||||||
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Ascenseurs — R6.2 (Parc). Le parc complet, déjà préchargé (D1 lecture
|
||||||
|
* locale) — même donnée que le Scanner, présentée en liste plutôt qu'en
|
||||||
|
* résolution QR. Ouvre la fiche R4 telle quelle (écran 5). */
|
||||||
|
export default function PageAscenseurs() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: assets } = useAssets();
|
||||||
|
const appareils = [...(assets ?? [])].sort((a, b) => a.reference.localeCompare(b.reference));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre="Ascenseurs" />
|
||||||
|
<FlatList
|
||||||
|
data={appareils}
|
||||||
|
keyExtractor={(a) => a.id}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Aucun appareil accessible.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: a }) => (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/ascenseur/${a.id}`)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre2,
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a.reference}
|
||||||
|
</Text>
|
||||||
|
<ChipStatutAppareil statut={a.status} t={t} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||||
|
{a.brand} {a.model ?? ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||||
|
{a.siteName} — {a.locationName}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChipStatutAppareil({ statut, t }: { statut: AssetStatus; t: Tokens }) {
|
||||||
|
const enService = statut === 'IN_SERVICE';
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: enService ? t.stTermine : t.stAttente,
|
||||||
|
backgroundColor: enService ? t.stTermineFond : t.stAttenteFond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ASSET_STATUS_LABELS[statut]}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
380
apps/mobile/app/assistant/index.tsx
Normal file
380
apps/mobile/app/assistant/index.tsx
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
// SDK 57 : les fonctions module-level (dont deleteAsync) sont dépréciées au
|
||||||
|
// profit des classes File/Directory et lèvent désormais en développement —
|
||||||
|
// import explicite du sous-chemin legacy, comportement inchangé sinon.
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
||||||
|
import {
|
||||||
|
RecordingPresets,
|
||||||
|
requestRecordingPermissionsAsync,
|
||||||
|
setAudioModeAsync,
|
||||||
|
useAudioRecorder,
|
||||||
|
} from 'expo-audio';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import type { AssistantAnswer, AssistantExcerpt } from '@siop/shared';
|
||||||
|
import { useAskAssistant } from '@/api/assistant';
|
||||||
|
import { useTranscription } from '@/api/exploitation';
|
||||||
|
import { BoutonTel, EnteteFiche } from '@/composants/ui';
|
||||||
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Assistant — maquette « Assistant mobile » (validée 02/08) : même contenu
|
||||||
|
* et mêmes règles que le web (D2 « sourcé ou silencieux », ADR-004 §4 —
|
||||||
|
* le service IA n'est jamais appelé directement, toujours via l'API).
|
||||||
|
* D5 : la question peut être tapée OU dictée — même pipeline que la
|
||||||
|
* dictée déjà livrée en clôture (ADR-004 §5), la transcription REMPLIT le
|
||||||
|
* champ (éditable), aucun envoi automatique. */
|
||||||
|
|
||||||
|
interface Echange {
|
||||||
|
question: string;
|
||||||
|
reponse?: AssistantAnswer;
|
||||||
|
erreur?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageAssistant() {
|
||||||
|
const t = useTokens();
|
||||||
|
const ask = useAskAssistant();
|
||||||
|
const transcrire = useTranscription();
|
||||||
|
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||||
|
const [question, setQuestion] = useState('');
|
||||||
|
const [echanges, setEchanges] = useState<Echange[]>([]);
|
||||||
|
const [enregistrement, setEnregistrement] = useState(false);
|
||||||
|
const saisieRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
const demarrerDictee = async () => {
|
||||||
|
const { granted } = await requestRecordingPermissionsAsync();
|
||||||
|
if (!granted) return;
|
||||||
|
// iOS refuse recorder.record() sans autorisation explicite de la session
|
||||||
|
// audio (RecordingDisabledException) — même correctif qu'en clôture.
|
||||||
|
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
|
||||||
|
await recorder.prepareToRecordAsync();
|
||||||
|
recorder.record();
|
||||||
|
setEnregistrement(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const terminerDictee = async () => {
|
||||||
|
await recorder.stop();
|
||||||
|
await setAudioModeAsync({ allowsRecording: false });
|
||||||
|
setEnregistrement(false);
|
||||||
|
const uri = recorder.uri;
|
||||||
|
if (!uri) return;
|
||||||
|
transcrire.mutate(
|
||||||
|
{ uri, nom: 'question.m4a', mime: 'audio/m4a' },
|
||||||
|
{
|
||||||
|
onSuccess: (resultat) => setQuestion(resultat.text),
|
||||||
|
// Loi 09-08 (D5) : l'audio ne survit jamais à l'appel, succès ou pas.
|
||||||
|
onSettled: () => void FileSystem.deleteAsync(uri, { idempotent: true }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const envoyer = () => {
|
||||||
|
const q = question.trim();
|
||||||
|
if (q.length < 3 || ask.isPending) return;
|
||||||
|
setQuestion('');
|
||||||
|
setEchanges((liste) => [...liste, { question: q }]);
|
||||||
|
ask.mutate(
|
||||||
|
{ question: q },
|
||||||
|
{
|
||||||
|
onSuccess: (reponse) =>
|
||||||
|
setEchanges((liste) =>
|
||||||
|
liste.map((e, i) => (i === liste.length - 1 ? { ...e, reponse } : e)),
|
||||||
|
),
|
||||||
|
onError: (erreur) =>
|
||||||
|
setEchanges((liste) =>
|
||||||
|
liste.map((e, i) => (i === liste.length - 1 ? { ...e, erreur: erreur.message } : e)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre="Assistant" />
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 10, paddingBottom: 8 }}>
|
||||||
|
{echanges.length === 0 ? (
|
||||||
|
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Posez une question sur vos notices, vos historiques d'intervention ou vos
|
||||||
|
procédures — chaque réponse cite ses sources. Quand le corpus ne porte pas la
|
||||||
|
réponse, l'assistant le dit au lieu d'inventer.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{echanges.map((e, i) => (
|
||||||
|
<View key={i} style={{ gap: 8 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
backgroundColor: t.primaire,
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: 13,
|
||||||
|
borderBottomRightRadius: 3,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 9,
|
||||||
|
maxWidth: '85%',
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{e.question}
|
||||||
|
</Text>
|
||||||
|
{e.reponse ? (
|
||||||
|
<Reponse reponse={e.reponse} t={t} surReformuler={() => saisieRef.current?.focus()} />
|
||||||
|
) : e.erreur ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||||
|
{e.erreur}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Recherche dans le corpus…
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{enregistrement ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderRadius: 13,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: t.prioBloque,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 18 }}>🎙</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
|
||||||
|
Enregistrement en cours…
|
||||||
|
</Text>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="■ Terminer la dictée"
|
||||||
|
surAppui={() => void terminerDictee()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
gap: 8,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
padding: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Zone pleine largeur, multiligne : la question dictée doit se
|
||||||
|
* lire EN ENTIER avant d'envoyer (trouvé en recette, 02/08 —
|
||||||
|
* une barre d'une ligne masquait le texte transcrit long). */}
|
||||||
|
<TextInput
|
||||||
|
ref={saisieRef}
|
||||||
|
accessibilityLabel="Poser une question"
|
||||||
|
multiline
|
||||||
|
scrollEnabled
|
||||||
|
value={transcrire.isPending ? 'Transcription…' : question}
|
||||||
|
editable={!transcrire.isPending}
|
||||||
|
onChangeText={setQuestion}
|
||||||
|
maxLength={500}
|
||||||
|
placeholder="Poser une question, ou dictez avec 🎙…"
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
style={{
|
||||||
|
// Hauteur FIXE plutôt que maxHeight seul : sur iOS, un
|
||||||
|
// TextInput multiligne ignore parfois maxHeight et grandit
|
||||||
|
// avec le contenu, poussant/recouvrant les boutons du
|
||||||
|
// dessous (trouvé en recette, 02/08). Hauteur fixe +
|
||||||
|
// scrollEnabled = défilement interne garanti au-delà.
|
||||||
|
height: 96,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13.5,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Dicter la question"
|
||||||
|
disabled={transcrire.isPending}
|
||||||
|
onPress={() => void demarrerDictee()}
|
||||||
|
style={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 15,
|
||||||
|
backgroundColor: t.primaireDoux,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 14 }}>🎙</Text>
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Envoyer"
|
||||||
|
desactive={question.trim().length < 3 || ask.isPending || transcrire.isPending}
|
||||||
|
surAppui={envoyer}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{transcrire.isError ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 11.5 }}>
|
||||||
|
{transcrire.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Reponse({
|
||||||
|
reponse,
|
||||||
|
t,
|
||||||
|
surReformuler,
|
||||||
|
}: {
|
||||||
|
reponse: AssistantAnswer;
|
||||||
|
t: Tokens;
|
||||||
|
surReformuler: () => void;
|
||||||
|
}) {
|
||||||
|
if (reponse.mode === 'REFUSAL') {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderRadius: 13,
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 12.5, color: t.encre }}>
|
||||||
|
Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer.
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre2 }}>
|
||||||
|
J'ai cherché dans {reponse.corpus.documents} document
|
||||||
|
{reponse.corpus.documents > 1 ? 's' : ''} indexé
|
||||||
|
{reponse.corpus.documents > 1 ? 's' : ''} et {reponse.corpus.reports} bilan
|
||||||
|
{reponse.corpus.reports > 1 ? 's' : ''} d'intervention : rien d'assez proche de votre
|
||||||
|
question.
|
||||||
|
</Text>
|
||||||
|
<BoutonTel libelle="Reformuler ma question" variante="contour" surAppui={surReformuler} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 13,
|
||||||
|
borderBottomLeftRadius: 3,
|
||||||
|
padding: 11,
|
||||||
|
gap: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reponse.mode === 'GENERATED' && reponse.answer ? (
|
||||||
|
<Text style={{ fontFamily: 'Manrope_500Medium', fontSize: 12.5, color: t.encre }}>
|
||||||
|
{reponse.answer}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={{ fontFamily: 'Manrope_500Medium', fontSize: 12.5, color: t.encre }}>
|
||||||
|
Voici ce que portent vos sources — les extraits sont cités tels quels.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{ gap: 7, borderTopWidth: 1, borderTopColor: t.bordure, paddingTop: 9 }}
|
||||||
|
>
|
||||||
|
{reponse.excerpts.map((ex, i) => (
|
||||||
|
<Source key={`${ex.locator}-${i}`} ex={ex} no={i + 1} t={t} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 6,
|
||||||
|
backgroundColor: t.safranDoux,
|
||||||
|
borderRadius: 9,
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: t.alerte, fontSize: 10.5, fontFamily: 'Manrope_600SemiBold', flex: 1 }}>
|
||||||
|
⚠ L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Source({ ex, no, t }: { ex: AssistantExcerpt; no: number; t: Tokens }) {
|
||||||
|
const ouvrir = () => {
|
||||||
|
if (ex.sourceType === 'DOCUMENT' && ex.documentId) router.push(`/bibliotheque/${ex.documentId}`);
|
||||||
|
else if (ex.workOrderId) router.push(`/ot/${ex.workOrderId}`);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={ouvrir}
|
||||||
|
style={{ backgroundColor: t.surface2, borderRadius: 9, padding: 9, gap: 3 }}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 7 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 5,
|
||||||
|
backgroundColor: t.primaireDoux,
|
||||||
|
color: t.primaire,
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{no}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 11.5, color: t.encre }}
|
||||||
|
>
|
||||||
|
{ex.title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 10.5, color: t.encre2, marginLeft: 23 }}>
|
||||||
|
{ex.sourceType === 'DOCUMENT' ? 'Bibliothèque' : 'Historique'} · {ex.locator}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_500Medium',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontSize: 11,
|
||||||
|
color: t.encre2,
|
||||||
|
marginLeft: 23,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: t.safran,
|
||||||
|
paddingLeft: 7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
« {ex.content} »
|
||||||
|
</Text>
|
||||||
|
<Text style={{ marginLeft: 23, color: t.primaire, fontFamily: 'Manrope_700Bold', fontSize: 11 }}>
|
||||||
|
{ex.sourceType === 'DOCUMENT' ? 'Voir le document' : "Ouvrir l'OT"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/mobile/app/bibliotheque/[id].tsx
Normal file
52
apps/mobile/app/bibliotheque/[id].tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { ScrollView, Text } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useDocuments } from '@/api/ressources';
|
||||||
|
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
NOTICE: 'Notice',
|
||||||
|
CERTIFICATE: 'Certificat',
|
||||||
|
PHOTO: 'Photo',
|
||||||
|
OTHER: 'Autre',
|
||||||
|
};
|
||||||
|
|
||||||
|
function taille(octets: number): string {
|
||||||
|
if (octets < 1024) return `${octets} o`;
|
||||||
|
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
|
||||||
|
return `${(octets / (1024 * 1024)).toFixed(1)} Mo`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fiche document — R6.5. Métadonnées seules (D2, comme la bibliothèque
|
||||||
|
* R6.3) : l'ouverture du fichier lui-même demande `expo-sharing`, pas
|
||||||
|
* encore une dépendance du projet — ouvrir depuis le web pour l'instant.
|
||||||
|
* Destination de « Voir le document » depuis les sources de l'Assistant. */
|
||||||
|
export default function PageFicheDocument() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: documents } = useDocuments();
|
||||||
|
const doc = (documents ?? []).find((d) => d.id === id);
|
||||||
|
if (!doc) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre={doc.fileName} />
|
||||||
|
<Carte titre="Document">
|
||||||
|
<LigneInfo nom="Type" valeur={KIND_LABEL[doc.kind] ?? doc.kind} />
|
||||||
|
<LigneInfo nom="Taille" valeur={taille(doc.size)} />
|
||||||
|
<LigneInfo nom="Rattaché à" valeur={doc.assetReference ?? doc.workOrderReference ?? '—'} />
|
||||||
|
<LigneInfo nom="Ajouté par" valeur={doc.uploadedByName ?? '—'} />
|
||||||
|
<LigneInfo
|
||||||
|
nom="Ajouté le"
|
||||||
|
valeur={new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(doc.createdAt))}
|
||||||
|
/>
|
||||||
|
</Carte>
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5, textAlign: 'center' }}>
|
||||||
|
Ouvrir le fichier se fait depuis le web pour l'instant.
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/mobile/app/bibliotheque/index.tsx
Normal file
83
apps/mobile/app/bibliotheque/index.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useDocuments } from '@/api/ressources';
|
||||||
|
import { EnteteFiche } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
const ICONE_TYPE: Record<string, string> = {
|
||||||
|
NOTICE: '📘',
|
||||||
|
CERTIFICATE: '📜',
|
||||||
|
PHOTO: '🖼',
|
||||||
|
OTHER: '📄',
|
||||||
|
};
|
||||||
|
|
||||||
|
function taille(octets: number): string {
|
||||||
|
if (octets < 1024) return `${octets} o`;
|
||||||
|
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
|
||||||
|
return `${(octets / (1024 * 1024)).toFixed(1)} Mo`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fichiers — R6.3 (Ressources). Consultation des métadonnées seulement
|
||||||
|
* (D4) : l'ouverture/téléchargement d'un document sur mobile demande un
|
||||||
|
* flux authentifié (l'API le streame, MinIO n'est jamais exposé) suivi
|
||||||
|
* d'un partage natif — nécessite `expo-sharing`, pas encore une dépendance
|
||||||
|
* du projet ; ajoutée dans une prochaine étape plutôt que d'introduire une
|
||||||
|
* nouvelle dépendance native (et donc un nouveau build) dans cette passe.
|
||||||
|
* En attendant : ouvrir depuis le web. */
|
||||||
|
export default function PageBibliotheque() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: documents } = useDocuments();
|
||||||
|
const fichiers = [...(documents ?? [])].sort(
|
||||||
|
(a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre="Fichiers" />
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 11.5, color: t.encre3 }}>
|
||||||
|
Consultation seulement — l'ouverture se fait depuis le web pour l'instant.
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
data={fichiers}
|
||||||
|
keyExtractor={(d) => d.id}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Aucun document accessible.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: d }) => (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/bibliotheque/${d.id}`)}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 20 }}>{ICONE_TYPE[d.kind] ?? '📄'}</Text>
|
||||||
|
<View style={{ flex: 1, gap: 1 }}>
|
||||||
|
<Text numberOfLines={1} style={{ fontFamily: 'Manrope_700Bold', fontSize: 13, color: t.encre }}>
|
||||||
|
{d.fileName}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre2 }}>
|
||||||
|
{[d.assetReference && `Asc. ${d.assetReference}`, d.workOrderReference, taille(d.size)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,12 +9,18 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import type { RoleName } from '@siop/shared';
|
||||||
|
import { ongletAccueil } from '@/auth/roles';
|
||||||
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session';
|
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session';
|
||||||
import { useTokens } from '@/theme/tokens';
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
/** Connexion mobile — mêmes règles que le web : formulaire e-mail/mot de
|
/** Connexion mobile — mêmes règles que le web : formulaire e-mail/mot de
|
||||||
* passe, et le sélecteur démo (< 3 s pour changer de rôle, ADR-002)
|
* passe, et le sélecteur démo (< 3 s pour changer de rôle, ADR-002)
|
||||||
* UNIQUEMENT si l'API l'expose. */
|
* UNIQUEMENT si l'API l'expose. Depuis R6, l'app n'est plus réservée au
|
||||||
|
* Technicien : on route vers l'onglet d'accueil du RÔLE réel (« Ma
|
||||||
|
* journée » pour le terrain, « Accueil » sinon) — jamais un onglet figé
|
||||||
|
* (trouvé en recette : un Demandeur atterrissait sur « Ma journée »,
|
||||||
|
* l'écran du Technicien, faute de lire le rôle retourné par la connexion). */
|
||||||
export default function PageConnexion() {
|
export default function PageConnexion() {
|
||||||
const t = useTokens();
|
const t = useTokens();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -23,7 +29,7 @@ export default function PageConnexion() {
|
|||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const demo = useDemoLogin();
|
const demo = useDemoLogin();
|
||||||
|
|
||||||
const entrer = () => router.replace('/(tabs)/journee');
|
const entrer = (role: RoleName) => router.replace(`/(tabs)/${ongletAccueil(role)}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@@ -36,12 +42,9 @@ export default function PageConnexion() {
|
|||||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}>
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}>
|
||||||
SIOP
|
SIOP
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre3 }}>
|
|
||||||
Technicien
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={{ fontFamily: 'Manrope_400Regular', color: t.encre2, marginBottom: 8 }}>
|
<Text style={{ fontFamily: 'Manrope_400Regular', color: t.encre2, marginBottom: 8 }}>
|
||||||
Vos ordres de travail, sur le terrain — même sans réseau.
|
Vos interventions et votre suivi, où que vous soyez.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={{ gap: 10 }}>
|
<View style={{ gap: 10 }}>
|
||||||
@@ -84,7 +87,10 @@ export default function PageConnexion() {
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
disabled={!email || !motDePasse || login.isPending}
|
disabled={!email || !motDePasse || login.isPending}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
login.mutate({ email, password: motDePasse }, { onSuccess: entrer })
|
login.mutate(
|
||||||
|
{ email, password: motDePasse },
|
||||||
|
{ onSuccess: (res) => entrer(res.user.role.name) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire,
|
backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire,
|
||||||
@@ -122,7 +128,9 @@ export default function PageConnexion() {
|
|||||||
key={c.id}
|
key={c.id}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
disabled={demo.isPending}
|
disabled={demo.isPending}
|
||||||
onPress={() => demo.mutate(c.id, { onSuccess: entrer })}
|
onPress={() =>
|
||||||
|
demo.mutate(c.id, { onSuccess: (res) => entrer(res.user.role.name) })
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: t.surface,
|
backgroundColor: t.surface,
|
||||||
borderColor: t.bordure,
|
borderColor: t.bordure,
|
||||||
|
|||||||
19
apps/mobile/app/demandes/index.tsx
Normal file
19
apps/mobile/app/demandes/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { PanneauDemandes } from '@/composants/panneau-demandes';
|
||||||
|
import { EnteteFiche } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran poussé depuis le Menu (Administrateur/Gestionnaire/Dispatcher/Vue
|
||||||
|
* seule — pas d'onglet dédié pour eux, contrairement au Demandeur). */
|
||||||
|
export default function PageDemandesPoussee() {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre="Demandes" />
|
||||||
|
<PanneauDemandes />
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/mobile/app/demandes/nouvelle.tsx
Normal file
18
apps/mobile/app/demandes/nouvelle.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { ScrollView } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { FormulaireDemande } from '@/composants/formulaire-demande';
|
||||||
|
import { EnteteFiche } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
export default function PageNouvelleDemandePoussee() {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre="Nouvelle demande" />
|
||||||
|
<FormulaireDemande surSucces={() => (router.canGoBack() ? router.back() : router.replace('/demandes'))} />
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Redirect } from 'expo-router';
|
import { Redirect } from 'expo-router';
|
||||||
import { ActivityIndicator, View } from 'react-native';
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
|
import { ongletAccueil } from '@/auth/roles';
|
||||||
import { useMe } from '@/auth/session';
|
import { useMe } from '@/auth/session';
|
||||||
import { useTokens } from '@/theme/tokens';
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
/** Aiguillage : session valide → Ma journée ; sinon → connexion.
|
/** Aiguillage : session valide → l'onglet d'accueil du rôle (Ma journée
|
||||||
|
* pour le terrain, Accueil pour les autres) ; sinon → connexion.
|
||||||
* (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */
|
* (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */
|
||||||
export default function Aiguillage() {
|
export default function Aiguillage() {
|
||||||
const t = useTokens();
|
const t = useTokens();
|
||||||
@@ -16,5 +18,9 @@ export default function Aiguillage() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return me ? <Redirect href="/(tabs)/journee" /> : <Redirect href="/connexion" />;
|
return me ? (
|
||||||
|
<Redirect href={`/(tabs)/${ongletAccueil(me.role.name)}`} />
|
||||||
|
) : (
|
||||||
|
<Redirect href="/connexion" />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
|
// SDK 57 : les fonctions module-level (dont deleteAsync) sont dépréciées au
|
||||||
|
// profit des classes File/Directory et lèvent désormais en développement —
|
||||||
|
// import explicite du sous-chemin legacy, comportement inchangé sinon.
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ScrollView, Text, View } from 'react-native';
|
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
||||||
|
import {
|
||||||
|
RecordingPresets,
|
||||||
|
requestRecordingPermissionsAsync,
|
||||||
|
setAudioModeAsync,
|
||||||
|
useAudioRecorder,
|
||||||
|
} from 'expo-audio';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import {
|
import {
|
||||||
BILAN_FIELD_LABELS,
|
BILAN_FIELD_LABELS,
|
||||||
BILAN_FIELDS,
|
BILAN_FIELDS,
|
||||||
REQUIRED_BILAN_FIELDS,
|
REQUIRED_BILAN_FIELDS,
|
||||||
type BilanField,
|
type BilanField,
|
||||||
|
type BilanSuggestion,
|
||||||
type ReportUpsert,
|
type ReportUpsert,
|
||||||
|
type WorkOrderDetail,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useReferenceValues, useWorkOrder } from '@/api/exploitation';
|
import {
|
||||||
|
useReferenceValues,
|
||||||
|
useSuggestionBilan,
|
||||||
|
useTranscription,
|
||||||
|
useWorkOrder,
|
||||||
|
} from '@/api/exploitation';
|
||||||
import { useHorsLigne } from '@/auth/session';
|
import { useHorsLigne } from '@/auth/session';
|
||||||
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
|
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
|
||||||
import { enfilerBilan, enfilerTransition } from '@/file/actions';
|
import { enfilerBilan, enfilerTransition } from '@/file/actions';
|
||||||
@@ -95,6 +112,11 @@ export default function PageCloture() {
|
|||||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
|
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
|
||||||
|
<CarteSuggestion
|
||||||
|
ot={ot}
|
||||||
|
horsLigne={horsLigne}
|
||||||
|
surApplication={(s) => setChoix((c) => ({ ...c, [s.field]: s.valueId }))}
|
||||||
|
/>
|
||||||
<Carte titre="Bilan d'intervention — requis pour clôturer">
|
<Carte titre="Bilan d'intervention — requis pour clôturer">
|
||||||
<View style={{ gap: 10 }}>
|
<View style={{ gap: 10 }}>
|
||||||
{[0, 2, 4].map((rang) => (
|
{[0, 2, 4].map((rang) => (
|
||||||
@@ -151,3 +173,198 @@ export default function PageCloture() {
|
|||||||
</SafeAreaView>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
94
apps/mobile/app/personnes/index.tsx
Normal file
94
apps/mobile/app/personnes/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useUsers } from '@/api/pilotage';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { BoutonTel, EnteteFiche } from '@/composants/ui';
|
||||||
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
const LABEL_STATUT: Record<'active' | 'invited' | 'disabled', string> = {
|
||||||
|
active: 'Actif',
|
||||||
|
invited: 'Invitation envoyée',
|
||||||
|
disabled: 'Désactivé',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Personnes & équipes — R6.4 (Pilotage). Consultation + « Inviter » (D4) —
|
||||||
|
* taux horaire, équipes, rôles restent gérés depuis le web pour l'instant. */
|
||||||
|
export default function PagePersonnes() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: users } = useUsers();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const personnes = [...(users ?? [])].sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre="Personnes" />
|
||||||
|
{can('PEOPLE_TEAMS', 'create') ? (
|
||||||
|
<BoutonTel libelle="+ Inviter" surAppui={() => router.push('/personnes/inviter')} />
|
||||||
|
) : null}
|
||||||
|
<FlatList
|
||||||
|
data={personnes}
|
||||||
|
keyExtractor={(u) => u.id}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Personne accessible.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: u }) => <LignePersonne utilisateur={u} t={t} />}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LignePersonne({
|
||||||
|
utilisateur: u,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
utilisateur: { id: string; displayName: string; email: string; role: { name: string }; teams: { name: string }[]; status: 'active' | 'invited' | 'disabled' };
|
||||||
|
t: Tokens;
|
||||||
|
}) {
|
||||||
|
const [enc, fond] =
|
||||||
|
u.status === 'active'
|
||||||
|
? [t.stTermine, t.stTermineFond]
|
||||||
|
: u.status === 'invited'
|
||||||
|
? [t.stAttente, t.stAttenteFond]
|
||||||
|
: [t.stAnnule, t.stAnnuleFond];
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||||
|
{u.displayName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: enc,
|
||||||
|
backgroundColor: fond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LABEL_STATUT[u.status]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||||
|
{u.role.name}
|
||||||
|
{u.teams.length ? ` · ${u.teams.map((eq) => eq.name).join(', ')}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/mobile/app/personnes/inviter.tsx
Normal file
99
apps/mobile/app/personnes/inviter.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ScrollView, Text, TextInput, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useInviteUser, useRoles } from '@/api/pilotage';
|
||||||
|
import { BoutonTel, ChoixTel, EnteteFiche } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Invitation — R6.4. Jamais de mot de passe créé pour autrui (décision R1) :
|
||||||
|
* seul un lien d'activation valable 7 j est émis, à transmettre à la main. */
|
||||||
|
export default function PageInviter() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: roles } = useRoles();
|
||||||
|
const invitation = useInviteUser();
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [roleId, setRoleId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const role = (roles ?? []).find((r) => r.id === roleId) ?? null;
|
||||||
|
const valide = displayName.trim().length >= 2 && /\S+@\S+\.\S+/.test(email) && !!roleId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
|
||||||
|
<EnteteFiche titre="Inviter" />
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
|
||||||
|
Nom complet <Text style={{ color: t.danger }}>*</Text>
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="Nom complet"
|
||||||
|
value={displayName}
|
||||||
|
onChangeText={setDisplayName}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderRadius: 9,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 9,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
|
||||||
|
E-mail <Text style={{ color: t.danger }}>*</Text>
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="E-mail"
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderRadius: 9,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 9,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<ChoixTel
|
||||||
|
libelle="Rôle"
|
||||||
|
requis
|
||||||
|
valeur={role ? { id: role.id, label: role.name } : null}
|
||||||
|
options={(roles ?? []).map((r) => ({ id: r.id, label: r.name }))}
|
||||||
|
surChoix={setRoleId}
|
||||||
|
/>
|
||||||
|
{invitation.isError ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||||
|
{invitation.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Émettre le lien d'activation"
|
||||||
|
desactive={!valide || invitation.isPending}
|
||||||
|
surAppui={() =>
|
||||||
|
invitation.mutate(
|
||||||
|
{ displayName: displayName.trim(), email: email.trim(), roleId: roleId! },
|
||||||
|
{
|
||||||
|
onSuccess: (r) =>
|
||||||
|
router.replace({
|
||||||
|
pathname: '/personnes/lien',
|
||||||
|
params: { token: r.activationToken, expiresAt: r.expiresAt },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/mobile/app/personnes/lien.tsx
Normal file
43
apps/mobile/app/personnes/lien.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { ScrollView, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { WEB_URL } from '@/api/client';
|
||||||
|
import { BoutonTel, Carte, EnteteFiche } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Lien d'activation émis — R6.4. Pas de presse-papiers natif sur mobile
|
||||||
|
* pour l'instant (`expo-clipboard` demanderait un nouveau build natif,
|
||||||
|
* même raisonnement que l'ouverture de document en Ressources) : le texte
|
||||||
|
* est sélectionnable (`Text selectable`, RN natif, aucune dépendance) —
|
||||||
|
* appui long → copier, comme partout sur le téléphone. */
|
||||||
|
export default function PageLienActivation() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { token, expiresAt } = useLocalSearchParams<{ token: string; expiresAt: string }>();
|
||||||
|
const url = `${WEB_URL}/activation?token=${token}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
|
||||||
|
<EnteteFiche titre="Lien d'activation émis" />
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre2 }}>
|
||||||
|
Transmettez ce lien à la personne (valable jusqu'au{' '}
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(expiresAt))}) :
|
||||||
|
</Text>
|
||||||
|
<Carte>
|
||||||
|
<Text
|
||||||
|
selectable
|
||||||
|
style={{ fontFamily: 'Manrope_700Bold', fontSize: 13, color: t.primaire }}
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</Text>
|
||||||
|
</Carte>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre3 }}>
|
||||||
|
Appui long sur le lien pour le sélectionner et le copier.
|
||||||
|
</Text>
|
||||||
|
<View style={{ marginTop: 8 }}>
|
||||||
|
<BoutonTel libelle="Terminé" surAppui={() => router.replace('/personnes')} />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/mobile/app/sites/[id].tsx
Normal file
99
apps/mobile/app/sites/[id].tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { Pressable, ScrollView, Text } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { ASSET_STATUS_LABELS, type AssetStatus } from '@siop/shared';
|
||||||
|
import { useAssets, useLocations } from '@/api/exploitation';
|
||||||
|
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||||
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Fiche site — R6.2. Consultation seule (D4) : identité, zones, ascenseurs
|
||||||
|
* du site. Pas de carte interactive ni d'édition sur mobile pour l'instant
|
||||||
|
* (réservé au web) — même filtre appareils↔site que fiche-site.tsx (web) :
|
||||||
|
* par nom, la liste étant déjà plate côté serveur. */
|
||||||
|
export default function PageFicheSite() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: locations } = useLocations();
|
||||||
|
const { data: assets } = useAssets();
|
||||||
|
|
||||||
|
const site = (locations ?? []).find((l) => l.id === id);
|
||||||
|
if (!site) return null;
|
||||||
|
|
||||||
|
const zones = (locations ?? []).filter((l) => l.parentId === site.id);
|
||||||
|
const appareils = (assets ?? []).filter((a) => a.siteName === site.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre={site.name} />
|
||||||
|
|
||||||
|
<Carte titre="Identité">
|
||||||
|
<LigneInfo nom="Adresse" valeur={[site.address, site.city].filter(Boolean).join(' — ') || '—'} />
|
||||||
|
<LigneInfo nom="Gardien" valeur={site.guardianName ?? '—'} />
|
||||||
|
{site.guardianPhone ? <LigneInfo nom="Téléphone" valeur={site.guardianPhone} /> : null}
|
||||||
|
<LigneInfo nom="Client / syndic" valeur={site.partnerName ?? '—'} />
|
||||||
|
<LigneInfo nom="Appareils" valeur={String(site.assetCount)} />
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
{zones.length ? (
|
||||||
|
<Carte titre="Emplacements">
|
||||||
|
{zones.map((z) => (
|
||||||
|
<LigneInfo
|
||||||
|
key={z.id}
|
||||||
|
nom={z.name}
|
||||||
|
valeur={appareils.filter((a) => a.locationId === z.id).map((a) => a.reference).join(' · ') || '—'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Carte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Carte titre={`Ascenseurs du site (${appareils.length})`}>
|
||||||
|
{appareils.map((a) => (
|
||||||
|
<Pressable
|
||||||
|
key={a.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/ascenseur/${a.id}`)}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 3 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.primaire }}>
|
||||||
|
{a.reference}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ flex: 1, fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre }}
|
||||||
|
>
|
||||||
|
{a.brand} {a.model ?? ''}
|
||||||
|
</Text>
|
||||||
|
<ChipStatutAppareil statut={a.status} t={t} />
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
{!appareils.length ? (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Aucun appareil rattaché.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChipStatutAppareil({ statut, t }: { statut: AssetStatus; t: Tokens }) {
|
||||||
|
const enService = statut === 'IN_SERVICE';
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: enService ? t.stTermine : t.stAttente,
|
||||||
|
backgroundColor: enService ? t.stTermineFond : t.stAttenteFond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ASSET_STATUS_LABELS[statut]}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/mobile/app/sites/index.tsx
Normal file
69
apps/mobile/app/sites/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useLocations } from '@/api/exploitation';
|
||||||
|
import { EnteteFiche } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Sites — R6.2 (Parc). Liste plate → sites de premier niveau seulement
|
||||||
|
* (une zone n'a jamais de fiche propre, elle suit son site — même règle
|
||||||
|
* que le web, sites.tsx). */
|
||||||
|
export default function PageSites() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: locations } = useLocations();
|
||||||
|
const sites = (locations ?? []).filter((l) => l.parentId === null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre="Sites" />
|
||||||
|
<FlatList
|
||||||
|
data={sites}
|
||||||
|
keyExtractor={(s) => s.id}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Aucun site accessible.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: s }) => (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/sites/${s.id}`)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||||
|
{s.name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: t.encre2,
|
||||||
|
backgroundColor: t.surface2,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.assetCount} appareil{s.assetCount > 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||||
|
{[s.address, s.city].filter(Boolean).join(' — ') || '—'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/mobile/app/statistiques/index.tsx
Normal file
130
apps/mobile/app/statistiques/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useAnalyticsSummary } from '@/api/pilotage';
|
||||||
|
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
const fmtMAD = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'MAD', maximumFractionDigits: 0 });
|
||||||
|
const PERIODES = [3, 6, 12] as const;
|
||||||
|
|
||||||
|
/** Statistiques — R6.4 (Pilotage). Maquette écran 7 : les mêmes chiffres
|
||||||
|
* que `/analytics/summary` (web), en cartes plutôt qu'en graphes denses
|
||||||
|
* (D4) — coût du mois, taux préventif, pannes par organe. */
|
||||||
|
export default function PageStatistiques() {
|
||||||
|
const t = useTokens();
|
||||||
|
const [periode, setPeriode] = useState<(typeof PERIODES)[number]>(12);
|
||||||
|
const { data } = useAnalyticsSummary(periode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre="Statistiques" />
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
{PERIODES.map((p) => (
|
||||||
|
<Pressable
|
||||||
|
key={p}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => setPeriode(p)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: periode === p ? t.primaire : t.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: periode === p ? t.primaire : t.bordure,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: periode === p ? '#fff' : t.encre2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p} mois
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{data ? (
|
||||||
|
<>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.primaire }}>
|
||||||
|
{fmtMAD.format(data.monthCost)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
|
||||||
|
Coût du mois
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
|
||||||
|
{data.preventiveRate != null ? `${Math.round(data.preventiveRate * 100)} %` : '—'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
|
||||||
|
Taux préventif
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Carte titre={`OT clôturés · ${data.months} mois`}>
|
||||||
|
<LigneInfo nom="Total" valeur={String(data.closed.total)} />
|
||||||
|
<LigneInfo nom="Dont préventif" valeur={String(data.closed.preventive)} />
|
||||||
|
<LigneInfo
|
||||||
|
nom="Délai moyen"
|
||||||
|
valeur={data.avgResolutionDays != null ? `${data.avgResolutionDays.toFixed(1)} j` : '—'}
|
||||||
|
/>
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<Carte titre={`Pannes par organe · ${data.months} mois`}>
|
||||||
|
{data.failuresByComponent.slice(0, 5).map((f) => (
|
||||||
|
<LigneInfo key={f.label} nom={f.label} valeur={String(f.count)} />
|
||||||
|
))}
|
||||||
|
{!data.failuresByComponent.length ? (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Aucune panne codée sur la période.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<Carte titre="Top équipements en coût">
|
||||||
|
{data.topAssets.slice(0, 5).map((a) => (
|
||||||
|
<LigneInfo
|
||||||
|
key={a.reference}
|
||||||
|
nom={`Asc. ${a.reference} — ${a.siteName}`}
|
||||||
|
valeur={fmtMAD.format(a.total)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!data.topAssets.length ? (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Aucune donnée sur la période.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
apps/mobile/app/stock/[id].tsx
Normal file
65
apps/mobile/app/stock/[id].tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { ScrollView, Text } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { usePart } from '@/api/ressources';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { BoutonTel, Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('fr-FR');
|
||||||
|
const fmtMAD = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'MAD' });
|
||||||
|
|
||||||
|
/** Fiche pièce — R6.3. Consultation + une action courante (D4) : commander,
|
||||||
|
* pré-rempli depuis cette fiche, comme la maquette (écran 6). Les
|
||||||
|
* mouvements de stock (entrée manuelle, ajustement) restent au web. */
|
||||||
|
export default function PageFichePiece() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: piece } = usePart(id);
|
||||||
|
const { can } = usePermissions();
|
||||||
|
|
||||||
|
if (!piece) return null;
|
||||||
|
|
||||||
|
const peutCommander = can('PURCHASE_ORDERS', 'create') && !!piece.supplierId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre={piece.designation} />
|
||||||
|
<Carte titre="Identité">
|
||||||
|
<LigneInfo nom="Référence" valeur={piece.reference} />
|
||||||
|
<LigneInfo
|
||||||
|
nom="Stock"
|
||||||
|
valeur={
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: piece.belowThreshold ? t.danger : t.encre,
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmt.format(piece.stock)}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LigneInfo nom="Seuil d'alerte" valeur={fmt.format(piece.threshold)} />
|
||||||
|
<LigneInfo nom="Fournisseur" valeur={piece.supplierName ?? '—'} />
|
||||||
|
<LigneInfo
|
||||||
|
nom="Dernier prix"
|
||||||
|
valeur={piece.lastUnitPrice != null ? fmtMAD.format(piece.lastUnitPrice) : '—'}
|
||||||
|
/>
|
||||||
|
{piece.compatible ? <LigneInfo nom="Compatible" valeur={piece.compatible} /> : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
{peutCommander ? (
|
||||||
|
<BoutonTel libelle="Commander" surAppui={() => router.push(`/stock/${piece.id}/commander`)} />
|
||||||
|
) : null}
|
||||||
|
{!piece.supplierId && can('PURCHASE_ORDERS', 'create') ? (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12, textAlign: 'center' }}>
|
||||||
|
Aucun fournisseur associé — commande depuis le web.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/mobile/app/stock/[id]/commander.tsx
Normal file
130
apps/mobile/app/stock/[id]/commander.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ScrollView, Text, TextInput, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import type { PurchaseOrderDto } from '@siop/shared';
|
||||||
|
import { useCreatePurchaseOrder, usePart } from '@/api/ressources';
|
||||||
|
import { BoutonTel, Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
const fmtMAD = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'MAD' });
|
||||||
|
|
||||||
|
/** Nouveau BC pré-rempli depuis l'alerte stock — maquette écran 6. Une
|
||||||
|
* seule ligne (cette pièce) : le bon de commande multi-lignes détaillé
|
||||||
|
* reste au web (D4). Confirmation explicite après création (trouvé en
|
||||||
|
* recette 02/08 : la navigation instantanée en cas de succès ne donnait
|
||||||
|
* aucun retour visible — deux BC créés côté serveur sans que le référent
|
||||||
|
* s'en aperçoive dans l'app). */
|
||||||
|
export default function PageCommander() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: piece } = usePart(id);
|
||||||
|
const creation = useCreatePurchaseOrder();
|
||||||
|
const manquant = piece ? Math.max(piece.threshold - piece.stock, 1) : 1;
|
||||||
|
const [quantite, setQuantite] = useState(String(manquant));
|
||||||
|
const [prix, setPrix] = useState('');
|
||||||
|
const [cree, setCree] = useState<PurchaseOrderDto | null>(null);
|
||||||
|
|
||||||
|
if (!piece || !piece.supplierId) return null;
|
||||||
|
|
||||||
|
if (cree) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
|
||||||
|
<EnteteFiche titre="BC créé" />
|
||||||
|
<Carte titre="✓ Bon de commande enregistré">
|
||||||
|
<LigneInfo nom="Référence" valeur={cree.reference} />
|
||||||
|
<LigneInfo nom="Fournisseur" valeur={cree.supplierName} />
|
||||||
|
<LigneInfo nom="Total" valeur={fmtMAD.format(cree.total)} />
|
||||||
|
<LigneInfo nom="Statut" valeur="Brouillon" />
|
||||||
|
</Carte>
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5, textAlign: 'center' }}>
|
||||||
|
L'envoi au fournisseur se fait depuis le web pour l'instant.
|
||||||
|
</Text>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Terminé"
|
||||||
|
surAppui={() => (router.canGoBack() ? router.back() : router.replace('/stock'))}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prixDefaut = piece.lastUnitPrice != null ? String(piece.lastUnitPrice) : '';
|
||||||
|
const prixSaisi = prix || prixDefaut;
|
||||||
|
const qte = Number(quantite);
|
||||||
|
const pu = Number(prixSaisi);
|
||||||
|
const valide = qte > 0 && pu >= 0 && Number.isFinite(qte) && Number.isFinite(pu);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre="Nouveau BC" />
|
||||||
|
<Carte titre="Ligne">
|
||||||
|
<LigneInfo nom="Fournisseur" valeur={piece.supplierName ?? '—'} />
|
||||||
|
<LigneInfo nom="Pièce" valeur={`${piece.designation} (${piece.reference})`} />
|
||||||
|
</Carte>
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>Quantité</Text>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="Quantité"
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={quantite}
|
||||||
|
onChangeText={setQuantite}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderRadius: 9,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 9,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
|
||||||
|
Prix unitaire (MAD)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="Prix unitaire"
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder={prixDefaut || '0'}
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
value={prix}
|
||||||
|
onChangeText={setPrix}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderRadius: 9,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 9,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{creation.isError ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||||
|
{creation.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Créer le BC"
|
||||||
|
desactive={!valide || creation.isPending}
|
||||||
|
surAppui={() =>
|
||||||
|
creation.mutate(
|
||||||
|
{
|
||||||
|
supplierId: piece.supplierId!,
|
||||||
|
lines: [{ partId: piece.id, quantity: qte, unitPrice: pu }],
|
||||||
|
},
|
||||||
|
{ onSuccess: (bc) => setCree(bc) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/mobile/app/stock/index.tsx
Normal file
90
apps/mobile/app/stock/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useParts } from '@/api/ressources';
|
||||||
|
import { EnteteFiche } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('fr-FR');
|
||||||
|
|
||||||
|
/** Stock — R6.3 (Ressources). Sous-seuil en tête, comme le web (stock.tsx) —
|
||||||
|
* ce qui demande une action passe avant le reste. */
|
||||||
|
export default function PageStock() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: parts } = useParts();
|
||||||
|
const pieces = [...(parts ?? [])].sort((a, b) => {
|
||||||
|
if (a.belowThreshold !== b.belowThreshold) return a.belowThreshold ? -1 : 1;
|
||||||
|
return a.designation.localeCompare(b.designation);
|
||||||
|
});
|
||||||
|
const sousSeuil = pieces.filter((p) => p.belowThreshold).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche
|
||||||
|
titre="Stock & achats"
|
||||||
|
apres={
|
||||||
|
sousSeuil ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: t.danger,
|
||||||
|
backgroundColor: t.prioBloqueFond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sousSeuil} sous seuil
|
||||||
|
</Text>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FlatList
|
||||||
|
data={pieces}
|
||||||
|
keyExtractor={(p) => p.id}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Aucune pièce accessible.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: p }) => (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/stock/${p.id}`)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: p.belowThreshold ? t.danger : t.bordure,
|
||||||
|
borderWidth: p.belowThreshold ? 1.5 : 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||||
|
{p.designation}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
fontSize: 13,
|
||||||
|
color: p.belowThreshold ? t.danger : t.encre,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmt.format(p.stock)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||||
|
{p.reference} · seuil {fmt.format(p.threshold)}
|
||||||
|
{p.supplierName ? ` · ${p.supplierName}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/mobile/app/tiers/[id].tsx
Normal file
48
apps/mobile/app/tiers/[id].tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { ScrollView, Text } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { usePartners } from '@/api/ressources';
|
||||||
|
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Fiche tiers — R6.3, ajoutée en recette (02/08). Consultation seule (D4) :
|
||||||
|
* identité, contact, activité (BC en cours pour un fournisseur, sites
|
||||||
|
* rattachés pour un client/syndic) — déjà connu de la liste, pas de
|
||||||
|
* requête supplémentaire. Création/édition restent au web. */
|
||||||
|
export default function PageFicheTiers() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: partners } = usePartners();
|
||||||
|
const p = (partners ?? []).find((x) => x.id === id);
|
||||||
|
if (!p) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre={p.name} />
|
||||||
|
<Carte titre="Identité">
|
||||||
|
<LigneInfo nom="Type" valeur={p.kind === 'SUPPLIER' ? 'Fournisseur' : 'Client'} />
|
||||||
|
<LigneInfo nom="Statut" valeur={p.isActive ? 'Actif' : 'Désactivé'} />
|
||||||
|
<LigneInfo nom="Contact" valeur={p.contactName ?? '—'} />
|
||||||
|
<LigneInfo nom="Téléphone" valeur={p.phone ?? '—'} />
|
||||||
|
<LigneInfo nom="E-mail" valeur={p.email ?? '—'} />
|
||||||
|
<LigneInfo nom="Ville" valeur={p.city ?? '—'} />
|
||||||
|
</Carte>
|
||||||
|
{p.kind === 'SUPPLIER' ? (
|
||||||
|
<Carte titre="Achats">
|
||||||
|
<LigneInfo nom="BC en cours" valeur={String(p.openOrders)} />
|
||||||
|
</Carte>
|
||||||
|
) : null}
|
||||||
|
{p.siteNames.length ? (
|
||||||
|
<Carte titre="Sites rattachés">
|
||||||
|
{p.siteNames.map((s) => (
|
||||||
|
<Text key={s} style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 12.5, color: t.encre }}>
|
||||||
|
{s}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Carte>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
apps/mobile/app/tiers/index.tsx
Normal file
72
apps/mobile/app/tiers/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { usePartners } from '@/api/ressources';
|
||||||
|
import { EnteteFiche } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Tiers — R6.3 (Ressources). Consultation seule sur mobile (D4) : la
|
||||||
|
* création/édition de fournisseurs et clients reste au web pour l'instant.
|
||||||
|
* Fiche détail ajoutée en recette (02/08) : le tap sans réaction se lisait
|
||||||
|
* comme un écran cassé plutôt que délibérément simple. */
|
||||||
|
export default function PageTiers() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: partners } = usePartners();
|
||||||
|
const tiers = [...(partners ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre="Tiers" />
|
||||||
|
<FlatList
|
||||||
|
data={tiers}
|
||||||
|
keyExtractor={(p) => p.id}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Aucun tiers accessible.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: p }) => (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/tiers/${p.id}`)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 2,
|
||||||
|
opacity: p.isActive ? 1 : 0.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||||
|
{p.name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: p.kind === 'SUPPLIER' ? t.stOuvert : t.stEncours,
|
||||||
|
backgroundColor: p.kind === 'SUPPLIER' ? t.stOuvertFond : t.stEncoursFond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.kind === 'SUPPLIER' ? 'Fournisseur' : 'Client'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||||
|
{[p.contactName, p.phone, p.city].filter(Boolean).join(' · ') || '—'}
|
||||||
|
{p.kind === 'SUPPLIER' && p.openOrders ? ` · ${p.openOrders} BC en cours` : ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,8 +11,10 @@
|
|||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@tanstack/react-query-persist-client": "^5.101.2",
|
"@tanstack/react-query-persist-client": "^5.101.2",
|
||||||
"expo": "~57.0.6",
|
"expo": "~57.0.6",
|
||||||
|
"expo-audio": "~57.0.2",
|
||||||
"expo-camera": "~57.0.3",
|
"expo-camera": "~57.0.3",
|
||||||
"expo-constants": "~57.0.5",
|
"expo-constants": "~57.0.5",
|
||||||
|
"expo-file-system": "~57.0.1",
|
||||||
"expo-font": "~57.0.1",
|
"expo-font": "~57.0.1",
|
||||||
"expo-image-manipulator": "~57.0.4",
|
"expo-image-manipulator": "~57.0.4",
|
||||||
"expo-image-picker": "~57.0.4",
|
"expo-image-picker": "~57.0.4",
|
||||||
@@ -39,8 +41,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
|
|||||||
26
apps/mobile/src/api/assistant.ts
Normal file
26
apps/mobile/src/api/assistant.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import type { AssistantAsk } from '@siop/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
/** Hook R6.5 — l'assistant passe par l'API NestJS (le service IA n'est
|
||||||
|
* jamais appelé depuis l'app, ADR-004 §4). Le 503 est un état ATTENDU du
|
||||||
|
* contrat (service éteint ou pas encore déployé) — même message que le web. */
|
||||||
|
|
||||||
|
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||||
|
if (res.response.status === 503) {
|
||||||
|
throw new Error('Assistant indisponible pour le moment — réessayez dans un instant.');
|
||||||
|
}
|
||||||
|
if (res.error || res.data === undefined) {
|
||||||
|
const message =
|
||||||
|
(res.error as { message?: string } | undefined)?.message ??
|
||||||
|
`Le serveur a répondu ${res.response.status}`;
|
||||||
|
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAskAssistant() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AssistantAsk) => unwrap(await api.POST('/assistant/ask', { body })),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ import { lireJeton } from './jeton';
|
|||||||
* EXPO_PUBLIC_API_URL pointe l'API (IP LAN pour Expo Go sur téléphone). */
|
* EXPO_PUBLIC_API_URL pointe l'API (IP LAN pour Expo Go sur téléphone). */
|
||||||
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
|
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
/** Origine du web (R6.4 — lien d'activation « Personnes », même page
|
||||||
|
* `/activation?token=…` que le web, il n'existe pas d'équivalent mobile).
|
||||||
|
* À définir par environnement comme EXPO_PUBLIC_API_URL. */
|
||||||
|
export const WEB_URL = process.env.EXPO_PUBLIC_WEB_URL ?? 'http://localhost:5173';
|
||||||
|
|
||||||
export const api = createClient<paths>({ baseUrl: API_URL });
|
export const api = createClient<paths>({ baseUrl: API_URL });
|
||||||
|
|
||||||
api.use({
|
api.use({
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared';
|
import type {
|
||||||
import { api, unwrap } from './client';
|
ChecklistState,
|
||||||
|
ReportUpsert,
|
||||||
|
RequestApprove,
|
||||||
|
RequestCreate,
|
||||||
|
RequestReject,
|
||||||
|
WorkOrderStatus,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { api, API_URL, unwrap } from './client';
|
||||||
|
import { lireJeton } from './jeton';
|
||||||
|
|
||||||
/** Hooks R4.2 — mêmes opérations que le web (contrat unique). Les écritures
|
/** Hooks R4.2 — mêmes opérations que le web (contrat unique). Les écritures
|
||||||
* restent EN LIGNE dans cette release ; la mise en file arrive en R4.3 (D1). */
|
* restent EN LIGNE dans cette release ; la mise en file arrive en R4.3 (D1). */
|
||||||
@@ -20,9 +28,17 @@ export function useWorkOrder(id: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAssets() {
|
export function useLocations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['locations'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/locations'))).locations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssets(opts?: { enabled?: boolean }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['assets'],
|
queryKey: ['assets'],
|
||||||
|
enabled: opts?.enabled ?? true,
|
||||||
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
|
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -46,14 +62,70 @@ export function useDocumentsOT(workOrderId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useReferenceValues() {
|
export function useReferenceValues(opts?: { enabled?: boolean }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['reference-values'],
|
queryKey: ['reference-values'],
|
||||||
|
enabled: opts?.enabled ?? true,
|
||||||
staleTime: 3600_000, // référentiels administrables : stables en journée
|
staleTime: 3600_000, // référentiels administrables : stables en journée
|
||||||
queryFn: async () => (await unwrap(await api.GET('/reference-values'))).referenceValues,
|
queryFn: async () => (await unwrap(await api.GET('/reference-values'))).referenceValues,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ————— Demandes (mobile ouvert à tous les rôles) —————
|
||||||
|
// Même endpoint pour tous : l'API scope déjà « les siennes » (Demandeur) ou
|
||||||
|
// « toutes » (viewOther, ADR-003) — rien à filtrer côté app.
|
||||||
|
|
||||||
|
export function useRequests() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['requests'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/requests'))).requests,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options minimales (signalement) — accessibles à tout rôle authentifié,
|
||||||
|
* y compris Demandeur qui n'a pas ASSETS.view (même trou corrigé qu'au web). */
|
||||||
|
export function useAssetOptions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['asset-options'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/assets/options'))).options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalideRequests() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return () => queryClient.invalidateQueries({ queryKey: ['requests'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRequest() {
|
||||||
|
const invalide = useInvalideRequests();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: RequestCreate) => unwrap(await api.POST('/requests', { body })),
|
||||||
|
onSuccess: invalide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApproveRequest() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: RequestApprove & { id: string }) =>
|
||||||
|
unwrap(await api.POST('/requests/{id}/approve', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: () =>
|
||||||
|
Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['requests'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['work-orders'] }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRejectRequest() {
|
||||||
|
const invalide = useInvalideRequests();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: RequestReject & { id: string }) =>
|
||||||
|
unwrap(await api.POST('/requests/{id}/reject', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function useInvalideOT(id: string) {
|
function useInvalideOT(id: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return () =>
|
return () =>
|
||||||
@@ -91,6 +163,42 @@ export function useCocheChecklist(otId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Suggestion R5 (D1) : l'IA est un service SERVEUR — la suggestion demande le
|
||||||
|
* réseau, la clôture en file R4 fonctionne sans elle. */
|
||||||
|
export function useSuggestionBilan() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (description: string) =>
|
||||||
|
unwrap(await api.POST('/assistant/suggest-bilan', { body: { description } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dictée (R5 D5, opt-in) : multipart hors client typé, même raison que la
|
||||||
|
* photo (D5) — le fichier natif {uri, name, type} ne passe pas par `api.*`.
|
||||||
|
* L'audio ne transite qu'une fois ; rien n'est stocké côté app après. */
|
||||||
|
export function useTranscription() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (fichier: { uri: string; nom: string; mime: string }) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', {
|
||||||
|
uri: fichier.uri,
|
||||||
|
name: fichier.nom,
|
||||||
|
type: fichier.mime,
|
||||||
|
} as unknown as Blob);
|
||||||
|
const jeton = await lireJeton();
|
||||||
|
const res = await fetch(`${API_URL}/assistant/transcribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jeton ? { Authorization: `Bearer ${jeton}` } : undefined,
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const corps = (await res.json().catch(() => null)) as { message?: string } | null;
|
||||||
|
throw new Error(corps?.message ?? `Dictée indisponible (${res.status})`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as { text: string };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useBilan(otId: string) {
|
export function useBilan(otId: string) {
|
||||||
const invalide = useInvalideOT(otId);
|
const invalide = useInvalideOT(otId);
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
39
apps/mobile/src/api/pilotage.ts
Normal file
39
apps/mobile/src/api/pilotage.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { InvitationCreate } from '@siop/shared';
|
||||||
|
import { api, unwrap } from './client';
|
||||||
|
|
||||||
|
/** Hooks R6.4 — Pilotage (Statistiques, Personnes & équipes) : mêmes
|
||||||
|
* opérations que le web (gestion.ts/referentiel.ts), lecture + une action
|
||||||
|
* courante (inviter) sur mobile — pas la gestion complète des rôles/
|
||||||
|
* équipes/taux horaires, réservée au web (D4). */
|
||||||
|
|
||||||
|
export function useAnalyticsSummary(months: 3 | 6 | 12 = 12) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['analytics', months],
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await api.GET('/analytics/summary', { params: { query: { months: String(months) } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/users'))).users,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoles() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['roles'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/roles'))).roles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInviteUser() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: InvitationCreate) =>
|
||||||
|
unwrap(await api.POST('/users/invitations', { body })),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
47
apps/mobile/src/api/ressources.ts
Normal file
47
apps/mobile/src/api/ressources.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { PurchaseOrderCreate } from '@siop/shared';
|
||||||
|
import { api, unwrap } from './client';
|
||||||
|
|
||||||
|
/** Hooks R6.3 — Ressources (Stock, Tiers, Fichiers) : mêmes opérations que
|
||||||
|
* le web (gestion.ts), périmètre mobile plus étroit (D4 — consultation +
|
||||||
|
* action courante « commander », pas les flux de gestion les plus denses :
|
||||||
|
* pas de création/édition de pièce ou de tiers sur mobile pour l'instant). */
|
||||||
|
|
||||||
|
export function usePartners() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['partners'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/partners'))).partners,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useParts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['parts'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/parts'))).parts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePart(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['parts', id],
|
||||||
|
queryFn: async () => unwrap(await api.GET('/parts/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePurchaseOrder() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: PurchaseOrderCreate) =>
|
||||||
|
unwrap(await api.POST('/purchase-orders', { body })),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['purchase-orders'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bibliothèque complète (sans filtre appareil/OT — voir aussi
|
||||||
|
* `useDocumentsOT` dans exploitation.ts, scopée à un OT). */
|
||||||
|
export function useDocuments() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['documents'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/documents'))).documents,
|
||||||
|
});
|
||||||
|
}
|
||||||
309
apps/mobile/src/api/schema.d.ts
vendored
309
apps/mobile/src/api/schema.d.ts
vendored
@@ -407,6 +407,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/documents/{id}/corpus": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/** Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation) */
|
||||||
|
patch: operations["updateDocumentCorpus"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/documents/{id}": {
|
"/documents/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -441,6 +458,74 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/assistant/ask": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2) */
|
||||||
|
post: operations["askAssistant"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/assistant/suggest-bilan": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Suggérer des codes de bilan depuis une description libre (D1 — l’humain valide) */
|
||||||
|
post: operations["suggestBilan"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/assistant/reindex": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à l’ingestion — D4) */
|
||||||
|
post: operations["reindexAssistant"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/assistant/transcribe": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Dictée (R5 D5, opt-in) — l’audio est transcrit puis JAMAIS conservé, à relire avant tout usage */
|
||||||
|
post: operations["transcribeAudio"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/search": {
|
"/search": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1331,6 +1416,11 @@ export interface components {
|
|||||||
status: "active" | "invited" | "disabled";
|
status: "active" | "invited" | "disabled";
|
||||||
isDemo: boolean;
|
isDemo: boolean;
|
||||||
hourlyRate: number | null;
|
hourlyRate: number | null;
|
||||||
|
assignedSites: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
RolesResponse: {
|
RolesResponse: {
|
||||||
@@ -1356,6 +1446,7 @@ export interface components {
|
|||||||
roleId: string;
|
roleId: string;
|
||||||
teamIds?: string[];
|
teamIds?: string[];
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
locationIds?: string[];
|
||||||
};
|
};
|
||||||
UserAdmin: {
|
UserAdmin: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1379,6 +1470,11 @@ export interface components {
|
|||||||
status: "active" | "invited" | "disabled";
|
status: "active" | "invited" | "disabled";
|
||||||
isDemo: boolean;
|
isDemo: boolean;
|
||||||
hourlyRate: number | null;
|
hourlyRate: number | null;
|
||||||
|
assignedSites: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
UserUpdate: {
|
UserUpdate: {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
@@ -1388,6 +1484,7 @@ export interface components {
|
|||||||
teamIds?: string[];
|
teamIds?: string[];
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
hourlyRate?: number | null;
|
hourlyRate?: number | null;
|
||||||
|
locationIds?: string[];
|
||||||
};
|
};
|
||||||
DocumentsResponse: {
|
DocumentsResponse: {
|
||||||
documents: {
|
documents: {
|
||||||
@@ -1403,6 +1500,9 @@ export interface components {
|
|||||||
uploadedByName: string | null;
|
uploadedByName: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
inCorpus: boolean;
|
||||||
|
indexedAt: string | null;
|
||||||
|
chunkCount: number;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
Document: {
|
Document: {
|
||||||
@@ -1418,6 +1518,12 @@ export interface components {
|
|||||||
uploadedByName: string | null;
|
uploadedByName: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
inCorpus: boolean;
|
||||||
|
indexedAt: string | null;
|
||||||
|
chunkCount: number;
|
||||||
|
};
|
||||||
|
DocumentCorpusUpdate: {
|
||||||
|
inCorpus: boolean;
|
||||||
};
|
};
|
||||||
AnalyticsSummary: {
|
AnalyticsSummary: {
|
||||||
months: number;
|
months: number;
|
||||||
@@ -1446,6 +1552,53 @@ export interface components {
|
|||||||
total: number;
|
total: number;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
AssistantAnswer: {
|
||||||
|
/** @enum {string} */
|
||||||
|
mode: "EXTRACTIVE" | "GENERATED" | "REFUSAL";
|
||||||
|
answer: string | null;
|
||||||
|
excerpts: {
|
||||||
|
/** @enum {string} */
|
||||||
|
sourceType: "DOCUMENT" | "WORK_ORDER";
|
||||||
|
documentId: string | null;
|
||||||
|
workOrderId: string | null;
|
||||||
|
title: string;
|
||||||
|
locator: string;
|
||||||
|
content: string;
|
||||||
|
score: number;
|
||||||
|
}[];
|
||||||
|
corpus: {
|
||||||
|
documents: number;
|
||||||
|
reports: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
AssistantAsk: {
|
||||||
|
question: string;
|
||||||
|
};
|
||||||
|
BilanSuggestions: {
|
||||||
|
suggestions: {
|
||||||
|
/** @enum {string} */
|
||||||
|
field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED";
|
||||||
|
/** Format: uuid */
|
||||||
|
valueId: string;
|
||||||
|
label: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
confidence: "HIGH" | "MEDIUM";
|
||||||
|
similarReports: number;
|
||||||
|
score: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
SuggestBilan: {
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
ReindexResult: {
|
||||||
|
documentsIndexed: number;
|
||||||
|
documentsSkipped: number;
|
||||||
|
reportsIndexed: number;
|
||||||
|
chunks: number;
|
||||||
|
};
|
||||||
|
TranscriptionResult: {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
SearchResponse: {
|
SearchResponse: {
|
||||||
workOrders: {
|
workOrders: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -2918,6 +3071,39 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
updateDocumentCorpus: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DocumentCorpusUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Document mis à jour */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Document"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Inconnu */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
deleteDocument: {
|
deleteDocument: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2967,6 +3153,129 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
askAssistant: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AssistantAsk"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Réponse sourcée ou refus */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AssistantAnswer"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
suggestBilan: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SuggestBilan"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Suggestions (codes existants seulement) */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BilanSuggestions"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
reindexAssistant: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Bilan d’indexation */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ReindexResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
transcribeAudio: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": {
|
||||||
|
/** Format: binary */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Texte transcrit — à relire (D1) */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TranscriptionResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Dictée non activée ou service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
globalSearch: {
|
globalSearch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
26
apps/mobile/src/auth/roles.ts
Normal file
26
apps/mobile/src/auth/roles.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { RoleName } from '@siop/shared';
|
||||||
|
|
||||||
|
/** Barre d'onglets ADAPTATIVE (maquette « mobile ouvert à tous les rôles »,
|
||||||
|
* D1) : le Technicien/Technicien limité gardent exactement leurs onglets
|
||||||
|
* terrain R4 ; les autres rôles reçoivent Accueil/OT/Menu (ou une variante
|
||||||
|
* plus étroite selon leurs droits). Décidé une fois ici, pas éparpillé. */
|
||||||
|
|
||||||
|
const ROLES_TERRAIN: RoleName[] = ['Technicien', 'Technicien limité'];
|
||||||
|
|
||||||
|
export function estRoleTerrain(role: RoleName | undefined): boolean {
|
||||||
|
return !role || ROLES_TERRAIN.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ongletsVisibles(role: RoleName | undefined): ReadonlySet<string> {
|
||||||
|
if (estRoleTerrain(role)) {
|
||||||
|
return new Set(['journee', 'scanner', 'preventif', 'synchro', 'menu']);
|
||||||
|
}
|
||||||
|
if (role === 'Demandeur') return new Set(['accueil', 'nouvelle-demande']);
|
||||||
|
if (role === 'Vue seule') return new Set(['accueil', 'menu']);
|
||||||
|
return new Set(['accueil', 'ot', 'menu']); // Administrateur, Gestionnaire, Dispatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Onglet sur lequel router.replace() après connexion (voir app/index.tsx). */
|
||||||
|
export function ongletAccueil(role: RoleName | undefined): 'journee' | 'accueil' {
|
||||||
|
return estRoleTerrain(role) ? 'journee' : 'accueil';
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export function useLogin() {
|
|||||||
mutationFn: async (input: { email: string; password: string }) => {
|
mutationFn: async (input: { email: string; password: string }) => {
|
||||||
const res = await unwrap(await api.POST('/auth/login', { body: input }));
|
const res = await unwrap(await api.POST('/auth/login', { body: input }));
|
||||||
await apres(res.accessToken);
|
await apres(res.accessToken);
|
||||||
|
return res; // le rôle sert à router vers le bon onglet d'accueil
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,7 @@ export function useDemoLogin() {
|
|||||||
mutationFn: async (userId: string) => {
|
mutationFn: async (userId: string) => {
|
||||||
const res = await unwrap(await api.POST('/auth/demo-login', { body: { userId } }));
|
const res = await unwrap(await api.POST('/auth/demo-login', { body: { userId } }));
|
||||||
await apres(res.accessToken);
|
await apres(res.accessToken);
|
||||||
|
return res; // le rôle sert à router vers le bon onglet d'accueil
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
26
apps/mobile/src/auth/use-permissions.ts
Normal file
26
apps/mobile/src/auth/use-permissions.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ObjectCategory, PermissionRight } from '@siop/shared';
|
||||||
|
import { useMe } from './session';
|
||||||
|
|
||||||
|
/** Lecture UI de la matrice — même patron que le web (use-permissions.ts).
|
||||||
|
* L'API re-vérifie chaque requête (ADR-003) : ceci ne fait QUE piloter
|
||||||
|
* l'affichage (onglets, groupes du Menu, boutons d'action). */
|
||||||
|
export function usePermissions() {
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const can = (category: ObjectCategory, right: PermissionRight): boolean => {
|
||||||
|
const entry = me?.permissions.find((p) => p.objectCategory === category);
|
||||||
|
if (!entry) return false;
|
||||||
|
switch (right) {
|
||||||
|
case 'view':
|
||||||
|
return entry.canView;
|
||||||
|
case 'viewOther':
|
||||||
|
return entry.canViewOther;
|
||||||
|
case 'create':
|
||||||
|
return entry.canCreate;
|
||||||
|
case 'edit':
|
||||||
|
return entry.canEdit;
|
||||||
|
case 'delete':
|
||||||
|
return entry.canDelete;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { can };
|
||||||
|
}
|
||||||
189
apps/mobile/src/composants/formulaire-demande.tsx
Normal file
189
apps/mobile/src/composants/formulaire-demande.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Platform, Pressable, Text, TextInput, View } from 'react-native';
|
||||||
|
import { useAssetOptions, useCreateRequest } from '@/api/exploitation';
|
||||||
|
import { analyseScan } from '@/lib/scan';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
import { BoutonTel, ChoixTel } from './ui';
|
||||||
|
|
||||||
|
/** Formulaire « Nouvelle demande » — mêmes champs que la modale de
|
||||||
|
* signalement du web (asset via `/assets/options`, accessible même sans
|
||||||
|
* ASSETS.view ; description ; personne bloquée). Utilisé à la fois par
|
||||||
|
* l'onglet du Demandeur et par le Menu des rôles gestion.
|
||||||
|
* Scan QR (R6.6) : raccourci mobile pour resélectionner un équipement déjà
|
||||||
|
* dans son périmètre — résolution UNIQUEMENT contre les options déjà
|
||||||
|
* chargées (déjà filtrées par site pour un Demandeur affecté), jamais un
|
||||||
|
* repli sur le parc complet qui annulerait la restriction. */
|
||||||
|
export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => void }) {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: options } = useAssetOptions();
|
||||||
|
const creation = useCreateRequest();
|
||||||
|
const [assetId, setAssetId] = useState<string | null>(null);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [personneBloquee, setPersonneBloquee] = useState(false);
|
||||||
|
const [scanOuvert, setScanOuvert] = useState(false);
|
||||||
|
const [erreurScan, setErreurScan] = useState<string | null>(null);
|
||||||
|
const [permission, demanderPermission] = useCameraPermissions();
|
||||||
|
const dernierScan = useRef(0);
|
||||||
|
|
||||||
|
const asset = (options ?? []).find((a) => a.id === assetId) ?? null;
|
||||||
|
const valide = !!assetId && description.trim().length >= 3;
|
||||||
|
const cameraUtilisable = Platform.OS !== 'web' && permission?.granted;
|
||||||
|
|
||||||
|
const surScan = ({ data }: { data: string }) => {
|
||||||
|
const maintenant = Date.now();
|
||||||
|
if (maintenant - dernierScan.current < 1500) return; // anti-rafale
|
||||||
|
dernierScan.current = maintenant;
|
||||||
|
const reference = analyseScan(data);
|
||||||
|
const trouve = reference
|
||||||
|
? (options ?? []).find((a) => a.reference.toUpperCase() === reference)
|
||||||
|
: null;
|
||||||
|
if (!trouve) {
|
||||||
|
setErreurScan("Cet appareil n'existe pas ou n'est pas dans votre périmètre.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErreurScan(null);
|
||||||
|
setAssetId(trouve.id);
|
||||||
|
setScanOuvert(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const envoyer = () => {
|
||||||
|
if (!assetId) return;
|
||||||
|
creation.mutate(
|
||||||
|
{ assetId, description: description.trim(), isPersonTrapped: personneBloquee },
|
||||||
|
{ onSuccess: (r) => surSucces(r.id) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<ChoixTel
|
||||||
|
libelle="Équipement"
|
||||||
|
requis
|
||||||
|
valeur={asset ? { id: asset.id, label: `${asset.reference} — ${asset.siteName}` } : null}
|
||||||
|
options={(options ?? []).map((a) => ({ id: a.id, label: `${a.reference} — ${a.siteName}` }))}
|
||||||
|
surChoix={setAssetId}
|
||||||
|
/>
|
||||||
|
{scanOuvert ? (
|
||||||
|
<View style={{ gap: 8 }}>
|
||||||
|
{cameraUtilisable ? (
|
||||||
|
<View style={{ height: 220, borderRadius: 12, overflow: 'hidden' }}>
|
||||||
|
<CameraView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||||
|
onBarcodeScanned={surScan}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 220,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#131c2c',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#dfe7f2',
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Platform.OS === 'web'
|
||||||
|
? 'Caméra indisponible sur web.'
|
||||||
|
: "Visez le QR de l'étiquette de cabine."}
|
||||||
|
</Text>
|
||||||
|
{Platform.OS !== 'web' && !permission?.granted ? (
|
||||||
|
<BoutonTel libelle="Autoriser la caméra" surAppui={() => void demanderPermission()} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<BoutonTel libelle="Annuler le scan" variante="gris" surAppui={() => setScanOuvert(false)} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<BoutonTel
|
||||||
|
libelle="📷 Scanner l'étiquette"
|
||||||
|
variante="contour"
|
||||||
|
surAppui={() => {
|
||||||
|
setErreurScan(null);
|
||||||
|
setScanOuvert(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{erreurScan ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||||
|
{erreurScan}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
|
||||||
|
Description <Text style={{ color: t.danger }}>*</Text>
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="Description du problème"
|
||||||
|
placeholder="Ex. : la porte ne se ferme plus au 3ᵉ étage"
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
multiline
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderRadius: 9,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 9,
|
||||||
|
minHeight: 64,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="switch"
|
||||||
|
accessibilityLabel="Une personne est-elle bloquée ?"
|
||||||
|
accessibilityState={{ checked: personneBloquee }}
|
||||||
|
onPress={() => setPersonneBloquee((v) => !v)}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: personneBloquee ? t.prioBloqueFond : t.surface,
|
||||||
|
borderColor: personneBloquee ? t.danger : t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 13,
|
||||||
|
color: personneBloquee ? t.danger : t.encre,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Une personne est-elle bloquée ?
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 13, color: personneBloquee ? t.danger : t.encre3 }}>
|
||||||
|
{personneBloquee ? 'OUI' : 'NON'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{creation.isError ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||||
|
{creation.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Envoyer la demande"
|
||||||
|
desactive={!valide || creation.isPending}
|
||||||
|
surAppui={envoyer}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
apps/mobile/src/composants/panneau-demandes.tsx
Normal file
238
apps/mobile/src/composants/panneau-demandes.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { FlatList, Text, TextInput, View } from 'react-native';
|
||||||
|
import type { RequestSummary } from '@siop/shared';
|
||||||
|
import {
|
||||||
|
useApproveRequest,
|
||||||
|
useRejectRequest,
|
||||||
|
useRequests,
|
||||||
|
} from '@/api/exploitation';
|
||||||
|
import { useUsers } from '@/api/pilotage';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
import { BoutonTel, ChoixTel } from './ui';
|
||||||
|
|
||||||
|
/** Panneau Demandes — UN SEUL composant pour tous les rôles (maquette
|
||||||
|
* « mobile ouvert à tous les rôles », écran 5) : le Demandeur y crée et
|
||||||
|
* suit SES demandes ; Gestionnaire/Dispatcher/Administrateur y approuvent
|
||||||
|
* ou rejettent (motif obligatoire, comme au web) ; Vue seule consulte sans
|
||||||
|
* bouton. L'API renvoie déjà la liste correctement scopée (ADR-003) — ce
|
||||||
|
* composant affiche ce qu'on lui donne et ne propose que les actions
|
||||||
|
* permises par `can(...)`. */
|
||||||
|
export function PanneauDemandes() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const { data: requests } = useRequests();
|
||||||
|
const peutTraiter = can('REQUESTS', 'edit');
|
||||||
|
const peutCreer = can('REQUESTS', 'create');
|
||||||
|
const toutes = requests ?? [];
|
||||||
|
const aTraiter = toutes.filter((r) => r.status === 'RECEIVED').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, gap: 10 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
Demandes
|
||||||
|
</Text>
|
||||||
|
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
|
||||||
|
{aTraiter} à traiter · {toutes.length} au total
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{peutCreer ? (
|
||||||
|
<BoutonTel libelle="+ Nouvelle demande" surAppui={() => router.push('/demandes/nouvelle')} />
|
||||||
|
) : null}
|
||||||
|
<FlatList
|
||||||
|
data={toutes}
|
||||||
|
keyExtractor={(r) => r.id}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Aucune demande pour l'instant.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: r }) => <CarteDemande demande={r} peutTraiter={peutTraiter} />}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_STATUT: Record<RequestSummary['status'], (t: Tokens) => [string, string]> = {
|
||||||
|
RECEIVED: (t) => [t.stAttente, t.stAttenteFond],
|
||||||
|
APPROVED: (t) => [t.stTermine, t.stTermineFond],
|
||||||
|
REJECTED: (t) => [t.stAnnule, t.stAnnuleFond],
|
||||||
|
};
|
||||||
|
const LABEL_STATUT: Record<RequestSummary['status'], string> = {
|
||||||
|
RECEIVED: 'Reçue',
|
||||||
|
APPROVED: 'Approuvée',
|
||||||
|
REJECTED: 'Rejetée',
|
||||||
|
};
|
||||||
|
|
||||||
|
function CarteDemande({ demande: r, peutTraiter }: { demande: RequestSummary; peutTraiter: boolean }) {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: users } = useUsers();
|
||||||
|
const approbation = useApproveRequest();
|
||||||
|
const rejet = useRejectRequest();
|
||||||
|
// Un seul panneau ouvert à la fois : approbation (avec assignation — ce
|
||||||
|
// n'est pas au Gestionnaire d'agir comme un technicien, il délègue) ou
|
||||||
|
// rejet (motif obligatoire). null = fermé, les deux boutons côte à côte.
|
||||||
|
const [panneau, setPanneau] = useState<'approuver' | 'rejeter' | null>(null);
|
||||||
|
const [motif, setMotif] = useState('');
|
||||||
|
const [assigneId, setAssigneId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const techniciens = (users ?? []).filter(
|
||||||
|
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
|
||||||
|
);
|
||||||
|
const technicien = techniciens.find((tt) => tt.id === assigneId) ?? null;
|
||||||
|
|
||||||
|
const [enc, fond] = STYLE_STATUT[r.status](t);
|
||||||
|
const enCours = r.status === 'RECEIVED';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: r.isPersonTrapped && enCours ? t.danger : t.bordure,
|
||||||
|
borderWidth: r.isPersonTrapped && enCours ? 1.5 : 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.reference}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: enc,
|
||||||
|
backgroundColor: fond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LABEL_STATUT[r.status]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||||
|
{r.isPersonTrapped ? '⚠ ' : ''}
|
||||||
|
{r.description}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||||
|
Asc. {r.assetReference} — {r.siteName} · {r.requesterLabel}
|
||||||
|
</Text>
|
||||||
|
{r.rejectionReason ? (
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 12, color: t.danger }}>
|
||||||
|
Motif du rejet : {r.rejectionReason}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{peutTraiter && enCours ? (
|
||||||
|
panneau === null ? (
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 4 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<BoutonTel libelle="Approuver → OT" variante="vert" surAppui={() => setPanneau('approuver')} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<BoutonTel libelle="Rejeter" variante="gris" surAppui={() => setPanneau('rejeter')} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : panneau === 'approuver' ? (
|
||||||
|
<View style={{ gap: 6, marginTop: 4 }}>
|
||||||
|
<ChoixTel
|
||||||
|
libelle="Assigner à"
|
||||||
|
valeur={technicien ? { id: technicien.id, label: technicien.displayName } : null}
|
||||||
|
options={techniciens.map((tt) => ({ id: tt.id, label: tt.displayName }))}
|
||||||
|
surChoix={setAssigneId}
|
||||||
|
/>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Annuler"
|
||||||
|
variante="contour"
|
||||||
|
surAppui={() => {
|
||||||
|
setPanneau(null);
|
||||||
|
setAssigneId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Confirmer l'approbation"
|
||||||
|
variante="vert"
|
||||||
|
desactive={approbation.isPending}
|
||||||
|
surAppui={() =>
|
||||||
|
approbation.mutate(
|
||||||
|
{
|
||||||
|
id: r.id,
|
||||||
|
priority: r.isPersonTrapped ? 'PERSON_TRAPPED' : 'HIGH',
|
||||||
|
assigneeIds: assigneId ? [assigneId] : undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setPanneau(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={{ gap: 6, marginTop: 4 }}>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="Motif du rejet"
|
||||||
|
placeholder="Motif (lisible par le demandeur) *"
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
value={motif}
|
||||||
|
onChangeText={setMotif}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderRadius: 9,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 9,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Annuler"
|
||||||
|
variante="contour"
|
||||||
|
surAppui={() => {
|
||||||
|
setPanneau(null);
|
||||||
|
setMotif('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Confirmer le rejet"
|
||||||
|
desactive={motif.trim().length < 3}
|
||||||
|
surAppui={() =>
|
||||||
|
rejet.mutate(
|
||||||
|
{ id: r.id, reason: motif.trim() },
|
||||||
|
{ onSuccess: () => setPanneau(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
import { Alert, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
import { WORK_ORDER_STATUS_LABELS, type WorkOrderStatus } from '@siop/shared';
|
import { WORK_ORDER_STATUS_LABELS, type WorkOrderStatus } from '@siop/shared';
|
||||||
|
import { useHorsLigne, useLogout, useMe } from '@/auth/session';
|
||||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
/** Briques d'écran de la maquette R4 — cartes, chips, boutons, sélecteur. */
|
/** Briques d'écran de la maquette R4 — cartes, chips, boutons, sélecteur. */
|
||||||
@@ -263,3 +264,76 @@ export function ChoixTel({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Entête commune à tous les onglets (D'où qu'on parte, on peut se
|
||||||
|
* déconnecter — trouvée manquante en recette : seule « Ma journée » avait
|
||||||
|
* ce contrôle, ailleurs l'utilisateur se sentait bloqué). Le rôle affiché
|
||||||
|
* est CELUI du compte connecté, pas un libellé figé — le mobile n'est plus
|
||||||
|
* réservé aux techniciens (voir décision élargissant le périmètre). */
|
||||||
|
export function EnteteTabs() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const logout = useLogout();
|
||||||
|
|
||||||
|
const seDeconnecter = () => {
|
||||||
|
Alert.alert('Se déconnecter ?', me?.displayName ? `Compte : ${me.displayName}` : undefined, [
|
||||||
|
{ text: 'Annuler', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Se déconnecter',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => void logout().then(() => router.replace('/connexion')),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Text style={{ color: t.safran, fontSize: 15 }}>♦</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>SIOP</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre3 }}>
|
||||||
|
{me?.role.name ?? '·'}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginLeft: 'auto' }}>
|
||||||
|
<Text
|
||||||
|
accessibilityLabel="État de synchronisation"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: horsLigne ? t.stAttente : t.stTermine,
|
||||||
|
backgroundColor: horsLigne ? t.stAttenteFond : t.stTermineFond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{horsLigne ? 'Hors-ligne' : 'Synchro à jour'}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Compte et déconnexion"
|
||||||
|
onPress={seDeconnecter}
|
||||||
|
hitSlop={8}
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 13,
|
||||||
|
backgroundColor: t.stEncours,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 10 }}>
|
||||||
|
{(me?.displayName ?? '·')
|
||||||
|
.split(' ')
|
||||||
|
.map((m) => m[0])
|
||||||
|
.join('')
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function enfilerBilan(
|
|||||||
patchDetail(queryClient, ot.id, (c) => ({
|
patchDetail(queryClient, ot.id, (c) => ({
|
||||||
...c,
|
...c,
|
||||||
report: {
|
report: {
|
||||||
note: c.report?.note ?? null,
|
note: corps.note !== undefined ? corps.note : (c.report?.note ?? null),
|
||||||
doorState: labels.doorStateId !== undefined ? (labels.doorStateId ?? null) : (c.report?.doorState ?? null),
|
doorState: labels.doorStateId !== undefined ? (labels.doorStateId ?? null) : (c.report?.doorState ?? null),
|
||||||
cabinPosition:
|
cabinPosition:
|
||||||
labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null),
|
labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null),
|
||||||
|
|||||||
149
apps/web/e2e/parcours-r5.spec.ts
Normal file
149
apps/web/e2e/parcours-r5.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recette R5 (plan de releases) : la bibliothèque EST le corpus (bandeau
|
||||||
|
* 09-08, statut d'indexation, interrupteur, réindexation explicite) ; puis
|
||||||
|
* l'assistant « sourcé ou silencieux » (réponse citée depuis le PDF téléversé,
|
||||||
|
* refus honnête et chiffré sinon) ; enfin la suggestion de bilan sur un OT
|
||||||
|
* (codes existants, appliqués par le geste humain, liseré « suggéré »).
|
||||||
|
* Le service IA tourne avec l'embeddeur déterministe et des seuils abaissés
|
||||||
|
* (ci.yml) — c'est le CIRCUIT qui se recette, le vrai modèle se recette en
|
||||||
|
* local (journal R5.1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
|
||||||
|
/** PDF 1 page minimal mais VALIDE (xref calculée) — pypdf doit pouvoir en
|
||||||
|
* extraire le texte : c'est ce qui alimente l'index côté service IA. */
|
||||||
|
function pdfMinimal(texte: string): Buffer {
|
||||||
|
const contenu = `BT /F1 12 Tf 72 720 Td (${texte.replace(/[()\\]/g, '\\$&')}) Tj ET`;
|
||||||
|
const objets = [
|
||||||
|
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||||
|
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||||
|
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R ' +
|
||||||
|
'/Resources << /Font << /F1 5 0 R >> >> >>',
|
||||||
|
`<< /Length ${contenu.length} >>\nstream\n${contenu}\nendstream`,
|
||||||
|
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||||
|
];
|
||||||
|
let corps = '%PDF-1.4\n';
|
||||||
|
const offsets: number[] = [];
|
||||||
|
objets.forEach((objet, i) => {
|
||||||
|
offsets.push(corps.length);
|
||||||
|
corps += `${i + 1} 0 obj\n${objet}\nendobj\n`;
|
||||||
|
});
|
||||||
|
const debutXref = corps.length;
|
||||||
|
corps += `xref\n0 ${objets.length + 1}\n0000000000 65535 f \n`;
|
||||||
|
for (const offset of offsets) corps += `${String(offset).padStart(10, '0')} 00000 n \n`;
|
||||||
|
corps += `trailer\n<< /Size ${objets.length + 1} /Root 1 0 R >>\nstartxref\n${debutXref}\n%%EOF`;
|
||||||
|
return Buffer.from(corps, 'latin1');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connexionDemo(page: import('@playwright/test').Page, nom: RegExp) {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: nom }).click();
|
||||||
|
await expect(page.locator('.topbar')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('recette R5 : corpus → réindexation → assistant sourcé → refus honnête', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000); // deux réindexations complètes du corpus réel
|
||||||
|
const fichier = `e2e-notice-${suffix}.pdf`;
|
||||||
|
|
||||||
|
// 1 · Nadia (gestionnaire, ASSETS.edit) — la bibliothèque est le corpus
|
||||||
|
await connexionDemo(page, /Nadia Berrada/);
|
||||||
|
await page.getByRole('link', { name: 'Fichiers' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /corpus de l'assistant/ })).toBeVisible();
|
||||||
|
await expect(page.locator('.avert')).toContainText('Loi 09-08'); // l'anonymisation est DITE
|
||||||
|
|
||||||
|
// 2 · Téléverser une notice PDF de test rattachée à A1
|
||||||
|
await page.getByRole('button', { name: 'Téléverser' }).click();
|
||||||
|
await page.getByLabel("Rattacher à l'appareil *").selectOption({ index: 1 });
|
||||||
|
await page.getByLabel('Choisir un fichier').setInputFiles({
|
||||||
|
name: fichier,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: pdfMinimal(
|
||||||
|
'Couple de serrage des coulisseaux de guides : 25 Nm. Verifier le jeu lateral.',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: 'Téléverser', exact: true }).last().click();
|
||||||
|
const vignette = page.locator('tr', { hasText: fichier }); // tableau du corpus (arbitrage référent)
|
||||||
|
await expect(vignette).toBeVisible();
|
||||||
|
await expect(vignette.locator('.st')).toHaveText('à indexer'); // jamais indexé à la naissance
|
||||||
|
|
||||||
|
// 3 · Réindexer tout — geste explicite, bilan chiffré, statut à jour
|
||||||
|
await page.getByRole('button', { name: 'Réindexer tout' }).click();
|
||||||
|
// l'ingestion réelle (MinIO + pypdf + embeddings) peut dépasser les 5 s
|
||||||
|
await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 });
|
||||||
|
await expect(vignette.locator('.st')).toContainText('indexé ·');
|
||||||
|
await expect(vignette.locator('.st')).toContainText('extrait');
|
||||||
|
|
||||||
|
// 4 · L'assistant répond SOURCÉ depuis ce PDF (embeddeur déterministe :
|
||||||
|
// la question reprend les mots de la notice)
|
||||||
|
await page.getByRole('link', { name: 'Assistant' }).click();
|
||||||
|
await expect(page.getByText('répond UNIQUEMENT depuis votre bibliothèque')).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByLabel('Poser une question')
|
||||||
|
.fill('couple de serrage des coulisseaux de guides ?');
|
||||||
|
await page.getByRole('button', { name: 'Envoyer' }).click();
|
||||||
|
const source = page.locator('.source', { hasText: fichier });
|
||||||
|
await expect(source).toBeVisible();
|
||||||
|
await expect(source.locator('.extrait')).toContainText('25 Nm'); // l'extrait EXACT
|
||||||
|
await expect(source.locator('.ou')).toContainText('p. 1'); // la citation pointe la page
|
||||||
|
await expect(page.locator('.msg-r .avert')).toContainText('vous validez'); // pas un disclaimer caché
|
||||||
|
|
||||||
|
// 5 · Sans source au-dessus du seuil : refus honnête, chiffré, avec l'action utile
|
||||||
|
await page.getByLabel('Poser une question').fill('xylophone quantique zorglub ?');
|
||||||
|
await page.getByRole('button', { name: 'Envoyer' }).click();
|
||||||
|
const refus = page.locator('.refus');
|
||||||
|
await expect(refus).toBeVisible();
|
||||||
|
await expect(refus).toContainText('je préfère ne pas inventer');
|
||||||
|
await expect(refus).toContainText(/J'ai cherché dans \d+ documents? indexés? et \d+ bilans?/);
|
||||||
|
await expect(refus.getByRole('link', { name: /Téléverser la notice/ })).toBeVisible();
|
||||||
|
|
||||||
|
// 6 · L'interrupteur exclut la notice — réversible, effectif à la réindexation
|
||||||
|
await page.getByRole('link', { name: 'Fichiers' }).click();
|
||||||
|
await vignette.getByRole('switch').click();
|
||||||
|
await expect(vignette.locator('.st')).toHaveText('exclu du corpus');
|
||||||
|
await page.getByRole('button', { name: 'Réindexer tout' }).click();
|
||||||
|
await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
// Ménage : la notice de test sort de la bibliothèque
|
||||||
|
page.on('dialog', (d) => void d.accept());
|
||||||
|
await vignette.getByRole('button', { name: 'Supprimer' }).click();
|
||||||
|
await expect(vignette).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recette R5 : suggestion de bilan — codes existants, appliqués par l\'humain', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const titre = `E2E Suggestion ${suffix}`;
|
||||||
|
|
||||||
|
// 1 · Nadia crée un OT de dépannage sur A1
|
||||||
|
await connexionDemo(page, /Nadia Berrada/);
|
||||||
|
await page.getByRole('link', { name: 'Ordres de travail' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
|
||||||
|
await page.getByLabel('Objet *').fill(titre);
|
||||||
|
await page.getByLabel('Équipement *').selectOption({ index: 1 });
|
||||||
|
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: titre })).toBeVisible();
|
||||||
|
|
||||||
|
// 2 · Décrire pour suggérer — l'IA propose des codes EXISTANTS, justifiés
|
||||||
|
await page
|
||||||
|
.getByLabel('Décrire pour suggérer (optionnel)')
|
||||||
|
.fill('Frottement mécanique sur les portes, nettoyage et graissage effectués, essais OK.');
|
||||||
|
await page.getByRole('button', { name: /Suggérer les codes/ }).click();
|
||||||
|
const suggestions = page.locator('.suggestion');
|
||||||
|
await expect(suggestions.first()).toBeVisible();
|
||||||
|
await expect(page.locator('.confiance').first()).toContainText('confiance');
|
||||||
|
|
||||||
|
// 3 · « Appliquer les N » : les sélecteurs se pré-remplissent, liseré « suggéré »
|
||||||
|
await page.getByRole('button', { name: /Appliquer les \d/ }).click();
|
||||||
|
await expect(page.locator('.champ-b[data-suggere]').first()).toBeVisible();
|
||||||
|
const anomalie = page.locator('#bilan-ANOMALY');
|
||||||
|
await expect(anomalie).not.toHaveValue('');
|
||||||
|
|
||||||
|
// 4 · Un choix MANUEL retire le liseré du champ concerné (l'humain a repris la main)
|
||||||
|
const champAnomalie = page.locator('.champ-b', { has: anomalie });
|
||||||
|
await expect(champAnomalie).toHaveAttribute('data-suggere', 'true');
|
||||||
|
await anomalie.selectOption({ index: 1 });
|
||||||
|
await expect(champAnomalie).not.toHaveAttribute('data-suggere', 'true');
|
||||||
|
});
|
||||||
@@ -14,6 +14,10 @@ const API_ENV = {
|
|||||||
DATABASE_URL:
|
DATABASE_URL:
|
||||||
process.env.DATABASE_URL ?? 'postgresql://siop:siop@localhost:5432/siop',
|
process.env.DATABASE_URL ?? 'postgresql://siop:siop@localhost:5432/siop',
|
||||||
REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||||
|
// R5 : le service IA écoute sur 8000 (CI : embeddeur déterministe,
|
||||||
|
// seuils abaissés — voir ci.yml ; localement : lancez apps/ai avant).
|
||||||
|
AI_SERVICE_URL: process.env.AI_SERVICE_URL ?? 'http://localhost:8000',
|
||||||
|
AI_SERVICE_TOKEN: process.env.AI_SERVICE_TOKEN ?? 'dev-only-ai-token',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Coquille } from '@/layout/coquille';
|
|||||||
import PageAchats from '@/pages/achats';
|
import PageAchats from '@/pages/achats';
|
||||||
import PageActivation from '@/pages/activation';
|
import PageActivation from '@/pages/activation';
|
||||||
import PageAscenseurs from '@/pages/ascenseurs';
|
import PageAscenseurs from '@/pages/ascenseurs';
|
||||||
|
import PageAssistant from '@/pages/assistant';
|
||||||
import PageBibliotheque from '@/pages/bibliotheque';
|
import PageBibliotheque from '@/pages/bibliotheque';
|
||||||
import PageCategories from '@/pages/categories';
|
import PageCategories from '@/pages/categories';
|
||||||
import PageCompteurs from '@/pages/compteurs';
|
import PageCompteurs from '@/pages/compteurs';
|
||||||
@@ -67,6 +68,7 @@ export default function App() {
|
|||||||
<Route path="/tiers" element={dansCoquille(<PageTiers />)} />
|
<Route path="/tiers" element={dansCoquille(<PageTiers />)} />
|
||||||
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
|
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
|
||||||
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
|
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
|
||||||
|
<Route path="/assistant" element={dansCoquille(<PageAssistant />)} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
57
apps/web/src/api/assistant.ts
Normal file
57
apps/web/src/api/assistant.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { AssistantAsk, SuggestBilan } from '@siop/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
/** Hooks R5 — l'assistant passe par l'API NestJS (le service IA n'est jamais
|
||||||
|
* appelé du navigateur, ADR-004 §4). Le 503 est un état ATTENDU du contrat
|
||||||
|
* (service éteint ou pas encore déployé) : les écrans l'affichent posément. */
|
||||||
|
|
||||||
|
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||||
|
if (res.response.status === 503) {
|
||||||
|
throw new Error('Assistant indisponible pour le moment — réessayez dans un instant.');
|
||||||
|
}
|
||||||
|
if (res.error || res.data === undefined) {
|
||||||
|
const message =
|
||||||
|
(res.error as { message?: string } | undefined)?.message ??
|
||||||
|
`Le serveur a répondu ${res.response.status}`;
|
||||||
|
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAskAssistant() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AssistantAsk) =>
|
||||||
|
unwrap(await api.POST('/assistant/ask', { body })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSuggestBilan() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: SuggestBilan) =>
|
||||||
|
unwrap(await api.POST('/assistant/suggest-bilan', { body })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReindexAssistant() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => unwrap(await api.POST('/assistant/reindex')),
|
||||||
|
// la réindexation met à jour indexedAt/chunkCount de chaque document
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['documents'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetDocumentCorpus() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { id: string; inCorpus: boolean }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH('/documents/{id}/corpus', {
|
||||||
|
params: { path: { id: input.id } },
|
||||||
|
body: { inCorpus: input.inCorpus },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['documents'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
309
apps/web/src/api/schema.d.ts
vendored
309
apps/web/src/api/schema.d.ts
vendored
@@ -407,6 +407,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/documents/{id}/corpus": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/** Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation) */
|
||||||
|
patch: operations["updateDocumentCorpus"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/documents/{id}": {
|
"/documents/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -441,6 +458,74 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/assistant/ask": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2) */
|
||||||
|
post: operations["askAssistant"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/assistant/suggest-bilan": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Suggérer des codes de bilan depuis une description libre (D1 — l’humain valide) */
|
||||||
|
post: operations["suggestBilan"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/assistant/reindex": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à l’ingestion — D4) */
|
||||||
|
post: operations["reindexAssistant"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/assistant/transcribe": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Dictée (R5 D5, opt-in) — l’audio est transcrit puis JAMAIS conservé, à relire avant tout usage */
|
||||||
|
post: operations["transcribeAudio"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/search": {
|
"/search": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1331,6 +1416,11 @@ export interface components {
|
|||||||
status: "active" | "invited" | "disabled";
|
status: "active" | "invited" | "disabled";
|
||||||
isDemo: boolean;
|
isDemo: boolean;
|
||||||
hourlyRate: number | null;
|
hourlyRate: number | null;
|
||||||
|
assignedSites: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
RolesResponse: {
|
RolesResponse: {
|
||||||
@@ -1356,6 +1446,7 @@ export interface components {
|
|||||||
roleId: string;
|
roleId: string;
|
||||||
teamIds?: string[];
|
teamIds?: string[];
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
locationIds?: string[];
|
||||||
};
|
};
|
||||||
UserAdmin: {
|
UserAdmin: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1379,6 +1470,11 @@ export interface components {
|
|||||||
status: "active" | "invited" | "disabled";
|
status: "active" | "invited" | "disabled";
|
||||||
isDemo: boolean;
|
isDemo: boolean;
|
||||||
hourlyRate: number | null;
|
hourlyRate: number | null;
|
||||||
|
assignedSites: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
UserUpdate: {
|
UserUpdate: {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
@@ -1388,6 +1484,7 @@ export interface components {
|
|||||||
teamIds?: string[];
|
teamIds?: string[];
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
hourlyRate?: number | null;
|
hourlyRate?: number | null;
|
||||||
|
locationIds?: string[];
|
||||||
};
|
};
|
||||||
DocumentsResponse: {
|
DocumentsResponse: {
|
||||||
documents: {
|
documents: {
|
||||||
@@ -1403,6 +1500,9 @@ export interface components {
|
|||||||
uploadedByName: string | null;
|
uploadedByName: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
inCorpus: boolean;
|
||||||
|
indexedAt: string | null;
|
||||||
|
chunkCount: number;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
Document: {
|
Document: {
|
||||||
@@ -1418,6 +1518,12 @@ export interface components {
|
|||||||
uploadedByName: string | null;
|
uploadedByName: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
inCorpus: boolean;
|
||||||
|
indexedAt: string | null;
|
||||||
|
chunkCount: number;
|
||||||
|
};
|
||||||
|
DocumentCorpusUpdate: {
|
||||||
|
inCorpus: boolean;
|
||||||
};
|
};
|
||||||
AnalyticsSummary: {
|
AnalyticsSummary: {
|
||||||
months: number;
|
months: number;
|
||||||
@@ -1446,6 +1552,53 @@ export interface components {
|
|||||||
total: number;
|
total: number;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
AssistantAnswer: {
|
||||||
|
/** @enum {string} */
|
||||||
|
mode: "EXTRACTIVE" | "GENERATED" | "REFUSAL";
|
||||||
|
answer: string | null;
|
||||||
|
excerpts: {
|
||||||
|
/** @enum {string} */
|
||||||
|
sourceType: "DOCUMENT" | "WORK_ORDER";
|
||||||
|
documentId: string | null;
|
||||||
|
workOrderId: string | null;
|
||||||
|
title: string;
|
||||||
|
locator: string;
|
||||||
|
content: string;
|
||||||
|
score: number;
|
||||||
|
}[];
|
||||||
|
corpus: {
|
||||||
|
documents: number;
|
||||||
|
reports: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
AssistantAsk: {
|
||||||
|
question: string;
|
||||||
|
};
|
||||||
|
BilanSuggestions: {
|
||||||
|
suggestions: {
|
||||||
|
/** @enum {string} */
|
||||||
|
field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED";
|
||||||
|
/** Format: uuid */
|
||||||
|
valueId: string;
|
||||||
|
label: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
confidence: "HIGH" | "MEDIUM";
|
||||||
|
similarReports: number;
|
||||||
|
score: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
SuggestBilan: {
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
ReindexResult: {
|
||||||
|
documentsIndexed: number;
|
||||||
|
documentsSkipped: number;
|
||||||
|
reportsIndexed: number;
|
||||||
|
chunks: number;
|
||||||
|
};
|
||||||
|
TranscriptionResult: {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
SearchResponse: {
|
SearchResponse: {
|
||||||
workOrders: {
|
workOrders: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -2918,6 +3071,39 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
updateDocumentCorpus: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DocumentCorpusUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Document mis à jour */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Document"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Inconnu */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
deleteDocument: {
|
deleteDocument: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2967,6 +3153,129 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
askAssistant: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AssistantAsk"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Réponse sourcée ou refus */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AssistantAnswer"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
suggestBilan: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SuggestBilan"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Suggestions (codes existants seulement) */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BilanSuggestions"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
reindexAssistant: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Bilan d’indexation */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ReindexResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
transcribeAudio: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": {
|
||||||
|
/** Format: binary */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Texte transcrit — à relire (D1) */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TranscriptionResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Dictée non activée ou service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
globalSearch: {
|
globalSearch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type DocumentDto,
|
type DocumentDto,
|
||||||
type DocumentKind,
|
type DocumentKind,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import { useSetDocumentCorpus } from '@/api/assistant';
|
||||||
import {
|
import {
|
||||||
blobDocument,
|
blobDocument,
|
||||||
ouvrirDocument,
|
ouvrirDocument,
|
||||||
@@ -63,7 +64,58 @@ function ApercuVignette({ doc }: { doc: DocumentDto }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VignetteDoc({ doc, surSuppression }: { doc: DocumentDto; surSuppression?: (id: string) => void }) {
|
/** Statut corpus (R5, D3) : lisible d'un coup d'œil, jamais ambigu. */
|
||||||
|
export function StatutCorpus({ doc }: { doc: DocumentDto }) {
|
||||||
|
if (doc.contentType !== 'application/pdf') {
|
||||||
|
return <span className="st exclu">image — non indexable</span>;
|
||||||
|
}
|
||||||
|
if (!doc.inCorpus) return <span className="st exclu">exclu du corpus</span>;
|
||||||
|
if (!doc.indexedAt) return <span className="st encours">à indexer</span>;
|
||||||
|
const quand = new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(doc.indexedAt));
|
||||||
|
return (
|
||||||
|
<span className="st ok">
|
||||||
|
indexé · {quand} · {doc.chunkCount} extrait{doc.chunkCount > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interrupteur d'inclusion au corpus (R5, D3) — PDF seulement, réversible.
|
||||||
|
* Une image non indexable s'affiche ÉTEINTE quel que soit l'état stocké :
|
||||||
|
* l'interrupteur montre la réalité du corpus, pas une colonne de base. */
|
||||||
|
export function InterrupteurCorpus({ doc }: { doc: DocumentDto }) {
|
||||||
|
const bascule = useSetDocumentCorpus();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={doc.contentType === 'application/pdf' && doc.inCorpus}
|
||||||
|
aria-label={doc.inCorpus ? 'Exclure du corpus' : 'Inclure au corpus'}
|
||||||
|
title={
|
||||||
|
doc.contentType !== 'application/pdf'
|
||||||
|
? 'Seuls les PDF sont indexables'
|
||||||
|
: doc.inCorpus
|
||||||
|
? 'Exclure du corpus (effectif à la prochaine réindexation)'
|
||||||
|
: 'Inclure au corpus (effectif à la prochaine réindexation)'
|
||||||
|
}
|
||||||
|
className="interrupteur corpus"
|
||||||
|
disabled={bascule.isPending || doc.contentType !== 'application/pdf'}
|
||||||
|
onClick={() => bascule.mutate({ id: doc.id, inCorpus: !doc.inCorpus })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VignetteDoc({
|
||||||
|
doc,
|
||||||
|
surSuppression,
|
||||||
|
}: {
|
||||||
|
doc: DocumentDto;
|
||||||
|
surSuppression?: (id: string) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="doc">
|
<div className="doc">
|
||||||
<ApercuVignette doc={doc} />
|
<ApercuVignette doc={doc} />
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ export const IcoStatistiques = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IcoAssistant = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<path d="M12 3l1.8 4.7L18.5 9l-4.7 1.8L12 15.5l-1.8-4.7L5.5 9l4.7-1.3z" />
|
||||||
|
<path d="M18.5 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const IcoPersonnes = () => (
|
export const IcoPersonnes = () => (
|
||||||
<svg {...base}>
|
<svg {...base}>
|
||||||
<circle cx="9" cy="8" r="3.5" />
|
<circle cx="9" cy="8" r="3.5" />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user