feat(r2.3): écrans web exploitation — OT, demandes, préventif, compteurs, urgence

- liste OT (filtres, strie rouge, « immédiat ») ; fiche OT : transitions
  pilotées par allowedTransitions, clôture grisée avec la garde expliquée,
  bilan codé (6 selects sur référentiels), checklist Fait→N-A→à faire,
  activité + commentaires, assignation, annulation motivée
- nouvel OT : interrupteur « personne bloquée » qui force la priorité
- demandes : table + panneau d'approbation (priorité, assignation,
  approuver → fiche OT), rejet en modale à motif obligatoire, signalement
  interne ; statut « Résolue » dérivé de l'OT lié
- préventif : tuiles réelles, générer + résumé (« regénérer ne double
  rien »), gabarits administrables ; compteurs : saisie + historique
- tableau de bord réel : bandeau urgence cliquable, KPIs, OT par statut,
  interventions récentes ; accueil dédié aux rôles sans exploitation
- urgence traversante : chip topbar pulsante (60 s), badges de nav
- GET /assets/options (auth seule) : le Demandeur peut désigner l'appareil
  qu'il signale — trou débusqué par l'e2e (49 opérations au contrat)
- génération préventive durcie : collision de référence RETENTÉE (plus de
  saut silencieux), P2003 toléré ; 3 runs Jest complets consécutifs verts
- 9 tests Playwright (recette R2 officielle rejouée intégralement),
  50 tests API (94,6 % / 78,9 %)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-16 17:13:20 +01:00
parent 902efa6192
commit 6a2dae1710
26 changed files with 2274 additions and 93 deletions

View File

@@ -0,0 +1,89 @@
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { METER_KIND_LABELS, type MeterDto, type MeterKind } from '@siop/shared';
import { useAddMeterReading, useMeters } from '@/api/exploitation';
import { useAsset } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { Button } from '@/components/ui/button';
const UNITES: Record<MeterKind, string> = { RUNNING_HOURS: 'h', STARTS: 'cycles' };
const fmt = new Intl.NumberFormat('fr-FR');
/** Écran 5 des maquettes R2 : relevés simples du carnet, strictement croissants. */
export default function PageCompteurs() {
const { id } = useParams<{ id: string }>();
const { data: asset } = useAsset(id);
const { data: meters } = useMeters(id);
if (!asset) return null;
return (
<>
<div className="filajout">
<Link to="/ascenseurs">Ascenseurs</Link> /{' '}
<Link to={`/ascenseurs/${asset.id}`}>Ascenseur {asset.reference}</Link> / Compteurs
</div>
<div className="entete-page"><h1>Compteurs Ascenseur {asset.reference}</h1></div>
<div className="grille-2">
{(meters ?? []).map((meter) => (
<CarteCompteur key={meter.kind} assetId={asset.id} meter={meter} />
))}
</div>
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
Un relevé <b>inférieur au précédent</b> est refusé (un compteur ne redescend pas).
Les seuils d'alerte (préventif conditionnel) sont au backlog — R2 stocke et trace.
</div>
</>
);
}
function CarteCompteur({ assetId, meter }: { assetId: string; meter: MeterDto }) {
const { can } = usePermissions();
const ajout = useAddMeterReading(assetId);
const [valeur, setValeur] = useState('');
return (
<div className="carte">
<h2>{METER_KIND_LABELS[meter.kind]}</h2>
{can('METERS', 'create') ? (
<form
className="releve-saisie"
onSubmit={(e) => {
e.preventDefault();
const v = Number(valeur.replace(/\s/g, ''));
if (!Number.isFinite(v)) return;
ajout.mutate({ kind: meter.kind, value: v }, { onSuccess: () => setValeur('') });
}}
>
<input
aria-label={`Nouveau relevé — ${METER_KIND_LABELS[meter.kind]}`}
className="num"
inputMode="numeric"
placeholder={meter.readings[0] ? fmt.format(meter.readings[0].value) : '0'}
value={valeur}
onChange={(e) => setValeur(e.target.value)}
/>
<span style={{ color: 'var(--encre-2)', fontSize: 13 }}>{UNITES[meter.kind]}</span>
<Button type="submit" variant="prim" disabled={ajout.isPending}>
Enregistrer le relevé
</Button>
</form>
) : null}
{ajout.isError ? <p className="erreur-form mt-2">{ajout.error.message}</p> : null}
<ul className="chrono" style={{ marginTop: 14 }}>
{meter.readings.map((r) => (
<li key={r.id} className="fait">
<span className="num">{fmt.format(r.value)} {UNITES[meter.kind]}</span>
<br />
<span className="quand">
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(r.createdAt))}
{r.readBy ? ` · ${r.readBy.displayName}` : ''}
</span>
</li>
))}
{meter.readings.length === 0 ? (
<li className="text-encre-3" style={{ padding: 0 }}>Aucun relevé pour l'instant.</li>
) : null}
</ul>
</div>
);
}

View File

@@ -0,0 +1,301 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
WORK_ORDER_PRIORITY_LABELS,
type RequestSummary,
} from '@siop/shared';
import {
useApproveRequest,
useCreateRequest,
useRejectRequest,
useRequests,
} from '@/api/exploitation';
import { useAssetOptions, useUsers } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { StatutDemande } from '@/components/chips-ot';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
/** Écran 1 des maquettes R2 : la file d'attente + le panneau d'approbation.
* Une demande approuvée devient UN OT (1-1) ; un rejet exige un motif. */
export default function PageDemandes() {
const { data: requests } = useRequests();
const { can } = usePermissions();
const [selectionnee, setSelectionnee] = useState<string | null>(null);
const [modaleSignalement, setModaleSignalement] = useState(false);
const toutes = requests ?? [];
const aTraiter = toutes.filter((r) => r.status === 'RECEIVED').length;
const demande = toutes.find((r) => r.id === selectionnee) ??
toutes.find((r) => r.status === 'RECEIVED') ?? null;
const peutTraiter = can('REQUESTS', 'edit');
return (
<>
<div className="entete-page">
<h1>Demandes</h1>
<span className="filajout">
{aTraiter} à traiter · {toutes.length} au total
</span>
<div className="actions">
{can('REQUESTS', 'create') ? (
<Button variant="prim" onClick={() => setModaleSignalement(true)}>
+ Signaler
</Button>
) : null}
</div>
</div>
<div className="grille-2" style={{ gridTemplateColumns: peutTraiter ? '1.4fr 1fr' : '1fr' }}>
<div className="carte" style={{ padding: 0 }}>
<div className="table">
<table>
<thead>
<tr>
<th>N°</th>
<th>Signalement</th>
<th>Équipement · Site</th>
<th>Par</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{toutes.map((r) => (
<tr
key={r.id}
className={r.isPersonTrapped && r.status === 'RECEIVED' ? 'ligne-bloque' : undefined}
style={{
cursor: peutTraiter ? 'pointer' : undefined,
background: r.id === demande?.id && peutTraiter ? 'var(--primaire-doux)' : undefined,
}}
onClick={() => setSelectionnee(r.id)}
>
<td className="ref num">{r.reference}</td>
<td>{r.isPersonTrapped ? <b>{r.description}</b> : r.description}</td>
<td>
Asc. {r.assetReference}
<span className="sous">{r.siteName}</span>
</td>
<td>{r.requesterLabel}</td>
<td>
{r.workOrder ? (
<Link to={`/ot/${r.workOrder.id}`} onClick={(e) => e.stopPropagation()}>
<StatutDemande statut={r.status} otStatut={r.workOrder.status} />
</Link>
) : (
<StatutDemande statut={r.status} />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{peutTraiter && demande ? <PanneauApprobation demande={demande} /> : null}
</div>
<ModaleSignalement ouverte={modaleSignalement} surFermeture={setModaleSignalement} />
</>
);
}
function PanneauApprobation({ demande }: { demande: RequestSummary }) {
const navigate = useNavigate();
const { data: users } = useUsers();
const approbation = useApproveRequest();
const rejet = useRejectRequest();
const [motif, setMotif] = useState<string | null>(null); // null = fermé
const [priorite, setPriorite] = useState('HIGH');
const [assigne, setAssigne] = useState('');
const techniciens = (users ?? []).filter(
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
);
const traitee = demande.status !== 'RECEIVED';
return (
<div className="panneau">
<h2 style={{ fontSize: 13, fontWeight: 700, letterSpacing: '.04em', textTransform: 'uppercase', color: 'var(--encre-2)' }}>
{demande.reference} {traitee ? 'déjà traitée' : 'approbation'}
</h2>
<dl className="infos" style={{ gridTemplateColumns: '110px 1fr' }}>
<dt>Équipement</dt>
<dd>
<Link to={`/ascenseurs/${demande.assetId}`} style={{ color: 'var(--primaire)', fontWeight: 600 }}>
Ascenseur {demande.assetReference}
</Link>{' '}
{demande.siteName}
</dd>
<dt>Signalé par</dt>
<dd>{demande.requesterLabel}</dd>
<dt>Description</dt>
<dd>« {demande.description} »</dd>
{demande.rejectionReason ? (
<>
<dt>Motif du rejet</dt>
<dd>{demande.rejectionReason}</dd>
</>
) : null}
</dl>
{!traitee ? (
<>
<div className="champ">
<label htmlFor="app-prio">Priorité de l'OT</label>
<select
id="app-prio"
value={demande.isPersonTrapped ? 'PERSON_TRAPPED' : priorite}
disabled={demande.isPersonTrapped}
onChange={(e) => setPriorite(e.target.value)}
>
{(['LOW', 'MEDIUM', 'HIGH', 'PERSON_TRAPPED'] as const).map((p) => (
<option key={p} value={p}>{WORK_ORDER_PRIORITY_LABELS[p]}</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="app-assigne">Assigner à</label>
<select id="app-assigne" value={assigne} onChange={(e) => setAssigne(e.target.value)}>
<option value="">— plus tard —</option>
{techniciens.map((t) => (
<option key={t.id} value={t.id}>
{t.displayName}
{t.teams.length ? ` — équipe ${t.teams[0]!.name}` : ''}
</option>
))}
</select>
</div>
{(approbation.isError || rejet.isError) ? (
<p className="erreur-form" role="alert">
{approbation.error?.message ?? rejet.error?.message}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button variant="dangerLeger" onClick={() => setMotif('')}>
Rejeter (motif requis)
</Button>
<Button
variant="prim"
disabled={approbation.isPending}
onClick={() =>
approbation.mutate(
{
id: demande.id,
priority: (demande.isPersonTrapped ? 'PERSON_TRAPPED' : priorite) as never,
assigneeIds: assigne ? [assigne] : undefined,
},
{ onSuccess: (ot) => navigate(`/ot/${ot.id}`) },
)
}
>
Approuver → créer l'OT
</Button>
</div>
<p style={{ fontSize: 12, color: 'var(--encre-3)' }}>
L'OT reprend équipement et description — la demande suit ensuite l'OT
(Reçu Intervention Résolu côté demandeur).
</p>
</>
) : null}
<Modale titre="Rejeter la demande" ouverte={motif !== null} surFermeture={() => setMotif(null)}>
<form
onSubmit={(e: FormEvent) => {
e.preventDefault();
rejet.mutate({ id: demande.id, reason: motif ?? '' }, { onSuccess: () => setMotif(null) });
}}
className="flex flex-col gap-3"
>
<div className="champ">
<label htmlFor="rej-motif">Motif (lisible par le demandeur, sans jargon) *</label>
<input
id="rej-motif"
required
minLength={3}
value={motif ?? ''}
onChange={(e) => setMotif(e.target.value)}
placeholder="Ex. : fausse alerte confirmée sur place"
/>
</div>
{rejet.isError ? <p className="erreur-form">{rejet.error.message}</p> : null}
<div className="pied">
<Button onClick={() => setMotif(null)}>Annuler</Button>
<Button type="submit" variant="dangerLeger" disabled={rejet.isPending}>
Rejeter la demande
</Button>
</div>
</form>
</Modale>
</div>
);
}
/** Signalement interne (le portail public QR arrive en R2.4). */
function ModaleSignalement({
ouverte,
surFermeture,
}: {
ouverte: boolean;
surFermeture: (o: boolean) => void;
}) {
const { data: options } = useAssetOptions();
const creation = useCreateRequest();
const [personneBloquee, setPersonneBloquee] = useState(false);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const donnees = new FormData(e.currentTarget);
creation.mutate(
{
assetId: String(donnees.get('assetId')),
description: String(donnees.get('description')),
isPersonTrapped: personneBloquee,
},
{
onSuccess: () => {
surFermeture(false);
setPersonneBloquee(false);
},
},
);
};
return (
<Modale titre="Signaler un problème" ouverte={ouverte} surFermeture={surFermeture}>
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
<div className="champ">
<label htmlFor="sig-asset">Équipement *</label>
<select id="sig-asset" name="assetId" required defaultValue="">
<option value="" disabled>Choisir</option>
{(options ?? []).map((a) => (
<option key={a.id} value={a.id}>{a.reference} {a.siteName}</option>
))}
</select>
</div>
<div className="gros-toggle">
<b>Une personne est-elle bloquée ?</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="sig-desc">Décrivez le problème *</label>
<input
id="sig-desc"
name="description"
required
placeholder="Ex. : la porte ne se ferme plus au 3ᵉ étage"
/>
</div>
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
<div className="pied">
<Button onClick={() => surFermeture(false)}>Annuler</Button>
<Button type="submit" variant="prim" disabled={creation.isPending}>
Envoyer le signalement
</Button>
</div>
</form>
</Modale>
);
}

View File

@@ -2,6 +2,7 @@ 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 { useWorkOrders } from '@/api/exploitation';
import {
useAddComponent,
useAsset,
@@ -10,6 +11,7 @@ import {
useUpdateAsset,
} from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { StatutOT } from '@/components/chips-ot';
import { StatutEquipement } from '@/components/statut-equipement';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
@@ -51,6 +53,9 @@ export default function PageFicheAscenseur() {
))}
</select>
) : null}
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/compteurs`)}>
Compteurs
</Button>
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/etiquette`)}>
Étiquette QR
</Button>
@@ -129,12 +134,7 @@ export default function PageFicheAscenseur() {
) : 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 lexploitation (R2).
</p>
</div>
<CarteHistorique assetId={asset.id} />
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Documents</h2>
<p className="text-encre-3">
@@ -148,6 +148,37 @@ export default function PageFicheAscenseur() {
);
}
/** Historique récent (écran 5 validé R0) — les OT de CET appareil. */
function CarteHistorique({ assetId }: { assetId: string }) {
const { can } = usePermissions();
const { data: workOrders } = useWorkOrders(can('WORK_ORDERS', 'view'));
const historique = (workOrders ?? []).filter((w) => w.assetId === assetId).slice(0, 4);
if (!can('WORK_ORDERS', 'view')) return null;
return (
<div className="carte">
<h2>Historique récent</h2>
{historique.length ? (
<ul className="chrono">
{historique.map((w) => (
<li key={w.id} className="fait">
<Link to={`/ot/${w.id}`} className="num" style={{ color: 'var(--primaire)', fontWeight: 600 }}>
{w.reference}
</Link>{' '}
· {w.title} <StatutOT statut={w.status} />
<br />
<span className="quand">
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(w.createdAt))}
</span>
</li>
))}
</ul>
) : (
<p className="text-encre-3">Aucune intervention enregistrée.</p>
)}
</div>
);
}
function NouvelOrgane({
assetId,
ouverte,

View File

@@ -0,0 +1,354 @@
import { useState, type FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom';
import {
BILAN_FIELD_LABELS,
REQUIRED_BILAN_FIELDS,
WORK_ORDER_TYPE_LABELS,
type BilanField,
type WorkOrderDetail,
type WorkOrderStatus,
} from '@siop/shared';
import {
useCommentWorkOrder,
usePatchChecklist,
useReferenceValues,
useSetAssignees,
useTransitionWorkOrder,
useUpsertReport,
useWorkOrder,
} from '@/api/exploitation';
import { useUsers } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { Avatar } from '@/components/avatar';
import { PrioriteOT, StatutOT } from '@/components/chips-ot';
import { Button } from '@/components/ui/button';
import { formatDateFr } from '@/lib/lieux';
const BOUTONS_TRANSITION: Record<WorkOrderStatus, { libelle: string; variant?: 'prim' | 'succes' | 'dangerLeger' | 'ghost' }> = {
IN_PROGRESS: { libelle: 'Démarrer', variant: 'prim' },
ON_HOLD: { libelle: 'En attente', variant: 'ghost' },
DONE: { libelle: "Clôturer l'intervention", variant: 'succes' },
CANCELLED: { libelle: 'Annuler', variant: 'dangerLeger' },
OPEN: { libelle: 'Rouvrir' },
};
/** Écran 4 validé R0 (+ checklist maquette R2) : tout le carnet sur un écran. */
export default function PageFicheOT() {
const { id } = useParams<{ id: string }>();
const { data: ot } = useWorkOrder(id);
const { can } = usePermissions();
const transition = useTransitionWorkOrder(id ?? '');
if (!ot) return null;
const peutEditer = can('WORK_ORDERS', 'edit');
const cloturable = ot.closureBlockers.length === 0;
return (
<>
<div className="filajout">
<Link to="/ot">Ordres de travail</Link> / {ot.reference}
</div>
<div className="entete-page">
<h1>{ot.title}</h1>
<StatutOT statut={ot.status} />
<div className="actions">
{peutEditer
? ot.allowedTransitions.map((cible) => {
const bouton = BOUTONS_TRANSITION[cible];
const bloquee = cible === 'DONE' && !cloturable;
return (
<Button
key={cible}
variant={bouton.variant}
disabled={transition.isPending || bloquee}
title={bloquee ? ot.closureBlockers.join(' ; ') : undefined}
onClick={() => {
const commentaire =
cible === 'CANCELLED'
? window.prompt('Motif dannulation (visible dans lactivité) :') ?? undefined
: undefined;
if (cible === 'CANCELLED' && !commentaire) return;
transition.mutate({ to: cible, comment: commentaire });
}}
>
{bouton.libelle}
</Button>
);
})
: null}
</div>
</div>
{transition.isError ? (
<p className="erreur-form" role="alert">{transition.error.message}</p>
) : null}
<div className="grille-2">
<CarteIntervention ot={ot} peutEditer={peutEditer} />
<CarteActivite ot={ot} peutEditer={peutEditer} />
</div>
<div className="grille-2">
<div className="flex flex-col gap-4">
{ot.checklist.length > 0 ? <CarteChecklist ot={ot} peutEditer={peutEditer} /> : null}
<CarteBilan ot={ot} peutEditer={peutEditer} />
</div>
<div className="flex flex-col gap-4">
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Pièces & main-d'œuvre</h2>
<p className="text-encre-3">Consommations et coûts arrivent avec la gestion (R3).</p>
</div>
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Documents</h2>
<p className="text-encre-3">Photos et pièces jointes arrivent avec la bibliothèque (R3).</p>
</div>
</div>
</div>
</>
);
}
function CarteIntervention({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
const { data: users } = useUsers();
const assignation = useSetAssignees(ot.id);
const [enAssignation, setEnAssignation] = useState(false);
const techniciens = (users ?? []).filter(
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
);
return (
<div className="carte">
<h2>Intervention</h2>
<dl className="infos">
<dt>N°</dt>
<dd className="num">{ot.reference} · {WORK_ORDER_TYPE_LABELS[ot.type]}</dd>
<dt>Équipement</dt>
<dd>
<Link to={`/ascenseurs/${ot.assetId}`} style={{ color: 'var(--primaire)', fontWeight: 600 }}>
Ascenseur {ot.assetReference}
</Link>{' '}
— {ot.siteName}
</dd>
<dt>Priorité</dt>
<dd><PrioriteOT priorite={ot.priority} /></dd>
<dt>Échéance</dt>
<dd className="num">{formatDateFr(ot.dueDate)}</dd>
<dt>Assignés</dt>
<dd className="flex items-center gap-2">
{ot.assignees.length ? (
<>
<span className="avatars">
{ot.assignees.map((a) => (
<Avatar key={a.id} initials={a.initials} className="h-6 w-6 text-[10px]" />
))}
</span>
{ot.assignees.map((a) => a.displayName).join(', ')}
</>
) : (
''
)}
{peutEditer ? (
<Button
variant="ghost"
className="px-2 py-1 text-[12px]"
onClick={() => setEnAssignation((v) => !v)}
>
+ Assigner
</Button>
) : null}
</dd>
{ot.request ? (
<>
<dt>Demande liée</dt>
<dd>
<span className="num">{ot.request.reference}</span> — {ot.request.requesterLabel}
</dd>
</>
) : null}
</dl>
{enAssignation ? (
<div className="mt-3 flex items-center gap-2">
<select
aria-label="Choisir un technicien"
className="flex-1 rounded-lg border border-bordure-forte bg-surface p-2 text-encre"
defaultValue=""
onChange={(e) => {
if (!e.target.value) return;
assignation.mutate(
{ assigneeIds: [...new Set([...ot.assignees.map((a) => a.id), e.target.value])] },
{ onSuccess: () => setEnAssignation(false) },
);
}}
>
<option value="" disabled>Choisir un technicien…</option>
{techniciens.map((t) => (
<option key={t.id} value={t.id}>{t.displayName}</option>
))}
</select>
{ot.assignees.length ? (
<Button
variant="ghost"
className="text-[12px]"
onClick={() =>
assignation.mutate({ assigneeIds: [] }, { onSuccess: () => setEnAssignation(false) })
}
>
Tout retirer
</Button>
) : null}
</div>
) : null}
</div>
);
}
const KIND_LABELS: Record<string, string> = {
CREATED: 'OT créé',
GENERATED: 'Généré',
FROM_REQUEST: 'Créé depuis une demande',
STATUS_CHANGED: 'Changement détat',
ASSIGNED: 'Assignation',
COMMENT: 'Commentaire',
};
function CarteActivite({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
const commentaire = useCommentWorkOrder(ot.id);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const message = String(new FormData(form).get('message') ?? '').trim();
if (!message) return;
commentaire.mutate(message, { onSuccess: () => form.reset() });
};
return (
<div className="carte">
<h2>Activité</h2>
{peutEditer ? (
<form onSubmit={surEnvoi} className="mb-3 flex gap-2">
<input
name="message"
aria-label="Commenter"
placeholder="Commenter…"
className="flex-1 rounded-lg border border-bordure-forte bg-surface px-3 py-2 text-encre"
/>
<Button type="submit" disabled={commentaire.isPending}>Publier</Button>
</form>
) : null}
<ul className="chrono">
{ot.events.map((e) => (
<li key={e.id} className="fait">
{e.kind === 'COMMENT' ? (
<>Commentaire {e.by ? <b>{e.by.displayName}</b> : null} : « {e.message} »</>
) : (
<>
<b>{KIND_LABELS[e.kind] ?? e.kind}</b>
{e.message ? <> — {e.message}</> : null}
</>
)}
<br />
<span className="quand">
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(e.createdAt))}
{e.by && e.kind !== 'COMMENT' ? ` · ${e.by.displayName}` : ''}
</span>
</li>
))}
</ul>
</div>
);
}
function CarteChecklist({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
const patch = usePatchChecklist(ot.id);
const traitees = ot.checklist.filter((c) => c.state !== 'PENDING').length;
return (
<div className="carte">
<h2>
Checklist du mois{' '}
<span style={{ color: 'var(--encre-3)', textTransform: 'none', letterSpacing: 0 }}>
— {traitees} / {ot.checklist.length} traitées
</span>
</h2>
{ot.checklist.map((item) => (
<div className="check" key={item.id}>
<button
type="button"
className={`coche ${item.state === 'DONE' ? 'ok' : item.state === 'NA' ? 'na' : ''}`}
aria-label={`${item.label} — ${item.state === 'PENDING' ? 'marquer Fait (clic) ou N-A (double clic)' : 'remettre à faire'}`}
disabled={!peutEditer || patch.isPending}
onClick={() =>
patch.mutate({
itemId: item.id,
state: item.state === 'PENDING' ? 'DONE' : item.state === 'DONE' ? 'NA' : 'PENDING',
})
}
>
{item.state === 'DONE' ? '' : item.state === 'NA' ? 'N-A' : ''}
</button>
{item.label}
<span className="qui-fait">
{item.doneBy
? `${item.doneBy.displayName.split(' ')[0]} · ${new Intl.DateTimeFormat('fr-FR', { timeStyle: 'short' }).format(new Date(item.doneAt!))}`
: ''}
</span>
</div>
))}
{ot.checklist.some((c) => c.state === 'PENDING') ? (
<p className="avertissement">
⚠ La clôture reste bloquée : chaque tâche doit être <b>Fait</b> ou <b>N-A</b>.
</p>
) : null}
</div>
);
}
const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable<WorkOrderDetail['report']> & string; dto: string }[] = [
{ champ: 'DOOR_STATE', cle: 'doorState', dto: 'doorStateId' },
{ champ: 'CABIN_POSITION', cle: 'cabinPosition', dto: 'cabinPositionId' },
{ champ: 'ANOMALY', cle: 'anomaly', dto: 'anomalyId' },
{ champ: 'EXTERNAL_CAUSE', cle: 'externalCause', dto: 'externalCauseId' },
{ champ: 'ACTION_TAKEN', cle: 'actionTaken', dto: 'actionTakenId' },
{ champ: 'COMPONENT_CONCERNED', cle: 'componentConcerned', dto: 'componentConcernedId' },
];
function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
const { data: valeurs } = useReferenceValues();
const maj = useUpsertReport(ot.id);
const bloqueurBilan = ot.closureBlockers.find((b) => b.includes('bilan'));
return (
<div className="carte">
<h2>
Bilan d'intervention{' '}
<span style={{ color: 'var(--encre-3)', textTransform: 'none', letterSpacing: 0 }}>
requis pour clôturer
</span>
</h2>
<div className="bilan">
{CHAMPS_BILAN.map(({ champ, cle, dto }) => {
const options = (valeurs ?? []).filter((v) => v.field === champ && v.isActive);
const valeur = ot.report?.[cle as 'doorState'] ?? null;
const requis = REQUIRED_BILAN_FIELDS.includes(champ);
return (
<div className="champ-b" key={champ}>
<label htmlFor={`bilan-${champ}`}>
{BILAN_FIELD_LABELS[champ]} {requis ? <em>*</em> : null}
</label>
<select
id={`bilan-${champ}`}
disabled={!peutEditer || maj.isPending || ot.status === 'DONE' || ot.status === 'CANCELLED'}
value={valeur?.id ?? ''}
onChange={(e) => maj.mutate({ [dto]: e.target.value || null })}
>
<option value="">Sélectionner</option>
{options.map((o) => (
<option key={o.id} value={o.id}>{o.label}</option>
))}
</select>
</div>
);
})}
</div>
{bloqueurBilan && ot.status !== 'DONE' && ot.status !== 'CANCELLED' ? (
<p className="avertissement"> {bloqueurBilan} la clôture restera bloquée.</p>
) : null}
{maj.isError ? <p className="erreur-form" role="alert">{maj.error.message}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import {
WORK_ORDER_PRIORITY_LABELS,
WORK_ORDER_TYPE_LABELS,
WORK_ORDER_TYPES,
type WorkOrderPriority,
type WorkOrderType,
} from '@siop/shared';
import { useCreateWorkOrder } from '@/api/exploitation';
import { useAssets } from '@/api/referentiel';
import { useUsers } from '@/api/referentiel';
import { Button } from '@/components/ui/button';
/** Écran 2 des maquettes R2 : l'OT manuel — « personne bloquée » est un
* choix EXPLICITE (interrupteur) qui déclenche l'urgence partout. */
export default function PageNouvelOT() {
const navigate = useNavigate();
const [params] = useSearchParams();
const { data: assets } = useAssets();
const { data: users } = useUsers();
const creation = useCreateWorkOrder();
const [personneBloquee, setPersonneBloquee] = useState(false);
const techniciens = (users ?? []).filter(
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const donnees = new FormData(e.currentTarget);
const champ = (n: string) => String(donnees.get(n) ?? '').trim();
const assigne = champ('assigneeId');
creation.mutate(
{
title: champ('title'),
description: champ('description') || undefined,
type: champ('type') as WorkOrderType,
priority: personneBloquee
? 'PERSON_TRAPPED'
: (champ('priority') as WorkOrderPriority),
assetId: champ('assetId'),
dueDate: champ('dueDate') ? new Date(champ('dueDate')).toISOString() : undefined,
assigneeIds: assigne ? [assigne] : undefined,
},
{ onSuccess: (ot) => navigate(`/ot/${ot.id}`) },
);
};
return (
<div style={{ maxWidth: 760 }} className="flex flex-col gap-4">
<div className="filajout">
<Link to="/ot">Ordres de travail</Link> / Nouvel OT
</div>
<div className="entete-page"><h1>Nouvel ordre de travail</h1></div>
<form onSubmit={surEnvoi} className="flex flex-col gap-4">
<div className="carte">
<div className="form-grille">
<div className="champ pleine-l">
<label htmlFor="not-titre">Objet *</label>
<input id="not-titre" name="title" required placeholder="Ex. : Remplacement contact de porte" />
</div>
<div className="champ">
<label htmlFor="not-type">Type *</label>
<select id="not-type" name="type" required defaultValue="CORRECTIVE">
{WORK_ORDER_TYPES.map((t) => (
<option key={t} value={t}>{WORK_ORDER_TYPE_LABELS[t]}</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="not-asset">Équipement *</label>
<select id="not-asset" name="assetId" required defaultValue={params.get('asset') ?? ''}>
<option value="" disabled>Choisir</option>
{(assets ?? []).map((a) => (
<option key={a.id} value={a.id}>
{a.reference} {a.siteName}
</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="not-prio">Priorité *</label>
<select id="not-prio" name="priority" defaultValue="MEDIUM" disabled={personneBloquee}>
{(['NONE', 'LOW', 'MEDIUM', 'HIGH'] as const).map((p) => (
<option key={p} value={p}>{WORK_ORDER_PRIORITY_LABELS[p]}</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="not-echeance">Échéance</label>
<input id="not-echeance" name="dueDate" type="date" />
</div>
<div className="champ pleine-l">
<label htmlFor="not-desc">Description</label>
<input id="not-desc" name="description" placeholder="Constat, contexte, consignes…" />
</div>
<div className="champ">
<label htmlFor="not-assigne">Assigner à</label>
<select id="not-assigne" name="assigneeId" defaultValue="">
<option value=""></option>
{techniciens.map((t) => (
<option key={t.id} value={t.id}>{t.displayName}</option>
))}
</select>
</div>
</div>
<div className="gros-toggle" style={{ marginTop: 14 }}>
<b>Personne bloquée dans la cabine ?</b>
<span style={{ marginLeft: 6, fontSize: 12, color: 'var(--encre-2)' }}>
passe l'OT en urgence absolue (bandeau + tête de liste)
</span>
<button
type="button"
className="interrupteur"
role="switch"
aria-checked={personneBloquee}
aria-label="Personne bloquée"
onClick={() => setPersonneBloquee((v) => !v)}
/>
</div>
</div>
{creation.isError ? (
<p className="erreur-form" role="alert">{creation.error.message}</p>
) : null}
<div className="flex justify-end gap-2">
<Button onClick={() => navigate('/ot')}>Annuler</Button>
<Button type="submit" variant="prim" disabled={creation.isPending}>
Créer l'OT (statut Ouvert)
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_STATUSES,
WORK_ORDER_TYPE_LABELS,
WORK_ORDER_TYPES,
type WorkOrderStatus,
type WorkOrderType,
} from '@siop/shared';
import { useWorkOrders } from '@/api/exploitation';
import { usePermissions } from '@/auth/use-permissions';
import { Avatar } from '@/components/avatar';
import { PrioriteOT, StatutOT } from '@/components/chips-ot';
import { Button } from '@/components/ui/button';
import { formatDateFr } from '@/lib/lieux';
/** Écran 3 validé R0 : table dense « bureau », strie rouge sur l'urgence. */
export default function PageOrdresTravail() {
const navigate = useNavigate();
const { data: workOrders } = useWorkOrders();
const { can } = usePermissions();
const [statut, setStatut] = useState<'actifs' | 'tous' | WorkOrderStatus>('actifs');
const [type, setType] = useState<'' | WorkOrderType>('');
const tous = workOrders ?? [];
const visibles = tous.filter((w) => {
if (statut === 'actifs' && (w.status === 'DONE' || w.status === 'CANCELLED')) return false;
if (statut !== 'actifs' && statut !== 'tous' && w.status !== statut) return false;
if (type && w.type !== type) return false;
return true;
});
return (
<>
<div className="entete-page">
<h1>Ordres de travail</h1>
<div className="actions">
{can('WORK_ORDERS', 'create') ? (
<Button variant="prim" onClick={() => navigate('/ot/nouveau')}>
+ Nouvel OT
</Button>
) : null}
</div>
</div>
<div className="filtres">
<select
aria-label="Filtrer par statut"
value={statut}
onChange={(e) => setStatut(e.target.value as typeof statut)}
>
<option value="actifs">Statut : actifs</option>
<option value="tous">Statut : tous</option>
{WORK_ORDER_STATUSES.map((s) => (
<option key={s} value={s}>{WORK_ORDER_STATUS_LABELS[s]}</option>
))}
</select>
<select
aria-label="Filtrer par type"
value={type}
onChange={(e) => setType(e.target.value as '' | WorkOrderType)}
>
<option value="">Type : tous</option>
{WORK_ORDER_TYPES.map((t) => (
<option key={t} value={t}>{WORK_ORDER_TYPE_LABELS[t]}</option>
))}
</select>
<span className="num" style={{ color: 'var(--encre-3)', fontSize: 12.5, marginLeft: 'auto' }}>
{visibles.length} résultats
</span>
</div>
<div className="carte" style={{ padding: 0 }}>
<div className="table">
<table>
<thead>
<tr>
<th>N°</th>
<th>Objet</th>
<th>Équipement · Site</th>
<th>Type</th>
<th>Priorité</th>
<th>Statut</th>
<th>Assignés</th>
<th>Échéance</th>
</tr>
</thead>
<tbody>
{visibles.map((w) => (
<tr
key={w.id}
className={w.priority === 'PERSON_TRAPPED' ? 'ligne-bloque' : undefined}
onClick={() => navigate(`/ot/${w.id}`)}
style={{ cursor: 'pointer' }}
>
<td className="ref num">{w.reference}</td>
<td>{w.priority === 'PERSON_TRAPPED' ? <b>{w.title}</b> : w.title}</td>
<td>
Asc. {w.assetReference}
<span className="sous">{w.siteName}</span>
</td>
<td>{WORK_ORDER_TYPE_LABELS[w.type]}</td>
<td><PrioriteOT priorite={w.priority} /></td>
<td><StatutOT statut={w.status} /></td>
<td>
{w.assignees.length ? (
<div className="avatars">
{w.assignees.map((a) => (
<Avatar key={a.id} initials={a.initials} className="h-6 w-6 text-[10px]" />
))}
</div>
) : (
'—'
)}
</td>
<td className="num">
{w.priority === 'PERSON_TRAPPED' ? <b>immédiat</b> : formatDateFr(w.dueDate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,166 @@
import { useState, type FormEvent } from 'react';
import type { PreventiveGenerationResult } from '@siop/shared';
import {
useCreateTaskTemplate,
useGeneratePreventive,
usePreventiveStatus,
useTaskTemplates,
useUpdateTaskTemplate,
} from '@/api/exploitation';
import { usePermissions } from '@/auth/use-permissions';
import { Button } from '@/components/ui/button';
const MOIS_FR = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
];
const libelleMois = (period: string) => {
const [annee, mois] = period.split('-').map(Number);
return `${MOIS_FR[mois! - 1]} ${annee}`;
};
/** Écran 3 des maquettes R2 : gabarits à périodicité + génération idempotente. */
export default function PagePreventif() {
const { data: status } = usePreventiveStatus();
const { data: templates } = useTaskTemplates();
const { can } = usePermissions();
const generation = useGeneratePreventive();
const creation = useCreateTaskTemplate();
const maj = useUpdateTaskTemplate();
const [resultat, setResultat] = useState<PreventiveGenerationResult | null>(null);
const [nouveau, setNouveau] = useState({ label: '', periodMonths: 1 });
const peutGenerer = can('WORK_ORDERS', 'create');
const peutAdministrer = can('SETTINGS', 'edit');
const ajouterGabarit = (e: FormEvent) => {
e.preventDefault();
if (!nouveau.label.trim()) return;
creation.mutate(
{ label: nouveau.label.trim(), periodMonths: nouveau.periodMonths },
{ onSuccess: () => setNouveau({ label: '', periodMonths: 1 }) },
);
};
return (
<>
<div className="entete-page">
<h1>Préventif</h1>
<div className="actions">
{peutGenerer && status ? (
<Button
variant="prim"
disabled={generation.isPending}
onClick={() => generation.mutate({}, { onSuccess: setResultat })}
>
Générer la grille de {libelleMois(status.month)}
</Button>
) : null}
</div>
</div>
{status ? (
<div className="kpis">
<div className="kpi">
<div className="lib">Grilles générées</div>
<div className="val num">{status.generated} / {status.assetsUnderContract}</div>
</div>
<div className="kpi">
<div className="lib">Terminées</div>
<div className="val num">{status.done}</div>
</div>
<div className={status.late ? 'kpi alarme' : 'kpi'}>
<div className="lib">En retard</div>
<div className="val num">{status.late}</div>
</div>
<div className="kpi">
<div className="lib">Premiers contrôles</div>
<div className="val num">{status.firstControls}</div>
</div>
</div>
) : null}
{resultat ? (
<div className="carte gen-info">
<span style={{ fontSize: 13 }}>
Génération {libelleMois(resultat.month)} : <b className="num">{resultat.created}</b> OT créés
{resultat.firstControls ? <> (dont <b className="num">{resultat.firstControls}</b> premiers contrôles)</> : null},{' '}
<b className="num">{resultat.skipped}</b> déjà en place <b>regénérer ne double rien</b>.
</span>
</div>
) : null}
{generation.isError ? (
<p className="erreur-form" role="alert">{generation.error.message}</p>
) : null}
<div className="carte" style={{ padding: 0 }}>
<div className="table">
<table>
<thead>
<tr>
<th>Tâche du gabarit</th>
<th>Organe visé</th>
<th>Périodicité</th>
{peutAdministrer ? <th></th> : null}
</tr>
</thead>
<tbody>
{(templates ?? []).map((t) => (
<tr key={t.id} style={!t.isActive ? { color: 'var(--encre-3)' } : undefined}>
<td>{t.isRegulatory ? <b>{t.label}</b> : t.label}{!t.isActive ? ' (désactivé)' : ''}</td>
<td>{t.componentTypeName ?? '—'}</td>
<td>
<span className={t.isRegulatory ? 'periode reglementaire' : 'periode'}>
{t.periodMonths === 1
? 'Tous les mois'
: `${t.periodMonths} mois${t.isRegulatory ? ' · réglementaire' : ''}`}
</span>
</td>
{peutAdministrer ? (
<td>
<Button
variant="ghost"
className="px-2 py-1 text-[12px]"
onClick={() => maj.mutate({ id: t.id, isActive: !t.isActive })}
>
{t.isActive ? 'Désactiver' : 'Réactiver'}
</Button>
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
</div>
{peutAdministrer ? (
<form className="carte flex items-end gap-3" onSubmit={ajouterGabarit}>
<div className="champ" style={{ flex: 2 }}>
<label htmlFor="gab-label">Nouvelle tâche</label>
<input
id="gab-label"
placeholder="Ex. : Contrôle éclairage de gaine"
value={nouveau.label}
onChange={(e) => setNouveau((n) => ({ ...n, label: e.target.value }))}
/>
</div>
<div className="champ">
<label htmlFor="gab-periode">Périodicité (mois)</label>
<input
id="gab-periode"
type="number"
min={1}
max={60}
value={nouveau.periodMonths}
onChange={(e) => setNouveau((n) => ({ ...n, periodMonths: Number(e.target.value) }))}
/>
</div>
<Button type="submit" variant="prim" disabled={creation.isPending}>Ajouter</Button>
</form>
) : null}
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
Le gabarit s'applique à tout appareil <b>sous contrat préventif</b>. Un appareil sans
historique reçoit un OT « <b>premier contrôle</b> » : toutes les tâches, quelle que soit
la périodicité. Les autres tâches tombent aux mois anniversaires de la mise en service.
</div>
</>
);
}

View File

@@ -1,49 +1,206 @@
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import {
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_TYPE_LABELS,
type WorkOrderStatus,
} from '@siop/shared';
import { usePreventiveStatus, useRequests, useWorkOrders } from '@/api/exploitation';
import { useMe } from '@/auth/use-auth';
import { usePermissions } from '@/auth/use-permissions';
import { Avatar } from '@/components/avatar';
import { PrioriteOT, StatutOT } from '@/components/chips-ot';
import { Button } from '@/components/ui/button';
/** R0 : coquille seulement — le vrai tableau de bord (KPI, urgences,
* graphiques) arrive avec l'exploitation (R2), conformément au plan. */
const ST_COULEURS: Record<string, string> = {
OPEN: 'var(--st-ouvert)',
IN_PROGRESS: 'var(--st-encours)',
ON_HOLD: 'var(--st-attente)',
DONE: 'var(--st-termine)',
};
/** Écran 2 validé R0 — alimenté par les données réelles de R2.
* (Coûts → R3 ; pannes par organe → analytics R3.) */
export default function PageTableauDeBord() {
const { data: me } = useMe();
const navigate = useNavigate();
const { can, chargé } = usePermissions();
const peutVoirOT = can('WORK_ORDERS', 'view');
const { data: workOrders } = useWorkOrders(peutVoirOT);
const { data: requests } = useRequests();
const { data: preventif } = usePreventiveStatus(peutVoirOT);
if (!chargé) return null;
if (!peutVoirOT) return <AccueilSansExploitation />;
const tous = workOrders ?? [];
const urgences = tous.filter(
(w) => w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
);
const ouverts = tous.filter((w) => w.status !== 'DONE' && w.status !== 'CANCELLED');
const enRetard = ouverts.filter((w) => w.dueDate && new Date(w.dueDate) < new Date());
const aTraiter = (requests ?? []).filter((r) => r.status === 'RECEIVED');
const parStatut = (['OPEN', 'IN_PROGRESS', 'ON_HOLD', 'DONE'] as WorkOrderStatus[]).map(
(statut) => ({
statut,
n: tous.filter((w) => w.status === statut).length,
}),
);
const max = Math.max(1, ...parStatut.map((s) => s.n));
const recents = tous.slice(0, 5);
return (
<>
<div className="entete-page">
<h1>Tableau de bord</h1>
</div>
<div className="carte">
<h2>Fondations (R0)</h2>
<p>
Bienvenue{me ? ` ${me.displayName}` : ''} vous êtes connecté avec le
rôle <b>{me?.role.name}</b>. Cette coquille valide l'authentification,
la bascule de rôle en un clic et le bi-thème ; les indicateurs
d'exploitation arriveront avec la release R2.
</p>
<p className="mt-2 text-encre-2">
La référence vivante du design est consultable sur la page{' '}
<Link to="/design" className="font-semibold text-primaire">
/design
</Link>
.
</p>
</div>
{me ? (
<div className="carte">
<h2>Vos droits (matrice en base)</h2>
<div className="flex flex-wrap gap-2">
{me.permissions
.filter((p) => p.canView)
.map((p) => (
<span key={p.objectCategory} className="st ouvert">
{p.objectCategory}
</span>
))}
{me.permissions.every((p) => !p.canView) ? (
<p className="text-encre-2">Ce rôle ne voit aucune catégorie d'objets.</p>
) : null}
<div className="entete-page"><h1>Tableau de bord</h1></div>
{urgences.map((u) => (
<div className="bandeau-urgence" key={u.id}>
<span className="point-pulse" />
<div>
<b>{u.title} Ascenseur {u.assetReference}</b> ({u.siteName})
<br />
<span className="depuis">
{u.reference} · signalé{' '}
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(u.createdAt))}
{u.assignees.length === 0 ? ' · aucune intervention en cours' : ''}
</span>
</div>
<div style={{ marginLeft: 'auto' }}>
<Button variant="prim" onClick={() => navigate(`/ot/${u.id}`)}>
Ouvrir l'OT d'urgence
</Button>
</div>
</div>
) : null}
))}
<div className="kpis">
<div className="kpi">
<div className="lib">OT ouverts</div>
<div className="val num">{ouverts.length}</div>
<div className="delta neutre">
dont {tous.filter((w) => w.status === 'IN_PROGRESS').length} en cours
</div>
</div>
<div className={enRetard.length ? 'kpi alarme' : 'kpi'}>
<div className="lib">En retard</div>
<div className="val num">{enRetard.length}</div>
</div>
<div className="kpi">
<div className="lib">Préventif · {preventif ? mois(preventif.month) : '…'}</div>
<div className="val num">
{preventif && preventif.generated > 0
? `${Math.round((preventif.done / preventif.generated) * 100)} %`
: '—'}
</div>
<div className="delta bon">
{preventif ? `${preventif.done} / ${preventif.generated} grilles` : ''}
</div>
</div>
<div className="kpi">
<div className="lib">Demandes à traiter</div>
<div className="val num">{aTraiter.length}</div>
<div className="delta neutre">
{aTraiter[0]
? `la + ancienne : ${new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(aTraiter[aTraiter.length - 1]!.createdAt))}`
: 'file vide'}
</div>
</div>
</div>
<div className="grille-2">
<div className="carte">
<h2>OT par statut</h2>
<div className="barres-h flex flex-col gap-2">
{parStatut.map(({ statut, n }) => (
<div className="ligne-b" key={statut}>
<span>{WORK_ORDER_STATUS_LABELS[statut]}</span>
<div className="fond-b">
<div
className="rempli"
style={{ width: `${(n / max) * 100}%`, background: ST_COULEURS[statut] }}
/>
</div>
<span className="valeur num">{n}</span>
</div>
))}
</div>
</div>
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Pannes par organe · 12 mois</h2>
<p className="text-encre-3">
Ce graphique s'alimentera des bilans codés — il arrive avec les analytics (R3).
</p>
</div>
</div>
<div className="carte">
<h2>Interventions récentes</h2>
<div className="table">
<table>
<thead>
<tr>
<th>N°</th><th>Objet</th><th>Équipement</th><th>Type</th><th>Priorité</th><th>Statut</th><th>Assignés</th>
</tr>
</thead>
<tbody>
{recents.map((w) => (
<tr
key={w.id}
className={w.priority === 'PERSON_TRAPPED' ? 'ligne-bloque' : undefined}
onClick={() => navigate(`/ot/${w.id}`)}
style={{ cursor: 'pointer' }}
>
<td className="ref num">{w.reference}</td>
<td>{w.title}</td>
<td>
Asc. {w.assetReference}
<span className="sous">{w.siteName}</span>
</td>
<td>{WORK_ORDER_TYPE_LABELS[w.type]}</td>
<td><PrioriteOT priorite={w.priority} /></td>
<td><StatutOT statut={w.status} /></td>
<td>
{w.assignees.length ? (
<div className="avatars">
{w.assignees.map((a) => (
<Avatar key={a.id} initials={a.initials} className="h-6 w-6 text-[10px]" />
))}
</div>
) : (
''
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}
const MOIS_FR = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
];
const mois = (period: string) => MOIS_FR[Number(period.split('-')[1]) - 1];
/** Accueil des rôles sans exploitation (Demandeur) : ses demandes. */
function AccueilSansExploitation() {
const { data: me } = useMe();
const { data: requests } = useRequests();
const { can } = usePermissions();
return (
<>
<div className="entete-page"><h1>Mes signalements</h1></div>
<div className="carte">
<p>
Bienvenue{me ? ` ${me.displayName}` : ''} — retrouvez ici vos signalements
et leur avancement.
</p>
{can('REQUESTS', 'view') ? (
<p className="mt-2">
<Link to="/demandes" className="font-semibold text-primaire">
Voir mes demandes ({(requests ?? []).length})
</Link>
</p>
) : null}
</div>
</>
);
}