mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user