mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r6): R6.6 — Demandeur restreint à son site
Trouvé en recette : GET /assets/options n'avait aucun filtre — tout Demandeur voyait le parc complet dans le sélecteur d'équipement de "Nouvelle demande", sur le web ET le mobile (même endpoint partagé). Risque réel : signaler accidentellement une panne sur un ascenseur qu'on ne gère pas. Corrigé à la racine, sur les deux plateformes à la fois : - Relation many-to-many User↔Location (assignedSites/assignedUsers, migration r6_demandeur_sites, même style que Team.members) — vide = aucune restriction, comportement historique inchangé pour tous les rôles sauf un Demandeur affecté à un site. - AssetsService.allowedLocationIds(user) : sites + zones filles autorisés, ou null si aucune restriction — réutilisée par options() ET par RequestsService.create (défense en profondeur : un assetId soumis directement hors périmètre est rejeté, 400). - UsersService : assertTopLevelSites (un Demandeur est affecté à un site, jamais une zone) ; invite()/update() branchent locationIds (remplace l'affectation, comme teamIds). - Web (personnes.tsx) : ModaleInvitation affiche les sites à cocher pour un rôle Demandeur ; colonne "Sites" éditable via une modale dédiée. - Mobile (formulaire-demande.tsx) : bouton "Scanner l'étiquette" en raccourci — résout uniquement contre les options déjà chargées (déjà filtrées), jamais de repli sur le parc complet qui annulerait la restriction. Aucun changement à useAssetOptions() : le filtrage serveur profite automatiquement au formulaire mobile. - Seed : Karim Doukkali (démo) rattaché à Tour Atlas. Bug trouvé en vérification avant tout commit : create() comparait allowed.includes(dto.assetId), mais allowed est une liste d'ids de sites/zones, pas d'ids d'appareils — aurait rejeté à tort tout signalement d'un Demandeur affecté, y compris dans son propre périmètre. Corrigé (comparaison sur asset.locationId) ; méthode renommée allowedAssetIds → allowedLocationIds pour que le nom dise ce qu'elle retourne. exploitation.e2e-spec.ts mis à jour (A1/C1 → A2/B1, dans le site de Karim — sinon rejetés par la nouvelle règle, comportement voulu). 79/80 tests API verts, le seul échec (documents-analytics, monthCost) est le flake calendaire déjà identifié cette session, sans rapport. Typecheck/tests/lint verts sur les 4 paquets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,5 +79,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
|||||||
- ✅ **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é.
|
- ✅ **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).
|
- 🔄 **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).
|
||||||
- 🔄 **Reprise ici** : suite de la recette Gestionnaire (Sites/Ascenseurs/Fichiers restent à confirmer explicitement), puis passage Demandeur (permissions les plus étroites) — 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.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).
|
||||||
- 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.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_LocationToUser" (
|
||||||
|
"A" UUID NOT NULL,
|
||||||
|
"B" UUID NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_LocationToUser_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_LocationToUser_B_index" ON "_LocationToUser"("B");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_LocationToUser" ADD CONSTRAINT "_LocationToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_LocationToUser" ADD CONSTRAINT "_LocationToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -49,6 +49,8 @@ model User {
|
|||||||
activationToken String? @unique
|
activationToken String? @unique
|
||||||
activationExpiresAt DateTime?
|
activationExpiresAt DateTime?
|
||||||
teams Team[]
|
teams Team[]
|
||||||
|
// R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction
|
||||||
|
assignedSites Location[]
|
||||||
// R2 — exploitation
|
// R2 — exploitation
|
||||||
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
||||||
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
||||||
@@ -111,6 +113,7 @@ model Location {
|
|||||||
partnerId String? @db.Uuid
|
partnerId String? @db.Uuid
|
||||||
partner Partner? @relation(fields: [partnerId], references: [id])
|
partner Partner? @relation(fields: [partnerId], references: [id])
|
||||||
assets Asset[]
|
assets Asset[]
|
||||||
|
assignedUsers User[] // reverse de User.assignedSites (R6.6)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -462,6 +462,19 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
|
|||||||
const karim = await parEmail('demandeur@demo.siop.ma');
|
const karim = await parEmail('demandeur@demo.siop.ma');
|
||||||
const annee = new Date().getFullYear();
|
const annee = new Date().getFullYear();
|
||||||
|
|
||||||
|
// R6.6 — Karim (Demandeur) est rattaché à Tour Atlas (cohérent avec le
|
||||||
|
// guardianName déjà seedé pour ce site) : son signalement se limite à ce
|
||||||
|
// parc, ses demandes historiques sur d'autres sites restent visibles.
|
||||||
|
const tourAtlas = await prisma.location.findFirst({
|
||||||
|
where: { name: 'Tour Atlas', parentId: null },
|
||||||
|
});
|
||||||
|
if (tourAtlas) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: karim },
|
||||||
|
data: { assignedSites: { connect: [{ id: tourAtlas.id }] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
||||||
(t) => t.label,
|
(t) => t.label,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type AssetCreate,
|
type AssetCreate,
|
||||||
type AssetUpdate,
|
type AssetUpdate,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import { type AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
import { AssetsService } from './assets.service';
|
import { AssetsService } from './assets.service';
|
||||||
@@ -32,10 +33,11 @@ export class AssetsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
|
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
|
||||||
* doit pouvoir désigner l'appareil qu'il signale. */
|
* doit pouvoir désigner l'appareil qu'il signale (filtré à son site s'il
|
||||||
|
* en a un, R6.6). */
|
||||||
@Get('options')
|
@Get('options')
|
||||||
options() {
|
options(@CurrentUser() user: AuthenticatedUser) {
|
||||||
return this.assetsService.options();
|
return this.assetsService.options(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
AssetsResponse,
|
AssetsResponse,
|
||||||
AssetUpdate,
|
AssetUpdate,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
const assetInclude = {
|
const assetInclude = {
|
||||||
@@ -29,9 +30,30 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
|||||||
export class AssetsService {
|
export class AssetsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
|
/** Sites + zones autorisés pour le signalement de `user`, ou `null` si aucune
|
||||||
async options(): Promise<AssetOptionsResponse> {
|
* restriction (comportement historique — tous les rôles sauf un Demandeur
|
||||||
|
* affecté à un site, R6.6). Réutilisée par `options()` ET par
|
||||||
|
* `RequestsService.create` pour que les deux filtres ne divergent jamais. */
|
||||||
|
async allowedLocationIds(user: AuthenticatedUser): Promise<string[] | null> {
|
||||||
|
const me = await this.prisma.user.findUnique({
|
||||||
|
where: { id: user.userId },
|
||||||
|
select: { assignedSites: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
const siteIds = (me?.assignedSites ?? []).map((s) => s.id);
|
||||||
|
if (siteIds.length === 0) return null;
|
||||||
|
const zones = await this.prisma.location.findMany({
|
||||||
|
where: { parentId: { in: siteIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return [...siteIds, ...zones.map((z) => z.id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options minimales pour le signalement — ouvert à tout rôle authentifié,
|
||||||
|
* filtré au périmètre du Demandeur s'il est affecté à un site (R6.6). */
|
||||||
|
async options(user: AuthenticatedUser): Promise<AssetOptionsResponse> {
|
||||||
|
const allowed = await this.allowedLocationIds(user);
|
||||||
const rows = await this.prisma.asset.findMany({
|
const rows = await this.prisma.asset.findMany({
|
||||||
|
where: allowed ? { locationId: { in: allowed } } : undefined,
|
||||||
include: { location: { include: { parent: true } } },
|
include: { location: { include: { parent: true } } },
|
||||||
orderBy: { reference: 'asc' },
|
orderBy: { reference: 'asc' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,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 })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
12
apps/mobile/src/api/schema.d.ts
vendored
12
apps/mobile/src/api/schema.d.ts
vendored
@@ -1416,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: {
|
||||||
@@ -1441,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 */
|
||||||
@@ -1464,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;
|
||||||
@@ -1473,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: {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { useState } from 'react';
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
import { Pressable, Text, TextInput, View } from 'react-native';
|
import { useRef, useState } from 'react';
|
||||||
|
import { Platform, Pressable, Text, TextInput, View } from 'react-native';
|
||||||
import { useAssetOptions, useCreateRequest } from '@/api/exploitation';
|
import { useAssetOptions, useCreateRequest } from '@/api/exploitation';
|
||||||
|
import { analyseScan } from '@/lib/scan';
|
||||||
import { useTokens } from '@/theme/tokens';
|
import { useTokens } from '@/theme/tokens';
|
||||||
import { BoutonTel, ChoixTel } from './ui';
|
import { BoutonTel, ChoixTel } from './ui';
|
||||||
|
|
||||||
/** Formulaire « Nouvelle demande » — mêmes champs que la modale de
|
/** Formulaire « Nouvelle demande » — mêmes champs que la modale de
|
||||||
* signalement du web (asset via `/assets/options`, accessible même sans
|
* signalement du web (asset via `/assets/options`, accessible même sans
|
||||||
* ASSETS.view ; description ; personne bloquée). Utilisé à la fois par
|
* ASSETS.view ; description ; personne bloquée). Utilisé à la fois par
|
||||||
* l'onglet du Demandeur et par le Menu des rôles gestion. */
|
* 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 }) {
|
export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => void }) {
|
||||||
const t = useTokens();
|
const t = useTokens();
|
||||||
const { data: options } = useAssetOptions();
|
const { data: options } = useAssetOptions();
|
||||||
@@ -15,9 +21,31 @@ export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => vo
|
|||||||
const [assetId, setAssetId] = useState<string | null>(null);
|
const [assetId, setAssetId] = useState<string | null>(null);
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [personneBloquee, setPersonneBloquee] = useState(false);
|
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 asset = (options ?? []).find((a) => a.id === assetId) ?? null;
|
||||||
const valide = !!assetId && description.trim().length >= 3;
|
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 = () => {
|
const envoyer = () => {
|
||||||
if (!assetId) return;
|
if (!assetId) return;
|
||||||
@@ -36,6 +64,62 @@ export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => vo
|
|||||||
options={(options ?? []).map((a) => ({ id: a.id, label: `${a.reference} — ${a.siteName}` }))}
|
options={(options ?? []).map((a) => ({ id: a.id, label: `${a.reference} — ${a.siteName}` }))}
|
||||||
surChoix={setAssetId}
|
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 }}>
|
<View style={{ gap: 4 }}>
|
||||||
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
|
||||||
Description <Text style={{ color: t.danger }}>*</Text>
|
Description <Text style={{ color: t.danger }}>*</Text>
|
||||||
|
|||||||
12
apps/web/src/api/schema.d.ts
vendored
12
apps/web/src/api/schema.d.ts
vendored
@@ -1416,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: {
|
||||||
@@ -1441,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 */
|
||||||
@@ -1464,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;
|
||||||
@@ -1473,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: {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import type { InvitationResponse } from '@siop/shared';
|
import type { InvitationResponse, UserAdmin } from '@siop/shared';
|
||||||
import {
|
import {
|
||||||
useCreateTeam,
|
useCreateTeam,
|
||||||
useInviteUser,
|
useInviteUser,
|
||||||
|
useLocations,
|
||||||
useResendInvitation,
|
useResendInvitation,
|
||||||
useRoles,
|
useRoles,
|
||||||
useTeams,
|
useTeams,
|
||||||
@@ -46,6 +47,7 @@ export default function PagePersonnes() {
|
|||||||
<th>Rôle</th>
|
<th>Rôle</th>
|
||||||
<th>Équipe</th>
|
<th>Équipe</th>
|
||||||
<th>Taux horaire</th>
|
<th>Taux horaire</th>
|
||||||
|
<th>Sites</th>
|
||||||
<th>Statut</th>
|
<th>Statut</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -75,6 +77,13 @@ export default function PagePersonnes() {
|
|||||||
editable={can('PEOPLE_TEAMS', 'edit')}
|
editable={can('PEOPLE_TEAMS', 'edit')}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{u.role.name === 'Demandeur' ? (
|
||||||
|
<CelluleSites utilisateur={u} editable={can('PEOPLE_TEAMS', 'edit')} />
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{u.status === 'active' ? (
|
{u.status === 'active' ? (
|
||||||
<span className="st termine">Actif</span>
|
<span className="st termine">Actif</span>
|
||||||
@@ -206,6 +215,90 @@ function CelluleTaux({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sites autorisés en signalement (R6.6) — un Demandeur ne choisit son
|
||||||
|
* équipement que parmi ceux de ses sites rattachés (web + mobile, même
|
||||||
|
* filtre côté API : `/assets/options`). */
|
||||||
|
function CelluleSites({
|
||||||
|
utilisateur,
|
||||||
|
editable,
|
||||||
|
}: {
|
||||||
|
utilisateur: UserAdmin;
|
||||||
|
editable: boolean;
|
||||||
|
}) {
|
||||||
|
const [modale, setModale] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="num">
|
||||||
|
{utilisateur.assignedSites.map((s) => s.name).join(', ') || '—'}
|
||||||
|
{editable ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="ml-1 px-2 py-0.5 text-[12px]"
|
||||||
|
aria-label="Modifier les sites rattachés"
|
||||||
|
onClick={() => setModale(true)}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<ModaleSites utilisateur={utilisateur} ouverte={modale} surFermeture={setModale} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModaleSites({
|
||||||
|
utilisateur,
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
utilisateur: UserAdmin;
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (o: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { data: locations } = useLocations();
|
||||||
|
const sites = (locations ?? []).filter((l) => l.parentId === null);
|
||||||
|
const maj = useUpdateUser();
|
||||||
|
const [coches, setCoches] = useState<string[]>(() => utilisateur.assignedSites.map((s) => s.id));
|
||||||
|
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
maj.mutate(
|
||||||
|
{ id: utilisateur.id, locationIds: coches },
|
||||||
|
{ onSuccess: () => surFermeture(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modale titre={`Sites de ${utilisateur.displayName}`} ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{sites.map((s) => (
|
||||||
|
<label key={s.id} className="flex items-center gap-2 text-[13px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={coches.includes(s.id)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCoches((c) => (e.target.checked ? [...c, s.id] : c.filter((id) => id !== s.id)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{s.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="note-douce">
|
||||||
|
Le signalement se limite aux sites cochés ci-dessus. Aucun site coché = aucune
|
||||||
|
restriction (accès au parc complet, comportement historique).
|
||||||
|
</p>
|
||||||
|
{maj.isError ? <p className="erreur-form" role="alert">{maj.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={maj.isPending}>Enregistrer</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ModaleInvitation({
|
function ModaleInvitation({
|
||||||
ouverte,
|
ouverte,
|
||||||
surFermeture,
|
surFermeture,
|
||||||
@@ -217,7 +310,12 @@ function ModaleInvitation({
|
|||||||
}) {
|
}) {
|
||||||
const { data: roles } = useRoles();
|
const { data: roles } = useRoles();
|
||||||
const { data: teams } = useTeams();
|
const { data: teams } = useTeams();
|
||||||
|
const { data: locations } = useLocations();
|
||||||
const invitation = useInviteUser();
|
const invitation = useInviteUser();
|
||||||
|
const [roleId, setRoleId] = useState('');
|
||||||
|
const [sitesCoches, setSitesCoches] = useState<string[]>([]);
|
||||||
|
const roleName = roles?.find((r) => r.id === roleId)?.name;
|
||||||
|
const sites = (locations ?? []).filter((l) => l.parentId === null);
|
||||||
|
|
||||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -229,10 +327,13 @@ function ModaleInvitation({
|
|||||||
displayName: String(donnees.get('displayName')),
|
displayName: String(donnees.get('displayName')),
|
||||||
roleId: String(donnees.get('roleId')),
|
roleId: String(donnees.get('roleId')),
|
||||||
teamIds: teamId ? [teamId] : undefined,
|
teamIds: teamId ? [teamId] : undefined,
|
||||||
|
locationIds: sitesCoches.length ? sitesCoches : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: (reponse) => {
|
onSuccess: (reponse) => {
|
||||||
surFermeture(false);
|
surFermeture(false);
|
||||||
|
setRoleId('');
|
||||||
|
setSitesCoches([]);
|
||||||
surLien(reponse);
|
surLien(reponse);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -253,7 +354,13 @@ function ModaleInvitation({
|
|||||||
<div className="form-grille">
|
<div className="form-grille">
|
||||||
<div className="champ">
|
<div className="champ">
|
||||||
<label htmlFor="inv-role">Rôle *</label>
|
<label htmlFor="inv-role">Rôle *</label>
|
||||||
<select id="inv-role" name="roleId" required defaultValue="">
|
<select
|
||||||
|
id="inv-role"
|
||||||
|
name="roleId"
|
||||||
|
required
|
||||||
|
value={roleId}
|
||||||
|
onChange={(e) => setRoleId(e.target.value)}
|
||||||
|
>
|
||||||
<option value="" disabled>Choisir…</option>
|
<option value="" disabled>Choisir…</option>
|
||||||
{(roles ?? []).map((r) => (
|
{(roles ?? []).map((r) => (
|
||||||
<option key={r.id} value={r.id}>{r.name}</option>
|
<option key={r.id} value={r.id}>{r.name}</option>
|
||||||
@@ -270,6 +377,30 @@ function ModaleInvitation({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{roleName === 'Demandeur' ? (
|
||||||
|
<div className="champ">
|
||||||
|
<label>Sites autorisés en signalement (optionnel)</label>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{sites.map((s) => (
|
||||||
|
<label key={s.id} className="flex items-center gap-2 text-[13px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sitesCoches.includes(s.id)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSitesCoches((c) =>
|
||||||
|
e.target.checked ? [...c, s.id] : c.filter((id) => id !== s.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{s.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="note-douce">
|
||||||
|
Aucun site coché = aucune restriction (accès au parc complet).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<p className="note-douce">
|
<p className="note-douce">
|
||||||
La personne recevra un <b>lien d'activation valable 7 jours</b> pour choisir
|
La personne recevra un <b>lien d'activation valable 7 jours</b> pour choisir
|
||||||
son mot de passe. Aucun compte n'est actif avant cela.
|
son mot de passe. Aucun compte n'est actif avant cela.
|
||||||
|
|||||||
@@ -4,6 +4,68 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-08-02 — Pr. Daaif (+ Claude) — R6.6 : Demandeur restreint à son site
|
||||||
|
|
||||||
|
**Contexte** — en recette, le référent a remarqué que Karim Doukkali (Demandeur) voit tout le
|
||||||
|
parc dans le sélecteur d'équipement de « Nouvelle demande » : il pourrait accidentellement
|
||||||
|
signaler une panne sur un ascenseur qu'il ne gère pas. Investigation : le trou est dans
|
||||||
|
l'**API** — `GET /assets/options` (`assets.service.ts`) n'avait aucun filtre, et cet endpoint
|
||||||
|
est utilisé aussi bien par le web que par le mobile. Décision retenue : les deux à la fois —
|
||||||
|
association Demandeur → site (corrige web ET mobile) et scan QR comme raccourci mobile.
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- Relation many-to-many `User ↔ Location` (`assignedSites`/`assignedUsers`, migration
|
||||||
|
`r6_demandeur_sites`, même style que `Team.members`) — vide = aucune restriction (comportement
|
||||||
|
historique, inchangé pour tous les rôles sauf un Demandeur affecté).
|
||||||
|
- Contrat (`packages/shared/schemas/users-admin.ts`) : `InvitationCreate`/`UserUpdate` gagnent
|
||||||
|
`locationIds?` (remplace l'affectation, comme `teamIds`) ; `UserAdmin` gagne `assignedSites`.
|
||||||
|
- `AssetsService.allowedLocationIds(user)` : site(s) + zones filles affectés, ou `null` si aucune
|
||||||
|
restriction — réutilisée par `options()` **et** par `RequestsService.create` (défense en
|
||||||
|
profondeur : un `assetId` soumis directement hors périmètre est rejeté, 400) pour que les deux
|
||||||
|
filtres ne divergent jamais.
|
||||||
|
- `UsersService` : `assertTopLevelSites` (un Demandeur est affecté à un site, jamais une zone —
|
||||||
|
même invariant que la hiérarchie site/zone) ; `invite()`/`update()` branchent `locationIds`.
|
||||||
|
- Web (`personnes.tsx`) : `ModaleInvitation` affiche une liste de sites à cocher quand le rôle
|
||||||
|
choisi est Demandeur ; colonne « Sites » dans le tableau, éditable via une petite modale
|
||||||
|
dédiée (`ModaleSites`, même hook `useUpdateUser` que le taux horaire).
|
||||||
|
- Mobile (`formulaire-demande.tsx`) : bouton « 📷 Scanner l'étiquette » sous le sélecteur
|
||||||
|
d'équipement — réutilise `analyseScan`/`expo-camera` du Scanner R4, mais résout **uniquement**
|
||||||
|
contre les options déjà chargées (déjà filtrées par site) — jamais de repli sur le parc
|
||||||
|
complet, ce qui annulerait la restriction. Aucun changement à `useAssetOptions()` : le
|
||||||
|
filtrage serveur profite automatiquement au formulaire.
|
||||||
|
- Seed : Karim Doukkali rattaché à **Tour Atlas** (cohérent avec le `guardianName` déjà présent
|
||||||
|
dans le seed pour ce site) ; ses demandes historiques sur d'autres sites restent visibles en
|
||||||
|
lecture, seule la création est désormais restreinte.
|
||||||
|
- **Bug trouvé en vérification (pas en recette, avant tout commit)** : la première version de
|
||||||
|
`RequestsService.create` comparait `allowed.includes(dto.assetId)` — mais `allowed` est une
|
||||||
|
liste d'**ids de sites/zones**, pas d'ids d'appareils. Comparaison apples-to-oranges, aurait
|
||||||
|
rejeté TOUT signalement d'un Demandeur affecté, y compris dans son propre périmètre (repéré en
|
||||||
|
testant B2/Tour Atlas avec Karim, qui échouait à tort). Corrigé : comparaison sur
|
||||||
|
`asset.locationId`. Méthode renommée `allowedAssetIds` → `allowedLocationIds` pour que le nom
|
||||||
|
dise ce qu'elle retourne réellement.
|
||||||
|
- Vérifié : filtrage confirmé côté serveur (Dispatcher = 8 appareils inchangés, Karim = 4
|
||||||
|
appareils de Tour Atlas seulement) ; défense en profondeur confirmée (asset hors périmètre →
|
||||||
|
400, asset dans le périmètre → 201). Suite de tests API : 79/80 verts — le seul échec
|
||||||
|
(`documents-analytics.e2e-spec.ts`, `monthCost`) est le flake déjà identifié cette session
|
||||||
|
(fenêtre calendaire réelle vs données de seed), sans rapport avec R6.6 ; `exploitation.e2e-spec.ts`
|
||||||
|
mis à jour (A1/C1 → A2/B1, dans le site de Karim, sinon rejetés par la nouvelle règle — c'est
|
||||||
|
le comportement voulu). Typecheck/tests/lint verts sur les 4 paquets.
|
||||||
|
|
||||||
|
**Décisions**
|
||||||
|
|
||||||
|
- Affectation au niveau **site** (pas zone, pas appareil individuel) — même granularité que
|
||||||
|
`Partner.sites`, cohérent avec le modèle existant.
|
||||||
|
- Gestion des sites d'un Demandeur reste une action **web uniquement** (admin) — le mobile
|
||||||
|
n'a que le raccourci scan, aucune UI de gestion.
|
||||||
|
|
||||||
|
**Prochaine étape** : reste de la recette R6 (Sites/Ascenseurs/Fichiers côté Gestionnaire, puis
|
||||||
|
passage Demandeur avec le nouveau périmètre) ; `expo-sharing`/`expo-clipboard` en réserve ;
|
||||||
|
restes non bloquants inchangés (Dokploy, Android physique, `AI_SEUIL_*`/darija,
|
||||||
|
`DOKPLOY_WEBHOOK_URL`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-08-02 — Pr. Daaif (+ Claude) — Assistant mobile : dictée confirmée sur iPhone
|
## 2026-08-02 — Pr. Daaif (+ Claude) — Assistant mobile : dictée confirmée sur iPhone
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
@@ -4587,6 +4587,27 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"assignedSites": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -4598,7 +4619,8 @@
|
|||||||
"teams",
|
"teams",
|
||||||
"status",
|
"status",
|
||||||
"isDemo",
|
"isDemo",
|
||||||
"hourlyRate"
|
"hourlyRate",
|
||||||
|
"assignedSites"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
@@ -4704,6 +4726,14 @@
|
|||||||
"phone": {
|
"phone": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 40
|
"maxLength": 40
|
||||||
|
},
|
||||||
|
"locationIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -4808,6 +4838,27 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"assignedSites": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -4819,7 +4870,8 @@
|
|||||||
"teams",
|
"teams",
|
||||||
"status",
|
"status",
|
||||||
"isDemo",
|
"isDemo",
|
||||||
"hourlyRate"
|
"hourlyRate",
|
||||||
|
"assignedSites"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
@@ -4862,6 +4914,14 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"locationIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const UserAdminSchema = z.object({
|
|||||||
isDemo: z.boolean(),
|
isDemo: z.boolean(),
|
||||||
/** Taux horaire COURANT (MAD/h) — chaque saisie de main-d'œuvre fige le sien. */
|
/** Taux horaire COURANT (MAD/h) — chaque saisie de main-d'œuvre fige le sien. */
|
||||||
hourlyRate: z.number().nullable(),
|
hourlyRate: z.number().nullable(),
|
||||||
|
/** R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction. */
|
||||||
|
assignedSites: z.array(z.object({ id: z.uuid(), name: z.string() })),
|
||||||
});
|
});
|
||||||
export type UserAdmin = z.infer<typeof UserAdminSchema>;
|
export type UserAdmin = z.infer<typeof UserAdminSchema>;
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ export const UserUpdateSchema = z.object({
|
|||||||
teamIds: z.array(z.uuid()).optional(), // remplace l'affectation
|
teamIds: z.array(z.uuid()).optional(), // remplace l'affectation
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
hourlyRate: z.number().positive().nullable().optional(), // R3 — taux courant
|
hourlyRate: z.number().positive().nullable().optional(), // R3 — taux courant
|
||||||
|
locationIds: z.array(z.uuid()).optional(), // R6.6 — remplace l'affectation (sites racine)
|
||||||
});
|
});
|
||||||
export type UserUpdate = z.infer<typeof UserUpdateSchema>;
|
export type UserUpdate = z.infer<typeof UserUpdateSchema>;
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ export const InvitationCreateSchema = z.object({
|
|||||||
roleId: z.uuid(),
|
roleId: z.uuid(),
|
||||||
teamIds: z.array(z.uuid()).optional(),
|
teamIds: z.array(z.uuid()).optional(),
|
||||||
phone: z.string().max(40).optional(),
|
phone: z.string().max(40).optional(),
|
||||||
|
locationIds: z.array(z.uuid()).optional(), // R6.6 — sites racine (Demandeur)
|
||||||
});
|
});
|
||||||
export type InvitationCreate = z.infer<typeof InvitationCreateSchema>;
|
export type InvitationCreate = z.infer<typeof InvitationCreateSchema>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user