Files
siop2/apps/web/src/pages/portail.tsx
pr-daaif 50254daea4 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>
2026-07-16 17:33:10 +01:00

204 lines
7.2 KiB
TypeScript

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>
);
}