mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r2.4): portail public QR — signalement sans compte, suivi par jeton
- 3 routes publiques /portal (les seules @Public métier), throttlées
(@nestjs/throttler : 10 signalements/min, 60 lectures/min) : résolution
du QR, signalement (retourne un jeton de suivi opaque), suivi par
référence + jeton — pas de jeton, pas de lecture, jamais de liste
- migration r2_portail : Request.publicToken (unique)
- page /q/{réf} fidèle à l'écran 6 validé R0 (mobile d'abord) : équipement
prérempli « détecté par le QR », interrupteur personne bloquée,
suivi sans jargon Reçu → Intervention → Résolu (dérivé de l'OT lié),
rejet affiché « Sans suite : {motif} », photo annoncée (upload R3) ;
signalements gardés sur le téléphone (localStorage, 10 max)
- e2e : LA boucle produit — gardien sans compte → traitement → ✓ Résolu ;
11 tests Playwright verts, 50 tests API, 52 opérations au contrat
- générateur OpenAPI : paramètres non-« id » plus typés uuid
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
77
apps/web/e2e/parcours-portail.spec.ts
Normal file
77
apps/web/e2e/parcours-portail.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* R2.4 — LA boucle produit : le gardien scanne le QR (aucun compte), signale,
|
||||
* l'équipe traite (via API), le gardien voit « Résolu » sur son téléphone.
|
||||
*/
|
||||
|
||||
const suffix = Date.now().toString(36);
|
||||
const API = 'http://localhost:3000';
|
||||
|
||||
test('portail QR : signalement sans compte → traitement → suivi Résolu', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// 1 · Le gardien scanne le QR de l'ascenseur A1 → formulaire prérempli
|
||||
await page.goto('/q/A1');
|
||||
await expect(page.getByText(/Ascenseur A1/)).toBeVisible();
|
||||
await expect(page.getByText('détecté par le QR')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Décrivez le problème').fill(`E2E ${suffix} — voyant cabine éteint`);
|
||||
await page.getByRole('button', { name: 'Envoyer le signalement' }).click();
|
||||
await expect(page.getByText('l\'équipe SPELEV est prévenue')).toBeVisible();
|
||||
|
||||
// « Mes signalements » : Reçu, sans jargon
|
||||
const ligne = page.locator('.item-liste', { hasText: suffix });
|
||||
await expect(ligne).toBeVisible();
|
||||
await expect(ligne.locator('.etape.fait')).toHaveText('✓ Reçu');
|
||||
|
||||
// 2 · Côté SPELEV (via API — le parcours UI est couvert par parcours-r2) :
|
||||
// approbation → démarrage → bilan → clôture
|
||||
const comptes = await (await request.get(`${API}/auth/demo-accounts`)).json();
|
||||
const salmaId = comptes.accounts.find((a: { roleName: string }) => a.roleName === 'Dispatcher').id;
|
||||
const jeton = (await (await request.post(`${API}/auth/demo-login`, { data: { userId: salmaId } })).json()).accessToken;
|
||||
const auth = { Authorization: `Bearer ${jeton}` };
|
||||
|
||||
const demandes = await (await request.get(`${API}/requests`, { headers: auth })).json();
|
||||
const demande = demandes.requests.find((r: { description: string }) => r.description.includes(suffix));
|
||||
expect(demande).toBeTruthy();
|
||||
|
||||
const ot = await (
|
||||
await request.post(`${API}/requests/${demande.id}/approve`, { headers: auth, data: {} })
|
||||
).json();
|
||||
await request.post(`${API}/work-orders/${ot.id}/transition`, {
|
||||
headers: auth,
|
||||
data: { to: 'IN_PROGRESS' },
|
||||
});
|
||||
|
||||
// 3 · Le gardien recharge : « Intervention » franchie
|
||||
await page.reload();
|
||||
await expect(page.locator('.item-liste', { hasText: suffix }).getByText('✓ Intervention')).toBeVisible();
|
||||
|
||||
// 4 · Clôture avec bilan → « Résolu »
|
||||
const refs = await (await request.get(`${API}/reference-values`, { headers: auth })).json();
|
||||
const valeur = (field: string) =>
|
||||
refs.referenceValues.find((v: { field: string; isActive: boolean }) => v.field === field && v.isActive).id;
|
||||
await request.put(`${API}/work-orders/${ot.id}/report`, {
|
||||
headers: auth,
|
||||
data: {
|
||||
doorStateId: valeur('DOOR_STATE'),
|
||||
actionTakenId: valeur('ACTION_TAKEN'),
|
||||
componentConcernedId: valeur('COMPONENT_CONCERNED'),
|
||||
},
|
||||
});
|
||||
const cloture = await request.post(`${API}/work-orders/${ot.id}/transition`, {
|
||||
headers: auth,
|
||||
data: { to: 'DONE' },
|
||||
});
|
||||
expect(cloture.ok()).toBe(true);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('.item-liste', { hasText: suffix }).getByText('✓ Résolu')).toBeVisible();
|
||||
});
|
||||
|
||||
test('QR inconnu : message clair, pas d’écran technique', async ({ page }) => {
|
||||
await page.goto('/q/INTROUVABLE-99');
|
||||
await expect(page.getByText(/aucun appareil connu/)).toBeVisible();
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
|
||||
import PageNouvelOT from '@/pages/nouvel-ot';
|
||||
import PageOrdresTravail from '@/pages/ordres-travail';
|
||||
import PagePersonnes from '@/pages/personnes';
|
||||
import PagePortail from '@/pages/portail';
|
||||
import PagePreventif from '@/pages/preventif';
|
||||
import PageSites from '@/pages/sites';
|
||||
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
||||
@@ -34,6 +35,8 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/connexion" element={<PageConnexion />} />
|
||||
<Route path="/activation" element={<PageActivation />} />
|
||||
{/* Portail public — cible du QR cabine, AUCUN compte */}
|
||||
<Route path="/q/:reference" element={<PagePortail />} />
|
||||
<Route path="/" element={dansCoquille(<PageTableauDeBord />)} />
|
||||
<Route path="/design" element={dansCoquille(<PageDesign />)} />
|
||||
<Route path="/sites" element={dansCoquille(<PageSites />)} />
|
||||
|
||||
184
apps/web/src/api/schema.d.ts
vendored
184
apps/web/src/api/schema.d.ts
vendored
@@ -372,6 +372,57 @@ export interface paths {
|
||||
patch: operations["updateUser"];
|
||||
trace?: never;
|
||||
};
|
||||
"/portal/assets/{reference}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Résoudre le QR scanné (référence → appareil, préremplissage) */
|
||||
get: operations["getPortalAsset"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/portal/requests": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Signaler sans compte — renvoie le jeton de suivi (à garder sur le téléphone) */
|
||||
post: operations["createPortalRequest"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/portal/requests/{reference}/{token}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Suivi sans jargon (Reçu → Intervention → Résolu) — jeton requis */
|
||||
get: operations["getPortalRequestStatus"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/preventive/templates": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1069,6 +1120,42 @@ export interface components {
|
||||
teamIds?: string[];
|
||||
isActive?: boolean;
|
||||
};
|
||||
PortalAsset: {
|
||||
reference: string;
|
||||
siteName: string;
|
||||
locationName: string;
|
||||
};
|
||||
PortalRequestCreated: {
|
||||
reference: string;
|
||||
description: string;
|
||||
isPersonTrapped: boolean;
|
||||
/** @enum {string} */
|
||||
status: "RECEIVED" | "APPROVED" | "REJECTED";
|
||||
/** @enum {string} */
|
||||
step: "RECEIVED" | "IN_PROGRESS" | "RESOLVED";
|
||||
rejectionReason: string | null;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
publicToken: string;
|
||||
};
|
||||
PortalRequestCreate: {
|
||||
assetReference: string;
|
||||
description: string;
|
||||
isPersonTrapped?: boolean;
|
||||
requesterName?: string;
|
||||
};
|
||||
PortalRequestStatus: {
|
||||
reference: string;
|
||||
description: string;
|
||||
isPersonTrapped: boolean;
|
||||
/** @enum {string} */
|
||||
status: "RECEIVED" | "APPROVED" | "REJECTED";
|
||||
/** @enum {string} */
|
||||
step: "RECEIVED" | "IN_PROGRESS" | "RESOLVED";
|
||||
rejectionReason: string | null;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
TaskTemplatesResponse: {
|
||||
templates: {
|
||||
/** Format: uuid */
|
||||
@@ -2172,6 +2259,103 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getPortalAsset: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
reference: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Appareil */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PortalAsset"];
|
||||
};
|
||||
};
|
||||
/** @description Référence inconnue */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
createPortalRequest: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PortalRequestCreate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Signalement enregistré */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PortalRequestCreated"];
|
||||
};
|
||||
};
|
||||
/** @description Référence inconnue */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Trop de signalements — réessayez dans une minute */
|
||||
429: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
getPortalRequestStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
reference: string;
|
||||
token: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Avancement */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PortalRequestStatus"];
|
||||
};
|
||||
};
|
||||
/** @description Signalement inconnu ou jeton invalide */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
listTaskTemplates: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
203
apps/web/src/pages/portail.tsx
Normal file
203
apps/web/src/pages/portail.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { PortalRequestStatus } from '@siop/shared';
|
||||
import { api } from '@/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IcoAscenseurs } from '@/components/nav-icons';
|
||||
|
||||
/**
|
||||
* Écran 6 validé R0 : Karim (gardien, aucune formation, aucun compte) arrive
|
||||
* ici en scannant le QR. Équipement prérempli, UN choix critique, suivi sans
|
||||
* jargon. Ses signalements vivent sur SON téléphone (référence + jeton).
|
||||
*/
|
||||
|
||||
const CLE_SUIVIS = 'siop.portail.suivis';
|
||||
interface Suivi { reference: string; token: string; }
|
||||
|
||||
const lireSuivis = (): Suivi[] => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(CLE_SUIVIS) ?? '[]') as Suivi[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const ajouterSuivi = (s: Suivi) =>
|
||||
localStorage.setItem(CLE_SUIVIS, JSON.stringify([s, ...lireSuivis()].slice(0, 10)));
|
||||
|
||||
export default function PagePortail() {
|
||||
const { reference } = useParams<{ reference: string }>();
|
||||
const [suivis, setSuivis] = useState<Suivi[]>(lireSuivis);
|
||||
const [personneBloquee, setPersonneBloquee] = useState(false);
|
||||
const [envoye, setEnvoye] = useState(false);
|
||||
const [erreur, setErreur] = useState<string | null>(null);
|
||||
|
||||
const { data: asset, isError: refInconnue } = useQuery({
|
||||
queryKey: ['portal-asset', reference],
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const { data, response } = await api.GET('/portal/assets/{reference}', {
|
||||
params: { path: { reference: reference! } },
|
||||
});
|
||||
if (!data) throw new Error(String(response.status));
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const surEnvoi = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErreur(null);
|
||||
const form = e.currentTarget;
|
||||
const description = String(new FormData(form).get('description') ?? '').trim();
|
||||
const { data, response } = await api.POST('/portal/requests', {
|
||||
body: {
|
||||
assetReference: reference!,
|
||||
description,
|
||||
isPersonTrapped: personneBloquee,
|
||||
},
|
||||
});
|
||||
if (!data) {
|
||||
setErreur(
|
||||
response.status === 429
|
||||
? 'Trop de signalements envoyés — réessayez dans une minute.'
|
||||
: 'Envoi impossible — réessayez ou appelez le numéro affiché en cabine.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
ajouterSuivi({ reference: data.reference, token: data.publicToken });
|
||||
setSuivis(lireSuivis());
|
||||
setPersonneBloquee(false);
|
||||
setEnvoye(true);
|
||||
form.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--fond)', minHeight: '100vh' }}>
|
||||
<div className="portail">
|
||||
<div className="logo" style={{ color: 'var(--encre)', padding: 0 }}>
|
||||
<span className="marque" aria-hidden="true">▲<br />▼</span>
|
||||
<div>
|
||||
<b style={{ fontSize: 19 }}>SIOP</b>
|
||||
<small style={{ color: 'var(--encre-2)' }}>
|
||||
Signalement{asset ? ` — ${asset.siteName}` : ''}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{refInconnue ? (
|
||||
<div className="carte">
|
||||
<p className="erreur-form">
|
||||
Ce QR ne correspond à aucun appareil connu. Vérifiez l'étiquette ou
|
||||
appelez le numéro affiché en cabine.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form className="carte flex flex-col gap-3" onSubmit={surEnvoi}>
|
||||
<div className="bandeau-detecte">
|
||||
<span style={{ color: 'var(--primaire)', width: 18, height: 18, flex: 'none' }}>
|
||||
<IcoAscenseurs />
|
||||
</span>
|
||||
<span>
|
||||
<b>Ascenseur {reference}{asset ? ` — ${asset.locationName}` : ''}</b>{' '}
|
||||
<span style={{ color: 'var(--encre-2)' }}>(détecté par le QR)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="gros-toggle">
|
||||
<b>Une personne est-elle bloquée dans la cabine ?</b>
|
||||
<button
|
||||
type="button"
|
||||
className="interrupteur"
|
||||
role="switch"
|
||||
aria-checked={personneBloquee}
|
||||
aria-label="Personne bloquée"
|
||||
onClick={() => setPersonneBloquee((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="po-desc">Décrivez le problème</label>
|
||||
<input
|
||||
id="po-desc"
|
||||
name="description"
|
||||
required
|
||||
minLength={3}
|
||||
placeholder="Ex. : la porte ne se ferme plus au 3ᵉ étage"
|
||||
/>
|
||||
</div>
|
||||
<div className="zone-photo">📷 Ajouter une photo (bientôt disponible)</div>
|
||||
{erreur ? <p className="erreur-form" role="alert">{erreur}</p> : null}
|
||||
{envoye ? (
|
||||
<p style={{ color: 'var(--succes)', fontWeight: 600, fontSize: 13 }} role="status">
|
||||
✓ Signalement envoyé — l'équipe SPELEV est prévenue.
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" variant="prim" className="justify-center" style={{ padding: 11 }}>
|
||||
Envoyer le signalement
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{suivis.length > 0 ? (
|
||||
<div className="carte">
|
||||
<h2>Mes signalements</h2>
|
||||
{suivis.map((s) => (
|
||||
<LigneSuivi key={s.reference} suivi={s} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LigneSuivi({ suivi }: { suivi: Suivi }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['portal-status', suivi.reference],
|
||||
refetchInterval: 30_000,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const { data } = await api.GET('/portal/requests/{reference}/{token}', {
|
||||
params: { path: { reference: suivi.reference, token: suivi.token } },
|
||||
});
|
||||
return data ?? null;
|
||||
},
|
||||
});
|
||||
if (!data) return null;
|
||||
return (
|
||||
<div className="item-liste" style={{ alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<b>{data.description}</b>
|
||||
<span className="sous num">
|
||||
{data.reference} ·{' '}
|
||||
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(data.createdAt))}
|
||||
</span>
|
||||
{data.status === 'REJECTED' ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--encre-2)', marginTop: 4 }}>
|
||||
Sans suite : {data.rejectionReason ?? 'voir avec le gestionnaire'}
|
||||
</div>
|
||||
) : (
|
||||
<Etapes status={data} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Etapes({ status }: { status: PortalRequestStatus }) {
|
||||
const franchi = (etape: 'RECEIVED' | 'IN_PROGRESS' | 'RESOLVED') => {
|
||||
const ordre = ['RECEIVED', 'IN_PROGRESS', 'RESOLVED'];
|
||||
return ordre.indexOf(status.step) >= ordre.indexOf(etape);
|
||||
};
|
||||
return (
|
||||
<div className="suivi-simple">
|
||||
<span className={franchi('RECEIVED') ? 'etape fait' : 'etape'}>✓ Reçu</span>
|
||||
<span className="lien-e" />
|
||||
<span className={franchi('IN_PROGRESS') ? 'etape fait' : 'etape'}>
|
||||
{franchi('IN_PROGRESS') ? '✓ ' : ''}Intervention
|
||||
</span>
|
||||
<span className="lien-e" />
|
||||
<span className={franchi('RESOLVED') ? 'etape fait' : 'etape'}>
|
||||
{franchi('RESOLVED') ? '✓ ' : ''}Résolu
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -873,6 +873,45 @@ table {
|
||||
color: var(--encre); text-align: right;
|
||||
}
|
||||
|
||||
/* ═══ Portail demandeur (écran 6 validé R0 — aucun compte, aucun jargon) ═══ */
|
||||
.portail {
|
||||
max-width: 430px;
|
||||
margin: 28px auto;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.zone-photo {
|
||||
border: 1.5px dashed var(--bordure-forte);
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
color: var(--encre-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.bandeau-detecte {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--primaire-doux);
|
||||
border-radius: var(--rayon-controle);
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.suivi-simple {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--encre-3);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.suivi-simple .etape { display: flex; align-items: center; gap: 6px; }
|
||||
.suivi-simple .etape.fait { color: var(--succes); font-weight: 700; }
|
||||
.suivi-simple .lien-e { width: 26px; height: 1px; background: var(--bordure-forte); margin: 0 6px; }
|
||||
|
||||
/* ═══ Page /design : nuanciers ═══ */
|
||||
.nuancier {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user