mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
@@ -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 R0→R5. `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 R0→R5. 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 R0→R5 (v1) + R6 en cours.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5 (v1) + R6 en cours.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user