fix(mobile): R6.7 — assignation à un technicien lors de l'approbation

Le Gestionnaire approuvait une demande sur mobile sans pouvoir assigner
l'OT à un technicien (l'OT partait non assigné) — remonté en recette :
"ce n'est pas à lui d'agir comme un technicien". Le web le fait déjà
(PanneauApprobation, demandes.tsx).

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-08-02 20:44:21 +01:00
parent c9168ece16
commit ed9833e56d
3 changed files with 107 additions and 17 deletions

View File

@@ -80,5 +80,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- 🔄 **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). - 🔄 **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). - **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.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.
- 🔄 **Reprise ici** : reste de la recette R6 (Sites/Ascenseurs/Fichiers côté Gestionnaire à confirmer explicitement, puis passage Demandeur avec le nouveau périmètre de site) avant de considérer R6 close au même sens que R0R5. `expo-sharing` (ouverture de documents) et `expo-clipboard` (copie du lien d'activation) en réserve pour une prochaine étape native. Restes non bloquants inchangés : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer runbook §2 puis « Réindexer tout »), recette Android sur appareil physique, 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). - **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.
- 🔄 **Reprise ici** : reste de la recette R6 (Sites/Ascenseurs/Fichiers côté Gestionnaire à confirmer explicitement, puis passage Demandeur avec le nouveau périmètre de site et le scan QR) avant de considérer R6 close au même sens que R0R5. Question ouverte : le Gestionnaire garde par la matrice R2/R3 un accès `WORK_ORDERS` complet (peut techniquement démarrer/clôturer n'importe quel OT comme un technicien) établi depuis R2/R3, identique au web, pas une régression R6 ; à resserrer seulement si le référent le demande explicitement. `expo-sharing` (ouverture de documents) et `expo-clipboard` (copie du lien d'activation) en réserve pour une prochaine étape native. Restes non bloquants inchangés : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer runbook §2 puis « Réindexer tout »), recette Android sur appareil physique, calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire).
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5 (v1) + R6 en cours. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5 (v1) + R6 en cours.

View File

@@ -7,9 +7,10 @@ import {
useRejectRequest, useRejectRequest,
useRequests, useRequests,
} from '@/api/exploitation'; } from '@/api/exploitation';
import { useUsers } from '@/api/pilotage';
import { usePermissions } from '@/auth/use-permissions'; import { usePermissions } from '@/auth/use-permissions';
import { useTokens, type Tokens } from '@/theme/tokens'; import { useTokens, type Tokens } from '@/theme/tokens';
import { BoutonTel } from './ui'; import { BoutonTel, ChoixTel } from './ui';
/** Panneau Demandes — UN SEUL composant pour tous les rôles (maquette /** 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 * « mobile ouvert à tous les rôles », écran 5) : le Demandeur y crée et
@@ -68,9 +69,20 @@ const LABEL_STATUT: Record<RequestSummary['status'], string> = {
function CarteDemande({ demande: r, peutTraiter }: { demande: RequestSummary; peutTraiter: boolean }) { function CarteDemande({ demande: r, peutTraiter }: { demande: RequestSummary; peutTraiter: boolean }) {
const t = useTokens(); const t = useTokens();
const { data: users } = useUsers();
const approbation = useApproveRequest(); const approbation = useApproveRequest();
const rejet = useRejectRequest(); const rejet = useRejectRequest();
const [motif, setMotif] = useState<string | null>(null); // null = fermé // 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 [enc, fond] = STYLE_STATUT[r.status](t);
const enCours = r.status === 'RECEIVED'; const enCours = r.status === 'RECEIVED';
@@ -128,22 +140,51 @@ function CarteDemande({ demande: r, peutTraiter }: { demande: RequestSummary; pe
) : null} ) : null}
{peutTraiter && enCours ? ( {peutTraiter && enCours ? (
motif === null ? ( panneau === null ? (
<View style={{ flexDirection: 'row', gap: 8, marginTop: 4 }}> <View style={{ flexDirection: 'row', gap: 8, marginTop: 4 }}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<BoutonTel <BoutonTel libelle="Approuver → OT" variante="vert" surAppui={() => setPanneau('approuver')} />
libelle="Approuver → OT"
variante="vert"
surAppui={() =>
approbation.mutate({
id: r.id,
priority: r.isPersonTrapped ? 'PERSON_TRAPPED' : 'HIGH',
})
}
/>
</View> </View>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<BoutonTel libelle="Rejeter" variante="gris" surAppui={() => setMotif('')} /> <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> </View>
) : ( ) : (
@@ -167,13 +208,25 @@ function CarteDemande({ demande: r, peutTraiter }: { demande: RequestSummary; pe
/> />
<View style={{ flexDirection: 'row', gap: 8 }}> <View style={{ flexDirection: 'row', gap: 8 }}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<BoutonTel libelle="Annuler" variante="contour" surAppui={() => setMotif(null)} /> <BoutonTel
libelle="Annuler"
variante="contour"
surAppui={() => {
setPanneau(null);
setMotif('');
}}
/>
</View> </View>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<BoutonTel <BoutonTel
libelle="Confirmer le rejet" libelle="Confirmer le rejet"
desactive={motif.trim().length < 3} desactive={motif.trim().length < 3}
surAppui={() => rejet.mutate({ id: r.id, reason: motif.trim() }, { onSuccess: () => setMotif(null) })} surAppui={() =>
rejet.mutate(
{ id: r.id, reason: motif.trim() },
{ onSuccess: () => setPanneau(null) },
)
}
/> />
</View> </View>
</View> </View>

View File

@@ -4,6 +4,42 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
--- ---
## 2026-08-02 — Pr. Daaif (+ Claude) — R6.7 : assignation à l'approbation (mobile)
**Contexte** — en recette, le référent a signalé que le Gestionnaire doit pouvoir affecter un OT
à un technicien au moment où il approuve une demande — « ce n'est pas à lui d'agir comme un
technicien ». Vérification : le web le fait déjà (`PanneauApprobation`, `demandes.tsx`
sélecteur « Assigner à » parmi les techniciens actifs). Le mobile (`PanneauDemandes`, construit
en R6.1) approuvait directement avec la priorité seule, sans écran d'assignation — l'OT partait
non assigné, obligeant quelqu'un à revenir dessus ensuite pour le confier à un technicien.
**Actions**
- `apps/mobile/src/composants/panneau-demandes.tsx` : le bouton « Approuver → OT » ouvre
désormais un panneau (même patron que le rejet à motif) avec un `ChoixTel` « Assigner à »,
peuplé des techniciens actifs (`useUsers()` de `api/pilotage.ts`, filtre
`status === 'active' && role.name.startsWith('Technicien')` — identique au web), avant de
confirmer l'approbation (`assigneeIds` transmis à `POST /requests/{id}/approve`, déjà supporté
côté API et par le contrat — rien à changer côté serveur, seule l'UI mobile manquait cette
étape).
- Vérifié de bout en bout : demande créée par Karim → approuvée par Nadia (Gestionnaire) avec
Ahmed (Technicien) assigné → OT confirmé avec `assignees: [Ahmed Benali]`. Typecheck/tests/lint
verts sur les 4 paquets.
**Décisions**
- Le périmètre plus large — le Gestionnaire garde par la matrice R2/R3 `WORK_ORDERS` en accès
complet (`view/viewOther/create/edit/delete`), ce qui lui permet TECHNIQUEMENT de démarrer/
clôturer n'importe quel OT comme le ferait un technicien — n'est pas touché ici : c'est un
choix de matrice établi depuis R2/R3, identique sur le web, pas une régression du mobile R6.
Signalé au référent comme question ouverte séparée si un resserrement est souhaité.
**Prochaine étape** : reste de la recette R6 (Sites/Ascenseurs/Fichiers, puis passage Demandeur
avec le nouveau périmètre de site et le scan QR) ; question ouverte sur le périmètre d'édition OT
du Gestionnaire à trancher si besoin.
---
## 2026-08-02 — Pr. Daaif (+ Claude) — R6.6 : Demandeur restreint à son site ## 2026-08-02 — Pr. Daaif (+ Claude) — R6.6 : Demandeur restreint à son site
**Contexte** — en recette, le référent a remarqué que Karim Doukkali (Demandeur) voit tout le **Contexte** — en recette, le référent a remarqué que Karim Doukkali (Demandeur) voit tout le