Commit 71f2d4e2 authored by Kourser's avatar Kourser
Browse files

La cellule se compose depuis la crise, plus depuis curl

La route existait sans appelant — le défaut que le lot précédent
reprochait un cran plus haut, déplacé d'un cran. Voici sa porte.

Un module « Cellules » dans le rail transverse. Il dit qui siège où et à
quel titre, et fait entrer quelqu'un. L'écran conduit, il ne garde pas :
il n'offre le geste qu'au directeur de crise, au propriétaire et à
l'animateur de la cellule concernée, et le serveur refuse de toute façon
les autres.

Le rôle « externe » est la raison d'être de la page. Choisir ce titre
retire « (facultative) » du libellé de la date de sortie, remplace l'aide
par la raison — un accès accordé dans l'urgence se rappelle rarement à
votre souvenir — et désactive le bouton tant que la date manque. Ce que
le serveur refuse, l'écran ne le propose pas ; ce qu'il refuse quand
même, l'écran l'affiche en clair sans vider la saisie.

Les candidats viennent des membres de l'ORGANISATION. La route de crise
« /crises/:id/membres » ne liste que les gens déjà en cellule : source
circulaire, inutile pour en désigner un — et c'est aussi pourquoi les
messages directs n'avaient personne à proposer tant que les cellules
étaient vides.

Et trois garanties pour que la porte ne disparaisse plus. Elles nomment
littéralement les gestes dont l'absence d'appelant a déjà coûté : inviter
dans l'organisation, composer une cellule, créer un compte depuis une
invitation. Le balayage des sources d'interface existait déjà et
s'arrêtait au premier gabarit d'un gabarit littéral ; il relit désormais
le chemin entier, faute de quoi il n'aurait jamais vu une route de crise.

Éprouvé en retirant la porte : « aucun appelant », rouge. 966 garanties.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 9bb5a5dd
Loading
Loading
Loading
Loading
Loading
+34 −0
Changes for apps/api/src/verification/interface.ts: 34 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -36,6 +36,8 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  const fichiers = await sources(web);
  const navigations: string[] = [];
  const appels: string[] = [];
  /** Tout chemin appelé par l'interface, gabarits compris. */
  const chemins: string[] = [];

  for (const f of fichiers) {
    const texte = await readFile(f, "utf8");
@@ -50,6 +52,11 @@ export async function verifierInterface(r: Rapport): Promise<void> {
        appels.push(`${court}:${texte.slice(0, m.index).split("\n").length} → ${m[1]}`);
      }
    }
    // Le même balayage, sans s'arrêter au premier gabarit : c'est le chemin
    // entier qu'il faut pour savoir quel geste l'interface sait faire.
    for (const m of texte.matchAll(/api\.(?:lire|poster|mettre|supprimer)\b[^(]*\(\s*[`"']([^`"']*)/g)) {
      chemins.push(m[1]!);
    }
  }

  r.verifie("EX-17", `${fichiers.length} sources d'interface relues`, fichiers.length > 20);
@@ -58,6 +65,33 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  r.verifie("EX-17", "tous les appels de données portent le préfixe /api",
    appels.length === 0, appels.slice(0, 5).join(", "));

  // ── Des gestes qui doivent avoir une porte (EX-17) ───────────────────────
  //
  // Une route sans appelant dans l'interface est un geste que le produit sait
  // faire et que personne ne peut faire. Deux l'ont été longtemps, et tous
  // deux servent le même moment — faire entrer quelqu'un pendant la crise :
  //
  //   inviter dans l'organisation, seul chemin d'accès à une crise, dont la
  //   route existait sans qu'aucun écran ne l'appelle ;
  //
  //   composer une cellule, où vit la garantie EF-108 sur les intervenants
  //   externes, dont la fonction n'était appelée que par cette suite — c'est
  //   la façon la plus coûteuse de croire qu'une garantie tient.
  //
  // Ce contrôle est volontairement littéral. Il ne devine rien : il nomme les
  // gestes dont l'absence de porte a déjà coûté, et il tombera si on la
  // reprend.
  const gestes: { quoi: string; motif: RegExp }[] = [
    { quoi: "inviter quelqu'un dans l'organisation", motif: /\/invitations\b/ },
    { quoi: "composer une cellule", motif: /\/cellules\/[^/]+\/membres\b/ },
    { quoi: "créer un compte depuis une invitation", motif: /\/invitation\/inscription\b/ },
  ];
  for (const g of gestes) {
    r.verifie("EX-17", `l'interface sait ${g.quoi}`,
      chemins.some((c) => g.motif.test(c)),
      chemins.find((c) => g.motif.test(c)) ?? "aucun appelant");
  }

  // ── Bilinguisme (EX-21) ──────────────────────────────────────────────────
  //
  // On mesure et on publie plutôt que d'annoncer « bilingue ». La clé de
+9 −0
Changes for apps/web/src/coque/Coque.tsx: 9 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -15,6 +15,7 @@ import { Cockpit as EcranCockpit } from "../ecrans/Cockpit.js";
import { MainCourante } from "../ecrans/MainCourante.js";
import { Salon } from "../ecrans/Salon.js";
import { Conduite } from "../ecrans/Conduite.js";
import { Cellules } from "../ecrans/Cellules.js";
import { Documents } from "../ecrans/Documents.js";
import { Preparation } from "../ecrans/Preparation.js";
import { Tableaux } from "../ecrans/Tableaux.js";
@@ -381,6 +382,11 @@ export function Coque({ criseId }: { criseId: string }) {
              {t("Main courante")}
              <span className="compteur">{cockpit.data?.entrees_main_courante ?? 0}</span>
            </button>
            <button className={`item${module === "cellules" ? " actif" : ""}`}
              aria-current={module === "cellules" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/cellules`)}>
              {t("Cellules")}
            </button>
            <button className={`item${module === "conduite" ? " actif" : ""}`}
              aria-current={module === "conduite" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/conduite`)}>
@@ -460,6 +466,9 @@ export function Coque({ criseId }: { criseId: string }) {
              ?? null}
            saisie={flux.saisie}
            consigner={(p) => definirConsigne(p)} />
        ) : module === "cellules" ? (
          <Cellules criseId={criseId} organisationId={crise?.organisation_id ?? ""}
            lectureSeule={rembobinage !== null} />
        ) : module === "conduite" ? (
          <Conduite criseId={criseId} cellules={cockpit.data?.cellules ?? []} />
        ) : module === "tableaux" ? (
+227 −0
Changes for apps/web/src/ecrans/Cellules.tsx: 227 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ErreurApi } from "../api.js";
import { dateHeure, useEtat } from "../etat.js";

interface MembreCellule {
  compte_id: string; nom: string;
  role: "animateur" | "membre" | "externe" | "observateur";
  expire_at: string | null;
}
interface Cellule {
  id: string; code: string; libelle: string; membres: MembreCellule[];
}
interface MembreOrganisation { compte_id: string; nom: string; email: string; role: string }

const ROLES = [
  { cle: "membre", libelle: "Membre",
    aide: "Participe aux travaux de la cellule." },
  { cle: "animateur", libelle: "Animateur",
    aide: "Anime la cellule, enregistre les réunions, modère son salon." },
  { cle: "observateur", libelle: "Observateur",
    aide: "Assiste sans prendre part. Exercice, audit, tutelle." },
  { cle: "externe", libelle: "Intervenant externe",
    aide: "Prestataire, assureur, liaison préfecture. Sa date de sortie est "
      + "obligatoire : un accès accordé dans l’urgence se rappelle rarement à "
      + "votre souvenir (EF-108)." },
] as const;

const NOM_ROLE = (r: string) => ROLES.find((x) => x.cle === r)?.libelle ?? r;

/**
 * La composition des cellules (EF-108).
 *
 * Cet écran ne donne pas accès à la crise — l’appartenance à l’organisation
 * s’en charge, et c’est l’écran « Membres » qui l’ouvre. Il dit qui siège où,
 * et à quel titre : qui anime, qui observe, et qui n’est là que pour un temps.
 *
 * Le rôle « externe » est la raison d’être de la page. Un prestataire qu’on
 * fait entrer à 3 h du matin et qu’on oublie de sortir, c’est un accès
 * permanent accordé par inadvertance à quelqu’un qui n’est plus concerné. La
 * date de sortie est donc exigée, ici comme au serveur — l’écran conduit, le
 * serveur garde.
 */
export function Cellules(
  { criseId, organisationId, lectureSeule }:
  { criseId: string; organisationId: string; lectureSeule: boolean },
) {
  const client = useQueryClient();
  const { moi } = useEtat();
  const [ouvert, definirOuvert] = useState<string | null>(null);
  const [qui, definirQui] = useState("");
  const [role, definirRole] = useState<string>("membre");
  const [sortie, definirSortie] = useState("");
  const [erreur, definirErreur] = useState<string | null>(null);
  const [envoi, definirEnvoi] = useState(false);

  const cellules = useQuery({
    queryKey: ["cellules", criseId],
    queryFn: () => api.lire<{ cellules: Cellule[] }>(`/api/crises/${criseId}/cellules`),
  });
  // Les membres de l’ORGANISATION, et non ceux de la crise : la route de crise
  // ne liste que les gens déjà en cellule, ce qui n’aide pas à en désigner un.
  const candidats = useQuery({
    queryKey: ["membres", organisationId],
    queryFn: () => api.lire<{ membres: MembreOrganisation[] }>(
      `/api/organisations/${organisationId}/membres`),
    enabled: organisationId.length > 0,
  });

  const roleOrga = moi?.organisations.find(
    (o) => o.organisation_id === organisationId)?.role;
  const dirige = roleOrga === "directeur_crise" || roleOrga === "proprietaire";
  // L’animateur compose sa propre cellule, et elle seule.
  const anime = (c: Cellule) => c.membres.some(
    (m) => m.compte_id === moi?.compte.id && m.role === "animateur");

  function ouvrirSur(celluleId: string) {
    definirErreur(null);
    definirQui(""); definirRole("membre"); definirSortie("");
    definirOuvert(ouvert === celluleId ? null : celluleId);
  }

  async function ajouter(celluleId: string) {
    definirErreur(null);
    definirEnvoi(true);
    try {
      await api.poster(`/api/crises/${criseId}/cellules/${celluleId}/membres`, {
        compteId: qui, role,
        expireAt: sortie ? new Date(sortie).toISOString() : null,
      });
      definirOuvert(null);
      await client.invalidateQueries({ queryKey: ["cellules", criseId] });
    } catch (e) {
      definirErreur(e instanceof ErreurApi ? e.message : "Ajout refusé.");
    } finally {
      definirEnvoi(false);
    }
  }

  return (
    <div className="scene">
      <header>
        <div className="etiq">Crise</div>
        <h1>Cellules</h1>
      </header>

      <div className="contenu">
        <p className="aide mesure" style={{ marginBottom: 18 }}>
          Qui siège où, et à quel titre. Entrer dans une cellule ne donne pas accès
          à la crise — cet accès vient de l’appartenance à l’organisation, et
          s’ouvre depuis l’écran « Membres ». On désigne ici qui anime, qui observe,
          et qui n’est là que pour un temps.
        </p>

        {erreur && <div className="erreur">{erreur}</div>}

        <div className="pile">
          {cellules.data?.cellules.map((c) => {
            const peut = !lectureSeule && (dirige || anime(c));
            const dedans = new Set(c.membres.map((m) => m.compte_id));
            const libres = (candidats.data?.membres ?? []).filter(
              (m) => !dedans.has(m.compte_id));
            return (
              <div key={c.id} className="carte">
                <div className="ligne">
                  <strong>{c.libelle}</strong>
                  <span className="pastille neutre sans-point">{c.code}</span>
                  {peut && (
                    <button className="pousse petit" onClick={() => ouvrirSur(c.id)}>
                      {ouvert === c.id ? "Annuler" : "Faire entrer quelqu’un"}
                    </button>
                  )}
                </div>

                {c.membres.length === 0 ? (
                  <div className="aide" style={{ marginTop: 8 }}>
                    Personne n’y siège encore.
                  </div>
                ) : (
                  <div className="pile" style={{ marginTop: 10 }}>
                    {c.membres.map((m) => (
                      <div key={m.compte_id} className="ligne" style={{ fontSize: 13.5 }}>
                        <span>{m.nom}</span>
                        <span className={`pastille sans-point ${
                          m.role === "externe" ? "alerte" : "neutre"}`}>
                          {NOM_ROLE(m.role)}
                        </span>
                        {m.expire_at && (
                          <span className="pousse discret" style={{ fontSize: 12.5 }}>
                            jusqu’au {dateHeure(m.expire_at)}
                          </span>
                        )}
                      </div>
                    ))}
                  </div>
                )}

                {ouvert === c.id && (
                  <div style={{ marginTop: 14, paddingTop: 12,
                    borderTop: "1px solid var(--ligne)" }}>
                    {libres.length === 0 ? (
                      <div className="aide">
                        Tous les membres de l’organisation siègent déjà ici. Pour
                        en faire venir un autre, invitez-le depuis l’écran
                        « Membres » — l’accès à la crise vient de là.
                      </div>
                    ) : (
                      <>
                        <div className="grille deux">
                          <div className="champ" style={{ marginBottom: 0 }}>
                            <label htmlFor={`qui-${c.id}`}>Qui</label>
                            <select id={`qui-${c.id}`} value={qui}
                              onChange={(e) => definirQui(e.target.value)}>
                              <option value="">Choisir…</option>
                              {libres.map((m) => (
                                <option key={m.compte_id} value={m.compte_id}>
                                  {m.nom} — {m.email}
                                </option>
                              ))}
                            </select>
                          </div>
                          <div className="champ" style={{ marginBottom: 0 }}>
                            <label htmlFor={`role-${c.id}`}>À quel titre</label>
                            <select id={`role-${c.id}`} value={role}
                              onChange={(e) => definirRole(e.target.value)}>
                              {ROLES.map((x) => (
                                <option key={x.cle} value={x.cle}>{x.libelle}</option>
                              ))}
                            </select>
                          </div>
                        </div>
                        <div className="aide" style={{ marginTop: 6 }}>
                          {ROLES.find((x) => x.cle === role)?.aide}
                        </div>

                        {/* Exigée pour un externe, offerte aux autres : un renfort
                            de nuit peut aussi n’être là que pour la nuit. */}
                        <div className="champ" style={{ marginTop: 12, marginBottom: 0 }}>
                          <label htmlFor={`sortie-${c.id}`}>
                            Date de sortie{role === "externe" ? "" : " (facultative)"}
                          </label>
                          <input id={`sortie-${c.id}`} type="datetime-local" value={sortie}
                            onChange={(e) => definirSortie(e.target.value)} />
                        </div>

                        <button className="accent" style={{ marginTop: 12 }}
                          disabled={envoi || qui === ""
                            || (role === "externe" && sortie === "")}
                          onClick={() => void ajouter(c.id)}>
                          {envoi ? "…" : "Faire entrer"}
                        </button>
                      </>
                    )}
                  </div>
                )}
              </div>
            );
          })}
          {cellules.data?.cellules.length === 0 && (
            <div className="vide"><strong>Aucune cellule ouverte.</strong>
              Les cellules se créent au déclenchement de la crise.</div>
          )}
        </div>
      </div>
    </div>
  );
}