diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py
index b15255105b10ff70e9504ea776d4c5c7b5430972..a2afc76bfdf19c5bb6feb259b4e8cf5fb0fb9c79 100644
--- a/app/scodoc/TrivialFormulator.py
+++ b/app/scodoc/TrivialFormulator.py
@@ -47,6 +47,7 @@ def TrivialFormulator(
     title="",
     after_table="",
     before_table="{title}",
+    hidden_args: list[tuple] | None = None,
 ):
     """
     form_url : URL for this form
@@ -124,6 +125,7 @@ def TrivialFormulator(
         title=title,
         after_table=after_table,
         before_table=before_table,
+        hidden_args=hidden_args,
     )
     form = t.getform()
     if t.canceled():
@@ -161,6 +163,7 @@ class TF(object):
         title="",
         after_table="",
         before_table="{title}",
+        hidden_args: list[tuple] | None = None,
     ):
         self.form_url = form_url
         self.values = values.copy()
@@ -186,6 +189,7 @@ class TF(object):
         self.title = title
         self.after_table = after_table
         self.before_table = before_table
+        self.hidden_args = hidden_args or []
         self.readonly = readonly
         self.result = None
         self.is_submitted = is_submitted
@@ -470,6 +474,8 @@ class TF(object):
                 }"/>"""
             )
         R.append(f"""<input type="hidden" name="{self.formid}_submitted" value="1"/>""")
+        for k, v in self.hidden_args:
+            R.append(f"""<input type="hidden" name="{k}" value="{v}"/>""")
         if self.top_buttons:
             R.append(buttons_markup + "<p></p>")
         R.append(self.before_table.format(title=self.title))
diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py
index 775e48aca4f86e8bf06f09340f16fac6de5856a1..c2a478e27639077bb082f097ca03915057267381 100644
--- a/app/scodoc/sco_groups_view.py
+++ b/app/scodoc/sco_groups_view.py
@@ -42,7 +42,6 @@ from flask_login import current_user
 from app import db, log
 from app.models import FormSemestre, Identite, ScolarEvent
 import app.scodoc.sco_utils as scu
-from app.scodoc import html_sco_header
 from app.scodoc import sco_assiduites as scass
 from app.scodoc import sco_excel
 from app.scodoc import sco_formsemestre
@@ -170,11 +169,10 @@ def form_groups_choice(
     with_deselect_butt=False,
     submit_on_change=False,
     default_deselect_others=True,
+    args: dict | None = None,
+    title="Groupes&nbsp;:",
 ):
-    """form pour selection groupes
-    group_ids est la liste des groupes actuellement sélectionnés
-    et doit comporter au moins un élément, sauf si formsemestre_id est spécifié.
-    (utilisé pour retrouver le semestre et proposer la liste des autres groupes)
+    """Formulaire complet pour choix de groupe.
 
     Si submit_on_change, soumet (recharge la page) à chaque modif.
     Si default_deselect_others, désélectionne le groupe "Tous" quand on sélectionne un autre groupe.
@@ -182,7 +180,14 @@ def form_groups_choice(
     Ces deux options ajoutent des classes utilisées en JS pour la gestion du formulaire.
     """
     default_group_id = sco_groups.get_default_group(groups_infos.formsemestre_id)
-
+    args = args or {}
+    args_wdg = "\n".join(
+        [
+            f"""<input type="hidden" name="{k}" value="{v}"/>"""
+            for k, v in args.items()
+            if k not in ("formsemestre_id", "group_ids")
+        ]
+    )
     H = [
         f"""
     <form id="group_selector" method="get">
@@ -190,7 +195,8 @@ def form_groups_choice(
             value="{groups_infos.formsemestre_id}"/>
         <input type="hidden" name="default_group_id" id="default_group_id"
             value="{default_group_id}"/>
-    Groupes:
+        {args_wdg}
+        {title}
     {
         menu_groups_choice(
             groups_infos,
@@ -480,6 +486,7 @@ class DisplayedGroupsInfos:
         return "\n".join(H)
 
     def get_formsemestre(self) -> FormSemestre:
+        "le formsemestre"
         return (
             db.session.get(FormSemestre, self.formsemestre_id)
             if self.formsemestre_id
diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py
index fda6e0d8c832f15436707e62375dd2c1609eea73..eb66472568d45b1bdedde93467f0ede266c7f6ff 100644
--- a/app/scodoc/sco_liste_notes.py
+++ b/app/scodoc/sco_liste_notes.py
@@ -43,17 +43,21 @@ from app.models import FormSemestre, Module
 from app.models.etudiants import Identite
 from app.models.evaluations import Evaluation
 from app.models.moduleimpls import ModuleImpl
-from app.scodoc.TrivialFormulator import TrivialFormulator
-
+from app.scodoc import (
+    sco_evaluations,
+    sco_evaluation_db,
+    sco_groups,
+    sco_groups_view,
+    sco_preferences,
+    sco_users,
+)
+from app.scodoc.sco_groups_view import DisplayedGroupsInfos
 from app.scodoc.sco_etud import etud_sort_key
-from app.scodoc import sco_evaluations
-from app.scodoc import sco_evaluation_db
-from app.scodoc import sco_groups
-from app.scodoc import sco_preferences
-from app.scodoc import sco_users
-import app.scodoc.sco_utils as scu
+from app.scodoc.sco_exceptions import ScoValueError
 from app.scodoc.gen_tables import GenTable
 from app.scodoc.htmlutils import histogram_notes
+from app.scodoc.TrivialFormulator import TrivialFormulator
+import app.scodoc.sco_utils as scu
 import sco_version
 
 
@@ -82,18 +86,23 @@ def do_evaluation_listenotes(
         return "<p>Aucune évaluation !</p>", "ScoDoc"
     evaluation = evaluations[0]
     modimpl = evaluation.moduleimpl  # il y a au moins une evaluation
+    # Argument groupes:
+    group_ids = request.args.getlist("group_ids")
+    try:
+        group_ids = [int(gid) for gid in group_ids]
+    except ValueError as exc:
+        raise ScoValueError("group_ids invalide") from exc
+    groups_infos = DisplayedGroupsInfos(
+        group_ids,
+        formsemestre_id=modimpl.formsemestre.id,
+        select_all_when_unspecified=True,
+    )
 
     # description de l'évaluation
     if evaluation_id is not None:
-        H = [sco_evaluations.evaluation_describe(evaluation_id=evaluation_id)]
         page_title = f"Notes {evaluation.description or modimpl.module.code}"
     else:
-        H = []
         page_title = f"Notes {modimpl.module.code}"
-    # groupes
-    groups = sco_groups.do_evaluation_listegroupes(evaluation.id, include_default=True)
-    grlabs = [g["group_name"] or "tous" for g in groups]  # legendes des boutons
-    grnams = [str(g["group_id"]) for g in groups]  # noms des checkbox
 
     if len(evaluations) > 1:
         descr = [
@@ -109,37 +118,6 @@ def do_evaluation_listenotes(
                 {"default": evaluation.id, "input_type": "hidden"},
             )
         ]
-    if len(grnams) > 1:
-        descr += [
-            (
-                "s",
-                {
-                    "input_type": "separator",
-                    "title": "<b>Choix du ou des groupes d'étudiants:</b>",
-                },
-            ),
-            (
-                "group_ids",
-                {
-                    "input_type": "checkbox",
-                    "title": "",
-                    "allowed_values": grnams,
-                    "labels": grlabs,
-                    "attributes": ('onclick="document.tf.submit();"',),
-                },
-            ),
-        ]
-    else:
-        if grnams:
-            def_nam = grnams[0]
-        else:
-            def_nam = ""
-        descr += [
-            (
-                "group_ids",
-                {"input_type": "hidden", "type": "list", "default": [def_nam]},
-            )
-        ]
     descr += [
         (
             "anonymous_listing",
@@ -202,17 +180,16 @@ def do_evaluation_listenotes(
         request.base_url,
         scu.get_request_args(),
         descr,
+        hidden_args=[("group_ids", gid) for gid in group_ids],
         cancelbutton=None,
         submitbutton=None,
         bottom_buttons=False,
         method="GET",  # consultation
-        cssclass="noprint",
+        cssclass="noprint tf-liste-notes",
         name="tf",
         is_submitted=True,  # toujours "soumis" (démarre avec liste complète)
     )
-    if tf[0] == 0:
-        return "\n".join(H) + "\n" + tf[1], page_title
-    elif tf[0] == -1:
+    if tf[0] == -1:
         return (
             flask.redirect(
                 url_for(
@@ -223,44 +200,43 @@ def do_evaluation_listenotes(
             ),
             "",
         )
-    else:
-        anonymous_listing = tf[2]["anonymous_listing"]
-        note_sur_20 = tf[2]["note_sur_20"]
-        hide_groups = tf[2]["hide_groups"]
-        split_groups = tf[2]["split_groups"]
-        with_emails = tf[2]["with_emails"]
-        group_ids = [x for x in tf[2]["group_ids"] if x != ""]
-        return (
-            _make_table_notes(
-                tf[1],
-                evaluations,
-                fmt=fmt,
-                note_sur_20=note_sur_20,
-                anonymous_listing=anonymous_listing,
-                group_ids=group_ids,
-                hide_groups=hide_groups,
-                split_groups=split_groups,
-                with_emails=with_emails,
-                mode=mode,
-            ),
-            page_title,
-        )
+    return (
+        _make_table_notes(
+            tf[1],
+            evaluations,
+            fmt=fmt,
+            groups_infos=groups_infos,
+            args={
+                "note_sur_20": tf[2]["note_sur_20"],
+                "anonymous_listing": tf[2]["anonymous_listing"],
+                "group_ids": group_ids,
+                "hide_groups": tf[2]["hide_groups"],
+                "split_groups": tf[2]["split_groups"],
+                "with_emails": tf[2]["with_emails"],
+            },
+            mode=mode,
+        ),
+        page_title,
+    )
 
 
 def _make_table_notes(
     html_form,
     evaluations: list[Evaluation],
     fmt: str = "",
-    note_sur_20=False,
-    anonymous_listing=False,
-    hide_groups=False,
-    split_groups=False,
-    with_emails=False,
-    group_ids: list[int] | None = None,
+    groups_infos: DisplayedGroupsInfos = None,
+    args: dict = None,
     mode="module",  # "eval" or "module"
 ) -> str:
-    """Table liste notes (une seule évaluation ou toutes celles d'un module)"""
-    group_ids = group_ids or []
+    """Table liste notes (une seule évaluation ou toutes celles d'un module)
+    args doit contenir:
+        note_sur_20=False,
+        anonymous_listing=False,
+        hide_groups=False,
+        split_groups=False,
+        with_emails=False
+    """
+    args = args or {}
     if not evaluations:
         return "<p>Aucune évaluation !</p>"
     evaluation = evaluations[0]
@@ -286,15 +262,25 @@ def _make_table_notes(
         keep_numeric = True  # pas de conversion des notes en strings
     else:
         keep_numeric = False
-    # Si pas de groupe, affiche tout
-    if not group_ids:
-        group_ids = [sco_groups.get_default_group(formsemestre.id)]
-    groups = sco_groups.listgroups(group_ids)
+    # Menu groupes
+    group_selector = (
+        sco_groups_view.form_groups_choice(
+            groups_infos, submit_on_change=True, args=args
+        )
+        if groups_infos
+        else ""
+    )
+
+    # Si pas de groupe, affiche tout XXX TODO
+    # if not groups_infos.group_ids:
+    #    groups_infos.group_ids = [sco_groups.get_default_group(formsemestre.id)]
 
-    gr_title = sco_groups.listgroups_abbrev(groups)
-    gr_title_filename = sco_groups.listgroups_filename(groups)
+    groups = sco_groups.listgroups(groups_infos.group_ids)
 
-    if anonymous_listing:
+    gr_title = groups_infos.groups_titles
+    gr_title_filename = groups_infos.groups_filename
+
+    if args["anonymous_listing"]:
         columns_ids = ["code"]  # cols in table
     else:
         if fmt in {"xls", "xml", "json"}:
@@ -302,14 +288,15 @@ def _make_table_notes(
         else:
             columns_ids = ["nomprenom"]
     partitions = []
-    if split_groups:
-        partitions = formsemestre.get_partitions_list(
-            with_default=False, only_listed=True
-        )
-        columns_ids += [f"partition_{p.id}" for p in partitions]
-    elif not hide_groups and fmt not in {"xml", "json"}:
-        # n'indique pas les groupes en xml et json car notation "humaine" ici
-        columns_ids.append("group")
+    if not args["hide_groups"]:
+        if args["split_groups"]:
+            partitions = formsemestre.get_partitions_list(
+                with_default=False, only_listed=True
+            )
+            columns_ids += [f"partition_{p.id}" for p in partitions]
+        elif fmt not in {"xml", "json"}:
+            # n'indique pas les groupes en xml et json car notation "humaine" ici
+            columns_ids.append("group")
 
     titles = {
         "code": "Code",
@@ -456,13 +443,13 @@ def _make_table_notes(
             row_moys,
             is_apc,
             key_mgr,
-            note_sur_20,
+            args["note_sur_20"],
             keep_numeric,
             fmt=fmt,
         )
         columns_ids.append(e.id)
     #
-    if anonymous_listing:
+    if args["anonymous_listing"]:
         rows.sort(key=lambda x: x["code"] or "")
     else:
         # sort by nom, prenom, sans accents
@@ -504,7 +491,7 @@ def _make_table_notes(
             )
 
     # Ajoute colonnes emails tout à droite:
-    if with_emails:
+    if args["with_emails"]:
         columns_ids += ["email", "emailperso"]
     # Ajoute lignes en tête et moyennes
     if len(evaluations) > 0 and fmt != "bordereau" and fmt != "json":
@@ -539,17 +526,10 @@ def _make_table_notes(
             columns_ids.append("signatures")
 
     # titres divers:
-    gl = "".join(["&group_ids%3Alist=" + str(g) for g in group_ids])
-    if note_sur_20:
-        gl = "&note_sur_20%3Alist=yes" + gl
-    if anonymous_listing:
-        gl = "&anonymous_listing%3Alist=yes" + gl
-    if hide_groups:
-        gl = "&hide_groups%3Alist=yes" + gl
-    if split_groups:
-        gl = "&split_groups%3Alist=yes" + gl
-    if with_emails:
-        gl = "&with_emails%3Alist=yes" + gl
+    gl = "".join(["&group_ids=" + str(g) for g in groups_infos.group_ids])
+    for k, v in args.items():
+        gl += f"&{k}%3Alist=yes" if v else ""
+
     if len(evaluations) == 1:
         evalname = f"""{module.code}-{
             evaluation.date_debut.replace(tzinfo=None).isoformat()
@@ -582,7 +562,8 @@ def _make_table_notes(
                 len(etudid_etats),
             )
             if evaluation.date_debut:
-                pdf_title = f"{evaluation.description} ({evaluation.date_debut.strftime('%d/%m/%Y')})"
+                pdf_title = f"""{evaluation.description} ({
+                    evaluation.date_debut.strftime('%d/%m/%Y')})"""
             else:
                 pdf_title = evaluation.description or f"évaluation dans {module.code}"
 
@@ -671,12 +652,14 @@ def _make_table_notes(
             Les moyennes sur le groupe sont estimées sans les absents
             (sauf pour les moyennes des moyennes d'UE) ni les démissionnaires.</span>"""
         eval_info += """</span>"""
-        return html_form + eval_info + t + "<p></p>"
+        return group_selector + html_form + eval_info + t + "<p></p>"
+
     # Une seule evaluation: ajoute histogramme
     histo = histogram_notes(notes)
     # 2 colonnes: histo, comments
-    C = [
-        f"""<br><a class="stdlink" href="{base_url}&fmt=bordereau">Bordereau de Signatures (version PDF)</a>
+    section_basse_html = [
+        f"""<br><a class="stdlink" href="{base_url}&fmt=bordereau"
+        >Bordereau de Signatures (version PDF)</a>
         <table>
         <tr><td>
         <div><h4>Répartition des notes:</h4>
@@ -689,15 +672,17 @@ def _make_table_notes(
     commentkeys = list(key_mgr.items())  # [ (comment, key), ... ]
     commentkeys.sort(key=lambda x: int(x[1]))
     for comment, key in commentkeys:
-        C.append(f"""<span class="colcomment">({key})</span> <em>{comment}</em><br>""")
+        section_basse_html.append(
+            f"""<span class="colcomment">({key})</span> <em>{comment}</em><br>"""
+        )
     if commentkeys:
-        C.append(
+        section_basse_html.append(
             f"""<span><a class=stdlink" href="{ url_for(
                     'notes.evaluation_list_operations', scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id )
                 }">Gérer les opérations</a></span><br>
             """
         )
-    eval_info = "xxx"
+    eval_info = ""
     if evals_state[evaluation.id]["evalcomplete"]:
         eval_info = '<span class="eval_info eval_complete">Evaluation prise en compte dans les moyennes</span>'
     elif evals_state[evaluation.id]["evalattente"]:
@@ -708,9 +693,10 @@ def _make_table_notes(
     return (
         sco_evaluations.evaluation_describe(evaluation_id=evaluation.id)
         + eval_info
+        + group_selector
         + html_form
         + t
-        + "\n".join(C)
+        + "\n".join(section_basse_html)
     )
 
 
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index d38595a91211c76e51b6ea2073b8b0ecb67a2bde..8c72b700d31b7ef55c69b55898af149a960623c9 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -1536,6 +1536,10 @@ span.eval_title {
   padding-top: 24px;
 }
 
+.tf-liste-notes td.tf-field {
+  max-width: var(--sco-content-max-width);
+}
+
 /* #saisie_notes span.eval_title {
     border-bottom: 1px solid rgb(100,100,100);
 }
@@ -1581,8 +1585,10 @@ div.jury_footer>span {
   color: red;
 }
 
-span.eval_info {
+.eval_info {
   font-style: italic;
+  max-width: var(--sco-content-max-width);
+  margin: 16px 0 16px 0;
 }
 
 span.eval_complete {
diff --git a/app/views/notes.py b/app/views/notes.py
index c153e75e934fd3e29e33d0f92bf727e503884954..5499641e6ca7361b7c29e2092a04931df7f8e6e6 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -1787,6 +1787,7 @@ def evaluation_listenotes():
             content=content,
             title=page_title,
             cssstyles=["css/verticalhisto.css"],
+            javascripts=["js/groups_view.js"],
         )
     return content