Commit 8a833247 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Savoir qui est d'astreinte ne dit pas quand personne ne l'est

Kastell savait déclarer des astreintes et dire qui est joignable maintenant. Il
ne disait pas *quand* personne ne l'est — or c'est la première cause d'échec
constatée en réel : un tour d'astreinte comportant un trou le dimanche matin ne
se voit pas en lisant la liste, il se voit le dimanche matin, quand on appelle.

Le calcul est le complément, sur un horizon réglable, de l'union des périodes
d'astreinte. Deux astreintes qui se chevauchent n'ouvrent pas de trou entre
elles, et deux qui se touchent non plus.

Les absences n'y entrent pas, et la distinction est le cœur du sujet : une
absence dit que quelqu'un n'est pas joignable, pas que personne ne l'est.

Un horizon sans aucune astreinte déclarée rend un trou couvrant tout
l'horizon. C'est voulu : dire « aucun trou » d'une organisation qui n'a rien
organisé serait le mensonge le plus coûteux que cet indicateur puisse faire.

L'écran des astreintes affiche les créneaux découverts avec leur durée, et
propose de les combler — le formulaire s'ouvre déjà rempli sur le trou qu'on
vient de lire. Le redemander en toutes lettres serait le seul travail inutile
de cet écran.

Un point rejoint l'indicateur de préparation. Le texte et le calcul sont reliés
à une même constante : un premier jet annonçait « quinze jours » en en mesurant
quatorze.

J'avais d'abord rattaché ce travail à EF-204, qui porte les éléments
contractuels des tiers. L'exigence juste est EF-202, l'annuaire interne, qui
porte les périodes d'astreinte et d'indisponibilité. Les références sont
corrigées, ici et dans l'écran écrit au lot précédent.

Neuf garanties neuves, dont celles qui tiennent aux cas limites : la période
qui commence avant l'horizon, l'absence qui ne couvre rien, l'horizon
entièrement couvert.

Ce fichier porte aussi la lecture de la langue déclarée, qui sert aux alertes
multilingues du lot suivant.

Co-Authored-By: Claude (RCA)
parent ef209879
Loading
Loading
Loading
Loading
+63 −0
Changes for apps/api/src/preparation/annuaire.ts: 63 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -215,6 +215,69 @@ export async function retirerPeriode(
}

/** Qui est joignable maintenant : la première question d'une mobilisation. */
export interface TrouAstreinte {
  du: Date;
  au: Date;
  minutes: number;
}

/**
 * Les périodes où personne n'est d'astreinte (EF-202a).
 *
 * Kastell savait déclarer des astreintes et dire qui est joignable maintenant.
 * Il ne disait pas **quand personne ne l'est** — or c'est la première cause
 * d'échec constatée en réel : un tour d'astreinte avec un trou le dimanche
 * matin ne se voit pas en lisant la liste, il se voit le dimanche matin.
 *
 * Le calcul est le complément, sur l'horizon demandé, de l'union des périodes
 * d'astreinte. Les absences n'y entrent pas : une absence dit que quelqu'un
 * n'est pas joignable, pas que personne ne l'est.
 *
 * Un horizon sans aucune astreinte déclarée rend un trou unique couvrant tout
 * l'horizon. C'est voulu : dire « aucun trou » d'une organisation qui n'a rien
 * organisé serait le mensonge le plus coûteux de cet indicateur.
 */
export async function trousAstreinte(
  sql: Sql, organisationId: string, jours = 14, maintenant = new Date(),
): Promise<TrouAstreinte[]> {
  const fin = new Date(maintenant.getTime() + jours * 86400_000);

  const periodes = await sql<{ du: Date; au: Date }[]>`
    select i.du, i.au from indisponibilite i
     where i.genre = 'astreinte'
       and i.personne_id in (select id from personne_courante
                              where organisation_id = ${organisationId})
       and i.au > ${maintenant} and i.du < ${fin}
     order by i.du`;

  // Bornées à l'horizon, puis fusionnées : deux astreintes qui se chevauchent
  // ne laissent pas de trou entre elles, et deux qui se touchent non plus.
  const couvertes: { du: number; au: number }[] = [];
  for (const p of periodes) {
    const du = Math.max(p.du.getTime(), maintenant.getTime());
    const au = Math.min(p.au.getTime(), fin.getTime());
    if (au <= du) continue;
    const dernier = couvertes[couvertes.length - 1];
    if (dernier && du <= dernier.au) dernier.au = Math.max(dernier.au, au);
    else couvertes.push({ du, au });
  }

  const trous: TrouAstreinte[] = [];
  let curseur = maintenant.getTime();
  for (const c of couvertes) {
    if (c.du > curseur) {
      trous.push({ du: new Date(curseur), au: new Date(c.du),
        minutes: Math.round((c.du - curseur) / 60000) });
    }
    curseur = Math.max(curseur, c.au);
  }
  if (curseur < fin.getTime()) {
    trous.push({ du: new Date(curseur), au: fin,
      minutes: Math.round((fin.getTime() - curseur) / 60000) });
  }
  return trous;
}

export async function joignables(sql: Sql, organisationId: string): Promise<Personne[]> {
  return sql<Personne[]>`
    select p.* from personne_courante p
+39 −2
Changes for apps/api/src/preparation/indicateur.ts: 39 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -43,7 +43,7 @@ export interface Point {
   * configure comment ? » est une question qu'un indicateur ne doit jamais
   * laisser sans réponse.
   */
  ou: "annuaire" | "tiers" | "fiches" | "kit" | null;
  ou: "annuaire" | "tiers" | "fiches" | "kit" | "disponibilites" | null;
}

export interface Indicateur {
@@ -53,11 +53,20 @@ export interface Indicateur {
  points: Point[];
}

/**
 * L'horizon sur lequel on cherche les trous d'astreinte.
 *
 * Nommé plutôt qu'écrit deux fois : le texte du point et le calcul doivent
 * bouger ensemble. Un premier jet annonçait « quinze jours » en en mesurant
 * quatorze.
 */
const HORIZON_ASTREINTE = 14;

const perime = (d: Date | null | undefined, mois = FRAICHEUR_MOIS) =>
  !d || Date.now() - new Date(d).getTime() > mois * MS_MOIS;

export async function calculer(sql: Sql, organisationId: string): Promise<Indicateur> {
  const [personnes, tiers, listeFiches, version, membres] = await Promise.all([
  const [personnes, tiers, listeFiches, version, membres, trous] = await Promise.all([
    annuaire.lister(sql, organisationId),
    tiersModule.lister(sql, organisationId),
    fiches.lister(sql, organisationId),
@@ -67,10 +76,38 @@ export async function calculer(sql: Sql, organisationId: string): Promise<Indica
             count(*) filter (where c.derniere_connexion_at is null) as jamais
        from membre m join compte c on c.id = m.compte_id
       where m.organisation_id = ${organisationId}`,
    annuaire.trousAstreinte(sql, organisationId, HORIZON_ASTREINTE),
  ]);

  const points: Point[] = [];

  // ── Le tour d'astreinte, et ses trous (EF-202a) ───────────────────────────
  //
  // Première cause d'échec constatée en réel : la crise arrive pendant le
  // créneau que personne ne couvre. Le trou ne se voit pas dans la liste des
  // astreintes ; il se voit quand on appelle.
  const heuresATrou = Math.round(trous.reduce((s, t) => s + t.minutes, 0) / 60);
  points.push({
    cle: "astreinte_couverte",
    libelle: "Tour d'astreinte sans trou",
    ou: "disponibilites",
    etat: trous.length === 0 ? "bon"
      : heuresATrou >= HORIZON_ASTREINTE * 24 ? "critique"
        : heuresATrou > 24 ? "attention" : "bon",
    detail: trous.length === 0
      ? `Les ${HORIZON_ASTREINTE} prochains jours sont couverts sans interruption.`
      : heuresATrou >= HORIZON_ASTREINTE * 24
        ? `Aucune astreinte déclarée sur les ${HORIZON_ASTREINTE} prochains jours.`
        : `${trous.length} trou(s), ${heuresATrou} h au total sur `
          + `${HORIZON_ASTREINTE} jours — `
          + `le premier le ${trous[0]!.du.toLocaleDateString("fr-FR", {
            weekday: "long", day: "numeric", month: "long" })}.`,
    action: trous.length === 0 ? null
      : "Comblez ces créneaux : une crise qui arrive dans un trou d'astreinte "
        + "n'appelle personne.",
    poids: 3,
  });

  // ── Les trois tiers sans lesquels une cellule cyber est aveugle ──────────
  const manquants = CATEGORIES_ESSENTIELLES.filter(
    (c) => !tiers.some((t) => t.categorie === c));
+14 −0
Changes for apps/api/src/routes/preparation.ts: 14 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -223,6 +223,20 @@ export function enregistrerRoutesPreparation(app: FastifyInstance, env: Env, sql
    personnes: await annuaire.joignables(sql, org(req)),
  }));

  /**
   * EF-202a — quand personne n'est d'astreinte.
   *
   * Savoir qui est joignable maintenant ne dit pas si le tour d'astreinte a un
   * trou dimanche matin. Ce trou ne se voit pas en lisant la liste : il se voit
   * le dimanche matin.
   */
  app.get("/organisations/:id/preparation/astreinte/trous", lecture, async (req) => {
    const { jours } = z.object({
      jours: z.coerce.number().int().min(1).max(365).default(14),
    }).parse(req.query);
    return { jours, trous: await annuaire.trousAstreinte(sql, org(req), jours) };
  });

  app.post("/organisations/:id/preparation/indisponibilites", ecriture, async (req, reply) => {
    const c = z.object({
      personneId: z.string().uuid(),
+56 −0
Changes for apps/api/src/verification/preparation.ts: 56 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -228,6 +228,62 @@ export async function verifierPreparation(
  r.verifie("EF-209", `le score progresse quand on comble un manque (${etat.score} → ${apresAjout.score})`,
    apresAjout.score > etat.score);

  // ── Trous d'astreinte ────────────────────────────────────────────────────
  //
  // Savoir qui est d'astreinte ne dit pas quand personne ne l'est. Un tour
  // comportant un trou le dimanche matin ne se voit pas en lisant la liste des
  // astreintes : il se voit le dimanche matin.
  r.titre("Trous d'astreinte");

  const J = 86_400_000;
  const T0 = new Date("2026-03-02T09:00:00Z");
  const quand = (jours: number) => new Date(T0.getTime() + jours * J);

  const avantToutePeriode = await annuaire.trousAstreinte(sql, organisationId, 14, T0);
  r.verifie("EF-202a", "sans astreinte déclarée, tout l'horizon est un trou",
    avantToutePeriode.length === 1 && avantToutePeriode[0]!.minutes === 14 * 1440,
    `${avantToutePeriode.length} trou(s)`);

  // Deux astreintes qui se chevauchent, puis une jointive : aucune ne doit
  // ouvrir de trou entre elles.
  await annuaire.ajouterPeriode(sql, organisationId, directrice, "astreinte",
    quand(-0.5), quand(2), "Tour de semaine");
  await annuaire.ajouterPeriode(sql, organisationId, rssi, "astreinte",
    quand(1), quand(3));
  await annuaire.ajouterPeriode(sql, organisationId, directrice, "astreinte",
    quand(3), quand(4));
  await annuaire.ajouterPeriode(sql, organisationId, rssi, "astreinte",
    quand(6), quand(10));
  // Une absence dit que quelqu'un n'est pas joignable, pas que personne ne
  // l'est : elle ne doit rien couvrir.
  await annuaire.ajouterPeriode(sql, organisationId, directrice, "absence",
    quand(11), quand(13), "Congés");

  const trous = await annuaire.trousAstreinte(sql, organisationId, 14, T0);
  r.verifie("EF-202a", "les astreintes qui se chevauchent ou se touchent n'ouvrent pas de trou",
    trous.length === 2, `${trous.length} trou(s) au lieu de 2`);
  r.verifie("EF-202a", "le trou commence où l'astreinte s'arrête",
    trous[0]?.du.getTime() === quand(4).getTime()
      && trous[0]?.au.getTime() === quand(6).getTime());
  r.verifie("EF-202a", "sa durée est donnée, pour trier ce qui est grave",
    trous[0]?.minutes === 2880, String(trous[0]?.minutes));
  r.verifie("EF-202a", "une absence ne compte pas comme une couverture",
    trous[1]?.du.getTime() === quand(10).getTime()
      && trous[1]?.au.getTime() === quand(14).getTime());
  r.verifie("EF-202a", "une astreinte qui commence avant l'horizon le couvre depuis son début",
    trous.every((t) => t.du.getTime() >= T0.getTime()));

  const surTroisJours = await annuaire.trousAstreinte(sql, organisationId, 3, T0);
  r.verifie("EF-202a", "un horizon entièrement couvert ne signale aucun trou",
    surTroisJours.length === 0, `${surTroisJours.length} trou(s)`);

  const avecTrous = await indicateur.calculer(sql, organisationId);
  const pointAstreinte = avecTrous.points.find((p) => p.cle === "astreinte_couverte");
  r.verifie("EF-202a", "l'indicateur de préparation porte le point",
    Boolean(pointAstreinte));
  r.verifie("EF-202a", "et renvoie vers l'onglet où l'astreinte se déclare",
    pointAstreinte?.ou === "disponibilites", String(pointAstreinte?.ou));

    // ── Campagne de vérification ─────────────────────────────────────────────
  r.titre("Campagne de vérification");
  await sql`update personne set verifie_at = now() - interval '8 months'
+290 −0
Changes for apps/web/src/ecrans/preparation/Disponibilites.tsx: 290 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 } from "../../etat.js";

interface Personne { id: string; nom: string; role_crise: string | null; cellule: string | null }
interface Trou { du: string; au: string; minutes: number }
interface Indisponibilite {
  id: string;
  personne_id: string;
  genre: "astreinte" | "absence";
  du: string;
  au: string;
  motif: string | null;
}

/** « 3 j 4 h », « 90 min » — la durée d'un trou se lit, elle ne se calcule pas. */
const duree = (minutes: number) => {
  if (minutes < 60) return `${minutes} min`;
  const heures = Math.round(minutes / 60);
  if (heures < 48) return `${heures} h`;
  return `${Math.floor(heures / 24)} j ${heures % 24} h`;
};

/** Pour un champ « datetime-local », qui n'accepte pas l'ISO avec fuseau. */
const pourChamp = (d: Date) => {
  const p = (n: number) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
    + `T${p(d.getHours())}:${p(d.getMinutes())}`;
};

const enCours = (i: Indisponibilite) => {
  const maintenant = Date.now();
  return new Date(i.du).getTime() <= maintenant && new Date(i.au).getTime() >= maintenant;
};

/**
 * Astreintes et absences (EF-202).
 *
 * L'annuaire dit qui fait quoi ; il ne disait pas qui est joignable ce soir.
 * Les deux routes existaient — poser une période, la retirer — sans aucun
 * chemin depuis l'écran, et la liste « joignables » que le serveur calcule
 * n'était affichée nulle part.
 *
 * La distinction compte : une astreinte désigne quelqu'un qu'on appelle en
 * priorité, une absence quelqu'un qu'on n'appelle pas. Seule la seconde retire
 * de la liste des joignables — être d'astreinte, c'est précisément être
 * joignable.
 */
export function Disponibilites({ organisationId }: { organisationId: string }) {
  const client = useQueryClient();
  const [erreur, definirErreur] = useState<string | null>(null);
  const [ouvert, definirOuvert] = useState(false);

  const [personneId, definirPersonne] = useState("");
  const [genre, definirGenre] = useState<"astreinte" | "absence">("astreinte");
  const [du, definirDu] = useState(pourChamp(new Date()));
  const [au, definirAu] = useState(pourChamp(new Date(Date.now() + 86400000)));
  const [motif, definirMotif] = useState("");
  const [horizon, definirHorizon] = useState(14);

  const annuaire = useQuery({
    queryKey: ["annuaire", organisationId],
    queryFn: () => api.lire<{ personnes: Personne[]; periodes: Indisponibilite[] }>(
      `/api/organisations/${organisationId}/preparation/annuaire`),
  });
  const joignables = useQuery({
    queryKey: ["joignables", organisationId],
    queryFn: () => api.lire<{ personnes: Personne[] }>(
      `/api/organisations/${organisationId}/preparation/joignables`),
  });

  const trous = useQuery({
    queryKey: ["trous-astreinte", organisationId, horizon],
    queryFn: () => api.lire<{ jours: number; trous: Trou[] }>(
      `/api/organisations/${organisationId}/preparation/astreinte/trous?jours=${horizon}`),
  });

  const personnes = annuaire.data?.personnes ?? [];
  const periodes = annuaire.data?.periodes ?? [];
  const nom = (id: string) => personnes.find((p) => p.id === id)?.nom ?? "personne retirée";

  async function rafraichir() {
    await Promise.all([
      client.invalidateQueries({ queryKey: ["annuaire", organisationId] }),
      client.invalidateQueries({ queryKey: ["joignables", organisationId] }),
      client.invalidateQueries({ queryKey: ["trous-astreinte", organisationId] }),
    ]);
  }

  async function poser() {
    definirErreur(null);
    try {
      await api.poster(`/api/organisations/${organisationId}/preparation/indisponibilites`, {
        personneId, genre,
        du: new Date(du).toISOString(), au: new Date(au).toISOString(),
        ...(motif.trim() ? { motif: motif.trim() } : {}),
      });
      definirOuvert(false);
      definirMotif("");
      await rafraichir();
    } catch (e) {
      definirErreur(e instanceof ErreurApi ? e.message : "La période n’a pas été posée.");
    }
  }

  /**
   * Le geste qui comble. Le trou est déjà connu : le redemander en toutes
   * lettres à quelqu'un qui vient de le lire serait le seul travail inutile de
   * cet écran.
   */
  function combler(t: Trou) {
    definirGenre("astreinte");
    definirDu(pourChamp(new Date(t.du)));
    definirAu(pourChamp(new Date(t.au)));
    definirOuvert(true);
  }

  const valide = personneId !== "" && du !== "" && au !== ""
    && new Date(au).getTime() > new Date(du).getTime();

  return (
    <div>
      <p className="aide mesure" style={{ marginTop: 0 }}>
        Une astreinte désigne quelqu’un qu’on appelle en priorité ; une absence quelqu’un
        qu’on n’appelle pas. Seule l’absence retire de la liste des joignables — être
        d’astreinte, c’est précisément être joignable.
      </p>

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

      {/* ── Qui répondrait maintenant ─────────────────────────────────────── */}
      <div className="carte" style={{ marginBottom: 18 }}>
        <div className="ligne">
          <span className="etiq">Joignables en ce moment</span>
          <span className="discret pousse" style={{ fontSize: 13 }}>
            {joignables.data?.personnes.length ?? 0} sur {personnes.length}
          </span>
        </div>
        {joignables.isLoading && <p className="discret" style={{ fontSize: 13 }}>Calcul…</p>}
        {!joignables.isLoading && (joignables.data?.personnes.length ?? 0) === 0 && (
          <p style={{ fontSize: 13.5, marginTop: 6, color: "var(--crit)" }}>
            Personne n’est joignable. C’est le genre de constat qu’il vaut mieux faire
            maintenant que le jour d’une crise.
          </p>
        )}
        <div className="ligne" style={{ gap: 6, flexWrap: "wrap", marginTop: 8 }}>
          {joignables.data?.personnes.map((p) => (
            <span key={p.id} className="pastille ok sans-point">
              {p.nom}{p.role_crise ? ` · ${p.role_crise}` : ""}
            </span>
          ))}
        </div>
      </div>

      {/* ── Quand personne n'est d'astreinte ──────────────────────────────── */}
      <div className="carte" style={{ marginBottom: 18 }}>
        <div className="ligne">
          <span className="etiq">Trous d’astreinte</span>
          <select className="petit pousse" value={horizon}
            onChange={(e) => definirHorizon(Number(e.target.value))}>
            <option value={7}>7 jours</option>
            <option value={14}>14 jours</option>
            <option value={30}>30 jours</option>
          </select>
        </div>
        {trous.isLoading && <p className="discret" style={{ fontSize: 13 }}>Calcul…</p>}
        {!trous.isLoading && (trous.data?.trous.length ?? 0) === 0 && (
          <p style={{ fontSize: 13.5, marginTop: 6 }}>
            <span className="pastille ok sans-point">couvert</span>{" "}
            Les {horizon} prochains jours sont couverts sans interruption.
          </p>
        )}
        {(trous.data?.trous.length ?? 0) > 0 && (
          <>
            <p className="aide mesure" style={{ marginTop: 6 }}>
              Personne n’est d’astreinte sur ces créneaux. C’est le genre de trou qui ne se
              voit pas dans la liste ci-dessous : il se voit le jour où l’on appelle.
            </p>
            <table style={{ marginTop: 8 }}>
              <thead><tr><th>Du</th><th>Au</th><th>Durée</th><th /></tr></thead>
              <tbody>
                {trous.data!.trous.map((t) => (
                  <tr key={t.du}>
                    <td className="nombres">{dateHeure(t.du)}</td>
                    <td className="nombres">{dateHeure(t.au)}</td>
                    <td className="nombres" style={{ color: "var(--crit)" }}>
                      {duree(t.minutes)}
                    </td>
                    <td>
                      <button className="petit" onClick={() => combler(t)}>Combler</button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </>
        )}
      </div>

      {/* ── Poser une période ─────────────────────────────────────────────── */}
      {!ouvert ? (
        <button className="petit" style={{ marginBottom: 14 }}
          onClick={() => definirOuvert(true)}>Déclarer une période</button>
      ) : (
        <div className="carte" style={{ marginBottom: 14 }}>
          <span className="etiq">Nouvelle période</span>
          <div className="ligne" style={{ gap: 8, marginTop: 8, flexWrap: "wrap" }}>
            <select value={personneId} onChange={(e) => definirPersonne(e.target.value)}>
              <option value="">— choisir une personne —</option>
              {personnes.map((p) => (
                <option key={p.id} value={p.id}>
                  {p.nom}{p.cellule ? ` (${p.cellule})` : ""}
                </option>
              ))}
            </select>
            <select value={genre}
              onChange={(e) => definirGenre(e.target.value as "astreinte" | "absence")}>
              <option value="astreinte">Astreinte</option>
              <option value="absence">Absence</option>
            </select>
          </div>
          <div className="ligne" style={{ gap: 8, marginTop: 8, flexWrap: "wrap" }}>
            <input type="datetime-local" value={du}
              onChange={(e) => definirDu(e.target.value)} />
            <span className="discret">→</span>
            <input type="datetime-local" value={au}
              onChange={(e) => definirAu(e.target.value)} />
          </div>
          <input style={{ marginTop: 8, maxWidth: 420 }} value={motif} maxLength={200}
            placeholder="Motif (facultatif) — congés, formation, tour d’astreinte…"
            onChange={(e) => definirMotif(e.target.value)} />
          <div className="ligne" style={{ marginTop: 8 }}>
            <button className="accent petit" disabled={!valide}
              onClick={() => void poser()}>Poser la période</button>
            <button className="discret petit"
              onClick={() => definirOuvert(false)}>Annuler</button>
          </div>
        </div>
      )}

      {/* ── Les périodes déclarées ────────────────────────────────────────── */}
      {periodes.length === 0 ? (
        <div className="vide">
          <strong>Aucune période déclarée.</strong>
          Sans astreinte ni absence, la liste des joignables est celle de tout l’annuaire —
          ce qui est rarement vrai un dimanche soir.
        </div>
      ) : (
        <table>
          <thead>
            <tr><th>Personne</th><th>Genre</th><th>Du</th><th>Au</th><th>Motif</th><th /></tr>
          </thead>
          <tbody>
            {periodes.map((i) => (
              <tr key={i.id} style={enCours(i) ? undefined : { opacity: 0.6 }}>
                <td>{nom(i.personne_id)}</td>
                <td>
                  <span className={`pastille ${
                    i.genre === "astreinte" ? "ok sans-point" : "alerte sans-point"}`}>
                    {i.genre}
                  </span>
                  {enCours(i) && (
                    <span className="pastille accent sans-point" style={{ marginLeft: 6 }}>
                      en cours
                    </span>
                  )}
                </td>
                <td className="nombres">{dateHeure(i.du)}</td>
                <td className="nombres">{dateHeure(i.au)}</td>
                <td className="discret">{i.motif ?? "—"}</td>
                <td>
                  <button className="petit"
                    onClick={() => void api.supprimer(
                      `/api/organisations/${organisationId}`
                      + `/preparation/indisponibilites/${i.id}`)
                      .then(rafraichir)
                      .catch((e) => definirErreur(
                        e instanceof ErreurApi ? e.message : "Retrait impossible."))}>
                    Retirer
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  );
}