Commit dbb6efb8 authored by Kourser's avatar Kourser
Browse files

Le rail mêlait ce qu'on fait debout et ce qu'on tient au calme

Deux entrées du rail partageaient un mot pour deux sujets sans rapport :
« Veille », l'écran d'accueil, et « Veille externe », la surveillance des
signaux du dehors. Le genre de flottement qui coûte trois secondes chaque fois,
jusqu'au jour où elles manquent. L'accueil s'appelle « Accueil ».

── Un onglet pour conduire

Ouvrir une crise, rejoindre celles qui courent, rouvrir un dossier clos se
cherchaient sur l'écran d'accueil, entre l'état de préparation et la liste des
crises passées. Ces gestes-là se font debout ; l'état de préparation se lit au
calme. Les mêler faisait qu'aucun des deux ne se lisait vite.

« Gestion de crise » les rassemble. L'accueil garde l'annonce d'une crise en
cours — une ligne et un bouton : ce n'est pas de la navigation mais une alerte,
et une alerte qui attend qu'on ouvre le bon onglet n'en est pas une.

── Deux groupes au lieu d'un

Le groupe « Organisation » contenait tout. Il devient « Conduite » — accueil,
gestion de crise, mes actions — et « Préparation » — dossier, veille externe,
écrire à l'annuaire.

La veille externe est rangée en préparation, et c'est le point sur lequel on
peut ne pas être d'accord : on l'ouvre aussi pendant une crise, où l'écran de
crise a d'ailleurs son propre onglet. Mais surveiller les signaux du dehors est
un travail de temps calme, celui qui perd son sens le jour où l'on n'a plus le
temps de le faire.

Aucune garantie ne porte sur la composition du rail, et ce remaniement n'en
change donc aucune. C'est une lacune assumée : un contrôle qui figerait l'ordre
des onglets se casserait à chaque réorganisation demandée, ce qui en ferait un
frein plutôt qu'un garde-fou.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent aa3a9050
Loading
Loading
Loading
Loading
Loading
+157 −0
Changes for apps/web/src/ecrans/Accueil.tsx: 157 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useQuery } from "@tanstack/react-query";
import { api } from "../api.js";
import { useEtat, dateHeure } from "../etat.js";
import { Lien, useRoute } from "../routeur.js";
import { useT } from "../langues/index.js";
import { LIBELLES_GRAVITE, type Gravite } from "@kastell/shared";

interface Indicateur {
  score: number;
  etat: "bon" | "attention" | "critique";
  version: number;
  points: {
    cle: string; libelle: string; etat: "bon" | "attention" | "critique" | "sans_objet";
    detail: string; action: string | null;
  }[];
}

interface Crise {
  id: string; intitule: string; type: string; gravite: string; phase: string;
  exercice: boolean; declenchee_at: string; close_at: string | null;
}

/**
 * Écran d'accueil (EF-201).
 *
 * Il s'appelait « Veille », à côté d'un « Veille externe » qui désigne tout
 * autre chose — la surveillance des signaux du dehors. Deux entrées du rail
 * partageaient un mot pour deux sujets, et c'est le genre de flottement qui
 * coûte trois secondes chaque fois, jusqu'au jour où elles manquent.
 *
 * L'outil dort 99 % du temps. Cet écran répond donc à une question et une
 * seule : « suis-je prêt ? ». Ouvrir une crise, rejoindre celles qui courent,
 * rouvrir un dossier clos sont d'un autre ordre — on s'y prend debout, pas au
 * calme — et vivent dans « Gestion de crise ».
 */
export function Accueil() {
  const t = useT();
  const { organisationId, moi } = useEtat();
  const { aller } = useRoute();

  const preparation = useQuery({
    queryKey: ["preparation", organisationId],
    queryFn: () => api.lire<{ indicateur: Indicateur }>(
      `/api/organisations/${organisationId}/preparation`),
    enabled: Boolean(organisationId),
  });

  const crises = useQuery({
    queryKey: ["crises", organisationId],
    queryFn: () => api.lire<{ crises: Crise[] }>(`/api/organisations/${organisationId}/crises`),
    enabled: Boolean(organisationId),
  });

  const organisation = moi?.organisations.find((o) => o.organisation_id === organisationId);
  const enCours = crises.data?.crises.filter((c) => c.phase !== "close") ?? [];
  const closes = crises.data?.crises.filter((c) => c.phase === "close") ?? [];
  const indicateur = preparation.data?.indicateur;

  return (
    <div className="scene">
      <header>
        <div className="etiq">{t("Accueil")}</div>
        <h1>{organisation?.nom ?? t("Votre organisation")}</h1>
      </header>

      <div className="contenu">
        {/*
          Une ligne, et un bouton. La liste complète, l'ouverture et les crises
          passées sont dans « Gestion de crise » — mais annoncer qu'une crise
          tourne n'est pas de la navigation, c'est une alerte, et une alerte qui
          attend qu'on ouvre le bon onglet n'en est pas une.
        */}
        {enCours.length > 0 && (
          <div className="carte crit" style={{ marginBottom: 20 }}>
            {enCours.map((c) => (
              <div key={c.id} className="ligne" style={{ marginBottom: 6 }}>
                <span className={`gravite ${c.gravite}`}>
                  {t(LIBELLES_GRAVITE[c.gravite as Gravite] ?? c.gravite)}
                </span>
                <strong>{c.intitule}</strong>
                <span className="discret" style={{ fontSize: 13 }}>
                  {t("depuis le")} {dateHeure(c.declenchee_at)}
                </span>
                <button className="accent pousse" onClick={() => aller(`/crises/${c.id}`)}>
                  {t("Rejoindre")}
                </button>
              </div>
            ))}
          </div>
        )}

        <div style={{ marginBottom: 20, maxWidth: 720 }}>
          <div className="carte">
            <div className="etiq" style={{ marginBottom: 10 }}>{t("Préparation")}</div>
            {indicateur ? (
              <>
                <div className="jauge">
                  <span className={`score ${indicateur.etat}`}>{indicateur.score}</span>
                  <span className="discret" style={{ fontSize: 13 }}>
                    {t("sur 100 · dossier en version")} {indicateur.version}
                  </span>
                </div>
                {indicateur.points.filter((p) => p.etat !== "bon").slice(0, 3).map((p) => (
                  <div key={p.cle} className="point-preparation">
                    <span className={`voyant ${p.etat}`} />
                    <div>
                      <strong style={{ fontSize: 13.5 }}>{p.libelle}</strong>
                      <div className="discret" style={{ fontSize: 13 }}>{p.detail}</div>
                      {p.action && <div className="action">{p.action}</div>}
                    </div>
                  </div>
                ))}
                {indicateur.points.every((p) => p.etat === "bon") && (
                  <p className="discret" style={{ fontSize: 13.5, margin: 0 }}>
                    {t("Tout "
                                                                                                      + "est "
                                                                                                      + "à "
                                                                                                      + "jour. "
                                                                                                      + "C’est "
                                                                                                      + "rare "
                                                                                                      + "— "
                                                                                                      + "profitez-en "
                                                                                                      + "pour "
                                                                                                      + "imprimer "
                                                                                                      + "le "
                                                                                                      + "dossier.")}
                  </p>
                )}
                <Lien vers={`/preparation`} className="bouton" style={{ marginTop: 12 }}>
                  {t("Ouvrir "
                                                                                                              + "le "
                                                                                                              + "dossier "
                                                                                                              + "de "
                                                                                                              + "préparation")}
                </Lien>
              </>
            ) : (
              <p className="discret">{t("Chargement…")}</p>
            )}
          </div>

        </div>

        <div className="carte" style={{ maxWidth: 720 }}>
          <div className="etiq" style={{ marginBottom: 8 }}>{t("Conduire une crise")}</div>
          <p style={{ fontSize: 13.5, color: "var(--encre-2)", marginTop: 0 }}>
            {t("Ouvrir une crise, rejoindre celles qui courent, rouvrir un dossier "
              + "clos : c’est là.")}
          </p>
          <button className="accent" onClick={() => aller("/gestion-de-crise")}>
            {t("Gestion de crise")}
          </button>
        </div>
      </div>
    </div>
  );
}
+24 −8
Changes for apps/web/src/ecrans/EspaceOrganisation.tsx: 24 added lines, 8 removed lines.
Original line number Diff line number Diff line
import { useState } from "react";
import { useEtat } from "../etat.js";
import { useRoute } from "../routeur.js";
import { Veille } from "./Veille.js";
import { Accueil } from "./Accueil.js";
import { GestionCrise } from "./GestionCrise.js";
import { Declenchement } from "./Declenchement.js";
import { DossierPreparation } from "./preparation/DossierPreparation.js";
import { Administration } from "./administration/Administration.js";
@@ -82,19 +83,33 @@ export function EspaceOrganisation() {
            sinon devant l'écran qu'on vient de demander. */}
        <nav className={`rail${railOuvert ? " ouvert" : ""}`}
          onClick={() => definirRailOuvert(false)}>
          {/* Ce que l'organisation fait. */}
          <div role="group" aria-label={t("Organisation")} className="groupe">
            <span className="etiq">{t("Organisation")}</span>
          {/* ── Ce qu'on fait le jour J ─────────────────────────────────
              Un seul groupe « Organisation » mêlait la conduite et
              l'entretien : on cherchait « Déclencher » entre le dossier de
              préparation et l'annuaire. Ce qui se fait debout et ce qui se
              tient au calme n'ont pas à se disputer la même colonne. */}
          <div role="group" aria-label={t("Conduite")} className="groupe">
            <span className="etiq">{t("Conduite")}</span>
            <button className={`item${module === "veille" ? " actif" : ""}`}
              onClick={() => aller("/")}>{t("Veille")}</button>
              onClick={() => aller("/")}>{t("Accueil")}</button>
            <button className={`item${module === "gestion-de-crise" ? " actif" : ""}`}
              onClick={() => aller("/gestion-de-crise")}>{t("Gestion de crise")}</button>
            {/* Ce qui m'incombe traverse les crises : sa place est ici, hors
                d'une crise en particulier. */}
            <button className={`item${module === "mes-actions" ? " actif" : ""}`}
              onClick={() => aller("/mes-actions")}>{t("Mes actions")}</button>
            <button className={`item${module === "veille-externe" ? " actif" : ""}`}
              onClick={() => aller("/veille-externe")}>{t("Veille externe")}</button>
          </div>

          {/* ── Ce qu'on entretient avant ───────────────────────────────────
              La veille externe est ici, et non dans la conduite : surveiller
              les signaux du dehors est un travail de temps calme, qui perd son
              sens le jour où l'on n'a plus le temps de le faire. */}
          <div role="group" aria-label={t("Préparation")} className="groupe">
            <span className="etiq">{t("Préparation")}</span>
            <button className={`item${module === "preparation" ? " actif" : ""}`}
              onClick={() => aller("/preparation")}>{t("Dossier de préparation")}</button>
            <button className={`item${module === "veille-externe" ? " actif" : ""}`}
              onClick={() => aller("/veille-externe")}>{t("Veille externe")}</button>
            {estProprietaire && (
              <button className={`item${module === "diffusion" ? " actif" : ""}`}
                onClick={() => aller("/diffusion")}>{t("Écrire à l’annuaire")}</button>
@@ -153,7 +168,8 @@ export function EspaceOrganisation() {
          : module === "securite" ? <Securite organisationId={organisationId ?? undefined} />
          : module === "preferences" ? <Preferences />
          : module === "administration" ? <Administration />
          : <Veille />}
          : module === "gestion-de-crise" ? <GestionCrise />
          : <Accueil />}
        </main>
      </div>
    </div>
+21 −80
Changes for apps/web/src/ecrans/GestionCrise.tsx: 21 added lines, 80 removed lines.
Original line number Diff line number Diff line
import { useQuery } from "@tanstack/react-query";
import { api } from "../api.js";
import { useEtat, dateHeure } from "../etat.js";
import { Lien, useRoute } from "../routeur.js";
import { useRoute } from "../routeur.js";
import { useT } from "../langues/index.js";
import { LIBELLES_GRAVITE, type Gravite } from "@kastell/shared";

interface Indicateur {
  score: number;
  etat: "bon" | "attention" | "critique";
  version: number;
  points: {
    cle: string; libelle: string; etat: "bon" | "attention" | "critique" | "sans_objet";
    detail: string; action: string | null;
  }[];
}

interface Crise {
  id: string; intitule: string; type: string; gravite: string; phase: string;
  exercice: boolean; declenchee_at: string; close_at: string | null;
}

/**
 * Écran de veille (EF-201).
 * Gestion de crise (EF-201).
 *
 * Ce qui était sur l'écran d'accueil et n'y avait pas sa place : ouvrir une
 * crise, rejoindre celles qui courent, rouvrir celles qui sont closes.
 *
 * La séparation n'est pas cosmétique. L'accueil répond à « suis-je prêt ? »,
 * une question qu'on se pose au calme et rarement. Cet écran-ci répond à
 * « qu'est-ce qui tourne, et comment j'y entre », qu'on se pose debout. Les
 * mêler faisait qu'aucune des deux ne se lisait vite.
 *
 * L’outil dort 99 % du temps. Cet écran doit donc répondre à deux questions et
 * pas davantage : « suis-je prêt ? » et « comment j’ouvre une crise ? ». Le
 * reste attend.
 * L'accueil garde tout de même l'annonce d'une crise en cours, en une ligne :
 * elle n'est pas ici une commande de navigation mais une alerte, et une alerte
 * qui attend qu'on ouvre le bon onglet n'en est pas une.
 */
export function Veille() {
export function GestionCrise() {
  const t = useT();
  const { organisationId, moi } = useEtat();
  const { organisationId } = useEtat();
  const { aller } = useRoute();

  const preparation = useQuery({
    queryKey: ["preparation", organisationId],
    queryFn: () => api.lire<{ indicateur: Indicateur }>(
      `/api/organisations/${organisationId}/preparation`),
    enabled: Boolean(organisationId),
  });

  const crises = useQuery({
    queryKey: ["crises", organisationId],
    queryFn: () => api.lire<{ crises: Crise[] }>(`/api/organisations/${organisationId}/crises`),
    enabled: Boolean(organisationId),
  });

  const organisation = moi?.organisations.find((o) => o.organisation_id === organisationId);
  const enCours = crises.data?.crises.filter((c) => c.phase !== "close") ?? [];
  const closes = crises.data?.crises.filter((c) => c.phase === "close") ?? [];
  const indicateur = preparation.data?.indicateur;

  return (
    <div className="scene">
      <header>
        <div className="etiq">{t("Veille")}</div>
        <h1>{organisation?.nom ?? t("Votre organisation")}</h1>
        <div className="etiq">{t("Gestion de crise")}</div>
        <h1>{enCours.length === 0
          ? t("Aucune crise en cours")
          : enCours.length === 1 ? t("Une crise en cours")
            : t("{n} crises en cours", { n: enCours.length })}</h1>
      </header>

      <div className="contenu">
@@ -78,57 +70,7 @@ export function Veille() {
          </div>
        )}

        <div className="grille deux" style={{ marginBottom: 20 }}>
          <div className="carte">
            <div className="etiq" style={{ marginBottom: 10 }}>{t("Préparation")}</div>
            {indicateur ? (
              <>
                <div className="jauge">
                  <span className={`score ${indicateur.etat}`}>{indicateur.score}</span>
                  <span className="discret" style={{ fontSize: 13 }}>
                    {t("sur 100 · dossier en version")} {indicateur.version}
                  </span>
                </div>
                {indicateur.points.filter((p) => p.etat !== "bon").slice(0, 3).map((p) => (
                  <div key={p.cle} className="point-preparation">
                    <span className={`voyant ${p.etat}`} />
                    <div>
                      <strong style={{ fontSize: 13.5 }}>{p.libelle}</strong>
                      <div className="discret" style={{ fontSize: 13 }}>{p.detail}</div>
                      {p.action && <div className="action">{p.action}</div>}
                    </div>
                  </div>
                ))}
                {indicateur.points.every((p) => p.etat === "bon") && (
                  <p className="discret" style={{ fontSize: 13.5, margin: 0 }}>
                    {t("Tout "
                                                                                                      + "est "
                                                                                                      + "à "
                                                                                                      + "jour. "
                                                                                                      + "C’est "
                                                                                                      + "rare "
                                                                                                      + "— "
                                                                                                      + "profitez-en "
                                                                                                      + "pour "
                                                                                                      + "imprimer "
                                                                                                      + "le "
                                                                                                      + "dossier.")}
                  </p>
                )}
                <Lien vers={`/preparation`} className="bouton" style={{ marginTop: 12 }}>
                  {t("Ouvrir "
                                                                                                              + "le "
                                                                                                              + "dossier "
                                                                                                              + "de "
                                                                                                              + "préparation")}
                </Lien>
              </>
            ) : (
              <p className="discret">{t("Chargement…")}</p>
            )}
          </div>

          <div className="carte">
      <div className="carte" style={{ marginBottom: 20, maxWidth: 720 }}>
          <div className="etiq" style={{ marginBottom: 10 }}>{t("Déclencher")}</div>
          <p style={{ fontSize: 13.5, color: "var(--encre-2)" }}>
            {t("Quatre "
@@ -176,7 +118,6 @@ export function Veille() {
                                                                         + "statistiques.")}
          </p>
        </div>
        </div>

        {closes.length > 0 && (
          <>
+8 −0
Changes for apps/web/src/langues/en.ts: 8 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2466,4 +2466,12 @@ export const EN: Record<string, string> = {
  "Cet appareil": "This device",
  "Les réglages ci-dessous valent pour ce navigateur et pour lui seul. Ils ne suivent pas votre compte d’un appareil à l’autre — le poste de la salle de crise et votre téléphone n’ont pas les mêmes besoins.":
    "The settings below apply to this browser and to it alone. They do not follow your account from one device to the next — the crisis-room workstation and your phone do not have the same needs.",
  "Accueil": "Home",
  "Conduire une crise": "Running a crisis",
  "Ouvrir une crise, rejoindre celles qui courent, rouvrir un dossier clos : c’est là.":
    "Open a crisis, join those under way, reopen a closed file: it is all there.",
  "Gestion de crise": "Crisis management",
  "Aucune crise en cours": "No crisis under way",
  "Une crise en cours": "One crisis under way",
  "{n} crises en cours": "{n} crises under way",
};