diff --git a/app/but/jury_but.py b/app/but/jury_but.py
index 561ba543385761c3a6b4a48f2779d7e7459dc4c3..5c3d04934e6f33503d506387dc1c0464e6d6d183 100644
--- a/app/but/jury_but.py
+++ b/app/but/jury_but.py
@@ -58,9 +58,12 @@ DecisionsProposeesUE: décisions de jury sur une UE du BUT
     DecisionsProposeesRCUE appelera .set_compensable()
     si on a la possibilité de la compenser dans le RCUE.
 """
+import html
 from operator import attrgetter
+import re
 from typing import Union
 
+from app import db
 from app import log
 from app.comp.res_but import ResultatsSemestreBUT
 from app.comp import res_sem
@@ -72,7 +75,7 @@ from app.models.but_refcomp import (
     ApcParcours,
     ApcParcoursNiveauCompetence,
 )
-from app.models import but_validations
+from app.models import Scolog
 from app.models.but_validations import (
     ApcValidationAnnee,
     ApcValidationRCUE,
@@ -122,10 +125,14 @@ class DecisionsProposees:
             self.codes = code + self.codes
         elif code is not None:
             self.codes = [code] + self.codes
+        self.validation = None
+        "Validation enregistrée"
         self.code_valide: str = code_valide
-        "La décision actuelle enregistrée"
+        "Code décision actuel enregistré"
         self.explanation: str = explanation
         "Explication à afficher à côté de la décision"
+        self.recorded = False
+        "true si la décision vient d'être enregistrée"
 
     def __repr__(self) -> str:
         return f"""<{self.__class__.__name__} valid={self.code_valide
@@ -266,7 +273,15 @@ class DecisionsProposeesAnnee(DecisionsProposees):
         explanation: {self.explanation}
         """
 
-    def annee_scolaire_sr(self)
+    def annee_scolaire(self) -> int:
+        "L'année de début de l'année scolaire"
+        formsemestre = self.formsemestre_impair or self.formsemestre_pair
+        return formsemestre.annee_scolaire()
+
+    def annee_scolaire_str(self) -> str:
+        "L'année scolaire, eg '2021 - 2022'"
+        formsemestre = self.formsemestre_impair or self.formsemestre_pair
+        return formsemestre.annee_scolaire_str().replace(" ", "")
 
     def comp_formsemestres(
         self, formsemestre: FormSemestre
@@ -397,6 +412,90 @@ class DecisionsProposeesAnnee(DecisionsProposees):
         decisions_rcue_by_niveau = {x[1]: x[0] for x in rc_niveaux}
         return decisions_rcue_by_niveau
 
+    # def lookup_ue(self, ue_id: int) -> UniteEns:
+    #     "check that ue_id belongs to our UE, if not returns None"
+    #     ues = [ue for ue in self.ues_impair + self.ues_pair if ue.id == ue_id]
+    #     assert len(ues) < 2
+    #     if len(ues):
+    #         return ues[0]
+    #     return None
+
+    def record_form(self, form: dict):
+        """Enregistre les codes de jury en base
+        form dict:
+        - 'code_ue_1896' : 'AJ'  code pour l'UE id 1896
+        - 'code_rcue_6" : 'ADM'  code pour le RCUE du niveau 6
+        - 'code_annee' : 'ADM'   code pour l'année
+
+        Si les code_rcue et le code_annee ne sont pas fournis,
+        enregistre ceux par défaut.
+        """
+        for key in form:
+            code = form[key]
+            # Codes d'UE
+            m = re.match(r"^code_ue_(\d+)$", key)
+            if m:
+                ue_id = int(m.group(1))
+                dec_ue = self.decisions_ues.get(ue_id)
+                if not dec_ue:
+                    raise ScoValueError(f"UE invalide ue_id={ue_id}")
+                dec_ue.record(code)
+            else:
+                # Codes de RCUE
+                m = re.match(r"^code_rcue_(\d+)$", key)
+                if m:
+                    niveau_id = int(m.group(1))
+                    dec_rcue = self.decisions_rcue_by_niveau.get(niveau_id)
+                    if not dec_rcue:
+                        raise ScoValueError(f"RCUE invalide niveau_id={niveau_id}")
+                    dec_rcue.record(code)
+                elif key == "code_annee":
+                    # Code annuel
+                    self.record(code)
+
+        self.record_all()
+        db.session.commit()
+
+    def record(self, code: str):
+        """Enregistre le code"""
+        if not code in self.codes:
+            raise ScoValueError(
+                f"code annee <tt>{html.escape(code)}</tt> invalide pour formsemestre {html.escape(self.formsemestre)}"
+            )
+        if code == self.code_valide:
+            return  # no change
+        if self.validation:
+            db.session.delete(self.validation)
+            db.session.flush()
+
+        self.validation = ApcValidationAnnee(
+            etudid=self.etud.id,
+            formsemestre=self.formsemestre_impair,
+            ordre=self.annee_but,
+            annee_scolaire=self.annee_scolaire(),
+            code=code,
+        )
+        Scolog.logdb(
+            method="jury_but",
+            etudid=self.etud.id,
+            msg=f"Validation année BUT{self.annee_but}: {code}",
+        )
+        db.session.add(self.validation)
+        self.recorded = True
+
+    def record_all(self):
+        """Enregistre les codes qui n'ont pas été spécifiés par le formulaire,
+        et sont donc en mode "automatique"
+        """
+        decisions = (
+            list(self.decisions_ues.values())
+            + list(self.decisions_rcue_by_niveau.values())
+            + [self]
+        )
+        for dec in decisions:
+            if not dec.recorded:
+                dec.record(dec.codes[0])  # rappel: le code par défaut est en tête
+
 
 class DecisionsProposeesRCUE(DecisionsProposees):
     """Liste des codes de décisions que l'on peut proposer pour
@@ -417,10 +516,10 @@ class DecisionsProposeesRCUE(DecisionsProposees):
     ):
         super().__init__(etud=dec_prop_annee.etud)
         self.rcue = rcue
-
-        validation = rcue.query_validations().first()
-        if validation is not None:
-            self.code_valide = validation.code
+        self.parcour = dec_prop_annee.parcour
+        self.validation = rcue.query_validations().first()
+        if self.validation is not None:
+            self.code_valide = self.validation.code
         if rcue.est_compensable():
             self.codes.insert(0, sco_codes.CMP)
         elif rcue.est_validable():
@@ -428,6 +527,34 @@ class DecisionsProposeesRCUE(DecisionsProposees):
         else:
             self.codes.insert(0, sco_codes.AJ)
 
+    def record(self, code: str):
+        """Enregistre le code"""
+        if not code in self.codes:
+            raise ScoValueError(
+                f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}"
+            )
+        if code == self.code_valide:
+            return  # no change
+        parcours_id = self.parcour.id if self.parcour is not None else None
+        if self.validation:
+            db.session.delete(self.validation)
+            db.session.flush()
+        self.validation = ApcValidationRCUE(
+            etudid=self.etud.id,
+            formsemestre_id=self.rcue.formsemestre_2.id,
+            ue1_id=self.rcue.ue_1.id,
+            ue2_id=self.rcue.ue_2.id,
+            parcours_id=parcours_id,
+            code=code,
+        )
+        Scolog.logdb(
+            method="jury_but",
+            etudid=self.etud.id,
+            msg=f"Validation RCUE {repr(self.rcue)}",
+        )
+        db.session.add(self.validation)
+        self.recorded = True
+
 
 class DecisionsProposeesUE(DecisionsProposees):
     """Décisions de jury sur une UE du BUT
@@ -460,6 +587,7 @@ class DecisionsProposeesUE(DecisionsProposees):
         ue: UniteEns,
     ):
         super().__init__(etud=etud)
+        self.formsemestre = formsemestre
         self.ue: UniteEns = ue
         self.rcue: RegroupementCoherentUE = None
         "Le rcu auquel est rattaché cette UE, ou None"
@@ -503,6 +631,31 @@ class DecisionsProposeesUE(DecisionsProposees):
             self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes
             self.explanation = "notes insuffisantes"
 
+    def record(self, code: str):
+        """Enregistre le code"""
+        if not code in self.codes:
+            raise ScoValueError(
+                f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}"
+            )
+        if code == self.code_valide:
+            return  # no change
+        if self.validation:
+            db.session.delete(self.validation)
+            db.session.flush()
+        self.validation = ScolarFormSemestreValidation(
+            etudid=self.etud.id,
+            formsemestre_id=self.formsemestre.id,
+            ue_id=self.ue.id,
+            code=code,
+        )
+        Scolog.logdb(
+            method="jury_but",
+            etudid=self.etud.id,
+            msg=f"Validation UE {self.ue.id}",
+        )
+        db.session.add(self.validation)
+        self.recorded = True
+
 
 class BUTCursusEtud:  # WIP TODO
     """Validation du cursus d'un étudiant"""
diff --git a/app/models/but_validations.py b/app/models/but_validations.py
index 51d172872c1444ebd2570c3b651399b0a54b9584..27aee4299ce0f33e22ae851419781a0482913227 100644
--- a/app/models/but_validations.py
+++ b/app/models/but_validations.py
@@ -277,4 +277,4 @@ class ApcValidationAnnee(db.Model):
     formsemestre = db.relationship("FormSemestre", backref="apc_validations_annees")
 
     def __repr__(self):
-        return f"<{self.__class__.__name__} {self.id} {self.etud} BUT{self.ordre}:{self.code!r}>"
+        return f"<{self.__class__.__name__} {self.id} {self.etud} BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>"
diff --git a/app/models/events.py b/app/models/events.py
index b94549e76491bc1041e55ad1d40a58a7e06bb5b8..4e566cbd9656b3208e532610fdefdcd1dbca7dd4 100644
--- a/app/models/events.py
+++ b/app/models/events.py
@@ -32,6 +32,21 @@ class Scolog(db.Model):
     authenticated_user = db.Column(db.Text)  # login, sans contrainte
     # zope_remote_addr suppressed
 
+    @classmethod
+    def logdb(
+        cls, method: str = None, etudid: int = None, msg: str = None, commit=False
+    ):
+        """Add entry in student's log (replacement for old scolog.logdb)"""
+        entry = Scolog(
+            method=method,
+            msg=msg,
+            etudid=etudid,
+            authenticated_user=current_user.user_name,
+        )
+        db.session.add(entry)
+        if commit:
+            db.session.commit()
+
 
 class ScolarNews(db.Model):
     """Nouvelles pour page d'accueil"""
diff --git a/app/models/validations.py b/app/models/validations.py
index 976e35f9f9b80ba87989028b06e034347eb416b4..00d170729f3d28fdbc39758e38be97c376c6b807 100644
--- a/app/models/validations.py
+++ b/app/models/validations.py
@@ -36,7 +36,7 @@ class ScolarFormSemestreValidation(db.Model):
     # NULL pour les UE, True|False pour les semestres:
     assidu = db.Column(db.Boolean)
     event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
-    # NULL sauf si compense un semestre:
+    # NULL sauf si compense un semestre: (pas utilisé pour BUT)
     compense_formsemestre_id = db.Column(
         db.Integer,
         db.ForeignKey("notes_formsemestre.id"),
diff --git a/app/static/css/jury_but.css b/app/static/css/jury_but.css
index beffba97584bd530bf4a571c248da5b4aa3906e3..1182096a95cb50e263eefb75b8d909d7f83faace 100644
--- a/app/static/css/jury_but.css
+++ b/app/static/css/jury_but.css
@@ -65,4 +65,28 @@ div.but_settings {
 span.but_explanation {
     color: blueviolet;
     font-style: italic;
+}
+
+select:disabled {
+    font-weight: bold;
+    color: blue;
+}
+
+select:invalid {
+    background: red;
+}
+
+select.but_code option.recorded {
+    color: rgb(3, 157, 3);
+    font-weight: bold;
+}
+
+div.but_niveau_ue.recorded,
+div.but_niveau_rcue.recorded {
+    border-color: rgb(136, 252, 136);
+    border-width: 2px;
+}
+
+div.but_niveau_ue.modified {
+    background-color: rgb(255, 214, 254);
 }
\ No newline at end of file
diff --git a/app/static/js/jury_but.js b/app/static/js/jury_but.js
index a2f13dd181488127e341cb2357a37db7dd51327c..e67362cb233b7e36ba2814dcdad5acdd6aea4a28 100644
--- a/app/static/js/jury_but.js
+++ b/app/static/js/jury_but.js
@@ -4,3 +4,11 @@
 function enable_manual_codes(elt) {
     $(".jury_but select.manual").prop("disabled", !elt.checked);
 }
+
+// changement menu code:
+function change_menu_code(elt) {
+    elt.parentElement.parentElement.classList.remove("recorded");
+    // TODO: comparer avec valeur enregistrée (à mettre en data-orig ?)
+    // et colorer en fonction
+    elt.parentElement.parentElement.classList.add("modified");
+}
\ No newline at end of file
diff --git a/app/views/notes.py b/app/views/notes.py
index 020d1cca4a91332280f7372267b31ceed496ad7f..c43eb6366bcb19466336b6a9eefa591fd3dbf43b 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -2231,7 +2231,6 @@ def formsemestre_validation_but(formsemestre_id: int, etudid: int):
                 formsemestre_id=formsemestre_id,
             ),
         )
-    # XXX TODO  Page expérimentale pour les devs
     H = [
         html_sco_header.sco_header(
             page_title="Validation BUT",
@@ -2244,22 +2243,37 @@ def formsemestre_validation_but(formsemestre_id: int, etudid: int):
         <div class="jury_but">
         """,
     ]
+
     formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
     etud = Identite.query.get_or_404(etudid)
     res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
     deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
-
+    if request.method == "POST":
+        deca.record_form(request.form)
+        flash("codes enregistrés")
+        return flask.redirect(
+            url_for(
+                "notes.formsemestre_validation_but",
+                scodoc_dept=g.scodoc_dept,
+                formsemestre_id=formsemestre_id,
+                etudid=etudid,
+            )
+        )
     H.append(
         f"""
     <form method="POST">
-    <div class="titre_parcours"><h2>Jury BUT{deca.annee_but} - Parcours {deca.parcour.libelle or "non spécifié"}
-    - {deca.formsemestre_impair.annee_scolaire_str()}</h2>
+    <div class="titre_parcours">
+    <h2>Jury BUT{deca.annee_but}
+    - Parcours {deca.parcour.libelle or "non spécifié"}
+    - {deca.annee_scolaire_str()}</h2>
     </div>
     <div class="but_section_annee">
         <div>
         <b>Décision de jury pour l'année :</b> {
             _gen_but_select("code_annee", deca.codes, deca.code_valide, disabled=True, klass="manual")
-        }</div>
+        }
+        <span>({'non ' if deca.code_valide is None else ''}enregistrée)</span>
+        </div>
         <span class="but_explanation">{deca.explanation}</span>
     </div>
     <b>Niveaux de compétences et unités d'enseignement :</b>
@@ -2279,36 +2293,26 @@ def formsemestre_validation_but(formsemestre_id: int, etudid: int):
         )
         dec_rcue = deca.decisions_rcue_by_niveau[niveau.id]
         # Semestre impair
-        ue = dec_rcue.rcue.ue_1
         H.append(
-            f"""<div class="but_niveau_ue">
-            <div title="{ue.titre}">{ue.acronyme}</div>
-            <div class="but_note">{scu.fmt_note(dec_rcue.rcue.moy_ue_1)}</div>
-            <div class="but_code">{
-                _gen_but_select("code_ue_"+str(ue.id), 
-                    deca.decisions_ues[ue.id].codes, 
-                    deca.decisions_ues[ue.id].code_valide
-                )
-            }</div>
-            </div>"""
+            _gen_but_niveau_ue(
+                dec_rcue.rcue.ue_1,
+                dec_rcue.rcue.moy_ue_1,
+                deca.decisions_ues[dec_rcue.rcue.ue_1.id],
+            )
         )
         # Semestre pair
-        ue = dec_rcue.rcue.ue_2
         H.append(
-            f"""<div class="but_niveau_ue">
-            <div title="{ue.titre}">{ue.acronyme}</div>
-            <div class="but_note">{scu.fmt_note(dec_rcue.rcue.moy_ue_2)}</div>
-            <div class="but_code">{
-                _gen_but_select("code_ue_"+str(ue.id), 
-                    deca.decisions_ues[ue.id].codes, 
-                    deca.decisions_ues[ue.id].code_valide
-                )
-            }</div>
-            </div>"""
+            _gen_but_niveau_ue(
+                dec_rcue.rcue.ue_2,
+                dec_rcue.rcue.moy_ue_2,
+                deca.decisions_ues[dec_rcue.rcue.ue_2.id],
+            )
         )
         # RCUE
         H.append(
-            f"""<div class="but_niveau_rcue">
+            f"""<div class="but_niveau_rcue
+            {'recorded' if dec_rcue.code_valide is not None else ''}
+            ">
             <div class="but_note">{scu.fmt_note(dec_rcue.rcue.moy_rcue)}</div>
             <div class="but_code">{
                 _gen_but_select("code_rcue_"+str(niveau.id),
@@ -2322,9 +2326,16 @@ def formsemestre_validation_but(formsemestre_id: int, etudid: int):
     H.append("</div>")  # but_annee
 
     H.append(
-        """<div class="but_settings"><input type="checkbox" onchange="enable_manual_codes(this)">
-        <em>permettre la saisie manuelles des codes d'année et de niveaux</em>
-        </input></div>"""
+        """<div class="but_settings">
+        <input type="checkbox" onchange="enable_manual_codes(this)">
+        <em>permettre la saisie manuelles des codes d'année et de niveaux. 
+        Dans ce cas, il vous revient de vous assurer de la cohérence entre 
+        vos codes d'UE/RCUE/Année !</em>
+        </input>
+        </div>
+
+        <input type="submit" value="Enregistrer ces décisions">
+        """
     )
     H.append("</form>")  # but_annee
 
@@ -2348,11 +2359,36 @@ def _gen_but_select(
     "Le menu html select avec les codes"
     h = "\n".join(
         [
-            f"""<option value="{code}" {'selected' if code == code_valide else ''}>{code}</option>"""
+            f"""<option value="{code}" 
+            {'selected' if code == code_valide else ''}
+            class="{'recorded' if code == code_valide else ''}"
+            >{code}</option>"""
             for code in codes
         ]
     )
-    return f"""<select name="{name}" class="{klass}" {"disabled" if disabled else ""}>{h}</select>"""
+    return f"""<select required name="{name}" 
+        class="but_code {klass}" 
+        onchange="change_menu_code(this);"
+        {"disabled" if disabled else ""}
+        >{h}</select>
+        """
+
+
+def _gen_but_niveau_ue(
+    ue: UniteEns, moy_ue: float, dec_ue: jury_but.DecisionsProposeesUE
+):
+    return f"""<div class="but_niveau_ue {
+        'recorded' if dec_ue.code_valide is not None else ''}
+        ">
+    <div title="{ue.titre}">{ue.acronyme}</div>
+    <div class="but_note">{scu.fmt_note(moy_ue)}</div>
+    <div class="but_code">{
+        _gen_but_select("code_ue_"+str(ue.id), 
+            dec_ue.codes, 
+            dec_ue.code_valide
+        )
+    }</div>
+    </div>"""
 
 
 @bp.route("/formsemestre_validate_previous_ue", methods=["GET", "POST"])