diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index 13085d7eb6cd45953619cf60d1e7f2762811af2c..56792880990d643be5dbf57475b0718d8123d656 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -1,4 +1,6 @@
+import os
 from flask import render_template, g, redirect, url_for, flash, request
+from flask.helpers import send_file
 from flask_wtf import FlaskForm
 import flask_wtf.file
 import locale
@@ -1775,3 +1777,78 @@ def org_contest_scans(ct_id: int, site_id: Optional[int] = None):
         proc_task_fields=proc_task_fields,
         jobs=jobs,
     )
+
+
+@app.route('/org/contest/c/<int:ct_id>/scans/<int:job_id>', methods=('GET', 'POST'))
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/scans/<int:job_id>', methods=('GET', 'POST'))
+def org_contest_scans_process(ct_id: int, job_id: int, site_id: Optional[int] = None):
+    ctx = get_context(ct_id=ct_id, site_id=site_id, right_needed=Right.upload_solutions)
+    contest = ctx.contest
+    assert contest
+
+    sess = db.get_session()
+
+    # Získáme job a zkontrolujeme, že je to správný job a máme na něj práva
+    job = sess.query(db.Job).get(job_id)
+    if not job or job.type != db.JobType.process_scans:
+        flash('Dávka naskenovaných úloh nenalezena, skeny již byly pravděpodobně zpracovány nebo smazány.')
+        return redirect(ctx.url_for('org_contest_scans'))
+    if not g.user.is_admin and g.user.user_id != job.user_id:
+        raise werkzeug.exceptions.Forbidden()
+
+    pages = sess.query(db.ScanPage).filter_by(job_id=job_id).order_by('file_nr', 'page_nr').all()
+    tasks = sess.query(db.Task).filter(db.Task.task_id.in_(job.in_json['task_ids'])).order_by('code').all()
+    pion_query = sess.query(db.Participation).filter(
+        db.Participation.contest == contest,
+        db.Participation.state == db.PartState.active
+    )
+    if site_id is not None:
+        pion_query = pion_query.filter_by(place_id=site_id)
+    pions = pion_query.options(joinedload(db.Participation.user)).all()
+
+    def png_small(page: db.ScanPage) -> str:
+        return url_for(
+            'org_contest_scans_file', id=id, job_id=job_id, site_id=site_id,
+            file=f'p-{page.file_nr:02d}-{page.page_nr:04d}-small.png'
+        )
+
+    def png_full(page: db.ScanPage) -> str:
+        return url_for(
+            'org_contest_scans_file', id=id, job_id=job_id, site_id=site_id,
+            file=f'p-{page.file_nr:02d}-{page.page_nr:04d}-full.png'
+        )
+
+    return render_template(
+        'org_contest_scans_process.html',
+        ctx=ctx,
+        job=job,
+        tasks=tasks,
+        pions=pions,
+        pages=pages,
+        png_small=png_small,
+        png_full=png_full,
+    )
+
+
+@app.route('/org/contest/c/<int:ct_id>/scans/<int:job_id>/file/<file>')
+@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/scans/<int:job_id>/file/<file>')
+def org_contest_scans_file(ct_id: int, job_id: int, file: str, site_id: Optional[int] = None):
+    get_context(ct_id=ct_id, site_id=site_id, right_needed=Right.upload_solutions)
+
+    sess = db.get_session()
+
+    # Získáme job a zkontrolujeme, že je to správný job a máme na něj práva
+    job = sess.query(db.Job).get(job_id)
+    if not job or job.type != db.JobType.process_scans:
+        raise werkzeug.exceptions.NotFound()
+    if not g.user.is_admin and g.user.user_id != job.user_id:
+        raise werkzeug.exceptions.Forbidden()
+
+    if os.path.basename(file) != file:
+        raise werkzeug.exceptions.Forbidden()
+
+    path = os.path.join(job.dir_path(), file)
+    if os.path.isfile(path):
+        return send_file(path)
+    else:
+        raise werkzeug.exceptions.NotFound()
diff --git a/mo/web/templates/org_contest_scans_process.html b/mo/web/templates/org_contest_scans_process.html
new file mode 100644
index 0000000000000000000000000000000000000000..f0f4a1f8c7414bc4a2704acafd63bf44c8fe7c81
--- /dev/null
+++ b/mo/web/templates/org_contest_scans_process.html
@@ -0,0 +1,351 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block head %}
+	<script src="{{ asset_url('js/autocomplete.js') }}" type="text/javascript"></script>
+{% endblock %}
+
+{% block title %}
+Třídění skenů pro {{ ctx.round.name|lower }} kategorie {{ ctx.round.category }}
+{% endblock %}
+{% block breadcrumbs %}
+{{ ctx.breadcrumbs(action="Třídění skenů") }}
+{% endblock %}
+{% block body %}
+
+{% if job.state in [JobState.preparing, JobState.ready, JobState.running] %}
+<p>Právě běží zpracování, vyčkejte prosím několik okamžiků (stránka se sama obnoví).</p>
+
+<script>
+setTimeout(function () { location.reload(1); }, 10_000);
+</script>
+{% elif job.state == JobState.failed %}
+<p>Zpracování selhalo, více detailů naleznete v <a href="{{ url_for('org_job', id=job.job_id) }}">detailu dávky</a>.</p>
+{% else %}
+
+<p>Napravo můžete klikáním vybírat jednotlivé naskenované stránky a pomocí vrchních políček je přiřazovat jednotlivým úlohám a soutěžícím. Pokud
+je nějaké řešení přes více stránek, musí na sebe navazovat číslování stránek. Až bude vše správně zatříděné, můžete aktuální stav uložit
+tlačítkem <b>[Uložit]</b>. Poté můžete celou dávku odeslat ke zpracování pomocí <b>[Ukončit a zpracovat]</b> (řešení se uloží k řešitelům,
+tuto akci nelze vzít zpět).
+
+<p><b>TODO: Zatím není backend pro tlačítka, ale bude</b>
+
+<form method="post" class="btn-group pull-right">
+	<input class="btn btn-primary" type="submit" name="savel" value="Uložit" id="save-button">
+	<input class="btn btn-primary" type="submit" name="process-all" value="Ukončit a zpracovat" id="process-all-button" onclick="return confirm('Opravud ukončit a zpracovat? Nelze vzít zpět.');">
+</form>
+
+<style>
+	#sort_wrapper {
+		display: grid;
+		grid-template-columns: auto 350px;
+		grid-template-rows: 100px auto;
+		gap: 5px;
+	}
+	#sort_wrapper div.controls {
+		border: 1px #bbbbbb solid;
+		background-color: #e5e5e5;
+		border-radius: 5px;
+		padding: 5px 10px;
+	}
+	#sort_wrapper div.scan {
+		position: relative;
+	}
+	#sort_wrapper div.scan #scan_loader {
+		position: absolute;
+		width: 100%;
+		height: 100%;
+		opacity: 0;
+		background-color: black;
+		transition: 0.5s;
+	}
+	#sort_wrapper div.scan img {
+		max-width: 100%;
+		max-height: 100%;
+	}
+	#sort_wrapper div.pages {
+		max-height: 80vh;
+		grid-row: span 2;
+		overflow-y: scroll;
+		position: relative;
+	}
+	#sort_wrapper div.pages table {
+		width: 100%;
+		margin: 0px;
+	}
+	#sort_wrapper div.pages table thead {
+		position: sticky;
+		top: 0px;
+	}
+	#sort_wrapper div.pages table tr {
+		cursor: pointer;
+	}
+	#sort_wrapper div.pages table tr.ok { background-color: lightgreen; }
+	#sort_wrapper div.pages table tr.empty { background-color: #ff7777; }
+	#sort_wrapper div.pages table tr.changed { background-color: yellow; }
+	#sort_wrapper div.pages table tr.active {
+		color: white;
+		background-color: black;
+	}
+	#sort_wrapper div.pages table tr.active.changed { color: yellow; }
+</style>
+
+<p><b>Ovládání klávesnicí:</b> <code>↑</code> a <code>↓</code> – posun ve skenech, <code>e</code> – přepnutí se na editaci,
+<code>[esc]</code> – vyskočení z editačního políčka při editaci, <code>r</code> – reset (vrácení) změn u konkrétního skenu,
+<code>x</code> – nastavit stránku jako prázdnou, <code>f</code> – nastavit stránku jako pokračování minulé.
+
+<div id="sort_wrapper">
+	<div class="controls form-horizontal">
+		<span tabindex=1 onfocus="document.getElementById('seq_input').focus();"></span>
+		<div class="form-group">
+			<label class="col-sm-2 control-label" for="user_input">Soutěžící:</label>
+			<div class="col-sm-10 autocomplete">
+				<input tabindex=2 class="form-control" type="text" id="user_input">
+			</div>
+		</div>
+		<div class="form-group">
+			<label class="col-sm-2 control-label" for="task_input">Úloha:</label>
+			<div class="col-sm-4 autocomplete">
+				<input tabindex=3 class="form-control" type="text" id="task_input">
+			</div>
+			<label class="col-sm-2 control-label" for="seq_input">Stránka:</label>
+			<div class="col-sm-4 autocomplete">
+				<input tabindex=4 class="form-control" type="number" min="1" id="seq_input">
+			</div>
+		</div>
+		<span tabindex=5 onfocus="document.getElementById('user_input').focus();"></span>
+	</div>
+	<div class="pages">
+		<table class="data">
+			<thead>
+				<th title="Číslo skenu">#
+				<th>Úloha
+				<th>Soutěžící
+				<th title="Stránka">St.
+			</thead>
+			<tbody id="pages_rows">
+			</tbody>
+		</table>
+	</div>
+	<div class="scan">
+		<div id="scan_loader"></div>
+		<img id="scan_img">
+	</div>
+</div>
+
+<script type="text/javascript">
+var tasks = [
+{% for task in tasks %}
+	{ id: {{ task.task_id }}, code: "{{ task.code }}", name: "{{ task.name }}" },
+{% endfor %}
+];
+
+var users = [
+{% for pion in pions %}
+	{ id: {{ pion.user_id }}, name: "{{ pion.user.first_name }} {{ pion.user.last_name }}" },
+{% endfor %}
+];
+
+var pages = [
+{% for page in pages %}
+	{
+		file_nr: {{ page.file_nr }},
+		page_nr: {{ page.page_nr }},
+		user_id: {{ page.user_id if page.user_id else 'null' }},
+		orig_user_id: {{ page.user_id if page.user_id else 'null' }},
+		task_id: {{ page.task_id if page.task_id else 'null' }},
+		orig_task_id: {{ page.task_id if page.task_id else 'null' }},
+		seq_id: {{ page.seq_id }},
+		orig_seq_id: {{ page.seq_id }},
+		img_full: "{{ png_full(page) }}",
+		img_small: "{{ png_small(page) }}",
+	},
+{% endfor %}
+];
+
+var tasks_map = {};
+var tasks_autocomplete = [];
+var users_map = {};
+var users_autocomplete = [];
+
+// Global elements
+var tbody = document.getElementById('pages_rows');
+var img = document.getElementById('scan_img');
+var loader = document.getElementById('scan_loader');
+var rows = tbody.rows;
+var user_input = document.getElementById('user_input');
+var task_input = document.getElementById('task_input');
+var seq_input = document.getElementById('seq_input');
+var process_all_button = document.getElementById('process-all-button');
+
+function isChanged(p) {return (p.orig_user_id != p.user_id || p.orig_task_id != p.task_id || p.orig_seq_id != p.seq_id); }
+function isOk(p) { return (p.user_id && p.task_id); }
+function isEmpty(p) { return (p.user_id == null && p.task_id == null); }
+
+function beforeUnload(e) {
+	e.preventDefault();
+	return e.returnValue = "Byly provedeny editace, opuštěním stránky je ztratíte. Skutečně opustit stránku?";
+}
+
+function setRow(i) {
+	p = pages[i];
+	row = rows[i];
+
+	row.innerHTML = '';
+	row.className = '';
+	row.title = '';
+	cellScan = row.insertCell(-1);
+	cellTask = row.insertCell(-1);
+	cellUser = row.insertCell(-1);
+	cellSeq = row.insertCell(-1);
+
+	cellScan.innerHTML = p.file_nr + '/' + p.page_nr;
+	if (p.task_id) {
+		task = tasks_map[p.task_id];
+		cellTask.innerHTML = task.code + ' ' + task.name;
+	}
+	if (p.user_id) {
+		user = users_map[p.user_id];
+		cellUser.innerHTML = user.name;
+	}
+	cellSeq.innerHTML = p.seq_id;
+
+	if (isOk(p)) {
+		row.classList.add('ok');
+	}
+	if (isEmpty(p)) {
+		row.classList.add('empty');
+		row.title += "Prázdná stránka, nebude se ukládat\n";
+	}
+
+	if (isChanged(p)) {
+		process_all_button.disabled = true;
+		window.onbeforeunload = beforeUnload;
+		row.classList.add('changed');
+		row.title += "Neuložené změny\n";
+	}
+}
+
+var activeRow = 0;
+function selectRow(i) {
+	page = pages[i];
+	user = null;
+
+	user_input.blur();
+
+	rows[activeRow].classList.remove('active');
+	rows[i].classList.add('active');
+	activeRow = i;
+
+	loader.style.opacity = 0.7;
+	img.onload = function() {
+		loader.style.opacity = 0;
+		if (page.user_id) {
+			user_input.value = users_map[page.user_id].name;
+		} else {
+			user_input.value = '';
+		}
+
+		if (page.task_id) {
+			task = tasks_map[page.task_id];
+			task_input.value = task.code + ' ' + task.name;
+		} else {
+			task_input.value = '';
+		}
+
+		seq_input.value = page.seq_id;
+	}
+	img.src = page.img_full;
+}
+
+function refreshActiveRow() {
+	setRow(activeRow);
+	selectRow(activeRow);
+}
+
+// Activate autocomplete on two fields + prevent catching keys on third one
+autocomplete(task_input, tasks_autocomplete, callback=function (id) {
+	pages[activeRow].task_id = id;
+	refreshActiveRow();
+});
+autocomplete(user_input, users_autocomplete, callback=function (id) {
+	pages[activeRow].user_id = id;
+	refreshActiveRow();
+});
+seq_input.addEventListener("keydown", function(e) {
+	if (e.keyCode == 27) { // escape
+		this.blur();
+	}
+	e.stopPropagation();
+});
+seq_input.addEventListener("input", function(e) {
+	pages[activeRow].seq_id = seq_input.value;
+	refreshActiveRow();
+});
+
+// Keyboard control
+function checkKey(e) {
+	e = e || window.event;
+	if (e.keyCode == '38') {
+		if (activeRow > 0) {
+			selectRow(activeRow - 1);
+		}
+	} else if (e.keyCode == '40') {
+		if (activeRow < pages.length - 1) {
+			selectRow(activeRow + 1);
+		}
+	} else if (e.key == 'e') {
+		user_input.focus();
+	} else if (e.key == 'x') {
+		page = pages[activeRow];
+		page.user_id = null;
+		page.task_id = null;
+		page.seq_id = null;
+		refreshActiveRow();
+	} else if (e.key == 'r') {
+		page = pages[activeRow];
+		page.user_id = page.orig_user_id;
+		page.task_id = page.orig_task_id;
+		page.seq_id = page.orig_seq_id;
+		refreshActiveRow();
+	} else if (e.key == 'f') {
+		if (activeRow > 1) {
+			prev = pages[activeRow-1];
+			page = pages[activeRow];
+			if (isOk(prev)) {
+				page.user_id = prev.user_id;
+				page.task_id = prev.task_id;
+				page.seq_id = prev.seq_id + 1;
+				refreshActiveRow();
+			} else {
+				alert('Nelze nastavit jako pokračování předchozí stránky, je nekompletní.');
+			}
+		}
+	} else {
+		return;
+	}
+	e.preventDefault();
+}
+document.onkeydown = checkKey;
+
+// Start everything :)
+
+for (task of tasks) {
+	tasks_map[task.id] = task; console.log(task);
+	tasks_autocomplete.push([task.id, task.code + ' ' + task.name]);
+}
+for (user of users) {
+	users_map[user.id] = user;
+	users_autocomplete.push([user.id, user.name]);
+}
+for (page of pages) { tbody.insertRow(); }
+for (let i = 0; i < pages.length; i++) {
+	setRow(i);
+	rows[i].onclick = function() { selectRow(i); }
+}
+
+selectRow(0);
+</script>
+
+{% endif %}
+{% endblock %}