diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index 84e6821ce8bdd68893d60c936c2901328f4fb9cf..7c2c8198cf8128b291236328e3986d6954165f8e 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -1068,7 +1068,7 @@ def org_contest_task(ct_id: int, task_id: int, site_id: Optional[int] = None):
     assert ctx.contest and ctx.task
 
     action_edit = request.endpoint == "org_contest_task_edit"
-    if action_edit and not ctx.rights.can_edit_points() and not ctx.rights.can_create_solutions():
+    if action_edit and not (ctx.rights.can_edit_points() or ctx.rights.can_create_solutions()):
         raise werkzeug.exceptions.Forbidden()
 
     sess = db.get_session()
@@ -1078,77 +1078,120 @@ def org_contest_task(ct_id: int, task_id: int, site_id: Optional[int] = None):
     rows.sort(key=lambda r: r[0].user.sort_key())
 
     edit_form: Optional[TaskPointsEditForm] = None
+    edit_errors: Dict[int, str] = {}
 
-    if action_edit:
-        edit_form = TaskPointsEditForm()
-        if edit_form.validate_on_submit():
-            # Založení řešení:
-            new_sols: List[db.Solution] = []
-            all_sols: List[db.Solution] = []
-            for pion, sol in rows:
-                if sol:
-                    all_sols.append(sol)
-                    continue  # již existuje
-                if not request.form.get(f"create_sol_{pion.user_id}"):
-                    continue  # nikdo nežádá o vytvoření
+    def process_edit_form() -> bool:
+        if not edit_form.validate_on_submit():
+            return False
+
+        # Parsování bodů
+        action_for: Dict[db.Participation, str] = {}
+        points_for: Dict[db.Participation, Decimal] = {}
+        for pion, sol in rows:
+            gen_pts = request.form.get(f"points_{pion.user_id}")
+            action_for[pion], points_for[pion], err = mo.util.parse_gen_points(gen_pts, for_task=ctx.task, for_round=ctx.round)
+            if err is not None:
+                edit_errors[pion.user_id] = err
+
+        if edit_errors:
+            return False
+
+        new_sols: List[db.Solution] = []
+        deleted_sols: List[db.Solution] = []
+        changed_sols: List[db.Solution] = []
+
+        def update_sol(pion, sol, action, points) -> Optional[str]:
+            print(f'update_sol: action={action} points={points}')
+            # Aktualizuje řešení, zkontroluje práva a případně vrátí chybovou hlašku.
+            # Vyhýbáme se všemu, co nejde rollbacknout, takže logování do souboru necháme na později.
+            if not sol:
+                if action == 'X':
+                    return None
+                # Řešení neexistuje a chceme ho založit
+                if not ctx.rights.can_create_solutions():
+                    return 'Nemáte právo k zakládání nových řešení, můžete jen editovat body'
                 sol = db.Solution(task=ctx.task, user=pion.user)
+                sess.add(sol)
+                mo.util.log(
+                    type=db.LogType.participant,
+                    what=pion.user_id,
+                    details={
+                        'action': 'solution-created',
+                        'task': task_id,
+                    },
+                )
                 new_sols.append(sol)
-                all_sols.append(sol)
+            elif action == 'X':
+                # Řešení existuje a chceme ho zrušit
+                if sol.final_submit or sol.final_feedback:
+                    return 'Nelze smazat řešení, ke kterému již byl odevzdán soubor'
+                if not ctx.rights.can_create_solutions():
+                    return 'Nemáte právo k zakládání nových řešení, můžete jen editovat body'
+                sess.delete(sol)
+                mo.util.log(
+                    type=db.LogType.participant,
+                    what=pion.user_id,
+                    details={
+                        'action': 'solution-removed',
+                        'task': task_id,
+                    },
+                )
+                deleted_sols.append(sol)
+                return None
 
-            if len(new_sols) > 0 and not ctx.rights.can_create_solutions():
-                flash("Nemáte právo k zakládání nových řešení, můžete jen editovat body", "danger")
-                return redirect(ctx.url_for('org_contest_task'))
-            elif len(new_sols) > 0:
-                for sol in new_sols:
-                    sess.add(sol)
-                    mo.util.log(
-                        type=db.LogType.participant,
-                        what=pion.user_id,
-                        details={
-                            'action': 'solution-created',
-                            'task': task_id,
-                        },
-                    )
-                    app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {pion.user_id} založeno")
-                sess.commit()
-                flash(inflect_with_number(len(new_sols), "Založeno %s nové řešení", "Založena %s nová řešení", "Založeno %s nových řešení"), "success")
-
-            # Zadání bodů:
-            if site_id is not None:
-                # Body lze zadat jen pro celý contest, ne pro site
-                if len(new_sols) == 0:
-                    flash("Žádné změny k uložení", "info")
-                return redirect(ctx.url_for('org_contest_task'))
-            count = 0
-            ok = True
-            for sol in all_sols:
-                points, error = mo.util.parse_points(request.form.get(f"points_{sol.user_id}"), for_task=ctx.task, for_round=ctx.round)
-                if error:
-                    flash(f'{sol.user.first_name} {sol.user.last_name}: {error}', 'danger')
-                    ok = False
-
-                if ok and points != sol.points:
-                    # Save points
-                    sol.points = points
-                    sess.add(db.PointsHistory(
-                        task=ctx.task,
-                        participant=sol.user,
-                        user=g.user,
-                        points_at=mo.now,
-                        points=points,
-                    ))
-                    count += 1
+            if sol.points != points:
+                # Chceme změnit počet bodů (po případném založení řešení)
+                if not ctx.rights.can_edit_points():
+                    return 'Nemáte právo k zadávání bodů, můžete jen zakládat řešení'
+                sol.points = points
+                sess.add(db.PointsHistory(
+                    task=ctx.task,
+                    participant=sol.user,
+                    user=g.user,
+                    points_at=mo.now,
+                    points=points,
+                ))
+                changed_sols.append(sol)
 
-            if count > 0 and not ctx.rights.can_edit_points():
-                flash("Nemáte právo k zadávání bodů, můžete jen zakládat řešení", "danger")
-                return redirect(ctx.url_for('org_contest_task'))
-            elif ok:
-                if count > 0:
-                    sess.commit()
-                    flash("Změněny body u " + inflect_number(count, "řešení", "řešení", "řešení"), "success")
-                elif len(new_sols) == 0:
-                    flash("Žádné změny k uložení", "info")
-                return redirect(ctx.url_for('org_contest_task'))
+            return None
+
+        # Připravíme všechny změny, ale ještě necommitujeme ani nelogujeme
+        for pion, sol in rows:
+            err = update_sol(pion, sol, action_for[pion], points_for[pion])
+            if err is not None:
+                edit_errors[pion.user_id] = err
+
+        if edit_errors:
+            return False
+
+        # Pokud jsme nic neudělali, vrátíme se
+        if len(new_sols) + len(deleted_sols) + len(changed_sols) == 0:
+            flash("Žádné změny k uložení.", "info")
+            return True
+
+        # Teď už víme, že se všechno povede, takže log a commit
+        if new_sols:
+            for sol in new_sols:
+                app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {pion.user_id} založeno")
+            flash(inflect_with_number(len(new_sols), "Založeno %s nové řešení.", "Založena %s nová řešení.", "Založeno %s nových řešení."), "success")
+
+        if deleted_sols:
+            for sol in deleted_sols:
+                app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {pion.user_id} smazáno")
+            flash(inflect_with_number(len(new_sols), "Smazáno %s řešení.", "Smazána %s řešení.", "Smazáno %s nových řešení."), "success")
+
+        if changed_sols:
+            flash("Změněny body u " + inflect_number(len(changed_sols), "řešení", "řešení", "řešení") + '.', "success")
+
+        sess.commit()
+        return True
+
+    if action_edit:
+        edit_form = TaskPointsEditForm()
+        if process_edit_form():
+            return redirect(ctx.url_for('org_contest_task'))
+        if edit_errors:
+            flash("Některé změny se nepodařilo provést. Důvod uvidíte po najetí myší nad políčka s červeným okrajem.", "danger")
 
     # Count papers for each solution
     paper_counts = {}
@@ -1167,6 +1210,7 @@ def org_contest_task(ct_id: int, task_id: int, site_id: Optional[int] = None):
         rows=rows, paper_counts=paper_counts,
         paper_link=lambda u, p: mo.web.util.org_paper_link(ctx.contest, ctx.site, u, p),
         edit_form=edit_form, request_form=request.form,
+        edit_errors=edit_errors,
     )
 
 
diff --git a/mo/web/templates/org_contest_user.html b/mo/web/templates/org_contest_user.html
index 8b04def038ac6139c7df9a0c0a258c112e99af04..91919e04160490e1e5c87012284f2bdd70658596 100644
--- a/mo/web/templates/org_contest_user.html
+++ b/mo/web/templates/org_contest_user.html
@@ -55,7 +55,7 @@
 
 <h3>Odevzdané úlohy</h3>
 
-{% with for_user=user, for_task=None, rows=task_sols, site_id=None %}
+{% with for_user=user, for_task=None, rows=task_sols %}
 	{% include "parts/org_solution_table.html" %}
 {% endwith %}
 
diff --git a/mo/web/templates/parts/org_solution_table.html b/mo/web/templates/parts/org_solution_table.html
index a44677b2d5cbf4c9abb505bba271f8f1447db4a5..dc9619d0852d7d430caed7148ca6a8f69625eef6 100644
--- a/mo/web/templates/parts/org_solution_table.html
+++ b/mo/web/templates/parts/org_solution_table.html
@@ -1,16 +1,35 @@
-{% set edit_points = not ctx.site and rights.can_edit_points() %}
-{% set edit_create = rights.can_create_solutions() %}
-{% set edit_both = edit_points and edit_create %}
+{#
+    Rozhraní:
+
+    - ctx
+    - rights
+    - round
+    - contest
+    - for_user (máme vypsat submity účastníka pro všechny úlohy)
+    - for_task (máme vypsat submity všech účastníků pro jednu úlohu)
+    - rows:
+	- režim for_user: dvojice (Task, Solution)
+	- režit for_task: dvojice (Participation, Solution)
+    - edit_form
+    - edit_errors (jen když máme edit_form)
+#}
+{% set edit_points = edit_form and rights.can_edit_points() %}
+{% set edit_create = edit_form and rights.can_create_solutions() %}
+{% set edit = edit_points or edit_create %}
 
-<p><i>
 {% if edit_form %}
+<p><i>
 {% if edit_points %}
-Změňte body ve sloupečku "Přidělené body". Prázdná hodnota znamená "nebodováno".
+Zde můžete bodovat odevzdaná řešení.
+{% else %}
+Zde můžete zakládat a rušit odevzdaná řešení.
 {% endif %}
+Prázdná hodnota ve sloupečku „Přidělené body“ znamená „neobodováno“,
+hodnota X znamená „řešení nebylo odevzdáno“.
 {% if edit_create %}
-Zaškrtnutím políček u řešení, která dosud neexistují, tato řešení založíte.
-To se hodí, pokud se nechystáte do systému nahrávat soubory řešení, ale jen chcete řešení vytvořit, aby jim
-bylo možné vyplnit body. Pokud nějaké řešení založíte omylem, lze toto prázdné řešení smazat v jeho detailu.
+Smazáním X založíte nové řešení – to se hodí, pokud se nechystáte do systému nahrávat soubory řešení,
+ale jen chcete řešení vytvořit, aby jim bylo možné vyplnit body.
+Pokud nějaké řešení založíte omylem, lze toto prázdné řešení smazat přepsáním bodů zpět na X.
 {% endif %}
 </i></p>
 <p><i>
@@ -24,7 +43,7 @@ finální (ve výchozím stavu poslední nahrané).{% elif rights.can_upload_sol
 </i></p>
 
 <p><i>Legenda: <span class='sol-warn icon'>⚠</span> odevzdané po termínu,
-	<span class="icon">🛈</span> nahráno někým jiným, než řešitelem, <span class="icon">🗐</span> existuje více verzí. Symboly po najetí myší zobrazí bližší informace.
+	<span class="icon">🛈</span> nahráno někým jiným než řešitelem, <span class="icon">🗐</span> existuje více verzí. Symboly po najetí myší zobrazí bližší informace.
 </i></p>
 
 <table class="data full">
@@ -93,47 +112,34 @@ finální (ve výchozím stavu poslední nahrané).{% elif rights.can_upload_sol
 				<span title="Celkem {{ paper_counts[key]|inflected('verze', 'verze', 'verzí') }}" class="icon">🗐</span>
 			{% endif %}
 		    {% else %}–{% endif %}
-		<td style="text-align: center;">
+		<td class="text-center">
 			{% if sol.note %}<span class="icon" title="Poznámka pro řešitele: {{ sol.note }}">🗩</span>{% endif %}
 			{% if sol.org_note %} <span class="icon" title="Interní poznámka: {{ sol.org_note }}">🗩</span>{% endif %}
-		<td>
-			{% if edit_points and edit_form %}
+		{% else %}
+		<td colspan="3" class="text-center">–
+		{% endif %}
+		<td class="text-center">
+			{% if edit %}
+				{% set def_pts = sol | sol_editable_points %}
+				{% set new_pts = request_form.get("points_{}".format(u.user_id)) %}
+				{% set err = edit_errors[u.user_id] %}
 				<input
-					type="number" class="form-control" name="points_{{u.user_id}}"
-					min=0 {% if task.max_points is not none %}max={{ task.max_points }}{% endif %}
-					step="{{ round.points_step }}"
-					value="{{ request_form.get("points_{}".format(u.user_id))|none_value(sol.points|decimal) }}"
-					oninput="this.classList.toggle('modified', this.value != '{{sol.points|decimal}}')"
+					type="text" class="form-control{% if err %} has-error{% endif %}" name="points_{{u.user_id}}"
+					value="{{ new_pts|none_value(def_pts) }}"
+					oninput="this.classList.toggle('modified', this.value != '{{ def_pts }}')"
 					size="4" tabindex={{ tabindex.value }} autofocus
+					data-sol="{% if sol %}1{% else %}0{% endif %}"
+					{% if not edit_create %}
+						disabled
+					{% endif %}
+					{% if err %}
+						title="{{ err }}"
+					{% endif %}
 				>
-				{% set tabindex.value = tabindex.value + 1%}
+				{% set tabindex.value = tabindex.value + 1 %}
 			{% else %}
-				{{ sol.points|decimal|none_value(Markup('<span class="unknown">?</span>')) }}
-			{% endif %}
-		{% elif edit_create and edit_form %}
-		<td colspan="{{ 3 if edit_points else 4 }}" class="text-center">
-			<input
-				type="checkbox" name="create_sol_{{u.user_id}}" id="create_sol_{{u.user_id}}"
-				onchange="document.getElementById('points_{{u.user_id}}').disabled = !this.checked"
-				{% if request_form.get("create_sol_{}".format(u.user_id)) %}checked{% endif %}
-				tabindex={{ tabindex.value }} autofocus
-			>
-			{% set tabindex.value = tabindex.value + 1%}
-			<label for="create_sol_{{u.user_id}}">Založit řešení</label>
-			{% if edit_points %}
-				<td><input
-					type="number" class="form-control" name="points_{{u.user_id}}" id="points_{{u.user_id}}"
-					min=0 {% if task.max_points is not none %}max={{ task.max_points }}{% endif %}
-					step="{{ round.points_step }}"
-					value="{{ request_form.get("points_{}".format(u.user_id))|none_value('') }}"
-					oninput="this.classList.toggle('modified', this.value != '')"
-					size="4" tabindex={{ tabindex.value }} autofocus
-				>
-				{% set tabindex.value = tabindex.value + 1%}
+				{{ sol|sol_display_points }}
 			{% endif %}
-		{% else %}
-		<td colspan="4" class="text-center">–
-		{% endif %}
 		<td><div class="btn-group">
 				<a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_submit_list', user_id=u.user_id, task_id=task.task_id, site_id=ctx.site_id) }}">Detail</a>
 		</div>
@@ -141,13 +147,43 @@ finální (ve výchozím stavu poslední nahrané).{% elif rights.can_upload_sol
 {% endfor %}
 </table>
 
+{% if edit %}
 <script type="text/javascript">
 	window.addEventListener("load", function() {
-		for (const ch of document.querySelectorAll("input[type=checkbox")) {
+		for (const ch of document.querySelectorAll("input[type=checkbox]")) {
 			ch.onchange();
 		}
-		for (const i of document.querySelectorAll("input[type=number]")) {
+		for (const i of document.querySelectorAll("input[type=text]")) {
 			i.oninput();
 		}
 	});
+
+	function update_disabled_fields(allow_create) {
+		for (const i of document.querySelectorAll("input[type=text]")) {
+			if (i.dataset.sol == "0") {
+				i.disabled = !allow_create;
+			}
+		}
+	}
 </script>
+{% endif %}
+
+{% if edit_create %}
+<div class="form-group"><div class="checkbox">
+	<label>
+		<input
+			type="checkbox"
+			name="create_sols"
+			onchange="update_disabled_fields(this.checked)"
+			tabindex={{ tabindex.value }} autofocus
+			value="y"
+			{% if not edit_create %}
+				disabled
+			{% elif not contest_online_submit %}
+				checked
+			{% endif %}
+		>
+		Zakládat nová řešení
+	</label>
+</div></div>
+{% endif %}
diff --git a/static/mo.css b/static/mo.css
index b79032c3ddff01a76dd0a9b884fadf97b1690f81..1fa431b3254e40a41e522731a8f074e571866ec3 100644
--- a/static/mo.css
+++ b/static/mo.css
@@ -494,6 +494,19 @@ input.modified {
 	border-color: orange;
 }
 
+table.data input.has-error {
+	border-color: #a94442;	/* See Bootstrap */
+	border-width: 2px;
+}
+
+table.data input.form-control {
+	/* Override Bootstrap's settings which are more suitable for large forms */
+	display: inline;
+	width: auto;
+	height: auto;
+	padding: 0;
+}
+
 /* Loading circle animation (source: https://codepen.io/jake-lee/pen/jrJYON) */
 
 @keyframes rotate {