diff --git a/app/but/jury_but_recap.py b/app/but/jury_but_recap.py
index 4d8e5922b5791a426a49b480a80776a03b16f3a1..12225fd8e31db53e8a71778d5e3406581a90e6c8 100644
--- a/app/but/jury_but_recap.py
+++ b/app/but/jury_but_recap.py
@@ -4,7 +4,7 @@
 # See LICENSE
 ##############################################################################
 
-"""Jury BUT: table recap annuelle et liens saisie
+"""Jury BUT et classiques: table recap annuelle et liens saisie
 """
 
 import collections
@@ -19,15 +19,15 @@ from app.but.jury_but import (
     DecisionsProposeesUE,
 )
 from app.comp.res_but import ResultatsSemestreBUT
+from app.comp.res_compat import NotesTableCompat
 from app.comp import res_sem
+from app.models import UniteEns
 from app.models.etudiants import Identite
 from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
 from app.models.formsemestre import FormSemestre
 from app.scodoc import html_sco_header
 from app.scodoc.sco_codes_parcours import (
     BUT_BARRE_RCUE,
-    BUT_BARRE_UE,
-    BUT_BARRE_UE8,
     BUT_RCUE_SUFFISANT,
 )
 from app.scodoc import sco_formsemestre_status
@@ -39,16 +39,97 @@ from app.tables.recap import RowRecap, TableRecap
 class TableJury(TableRecap):
     """Cette table recap reprend les colonnes du tableau recap, sauf les évaluations,
     et ajoute:
-    - les RCUEs
-    - le lien de saisie ou modif de la décision de jury
+    Pour le BUT:
+        - les RCUEs (moyenne et code décision)
+    Pour toutes les formations:
+        - les codes de décisions jury sur les UEs
+        - le lien de saisie ou modif de la décision de jury
     """
 
+    def __init__(self, *args, row_class: str = None, read_only=True, **kwargs):
+        super().__init__(
+            *args, row_class=row_class or RowJury, finalize=False, **kwargs
+        )
+        # redéclare pour VSCode
+        self.rows: list["RowJury"] = self.rows
+        self.res: NotesTableCompat = self.res
+        self.read_only = read_only
+        # Stats jury: fréquence de chaque code enregistré
+        self.freq_codes_annuels = collections.Counter()
+        # Ajout colonnes spécifiques à la table jury:
+
+        if self.res.is_apc:
+            self.add_rcues()
+        self.add_jury()
+        # Termine la table
+        self.finalize()
+        self.add_groups_header()
+
+    def add_rcues(self):
+        """Ajoute les colonnes indiquant le nb de RCUEs et chaque RCUE
+        pour tous les étudiants de la table.
+        La table contient des rows avec la clé etudid.
+        Les colonnes ont la classe css "rcue".
+        """
+        self.insert_group("rcue", before="col_ues_validables")
+        for row in self.rows:
+            etud: Identite = row.etud
+            deca = row.deca
+            if deca.code_valide:
+                self.freq_codes_annuels[deca.code_valide] += 1
+            row.add_nb_rcues_cell()
+            # --- 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_rcue_cols(dec_rcue)
+
+    def add_jury(self):
+        """Ajoute la colonne code jury et le lien.
+        Le code jury est celui du semestre: cette colonne n'est montrée
+        que pour les formations classiques, ce code n'est pas utilisé en BUT.
+        """
+        res = self.res
+        if res.validations:
+            for row in self.rows:
+                etud = row.etud
+                if not res.is_apc:
+                    # formations classiques: code semestre
+                    dec_sem = res.validations.decisions_jury.get(etud.id)
+                    jury_code_sem = dec_sem["code"] if dec_sem else ""
+                    row.add_cell(
+                        "jury_code_sem", "Jury", jury_code_sem, group="jury_code_sem"
+                    )
+                row.add_cell(
+                    "jury_link",
+                    "",
+                    f"""{("modifier" if res.validations.has_decision(etud) else "saisir")
+                        if res.formsemestre.etat else "voir"} décisions""",
+                    group="col_jury_link",
+                    target=url_for(
+                        "notes.formsemestre_validation_etud_form",
+                        scodoc_dept=g.scodoc_dept,
+                        formsemestre_id=res.formsemestre.id,
+                        etudid=etud.id,
+                    ),
+                    target_attrs={"class": "stdlink"},
+                )
+
 
 class RowJury(RowRecap):
     "Ligne de la table saisie jury"
 
-    def add_nb_rcues_cell(self, deca: DecisionsProposeesAnnee):
+    def __init__(self, table: TableJury, etud: Identite, *args, **kwargs):
+        self.table: TableJury = table
+        super().__init__(table, etud, *args, **kwargs)
+        # Conserve le deca de cet étudiant:
+        self.deca = jury_but.DecisionsProposeesAnnee(
+            self.etud, self.table.res.formsemestre
+        )
+
+    def add_nb_rcues_cell(self):
         "cell avec nb niveaux validables / total"
+        deca = self.deca
         classes = ["col_rcue", "col_rcues_validables"]
         if deca.nb_rcues_under_8 > 0:
             classes.append("moy_ue_warning")
@@ -78,42 +159,71 @@ class RowJury(RowRecap):
             "RCUEs",
             f"""{deca.nb_validables}/{deca.nb_competences}"""
             + ((" " + scu.EMO_WARNING) if deca.nb_rcues_under_8 > 0 else ""),
-            group="rcues_validables",
+            raw_content=f"""{deca.nb_validables}/{deca.nb_competences}""",
+            group="rcue",
             classes=classes,
             data={"order": order},
         )
 
-    def add_ue_cells(self, dec_ue: DecisionsProposeesUE):
-        "cell de moyenne d'UE"
-        col_id = f"moy_ue_{dec_ue.ue.id}"
+    def add_ue_cols(self, ue: UniteEns, ue_status: dict):
+        "Ajoute 2 colonnes: moyenne d'UE et code jury"
+        super().add_ue_cols(ue, ue_status)  # table recap standard
+        dues = self.table.res.get_etud_decision_ues(self.etud.id)
+        if not dues:
+            return
+        due = dues.get(ue.id)
+        if not due:
+            return
+        col_id = f"moy_ue_{ue.id}_code"
+        self.add_cell(
+            col_id,
+            "",  # titre vide
+            due["code"],
+            raw_content=due["code"],
+            group="col_ue",
+            classes=["recorded_code"],
+            column_classes={"col_jury", "col_ue_code"},
+            target_attrs={
+                "title": f"""enregistrée le {due['event_date']}, {
+                (due["ects"] or 0):.3g} ECTS."""
+            },
+        )
+
+    def add_rcue_cols(self, dec_rcue: DecisionsProposeesRCUE):
+        "2 cells: moyenne du RCUE, code enregistré"
+        self.table.group_titles["rcue"] = "RCUEs en cours"
+        rcue = dec_rcue.rcue
+        col_id = f"moy_rcue_{rcue.ue_1.niveau_competence_id}"  # le niveau_id
         note_class = ""
-        val = dec_ue.moy_ue
+        val = rcue.moy_rcue
         if isinstance(val, float):
-            if val < BUT_BARRE_UE:
-                note_class = "moy_inf"
-            elif val >= BUT_BARRE_UE:
+            if val < BUT_BARRE_RCUE:
+                note_class = "moy_ue_inf"
+            elif val >= BUT_BARRE_RCUE:
                 note_class = "moy_ue_valid"
-            if val < BUT_BARRE_UE8:
+            if val < BUT_RCUE_SUFFISANT:
                 note_class = "moy_ue_warning"  # notes très basses
         self.add_cell(
             col_id,
-            dec_ue.ue.acronyme,
-            self.fmt_note(val),
-            group="col_ue",
-            classes="col_ue" + note_class,
-            column_class="col_ue",
+            f"<div>{rcue.ue_1.acronyme}</div><div>{rcue.ue_2.acronyme}</div>",
+            self.table.fmt_note(val),
+            raw_content=val,
+            group="rcue",
+            classes=[note_class],
+            column_classes={"col_rcue"},
         )
         self.add_cell(
             col_id + "_code",
-            dec_ue.ue.acronyme,
-            dec_ue.code_valide or "",
-            classes="col_ue_code recorded_code",
-            column_class="col_ue",
+            f"<div>{rcue.ue_1.acronyme}</div><div>{rcue.ue_2.acronyme}</div>",
+            dec_rcue.code_valide or "",
+            group="rcue",
+            classes=["col_rcue_code", "recorded_code"],
+            column_classes={"col_rcue"},
         )
 
 
 def formsemestre_saisie_jury_but(
-    formsemestre2: FormSemestre,
+    formsemestre: FormSemestre,
     read_only: bool = False,
     selected_etudid: int = None,
     mode="jury",
@@ -125,7 +235,6 @@ def formsemestre_saisie_jury_but(
 
     Si mode == "recap", table recap des codes, sans liens de saisie.
     """
-    # Quick & Dirty
     # pour chaque etud de res2 trié
     #   S1: UE1, ..., UEn
     #   S2: UE1, ..., UEn
@@ -137,32 +246,40 @@ def formsemestre_saisie_jury_but(
     #      Pour le 1er etud, faire un check_ues_ready_jury(self) -> page d'erreur
     #   -> rcue .ue_1, .ue_2 -> stroe moy ues, rcue.moy_rcue,  etc
 
-    if formsemestre2.formation.referentiel_competence is None:
-        raise ScoNoReferentielCompetences(formation=formsemestre2.formation)
+    if formsemestre.formation.referentiel_competence is None:
+        raise ScoNoReferentielCompetences(formation=formsemestre.formation)
 
-    rows, titles, column_ids, jury_stats = get_jury_but_table(
-        formsemestre2, read_only=read_only, mode=mode
+    res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
+    table = TableJury(
+        res,
+        convert_values=True,
+        mode_jury=True,
+        read_only=read_only,
+        classes=[
+            "table_jury_but_bilan" if mode == "recap" else "",
+            "table_recap",
+            "apc",
+            "jury table_jury_but",
+        ],
+        selected_row_id=selected_etudid,
     )
-    if not rows:
+    if table.is_empty():
         return (
             '<div class="table_recap"><div class="message">aucun étudiant !</div></div>'
         )
-    filename = scu.sanitize_filename(
-        f"""jury-but-{formsemestre2.titre_num()}-{time.strftime("%Y-%m-%d")}"""
-    )
-    klass = "table_jury_but_bilan" if mode == "recap" else ""
-    table_html = build_table_jury_but_html(
-        filename, rows, titles, column_ids, selected_etudid=selected_etudid, klass=klass
+    table.data["filename"] = scu.sanitize_filename(
+        f"""jury-but-{formsemestre.titre_num()}-{time.strftime("%Y-%m-%d")}"""
     )
+    table_html = table.html()
     H = [
         html_sco_header.sco_header(
-            page_title=f"{formsemestre2.sem_modalite()}: jury BUT annuel",
+            page_title=f"{formsemestre.sem_modalite()}: jury BUT",
             no_side_bar=True,
             init_qtip=True,
             javascripts=["js/etud_info.js", "js/table_recap.js"],
         ),
         sco_formsemestre_status.formsemestre_status_head(
-            formsemestre_id=formsemestre2.id
+            formsemestre_id=formsemestre.id
         ),
     ]
     if mode == "recap":
@@ -173,12 +290,12 @@ def formsemestre_saisie_jury_but(
                     <ul>
                     <li><a href="{url_for(
                         "notes.pvjury_table_but",
-                        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
+                        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
                     }" class="stdlink">Tableau PV de jury</a>
                     </li>
                     <li><a href="{url_for(
                         "notes.formsemestre_lettres_individuelles",
-                        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
+                        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
                     }" class="stdlink">Courriers individuels (classeur pdf)</a>
                     </li>
                 </div>
@@ -187,9 +304,10 @@ def formsemestre_saisie_jury_but(
         )
     H.append(
         f"""
-
+    <div class="table_recap">
     {table_html}
-
+    </div>
+    
     <div class="table_jury_but_links">
     """
     )
@@ -199,7 +317,7 @@ def formsemestre_saisie_jury_but(
             f"""
     <p><a class="stdlink" href="{url_for(
         "notes.formsemestre_saisie_jury",
-        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
+        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
     }">Saisie des décisions du jury</a>
     </p>"""
         )
@@ -208,12 +326,12 @@ def formsemestre_saisie_jury_but(
             f"""
     <p><a class="stdlink" href="{url_for(
         "notes.formsemestre_validation_auto_but",
-        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
+        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
     }">Calcul automatique des décisions du jury</a>
     </p>
     <p><a class="stdlink" href="{url_for(
         "notes.formsemestre_jury_but_recap",
-        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
+        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
     }">Tableau récapitulatif des décisions du jury</a>
     </p>
     """
@@ -224,19 +342,19 @@ def formsemestre_saisie_jury_but(
 
     <div class="jury_stats">
         <div>Nb d'étudiants avec décision annuelle:
-            {sum(jury_stats["codes_annuels"].values())} / {jury_stats["nb_etuds"]}
+            {sum(table.freq_codes_annuels.values())} / {len(table)}
         </div>
         <div><b>Codes annuels octroyés:</b></div>
         <table class="jury_stats_codes">
     """
     )
-    for code in sorted(jury_stats["codes_annuels"].keys()):
+    for code in sorted(table.freq_codes_annuels.keys()):
         H.append(
             f"""<tr>
             <td>{code}</td>
-            <td style="text-align:right">{jury_stats["codes_annuels"][code]}</td>
+            <td style="text-align:right">{table.freq_codes_annuels[code]}</td>
             <td style="text-align:right">{
-                (100*jury_stats["codes_annuels"][code] / jury_stats["nb_etuds"]):2.1f}%
+                (100*table.freq_codes_annuels[code] / len(table)):2.1f}%
             </td>
             </tr>"""
         )
@@ -346,43 +464,16 @@ class RowCollector:
         self.column_classes[col_id] = column_class
         self.idx += 1
 
-    def add_rcue_cells(self, dec_rcue: DecisionsProposeesRCUE):
-        "2 cells: moyenne du RCUE, code enregistré"
-        rcue = dec_rcue.rcue
-        col_id = f"moy_rcue_{rcue.ue_1.niveau_competence_id}"  # le niveau_id
-        note_class = ""
-        val = rcue.moy_rcue
-        if isinstance(val, float):
-            if val < BUT_BARRE_RCUE:
-                note_class = " moy_ue_inf"
-            elif val >= BUT_BARRE_RCUE:
-                note_class = " moy_ue_valid"
-            if val < BUT_RCUE_SUFFISANT:
-                note_class = " moy_ue_warning"  # notes très basses
-        self.add_cell(
-            col_id,
-            f"<div>{rcue.ue_1.acronyme}</div><div>{rcue.ue_2.acronyme}</div>",
-            self.fmt_note(val),
-            "col_rcue" + note_class,
-            column_class="col_rcue",
-        )
-        self.add_cell(
-            col_id + "_code",
-            f"<div>{rcue.ue_1.acronyme}</div><div>{rcue.ue_2.acronyme}</div>",
-            dec_rcue.code_valide or "",
-            "col_rcue_code recorded_code",
-            column_class="col_rcue",
-        )
-
 
-def get_jury_but_table(
+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.
     """
-    res2: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre2)
+
+    # /////// XXX /////// XXX //////
     titles = {}  # column_id : title
     jury_stats = {
         "nb_etuds": len(formsemestre2.etuds_inscriptions),
diff --git a/app/comp/jury.py b/app/comp/jury.py
index e41af510786eab31111ec5dae33df5a55c61979c..226d5a4f014d4d89a187cf3f59aa9a93e8fd56a5 100644
--- a/app/comp/jury.py
+++ b/app/comp/jury.py
@@ -9,7 +9,7 @@
 import pandas as pd
 
 from app import db
-from app.models import FormSemestre, ScolarFormSemestreValidation, UniteEns
+from app.models import FormSemestre, Identite, ScolarFormSemestreValidation, UniteEns
 from app.comp.res_cache import ResultatsCache
 from app.scodoc import sco_cache
 from app.scodoc import sco_codes_parcours
@@ -53,7 +53,7 @@ class ValidationsSemestre(ResultatsCache):
         self.comp_decisions_jury()
 
     def comp_decisions_jury(self):
-        """Cherche les decisions du jury pour le semestre (pas les UE).
+        """Cherche les decisions du jury pour le semestre (pas les RCUE).
         Calcule les attributs:
         decisions_jury = { etudid : { 'code' : None|ATT|..., 'assidu' : 0|1 }}
         decision_jury_ues={ etudid :
@@ -102,6 +102,12 @@ class ValidationsSemestre(ResultatsCache):
 
         self.decisions_jury_ues = decisions_jury_ues
 
+    def has_decision(self, etud: Identite) -> bool:
+        """Vrai si etud a au moins une décision enregistrée depuis
+        ce semestre (quelle qu'elle soit)
+        """
+        return (etud.id in self.decisions_jury_ues) or (etud.id in self.decisions_jury)
+
 
 def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame:
     """Liste des UE capitalisées (ADM) utilisables dans ce formsemestre
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index 58fd7307bbcca57c38d9a87fabfb173db41b5119..c5414db8f2be56dcf38e6f62ccc9e5c6584929d1 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -4020,6 +4020,11 @@ table.table_recap tbody td:hover {
   text-decoration: dashed underline;
 }
 
+table.table_recap tfoot tr td {
+  padding-left: 10px;
+  padding-right: 10px;
+}
+
 /* col moy gen en gras seulement pour les form. classiques */
 table.table_recap.classic td.col_moy_gen {
   font-weight: bold;
@@ -4039,10 +4044,10 @@ table.table_recap .cursus {
   white-space: nowrap;
 }
 
-table.table_recap .col_ue,
-table.table_recap .col_ue_code,
-table.table_recap .col_moy_gen,
-table.table_recap .group {
+table.table_recap td.col_ue,
+table.table_recap td.col_ue_code,
+table.table_recap td.col_moy_gen,
+table.table_recap td.group {
   border-left: 1px solid blue;
 }
 
@@ -4050,7 +4055,7 @@ table.table_recap .col_ue {
   font-weight: bold;
 }
 
-table.table_recap.jury .col_ue {
+table.table_recap.jury td.col_ue {
   font-weight: normal;
 }
 
@@ -4059,29 +4064,53 @@ table.table_recap.jury .col_rcue_code {
   font-weight: bold;
 }
 
-table.table_recap.jury tr.even td.col_rcue,
-table.table_recap.jury tr.even td.col_rcue_code {
-  background-color: #b0d4f8;
-}
-
 table.table_recap.jury tr.odd td.col_rcue,
 table.table_recap.jury tr.odd td.col_rcue_code {
-  background-color: #abcdef;
+  background-color: #e0eeff;
 }
 
-table.table_recap.jury tr.odd td.col_rcues_validables {
-  background-color: #e1d3c5 !important;
+/* table.table_recap.jury tr.even td.col_rcue,
+table.table_recap.jury tr.even td.col_rcue_code {
+  background-color: #e5f2ff;
+} */
+
+/* table.table_recap.jury tr.odd td.col_rcues_validables {
+  background-color: #d5eaff !important;
 }
 
 table.table_recap.jury tr.even td.col_rcues_validables {
-  background-color: #fcebda !important;
-}
+  background-color: #e5f2ff !important;
+} */
+
+
 
 table.table_recap .group {
   border-left: 1px dashed rgb(160, 160, 160);
   white-space: nowrap;
 }
 
+table.table_recap thead th {
+  border-left: 1px solid rgb(200, 200, 200);
+  border-right: 1px solid rgb(200, 200, 200);
+}
+
+table.table_recap tr.groups_header th {
+  border-bottom: none;
+  font-weight: normal;
+  font-style: italic;
+  text-align: center;
+  padding-bottom: 8px;
+}
+
+table.table_recap thead tr.titles th {
+  padding-top: 0px;
+}
+
+table.table_recap tr td.col_ue_code,
+table.table_recap tr th.col_ue_code {
+  border-left: none;
+}
+
 table.table_recap .admission {
   white-space: nowrap;
   color: rgb(6, 73, 6);
@@ -4250,7 +4279,7 @@ table.table_recap tr.apo td {
   max-width: 1px;
 }
 
-table.table_recap tr.type_col {
+table.table_recap tr.type_col td {
   font-size: 40%;
   font-family: monospace;
   overflow-wrap: anywhere;
diff --git a/app/static/js/table_recap.js b/app/static/js/table_recap.js
index 4b45961df8b1cabddd54e863ba95de5076fab640..9597f9dbb4b0b3f11e543d6c6423efe3366f72e7 100644
--- a/app/static/js/table_recap.js
+++ b/app/static/js/table_recap.js
@@ -10,8 +10,6 @@ $(function () {
         if (mode_jury_but_bilan) {
             // table bilan décisions: cache les notes
             hidden_colums = hidden_colums.concat(["col_ue", "col_rcue", "col_lien_saisie_but"]);
-        } else {
-            hidden_colums = hidden_colums.concat(["recorded_code"]);
         }
         // Etat (tri des colonnes) de la table:
 
diff --git a/app/tables/recap.py b/app/tables/recap.py
index 800adda1bb357fbad2dd8c1de4d2646622ff45e5..e405e987322ffb7ee7362fb0cfe54d3e36e3be53 100644
--- a/app/tables/recap.py
+++ b/app/tables/recap.py
@@ -54,8 +54,12 @@ class TableRecap(tb.Table):
         convert_values=False,
         include_evaluations=False,
         mode_jury=False,
+        row_class=None,
+        finalize=True,
+        **kwargs,
     ):
-        super().__init__()
+        self.rows: list["RowRecap"] = []  # juste pour que VSCode nous aide sur .rows
+        super().__init__(row_class=row_class or RowRecap, **kwargs)
         self.res = res
         self.include_evaluations = include_evaluations
         self.mode_jury = mode_jury
@@ -72,13 +76,12 @@ class TableRecap(tb.Table):
         # couples (modimpl, ue) effectivement présents dans la table:
         self.modimpl_ue_ids = set()
 
-        etuds_inscriptions = res.formsemestre.etuds_inscriptions
         ues = res.formsemestre.query_ues(with_sport=True)  # avec bonus
         ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
 
-        for etudid in etuds_inscriptions:
+        for etudid in res.formsemestre.etuds_inscriptions:
             etud = Identite.query.get(etudid)
-            row = RowRecap(self, etud)
+            row = self.row_class(self, etud)
             self.add_row(row)
             row.add_etud_cols()
             row.add_moyennes_cols(ues_sans_bonus)
@@ -100,11 +103,14 @@ class TableRecap(tb.Table):
         if include_evaluations:
             self.add_evaluations()
 
+    def finalize(self):
+        """Termine la table: ajoute ligne avec les types,
+        et ajoute classe sur les colonnes vides"""
         self.mark_empty_cols()
         self.add_type_row()
 
     def mark_empty_cols(self):
-        """Ajoute style "col_empty" aux colonnes de modules vides"""
+        """Ajoute classe "col_empty" aux colonnes de modules vides"""
         # identifie les col. vides par la classe sur leur moyenne
         row_moy = self.get_row_by_id("moy")
         for col_id in self.column_ids:
@@ -275,11 +281,12 @@ class TableRecap(tb.Table):
 
     def add_partitions(self):
         """Ajoute les colonnes indiquant les groupes
+        pour tous les étudiants de la table.
         La table contient des rows avec la clé etudid.
-
-        Les colonnes ont la classe css "partition"
+        Les colonnes ont la classe css "partition".
         """
         self.insert_group("partition", after="identite_court")
+        self.group_titles["partition"] = "Partitions"
         partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups(
             self.res.formsemestre.id
         )
@@ -404,6 +411,7 @@ class TableRecap(tb.Table):
                         scodoc_dept=g.scodoc_dept,
                         evaluation_id=e.id,
                     ),
+                    target_attrs={"class": "stdlink"},
                 )
 
     def add_admissions(self):
@@ -478,6 +486,13 @@ class RowRecap(tb.Row):
         """Ajoute colonnes étudiant: codes, noms"""
         res = self.table.res
         etud = self.etud
+        self.table.group_titles.update(
+            {
+                "etud_codes": "Codes",
+                "identite_detail": "",
+                "identite_court": "",
+            }
+        )
         # --- Codes (seront cachés, mais exportés en excel)
         self.add_cell("etudid", "etudid", etud.id, "etud_codes")
         self.add_cell(
@@ -614,35 +629,11 @@ class RowRecap(tb.Row):
             data={"order": self.nb_ues_validables},  #  tri
         )
 
-        if table.mode_jury and res.validations:
-            if res.is_apc:
-                # formations BUT: pas de code semestre, concatene ceux des UEs
-                dec_ues = res.validations.decisions_jury_ues.get(etud.id)
-                if dec_ues:
-                    jury_code_sem = ",".join(
-                        [dec_ues[ue_id].get("code", "") for ue_id in dec_ues]
-                    )
-                else:
-                    jury_code_sem = ""
-            else:
-                # formations classiques: code semestre
-                dec_sem = res.validations.decisions_jury.get(etud.id)
-                jury_code_sem = dec_sem["code"] if dec_sem else ""
-            self.add_cell("jury_code_sem", "Jury", jury_code_sem, "jury_code_sem")
-            self.add_cell(
-                "jury_link",
-                "",
-                f"""<a href="{url_for('notes.formsemestre_validation_etud_form',
-                scodoc_dept=g.scodoc_dept, formsemestre_id=res.formsemestre.id, etudid=etud.id
-                )
-                }">{("saisir" if not jury_code_sem else "modifier")
-                    if res.formsemestre.etat else "voir"} décisions</a>""",
-                "col_jury_link",
-            )
-
     def add_ue_cols(self, ue: UniteEns, ue_status: dict):
         "Ajoute résultat UE au row (colonne col_ue)"
+        # sous-classé par JuryRow pour ajouter les codes
         table = self.table
+        table.group_titles["col_ue"] = "UEs du semestre"
         col_id = f"moy_ue_{ue.id}"
         val = ue_status["moy"]
         note_class = ""
@@ -659,9 +650,9 @@ class RowRecap(tb.Row):
             col_id,
             ue.acronyme,
             table.fmt_note(val),
-            group=f"col_ue_{ue.id}",
+            group="col_ue",
             classes=[note_class],
-            column_classes={"col_ue", "col_moy_ue"},
+            column_classes={f"col_ue_{ue.id}", "col_moy_ue"},
         )
         table.foot_title_row.cells[col_id].target_attrs[
             "title"
diff --git a/app/tables/table_builder.py b/app/tables/table_builder.py
index f500fb0f14c84bad52519cdd538801d199425876..19e14ea17c0655fa937fd8d9410b391bff777d97 100644
--- a/app/tables/table_builder.py
+++ b/app/tables/table_builder.py
@@ -73,8 +73,10 @@ class Table(Element):
         classes: list[str] = None,
         attrs: dict[str, str] = None,
         data: dict = None,
+        row_class=None,
     ):
         super().__init__("table", classes=classes, attrs=attrs, data=data)
+        self.row_class = row_class or Row
         self.rows: list["Row"] = []
         "ordered list of Rows"
         self.row_by_id: dict[str, "Row"] = {}
@@ -82,6 +84,8 @@ class Table(Element):
         "ordered list of columns ids"
         self.groups = []
         "ordered list of column groups names"
+        self.group_titles = {}
+        "title (in header top row) for the group"
         self.head = []
         self.foot = []
         self.column_group = {}
@@ -92,8 +96,12 @@ class Table(Element):
         "l'id de la ligne sélectionnée"
         self.titles = {}
         "Column title: { col_id : titre }"
-        self.head_title_row: "Row" = Row(self, "title_head", cell_elt="th")
-        self.foot_title_row: "Row" = Row(self, "title_foot", cell_elt="th")
+        self.head_title_row: "Row" = Row(
+            self, "title_head", cell_elt="th", classes=["titles"]
+        )
+        self.foot_title_row: "Row" = Row(
+            self, "title_foot", cell_elt="th", classes=["titles"]
+        )
         self.empty_cell = Cell.empty()
 
     def _prepare(self):
@@ -109,6 +117,10 @@ class Table(Element):
         "return the row, or None"
         return self.row_by_id.get(row_id)
 
+    def __len__(self):
+        "nombre de lignes dans le corps de la table"
+        return len(self.rows)
+
     def is_empty(self) -> bool:
         "true if table has no rows"
         return len(self.rows) == 0
@@ -166,7 +178,6 @@ class Table(Element):
 
     def add_head_row(self, row: "Row") -> "Row":
         "Add a row to table head"
-        # row = Row(self, cell_elt="th", category="head")
         self.head.append(row)
         self.row_by_id[row.id] = row
         return row
@@ -177,6 +188,16 @@ class Table(Element):
         self.row_by_id[row.id] = row
         return row
 
+    def add_groups_header(self):
+        """Insert a header line at the top of the table
+        with a multicolumn th cell per group
+        """
+        self.sort_columns()
+        groups_header = RowGroupsHeader(
+            self, "groups_header", classes=["groups_header"]
+        )
+        self.head.insert(0, groups_header)
+
     def sort_rows(self, key: callable, reverse: bool = False):
         """Sort table rows"""
         self.rows.sort(key=key, reverse=reverse)
@@ -418,3 +439,33 @@ class Cell(Element):
             return f"<a {href} {target_attrs_str}>{super().html_content()}</a>"
 
         return super().html_content()
+
+
+class RowGroupsHeader(Row):
+    """Header line at the top of the table
+    with a multicolumn th cell per group
+    """
+
+    def html_content(self):
+        """Le contenu est généré intégralement ici: un th par groupe contigu
+        Note: les colonnes doivent avoir déjà été triées par table.sort_columns()
+        """
+        column_ids = self.table.column_ids
+        nb_cols = len(self.table.column_ids)
+        elements = []
+        idx = 0
+        while idx < nb_cols:
+            group_title = self.table.group_titles.get(
+                self.table.column_group.get(column_ids[idx])
+            )
+            colspan = 1
+            idx += 1
+            # on groupe tant que les TITRES des groupes sont identiques
+            while idx < nb_cols and group_title == self.table.group_titles.get(
+                self.table.column_group.get(column_ids[idx])
+            ):
+                idx += 1
+                colspan += 1
+            elements.append(f"""<th colspan="{colspan}">{group_title or ""}</th>""")
+
+        return "\n".join(elements) if len(elements) > 1 else ""
diff --git a/app/views/notes.py b/app/views/notes.py
index d5b9bca9fc0609866725f840258ad0448b36f5c8..4ae34dd7688ac903041a4865857d5ef4aa45ab54 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -2891,7 +2891,7 @@ def formsemestre_jury_but_erase(
     return render_template(
         "confirm_dialog.j2",
         title=f"""Effacer les validations de jury {
-            ("de" + etud.nomprenom)
+            ("de " + etud.nomprenom)
             if etud
             else ("des " + str(len(etuds)) + " étudiants inscrits dans ce semestre")
             } ?""",