Files
siop2/apps/web/src/pages/fiche-ot.tsx
pr-daaif 6a2dae1710 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>
2026-07-16 17:13:20 +01:00

355 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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