diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000000000000000000000000000000000000..996cd5aad4d7331e9c74dcaa7eb6483a5116eed9
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,216 @@
+module.exports = {
+  extends: [
+    "eslint:recommended",
+    "plugin:vue/strongly-recommended",
+    //    "plugin:prettier/recommended",
+    "plugin:@intlify/vue-i18n/recommended",
+  ],
+  rules: {
+    "no-unused-vars": "warn",
+    "vue/no-unused-vars": "off",
+    "vue/multi-word-component-names": "off",
+    "@intlify/vue-i18n/key-format-style": [
+      "error",
+      "snake_case",
+      {
+        splitByDots: false,
+      },
+    ],
+    // "@intlify/vue-i18n/no-unused-keys": ["warn", {}],
+    "@intlify/vue-i18n/no-raw-text": [
+      "error",
+      {
+        ignoreNodes: ["v-icon"],
+        ignorePattern: "^[-–—·#:()\\[\\]&\\.\\s]+$",
+      },
+    ],
+    // Fixes for prettier (avoid eslint-config-prettier)
+    // The following rules can be used in some cases. See the README for more
+    // information. (These are marked with `0` instead of `"off"` so that a
+    // script can distinguish them.)
+    curly: 0,
+    "lines-around-comment": 0,
+    "max-len": 0,
+    "no-confusing-arrow": 0,
+    "no-mixed-operators": 0,
+    "no-tabs": 0,
+    "no-unexpected-multiline": 0,
+    quotes: 0,
+    "@typescript-eslint/quotes": 0,
+    "babel/quotes": 0,
+    "vue/html-self-closing": 0,
+    "vue/max-len": 0,
+
+    // The rest are rules that you never need to enable when using Prettier.
+    "array-bracket-newline": "off",
+    "array-bracket-spacing": "off",
+    "array-element-newline": "off",
+    "arrow-parens": "off",
+    "arrow-spacing": "off",
+    "block-spacing": "off",
+    "brace-style": "off",
+    "comma-dangle": "off",
+    "comma-spacing": "off",
+    "comma-style": "off",
+    "computed-property-spacing": "off",
+    "dot-location": "off",
+    "eol-last": "off",
+    "func-call-spacing": "off",
+    "function-call-argument-newline": "off",
+    "function-paren-newline": "off",
+    "generator-star": "off",
+    "generator-star-spacing": "off",
+    "implicit-arrow-linebreak": "off",
+    indent: "off",
+    "jsx-quotes": "off",
+    "key-spacing": "off",
+    "keyword-spacing": "off",
+    "linebreak-style": "off",
+    "multiline-ternary": "off",
+    "newline-per-chained-call": "off",
+    "new-parens": "off",
+    "no-arrow-condition": "off",
+    "no-comma-dangle": "off",
+    "no-extra-parens": "off",
+    "no-extra-semi": "off",
+    "no-floating-decimal": "off",
+    "no-mixed-spaces-and-tabs": "off",
+    "no-multi-spaces": "off",
+    "no-multiple-empty-lines": "off",
+    "no-reserved-keys": "off",
+    "no-space-before-semi": "off",
+    "no-trailing-spaces": "off",
+    "no-whitespace-before-property": "off",
+    "no-wrap-func": "off",
+    "nonblock-statement-body-position": "off",
+    "object-curly-newline": "off",
+    "object-curly-spacing": "off",
+    "object-property-newline": "off",
+    "one-var-declaration-per-line": "off",
+    "operator-linebreak": "off",
+    "padded-blocks": "off",
+    "quote-props": "off",
+    "rest-spread-spacing": "off",
+    semi: "off",
+    "semi-spacing": "off",
+    "semi-style": "off",
+    "space-after-function-name": "off",
+    "space-after-keywords": "off",
+    "space-before-blocks": "off",
+    "space-before-function-paren": "off",
+    "space-before-function-parentheses": "off",
+    "space-before-keywords": "off",
+    "space-in-brackets": "off",
+    "space-in-parens": "off",
+    "space-infix-ops": "off",
+    "space-return-throw-case": "off",
+    "space-unary-ops": "off",
+    "space-unary-word-ops": "off",
+    "switch-colon-spacing": "off",
+    "template-curly-spacing": "off",
+    "template-tag-spacing": "off",
+    "unicode-bom": "off",
+    "wrap-iife": "off",
+    "wrap-regex": "off",
+    "yield-star-spacing": "off",
+    "@babel/object-curly-spacing": "off",
+    "@babel/semi": "off",
+    "@typescript-eslint/brace-style": "off",
+    "@typescript-eslint/comma-dangle": "off",
+    "@typescript-eslint/comma-spacing": "off",
+    "@typescript-eslint/func-call-spacing": "off",
+    "@typescript-eslint/indent": "off",
+    "@typescript-eslint/keyword-spacing": "off",
+    "@typescript-eslint/member-delimiter-style": "off",
+    "@typescript-eslint/no-extra-parens": "off",
+    "@typescript-eslint/no-extra-semi": "off",
+    "@typescript-eslint/object-curly-spacing": "off",
+    "@typescript-eslint/semi": "off",
+    "@typescript-eslint/space-before-blocks": "off",
+    "@typescript-eslint/space-before-function-paren": "off",
+    "@typescript-eslint/space-infix-ops": "off",
+    "@typescript-eslint/type-annotation-spacing": "off",
+    "babel/object-curly-spacing": "off",
+    "babel/semi": "off",
+    "flowtype/boolean-style": "off",
+    "flowtype/delimiter-dangle": "off",
+    "flowtype/generic-spacing": "off",
+    "flowtype/object-type-curly-spacing": "off",
+    "flowtype/object-type-delimiter": "off",
+    "flowtype/quotes": "off",
+    "flowtype/semi": "off",
+    "flowtype/space-after-type-colon": "off",
+    "flowtype/space-before-generic-bracket": "off",
+    "flowtype/space-before-type-colon": "off",
+    "flowtype/union-intersection-spacing": "off",
+    "react/jsx-child-element-spacing": "off",
+    "react/jsx-closing-bracket-location": "off",
+    "react/jsx-closing-tag-location": "off",
+    "react/jsx-curly-newline": "off",
+    "react/jsx-curly-spacing": "off",
+    "react/jsx-equals-spacing": "off",
+    "react/jsx-first-prop-new-line": "off",
+    "react/jsx-indent": "off",
+    "react/jsx-indent-props": "off",
+    "react/jsx-max-props-per-line": "off",
+    "react/jsx-newline": "off",
+    "react/jsx-one-expression-per-line": "off",
+    "react/jsx-props-no-multi-spaces": "off",
+    "react/jsx-tag-spacing": "off",
+    "react/jsx-wrap-multilines": "off",
+    "standard/array-bracket-even-spacing": "off",
+    "standard/computed-property-even-spacing": "off",
+    "standard/object-curly-even-spacing": "off",
+    "unicorn/empty-brace-spaces": "off",
+    "unicorn/no-nested-ternary": "off",
+    "unicorn/number-literal-case": "off",
+    "vue/array-bracket-newline": "off",
+    "vue/array-bracket-spacing": "off",
+    "vue/arrow-spacing": "off",
+    "vue/block-spacing": "off",
+    "vue/block-tag-newline": "off",
+    "vue/brace-style": "off",
+    "vue/comma-dangle": "off",
+    "vue/comma-spacing": "off",
+    "vue/comma-style": "off",
+    "vue/dot-location": "off",
+    "vue/func-call-spacing": "off",
+    "vue/html-closing-bracket-newline": "off",
+    "vue/html-closing-bracket-spacing": "off",
+    "vue/html-end-tags": "off",
+    "vue/html-indent": "off",
+    "vue/html-quotes": "off",
+    "vue/key-spacing": "off",
+    "vue/keyword-spacing": "off",
+    "vue/max-attributes-per-line": "off",
+    "vue/multiline-html-element-content-newline": "off",
+    "vue/multiline-ternary": "off",
+    "vue/mustache-interpolation-spacing": "off",
+    "vue/no-extra-parens": "off",
+    "vue/no-multi-spaces": "off",
+    "vue/no-spaces-around-equal-signs-in-attribute": "off",
+    "vue/object-curly-newline": "off",
+    "vue/object-curly-spacing": "off",
+    "vue/object-property-newline": "off",
+    "vue/operator-linebreak": "off",
+    "vue/quote-props": "off",
+    "vue/script-indent": "off",
+    "vue/singleline-html-element-content-newline": "off",
+    "vue/space-in-parens": "off",
+    "vue/space-infix-ops": "off",
+    "vue/space-unary-ops": "off",
+    "vue/template-curly-spacing": "off",
+  },
+  settings: {
+    "vue-i18n": {
+      localeDir: "./aleksis/core/frontend/messages/*.{json}",
+      messageSyntaxVersion: "^8.0.0",
+    },
+  },
+  env: {
+    es2021: true,
+  },
+  parserOptions: {
+    ecmaVersion: "latest",
+  },
+};
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 2b53733e8e0413b54a92dc721936cb86cfb74aa0..8beab2e52d96641789ac66d770118657f6847f02 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,19 +1,19 @@
 include:
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/general.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/prepare/lock.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/test/lint.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/test/security.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/build/dist.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/build/docs.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: "/ci/deploy/trigger_dist.yml"
-    - project: "AlekSIS/official/AlekSIS"
-      file: "/ci/docker/image.yml"
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/publish/pypi.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/general.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/prepare/lock.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/test/lint.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/test/security.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/build/dist.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/build/docs.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: "/ci/deploy/trigger_dist.yml"
+  - project: "AlekSIS/official/AlekSIS"
+    file: "/ci/docker/image.yml"
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/publish/pypi.yml
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000000000000000000000000000000000000..38d141b743fd55678f50077c0617924475817095
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,91 @@
+# Byte-compiled / optimized / DLL files
+*$py.class
+*.py[cod]
+__pycache__/
+
+# Distribution / packaging
+*.egg
+*.egg-info/
+.Python
+.eggs/
+.installed.cfg
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+
+# Installer logs
+pip-delete-this-directory.txt
+pip-log.txt
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+
+# pyenv
+.python-version
+
+# Environments
+.env
+.venv
+ENV/
+env/
+venv/
+
+# Editors
+*~
+DEADJOE
+\#*#
+
+# IntelliJ
+.idea
+.idea/
+
+# Database
+db.sqlite3
+
+# Sphinx
+docs/_build/
+
+# TeX
+*.aux
+
+# Generated files
+/node_modules/
+/static/
+/whoosh_index/
+poetry.lock
+
+.coverage
+.mypy_cache/
+.tox/
+htmlcov/
+maintenance_mode_state.txt
+media/
+package-lock.json
+yarn.lock
+
+# VSCode
+.vscode/
+.history/
+*.code-workspace
+
+/cache
+
+# Add HTML files to avoid problems with unsupported Django templates
+*.html
+
+# Do not check/reformat generated files
+aleksis/core/util/licenses.json
+.vite/
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ad28d552a386b7faf9cd9ca6b4c08f0f947fee11..3057df7a4f69a3777d18bc5b5a331dad02f799f9 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -6,21 +6,29 @@ All notable changes to this project will be documented in this file.
 The format is based on `Keep a Changelog`_,
 and this project adheres to `Semantic Versioning`_.
 
-Breaking changes
-----------------
+Unreleased
+----------
+
+`2.0`_ - 2023-05-15
+-------------------
+
+Nothing changed.
+
+`2.0b0`_ - 2023-03-01
+---------------------
+
+This version requires AlekSIS-Core 3.0. It is incompatible with any previous
+version.
 
 Removed
 ~~~~~~~
 
-* Remove legacy menu entries.
-
-Unreleased
-----------
+* Legacy menu integration for AlekSIS-Core pre-3.0
 
 Added
 ~~~~~
 
-* Support for usage with new AlekSIS SPA.
+* Add SPA support for AlekSIS-Core 3.0
 
 Fixed
 ~~~~~
@@ -58,6 +66,8 @@ Added
 .. _Semantic Versioning: https://semver.org/spec/v2.0.0.html
 
 
-.. _1.0: https://edugit.org/AlekSIS/official//AlekSIS-App-Stoelindeling/-/tags/1.0
-.. _1.0.1: https://edugit.org/AlekSIS/official//AlekSIS-App-Stoelindeling/-/tags/1.0.1
-.. _1.0.2: https://edugit.org/AlekSIS/official//AlekSIS-App-Stoelindeling/-/tags/1.0.2
+.. _1.0: https://edugit.org/AlekSIS/official/AlekSIS-App-Stoelindeling/-/tags/1.0
+.. _1.0.1: https://edugit.org/AlekSIS/official/AlekSIS-App-Stoelindeling/-/tags/1.0.1
+.. _1.0.2: https://edugit.org/AlekSIS/official/AlekSIS-App-Stoelindeling/-/tags/1.0.2
+.. _2.0b0: https://edugit.org/AlekSIS/official/AlekSIS-App-Stoelindeling/-/tags/2.0b0
+.. _2.0: https://edugit.org/AlekSIS/official/AlekSIS-App-Stoelindeling/-/tags/2.0
diff --git a/aleksis/apps/stoelindeling/frontend/index.js b/aleksis/apps/stoelindeling/frontend/index.js
index 367dad9dc24bca2bde0a0018761fc07af0754212..cf576c564221af38365e775a09fd836b286d7311 100644
--- a/aleksis/apps/stoelindeling/frontend/index.js
+++ b/aleksis/apps/stoelindeling/frontend/index.js
@@ -1,69 +1,67 @@
-export default
-  {
-    meta: {
-      inMenu: true,
-      titleKey: "stoelindeling.menu_title",
-      icon: "mdi-view-list-outline",
-      permission: "stoelindeling.view_seatingplans_rule",
-    },
-    props: {
-      byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-    },
-    children: [
-      {
-        path: "seating_plans/",
-        component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
-        name: "stoelindeling.seatingPlans",
-        meta: {
-          inMenu: true,
-          titleKey: "stoelindeling.menu_title",
-          icon: "mdi-view-list-outline",
-          permission: "stoelindeling.view_seatingplans_rule",
-        },
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
+export default {
+  meta: {
+    inMenu: true,
+    titleKey: "stoelindeling.menu_title",
+    icon: "mdi-view-list-outline",
+    permission: "stoelindeling.view_seatingplans_rule",
+  },
+  props: {
+    byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+  },
+  children: [
+    {
+      path: "seating_plans/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "stoelindeling.seatingPlans",
+      meta: {
+        inMenu: true,
+        titleKey: "stoelindeling.menu_title",
+        icon: "mdi-view-list-outline",
+        permission: "stoelindeling.view_seatingplans_rule",
       },
-      {
-        path: "seating_plans/create/",
-        component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
-        name: "stoelindeling.createSeatingPlan",
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
       },
-      {
-        path: "seating_plans/:pk/",
-        component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
-        name: "stoelindeling.seatingPlan",
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
+    },
+    {
+      path: "seating_plans/create/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "stoelindeling.createSeatingPlan",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
       },
-      {
-        path: "seating_plans/:pk/edit/",
-        component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
-        name: "stoelindeling.editSeatingPlan",
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
+    },
+    {
+      path: "seating_plans/:pk/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "stoelindeling.seatingPlan",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
       },
-      {
-        path: "seating_plans/:pk/copy/",
-        component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
-        name: "stoelindeling.copySeatingPlan",
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
+    },
+    {
+      path: "seating_plans/:pk/edit/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "stoelindeling.editSeatingPlan",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
       },
-      {
-        path: "seating_plans/:pk/delete/",
-        component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
-        name: "stoelindeling.deleteSeatingPlan",
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
+    },
+    {
+      path: "seating_plans/:pk/copy/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "stoelindeling.copySeatingPlan",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+    {
+      path: "seating_plans/:pk/delete/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "stoelindeling.deleteSeatingPlan",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
       },
-    ],
-  }
-
+    },
+  ],
+};
diff --git a/aleksis/apps/stoelindeling/frontend/messages/de.json b/aleksis/apps/stoelindeling/frontend/messages/de.json
index 4d4542c438bbc95914e3371e7219bbdc6f8580d2..2422e7d288ca61cce47ddbea0193a99ae07e8653 100644
--- a/aleksis/apps/stoelindeling/frontend/messages/de.json
+++ b/aleksis/apps/stoelindeling/frontend/messages/de.json
@@ -3,4 +3,3 @@
     "menu_title": "Sitzpläne"
   }
 }
-
diff --git a/aleksis/apps/stoelindeling/frontend/messages/en.json b/aleksis/apps/stoelindeling/frontend/messages/en.json
index a95f9cc2015a4239ad6a880ad04ca623f220763f..ff661f5ec5f5a31822d2e758e106b633826b0c33 100644
--- a/aleksis/apps/stoelindeling/frontend/messages/en.json
+++ b/aleksis/apps/stoelindeling/frontend/messages/en.json
@@ -3,4 +3,3 @@
     "menu_title": "Seating plans"
   }
 }
-
diff --git a/aleksis/apps/stoelindeling/frontend/messages/ru.json b/aleksis/apps/stoelindeling/frontend/messages/ru.json
index b58b25b5033b6cf8221799cca45636e6d3b63c68..6b236063928ab79537f98e80a8236f0846b77c30 100644
--- a/aleksis/apps/stoelindeling/frontend/messages/ru.json
+++ b/aleksis/apps/stoelindeling/frontend/messages/ru.json
@@ -1,5 +1,5 @@
 {
-    "stoelindeling": {
-        "menu_title": "Планы рассадки"
-    }
+  "stoelindeling": {
+    "menu_title": "Планы рассадки"
+  }
 }
diff --git a/aleksis/apps/stoelindeling/frontend/messages/uk.json b/aleksis/apps/stoelindeling/frontend/messages/uk.json
index 64657a7f7ba742098935cf058348fc0cbf987182..3c089f2dcf92ee3c3ab78e171e64fe9ab61fa67f 100644
--- a/aleksis/apps/stoelindeling/frontend/messages/uk.json
+++ b/aleksis/apps/stoelindeling/frontend/messages/uk.json
@@ -1,5 +1,5 @@
 {
-    "stoelindeling": {
-        "menu_title": "Плани розсаджень"
-    }
+  "stoelindeling": {
+    "menu_title": "Плани розсаджень"
+  }
 }
diff --git a/aleksis/apps/stoelindeling/static/css/stoelindeling/seating_plan.css b/aleksis/apps/stoelindeling/static/css/stoelindeling/seating_plan.css
index 7a7891faa09f09c31411bc55d384d12535648b14..7446b6acc2aaadca36847b6ee185db5ab57f2f7b 100644
--- a/aleksis/apps/stoelindeling/static/css/stoelindeling/seating_plan.css
+++ b/aleksis/apps/stoelindeling/static/css/stoelindeling/seating_plan.css
@@ -1,78 +1,78 @@
 .seat {
-    width: 80px;
-    height: 80px;
-    margin: 2px;
-    padding: 2px;
-    display: flex;
-    flex-direction: column;
-    justify-content: center;
-    text-align: center;
-    align-items: center;
-    font-size: 0.8em;
-    word-break: break-word;
-    position: relative;
+  width: 80px;
+  height: 80px;
+  margin: 2px;
+  padding: 2px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  text-align: center;
+  align-items: center;
+  font-size: 0.8em;
+  word-break: break-word;
+  position: relative;
 }
 
 .drag-area .seat {
-    cursor: grab;
+  cursor: grab;
 }
 
 #seats {
-    overflow: auto;
-    width: auto;
+  overflow: auto;
+  width: auto;
 }
 
 .seat-grid {
-    display: flex;
-    overflow-x: scroll;
+  display: flex;
+  overflow-x: scroll;
 }
 
 .seat-grid-col {
-    margin: 0;
+  margin: 0;
 }
 
 .seat-grid-cell {
-    width: 86px;
-    height: 86px;
-    border: 1px dashed darkgrey;
+  width: 86px;
+  height: 86px;
+  border: 1px dashed darkgrey;
 }
 
 #not-used-seats {
-    display: flex;
-    flex-direction: row;
-    flex-wrap: wrap;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
 }
 
 .seat-grid-add-col {
-    padding: 0 5px;
+  padding: 0 5px;
 }
 
 .seat-grid-add-col button {
-    height: 100%;
-    padding: 0 5px;
+  height: 100%;
+  padding: 0 5px;
 }
 
 .seat-grid-add-row {
-    padding: 5px 5px;
-    width: 100%;
+  padding: 5px 5px;
+  width: 100%;
 }
 
 .seat-grid-add-row button {
-    width: 100%;
+  width: 100%;
 }
 
 .seat .clip-circle {
-    width: auto;
-    height: 60%;
-    position: absolute;
-    left: 16px;
-    right: 16px;
-    top: 5px;
+  width: auto;
+  height: 60%;
+  position: absolute;
+  left: 16px;
+  right: 16px;
+  top: 5px;
 }
 
 .seat-name {
-    position: absolute;
-    bottom: 2px;
-    left: 2px;
-    right: 2px;
+  position: absolute;
+  bottom: 2px;
+  left: 2px;
+  right: 2px;
 }
diff --git a/aleksis/apps/stoelindeling/static/js/stoelindeling/edit_seating_plan.js b/aleksis/apps/stoelindeling/static/js/stoelindeling/edit_seating_plan.js
index d1dd2d9ffeaf438eae7deaa2a8ba3b7eb76be87f..e5155560f74cb7c320a500d4c2bd5f5a8c11bbfa 100644
--- a/aleksis/apps/stoelindeling/static/js/stoelindeling/edit_seating_plan.js
+++ b/aleksis/apps/stoelindeling/static/js/stoelindeling/edit_seating_plan.js
@@ -1,90 +1,115 @@
 function setPosition(event) {
-    const parent = $(event.to);
-    const xPos = parent.data("x");
-    const yPos = parent.data("y");
+  const parent = $(event.to);
+  const xPos = parent.data("x");
+  const yPos = parent.data("y");
 
-    let pk = $(event.item).attr("data-pk");
-    let sel = $("#seating-plan-form input[value=" + pk + "].pk-input");
-    let x = sel.nextAll("input.x-input").first();
-    let y = sel.nextAll("input.y-input").first();
-    let seated = sel.nextAll("input.seated-input").first();
+  let pk = $(event.item).attr("data-pk");
+  let sel = $("#seating-plan-form input[value=" + pk + "].pk-input");
+  let x = sel.nextAll("input.x-input").first();
+  let y = sel.nextAll("input.y-input").first();
+  let seated = sel.nextAll("input.seated-input").first();
 
-    if (parent.hasClass("seat-grid-cell")) {
-        x.val(xPos);
-        y.val(yPos);
-        seated.val("True");
-    } else {
-        x.val("0");
-        y.val("0");
-        seated.val("False");
-    }
+  if (parent.hasClass("seat-grid-cell")) {
+    x.val(xPos);
+    y.val(yPos);
+    seated.val("True");
+  } else {
+    x.val("0");
+    y.val("0");
+    seated.val("False");
+  }
 }
 
 function enableSeatGridCells() {
-    $('.seat-grid-cell').sortable({
-        group: 'seats',
-        animation: 150,
-        onEnd: setPosition
-    });
+  $(".seat-grid-cell").sortable({
+    group: "seats",
+    animation: 150,
+    onEnd: setPosition,
+  });
 }
 
 function getStartY() {
-    return Number.parseInt($(".seat-grid .seat-grid-col .seat-grid-cell").first().data("y"))
+  return Number.parseInt(
+    $(".seat-grid .seat-grid-col .seat-grid-cell").first().data("y")
+  );
 }
 
 function getEndY() {
-    return Number.parseInt($(".seat-grid .seat-grid-col .seat-grid-cell").last().data("y"))
+  return Number.parseInt(
+    $(".seat-grid .seat-grid-col .seat-grid-cell").last().data("y")
+  );
 }
 
 function getStartX() {
-    return Number.parseInt($(".seat-grid .seat-grid-col .seat-grid-cell").first().data("x"))
+  return Number.parseInt(
+    $(".seat-grid .seat-grid-col .seat-grid-cell").first().data("x")
+  );
 }
 
 function getEndX() {
-    return Number.parseInt($(".seat-grid .seat-grid-col .seat-grid-cell").last().data("x"))
+  return Number.parseInt(
+    $(".seat-grid .seat-grid-col .seat-grid-cell").last().data("x")
+  );
 }
 
 function buildRow(x) {
-    const el = $("<div class='seat-grid-col'></div>");
-    for (let y = getStartY(); y <= getEndY(); y++) {
-        el.append("<div class='seat-grid-cell' data-x='" + x + "' data-y='" + y + "'></div>");
-    }
-    return el;
+  const el = $("<div class='seat-grid-col'></div>");
+  for (let y = getStartY(); y <= getEndY(); y++) {
+    el.append(
+      "<div class='seat-grid-cell' data-x='" + x + "' data-y='" + y + "'></div>"
+    );
+  }
+  return el;
 }
 
-
 $(document).ready(function () {
-    $('#not-used-seats').sortable({
-        group: 'seats',
-        animation: 150,
-        onEnd: setPosition
-    });
-    enableSeatGridCells();
+  $("#not-used-seats").sortable({
+    group: "seats",
+    animation: 150,
+    onEnd: setPosition,
+  });
+  enableSeatGridCells();
 
-    $("#seat-row-add-top").click(function () {
-        const y = getStartY() - 1;
-        $(".seat-grid .seat-grid-col").each(function (idx, el) {
-            const x = Number.parseInt($(el).children(".seat-grid-cell").first().data("x"));
-            $(el).prepend("<div class='seat-grid-cell' data-x='" + x + "' data-y='" + y + "'></div>");
-        });
-        enableSeatGridCells();
-    });
-    $("#seat-row-add-bottom").click(function () {
-        const y = getEndY() + 1;
-        $(".seat-grid .seat-grid-col").each(function (idx, el) {
-            const x = Number.parseInt($(el).children(".seat-grid-cell").first().data("x"));
-            $(el).prepend("<div class='seat-grid-cell' data-x='" + x + "' data-y='" + y + "'></div>");
-        });
-        enableSeatGridCells();
+  $("#seat-row-add-top").click(function () {
+    const y = getStartY() - 1;
+    $(".seat-grid .seat-grid-col").each(function (idx, el) {
+      const x = Number.parseInt(
+        $(el).children(".seat-grid-cell").first().data("x")
+      );
+      $(el).prepend(
+        "<div class='seat-grid-cell' data-x='" +
+          x +
+          "' data-y='" +
+          y +
+          "'></div>"
+      );
     });
-    $("#seat-col-add-left").click(function () {
-        const el = buildRow(getStartX() - 1);
-        el.insertBefore(".seat-grid-col:first");
-        enableSeatGridCells();
-    });
-    $("#seat-col-add-right").click(function () {
-        const el = buildRow(getEndX() + 1);
-        el.insertAfter(".seat-grid-col:last");
-        enableSeatGridCells();
+    enableSeatGridCells();
+  });
+  $("#seat-row-add-bottom").click(function () {
+    const y = getEndY() + 1;
+    $(".seat-grid .seat-grid-col").each(function (idx, el) {
+      const x = Number.parseInt(
+        $(el).children(".seat-grid-cell").first().data("x")
+      );
+      $(el).prepend(
+        "<div class='seat-grid-cell' data-x='" +
+          x +
+          "' data-y='" +
+          y +
+          "'></div>"
+      );
     });
+    enableSeatGridCells();
+  });
+  $("#seat-col-add-left").click(function () {
+    const el = buildRow(getStartX() - 1);
+    el.insertBefore(".seat-grid-col:first");
+    enableSeatGridCells();
+  });
+  $("#seat-col-add-right").click(function () {
+    const el = buildRow(getEndX() + 1);
+    el.insertAfter(".seat-grid-col:last");
+    enableSeatGridCells();
+  });
 });
diff --git a/aleksis/apps/stoelindeling/views.py b/aleksis/apps/stoelindeling/views.py
index b3a9db165e0bf2dcd4c449bb63f2ce5798a936c7..f2e95ecfac6a5aad84c3dc0bb00f717b5a094985 100644
--- a/aleksis/apps/stoelindeling/views.py
+++ b/aleksis/apps/stoelindeling/views.py
@@ -1,6 +1,6 @@
 from django.contrib import messages
 from django.shortcuts import redirect
-from django.urls import reverse, reverse_lazy
+from django.urls import reverse
 from django.utils.decorators import method_decorator
 from django.utils.translation import gettext as _
 from django.views.decorators.cache import never_cache
diff --git a/docs/conf.py b/docs/conf.py
index dd57a50e4c74dc7e526c431886d9940e694facfc..5afcc39c5972addacc0f0388ae0fe5c293c5cd30 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -31,7 +31,7 @@ author = "The AlekSIS Team"
 # The short X.Y version
 version = "2.0"
 # The full version, including alpha/beta/rc tags
-release = "2.0.0.dev0"
+release = "2.0.1.dev0"
 
 
 # -- General configuration ---------------------------------------------------
diff --git a/pyproject.toml b/pyproject.toml
index f8b75e6bbc6b525298cf864bc67c6613ebaabfa3..389751f9cf70ad484540b9aaa9813653f13b97e3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "AlekSIS-App-Stoelindeling"
-version = "2.0.dev0"
+version = "2.0.1.dev0"
 packages = [
     { include = "aleksis" }
 ]
@@ -15,7 +15,7 @@ description = "AlekSIS (School Information System) — App Stoelindeling (Cr
 authors = ["Jonathan Weth <dev@jonathanweth.de>"]
 license = "EUPL-1.2-or-later"
 homepage = "https://aleksis.org"
-repository = "https://edugit.org/AlekSIS/official//AlekSIS-App-Stoelindeling"
+repository = "https://edugit.org/AlekSIS/official/AlekSIS-App-Stoelindeling"
 documentation = "https://aleksis.org/official/AlekSIS/docs/html/"
 classifiers = [
     "Environment :: Web Environment",
@@ -30,8 +30,8 @@ secondary = true
 
 [tool.poetry.dependencies]
 python = "^3.9"
-aleksis-core = "^3.0.dev3"
-aleksis-app-chronos = "^3.0.dev1"
+aleksis-core = "^3.0"
+aleksis-app-chronos = "^3.0"
 
 [tool.poetry.dev-dependencies]
 aleksis-builddeps = "*"
diff --git a/tox.ini b/tox.ini
index 749e0606f4f02fcbd1649627219b15850cbc0a90..6e4b77ab1ded935257117696975c2150772cd85c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,16 +1,15 @@
 [tox]
 skipsdist = True
 skip_missing_interpreters = true
-envlist = py37,py38,py39
+envlist = py39,py310,py311
 
 [testenv]
 whitelist_externals = poetry
-		      sudo
 skip_install = true
 envdir = {toxworkdir}/globalenv
 commands_pre =
      poetry install
-     poetry run aleksis-admin yarn install
+     poetry run aleksis-admin vite build
      poetry run aleksis-admin collectstatic --no-input
 commands =
     poetry run pytest --cov=. {posargs} aleksis/
@@ -27,6 +26,8 @@ commands =
     poetry run black --check --diff aleksis/
     poetry run isort -c --diff --stdout aleksis/
     poetry run flake8 {posargs} aleksis/
+    poetry run sh -c "aleksis-admin yarn run prettier --check --ignore-path={toxinidir}/.prettierignore {toxinidir}"
+    poetry run sh -c "aleksis-admin yarn run eslint {toxinidir}/aleksis/**/*/frontend/**/*.{js,vue} --config={toxinidir}/.eslintrc.js --resolve-plugins-relative-to=."
 
 [testenv:security]
 commands =
@@ -46,6 +47,7 @@ commands = poetry run make -C docs/ html {posargs}
 commands =
     poetry run isort aleksis/
     poetry run black aleksis/
+    poetry run sh -c "aleksis-admin yarn run prettier --write --ignore-path={toxinidir}/.prettierignore {toxinidir}"
 
 [testenv:makemessages]
 commands =