Commit e2b8d1a0 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Ce que le serveur savait faire s'atteint enfin depuis l'écran

Un relevé des routes contre les appels du client a montré une soixantaine de
gestes que l'API portait et qu'aucun écran n'appelait. Ce n'étaient pas des
fonctions à moitié écrites : elles étaient éprouvées, journalisées, et
inatteignables.

Huit lots, un par domaine.

La console root gagne ses écrans propres — comptes, organisations, droits root
— au lieu d'un seul panneau où la levée de bris de glace voisinait avec la
configuration. Toute action de l'exploitant chez un client passe désormais par
un motif obligatoire, et l'organisation peut lire ce qui a été regardé chez
elle : l'engagement de transparence tenait dans une table que le client ne
pouvait pas ouvrir.

La conduite gagne le détail d'une action — responsable, échéance, historique —
la demande d'arbitrage, la relecture d'un point de situation publié sans
passer par le tirage papier, et « mes actions », qui répond à la seule question
qu'un membre de cellule se pose en arrivant.

Deux défauts trouvés en construisant, et tous deux invisibles à la lecture du
code.

Le bouton « consigner » du salon ouvrait le formulaire général au lieu
d'appeler la route de consignation — la seule qui pose « promu_seq ». La
pastille « consigné » ne pouvait donc jamais s'allumer, et deux personnes
pouvaient consigner deux fois le même message sans que rien ne le dise.

Le préremplissage de la fenêtre de consignation gardait un genre qui ne
correspondait à aucun bouton : on consignait un « fait » en croyant enregistrer
une décision. La main courante affichait alors des décisions que l'écran des
décisions ne montrait pas, ce qui est le genre d'incohérence qui fait douter de
tout le reste.

Les enregistrements de visioconférence se pilotent depuis l'appel et non
depuis un écran séparé — on n'ouvre pas un onglet pour appuyer sur un bouton
d'enregistrement. Les documents gagnent leur fiche, le partage public, et un
dépôt reprenable au-delà de huit mégaoctets : sur un réseau de crise, un
transfert qui recommence à zéro ne finit jamais.

Le dossier de préparation gagne les astreintes et absences, les actions
préparatoires, l'inventaire des applications métier et le registre des parties
à informer. Les quatre routes de chacun existaient, cachées dans une boucle du
serveur, et personne ne les appelait.

Ce que je n'ai pas fait : les écrans que le volet mobile porte déjà, et la
frise chronologique de la main courante, qui demande un travail d'interface à
part entière.

Co-Authored-By: Claude (RCA)
parent c645bf55
Loading
Loading
Loading
Loading
+1 −0
Changes for apps/web/src/api.ts: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -90,6 +90,7 @@ export interface Cockpit {
    intitule: string; type: string; gravite: string; phase: string;
    exercice: boolean; declenchee_at: string; chrono: string;
    seuil_point_situation_minutes: number; preparation_version: number | null;
    pont_numero: string | null; pont_code: string | null;
  };
  cellules: {
    id: string; code: string; libelle: string; dernier_point_at: string | null;
+13 −1
Changes for apps/web/src/coque/Consigner.tsx: 13 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -2,6 +2,15 @@ import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { api, ErreurApi } from "../api.js";

/**
 * Ce que l'on consigne à la main.
 *
 * Deux genres du vocabulaire — « action » et « point_situation » — n'y figurent
 * pas, et c'est délibéré : ils naissent de leur propre geste, ouvrir une action
 * ou publier un point, et l'entrée de journal suit. Les écrire ici mettrait au
 * journal une action qui n'existe pas au registre, ou un point qui n'a pas de
 * trame — soit exactement la divergence que le produit refuse.
 */
const GENRES = [
  { cle: "fait", libelle: "Fait" },
  { cle: "decision", libelle: "Décision" },
@@ -29,7 +38,10 @@ export function Consigner({
  criseId: string; celluleId: string | null; prefill: Prefill; fermer: () => void;
}) {
  const client = useQueryClient();
  const [genre, definirGenre] = useState(prefill.genre ?? "fait");
  // Un genre prérempli hors de cette liste partait quand même, sans qu'aucun
  // bouton ne s'allume : le panneau envoyait autre chose que ce qu'il montrait.
  const [genre, definirGenre] = useState(
    GENRES.some((g) => g.cle === prefill.genre) ? prefill.genre! : "fait");
  const [texte, definirTexte] = useState(prefill.texte ?? "");
  const [diffusion, definirDiffusion] = useState("cellule");
  const [erreur, definirErreur] = useState<string | null>(null);
+46 −6
Changes for apps/web/src/coque/Coque.tsx: 46 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import { Preparation } from "../ecrans/Preparation.js";
import { Tableaux } from "../ecrans/Tableaux.js";
import { Mobilisation } from "../ecrans/Mobilisation.js";
import { Cloture } from "../ecrans/Cloture.js";
import { Enregistrements } from "../ecrans/Enregistrements.js";
import { useCapacites } from "../capacites.js";
import { tinter, titrer } from "../signal.js";
import { Continuite } from "../ecrans/Continuite.js";
@@ -161,6 +162,24 @@ export function Coque({ criseId }: { criseId: string }) {
    enabled: rembobinage === null,
    refetchInterval: 60_000,
  });
  /**
   * La présence, quand le flux ne la donne plus.
   *
   * Le compteur « en ligne » vient du canal temps réel. Coupé, il tombait à
   * zéro : l'écran cessait de dire qui travaille, précisément au moment où
   * l'on se demande si l'on est seul. La route existait pour cela et n'était
   * appelée nulle part — on l'interroge donc, mais seulement quand le flux est
   * tombé, pour ne pas doubler ce qu'il fait déjà bien.
   */
  const presentsHttp = useQuery({
    queryKey: ["presents", criseId],
    queryFn: () => api.lire<{ presents: { compte_id: string; nom: string }[] }>(
      `/api/crises/${criseId}/presents`),
    enabled: !flux.connecte && rembobinage === null,
    refetchInterval: 30_000,
  });
  const presents = flux.connecte ? flux.presents : (presentsHttp.data?.presents ?? []);

  const prochaine = (echeances.data?.parties ?? [])
    .filter((p) => p.informe_at === null && p.reste_minutes !== null)
    .sort((a, b) => a.reste_minutes! - b.reste_minutes!)[0];
@@ -240,19 +259,30 @@ export function Coque({ criseId }: { criseId: string }) {
            <span className="pastille alerte" title="Le flux temps réel est coupé ; la
              lecture reste à jour à chaque action.">{t("hors flux")}</span>
          )}
          {flux.presents.length > 0 && (
          {presents.length > 0 && (
            <span className="pastille ok sans-point" title={
              flux.presents.map((p) => p.nom).join(", ")}>
              {t("{n} en ligne", { n: flux.presents.length })}
              presents.map((p) => p.nom).join(", ")}>
              {t("{n} en ligne", { n: presents.length })}
            </span>
          )}
          <button className="discret petit" onClick={() => definirRecherche(true)}>
            {t("⌘K Chercher")}
          </button>
          <button className="discret petit"
            onClick={() => rembobinage === null
              ? rembobiner(Math.max(1, (cockpit.data?.entrees_main_courante ?? 1)))
              : rembobiner(null)}>
            onClick={() => {
              if (rembobinage !== null) { rembobiner(null); return; }
              const seq = Math.max(1, cockpit.data?.entrees_main_courante ?? 1);
              rembobiner(seq);
              // EB-13 : consulter le passé est lui-même un acte, et se
              // journalise. L'interface lisait jusqu'ici le passé par
              // « jusquASeq » sur chaque écran, ce qui n'inscrivait rien — le
              // rembobinage ne laissait aucune trace, alors que la route faite
              // pour cela en laisse une. On l'appelle donc, pour l'inscription ;
              // les écrans continuent de lire filtré, ce que la route ne sait
              // pas faire.
              void api.lire(`/api/crises/${criseId}/rembobinage?seq=${seq}`)
                .catch(() => { /* l'inscription a échoué : la lecture reste possible */ });
            }}>
            {t("↺ Rembobiner")}
          </button>
          {rembobinage === null && appel === null && salleDeLaCellule && (
@@ -402,6 +432,13 @@ export function Coque({ criseId }: { criseId: string }) {
              onClick={() => aller(`/crises/${criseId}/tableau`)}>
              {t("Tableau des actions")}
            </button>
            {/* Le service d'encodage tourne par défaut ; rien ne menait à ce
                qu'il produit. */}
            <button className={`item${module === "enregistrements" ? " actif" : ""}`}
              aria-current={module === "enregistrements" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/enregistrements`)}>
              {t("Enregistrements")}
            </button>
            <button className={`item${module === "veille" ? " actif" : ""}`}
              aria-current={module === "veille" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/veille`)}>
@@ -472,6 +509,8 @@ export function Coque({ criseId }: { criseId: string }) {
          <Conduite criseId={criseId} cellules={cockpit.data?.cellules ?? []} />
        ) : module === "tableaux" ? (
          <Tableaux criseId={criseId} rembobinage={rembobinage} />
        ) : module === "enregistrements" ? (
          <Enregistrements criseId={criseId} lectureSeule={rembobinage !== null} />
        ) : module === "mobilisation" ? (
          <Mobilisation criseId={criseId} />
        ) : module === "cloture" ? (
@@ -495,6 +534,7 @@ export function Coque({ criseId }: { criseId: string }) {
          <Documents criseId={criseId} />
        ) : module === "preparation" ? (
          <Preparation criseId={criseId}
            organisationId={crise?.organisation_id ?? ""}
            version={crise?.preparation_version ?? null} />
        ) : (
          <EcranCockpit criseId={criseId} cockpit={cockpit.data} aller={aller} />
+95 −0
Changes for apps/web/src/coque/Historique.tsx: 95 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "../api.js";
import { dateHeure } from "../etat.js";

interface Evenement {
  seq: string;
  type: string;
  occurred_at: string;
  acteur_id: string | null;
  charge: Record<string, unknown>;
}

/**
 * L'historique d'une action ou d'une décision, tel qu'il est au journal.
 *
 * Les deux routes rendent exactement la même forme — les événements de la
 * crise dont la charge porte l'identifiant concerné. Un seul composant, donc,
 * plutôt que deux qui divergeraient à la première correction.
 *
 * Ce qui est montré n'est pas un résumé : c'est la suite des événements, avec
 * leur numéro de séquence. Une action dont on discute des mois plus tard se
 * défend par là — « réaffectée le 12 à 14 h 03, séquence 418 » — et non par un
 * état final qui ne dit pas comment on y est arrivé.
 */
export function Historique({ chemin }: { chemin: string }) {
  const [ouvert, definirOuvert] = useState(false);

  const { data, isLoading } = useQuery({
    queryKey: ["historique", chemin],
    queryFn: () => api.lire<{ evenements: Evenement[] }>(chemin),
    enabled: ouvert,
  });

  if (!ouvert) {
    return (
      <button className="discret petit" onClick={() => definirOuvert(true)}>
        Historique
      </button>
    );
  }

  const evenements = data?.evenements ?? [];

  return (
    <div style={{ marginTop: 8 }}>
      <div className="ligne">
        <span className="etiq">Historique au journal</span>
        <button className="discret petit pousse" onClick={() => definirOuvert(false)}>
          Replier
        </button>
      </div>

      {isLoading && <p className="discret" style={{ marginTop: 6 }}>Lecture du journal…</p>}

      {!isLoading && evenements.length === 0 && (
        <p className="discret" style={{ marginTop: 6, fontSize: 13 }}>
          Aucun événement. C’est inattendu : la création elle-même en est un.
        </p>
      )}

      <div style={{ marginTop: 6 }}>
        {evenements.map((e) => (
          <div key={e.seq} className="fait" style={{ alignItems: "flex-start" }}>
            <span className="mono discret" style={{ fontSize: 11.5, minWidth: 52 }}>
              #{e.seq}
            </span>
            <div style={{ flex: 1 }}>
              <div style={{ fontSize: 13.5 }}>{e.type.replace(/[._]/g, " ")}</div>
              <div className="discret" style={{ fontSize: 12 }}>
                {dateHeure(e.occurred_at)}
                {resume(e.charge) && ` — ${resume(e.charge)}`}
              </div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

/**
 * Ce que la charge dit de lisible, et rien d'autre.
 *
 * Les identifiants sont écartés : ils remplissent la ligne sans rien apprendre
 * à qui relit. Ce qui reste — un motif, un statut, une échéance — est
 * précisément ce qu'on cherche quand on ouvre un historique.
 */
function resume(charge: Record<string, unknown>): string {
  return Object.entries(charge)
    .filter(([cle, v]) =>
      !cle.endsWith("_id") && cle !== "id" && v !== null && v !== undefined && v !== "")
    .map(([cle, v]) => `${cle.replace(/_/g, " ")} : ${String(v)}`)
    .join(" · ");
}
+33 −2
Changes for apps/web/src/coque/PanneauVisio.tsx: 33 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -52,7 +52,15 @@ function PanneauVisio({
  const [perdu, definirPerdu] = useState(false);
  const [camera, definirCamera] = useState(false);
  const [taille, definirTaille] = useState<Taille>("flottant");
  const [enregistre, definirEnregistre] = useState(false);
  /**
   * L'identifiant plutôt qu'un booléen : arrêter l'enregistrement le demande,
   * et le témoin ne suffisait qu'à l'afficher. Rien ne permettait de le
   * déclencher ni de l'arrêter depuis l'appel — seul endroit d'où cela ait un
   * sens, puisque le droit suit le canal et non la crise.
   */
  const [enregistrementId, definirEnregistrementId] = useState<string | null>(null);
  const [geneEnregistrement, definirGeneEnregistrement] = useState<string | null>(null);
  const enregistre = enregistrementId !== null;
  const [participationId, definirParticipationId] = useState<string | null>(null);
  /** Le même identifiant, lisible depuis un nettoyage ou un « pagehide ». */
  const participation = useRef<string | null>(null);
@@ -110,7 +118,7 @@ function PanneauVisio({
        if (!vivant) return;
        definirParticipationId(entree.participationId);
        participation.current = entree.participationId;
        definirEnregistre(entree.enregistrementEnCours !== null);
        definirEnregistrementId(entree.enregistrementEnCours?.id ?? null);
        await salle.connect(entree.jeton.url, entree.jeton.jeton);
        if (!vivant) return;

@@ -240,6 +248,8 @@ function PanneauVisio({
        <div className="gene">{geneMicro}</div>
      )}

      {geneEnregistrement && <div className="gene">{geneEnregistrement}</div>}

      {enregistre && (
        <div className="temoin">
          <span className="point" /> Enregistrement en cours
@@ -342,6 +352,27 @@ function PanneauVisio({
            }}>
              {ecran ? "Écran partagé" : "Partager l’écran"}
            </button>
            {/* EF-806 : l'enregistrement se déclenche par notre API, jamais par
                le client — et seuls l'animateur de la cellule ou le directeur
                de crise y ont droit. Le serveur tranche ; le bouton se contente
                de rapporter son refus. */}
            <button className={enregistre ? "danger" : ""}
              title={enregistre
                ? "Arrêter l’enregistrement de cette réunion"
                : "Enregistrer cette réunion — le témoin s’allume chez tous les participants"}
              onClick={() => {
                definirGeneEnregistrement(null);
                const requete = enregistre
                  ? api.poster(`/api/crises/${criseId}/enregistrements/${enregistrementId}/arret`)
                    .then(() => definirEnregistrementId(null))
                  : api.poster<{ id: string }>(
                    `/api/crises/${criseId}/salles/${appel.salleId}/enregistrement`)
                    .then((r) => definirEnregistrementId(r.id));
                void requete.catch((e) => definirGeneEnregistrement(
                  e instanceof ErreurApi ? e.message : "L’enregistrement n’a pas pu changer d’état."));
              }}>
              {enregistre ? "Arrêter l’enregistrement" : "Enregistrer"}
            </button>
            <button className={reglages ? "accent" : ""} onClick={() => void ouvrirReglages()}
              aria-expanded={reglages} title="Choisir le micro et la caméra">⚙</button>
            <span className="pousse discret" style={{ fontSize: 11.5 }}>
Loading