Commit 6a531034 authored by Kourser's avatar Kourser
Browse files

Tableau des actions : le journal vu de côté

Un Kanban, sans second gisement de tâches. Les cinq états existaient depuis le
lot L3, et le changement de statut passait déjà par « enregistrer() » : il n'y
avait qu'un écran à écrire. Aucune table, aucun événement, aucune route en plus.

  C'est le point qui compte. Un tableau qui aurait ses propres cartes aurait
  ses propres états, donc sa propre vérité — et la boîte noire aurait cessé de
  savoir ce qui s'est passé. Ici, déplacer une carte inscrit un événement au
  journal, rembobinable comme le reste : le tableau, c'est le journal vu
  autrement.

Trois partis pris tirés de l'usage
  Bloquée exige un motif. Une action bloquée sans motif est du bruit ; le motif
  dit ce qui la débloquerait, donc à qui parler et ce qu'il faut arbitrer. Le
  motif part au journal avec le changement de statut.

  Le retard se voit au bord de la carte, pas seulement à une pastille. On
  balaie une colonne du regard, on ne lit pas chaque carte.

  Une cellule à six actions en cours est signalée — pas bloquée. Un tableau qui
  refuserait une carte le jour d'une crise serait absurde. Mais personne ne
  remarque qu'il suit neuf choses à la fois tant qu'on ne le lui dit pas.

Le glisser n'est pas le seul chemin
  Un sélecteur natif sur chaque carte fait le même travail au clavier, au doigt
  et au lecteur d'écran. Le glisser-déposer est un confort de bureau ; il ne
  peut pas être la seule façon de faire avancer une action.

Un défaut réparé au passage
  Le flux temps réel n'invalidait ni les actions ni les décisions : une carte
  déplacée par quelqu'un d'autre restait immobile sur l'écran des autres, et
  deux personnes pouvaient traiter la même. Le défaut existait déjà sur l'écran
  Décisions et actions ; le tableau l'a rendu visible.

577 garanties inchangées : rien de neuf à garantir côté serveur, ce qui est
exactement le résultat recherché. Éprouvé au navigateur — une carte déplacée
vers « Bloquée » réclame son motif, et le journal retient « Rédiger le message
aux clients grands comptes → bloquee — motif : En attente de la validation
juridique. »

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 450b1344
Loading
Loading
Loading
Loading
+22 −0
Original line number Diff line number Diff line
@@ -411,6 +411,28 @@ domaine résolvable. En production, activez `tls_port: 443` dans
pare-feux laissent passer, et souvent le seul chemin depuis l'hôtel où la
cellule s'est repliée.

## Tableau des actions

Un Kanban, mais **pas un second gisement de tâches** : ce sont les actions de
la conduite de crise, regardées de côté. Les cinq états existaient déjà et le
changement passait déjà par `enregistrer()`**le tableau, c'est le journal vu
autrement.** Une carte déplacée est un événement inscrit, rembobinable comme le
reste. Aucune table, aucun événement, aucune route n'a été ajoutée.

Trois partis pris tirés de l'usage en crise :

- **Bloquée exige un motif.** Une action bloquée sans motif est du bruit ; le
  motif dit ce qui la débloquerait, donc à qui parler et ce qu'il faut arbitrer.
- **Le retard se voit au bord de la carte**, pas seulement à une pastille : on
  balaie la colonne du regard, on ne lit pas chaque carte.
- **Une cellule à six actions en cours est signalée.** Ce n'est pas une limite
  imposée — un tableau qui refuse une carte le jour d'une crise serait absurde —
  c'est un constat, parce que personne ne remarque qu'il suit neuf choses à la
  fois tant qu'on ne le lui dit pas.

Le glisser-déposer est un confort ; un sélecteur natif sur chaque carte est le
chemin qui marche au clavier, au doigt et au lecteur d'écran.

## Continuité, parties prenantes, échéances

Deux questions que le comité de direction pose toutes les deux heures, et
+12 −0
Original line number Diff line number Diff line
@@ -25,6 +25,7 @@ import { ChoixTheme } from "../theme.js";
import { definirMuet, muet, tinter, titrer } from "../signal.js";
import { Continuite } from "../ecrans/Continuite.js";
import { Echeances, reste as rendreReste } from "../ecrans/Echeances.js";
import { Kanban } from "../ecrans/Kanban.js";

interface SalleListee { id: string; libelle: string; cellule_id: string | null; genre: string }
interface SalonListe {
@@ -81,6 +82,10 @@ export function Coque({ criseId }: { criseId: string }) {
    void client.invalidateQueries({ queryKey: ["main-courante", criseId] });
    void client.invalidateQueries({ queryKey: ["messages"] });
    void client.invalidateQueries({ queryKey: ["cockpit", criseId] });
    // Sans cela, une action déplacée par quelqu'un d'autre reste immobile sur
    // l'écran des autres — et deux personnes traitent la même carte.
    void client.invalidateQueries({ queryKey: ["actions", criseId] });
    void client.invalidateQueries({ queryKey: ["decisions", criseId] });

    if (d.genre !== "message.envoye") return;
    const salon = String(d.charge?.["salon_id"] ?? "");
@@ -312,6 +317,10 @@ export function Coque({ criseId }: { criseId: string }) {
              onClick={() => aller(`/crises/${criseId}/tableaux`)}>
              Tableaux blancs
            </button>
            <button className={`item${module === "tableau" ? " actif" : ""}`}
              onClick={() => aller(`/crises/${criseId}/tableau`)}>
              Tableau des actions
            </button>
            <button className={`item${module === "continuite" ? " actif" : ""}`}
              onClick={() => aller(`/crises/${criseId}/continuite`)}>
              Continuité
@@ -362,6 +371,9 @@ export function Coque({ criseId }: { criseId: string }) {
          <Mobilisation criseId={criseId} />
        ) : module === "cloture" ? (
          <Cloture criseId={criseId} phase={crise?.phase ?? "ouverte"} />
        ) : module === "tableau" ? (
          <Kanban criseId={criseId} cellules={cockpit.data?.cellules ?? []}
            lectureSeule={rembobinage !== null} />
        ) : module === "continuite" ? (
          <Continuite criseId={criseId} lectureSeule={rembobinage !== null} />
        ) : module === "echeances" ? (
+260 −0
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";

interface Action {
  id: string; intitule: string; responsable_libelle: string | null; statut: string;
  echeance: string | null; cellule_id: string | null;
}

interface Cellule { id: string; libelle: string }

const COLONNES = [
  { cle: "a_faire", libelle: "À faire" },
  { cle: "en_cours", libelle: "En cours" },
  { cle: "bloquee", libelle: "Bloquée" },
  { cle: "faite", libelle: "Faite" },
] as const;

type Statut = typeof COLONNES[number]["cle"] | "abandonnee";

/**
 * Au-delà, une cellule ne suit plus : elle subit.
 *
 * Ce n'est pas une limite qu'on impose — un tableau qui refuse une carte le
 * jour d'une crise serait absurde. C'est un constat qu'on affiche, parce que
 * personne ne remarque qu'il a neuf choses en cours tant que personne ne le
 * lui dit.
 */
const TROP_EN_COURS = 6;

const enRetard = (a: Action) =>
  a.echeance !== null && new Date(a.echeance).getTime() < Date.now()
  && !["faite", "abandonnee"].includes(a.statut);

const quand = (iso: string) => {
  const d = new Date(iso);
  const reste = Math.round((d.getTime() - Date.now()) / 60000);
  const t = Math.abs(reste);
  const texte = t < 60 ? `${t} min` : t < 1440 ? `${Math.floor(t / 60)} h`
    : `${Math.floor(t / 1440)} j`;
  return reste < 0 ? `en retard de ${texte}` : `dans ${texte}`;
};

/**
 * Tableau des actions (§6.6).
 *
 * Ce n'est pas un second gisement de tâches : ce sont les actions de la
 * conduite de crise, regardées de côté. Une carte déplacée est un changement
 * de statut inscrit au journal, comme s'il avait été fait depuis la liste —
 * le tableau, c'est le journal vu autrement.
 */
export function Kanban({ criseId, cellules, lectureSeule }: {
  criseId: string; cellules: Cellule[]; lectureSeule?: boolean;
}) {
  const client = useQueryClient();
  const [couloirs, definirCouloirs] = useState(false);
  const [survol, definirSurvol] = useState<string | null>(null);
  const [motif, definirMotif] = useState<{ action: Action; texte: string } | null>(null);
  const [erreur, definirErreur] = useState<string | null>(null);
  const [voirAbandon, definirVoirAbandon] = useState(false);

  const { data } = useQuery({
    queryKey: ["actions", criseId],
    queryFn: () => api.lire<{ actions: Action[] }>(`/api/crises/${criseId}/actions`),
  });
  const actions = data?.actions ?? [];

  async function deplacer(a: Action, vers: Statut, commentaire: string | null = null) {
    if (a.statut === vers) return;
    // Une action bloquée sans motif est du bruit : c'est le motif qui dit ce
    // qui la débloquerait, et donc à qui parler.
    if (vers === "bloquee" && !commentaire) {
      definirMotif({ action: a, texte: "" });
      return;
    }
    definirErreur(null);
    try {
      await api.poster(`/api/crises/${criseId}/actions/${a.id}/statut`,
        { statut: vers, commentaire });
      await Promise.all([
        client.invalidateQueries({ queryKey: ["actions", criseId] }),
        client.invalidateQueries({ queryKey: ["cockpit", criseId] }),
      ]);
    } catch (e) {
      definirErreur(e instanceof ErreurApi ? e.message : "Le déplacement n’a pas été inscrit.");
    }
  }

  const carte = (a: Action) => (
    <article key={a.id} className={`carte-kanban${enRetard(a) ? " retard" : ""}`}
      draggable={!lectureSeule}
      onDragStart={(e) => e.dataTransfer.setData("text/plain", a.id)}>
      <div className="intitule">{a.intitule}</div>
      <div className="ligne" style={{ gap: 6, flexWrap: "wrap" }}>
        {/* Une liste de tâches sans responsable n'est pas un registre. */}
        <span className={a.responsable_libelle ? "discret" : "pastille alerte"}>
          {a.responsable_libelle ?? "non attribuée"}
        </span>
        {a.echeance && (
          <span className={enRetard(a) ? "pastille crit" : "discret"}>
            {quand(a.echeance)}
          </span>
        )}
        {!couloirs && a.cellule_id && (
          <span className="discret" style={{ fontSize: 11 }}>
            {cellules.find((c) => c.id === a.cellule_id)?.libelle}
          </span>
        )}
      </div>
      {!lectureSeule && (
        // Le glisser est un confort ; ce sélecteur est le chemin qui marche au
        // clavier, au doigt, et au lecteur d'écran.
        <select className="deplacer" value={a.statut}
          aria-label={`Déplacer « ${a.intitule} »`}
          onChange={(e) => void deplacer(a, e.target.value as Statut)}>
          {[...COLONNES, { cle: "abandonnee", libelle: "Abandonnée" } as const].map((c) => (
            <option key={c.cle} value={c.cle}>{c.libelle}</option>
          ))}
        </select>
      )}
    </article>
  );

  const colonne = (cle: string, liste: Action[]) => (
    <div className={`colonne-kanban${survol === cle ? " survol" : ""}`}
      onDragOver={(e) => { if (!lectureSeule) { e.preventDefault(); definirSurvol(cle); } }}
      onDragLeave={() => definirSurvol(null)}
      onDrop={(e) => {
        e.preventDefault(); definirSurvol(null);
        const id = e.dataTransfer.getData("text/plain");
        const a = actions.find((x) => x.id === id);
        if (a) void deplacer(a, cle as Statut);
      }}>
      {liste.map(carte)}
      {liste.length === 0 && <div className="vide-kanban"></div>}
    </div>
  );

  const filtrer = (statut: string, celluleId?: string) => actions
    .filter((a) => a.statut === statut && (celluleId === undefined || a.cellule_id === celluleId))
    .sort((x, y) => Number(enRetard(y)) - Number(enRetard(x)));

  const surchargees = cellules
    .map((c) => ({ c, n: filtrer("en_cours", c.id).length }))
    .filter((x) => x.n >= TROP_EN_COURS);

  return (
    <div className="scene">
      <header>
        <div className="etiq">Conduite</div>
        <h1>{actions.filter((a) => !["faite", "abandonnee"].includes(a.statut)).length} action(s) ouverte(s)</h1>
        <div className="ligne" style={{ gap: 6, marginTop: 8 }}>
          <button className={couloirs ? "accent petit" : "petit"}
            onClick={() => definirCouloirs(!couloirs)}>
            Par cellule
          </button>
          <button className={voirAbandon ? "accent petit" : "petit"}
            onClick={() => definirVoirAbandon(!voirAbandon)}>
            Voir les abandonnées
          </button>
        </div>
      </header>

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

        {/* Personne ne remarque qu'il a neuf choses en cours tant que personne
            ne le lui dit. */}
        {surchargees.length > 0 && (
          <div className="carte alerte" style={{ marginBottom: 14 }}>
            <span className="etiq">Une cellule ne suit pas neuf choses à la fois</span>
            <div style={{ fontSize: 13.5, marginTop: 4 }}>
              {surchargees.map((x) => `${x.c.libelle} : ${x.n} en cours`).join(" · ")}.
              Ce n’est pas une limite imposée, c’est un constat — arbitrez ce qui attend,
              ou répartissez.
            </div>
          </div>
        )}

        {actions.length === 0 ? (
          <div className="vide">
            <strong>Aucune action.</strong>
            Une décision sans action est une intention. Les actions se créent depuis
            l’écran Décisions et actions, ou en un geste depuis une décision.
          </div>
        ) : couloirs ? (
          cellules.map((c) => (
            <section key={c.id} style={{ marginBottom: 22 }}>
              <h2 style={{ fontSize: 15, marginBottom: 8 }}>{c.libelle}</h2>
              <div className="tableau-kanban">
                {COLONNES.map((col) => (
                  <div key={col.cle}>
                    <div className="tete-colonne">
                      {col.libelle}
                      <span className="nombres">{filtrer(col.cle, c.id).length}</span>
                    </div>
                    {colonne(col.cle, filtrer(col.cle, c.id))}
                  </div>
                ))}
              </div>
            </section>
          ))
        ) : (
          <div className="tableau-kanban">
            {COLONNES.map((col) => (
              <div key={col.cle}>
                <div className="tete-colonne">
                  {col.libelle}
                  <span className="nombres">{filtrer(col.cle).length}</span>
                </div>
                {colonne(col.cle, filtrer(col.cle))}
              </div>
            ))}
          </div>
        )}

        {voirAbandon && filtrer("abandonnee").length > 0 && (
          <section style={{ marginTop: 20 }}>
            <h2 style={{ fontSize: 15 }}>Abandonnées</h2>
            <p className="aide" style={{ marginTop: 2 }}>
              Elles restent au journal avec le motif de leur abandon&nbsp;: au retour
              d’expérience, ce qu’on a renoncé à faire compte autant que le reste.
            </p>
            <div className="tableau-kanban" style={{ gridTemplateColumns: "1fr" }}>
              {colonne("abandonnee", filtrer("abandonnee"))}
            </div>
          </section>
        )}

        {/* ── Ce qui bloque ────────────────────────────────────────────────── */}
        {motif && (
          <div className="carte" style={{ marginTop: 16 }}>
            <span className="etiq">Qu’est-ce qui bloque «&nbsp;{motif.action.intitule}&nbsp;»&nbsp;?</span>
            <p className="aide" style={{ marginTop: 4 }}>
              Une action bloquée sans motif est du bruit. Le motif dit ce qui la
              débloquerait — donc à qui parler, et ce qu’il faut arbitrer.
            </p>
            <div className="ligne" style={{ gap: 8, marginTop: 8 }}>
              <input autoFocus placeholder="En attente du retour du prestataire, du DAF…"
                value={motif.texte}
                onChange={(e) => definirMotif({ ...motif, texte: e.target.value })}
                onKeyDown={(e) => {
                  if (e.key === "Enter" && motif.texte.trim()) {
                    const m = motif; definirMotif(null);
                    void deplacer(m.action, "bloquee", m.texte.trim());
                  }
                }} />
              <button className="accent" disabled={motif.texte.trim().length === 0}
                onClick={() => { const m = motif; definirMotif(null);
                  void deplacer(m.action, "bloquee", m.texte.trim()); }}>
                Bloquer
              </button>
              <button className="discret petit" onClick={() => definirMotif(null)}>Annuler</button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}
+36 −0
Original line number Diff line number Diff line
@@ -647,3 +647,39 @@ tbody tr:hover { background: var(--surface-2); }
  border-radius: 9px; min-width: 18px; height: 18px; padding: 0 5px;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: Archivo, sans-serif; font-size: 10.5px; font-weight: 700; }

/* ── Tableau des actions (§6.6) ───────────────────────────────────────────
   Quatre colonnes qui tiennent sur un écran de salle de crise ; en dessous de
   900 px elles défilent horizontalement plutôt que de s'écraser à trois
   caractères de large. */
.tableau-kanban {
  display: grid; grid-template-columns: repeat(4, minmax(210px, 1fr));
  gap: 10px; overflow-x: auto; padding-bottom: 4px;
}
.tete-colonne {
  display: flex; align-items: baseline; gap: 8px; padding: 0 2px 6px;
  font-family: Archivo, sans-serif; font-size: 10.5px; font-weight: 600;
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--encre-3);
}
.tete-colonne .nombres { margin-left: auto; font-size: 12px; color: var(--encre-2); }
.colonne-kanban {
  display: flex; flex-direction: column; gap: 8px;
  min-height: 120px; padding: 8px; border-radius: 4px;
  background: var(--surface-2); border: 1px solid transparent;
}
.colonne-kanban.survol { border-color: var(--accent); background: var(--accent-doux); }
.carte-kanban {
  background: var(--surface); border: 1px solid var(--ligne); border-radius: 4px;
  padding: 9px 10px; display: flex; flex-direction: column; gap: 6px;
  cursor: grab; font-size: 13.5px;
}
.carte-kanban:active { cursor: grabbing; }
/* Le retard se voit à la couleur du bord, pas seulement à une pastille : on
   balaie la colonne du regard, on ne lit pas chaque carte. */
.carte-kanban.retard { border-left: 3px solid var(--crit); }
.carte-kanban .intitule { line-height: 1.35; }
.carte-kanban .deplacer {
  font-size: 11.5px; padding: 2px 4px; width: 100%;
  background: transparent; color: var(--encre-3); border-color: var(--ligne);
}
.vide-kanban { text-align: center; color: var(--encre-3); font-size: 13px; padding: 8px 0; }