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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user