diff --git a/app/api/jury.py b/app/api/jury.py
index f31d1a78808f7b281e7c3badad4ee16f44de732d..383396e58458e45d6e148dcef58538086333279f 100644
--- a/app/api/jury.py
+++ b/app/api/jury.py
@@ -5,7 +5,7 @@
 ##############################################################################
 
 """
-  ScoDoc 9 API : jury
+  ScoDoc 9 API : jury   WIP
 """
 
 from flask import g, jsonify, request
@@ -17,8 +17,8 @@ from app.api import api_bp as bp, api_web_bp
 from app.decorators import scodoc, permission_required
 from app.scodoc.sco_exceptions import ScoException
 from app.scodoc.sco_utils import json_error
-from app.but import jury_but_recap
-from app.models import FormSemestre, FormSemestreInscription, Identite
+from app.but import jury_but_results
+from app.models import FormSemestre
 from app.scodoc.sco_permissions import Permission
 
 
@@ -33,7 +33,7 @@ def decisions_jury(formsemestre_id: int):
     formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
     if formsemestre.formation.is_apc():
         app.set_sco_dept(formsemestre.departement.acronym)
-        rows = jury_but_recap.get_jury_but_results(formsemestre)
+        rows = jury_but_results.get_jury_but_results(formsemestre)
         return jsonify(rows)
     else:
         raise ScoException("non implemente")
diff --git a/app/but/jury_but_results.py b/app/but/jury_but_results.py
new file mode 100644
index 0000000000000000000000000000000000000000..79aa14df3349b9050839cd0596292d0beffef844
--- /dev/null
+++ b/app/but/jury_but_results.py
@@ -0,0 +1,94 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Jury BUT et classiques: récupération des résults pour API
+"""
+
+import numpy as np
+
+from app.but import jury_but
+from app.models.etudiants import Identite
+from app.models.formsemestre import FormSemestre
+from app.scodoc import sco_pvjury
+
+
+def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:
+    """Liste des résultats jury BUT sous forme de dict, pour API"""
+    if formsemestre.formation.referentiel_competence is None:
+        # pas de ref. comp., donc pas de decisions de jury (ne lance pas d'exception)
+        return []
+    dpv = sco_pvjury.dict_pvjury(formsemestre.id)
+    rows = []
+    for etudid in formsemestre.etuds_inscriptions:
+        rows.append(_get_jury_but_etud_result(formsemestre, dpv, etudid))
+    return rows
+
+
+def _get_jury_but_etud_result(
+    formsemestre: FormSemestre, dpv: dict, etudid: int
+) -> dict:
+    """Résultats de jury d'un étudiant sur un semestre pair de BUT"""
+    etud: Identite = Identite.query.get(etudid)
+    dec_etud = dpv["decisions_dict"][etudid]
+    if formsemestre.formation.is_apc():
+        deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
+    else:
+        deca = None
+    row = {
+        "etudid": etud.id,
+        "code_nip": etud.code_nip,
+        "code_ine": etud.code_ine,
+        "is_apc": dpv["is_apc"],  # BUT ou classic ?
+        "etat": dec_etud["etat"],  # I ou D ou DEF
+        "nb_competences": deca.nb_competences if deca else 0,
+    }
+    # --- Les RCUEs
+    rcue_list = []
+    if deca:
+        for rcue in deca.rcues_annee:
+            dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
+            if dec_rcue is not None:  # None si l'UE n'est pas associée à un niveau
+                dec_ue1 = deca.decisions_ues[rcue.ue_1.id]
+                dec_ue2 = deca.decisions_ues[rcue.ue_2.id]
+                rcue_dict = {
+                    "ue_1": {
+                        "ue_id": rcue.ue_1.id,
+                        "moy": None
+                        if (dec_ue1.moy_ue is None or np.isnan(dec_ue1.moy_ue))
+                        else dec_ue1.moy_ue,
+                        "code": dec_ue1.code_valide,
+                    },
+                    "ue_2": {
+                        "ue_id": rcue.ue_2.id,
+                        "moy": None
+                        if (dec_ue2.moy_ue is None or np.isnan(dec_ue2.moy_ue))
+                        else dec_ue2.moy_ue,
+                        "code": dec_ue2.code_valide,
+                    },
+                    "moy": rcue.moy_rcue,
+                    "code": dec_rcue.code_valide,
+                }
+                rcue_list.append(rcue_dict)
+    row["rcues"] = rcue_list
+    # --- Les UEs
+    ue_list = []
+    if dec_etud["decisions_ue"]:
+        for ue_id, ue_dec in dec_etud["decisions_ue"].items():
+            ue_dict = {
+                "ue_id": ue_id,
+                "code": ue_dec["code"],
+                "ects": ue_dec["ects"],
+            }
+            ue_list.append(ue_dict)
+    row["ues"] = ue_list
+    # --- Le semestre (pour les formations classiques)
+    if dec_etud["decision_sem"]:
+        row["semestre"] = {"code": dec_etud["decision_sem"].get("code")}
+    else:
+        row["semestre"] = {}  # APC, ...
+    # --- Autorisations
+    row["autorisations"] = dec_etud["autorisations"]
+    return row
diff --git a/app/scodoc/sco_pvjury.py b/app/scodoc/sco_pvjury.py
index e323348dedc0650997e7cc9bb518174a23432d8a..416c985ba44089c4e1eebe0304cf677b2a4329a5 100644
--- a/app/scodoc/sco_pvjury.py
+++ b/app/scodoc/sco_pvjury.py
@@ -522,9 +522,9 @@ def formsemestre_pvjury(formsemestre_id, format="html", publish=True):
     formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
     is_apc = formsemestre.formation.is_apc()
     if format == "html" and is_apc and formsemestre.semestre_id % 2 == 0:
-        from app.but import jury_but_recap
+        from app.tables import jury_recap
 
-        return jury_but_recap.formsemestre_saisie_jury_but(
+        return jury_recap.formsemestre_saisie_jury_but(
             formsemestre, read_only=True, mode="recap"
         )
     # /XXX
diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py
index 7343ceb2e8ee45668e3efb673519f4c482452f64..4c1a634013fb8e9bd5f441e32520800230e9ee82 100644
--- a/app/scodoc/sco_recapcomplet.py
+++ b/app/scodoc/sco_recapcomplet.py
@@ -54,7 +54,7 @@ from app.scodoc import sco_formsemestre_status
 from app.scodoc import sco_permissions_check
 from app.scodoc import sco_preferences
 from app.tables.recap import TableRecap
-from app.but.jury_but_recap import TableJury
+from app.tables.jury_recap import TableJury
 
 
 def formsemestre_recapcomplet(
diff --git a/app/static/js/table_recap.js b/app/static/js/table_recap.js
index 9597f9dbb4b0b3f11e543d6c6423efe3366f72e7..0e2f9f109ef2f7b159240bdc78a1f410ceaa244d 100644
--- a/app/static/js/table_recap.js
+++ b/app/static/js/table_recap.js
@@ -1,6 +1,8 @@
 // Tableau recap notes
 $(function () {
     $(function () {
+        if ($('table.table_recap').length == 0) { return; }
+
         let hidden_colums = [
             "etud_codes", "identite_detail",
             "partition_aux", "partition_rangs", "admission",
diff --git a/app/but/jury_but_recap.py b/app/tables/jury_recap.py
similarity index 59%
rename from app/but/jury_but_recap.py
rename to app/tables/jury_recap.py
index 6ac6ba0a919d539fd25a6b7e34299caea4a4627b..ce96200262f85c928b454154835f541aca0c393c 100644
--- a/app/but/jury_but_recap.py
+++ b/app/tables/jury_recap.py
@@ -230,6 +230,19 @@ class RowJury(RowRecap):
             column_classes={"col_rcue"},
         )
 
+    # # --- Les ECTS validés
+    # ects_valides = 0.0
+    # if deca.res_impair:
+    #     ects_valides += deca.res_impair.get_etud_ects_valides(etudid)
+    # if deca.res_pair:
+    #     ects_valides += deca.res_pair.get_etud_ects_valides(etudid)
+    # row.add_cell(
+    #     "ects_annee",
+    #     "ECTS",
+    #     f"""{int(ects_valides)}""",
+    #     "col_code_annee",
+    # )
+
 
 def formsemestre_saisie_jury_but(
     formsemestre: FormSemestre,
@@ -316,7 +329,7 @@ def formsemestre_saisie_jury_but(
     <div class="table_recap">
     {table_html}
     </div>
-    
+
     <div class="table_jury_but_links">
     """
     )
@@ -375,263 +388,3 @@ def formsemestre_saisie_jury_but(
     """
     )
     return "\n".join(H)
-
-
-def build_table_jury_but_html(
-    filename: str, rows, titles, column_ids, selected_etudid: int = None, klass=""
-) -> str:
-    """assemble la table html"""
-    footer_rows = []  # inutilisé pour l'instant
-    H = [
-        f"""<div class="table_recap"><table class="table_recap apc jury table_jury_but {klass}"
-            data-filename="{filename}">"""
-    ]
-    # header
-    H.append(
-        f"""
-    <thead>
-        {scu.gen_row(column_ids, titles, "th")}
-    </thead>
-    """
-    )
-    # body
-    H.append("<tbody>")
-    for row in rows:
-        H.append(f"{scu.gen_row(column_ids, row, selected_etudid=selected_etudid)}\n")
-    H.append("</tbody>\n")
-    # footer
-    H.append("<tfoot>")
-    idx_last = len(footer_rows) - 1
-    for i, row in enumerate(footer_rows):
-        H.append(f'{scu.gen_row(column_ids, row, "th" if i == idx_last else "td")}\n')
-    H.append(
-        """
-    </tfoot>
-    </table>
-    </div>
-    """
-    )
-    return "".join(H)
-
-
-class RowCollector:
-    """Une ligne de la table"""
-
-    def __init__(
-        self,
-        cells: dict = None,
-        titles: dict = None,
-        convert_values=True,
-        column_classes: dict = None,
-    ):
-        self.titles = titles
-        self.row = cells or {}  # col_id : str
-        self.column_classes = column_classes  # col_id : str, css class
-        self.idx = 0
-        self.last_etud_cell_idx = 0
-        if convert_values:
-            self.fmt_note = scu.fmt_note
-        else:
-            self.fmt_note = lambda x: x
-
-    def __setitem__(self, key, value):
-        self.row[key] = value
-
-    def __getitem__(self, key):
-        return self.row[key]
-
-    def get_row_dict(self):
-        "La ligne, comme un dict"
-        # create empty cells
-        for col_id in self.titles:
-            if col_id not in self.row:
-                self.row[col_id] = ""
-                klass = self.column_classes.get(col_id)
-                if klass:
-                    self.row[f"_{col_id}_class"] = klass
-        return self.row
-
-    def add_cell(
-        self,
-        col_id: str,
-        title: str,
-        content: str,
-        classes: str = "",
-        idx: int = None,
-        column_class="",
-    ):
-        """Add a row to our table. classes is a list of css class names"""
-        self.idx = idx if idx is not None else self.idx
-        self.row[col_id] = content
-        if classes:
-            self.row[f"_{col_id}_class"] = classes + f" c{self.idx}"
-        if not col_id in self.titles:
-            self.titles[col_id] = title
-            self.titles[f"_{col_id}_col_order"] = self.idx
-            if classes:
-                self.titles[f"_{col_id}_class"] = classes
-        self.column_classes[col_id] = column_class
-        self.idx += 1
-
-
-def get_jury_but_table(  # XXX A SUPPRIMER apres avoir recupéré les stats
-    formsemestre2: FormSemestre, read_only: bool = False, mode="jury", with_links=True
-) -> tuple[list[dict], list[str], list[str], dict]:
-    """Construit la table des résultats annuels pour le jury BUT
-    => rows_dict, titles, column_ids, jury_stats
-    où jury_stats est un dict donnant des comptages sur le jury.
-    """
-
-    # /////// XXX /////// XXX //////
-    titles = {}  # column_id : title
-    jury_stats = {
-        "nb_etuds": len(formsemestre2.etuds_inscriptions),
-        "codes_annuels": collections.Counter(),
-    }
-    table = TableJury(res2, mode_jury=True)
-    for etudid in formsemestre2.etuds_inscriptions:
-        etud: Identite = Identite.query.get(etudid)
-        deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre2)
-        # XXX row = RowCollector(titles=titles, column_classes=column_classes)
-        row = RowJury(table, etudid)
-        table.add_row(row)
-        row.add_etud(etud)
-        # --- Nombre de niveaux
-        row.add_nb_rcues_cell(deca)
-        # --- Les RCUEs
-        for rcue in deca.rcues_annee:
-            dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
-            if dec_rcue is not None:  # None si l'UE n'est pas associée à un niveau
-                row.add_ue_cells(deca.decisions_ues[rcue.ue_1.id])
-                row.add_ue_cells(deca.decisions_ues[rcue.ue_2.id])
-                row.add_rcue_cells(dec_rcue)
-        # --- Les ECTS validés
-        ects_valides = 0.0
-        if deca.res_impair:
-            ects_valides += deca.res_impair.get_etud_ects_valides(etudid)
-        if deca.res_pair:
-            ects_valides += deca.res_pair.get_etud_ects_valides(etudid)
-        row.add_cell(
-            "ects_annee",
-            "ECTS",
-            f"""{int(ects_valides)}""",
-            "col_code_annee",
-        )
-        # --- Le code annuel existant
-        row.add_cell(
-            "code_annee",
-            "Année",
-            f"""{deca.code_valide or ''}""",
-            "col_code_annee",
-        )
-        if deca.code_valide:
-            jury_stats["codes_annuels"][deca.code_valide] += 1
-        # --- Le lien de saisie
-        if mode != "recap" and with_links:
-            row.add_cell(
-                "lien_saisie",
-                "",
-                f"""
-                    <a href="{url_for(
-                    'notes.formsemestre_validation_but',
-                    scodoc_dept=g.scodoc_dept,
-                    etudid=etud.id,
-                    formsemestre_id=formsemestre2.id,
-                )}" class="stdlink">
-                    {"voir" if read_only else ("modif." if deca.code_valide else "saisie")}
-                    décision</a>
-                """
-                if deca.inscription_etat == scu.INSCRIT
-                else deca.inscription_etat,
-                "col_lien_saisie_but",
-            )
-        rows.append(row)
-    rows_dict = [row.get_row_dict() for row in rows]
-    if len(rows_dict) > 0:
-        col_idx = res2.recap_add_partitions(
-            rows_dict, titles, col_idx=row.last_etud_cell_idx + 1
-        )
-        res2.recap_add_cursus(rows_dict, titles, col_idx=col_idx + 1)
-    column_ids = [title for title in titles if not title.startswith("_")]
-    column_ids.sort(key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000))
-    rows_dict.sort(key=lambda row: row["_nom_disp_order"])
-    return rows_dict, titles, column_ids, jury_stats
-
-
-def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:
-    """Liste des résultats jury BUT sous forme de dict, pour API"""
-    if formsemestre.formation.referentiel_competence is None:
-        # pas de ref. comp., donc pas de decisions de jury (ne lance pas d'exception)
-        return []
-    dpv = sco_pvjury.dict_pvjury(formsemestre.id)
-    rows = []
-    for etudid in formsemestre.etuds_inscriptions:
-        rows.append(get_jury_but_etud_result(formsemestre, dpv, etudid))
-    return rows
-
-
-def get_jury_but_etud_result(
-    formsemestre: FormSemestre, dpv: dict, etudid: int
-) -> dict:
-    """Résultats de jury d'un étudiant sur un semestre pair de BUT"""
-    etud: Identite = Identite.query.get(etudid)
-    dec_etud = dpv["decisions_dict"][etudid]
-    if formsemestre.formation.is_apc():
-        deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
-    else:
-        deca = None
-    row = {
-        "etudid": etud.id,
-        "code_nip": etud.code_nip,
-        "code_ine": etud.code_ine,
-        "is_apc": dpv["is_apc"],  # BUT ou classic ?
-        "etat": dec_etud["etat"],  # I ou D ou DEF
-        "nb_competences": deca.nb_competences if deca else 0,
-    }
-    # --- Les RCUEs
-    rcue_list = []
-    if deca:
-        for rcue in deca.rcues_annee:
-            dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
-            if dec_rcue is not None:  # None si l'UE n'est pas associée à un niveau
-                dec_ue1 = deca.decisions_ues[rcue.ue_1.id]
-                dec_ue2 = deca.decisions_ues[rcue.ue_2.id]
-                rcue_dict = {
-                    "ue_1": {
-                        "ue_id": rcue.ue_1.id,
-                        "moy": None
-                        if (dec_ue1.moy_ue is None or np.isnan(dec_ue1.moy_ue))
-                        else dec_ue1.moy_ue,
-                        "code": dec_ue1.code_valide,
-                    },
-                    "ue_2": {
-                        "ue_id": rcue.ue_2.id,
-                        "moy": None
-                        if (dec_ue2.moy_ue is None or np.isnan(dec_ue2.moy_ue))
-                        else dec_ue2.moy_ue,
-                        "code": dec_ue2.code_valide,
-                    },
-                    "moy": rcue.moy_rcue,
-                    "code": dec_rcue.code_valide,
-                }
-                rcue_list.append(rcue_dict)
-    row["rcues"] = rcue_list
-    # --- Les UEs
-    ue_list = []
-    if dec_etud["decisions_ue"]:
-        for ue_id, ue_dec in dec_etud["decisions_ue"].items():
-            ue_dict = {
-                "ue_id": ue_id,
-                "code": ue_dec["code"],
-                "ects": ue_dec["ects"],
-            }
-            ue_list.append(ue_dict)
-    row["ues"] = ue_list
-    # --- Le semestre (pour les formations classiques)
-    if dec_etud["decision_sem"]:
-        row["semestre"] = {"code": dec_etud["decision_sem"].get("code")}
-    else:
-        row["semestre"] = {}  # APC, ...
-    # --- Autorisations
-    row["autorisations"] = dec_etud["autorisations"]
-    return row
diff --git a/app/tables/recap.py b/app/tables/recap.py
index 51e3ba429cb4996f5b572f1ba02caf294a9e08be..7431523c3f4aaf98ab4fc3fcee94773ccc2bea74 100644
--- a/app/tables/recap.py
+++ b/app/tables/recap.py
@@ -86,22 +86,23 @@ class TableRecap(tb.Table):
             row.add_etud_cols()
             row.add_moyennes_cols(ues_sans_bonus)
 
-        self.add_partitions()
-        self.add_cursus()
-        self.add_admissions()
-
-        # tri par rang croissant
-        if not res.formsemestre.block_moyenne_generale:
-            self.sort_rows(key=lambda row: row.rang_order)
-        else:
-            self.sort_rows(key=lambda row: row.nb_ues_validables, reverse=True)
+        if res.formsemestre.etuds_inscriptions:  # table non vide
+            self.add_partitions()
+            self.add_cursus()
+            self.add_admissions()
+
+            # tri par rang croissant
+            if not res.formsemestre.block_moyenne_generale:
+                self.sort_rows(key=lambda row: row.rang_order)
+            else:
+                self.sort_rows(key=lambda row: row.nb_ues_validables, reverse=True)
 
-        # Lignes footer (min, max, ects, apo, ...)
-        self.add_bottom_rows(ues_sans_bonus)
+            # Lignes footer (min, max, ects, apo, ...)
+            self.add_bottom_rows(ues_sans_bonus)
 
-        # Evaluations:
-        if include_evaluations:
-            self.add_evaluations()
+            # Evaluations:
+            if include_evaluations:
+                self.add_evaluations()
 
         if finalize:
             self.finalize()
diff --git a/app/views/notes.py b/app/views/notes.py
index 4ae34dd7688ac903041a4865857d5ef4aa45ab54..ce2f95b634d0161eb166e4b6906f6394745852e7 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -42,7 +42,7 @@ from flask_login import current_user
 from app import db
 from app import models
 from app.auth.models import User
-from app.but import apc_edit_ue, jury_but_recap
+from app.but import apc_edit_ue
 from app.but import jury_but, jury_but_validation_auto
 from app.but.forms import jury_but_forms
 from app.but import jury_but_pv
@@ -60,6 +60,7 @@ from app.models.moduleimpls import ModuleImpl
 from app.models.modules import Module
 from app.models.ues import DispenseUE, UniteEns
 from app.scodoc.sco_exceptions import ScoFormationConflict
+from app.tables import jury_recap
 from app.views import notes_bp as bp
 
 from app.decorators import (
@@ -2826,7 +2827,7 @@ def formsemestre_jury_but_recap(formsemestre_id: int, selected_etudid: int = Non
         raise ScoValueError(
             "formsemestre_jury_but_recap: réservé aux semestres pairs de BUT"
         )
-    return jury_but_recap.formsemestre_saisie_jury_but(
+    return jury_recap.formsemestre_saisie_jury_but(
         formsemestre, read_only=read_only, selected_etudid=selected_etudid, mode="recap"
     )