mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r1.2): écrans web du référentiel — 7 écrans fidèles à maquette-r1
- Sites : liste + carte Leaflet/OSM réelle (pins maquette, positions PostGIS), création en modale avec position posée au clic sur la carte - Fiche site : arbre site→zones, appareils rattachés, ajout de zone - Ascenseurs : table dense, filtres site/statut, strie rouge à l'arrêt ; fiche appareil (organes ± ajout/retrait, statut, QR réel) ; création identité → rattachement → organes ; étiquette A6 imprimable (objet papier, window.print n'imprime qu'elle) - Personnes & équipes : invitation → lien d'activation affiché à copier (pas d'email en R1, décision explicite), renvoi de lien, équipes ; page /activation (choix du mot de passe, connexion directe) - Catégories : ajout, renommage inline, désactivation - navigation et actions pilotées par la matrice (usePermissions) — l'API re-vérifie chaque requête - e2e Playwright : recette R1 officielle rejouée intégralement (site → zone → appareil+organe → étiquette → invitation → activation) + parcours « la matrice pilote l'UI » ; purge idempotente en globalSetup - alias Vite @siop/shared → source TS (exports nommés CJS ↯ workspace lié) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
96
apps/web/src/pages/activation.tsx
Normal file
96
apps/web/src/pages/activation.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api, setToken } from '@/api/client';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/** Cible du lien d'invitation (R1) : la personne choisit son mot de passe et
|
||||
* entre directement. Même moule visuel que la connexion (écran validé R0). */
|
||||
export default function PageActivation() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [params] = useSearchParams();
|
||||
const token = params.get('token') ?? '';
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmation, setConfirmation] = useState('');
|
||||
|
||||
const activation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data, error } = await api.POST('/auth/activate', {
|
||||
body: { token, password },
|
||||
});
|
||||
if (error || !data) {
|
||||
throw new Error('Ce lien est invalide ou a expiré — demandez un nouveau lien.');
|
||||
}
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setToken(data.accessToken);
|
||||
queryClient.clear();
|
||||
navigate('/', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const surEnvoi = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
activation.mutate();
|
||||
};
|
||||
const differents = confirmation.length > 0 && password !== confirmation;
|
||||
|
||||
return (
|
||||
<div className="conn">
|
||||
<div className="carte-conn">
|
||||
<Logo sousTitre="Activation de votre compte" />
|
||||
{token ? (
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-[14px]" aria-label="Activation">
|
||||
<p className="text-[13px] text-encre-2">
|
||||
Bienvenue ! Choisissez votre mot de passe pour activer votre compte.
|
||||
</p>
|
||||
<div className="champ">
|
||||
<label htmlFor="act-mdp">Mot de passe (8 caractères minimum)</label>
|
||||
<input
|
||||
id="act-mdp"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="act-conf">Confirmez le mot de passe</label>
|
||||
<input
|
||||
id="act-conf"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{differents ? (
|
||||
<p className="erreur-form">Les deux saisies ne correspondent pas.</p>
|
||||
) : null}
|
||||
{activation.isError ? (
|
||||
<p className="erreur-form" role="alert">{activation.error.message}</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="prim"
|
||||
className="justify-center"
|
||||
disabled={activation.isPending || differents || password.length < 8}
|
||||
>
|
||||
Activer mon compte
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="erreur-form" role="alert">
|
||||
Lien incomplet — utilisez le lien d'activation transmis par votre administrateur.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
apps/web/src/pages/ascenseurs.tsx
Normal file
97
apps/web/src/pages/ascenseurs.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared';
|
||||
import { useAssets } from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { StatutEquipement } from '@/components/statut-equipement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/** Écran 3 des maquettes R1 : inventaire dense du parc. */
|
||||
export default function PageAscenseurs() {
|
||||
const navigate = useNavigate();
|
||||
const { data: assets } = useAssets();
|
||||
const { can } = usePermissions();
|
||||
const [site, setSite] = useState('');
|
||||
const [statut, setStatut] = useState<'' | AssetStatus>('');
|
||||
|
||||
const tous = assets ?? [];
|
||||
const sites = [...new Set(tous.map((a) => a.siteName))].sort();
|
||||
const visibles = tous.filter(
|
||||
(a) => (!site || a.siteName === site) && (!statut || a.status === statut),
|
||||
);
|
||||
const arret = tous.filter((a) => a.status === 'OUT_OF_SERVICE').length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Ascenseurs</h1>
|
||||
<span className="filajout">
|
||||
{tous.length} appareils{arret ? ` · ${arret} à l’arrêt` : ''}
|
||||
</span>
|
||||
<div className="actions">
|
||||
{can('ASSETS', 'create') ? (
|
||||
<Button variant="prim" onClick={() => navigate('/ascenseurs/nouveau')}>
|
||||
+ Nouvel ascenseur
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="filtres">
|
||||
<select aria-label="Filtrer par site" value={site} onChange={(e) => setSite(e.target.value)}>
|
||||
<option value="">Site : tous</option>
|
||||
{sites.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Filtrer par statut"
|
||||
value={statut}
|
||||
onChange={(e) => setStatut(e.target.value as '' | AssetStatus)}
|
||||
>
|
||||
<option value="">Statut : tous</option>
|
||||
{ASSET_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{ASSET_STATUS_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="carte" style={{ padding: 0 }}>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Appareil</th>
|
||||
<th>Marque · modèle</th>
|
||||
<th>Site / emplacement</th>
|
||||
<th>Catégorie</th>
|
||||
<th>Statut</th>
|
||||
<th>Organes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibles.map((a) => (
|
||||
<tr
|
||||
key={a.id}
|
||||
className={a.status === 'OUT_OF_SERVICE' ? 'ligne-bloque' : undefined}
|
||||
onClick={() => navigate(`/ascenseurs/${a.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<td className="ref num">{a.reference}</td>
|
||||
<td>{a.brand} {a.model ?? ''}</td>
|
||||
<td>
|
||||
{a.siteName}
|
||||
{a.locationName !== a.siteName ? (
|
||||
<span className="sous">{a.locationName}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{a.categoryName}</td>
|
||||
<td><StatutEquipement statut={a.status} /></td>
|
||||
<td className="num">{a.componentCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
136
apps/web/src/pages/categories.tsx
Normal file
136
apps/web/src/pages/categories.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { CATEGORY_KINDS, type Category, type CategoryKind } from '@siop/shared';
|
||||
import { useCategories, useCreateCategory, useUpdateCategory } from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const TITRES: Record<CategoryKind, string> = {
|
||||
EQUIPMENT: 'Catégories d’équipement',
|
||||
COMPONENT_TYPE: 'Types d’organes',
|
||||
};
|
||||
|
||||
/** Écran 7 des maquettes R1 : référentiels administrables — renommer,
|
||||
* ajouter, (dés)activer. Jamais de suppression si utilisé. */
|
||||
export default function PageCategories() {
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page"><h1>Catégories</h1></div>
|
||||
<div className="grille-2">
|
||||
{CATEGORY_KINDS.map((kind) => (
|
||||
<ListeCategories key={kind} kind={kind} />
|
||||
))}
|
||||
</div>
|
||||
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||
Une catégorie <b>utilisée</b> ne peut pas être supprimée — seulement renommée ou
|
||||
désactivée (elle disparaît des sélecteurs, l'historique reste lisible).
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ListeCategories({ kind }: { kind: CategoryKind }) {
|
||||
const { data: categories } = useCategories();
|
||||
const { can } = usePermissions();
|
||||
const creation = useCreateCategory();
|
||||
const [nom, setNom] = useState('');
|
||||
|
||||
const liste = (categories ?? []).filter((c) => c.kind === kind);
|
||||
const peutEditer = can('SETTINGS', 'edit');
|
||||
|
||||
const surAjout = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!nom.trim()) return;
|
||||
creation.mutate({ kind, name: nom.trim() }, { onSuccess: () => setNom('') });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="carte">
|
||||
<h2>{TITRES[kind]}</h2>
|
||||
{liste.map((categorie) => (
|
||||
<LigneCategorie key={categorie.id} categorie={categorie} peutEditer={peutEditer} />
|
||||
))}
|
||||
{can('SETTINGS', 'create') ? (
|
||||
<form className="ligne-organe" style={{ border: 0, marginTop: 6 }} onSubmit={surAjout}>
|
||||
<input
|
||||
aria-label={`Nouvelle entrée — ${TITRES[kind]}`}
|
||||
style={{ flex: 1 }}
|
||||
placeholder={kind === 'EQUIPMENT' ? 'Nouvelle catégorie…' : 'Nouveau type…'}
|
||||
value={nom}
|
||||
onChange={(e) => setNom(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="prim" disabled={creation.isPending}>
|
||||
Ajouter
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
{creation.isError ? (
|
||||
<p className="erreur-form" role="alert">{creation.error.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LigneCategorie({
|
||||
categorie,
|
||||
peutEditer,
|
||||
}: {
|
||||
categorie: Category;
|
||||
peutEditer: boolean;
|
||||
}) {
|
||||
const maj = useUpdateCategory();
|
||||
const [enEdition, setEnEdition] = useState(false);
|
||||
const [nom, setNom] = useState(categorie.name);
|
||||
|
||||
const renommer = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
maj.mutate({ id: categorie.id, name: nom.trim() }, { onSuccess: () => setEnEdition(false) });
|
||||
};
|
||||
|
||||
if (enEdition) {
|
||||
return (
|
||||
<form className="ligne-organe" onSubmit={renommer}>
|
||||
<input
|
||||
aria-label={`Renommer ${categorie.name}`}
|
||||
style={{ flex: 1 }}
|
||||
value={nom}
|
||||
onChange={(e) => setNom(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" variant="prim" disabled={maj.isPending}>OK</Button>
|
||||
<Button onClick={() => setEnEdition(false)}>Annuler</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="item-liste" style={!categorie.isActive ? { color: 'var(--encre-3)' } : undefined}>
|
||||
{categorie.name}
|
||||
{!categorie.isActive ? <span style={{ fontSize: 11 }}> (désactivée)</span> : null}
|
||||
<span className="fin">
|
||||
<span className="num">
|
||||
{categorie.usageCount} {categorie.kind === 'EQUIPMENT' ? 'appareils' : ''}
|
||||
</span>
|
||||
{peutEditer ? (
|
||||
<>
|
||||
·{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="border-0 bg-transparent font-semibold text-primaire"
|
||||
onClick={() => setEnEdition(true)}
|
||||
>
|
||||
Renommer
|
||||
</button>
|
||||
·{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="border-0 bg-transparent font-semibold text-primaire"
|
||||
onClick={() => maj.mutate({ id: categorie.id, isActive: !categorie.isActive })}
|
||||
>
|
||||
{categorie.isActive ? 'Désactiver' : 'Réactiver'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/pages/etiquette.tsx
Normal file
76
apps/web/src/pages/etiquette.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useAsset } from '@/api/referentiel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/** Écran 5 des maquettes R1 : l'étiquette A6 imprimable — objet PAPIER
|
||||
* (noir sur blanc, même en thème sombre). window.print n'imprime qu'elle. */
|
||||
export default function PageEtiquette() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: asset } = useAsset(id);
|
||||
if (!asset) return null;
|
||||
|
||||
const url = `${window.location.origin}/q/${asset.reference}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="filajout">
|
||||
<Link to="/ascenseurs">Ascenseurs</Link> /{' '}
|
||||
<Link to={`/ascenseurs/${asset.id}`}>Ascenseur {asset.reference}</Link> / Étiquette QR
|
||||
</div>
|
||||
<div className="entete-page">
|
||||
<h1>Étiquette cabine — Ascenseur {asset.reference}</h1>
|
||||
<div className="actions">
|
||||
<Button variant="prim" onClick={() => window.print()}>
|
||||
Imprimer (A6)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="zone-impression">
|
||||
<div className="etiquette">
|
||||
<div className="tete">
|
||||
<span className="marque" aria-hidden="true">▲<br />▼</span>
|
||||
<b>SIOP</b>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#55647a' }}>
|
||||
Maintenance d'ascenseurs
|
||||
<br />
|
||||
SPELEV
|
||||
</span>
|
||||
</div>
|
||||
<div className="corps">
|
||||
<QRCodeSVG className="qr-grand" value={url} size={120} marginSize={1} />
|
||||
<div>
|
||||
<h3>Ascenseur {asset.reference}</h3>
|
||||
<div className="ou">
|
||||
{asset.siteName}
|
||||
<br />
|
||||
{asset.locationName !== asset.siteName ? `${asset.locationName} — ` : ''}
|
||||
{asset.brand} {asset.model ?? ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="consigne">
|
||||
Un problème ? Une personne bloquée ?
|
||||
<br />
|
||||
📱 Scannez ce code pour signaler
|
||||
</div>
|
||||
<div className="ref">
|
||||
SIOP · {asset.reference}
|
||||
{asset.serialNumber ? ` · ${asset.serialNumber}` : ''} · {url.replace(/^https?:\/\//, '')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="carte" style={{ flex: 1, minWidth: 260 }}>
|
||||
<h2>Ce que fait le scan</h2>
|
||||
<dl className="infos" style={{ gridTemplateColumns: '96px 1fr' }}>
|
||||
<dt>Gardien</dt>
|
||||
<dd>ouvrira le <b>portail de signalement</b>, équipement prérempli (R2)</dd>
|
||||
<dt>Technicien</dt>
|
||||
<dd>connecté → ouvre la <b>fiche de l'appareil</b></dd>
|
||||
<dt>Impression</dt>
|
||||
<dd>A6 autocollant, noir sur blanc</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
202
apps/web/src/pages/fiche-ascenseur.tsx
Normal file
202
apps/web/src/pages/fiche-ascenseur.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared';
|
||||
import {
|
||||
useAddComponent,
|
||||
useAsset,
|
||||
useCategories,
|
||||
useRemoveComponent,
|
||||
useUpdateAsset,
|
||||
} from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { StatutEquipement } from '@/components/statut-equipement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
import { formatDateFr } from '@/lib/lieux';
|
||||
|
||||
/** Fiche appareil — écran validé en R0 (maquette-web, écran 5) ; historique
|
||||
* (R2) et documents (R3) sont annoncés, pas simulés. */
|
||||
export default function PageFicheAscenseur() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: asset } = useAsset(id);
|
||||
const { can } = usePermissions();
|
||||
const [modaleOrgane, setModaleOrgane] = useState(false);
|
||||
const retrait = useRemoveComponent(id ?? '');
|
||||
const majStatut = useUpdateAsset(id ?? '');
|
||||
|
||||
if (!asset) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="filajout">
|
||||
<Link to="/ascenseurs">Ascenseurs</Link> / Ascenseur {asset.reference}
|
||||
</div>
|
||||
<div className="entete-page">
|
||||
<h1>
|
||||
Ascenseur {asset.reference} — {asset.brand} {asset.model ?? ''}
|
||||
</h1>
|
||||
<StatutEquipement statut={asset.status} />
|
||||
<div className="actions">
|
||||
{can('ASSETS', 'edit') ? (
|
||||
<select
|
||||
aria-label="Changer le statut"
|
||||
className="btn"
|
||||
value={asset.status}
|
||||
onChange={(e) => majStatut.mutate({ status: e.target.value as AssetStatus })}
|
||||
>
|
||||
{ASSET_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{ASSET_STATUS_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/etiquette`)}>
|
||||
Étiquette QR
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grille-2">
|
||||
<div className="carte">
|
||||
<h2>Identité</h2>
|
||||
<dl className="infos">
|
||||
<dt>N° de série</dt>
|
||||
<dd className="num">{asset.serialNumber ?? '—'}</dd>
|
||||
<dt>Marque · modèle</dt>
|
||||
<dd>{asset.brand} — {asset.model ?? '—'}</dd>
|
||||
<dt>Mise en service</dt>
|
||||
<dd className="num">{formatDateFr(asset.commissionedAt)}</dd>
|
||||
<dt>Site</dt>
|
||||
<dd>
|
||||
{asset.siteName}
|
||||
{asset.locationName !== asset.siteName ? ` — ${asset.locationName}` : ''}
|
||||
</dd>
|
||||
<dt>Catégorie</dt>
|
||||
<dd>{asset.categoryName}</dd>
|
||||
<dt>Charge · niveaux</dt>
|
||||
<dd className="num">
|
||||
{asset.loadKg ? `${asset.loadKg} kg` : '—'} · {asset.floors ?? '—'} niveaux
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="carte">
|
||||
<h2>Étiquette cabine</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<QRCodeSVG
|
||||
value={`${window.location.origin}/q/${asset.reference}`}
|
||||
size={86}
|
||||
marginSize={2}
|
||||
className="rounded-lg border border-bordure bg-white p-1"
|
||||
/>
|
||||
<div className="text-[12.5px] text-encre-2">
|
||||
Collée en cabine.<br />
|
||||
Scan gardien → signalement prérempli (R2).<br />
|
||||
Scan technicien → cette fiche.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grille-2">
|
||||
<div className="carte">
|
||||
<h2>Organes</h2>
|
||||
{asset.components.map((c) => (
|
||||
<div className="item-liste" key={c.id}>
|
||||
<div>
|
||||
<b>{c.typeName}</b>
|
||||
{c.designation ? <span className="sous">{c.designation}</span> : null}
|
||||
</div>
|
||||
<span className="fin">
|
||||
{can('ASSETS', 'edit') ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-suppr"
|
||||
aria-label={`Retirer ${c.typeName}`}
|
||||
onClick={() => retrait.mutate(c.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{asset.components.length === 0 ? (
|
||||
<p className="text-encre-3">Aucun organe déclaré.</p>
|
||||
) : null}
|
||||
{can('ASSETS', 'edit') ? (
|
||||
<Button variant="ghost" className="mt-2" onClick={() => setModaleOrgane(true)}>
|
||||
+ Ajouter un organe
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
||||
<h2>Historique</h2>
|
||||
<p className="text-encre-3">
|
||||
Les interventions de cet appareil apparaîtront ici avec l’exploitation (R2).
|
||||
</p>
|
||||
</div>
|
||||
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
||||
<h2>Documents</h2>
|
||||
<p className="text-encre-3">
|
||||
Notices et certificats rejoindront la bibliothèque en R3.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NouvelOrgane assetId={asset.id} ouverte={modaleOrgane} surFermeture={setModaleOrgane} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NouvelOrgane({
|
||||
assetId,
|
||||
ouverte,
|
||||
surFermeture,
|
||||
}: {
|
||||
assetId: string;
|
||||
ouverte: boolean;
|
||||
surFermeture: (o: boolean) => void;
|
||||
}) {
|
||||
const { data: categories } = useCategories();
|
||||
const ajout = useAddComponent(assetId);
|
||||
const types = (categories ?? []).filter((c) => c.kind === 'COMPONENT_TYPE' && c.isActive);
|
||||
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
ajout.mutate(
|
||||
{
|
||||
typeId: String(donnees.get('typeId')),
|
||||
designation: String(donnees.get('designation')) || undefined,
|
||||
},
|
||||
{ onSuccess: () => surFermeture(false) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale titre="Ajouter un organe" ouverte={ouverte} surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="no-type">Type d’organe *</label>
|
||||
<select id="no-type" name="typeId" required defaultValue="">
|
||||
<option value="" disabled>Choisir…</option>
|
||||
{types.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="no-designation">Désignation</label>
|
||||
<input id="no-designation" name="designation" placeholder="Ex. : Fermator 40/10" />
|
||||
</div>
|
||||
{ajout.isError ? (
|
||||
<p className="erreur-form" role="alert">{ajout.error.message}</p>
|
||||
) : null}
|
||||
<div className="pied">
|
||||
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||
<Button type="submit" variant="prim" disabled={ajout.isPending}>Ajouter</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
165
apps/web/src/pages/fiche-site.tsx
Normal file
165
apps/web/src/pages/fiche-site.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAssets, useCreateLocation, useLocations } from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { CarteGeo } from '@/components/carte-geo';
|
||||
import { StatutEquipement } from '@/components/statut-equipement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
|
||||
/** Écran 2 des maquettes R1 : identité du site, hiérarchie d'emplacements,
|
||||
* appareils rattachés. Un organe n'apparaît jamais ici : il suit son appareil. */
|
||||
export default function PageFicheSite() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: locations } = useLocations();
|
||||
const { data: assets } = useAssets();
|
||||
const { can } = usePermissions();
|
||||
const [modaleZone, setModaleZone] = useState(false);
|
||||
|
||||
const site = locations?.find((l) => l.id === id);
|
||||
if (!site) return null;
|
||||
const zones = (locations ?? []).filter((l) => l.parentId === site.id);
|
||||
const appareils = (assets ?? []).filter((a) => a.siteName === site.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="filajout">
|
||||
<Link to="/sites">Sites</Link> / {site.name}
|
||||
</div>
|
||||
<div className="entete-page">
|
||||
<h1>{site.name}</h1>
|
||||
<div className="actions">
|
||||
{can('ASSETS', 'create') ? (
|
||||
<Button variant="prim" onClick={() => navigate(`/ascenseurs/nouveau?site=${site.id}`)}>
|
||||
+ Nouvel ascenseur
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grille-2">
|
||||
<div className="carte">
|
||||
<h2>Identité</h2>
|
||||
<dl className="infos">
|
||||
<dt>Adresse</dt>
|
||||
<dd>{[site.address, site.city].filter(Boolean).join(' — ') || '—'}</dd>
|
||||
<dt>Position</dt>
|
||||
<dd className="num">
|
||||
{site.latitude != null
|
||||
? `${site.latitude} N · ${Math.abs(site.longitude!)} O`
|
||||
: 'non posée'}
|
||||
</dd>
|
||||
<dt>Gardien</dt>
|
||||
<dd>
|
||||
{site.guardianName ?? '—'}
|
||||
{site.guardianPhone ? (
|
||||
<span className="num"> · {site.guardianPhone}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
<dt>Appareils</dt>
|
||||
<dd className="num">{site.assetCount}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{site.latitude != null && site.longitude != null ? (
|
||||
<CarteGeo
|
||||
className="min-h-[200px]"
|
||||
zoom={15}
|
||||
centre={[site.latitude, site.longitude]}
|
||||
pins={[{ id: site.id, latitude: site.latitude, longitude: site.longitude, libelle: site.name }]}
|
||||
/>
|
||||
) : (
|
||||
<div className="carte text-encre-3">Position non posée — modifiez le site pour la placer.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grille-2">
|
||||
<div className="carte">
|
||||
<h2>Emplacements & appareils</h2>
|
||||
<ul className="arbre">
|
||||
<li className="racine">
|
||||
<b>{site.name}</b>
|
||||
<span className="meta">site</span>
|
||||
<span className="fin">{site.assetCount} appareils</span>
|
||||
</li>
|
||||
{zones.map((zone) => (
|
||||
<li key={zone.id}>
|
||||
<b>{zone.name}</b>
|
||||
<span className="meta">zone</span>
|
||||
<span className="fin">
|
||||
{appareils
|
||||
.filter((a) => a.locationId === zone.id)
|
||||
.map((a) => a.reference)
|
||||
.join(' · ') || '—'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{can('LOCATIONS', 'create') ? (
|
||||
<Button variant="ghost" className="mt-2" onClick={() => setModaleZone(true)}>
|
||||
+ Ajouter un emplacement
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="carte">
|
||||
<h2>Ascenseurs du site</h2>
|
||||
{appareils.map((a) => (
|
||||
<div className="item-liste" key={a.id}>
|
||||
<Link to={`/ascenseurs/${a.id}`} style={{ color: 'var(--primaire)', fontWeight: 600 }}>
|
||||
{a.reference} — {a.brand} {a.model ?? ''}
|
||||
</Link>
|
||||
<span className="fin">
|
||||
<StatutEquipement statut={a.status} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{appareils.length === 0 ? (
|
||||
<p className="text-encre-3">Aucun appareil rattaché pour l’instant.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<NouvelleZone siteId={site.id} ouverte={modaleZone} surFermeture={setModaleZone} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NouvelleZone({
|
||||
siteId,
|
||||
ouverte,
|
||||
surFermeture,
|
||||
}: {
|
||||
siteId: string;
|
||||
ouverte: boolean;
|
||||
surFermeture: (o: boolean) => void;
|
||||
}) {
|
||||
const creation = useCreateLocation();
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const nom = String(new FormData(e.currentTarget).get('name'));
|
||||
creation.mutate(
|
||||
{ name: nom, parentId: siteId },
|
||||
{ onSuccess: () => surFermeture(false) },
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Modale titre="Ajouter un emplacement" ouverte={ouverte} surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="nz-nom">Nom de la zone *</label>
|
||||
<input id="nz-nom" name="name" required placeholder="Ex. : Hall principal" />
|
||||
</div>
|
||||
<p className="note-douce">
|
||||
Hiérarchie limitée à deux niveaux : site → zone. Les organes, eux,
|
||||
suivent leur appareil — jamais d’emplacement propre.
|
||||
</p>
|
||||
{creation.isError ? (
|
||||
<p className="erreur-form" role="alert">{creation.error.message}</p>
|
||||
) : null}
|
||||
<div className="pied">
|
||||
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||
<Button type="submit" variant="prim" disabled={creation.isPending}>
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
206
apps/web/src/pages/nouvel-ascenseur.tsx
Normal file
206
apps/web/src/pages/nouvel-ascenseur.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useCategories, useCreateAsset, useLocations } from '@/api/referentiel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { construireArbre } from '@/lib/lieux';
|
||||
|
||||
interface LigneOrgane {
|
||||
cle: number;
|
||||
typeId: string;
|
||||
designation: string;
|
||||
}
|
||||
|
||||
/** Écran 4 des maquettes R1 — le parcours de recette : identité →
|
||||
* rattachement (site → zone) → organes ligne à ligne. QR généré à la création. */
|
||||
export default function PageNouvelAscenseur() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const { data: locations } = useLocations();
|
||||
const { data: categories } = useCategories();
|
||||
const creation = useCreateAsset();
|
||||
|
||||
const sites = construireArbre(locations ?? []);
|
||||
const [siteId, setSiteId] = useState(params.get('site') ?? '');
|
||||
const [organes, setOrganes] = useState<LigneOrgane[]>([
|
||||
{ cle: 1, typeId: '', designation: '' },
|
||||
]);
|
||||
|
||||
const siteChoisi = sites.find((s) => s.id === siteId);
|
||||
const equipements = (categories ?? []).filter((c) => c.kind === 'EQUIPMENT' && c.isActive);
|
||||
const typesOrganes = (categories ?? []).filter(
|
||||
(c) => c.kind === 'COMPONENT_TYPE' && c.isActive,
|
||||
);
|
||||
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
const champ = (nom: string) => String(donnees.get(nom) ?? '').trim();
|
||||
const dateMes = champ('commissionedAt');
|
||||
creation.mutate(
|
||||
{
|
||||
reference: champ('reference'),
|
||||
brand: champ('brand'),
|
||||
model: champ('model') || undefined,
|
||||
serialNumber: champ('serialNumber') || undefined,
|
||||
commissionedAt: dateMes ? new Date(dateMes).toISOString() : undefined,
|
||||
loadKg: champ('loadKg') ? Number(champ('loadKg')) : undefined,
|
||||
floors: champ('floors') ? Number(champ('floors')) : undefined,
|
||||
categoryId: champ('categoryId'),
|
||||
locationId: champ('locationId'),
|
||||
components: organes
|
||||
.filter((o) => o.typeId)
|
||||
.map((o) => ({ typeId: o.typeId, designation: o.designation || undefined })),
|
||||
},
|
||||
{ onSuccess: (asset) => navigate(`/ascenseurs/${asset.id}`) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 860 }} className="flex flex-col gap-4">
|
||||
<div className="filajout">
|
||||
<Link to="/ascenseurs">Ascenseurs</Link> / Nouvel ascenseur
|
||||
</div>
|
||||
<div className="entete-page"><h1>Nouvel ascenseur</h1></div>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-4">
|
||||
<div className="carte">
|
||||
<h2>1 · Identité</h2>
|
||||
<div className="form-grille">
|
||||
<div className="champ">
|
||||
<label htmlFor="na-ref">Référence *</label>
|
||||
<input id="na-ref" name="reference" required placeholder="Ex. : B3" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="na-serie">N° de série</label>
|
||||
<input id="na-serie" name="serialNumber" placeholder="Plaque constructeur" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="na-marque">Marque *</label>
|
||||
<input id="na-marque" name="brand" required placeholder="Ex. : Schindler" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="na-modele">Modèle</label>
|
||||
<input id="na-modele" name="model" placeholder="Ex. : 3300" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="na-mes">Mise en service</label>
|
||||
<input id="na-mes" name="commissionedAt" type="date" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="na-categorie">Catégorie *</label>
|
||||
<select id="na-categorie" name="categoryId" required defaultValue="">
|
||||
<option value="" disabled>Choisir…</option>
|
||||
{equipements.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="na-charge">Charge (kg)</label>
|
||||
<input id="na-charge" name="loadKg" type="number" min="1" placeholder="630" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="na-niveaux">Niveaux desservis</label>
|
||||
<input id="na-niveaux" name="floors" type="number" min="1" placeholder="8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="carte">
|
||||
<h2>2 · Rattachement</h2>
|
||||
<div className="form-grille">
|
||||
<div className="champ">
|
||||
<label htmlFor="na-site">Site *</label>
|
||||
<select
|
||||
id="na-site"
|
||||
required
|
||||
value={siteId}
|
||||
onChange={(e) => setSiteId(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>Choisir…</option>
|
||||
{sites.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="na-zone">Emplacement dans le site *</label>
|
||||
<select id="na-zone" name="locationId" required defaultValue="" disabled={!siteChoisi}>
|
||||
<option value="" disabled>{siteChoisi ? 'Choisir…' : 'Choisissez un site'}</option>
|
||||
{siteChoisi ? (
|
||||
<option value={siteChoisi.id}>— le site lui-même —</option>
|
||||
) : null}
|
||||
{siteChoisi?.zones.map((z) => (
|
||||
<option key={z.id} value={z.id}>{z.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="carte">
|
||||
<h2>
|
||||
3 · Organes{' '}
|
||||
<span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>
|
||||
— suivis individuellement (pannes par organe, essais réglementaires)
|
||||
</span>
|
||||
</h2>
|
||||
{organes.map((organe, index) => (
|
||||
<div className="ligne-organe" key={organe.cle}>
|
||||
<select
|
||||
aria-label={`Type de l'organe ${index + 1}`}
|
||||
value={organe.typeId}
|
||||
onChange={(e) =>
|
||||
setOrganes((o) =>
|
||||
o.map((x) => (x.cle === organe.cle ? { ...x, typeId: e.target.value } : x)),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Type…</option>
|
||||
{typesOrganes.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={`Désignation de l'organe ${index + 1}`}
|
||||
style={{ flex: 2 }}
|
||||
placeholder="Désignation…"
|
||||
value={organe.designation}
|
||||
onChange={(e) =>
|
||||
setOrganes((o) =>
|
||||
o.map((x) =>
|
||||
x.cle === organe.cle ? { ...x, designation: e.target.value } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-suppr"
|
||||
aria-label={`Retirer l'organe ${index + 1}`}
|
||||
onClick={() => setOrganes((o) => o.filter((x) => x.cle !== organe.cle))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
setOrganes((o) => [...o, { cle: Date.now(), typeId: '', designation: '' }])
|
||||
}
|
||||
>
|
||||
+ Ajouter un organe
|
||||
</Button>
|
||||
</div>
|
||||
{creation.isError ? (
|
||||
<p className="erreur-form" role="alert">{creation.error.message}</p>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={() => navigate('/ascenseurs')}>Annuler</Button>
|
||||
<Button type="submit" variant="prim" disabled={creation.isPending}>
|
||||
Créer l'ascenseur (QR généré)
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
297
apps/web/src/pages/personnes.tsx
Normal file
297
apps/web/src/pages/personnes.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import type { InvitationResponse } from '@siop/shared';
|
||||
import {
|
||||
useCreateTeam,
|
||||
useInviteUser,
|
||||
useResendInvitation,
|
||||
useRoles,
|
||||
useTeams,
|
||||
useUsers,
|
||||
} from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { Avatar, initialsOf } from '@/components/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
|
||||
/** Écran 6 des maquettes R1 : personnes (invitation par lien 7 j, jamais de
|
||||
* mot de passe créé pour autrui) et équipes par zone. */
|
||||
export default function PagePersonnes() {
|
||||
const { data: users } = useUsers();
|
||||
const { data: teams } = useTeams();
|
||||
const { can } = usePermissions();
|
||||
const [modaleInvitation, setModaleInvitation] = useState(false);
|
||||
const [modaleEquipe, setModaleEquipe] = useState(false);
|
||||
const [lien, setLien] = useState<InvitationResponse | null>(null);
|
||||
const renvoi = useResendInvitation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Personnes & équipes</h1>
|
||||
<div className="actions">
|
||||
{can('PEOPLE_TEAMS', 'create') ? (
|
||||
<Button variant="prim" onClick={() => setModaleInvitation(true)}>
|
||||
+ Inviter
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="carte" style={{ padding: 0 }}>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Personne</th>
|
||||
<th>Rôle</th>
|
||||
<th>Équipe</th>
|
||||
<th>Statut</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(users ?? []).map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="flex items-center gap-[9px]">
|
||||
<Avatar
|
||||
initials={initialsOf(u.displayName)}
|
||||
roleName={u.role.name}
|
||||
className="h-[26px] w-[26px] text-[10.5px]"
|
||||
/>
|
||||
<div>
|
||||
<b>{u.displayName}</b>
|
||||
<span className="sous">{u.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{u.role.name}</td>
|
||||
<td>{u.teams.map((t) => t.name).join(', ') || '—'}</td>
|
||||
<td>
|
||||
{u.status === 'active' ? (
|
||||
<span className="st termine">Actif</span>
|
||||
) : u.status === 'invited' ? (
|
||||
<span className="st attente">Invitation envoyée</span>
|
||||
) : (
|
||||
<span className="st annule">Désactivé</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{u.status === 'invited' && can('PEOPLE_TEAMS', 'edit') ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-[12px]"
|
||||
disabled={renvoi.isPending}
|
||||
onClick={() => renvoi.mutate(u.id, { onSuccess: setLien })}
|
||||
>
|
||||
Renvoyer le lien
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="carte">
|
||||
<h2>Équipes</h2>
|
||||
<div className="equipes">
|
||||
{(teams ?? []).map((t) => (
|
||||
<div className="equipe" key={t.id}>
|
||||
<b>{t.name}</b>
|
||||
<span className="zone">{t.description ?? '—'}</span>
|
||||
<div className="avatars">
|
||||
{t.members.map((m) => (
|
||||
<Avatar
|
||||
key={m.id}
|
||||
initials={m.initials}
|
||||
roleName={m.roleName}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
))}
|
||||
{t.members.length === 0 ? (
|
||||
<span className="text-[12px] text-encre-3">aucun membre</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{can('PEOPLE_TEAMS', 'create') ? (
|
||||
<button
|
||||
type="button"
|
||||
className="equipe items-center justify-center text-encre-3"
|
||||
style={{ borderStyle: 'dashed', cursor: 'pointer' }}
|
||||
onClick={() => setModaleEquipe(true)}
|
||||
>
|
||||
<b style={{ fontWeight: 600 }}>+ Nouvelle équipe</b>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModaleInvitation
|
||||
ouverte={modaleInvitation}
|
||||
surFermeture={setModaleInvitation}
|
||||
surLien={setLien}
|
||||
/>
|
||||
<ModaleEquipe ouverte={modaleEquipe} surFermeture={setModaleEquipe} />
|
||||
<ModaleLien lien={lien} surFermeture={() => setLien(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleInvitation({
|
||||
ouverte,
|
||||
surFermeture,
|
||||
surLien,
|
||||
}: {
|
||||
ouverte: boolean;
|
||||
surFermeture: (o: boolean) => void;
|
||||
surLien: (l: InvitationResponse) => void;
|
||||
}) {
|
||||
const { data: roles } = useRoles();
|
||||
const { data: teams } = useTeams();
|
||||
const invitation = useInviteUser();
|
||||
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
const teamId = String(donnees.get('teamId'));
|
||||
invitation.mutate(
|
||||
{
|
||||
email: String(donnees.get('email')),
|
||||
displayName: String(donnees.get('displayName')),
|
||||
roleId: String(donnees.get('roleId')),
|
||||
teamIds: teamId ? [teamId] : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (reponse) => {
|
||||
surFermeture(false);
|
||||
surLien(reponse);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale titre="Inviter une personne" ouverte={ouverte} surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="inv-nom">Nom complet *</label>
|
||||
<input id="inv-nom" name="displayName" required placeholder="Prénom Nom" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="inv-email">Email professionnel *</label>
|
||||
<input id="inv-email" name="email" type="email" required placeholder="prenom.nom@spelev.ma" />
|
||||
</div>
|
||||
<div className="form-grille">
|
||||
<div className="champ">
|
||||
<label htmlFor="inv-role">Rôle *</label>
|
||||
<select id="inv-role" name="roleId" required defaultValue="">
|
||||
<option value="" disabled>Choisir…</option>
|
||||
{(roles ?? []).map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="inv-equipe">Équipe (optionnel)</label>
|
||||
<select id="inv-equipe" name="teamId" defaultValue="">
|
||||
<option value="">—</option>
|
||||
{(teams ?? []).map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="note-douce">
|
||||
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.
|
||||
</p>
|
||||
{invitation.isError ? (
|
||||
<p className="erreur-form" role="alert">{invitation.error.message}</p>
|
||||
) : null}
|
||||
<div className="pied">
|
||||
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||
<Button type="submit" variant="prim" disabled={invitation.isPending}>
|
||||
Envoyer l'invitation
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
|
||||
/** L'envoi d'email n'existe pas encore : le lien se copie ici (décision R1). */
|
||||
function ModaleLien({
|
||||
lien,
|
||||
surFermeture,
|
||||
}: {
|
||||
lien: InvitationResponse | null;
|
||||
surFermeture: () => void;
|
||||
}) {
|
||||
const [copie, setCopie] = useState(false);
|
||||
if (!lien) return null;
|
||||
const url = `${window.location.origin}/activation?token=${lien.activationToken}`;
|
||||
return (
|
||||
<Modale titre="Lien d'activation émis" ouverte surFermeture={surFermeture}>
|
||||
<p className="text-[13px] text-encre-2">
|
||||
Transmettez ce lien à la personne (valable jusqu'au{' '}
|
||||
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(lien.expiresAt))}) :
|
||||
</p>
|
||||
<div className="lien-copiable" data-testid="lien-activation">{url}</div>
|
||||
<div className="pied">
|
||||
<Button
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(url);
|
||||
setCopie(true);
|
||||
}}
|
||||
>
|
||||
{copie ? 'Copié ✓' : 'Copier le lien'}
|
||||
</Button>
|
||||
<Button variant="prim" onClick={surFermeture}>Fermer</Button>
|
||||
</div>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleEquipe({
|
||||
ouverte,
|
||||
surFermeture,
|
||||
}: {
|
||||
ouverte: boolean;
|
||||
surFermeture: (o: boolean) => void;
|
||||
}) {
|
||||
const creation = useCreateTeam();
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
creation.mutate(
|
||||
{
|
||||
name: String(donnees.get('name')),
|
||||
description: String(donnees.get('description')) || undefined,
|
||||
},
|
||||
{ onSuccess: () => surFermeture(false) },
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Modale titre="Nouvelle équipe" ouverte={ouverte} surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="eq-nom">Nom *</label>
|
||||
<input id="eq-nom" name="name" required placeholder="Ex. : Rabat Agdal" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="eq-desc">Zone couverte</label>
|
||||
<input id="eq-desc" name="description" placeholder="Sites couverts…" />
|
||||
</div>
|
||||
{creation.isError ? (
|
||||
<p className="erreur-form" role="alert">{creation.error.message}</p>
|
||||
) : null}
|
||||
<div className="pied">
|
||||
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||
<Button type="submit" variant="prim" disabled={creation.isPending}>Créer</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
172
apps/web/src/pages/sites.tsx
Normal file
172
apps/web/src/pages/sites.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCreateLocation, useLocations } from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { CarteGeo } from '@/components/carte-geo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
import { construireArbre } from '@/lib/lieux';
|
||||
|
||||
/** Écran 1 des maquettes R1 : liste dense + carte OSM (positions PostGIS). */
|
||||
export default function PageSites() {
|
||||
const navigate = useNavigate();
|
||||
const { data: locations } = useLocations();
|
||||
const { can } = usePermissions();
|
||||
const [modale, setModale] = useState(false);
|
||||
|
||||
const sites = construireArbre(locations ?? []);
|
||||
const totalAppareils = sites.reduce((n, s) => n + s.assetCount, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Sites</h1>
|
||||
<span className="filajout">
|
||||
{sites.length} sites · {totalAppareils} ascenseurs
|
||||
</span>
|
||||
<div className="actions">
|
||||
{can('LOCATIONS', 'create') ? (
|
||||
<Button variant="prim" onClick={() => setModale(true)}>
|
||||
+ Nouveau site
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grille-2">
|
||||
<div className="carte" style={{ padding: 0 }}>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Site</th>
|
||||
<th>Ascenseurs</th>
|
||||
<th>Gardien</th>
|
||||
<th>Zones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sites.map((site) => (
|
||||
<tr
|
||||
key={site.id}
|
||||
onClick={() => navigate(`/sites/${site.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<td>
|
||||
<b>{site.name}</b>
|
||||
<span className="sous">
|
||||
{[site.address, site.city].filter(Boolean).join(', ') || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{site.assetCount}</td>
|
||||
<td>{site.guardianName ?? '—'}</td>
|
||||
<td className="num">{site.zones.length}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<CarteGeo
|
||||
pins={sites
|
||||
.filter((s) => s.latitude != null && s.longitude != null)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
latitude: s.latitude!,
|
||||
longitude: s.longitude!,
|
||||
libelle: `${s.name} · ${s.assetCount}`,
|
||||
onClick: () => navigate(`/sites/${s.id}`),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<NouveauSite ouverte={modale} surFermeture={setModale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NouveauSite({
|
||||
ouverte,
|
||||
surFermeture,
|
||||
}: {
|
||||
ouverte: boolean;
|
||||
surFermeture: (o: boolean) => void;
|
||||
}) {
|
||||
const creation = useCreateLocation();
|
||||
const navigate = useNavigate();
|
||||
const [position, setPosition] = useState<{ lat: number; lng: number } | null>(null);
|
||||
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
creation.mutate(
|
||||
{
|
||||
name: String(donnees.get('name')),
|
||||
address: String(donnees.get('address')) || undefined,
|
||||
city: String(donnees.get('city')) || undefined,
|
||||
guardianName: String(donnees.get('guardianName')) || undefined,
|
||||
guardianPhone: String(donnees.get('guardianPhone')) || undefined,
|
||||
latitude: position?.lat,
|
||||
longitude: position?.lng,
|
||||
},
|
||||
{
|
||||
onSuccess: (site) => {
|
||||
surFermeture(false);
|
||||
navigate(`/sites/${site.id}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale titre="Nouveau site" ouverte={ouverte} surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="ns-nom">Nom du site *</label>
|
||||
<input id="ns-nom" name="name" required placeholder="Ex. : Résidence Les Palmiers" />
|
||||
</div>
|
||||
<div className="form-grille">
|
||||
<div className="champ">
|
||||
<label htmlFor="ns-adresse">Adresse</label>
|
||||
<input id="ns-adresse" name="address" placeholder="Bd, rue…" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="ns-ville">Ville</label>
|
||||
<input id="ns-ville" name="city" placeholder="Casablanca" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="ns-gardien">Gardien</label>
|
||||
<input id="ns-gardien" name="guardianName" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="ns-tel">Téléphone gardien</label>
|
||||
<input id="ns-tel" name="guardianPhone" placeholder="06 …" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label>Position — cliquez sur la carte</label>
|
||||
<CarteGeo
|
||||
className="min-h-[180px]"
|
||||
zoom={11}
|
||||
onClick={(lat, lng) => setPosition({ lat, lng })}
|
||||
pins={
|
||||
position
|
||||
? [{ id: 'nouveau', latitude: position.lat, longitude: position.lng, libelle: 'Nouveau site' }]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-[12px] text-encre-3 num">
|
||||
{position ? `${position.lat} N · ${position.lng} E` : 'Aucune position posée'}
|
||||
</p>
|
||||
</div>
|
||||
{creation.isError ? (
|
||||
<p className="erreur-form" role="alert">{creation.error.message}</p>
|
||||
) : null}
|
||||
<div className="pied">
|
||||
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||
<Button type="submit" variant="prim" disabled={creation.isPending}>
|
||||
Créer le site
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user