mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +00:00
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:
89
apps/web/src/pages/compteurs.tsx
Normal file
89
apps/web/src/pages/compteurs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
301
apps/web/src/pages/demandes.tsx
Normal file
301
apps/web/src/pages/demandes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 l’exploitation (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,
|
||||
|
||||
354
apps/web/src/pages/fiche-ot.tsx
Normal file
354
apps/web/src/pages/fiche-ot.tsx
Normal 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 d’annulation (visible dans l’activité) :') ?? 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>
|
||||
);
|
||||
}
|
||||
135
apps/web/src/pages/nouvel-ot.tsx
Normal file
135
apps/web/src/pages/nouvel-ot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
apps/web/src/pages/ordres-travail.tsx
Normal file
126
apps/web/src/pages/ordres-travail.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
166
apps/web/src/pages/preventif.tsx
Normal file
166
apps/web/src/pages/preventif.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user