diff --git a/assets/script.js b/assets/script.js
index f9c023f89d1487030653a530a8d00e367f1e8c92..57e471fc7c58c98b83678d8204a47b5e2e32863b 100644
--- a/assets/script.js
+++ b/assets/script.js
@@ -5,63 +5,96 @@
     var lastRequestController = null;
 
     $(document).ready(function () {
-        if (document.getElementById('trac-migrate') === null) {
-            return;
-        }
+        if (document.getElementById('trac-migrate') !== null) {
+            let source = null;
+            STUDIP.loadChunk('vue').then(function ({createApp}) {
+                createApp({
+                    data: function () {
+                        return {
+                            needle: '',
+                            results: false,
+                            selectedTicket: false,
+                        };
+                    },
+                    mounted () {
+                        source = this.$el.dataset.source;
+                    },
+                    methods: {
+                        searchTickets (event) {
+                            const needle = this.needle.trim();
+                            if (needle.trim().length < 3) {
+                                return;
+                            }
 
-        let source = null;
-        STUDIP.loadChunk('vue').then(function ({createApp}) {
-            createApp({
-                data: function () {
-                    return {
-                        needle: '',
-                        results: false,
-                        selectedTicket: false,
-                    };
-                },
-                mounted () {
-                    source = this.$el.dataset.source;
-                },
-                methods: {
-                    searchTickets (event) {
-                        const needle = this.needle.trim();
-                        if (needle.trim().length < 3) {
-                            return;
-                        }
+                            if (lastRequestController) {
+                                lastRequestController.abort();
+                            }
+
+                            lastRequestController = new AbortController();
+                            const { signal } = lastRequestController;
 
-                        if (lastRequestController) {
-                            lastRequestController.abort();
+                            clearTimeout(searchTimeout);
+                            searchTimeout = setTimeout(function () {
+                                const url = STUDIP.URLHelper.getURL(source, {term: needle});
+                                fetch(url, { signal }).then(response => this.results = response.json());
+                            }.bind(this), 300);
+                        }
+                    },
+                    computed: {
+                        orderedResults () {
+                            const needle = this.needle.trim();
+                            return Object.values(this.results).sort(function (a, b) {
+                                if (a[0] === needle) {
+                                    return 1;
+                                }
+                                if (b[0] === needle) {
+                                    return -1;
+                                }
+                                return b[0] - a[0];
+                            });
                         }
+                    }
+                }).$mount('#trac-migrate');
+            });
+        } else if (document.getElementById('dashboard') !== null) {
+            const dashboard = document.getElementById('dashboard');
+            const issues = JSON.parse(dashboard.dataset.issues);
+            const qmLabels = JSON.parse(dashboard.dataset.qmLabels);
 
-                        lastRequestController = new AbortController();
-                        const { signal } = lastRequestController;
+            let filters = {};
+            Object.values(qmLabels).forEach(abbr => filters[abbr] = null);
 
-                        clearTimeout(searchTimeout);
-                        searchTimeout = setTimeout(function () {
-                            const url = STUDIP.URLHelper.getURL(source, {term: needle});
-                            fetch(url, { signal }).then(function (response) {
-                                return response.json();
-                            }).then(function (json) {
-                                this.results = json;
-                            }.bind(this));
-                        }.bind(this), 300);
-                    }
-                },
-                computed: {
-                    orderedResults () {
-                        const needle = this.needle.trim();
-                        return Object.values(this.results).sort(function (a, b) {
-                            if (a[0] === needle) {
-                                return 1;
-                            }
-                            if (b[0] === needle) {
-                                return -1;
-                            }
-                            return b[0] - a[0];
-                        });
+            STUDIP.loadChunk('vue').then(({createApp}) => {
+                createApp({
+                    data () {
+                        return {
+                            issues,
+                            qmLabels,
+                            filters
+                        };
+                    },
+                    methods: {
+                        getStateForIssueAndQmLabel(issue, qm) {
+                            return issue.qm_states[qm];
+                        },
+                    },
+                    computed: {
+                        filteredIssues() {
+                            return this.issues.filter(issue => {
+                                for (const [key, value] of Object.entries(this.filters)) {
+                                    if (value === null) {
+                                        continue;
+                                    }
+                                    if (issue.qm_states[key] !== value) {
+                                        return false;
+                                    }
+                                }
+                                return true;
+                            });
+                        }
                     }
-                }
-            }).$mount('#trac-migrate');
-        });
+                }).$mount('#dashboard');
+            });
+        }
     });
 }(jQuery, STUDIP));
diff --git a/controllers/dashboard.php b/controllers/dashboard.php
index c4ede9c43ae512c39950fc78cc6621642547cfbe..0178ad77d8df249c246bd5631215c5b48c651c6c 100644
--- a/controllers/dashboard.php
+++ b/controllers/dashboard.php
@@ -1,17 +1,6 @@
 <?php
 final class DashboardController extends TracToGitlab\Controller
 {
-    const QM_LABEL_MAPPING = [
-        'Anwendungs-Doku'   => 'userdoc',
-        'Barrierefreiheit'  => 'a11y',
-        'Code-Review'       => 'code',
-        'Entwicklungs-Doku' => 'devdoc',
-        'Funktionalität'    => 'func',
-        'GUI-Richtlinien'   => 'gui',
-        'Schnittstellen'    => 'iface',
-        'Textstrings'       => 'text',
-    ];
-
     public function before_filter(&$action, &$args)
     {
         parent::before_filter($action, $args);
@@ -152,6 +141,6 @@ final class DashboardController extends TracToGitlab\Controller
 
     private function getQMLabelMapping()
     {
-        return self::QM_LABEL_MAPPING;
+        return TracToGitlab\GitlabIssue::QM_LABEL_MAPPING;
     }
 }
diff --git a/lib/GitlabIssue.php b/lib/GitlabIssue.php
index e87f1ca9956f9edbae39d06357b89d1c3b91af67..1b1e7bf743cf8d56a1a80ef3a20d8b4606892d34 100644
--- a/lib/GitlabIssue.php
+++ b/lib/GitlabIssue.php
@@ -1,8 +1,19 @@
 <?php
 namespace TracToGitlab;
 
-final class GitlabIssue
+final class GitlabIssue implements \JsonSerializable
 {
+    const QM_LABEL_MAPPING = [
+        'Anwendungs-Doku'   => 'userdoc',
+        'Barrierefreiheit'  => 'a11y',
+        'Code-Review'       => 'code',
+        'Entwicklungs-Doku' => 'devdoc',
+        'Funktionalität'    => 'func',
+        'GUI-Richtlinien'   => 'gui',
+        'Schnittstellen'    => 'iface',
+        'Textstrings'       => 'text',
+    ];
+
     private $issue;
     private $mrs;
 
@@ -36,7 +47,7 @@ final class GitlabIssue
         return $this->issue[$offset] ?? null;
     }
 
-    public function isClosed()
+    public function isClosed(): bool
     {
         return $this->issue['state'] !== 'opened';
     }
@@ -69,22 +80,51 @@ final class GitlabIssue
         return in_array('StEP', $this->issue['labels']);
     }
 
-    public function getIconForQMLabel(string $label)
+    public function getIconForQMLabel(string $label): string
+    {
+        $state = $this->getStateForQMLabel($label);
+        if ($state === '+') {
+            return \Icon::create('accept', \Icon::ROLE_STATUS_GREEN);
+        }
+        if ($state === '?') {
+            return \Icon::create('question', \Icon::ROLE_STATUS_YELLOW);
+        }
+        if ($state === '-') {
+            return \Icon::create('decline', \Icon::ROLE_STATUS_RED);
+        }
+
+        return '';
+    }
+
+    public function getStateForQMLabel(string $label): ?string
     {
         foreach ($this->issue['labels'] as $l) {
             if (preg_match("/^QM::{$label}::(.)$/", $l, $match)) {
-                $state = $match[1];
-                if ($state === '+') {
-                    return \Icon::create('accept', \Icon::ROLE_STATUS_GREEN);
-                }
-                if ($state === '?') {
-                    return \Icon::create('question', \Icon::ROLE_STATUS_YELLOW);
-                }
-                if ($state === '-') {
-                    return \Icon::create('decline', \Icon::ROLE_STATUS_RED);
-                }
+                return $match[1];
             }
         }
-        return '';
+        return null;
+    }
+
+    public function jsonSerialize()
+    {
+        $result = [
+            'iid' => $this->iid,
+            'title' => $this->title,
+            'type' => $this->type,
+            'author' => $this->author['username'],
+            'assignee' => $this->assignee,
+            'web_url' => $this->web_url,
+            'closed' => $this->isClosed(),
+            'merge_requests' => $this->issue['merge_requests_count'],
+            'merged' => $this->isMerged(),
+            'qm_states' => array_combine(
+                array_values(self::QM_LABEL_MAPPING),
+                array_map(function ($qm) {
+                    return $this->getStateForQMLabel($qm);
+                }, array_keys(self::QM_LABEL_MAPPING))
+            ),
+        ];
+        return $result;
     }
 }
diff --git a/plugin.manifest b/plugin.manifest
index ca5ae143d01fb1b5896d0c366ae83b9107286bdc..cf2fe064ad1a600671c05b58ffb46b0bc3e00b0a 100644
--- a/plugin.manifest
+++ b/plugin.manifest
@@ -1,5 +1,5 @@
 pluginname=Trac to gitlab converter
 pluginclassname=TracToGitlabPlugin
 origin=UOL
-version=1.2
+version=1.3
 studipMinVersion=5.0
diff --git a/views/dashboard/index.php b/views/dashboard/index.php
index 32ef334f25e87451766efec4367545866682ec43..f44fc714791099aa7f16ff8f6c2f56a34b019225 100644
--- a/views/dashboard/index.php
+++ b/views/dashboard/index.php
@@ -4,76 +4,84 @@
  * @var array<int, TracToGitlab\GitlabIssue> $issues
  */
 ?>
-<table class="default">
-    <thead>
-        <tr>
-            <th><?= _('Issue') ?></th>
-            <th><?= _('Typ') ?></th>
-            <th><?= _('Status') ?></th>
-            <th><?= _('MR') ?></th>
-            <th><?= _('Titel') ?></th>
-            <th><?= _('Autor') ?></th>
-            <th><?= _('Bearbeiter') ?></th>
-        <? foreach ($mapping as $label => $abbrevation): ?>
-            <th>
-                <abbr title="<?= htmlReady($label) ?>">
-                    <?= htmlReady($abbrevation) ?>
-                </abbr>
-            </th>
-        <? endforeach; ?>
-        </tr>
-    </thead>
-    <tbody>
-    <? if (!$issues): ?>
-        <tr>
-            <td colspan="<?= 7 + count($mapping) ?>" style="text-align: center">
-                <?= _('Keine Issues für diesen Meilenstein und Typ') ?>
-            </td>
-        </tr>
-    <? endif; ?>
-    <? foreach ($issues as $issue): ?>
-        <tr>
-            <td>
-                <a href="<?= htmlReady($issue->web_url) ?>" target="_blank">
-                    #<?= htmlReady($issue->iid) ?>
-                </a>
-            </td>
-            <td><?= htmlReady($issue->type) ?></td>
-            <td>
-            <? if ($issue->isClosed()): ?>
-                closed
-            <? else: ?>
-                open
-            <? endif; ?>
-            </td>
-            <td>
-            <? if (!$issue->hasMergeRequests()): ?>
-                <abbr title="<?= _('Kein MR') ?>">
-                    <?= Icon::create('decline', Icon::ROLE_STATUS_RED) ?>
-                </abbr>
-            <? elseif ($issue->isMerged()): ?>
-                <abbr title="<?= _('MR bereits gemerget') ?>">
-                    <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN) ?>
-                </abbr>
-            <? else: ?>
-                <abbr title="<?= _('MR noch nicht gemerget') ?>">
-                    <?= Icon::create('date', Icon::ROLE_STATUS_YELLOW) ?>
-                </abbr>
-            <? endif; ?>
-            </td>
-            <td>
-                <a href="<?= htmlReady($issue->web_url) ?>" target="_blank">
-                    <?= htmlReady($issue->title) ?>
-                </a>
-            </td>
-            <td><?= htmlReady($issue->author['username']) ?></td>
-            <td><?= htmlReady($issue->assignee) ?></td>
-        <? foreach ($mapping as $label => $abbrevation): ?>
-            <td>
-                <?= $issue->getIconForQMLabel($label) ?>
-            </td>
-        <? endforeach; ?>
-        </tr>
-    <? endforeach; ?>
-    </tbody>
-</table>
+<div id="dashboard" data-issues='<?= json_encode($issues) ?>' data-qm-labels='<?= json_encode($mapping) ?>' v-cloak>
+    <table class="default">
+        <thead>
+            <tr>
+                <th><?= _('Issue') ?></th>
+                <th><?= _('Typ') ?></th>
+                <th><?= _('Status') ?></th>
+                <th><?= _('MR') ?></th>
+                <th><?= _('Titel') ?></th>
+                <th><?= _('Autor') ?></th>
+                <th><?= _('Bearbeiter') ?></th>
+                <th v-for="(abbr, label) in qmLabels">
+                    <abbr :title="label">{{ abbr }}</abbr>
+                </th>
+            </tr>
+        </thead>
+        <tbody>
+        <? if (!$issues): ?>
+            <tr>
+                <td colspan="<?= 7 + count($mapping) ?>" style="text-align: center">
+                    <?= _('Keine Issues für diesen Meilenstein und Typ') ?>
+                </td>
+            </tr>
+        <? endif; ?>
+            <tr v-for="issue in filteredIssues">
+                <td>
+                    <a :href="issue.web_url" target="_blank">
+                        #{{ issue.iid }}
+                    </a>
+                </td>
+                <td>{{ issue.type }}</td>
+                <td>{{ issue.closed ? 'closed' : 'open' }}</td>
+                <td v-if="!issue.merge_requests">
+                    <abbr title="<?= _('Kein MR') ?>">
+                        <?= Icon::create('decline', Icon::ROLE_STATUS_RED) ?>
+                    </abbr>
+                </td>
+                <td v-else-if="issue.merged">
+                    <abbr title="<?= _('MR bereits gemerget') ?>">
+                        <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN) ?>
+                    </abbr>
+                </td>
+                <td v-else>
+                    <abbr title="<?= _('MR noch nicht gemerget') ?>">
+                        <?= Icon::create('date', Icon::ROLE_STATUS_YELLOW) ?>
+                    </abbr>
+                </td>
+                <td>
+                    <a :href="issue.web_url" target="_blank">
+                        {{ issue.title }}
+                    </a>
+                </td>
+                <td>{{ issue.author }}</td>
+                <td>{{ issue.assignee }}</td>
+                <td v-for="(abbr, label) in qmLabels">
+                    <studip-icon shape="accept" role="status-green" v-if="getStateForIssueAndQmLabel(issue, abbr) === '+'"></studip-icon>
+                    <studip-icon shape="question" role="status-yellow" v-else-if="getStateForIssueAndQmLabel(issue, abbr) === '?'"></studip-icon>
+                    <studip-icon shape="decline" role="status-red" v-else-if="getStateForIssueAndQmLabel(issue, abbr) === '-'"></studip-icon>
+                </td>
+            </tr>
+            </tr>
+        </tbody>
+    </table>
+
+    <mounting-portal mount-to="#layout-sidebar .sidebar" name="sidebar-filter" append>
+        <div class="sidebar-widget">
+            <div class="sidebar-widget-header">Filter</div>
+            <div class="sidebar-widget-content">
+                <label v-for="(abbr, label) in qmLabels" style="display: block;">
+                    {{ label }}
+                    <select v-model="filters[abbr]" style="display: block;" class="sidebar-selectlist">
+                        <option :value="null"></option>
+                        <option>+</option>
+                        <option>?</option>
+                        <option>-</option>
+                    </select>
+                </label>
+            </div>
+        </div>
+    </mounting-portal>
+</div>