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

Celui qui ouvre la crise était absent de sa propre liste

Question posée à l'écran : pourquoi ne puis-je pas m'attribuer une action ? Parce
que la liste « qui la porte » ne rendait que les comptes admis dans une cellule.
Or l'accès à une crise vient de l'appartenance à l'organisation, pas d'une
cellule : celui qui ouvre la crise n'y figurait donc pas, alors qu'il est par
définition en train d'agir.

Le serveur, lui, l'acceptait : « responsableId » n'est validé que comme UUID. La
restriction n'existait que dans la liste du client.

Trois populations, groupées, et l'ordre est le propos.

Les membres de chaque cellule d'abord, par cellule : c'est là qu'on cherche, et
savoir de quelle cellule quelqu'un relève dit à peu près tout.

L'annuaire gelé ensuite, car une action se fait souvent par quelqu'un qui ne
siège dans aucune cellule — un responsable d'application, un chef de site. Ceux
qui n'ont pas de compte y figurent aussi, marqués comme tels : l'action portera
leur nom en clair, ce que « responsable_libelle » sait faire depuis l'origine, et
cela évite de recopier à la main un nom que le dossier connaît déjà.

Les membres de l'organisation absents de l'annuaire enfin. Cette troisième
population ne m'était pas demandée — la proposition s'arrêtait à l'annuaire —
mais sans elle le défaut signalé reviendrait à l'identique pour la personne qui
ouvre la crise, qui n'est pas nécessairement à l'annuaire.

Une route distincte plutôt que l'élargissement de « /membres », que trois autres
écrans utilisent : ils ne posent pas la même question. Écrire un message direct à
quelqu'un sans compte n'a pas de sens ; porter une action, si.

Six garanties, dont la première est exactement le cas signalé : celui qui a
ouvert la crise se trouve dans la liste, et y est signalé comme étant lui-même.

Ces fichiers portent aussi la route de lecture et ses garanties, qui viennent
avec le lot sur la main courante.

Co-Authored-By: Claude (RCA)
parent 1ef21eba
Loading
Loading
Loading
Loading
+106 −0
Changes for apps/api/src/domaine/crise.ts: 106 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -285,6 +285,112 @@ export interface CelluleOuverte {
 * restent visibles avec leur date — c'est ce qui permet de voir qu'un accès
 * externe court encore, et de s'en étonner à temps.
 */
export interface Porteur {
  nom: string;
  /** Nul : la personne n'a pas de compte — l'action portera son nom en clair. */
  compte_id: string | null;
  personne_id: string | null;
  role_crise: string | null;
  /** Vrai pour celui qui demande : « m'attribuer » est le geste le plus fréquent. */
  moi: boolean;
  origine: "annuaire" | "organisation";
}

export interface Porteurs {
  cellules: {
    id: string; code: string; libelle: string;
    membres: { nom: string; compte_id: string; moi: boolean }[];
  }[];
  /** L'annuaire gelé, puis les membres de l'organisation qui n'y figurent pas. */
  hors_cellule: Porteur[];
}

/**
 * Qui peut porter une action (EF-505a).
 *
 * La liste ne rendait que les comptes admis dans une cellule. Or l'accès à une
 * crise vient de l'appartenance à l'organisation, pas d'une cellule : celui qui
 * ouvre la crise était donc invisible — il ne pouvait même pas s'attribuer une
 * action à lui-même, alors qu'il est par définition en train d'agir.
 *
 * Trois populations, dans cet ordre, et l'ordre est le propos.
 *
 * Les membres de chaque cellule d'abord, groupés par cellule : c'est là qu'on
 * cherche, et savoir de quelle cellule quelqu'un relève dit à peu près tout.
 *
 * L'annuaire gelé ensuite, car une action se fait souvent par quelqu'un qui ne
 * siège dans aucune cellule — un responsable d'application, un chef de site.
 * Ceux qui n'ont pas de compte y figurent aussi : l'action portera leur nom en
 * clair, ce que « responsable_libelle » sait faire depuis l'origine.
 *
 * Les membres de l'organisation absents de l'annuaire enfin. Sans eux, celui
 * qui a ouvert la crise resterait introuvable, ce qui est le défaut d'origine.
 */
export async function porteurs(
  sql: Sql, criseId: string, moiId: string,
): Promise<Porteurs | null> {
  const [crise] = await sql<{ organisation_id: string; preparation_version: number | null }[]>`
    select organisation_id, preparation_version from crise where id = ${criseId}`;
  if (!crise) return null;

  const ouvertes = await cellulesEtMembres(sql, criseId);
  const version = crise.preparation_version;

  const personnes = await sql<{ id: string; nom: string; role_crise: string | null;
    compte_id: string | null }[]>`
    select id, nom, role_crise, compte_id from personne
     where organisation_id = ${crise.organisation_id} and not supprimee
       ${version === null
         ? sql`and jusqu_a_version is null`
         : sql`and depuis_version <= ${version}
               and (jusqu_a_version is null or jusqu_a_version > ${version})`}
     order by nom`;

  const membresOrga = await sql<{ compte_id: string; nom: string }[]>`
    select m.compte_id, c.nom from membre m
      join compte c on c.id = m.compte_id
     where m.organisation_id = ${crise.organisation_id}
       and c.desactive_at is null
     order by c.nom`;

  const enCellule = new Set(ouvertes.flatMap((c) => c.membres.map((m) => m.compte_id)));
  const hors: Porteur[] = [];
  const vus = new Set<string>();

  for (const p of personnes) {
    if (p.compte_id && enCellule.has(p.compte_id)) continue;
    // Deux fiches d'annuaire peuvent porter le même compte ; une seule ligne.
    const cle = p.compte_id ?? `nom:${p.nom.toLowerCase()}`;
    if (vus.has(cle)) continue;
    vus.add(cle);
    hors.push({
      nom: p.nom, compte_id: p.compte_id, personne_id: p.id,
      role_crise: p.role_crise, moi: p.compte_id === moiId, origine: "annuaire",
    });
  }

  for (const m of membresOrga) {
    if (enCellule.has(m.compte_id) || vus.has(m.compte_id)) continue;
    vus.add(m.compte_id);
    hors.push({
      nom: m.nom, compte_id: m.compte_id, personne_id: null,
      role_crise: null, moi: m.compte_id === moiId, origine: "organisation",
    });
  }

  hors.sort((a, b) => a.nom.localeCompare(b.nom, "fr"));

  return {
    cellules: ouvertes.map((c) => ({
      id: c.id, code: c.code, libelle: c.libelle,
      membres: c.membres.map((m) => ({
        nom: m.nom, compte_id: m.compte_id, moi: m.compte_id === moiId,
      })),
    })),
    hors_cellule: hors,
  };
}

export async function cellulesEtMembres(
  sql: Sql, criseId: string,
): Promise<CelluleOuverte[]> {
+112 −0
Changes for apps/web/src/coque/ChoixPorteur.tsx: 112 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";

interface MembreCellule { nom: string; compte_id: string; moi: boolean }
interface Porteur {
  nom: string; compte_id: string | null; personne_id: string | null;
  role_crise: string | null; moi: boolean; origine: "annuaire" | "organisation";
}
export interface Porteurs {
  cellules: { id: string; code: string; libelle: string; membres: MembreCellule[] }[];
  hors_cellule: Porteur[];
}

export interface Affectation {
  responsableId: string | null;
  responsableLibelle: string | null;
}

/** Sans responsable : le choix vide, qu'il faut pouvoir exprimer. */
export const SANS_RESPONSABLE: Affectation = { responsableId: null, responsableLibelle: null };

/**
 * Qui porte une action (EF-505a).
 *
 * La liste ne rendait que les comptes admis dans une cellule. Or l’accès à une
 * crise vient de l’appartenance à l’organisation&nbsp;: celui qui ouvre la crise
 * était donc absent de sa propre liste, et ne pouvait pas s’attribuer une action
 * — alors qu’il est par définition en train d’agir.
 *
 * Trois populations, et l’ordre est le propos. Les cellules d’abord, groupées,
 * parce que c’est là qu’on cherche et que la cellule dit à peu près tout. Puis
 * l’annuaire gelé, parce qu’une action se fait souvent par quelqu’un qui ne
 * siège nulle part — un responsable d’application, un chef de site. Puis les
 * membres de l’organisation absents de l’annuaire, sans quoi celui qui a ouvert
 * la crise resterait introuvable.
 *
 * Une personne de l’annuaire sans compte est proposée comme les autres&nbsp;:
 * l’action portera son nom en clair. C’est ce que « responsable_libelle » sait
 * faire depuis l’origine, et cela évite de recopier à la main un nom que le
 * dossier connaît déjà.
 */
export function ChoixPorteur({ criseId, surChoix, libelleInitial }: {
  criseId: string;
  surChoix: (a: Affectation) => void;
  /** Le nom déjà porté, quand on réaffecte une action qui en avait un. */
  libelleInitial?: string | undefined;
}) {
  // « c:<id> » un compte, « l:<nom> » un nom du dossier, « » le nom libre.
  const [valeur, definirValeur] = useState("");
  const [libre, definirLibre] = useState(libelleInitial ?? "");

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

  function choisir(v: string) {
    definirValeur(v);
    if (v.startsWith("c:")) {
      definirLibre("");
      surChoix({ responsableId: v.slice(2), responsableLibelle: null });
    } else if (v.startsWith("l:")) {
      const nom = v.slice(2);
      definirLibre(nom);
      surChoix({ responsableId: null, responsableLibelle: nom });
    } else {
      surChoix({ responsableId: null, responsableLibelle: libre.trim() || null });
    }
  }

  const nommer = (p: { nom: string; moi: boolean; role_crise?: string | null }) =>
    `${p.nom}${p.moi ? " (vous)" : ""}`
    + (p.role_crise ? ` · ${p.role_crise}` : "");

  return (
    <div className="ligne" style={{ gap: 8, flexWrap: "wrap" }}>
      <select value={valeur} onChange={(e) => choisir(e.target.value)}>
        <option value="">— nom libre —</option>
        {(data?.cellules ?? []).map((c) => (
          c.membres.length > 0 ? (
            <optgroup key={c.id} label={c.libelle}>
              {c.membres.map((m) => (
                <option key={m.compte_id} value={`c:${m.compte_id}`}>{nommer(m)}</option>
              ))}
            </optgroup>
          ) : null
        ))}
        {(data?.hors_cellule ?? []).length > 0 && (
          <optgroup label="Hors cellule">
            {data!.hors_cellule.map((p) => (
              <option
                key={p.compte_id ?? `nom:${p.nom}`}
                value={p.compte_id ? `c:${p.compte_id}` : `l:${p.nom}`}>
                {nommer(p)}{p.compte_id ? "" : " · sans compte"}
              </option>
            ))}
          </optgroup>
        )}
      </select>

      {!valeur.startsWith("c:") && (
        <input value={libre} maxLength={160} style={{ flex: 1, minWidth: 200 }}
          placeholder="Prestataire, élu, service d’astreinte…"
          onChange={(e) => {
            definirLibre(e.target.value);
            surChoix({ responsableId: null, responsableLibelle: e.target.value.trim() || null });
          }} />
      )}
    </div>
  );
}
+14 −28
Changes for apps/web/src/ecrans/ActionDetail.tsx: 14 added lines, 28 removed lines.
Original line number Diff line number Diff line
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChoixPorteur, type Affectation } from "../coque/ChoixPorteur.js";
import { useQueryClient } from "@tanstack/react-query";
import { api, ErreurApi } from "../api.js";
import { Historique } from "../coque/Historique.js";

@@ -37,17 +38,12 @@ export function ActionDetail({
  const [succes, definirSucces] = useState<string | null>(null);
  const [envoi, definirEnvoi] = useState(false);

  const [responsable, definirResponsable] = useState<string>("");
  const [libre, definirLibre] = useState(action.responsable_libelle ?? "");
  const [porteur, definirPorteur] = useState<Affectation>({
    responsableId: null, responsableLibelle: action.responsable_libelle ?? null,
  });
  const [echeance, definirEcheance] = useState(pourChamp(action.echeance));
  const [motif, definirMotif] = useState("");

  const membres = useQuery({
    queryKey: ["membres", criseId],
    queryFn: () => api.lire<{ membres: { compte_id: string; nom: string }[] }>(
      `/api/crises/${criseId}/membres`),
  });

  async function envoyer(chemin: string, corps: unknown, message: string) {
    definirErreur(null);
    definirEnvoi(true);
@@ -83,27 +79,17 @@ export function ActionDetail({
      <div style={{ marginTop: 12 }}>
        <span className="etiq">Responsable</span>
        <p className="aide" style={{ marginTop: 2 }}>
          Un membre de la crise, ou un nom libre pour quelqu’un qui n’a pas de compte —
          un prestataire, un élu, un service d’astreinte.
          Les cellules d’abord, puis l’annuaire gelé, puis les membres de l’organisation.
          Quelqu’un sans compte se désigne aussi : l’action portera son nom en clair.
        </p>
        <div className="ligne" style={{ gap: 8, marginTop: 8, flexWrap: "wrap" }}>
          <select value={responsable}
            onChange={(e) => { definirResponsable(e.target.value); definirLibre(""); }}>
            <option value="">— nom libre —</option>
            {membres.data?.membres.map((m) => (
              <option key={m.compte_id} value={m.compte_id}>{m.nom}</option>
            ))}
          </select>
          {responsable === "" && (
            <input value={libre} placeholder="Nom de la personne ou du service"
              style={{ maxWidth: 260 }}
              onChange={(e) => definirLibre(e.target.value)} />
          )}
          <button className="accent petit" disabled={envoi}
        <div style={{ marginTop: 8 }}>
          <ChoixPorteur criseId={criseId} surChoix={definirPorteur}
            libelleInitial={action.responsable_libelle ?? undefined} />
          <button className="accent petit" style={{ marginTop: 8 }} disabled={envoi}
            onClick={() => void envoyer("affectation",
              responsable
                ? { responsableId: responsable }
                : { responsableLibelle: libre.trim() || null },
              porteur.responsableId
                ? { responsableId: porteur.responsableId }
                : { responsableLibelle: porteur.responsableLibelle },
              "Action réaffectée.")}>
            Réaffecter
          </button>
+9 −25
Changes for apps/web/src/ecrans/Conduite.tsx: 9 added lines, 25 removed lines.
Original line number Diff line number Diff line
import { useState } from "react";
import { ChoixPorteur, SANS_RESPONSABLE, type Affectation }
  from "../coque/ChoixPorteur.js";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ErreurApi, type Entree } from "../api.js";
import { dateHeure } from "../etat.js";
@@ -665,19 +667,12 @@ function NouvelleAction({
  surCreation: () => void; surFermeture: () => void;
}) {
  const [intitule, definirIntitule] = useState("");
  const [responsable, definirResponsable] = useState("");
  const [libre, definirLibre] = useState("");
  const [porteur, definirPorteur] = useState<Affectation>(SANS_RESPONSABLE);
  const [echeance, definirEcheance] = useState("");
  const [celluleId, definirCellule] = useState(cellules[0]?.id ?? "");
  const [erreur, definirErreur] = useState<string | null>(null);
  const [envoi, definirEnvoi] = useState(false);

  const membres = useQuery({
    queryKey: ["membres", criseId],
    queryFn: () => api.lire<{ membres: { compte_id: string; nom: string }[] }>(
      `/api/crises/${criseId}/membres`),
  });

  async function enregistrer() {
    definirErreur(null);
    definirEnvoi(true);
@@ -685,8 +680,8 @@ function NouvelleAction({
      await api.poster(`/api/crises/${criseId}/actions`, {
        celluleId: celluleId || null,
        intitule: intitule.trim(),
        responsableId: responsable || null,
        responsableLibelle: responsable ? null : (libre.trim() || null),
        responsableId: porteur.responsableId,
        responsableLibelle: porteur.responsableLibelle,
        echeance: echeance ? new Date(echeance).toISOString() : null,
        decisionId: decision?.id ?? null,
      });
@@ -727,22 +722,11 @@ function NouvelleAction({

      <div className="champ">
        <label htmlFor="responsable">Qui la porte</label>
        <div className="ligne" style={{ gap: 8, flexWrap: "wrap" }}>
          <select id="responsable" value={responsable}
            onChange={(e) => { definirResponsable(e.target.value); definirLibre(""); }}>
            <option value="">— nom libre —</option>
            {membres.data?.membres.map((m) => (
              <option key={m.compte_id} value={m.compte_id}>{m.nom}</option>
            ))}
          </select>
          {responsable === "" && (
            <input value={libre} maxLength={160} style={{ flex: 1, minWidth: 200 }}
              placeholder="Prestataire, élu, service d’astreinte…"
              onChange={(e) => definirLibre(e.target.value)} />
          )}
        </div>
        <ChoixPorteur criseId={criseId} surChoix={definirPorteur} />
        <div className="aide">
          Une action sans responsable ne sera pas faite. Un nom libre vaut mieux qu’aucun nom.
          Les cellules d’abord, puis l’annuaire gelé, puis les membres de l’organisation.
          Une action se fait souvent par quelqu’un qui ne siège dans aucune cellule — et une
          action sans responsable ne sera pas faite : un nom libre vaut mieux qu’aucun nom.
        </div>
      </div>