diff --git a/app/comp/res_common.py b/app/comp/res_common.py
index f1020b481e7f042fe47012f8aec098cba60a134b..fbeba8bedc096bfe8e11f3999333cc497ee19595 100644
--- a/app/comp/res_common.py
+++ b/app/comp/res_common.py
@@ -426,47 +426,53 @@ class ResultatsSemestre(ResultatsCache):
         NO_NOTE = "-"  # contenu des cellules sans notes
         rows = []
         # column_id : title
-        titles = {
-            "rang": "Rg",
-            # ordre des colonnes de gauche à droite:
-            "_civilite_str_col_order": 2,
-            "_nom_disp_col_order": 3,
-            "_prenom_col_order": 4,
-            "_nom_short_col_order": 5,
-            "_rang_col_order": 6,
-            # les colonnes des groupes sont à la position 10
-            "_ues_validables_col_order": 20,
-        }
+        titles = {}
         # les titres en footer: les mêmes, mais avec des bulles et liens:
         titles_bot = {}
 
         def add_cell(
-            row: dict, col_id: str, title: str, content: str, classes: str = ""
+            row: dict,
+            col_id: str,
+            title: str,
+            content: str,
+            classes: str = "",
+            idx: int = 100,
         ):
             "Add a row to our table. classes is a list of css class names"
             row[col_id] = content
             if classes:
-                row[f"_{col_id}_class"] = classes
+                row[f"_{col_id}_class"] = classes + f" c{idx}"
             if not col_id in titles:
                 titles[col_id] = title
+                titles[f"_{col_id}_col_order"] = idx
                 if classes:
                     titles[f"_{col_id}_class"] = classes
+            return idx + 1
 
         etuds_inscriptions = self.formsemestre.etuds_inscriptions
         ues = self.formsemestre.query_ues(with_sport=True)  # avec bonus
         ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
         modimpl_ids = set()  # modimpl effectivement présents dans la table
         for etudid in etuds_inscriptions:
+            idx = 0  # index de la colonne
             etud = Identite.query.get(etudid)
             row = {"etudid": etudid}
             # --- Rang
-            add_cell(row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang")
+            idx = add_cell(
+                row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx
+            )
             row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}"
             # --- Identité étudiant
-            add_cell(row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail")
-            add_cell(row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail")
-            add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail")
-            add_cell(row, "nom_short", "Nom", etud.nom_short, "identite_court")
+            idx = add_cell(
+                row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail", idx
+            )
+            idx = add_cell(
+                row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail", idx
+            )
+            idx = add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail", idx)
+            idx = add_cell(
+                row, "nom_short", "Nom", etud.nom_short, "identite_court", idx
+            )
             row["_nom_short_target"] = url_for(
                 "notes.formsemestre_bulletinetud",
                 scodoc_dept=g.scodoc_dept,
@@ -476,6 +482,8 @@ class ResultatsSemestre(ResultatsCache):
             row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"'
             row["_nom_disp_target"] = row["_nom_short_target"]
             row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"]
+
+            idx = 30  # début des colonnes de notes
             # --- Moyenne générale
             moy_gen = self.etud_moy_gen.get(etudid, False)
             note_class = ""
@@ -483,12 +491,13 @@ class ResultatsSemestre(ResultatsCache):
                 moy_gen = NO_NOTE
             elif isinstance(moy_gen, float) and moy_gen < barre_moy:
                 note_class = " moy_ue_warning"  # en rouge
-            add_cell(
+            idx = add_cell(
                 row,
                 "moy_gen",
                 "Moy",
                 fmt_note(moy_gen),
                 "col_moy_gen" + note_class,
+                idx,
             )
             titles_bot["_moy_gen_target_attrs"] = (
                 'title="moyenne indicative"' if self.is_apc else ""
@@ -510,16 +519,32 @@ class ResultatsSemestre(ResultatsCache):
                         if val < barre_warning_ue:
                             note_class = " moy_ue_warning"  # notes très basses
                             nb_ues_warning += 1
-                    add_cell(
+                    idx = add_cell(
                         row,
                         col_id,
                         ue.acronyme,
                         fmt_note(val),
                         "col_ue" + note_class,
+                        idx,
                     )
                     titles_bot[
                         f"_{col_id}_target_attrs"
                     ] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """
+                    # Bonus (sport) dans cette UE ?
+                    # Le bonus sport appliqué sur cette UE
+                    if (self.bonus_ues is not None) and (ue.id in self.bonus_ues):
+                        val = self.bonus_ues[ue.id][etud.id] or ""
+                        val_fmt = fmt_note(val)
+                        if val:
+                            val_fmt = f'<span class="green-arrow-up"></span><span class="sp2l">{val_fmt}</span>'
+                        idx = add_cell(
+                            row,
+                            f"bonus_ue_{ue.id}",
+                            f"Bonus {ue.acronyme}",
+                            val_fmt,
+                            "col_ue_bonus",
+                            idx,
+                        )
                     # Les moyennes des modules (ou ressources et SAÉs) dans cette UE
                     for modimpl in self.modimpls_in_ue(ue.id, etudid, with_bonus=False):
                         if ue_status["is_capitalized"]:
@@ -546,13 +571,19 @@ class ResultatsSemestre(ResultatsCache):
                         col_id = (
                             f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
                         )
-                        add_cell(
+                        val_fmt = fmt_note(val)
+                        if modimpl.module.module_type == scu.ModuleType.MALUS:
+                            val_fmt = (
+                                (scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else ""
+                            )
+                        idx = add_cell(
                             row,
                             col_id,
                             modimpl.module.code,
-                            fmt_note(val),
+                            val_fmt,
                             # class col_res mod_ue_123
                             f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}",
+                            idx,
                         )
                         titles_bot[f"_{col_id}_target"] = url_for(
                             "notes.moduleimpl_status",
@@ -571,9 +602,10 @@ class ResultatsSemestre(ResultatsCache):
             add_cell(
                 row,
                 "ues_validables",
-                "Nb UE",
+                "UEs",
                 ue_valid_txt,
                 "col_ue col_ues_validables",
+                29,  # juste avant moy. gen.
             )
             if nb_ues_warning:
                 row["_ues_validables_class"] += " moy_ue_warning"
@@ -581,6 +613,7 @@ class ResultatsSemestre(ResultatsCache):
                 row["_ues_validables_class"] += " moy_inf"
             row["_ues_validables_order"] = nb_ues_validables  # pour tri
             rows.append(row)
+
         self._recap_add_partitions(rows, titles)
         self._recap_add_admissions(rows, titles)
         # tri par rang croissant
@@ -589,6 +622,16 @@ class ResultatsSemestre(ResultatsCache):
         # INFOS POUR FOOTER
         bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note)
 
+        # Ajoute style "col_empty" aux colonnes de modules vides
+        for col_id in titles:
+            c_class = f"_{col_id}_class"
+            if "col_empty" in bottom_infos["moy"].get(c_class, ""):
+                for row in rows:
+                    row[c_class] += " col_empty"
+                titles[c_class] += " col_empty"
+                for row in bottom_infos.values():
+                    row[c_class] = row.get(c_class, "") + " col_empty"
+
         # --- TABLE FOOTER: ECTS, moyennes, min, max...
         footer_rows = []
         for (bottom_line, row) in bottom_infos.items():
@@ -604,7 +647,9 @@ class ResultatsSemestre(ResultatsCache):
         titles_bot.update(titles)
         footer_rows.append(titles_bot)
         column_ids = [title for title in titles if not title.startswith("_")]
-        column_ids.sort(key=lambda col_id: titles.get("_" + col_id + "_col_order", 100))
+        column_ids.sort(
+            key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000)
+        )
         return (rows, footer_rows, titles, column_ids)
 
     def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict:
@@ -651,7 +696,11 @@ class ResultatsSemestre(ResultatsCache):
                     notes = self.modimpl_notes(modimpl.id, ue.id)
                     row_min[col_id] = fmt_note(np.nanmin(notes))
                     row_max[col_id] = fmt_note(np.nanmax(notes))
-                    row_moy[col_id] = fmt_note(np.nanmean(notes))
+                    moy = np.nanmean(notes)
+                    row_moy[col_id] = fmt_note(moy)
+                    if np.isnan(moy):
+                        # aucune note dans ce module
+                        row_moy[f"_{col_id}_class"] = "col_empty"
 
         return {  # { key : row } avec key = min, max, moy, coef
             "min": row_min,
@@ -696,6 +745,14 @@ class ResultatsSemestre(ResultatsCache):
             "type_admission": "Type Adm.",
             "classement": "Rg. Adm.",
         }
+        first = True
+        for i, cid in enumerate(fields):
+            titles[f"_{cid}_col_order"] = 10000 + i  # tout à droite
+            if first:
+                titles[f"_{cid}_class"] = "admission admission_first"
+                first = False
+            else:
+                titles[f"_{cid}_class"] = "admission"
         titles.update(fields)
         for row in rows:
             etud = Identite.query.get(row["etudid"])
@@ -708,8 +765,6 @@ class ResultatsSemestre(ResultatsCache):
                     first = False
                 else:
                     row[f"_{cid}_class"] = "admission"
-                titles[f"_{cid}_class"] = row[f"_{cid}_class"]
-                titles[f"_{cid}_col_order"] = 1000  # à la fin
 
     def _recap_add_partitions(self, rows: list[dict], titles: dict):
         """Ajoute les colonnes indiquant les groupes
diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py
index 117ce5673f1a9fbb52a646664b1ea92365f6c0bc..a00e7df39fd25d11b54a0a33b95e61ac72444888 100644
--- a/app/scodoc/sco_recapcomplet.py
+++ b/app/scodoc/sco_recapcomplet.py
@@ -643,7 +643,7 @@ def make_formsemestre_recapcomplet(
                     "recap_row_nbeval",
                     "recap_row_ects",
                 )[ir - nblines + 6]
-                cells = '<tr class="%s sortbottom">' % styl
+                cells = f'<tr class="{styl} sortbottom">'
             else:
                 el = etudlink % {
                     "formsemestre_id": formsemestre_id,
@@ -651,14 +651,14 @@ def make_formsemestre_recapcomplet(
                     "name": l[1],
                 }
                 if ir % 2 == 0:
-                    cells = '<tr class="recap_row_even" id="etudid%s">' % etudid
+                    cells = f'<tr class="recap_row_even" id="etudid{etudid}">'
                 else:
-                    cells = '<tr class="recap_row_odd" id="etudid%s">' % etudid
+                    cells = f'<tr class="recap_row_odd" id="etudid{etudid}">'
             ir += 1
             # XXX nsn = [ x.replace('NA', '-') for x in l[:-2] ]
             # notes sans le NA:
             nsn = l[:-2]  # copy
-            for i in range(len(nsn)):
+            for i, _ in enumerate(nsn):
                 if nsn[i] == "NA":
                     nsn[i] = "-"
             try:
@@ -1041,7 +1041,7 @@ def gen_formsemestre_recapcomplet_html(
             "",
         )
     H = [
-        f"""<div class="table_recap"><table class="table_recap {'apc' if formsemestre.formation.is_apc() else ''}">"""
+        f"""<div class="table_recap"><table class="table_recap {'apc' if formsemestre.formation.is_apc() else 'classic'}">"""
     ]
     # header
     H.append(
diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py
index 504343a4eb150bb85113cdc75abe60fccfc67b19..a332037f6e8abb65e03428f8f32be98e8401ea15 100644
--- a/app/scodoc/sco_utils.py
+++ b/app/scodoc/sco_utils.py
@@ -933,6 +933,7 @@ ICON_XLS = icontag("xlsicon_img", title="Version tableur")
 
 # HTML emojis
 EMO_WARNING = "&#9888;&#65039;"  # warning /!\
+EMO_RED_TRIANGLE_DOWN = "&#128315;"  # red triangle pointed down
 
 
 def sort_dates(L, reverse=False):
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index 6ece2dcf27e9f33d9bb2e3fc508e1df1d90d0abe..f393ac46356ad96c965f161de0b06aa07d2e5ea0 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -3568,6 +3568,11 @@ table.table_recap tbody td:hover {
   text-decoration: dashed underline;
 }
 
+/* col moy gen en gras seulement pour les form. classiques */
+table.table_recap.classic td.col_moy_gen {
+  font-weight: bold;
+}
+
 table.table_recap .identite_court {
   white-space: nowrap;
   text-align: left;
@@ -3637,6 +3642,39 @@ table.table_recap td.col_ues_validables {
   font-style: normal !important;
 }
 
+
+.green-arrow-up {
+  display: inline-block;
+  width: 0;
+  height: 0;
+  border-left: 8px solid transparent;
+  border-right: 8px solid transparent;
+  border-bottom: 8px solid rgb(48, 239, 0);
+}
+
+table.table_recap td.col_ue_bonus,
+table.table_recap th.col_ue_bonus {
+  font-size: 80%;
+  font-weight: bold;
+  color: rgb(0, 128, 11);
+}
+
+table.table_recap td.col_ue_bonus>span.sp2l {
+  margin-left: 2px;
+}
+
+table.table_recap td.col_ue_bonus {
+  white-space: nowrap;
+}
+
+table.table_recap td.col_malus,
+table.table_recap th.col_malus {
+  font-size: 80%;
+  font-weight: bold;
+  color: rgb(165, 0, 0);
+}
+
+
 table.table_recap tr.ects td {
   color: rgb(160, 86, 3);
   font-weight: bold;
diff --git a/app/static/js/table_recap.js b/app/static/js/table_recap.js
index 04acb30ef82868a489807355b25b0798dde33fac..803daee218967a4f94d252d2d3524053e88c9921 100644
--- a/app/static/js/table_recap.js
+++ b/app/static/js/table_recap.js
@@ -29,15 +29,19 @@ $(function () {
                     action: function (e, dt, node, config) {
                         let visible = dt.columns(".col_res").visible()[0];
                         dt.columns(".col_res").visible(!visible);
+                        dt.columns(".col_ue_bonus").visible(!visible);
                         dt.buttons('toggle_res:name').text(visible ? "Montrer les ressources" : "Cacher les ressources");
                     }
                 } : {
                     name: "toggle_mod",
                     text: "Cacher les modules",
                     action: function (e, dt, node, config) {
-                        let visible = dt.columns(".col_mod").visible()[0];
-                        dt.columns(".col_mod").visible(!visible);
+                        let visible = dt.columns(".col_mod:not(.col_empty)").visible()[0];
+                        dt.columns(".col_mod:not(.col_empty)").visible(!visible);
+                        dt.columns(".col_ue_bonus").visible(!visible);
                         dt.buttons('toggle_mod:name').text(visible ? "Montrer les modules" : "Cacher les modules");
+                        visible = dt.columns(".col_empty").visible()[0];
+                        dt.buttons('toggle_col_empty:name').text(visible ? "Cacher mod. vides" : "Montrer mod. vides");
                     }
                 }
         ];
@@ -62,6 +66,15 @@ $(function () {
                 dt.buttons('toggle_admission:name').text(visible ? "Montrer infos admission" : "Cacher infos admission");
             }
         })
+        buttons.push({
+            name: "toggle_col_empty",
+            text: "Montrer mod. vides",
+            action: function (e, dt, node, config) {
+                let visible = dt.columns(".col_empty").visible()[0];
+                dt.columns(".col_empty").visible(!visible);
+                dt.buttons('toggle_col_empty:name').text(visible ? "Montrer mod. vides" : "Cacher mod. vides");
+            }
+        })
         $('table.table_recap').DataTable(
             {
                 paging: false,
@@ -77,8 +90,8 @@ $(function () {
                 colReorder: true,
                 "columnDefs": [
                     {
-                        // cache le détail de l'identité et les colonnes admission
-                        "targets": ["identite_detail", "partition_aux", "admission"],
+                        // cache le détail de l'identité, les groupes, les colonnes admission et les vides
+                        "targets": ["identite_detail", "partition_aux", "admission", "col_empty"],
                         "visible": false,
                     },
                 ],