changeset 2501:9d9c6425db82 critical-bottlenecks

merged default into critical-bottlenecks branch
author Markus Kottlaender <markus@intevation.de>
date Mon, 04 Mar 2019 16:01:20 +0100
parents 7247eb03e7c0 (current diff) 8cc3cd1b27f2 (diff)
children e13daf439068
files
diffstat 9 files changed, 406 insertions(+), 526 deletions(-) [+]
line wrap: on
line diff
--- a/client/src/assets/application.scss	Mon Mar 04 08:32:05 2019 +0100
+++ b/client/src/assets/application.scss	Mon Mar 04 16:01:20 2019 +0100
@@ -145,3 +145,22 @@
 .btn.disabled {
   opacity: 0.4;
 }
+
+.snotifyToast {
+  text-align: left;
+  border-radius: 0.25rem;
+  box-shadow: 0 0.1rem 0.5rem rgba(0, 0, 0, 0.2);
+  border-left: 0 !important;
+  &.snotify-info {
+    border-top: 4px solid $color-info;
+    .snotify-icon--info {
+      background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20version=%221.1%22%20x=%220px%22%20y=%220px%22%20viewBox=%220%200%20512%20512%22%20fill=%22%2317a2b8%22%3E%3Cg%3E%3Cpath%20d=%22M256,0C114.84,0,0,114.84,0,256S114.84,512,256,512,512,397.16,512,256,397.15,0,256,0Zm0,478.43C133.35,478.43,33.57,378.64,33.57,256S133.35,33.58,256,33.58,478.42,133.36,478.42,256,378.64,478.43,256,478.43Z%22/%3E%3Cpath%20d=%22M251.26,161.24a22.39,22.39,0,1,0-22.38-22.39A22.39,22.39,0,0,0,251.26,161.24Z%22/%3E%3Cpath%20d=%22M286.84,357.87h-14v-160A16.79,16.79,0,0,0,256,181.05H225.17a16.79,16.79,0,0,0,0,33.58h14.05V357.87H225.17a16.79,16.79,0,0,0,0,33.57h61.67a16.79,16.79,0,1,0,0-33.57Z%22/%3E%3C/g%3E%3C/svg%3E");
+    }
+  }
+  &.snotify-error {
+    border-top: 4px solid #f44336;
+  }
+  .snotifyToast__title {
+    font-size: 1.2rem;
+  }
+}
--- a/client/src/components/Bottlenecks.vue	Mon Mar 04 08:32:05 2019 +0100
+++ b/client/src/components/Bottlenecks.vue	Mon Mar 04 16:01:20 2019 +0100
@@ -5,123 +5,109 @@
       title="Bottlenecks"
       :closeCallback="$parent.close"
     />
-    <div class="row p-2 text-left small">
-      <div class="col-5">
-        <a href="#" @click="sortBy('name')" class="sort-link">
-          <translate>Name</translate>
-        </a>
-        <font-awesome-icon
-          :icon="sortIcon"
-          class="ml-1"
-          v-if="sortColumn === 'name'"
-        ></font-awesome-icon>
+    <UITableHeader
+      :columns="[
+        { id: 'name', title: 'Name', class: 'col-4' },
+        {
+          id: 'latestMeasurement',
+          title: 'Latest Measurement',
+          class: 'col-3'
+        },
+        { id: 'chainage', title: 'Chainage', class: 'col-3' }
+      ]"
+      @sortingChanged="sortBy"
+    />
+    <UITableBody
+      :data="filteredAndSortedBottlenecks()"
+      :maxHeight="(showSplitscreen ? 18 : 35) + 'rem'"
+      :active="openBottleneck"
+      v-slot="{ item: bottleneck }"
+    >
+      <div class="col-4 py-2 text-left">
+        <a href="#" @click="selectBottleneck(bottleneck)">{{
+          bottleneck.properties.name
+        }}</a>
       </div>
-      <div class="col-2">
-        <a href="#" @click="sortBy('latestMeasurement')" class="sort-link">
-          <translate>Latest</translate> <br />
-          <translate>Measurement</translate>
-        </a>
-        <font-awesome-icon
-          :icon="sortIcon"
-          class="ml-1"
-          v-if="sortColumn === 'latestMeasurement'"
-        ></font-awesome-icon>
+      <div class="col-3 py-2">
+        {{ formatSurveyDate(bottleneck.properties.current) }}
+      </div>
+      <div class="col-3 py-2">
+        {{
+          displayCurrentChainage(
+            bottleneck.properties.from,
+            bottleneck.properties.to
+          )
+        }}
       </div>
-      <div class="col-3">
-        <a href="#" @click="sortBy('chainage')" class="sort-link">
-          <translate>Chainage</translate>
+      <div class="col-2 pr-0 text-right d-flex flex-column">
+        <a
+          class="text-info mt-auto mb-auto mr-2"
+          @click="loadSurveys(bottleneck)"
+          v-if="bottleneck.properties.current"
+        >
+          <font-awesome-icon
+            class="pointer"
+            icon="spinner"
+            fixed-width
+            spin
+            v-if="loading === bottleneck"
+          ></font-awesome-icon>
+          <font-awesome-icon
+            class="pointer"
+            icon="angle-down"
+            fixed-width
+            v-if="loading !== bottleneck && openBottleneck !== bottleneck"
+          ></font-awesome-icon>
+          <font-awesome-icon
+            class="pointer"
+            icon="angle-up"
+            fixed-width
+            v-if="loading !== bottleneck && openBottleneck === bottleneck"
+          ></font-awesome-icon>
         </a>
-        <font-awesome-icon
-          :icon="sortIcon"
-          class="ml-1"
-          v-if="sortColumn === 'chainage'"
-        ></font-awesome-icon>
       </div>
-      <div class="col-2"></div>
-    </div>
-    <div
-      class="bottleneck-list small text-left"
-      :style="'max-height: ' + (showSplitscreen ? 18 : 35) + 'rem'"
-      v-if="filteredAndSortedBottlenecks().length"
-    >
       <div
-        v-for="bottleneck in filteredAndSortedBottlenecks()"
-        :key="bottleneck.properties.name"
-        class="border-top row bottleneck-row mx-0"
+        :class="[
+          'col-12 p-0',
+          'surveys',
+          { open: openBottleneck === bottleneck }
+        ]"
       >
-        <div class="col-5 py-2 text-left">
-          <a href="#" @click="selectBottleneck(bottleneck)">{{
-            bottleneck.properties.name
-          }}</a>
-        </div>
-        <div class="col-2 py-2">
-          {{ formatSurveyDate(bottleneck.properties.current) }}
-        </div>
-        <div class="col-3 py-2">
-          {{
-            displayCurrentChainage(
-              bottleneck.properties.from,
-              bottleneck.properties.to
-            )
-          }}
-        </div>
-        <div class="col-2 pr-0 text-right d-flex flex-column">
-          <a
-            class="text-info mt-auto mb-auto mr-2"
-            @click="loadSurveys(bottleneck.properties.name)"
-            v-if="bottleneck.properties.current"
-          >
-            <font-awesome-icon
-              class="pointer"
-              icon="spinner"
-              fixed-width
-              spin
-              v-if="loading === bottleneck.properties.name"
-            ></font-awesome-icon>
-            <font-awesome-icon
-              class="pointer"
-              icon="angle-down"
-              fixed-width
-              v-if="
-                loading !== bottleneck.properties.name &&
-                  openBottleneck !== bottleneck.properties.name
-              "
-            ></font-awesome-icon>
-            <font-awesome-icon
-              class="pointer"
-              icon="angle-up"
-              fixed-width
-              v-if="
-                loading !== bottleneck.properties.name &&
-                  openBottleneck === bottleneck.properties.name
-              "
-            ></font-awesome-icon>
-          </a>
-        </div>
-        <div
-          :class="[
-            'col-12 p-0',
-            'surveys',
-            { open: openBottleneck === bottleneck.properties.name }
-          ]"
+        <a
+          href="#"
+          class="d-inline-block px-3 py-2"
+          v-for="(survey, index) in openBottleneckSurveys"
+          :key="index"
+          @click="selectSurvey(survey, bottleneck)"
         >
-          <a
-            href="#"
-            class="d-block px-3 py-2"
-            v-for="(survey, index) in openBottleneckSurveys"
-            :key="index"
-            @click="selectSurvey(survey, bottleneck)"
-            >{{ formatSurveyDate(survey.date_info) }}</a
-          >
-        </div>
+          {{ formatSurveyDate(survey.date_info) }}
+        </a>
       </div>
-    </div>
-    <div v-else class="small text-center py-3 border-top">
-      <translate>No results.</translate>
-    </div>
+    </UITableBody>
   </div>
 </template>
 
+<style lang="sass" scoped>
+.table-body
+  .row
+    > div:not(:last-child)
+      transition: background-color 0.3s, color 0.3s
+    &.active
+      > div:not(:last-child)
+        background-color: $color-info
+        color: #fff
+        a
+          color: #fff !important
+      .surveys
+        border-bottom: solid 1px $color-info
+    .surveys
+      overflow: hidden
+      max-height: 0
+      &.open
+        overflow-y: auto
+        max-height: 5rem
+</style>
+
 <script>
 /* This is Free Software under GNU Affero General Public License v >= 3.0
  * without warranty, see README.md and license for details.
@@ -249,19 +235,18 @@
           });
         });
     },
-    sortBy(column) {
-      this.sortColumn = column;
-      this.sortDirection = this.sortDirection === "ASC" ? "DESC" : "ASC";
+    sortBy(sorting) {
+      this.sortColumn = sorting.sortColumn;
+      this.sortDirection = sorting.sortDirection;
     },
-    loadSurveys(name) {
-      this.openBottleneckSurveys = null;
-      if (name === this.openBottleneck) {
+    loadSurveys(bottleneck) {
+      if (bottleneck === this.openBottleneck) {
         this.openBottleneck = null;
+        this.openBottleneckSurveys = null;
       } else {
-        this.openBottleneck = name;
-        this.loading = name;
+        this.loading = bottleneck;
 
-        HTTP.get("/surveys/" + name, {
+        HTTP.get("/surveys/" + bottleneck.properties.name, {
           headers: {
             "X-Gemma-Auth": localStorage.getItem("token"),
             "Content-type": "text/xml; charset=UTF-8"
@@ -271,6 +256,7 @@
             this.openBottleneckSurveys = response.data.surveys.sort((a, b) => {
               return a.date_info < b.date_info ? 1 : -1;
             });
+            this.openBottleneck = bottleneck;
           })
           .catch(error => {
             const { status, data } = error.response;
@@ -291,37 +277,3 @@
   }
 };
 </script>
-
-<style lang="scss" scoped>
-.bottleneck-list {
-  overflow-y: auto;
-}
-
-.bottleneck-list .bottleneck-row a {
-  text-decoration: none;
-}
-
-.bottleneck-list .bottleneck-row:hover {
-  background: #fbfbfb;
-}
-
-.surveys {
-  max-height: 0;
-  min-height: 0;
-  overflow: hidden;
-}
-
-.surveys a:hover {
-  background: #f3f3f3;
-}
-
-.surveys.open {
-  max-height: 250px;
-  overflow: auto;
-}
-
-.sort-link {
-  color: #444;
-  font-weight: bold;
-}
-</style>
--- a/client/src/components/ImportStretches.vue	Mon Mar 04 08:32:05 2019 +0100
+++ b/client/src/components/ImportStretches.vue	Mon Mar 04 16:01:20 2019 +0100
@@ -5,60 +5,52 @@
       title="Define Stretches"
       :closeCallback="$parent.close"
     />
-    <div v-if="!edit" class="mb-3 mr-3 ml-3 text-left">
-      <table v-if="stretches.length > 0" class="table">
-        <thead>
-          <tr>
-            <th class="header"><translate>Name</translate></th>
-            <th class="header"><translate>Datum</translate></th>
-            <th class="header"><translate>Source organization</translate></th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          <tr class="small" v-for="(stretch, index) in stretches" :key="index">
-            <td class="">
-              <a
-                class="linkto text-info"
-                v-if="isInStaging(stretch.properties.name)"
-                @click="gotoStaging(getStagingLink(stretch.properties.name))"
-              >
-                {{ stretch.properties.name
-                }}<font-awesome-icon
-                  class="ml-1 text-danger"
-                  icon="exclamation-triangle"
-                  fixed-width
-                ></font-awesome-icon
-                ><small class="ml-1">review</small>
-              </a>
-              <a v-else @click="moveMapToStretch(index)" href="#">{{
-                stretch.properties.name
-              }}</a>
-            </td>
-            <td class="">
-              {{ formatSurveyDate(stretch.properties["date_info"]) }}
-            </td>
-            <td>{{ stretch.properties["source_organization"] }}</td>
-            <td class="text-right">
-              <button
-                class="btn btn-sm btn-dark mr-1"
-                @click="editStretch(index)"
-              >
-                <font-awesome-icon icon="pencil-alt" fixed-width />
-              </button>
-              <button
-                class="btn btn-sm btn-dark"
-                @click="deleteStretch(stretch)"
-              >
-                <font-awesome-icon icon="trash" fixed-width />
-              </button>
-            </td>
-          </tr>
-        </tbody>
-      </table>
-      <div class="mt-3" v-if="stretches.length == 0">
-        <translate>No results.</translate>
-      </div>
+    <div v-if="!edit" class="mb-3">
+      <UITableHeader
+        :columns="[
+          { id: 'name', title: 'Name', class: 'col-4' },
+          { id: 'date', title: 'Date', class: 'col-2' },
+          { id: 'srcorg', title: 'Source organization', class: 'col-3' }
+        ]"
+        :sortable="false"
+      />
+      <UITableBody :data="stretches" v-slot="{ item: stretch }">
+        <div class="py-2 col-4 ">
+          <a
+            class="linkto text-info"
+            v-if="isInStaging(stretch.properties.name)"
+            @click="gotoStaging(getStagingLink(stretch.properties.name))"
+          >
+            {{ stretch.properties.name
+            }}<font-awesome-icon
+              class="ml-1 text-danger"
+              icon="exclamation-triangle"
+              fixed-width
+            ></font-awesome-icon
+            ><small class="ml-1">review</small>
+          </a>
+          <a v-else @click="moveMapToStretch(stretch)" href="#">{{
+            stretch.properties.name
+          }}</a>
+        </div>
+        <div class="py-2 col-2">
+          {{ formatSurveyDate(stretch.properties["date_info"]) }}
+        </div>
+        <div class="py-2 col-3">
+          {{ stretch.properties["source_organization"] }}
+        </div>
+        <div class="py-2 col text-right">
+          <button
+            class="btn btn-sm btn-dark mr-1"
+            @click="editStretch(stretch)"
+          >
+            <font-awesome-icon icon="pencil-alt" fixed-width />
+          </button>
+          <button class="btn btn-sm btn-dark" @click="deleteStretch(stretch)">
+            <font-awesome-icon icon="trash" fixed-width />
+          </button>
+        </div>
+      </UITableBody>
     </div>
     <div v-if="edit">
       <div class="ml-3 mr-3">
@@ -337,8 +329,8 @@
           });
       });
     },
-    editStretch(index) {
-      const properties = this.stretches[index].properties;
+    editStretch(stretch) {
+      const properties = stretch.properties;
       this.date_info = properties.date_info.split("T")[0];
       this.id = properties.name;
       this.nobjbn = properties.nobjnam;
@@ -376,10 +368,10 @@
         }
       });
     },
-    moveMapToStretch(index) {
+    moveMapToStretch(stretch) {
       this.$store.commit("map/setLayerVisible", LAYERS.STRETCHES);
       this.$store.commit("map/moveToExtent", {
-        feature: this.stretches[index],
+        feature: stretch,
         zoom: 17,
         preventZoomOut: true
       });
--- a/client/src/components/fairway/Fairwayprofile.vue	Mon Mar 04 08:32:05 2019 +0100
+++ b/client/src/components/fairway/Fairwayprofile.vue	Mon Mar 04 16:01:20 2019 +0100
@@ -1,12 +1,22 @@
 <template>
   <div :class="['position-relative', { show: showSplitscreen }]">
     <div class="profile bg-white position-relative d-flex flex-column">
-      <div class="d-flex flex-row justify-content-between border-bottom">
-        <div class="mt-1 mb-1 d-flex flex-row">
-          <small class="text-muted ml-1 mr-1 my-auto text-right"
-            ><translate>Available Waterlevels</translate></small
+      <div
+        class="d-flex flex-row justify-content-between align-items-center border-bottom position-relative"
+      >
+        <div class="d-flex flex-row align-items-center position-absolute">
+          <small class="text-muted px-2 text-right" style="line-height: 1rem;">
+            <translate v-if="availableWaterlevels.length > 1"
+              >Available Waterlevels:</translate
+            >
+            <translate v-else>Waterlevel:</translate>
+          </small>
+          <select
+            class="form-control pl-1"
+            v-model="currentLevel"
+            v-if="availableWaterlevels.length > 1"
+            style="width: 100px; height: 30px; font-size: 80%;"
           >
-          <select class="form-control" v-model="currentLevel">
             <option
               v-for="level in availableWaterlevels"
               :value="level"
@@ -15,6 +25,7 @@
               {{ formatSurveyDate(level) }}
             </option>
           </select>
+          <small v-else>{{ formatSurveyDate(currentLevel) }}</small>
         </div>
         <div class="flex-row mr-auto ml-auto">
           <h5
--- a/client/src/components/importschedule/Importschedule.vue	Mon Mar 04 08:32:05 2019 +0100
+++ b/client/src/components/importschedule/Importschedule.vue	Mon Mar 04 16:01:20 2019 +0100
@@ -4,100 +4,81 @@
     <div class="mt-3 w-100">
       <div class="card flex-grow-1 schedulecard shadow-xs">
         <UIBoxHeader icon="clock" title="Imports" />
-        <div class="card-body schedulecardbody">
-          <div class="card-body schedulecardbody">
-            <div class="searchandfilter mb-3  w-50 d-flex flex-row">
-              <div class="searchgroup input-group">
-                <div class="input-group-prepend">
-                  <span class="input-group-text" id="search">
-                    <font-awesome-icon icon="search"></font-awesome-icon>
-                  </span>
-                </div>
-                <input
-                  v-model="searchQuery"
-                  type="text"
-                  class="form-control"
-                  placeholder
-                  aria-label="Search"
-                  aria-describedby="search"
-                />
-              </div>
+        <div class="searchandfilter p-3 w-50 mx-auto">
+          <div class="searchgroup input-group">
+            <div class="input-group-prepend">
+              <span class="input-group-text" id="search">
+                <font-awesome-icon icon="search"></font-awesome-icon>
+              </span>
             </div>
-            <transition name="fade">
-              <table v-if="schedules.length" class="table table-hover">
-                <thead>
-                  <tr>
-                    <th><translate>ID</translate></th>
-                    <th><translate>Type</translate></th>
-                    <th><translate>Author</translate></th>
-                    <th><translate>Schedule</translate></th>
-                    <th><translate>Email</translate></th>
-                    <th style="width: 140px"></th>
-                  </tr>
-                </thead>
-                <transition-group name="fade" tag="tbody">
-                  <tr v-for="schedule in schedules" :key="schedule.id">
-                    <td>{{ schedule.id }}</td>
-                    <td>{{ schedule.kind.toUpperCase() }}</td>
-                    <td>{{ schedule.user }}</td>
-                    <td>{{ schedule.config.cron }}</td>
-                    <td>
-                      <font-awesome-icon
-                        v-if="schedule.config['send-email']"
-                        class="fa-fw mr-2"
-                        fixed-width
-                        icon="check"
-                      ></font-awesome-icon>
-                    </td>
-                    <td class="text-right">
-                      <button
-                        @click="editSchedule(schedule.id)"
-                        class="btn btn-sm btn-dark mr-1"
-                        :disabled="importScheduleDetailVisible"
-                      >
-                        <font-awesome-icon
-                          icon="pencil-alt"
-                          fixed-width
-                        ></font-awesome-icon>
-                      </button>
-                      <button
-                        @click="deleteSchedule(schedule)"
-                        class="btn btn-sm btn-dark mr-1"
-                        :disabled="importScheduleDetailVisible"
-                      >
-                        <font-awesome-icon
-                          icon="trash"
-                          fixed-width
-                        ></font-awesome-icon>
-                      </button>
-                      <button
-                        @click="triggerManualImport(schedule.id)"
-                        class="btn btn-sm btn-dark"
-                        :disabled="importScheduleDetailVisible"
-                      >
-                        <font-awesome-icon
-                          icon="play"
-                          fixed-width
-                        ></font-awesome-icon>
-                      </button>
-                    </td>
-                  </tr>
-                </transition-group>
-              </table>
-              <div v-else class="mt-4 small text-center py-3">
-                <translate>No scheduled imports</translate>
-              </div>
-            </transition>
-            <div class="text-right">
-              <button
-                :disabled="importScheduleDetailVisible"
-                @click="newImport"
-                class="btn btn-info newbutton"
-              >
-                <translate>New Import</translate>
-              </button>
-            </div>
+            <input
+              v-model="searchQuery"
+              type="text"
+              class="form-control"
+              placeholder
+              aria-label="Search"
+              aria-describedby="search"
+            />
+          </div>
+        </div>
+        <UITableHeader
+          :columns="[
+            { id: 'id', title: 'ID', class: 'col-1' },
+            { id: 'type', title: 'Type', class: 'col-2' },
+            { id: 'author', title: 'Author', class: 'col-2' },
+            { id: 'schedule', title: 'Schedule', class: 'col-2' },
+            { id: 'email', title: 'Email', class: 'col-2' }
+          ]"
+          :sortable="false"
+        />
+        <UITableBody :data="schedules" v-slot="{ item: schedule }">
+          <div class="py-2 col-1">{{ schedule.id }}</div>
+          <div class="py-2 col-2">{{ schedule.kind.toUpperCase() }}</div>
+          <div class="py-2 col-2">{{ schedule.user }}</div>
+          <div class="py-2 col-2">{{ schedule.config.cron }}</div>
+          <div class="py-2 col-2">
+            <font-awesome-icon
+              v-if="schedule.config['send-email']"
+              class="fa-fw mr-2"
+              fixed-width
+              icon="check"
+            ></font-awesome-icon>
           </div>
+          <div class="py-2 col text-right">
+            <button
+              @click="editSchedule(schedule.id)"
+              class="btn btn-sm btn-dark mr-1"
+              :disabled="importScheduleDetailVisible"
+            >
+              <font-awesome-icon
+                icon="pencil-alt"
+                fixed-width
+              ></font-awesome-icon>
+            </button>
+            <button
+              @click="deleteSchedule(schedule)"
+              class="btn btn-sm btn-dark mr-1"
+              :disabled="importScheduleDetailVisible"
+            >
+              <font-awesome-icon icon="trash" fixed-width></font-awesome-icon>
+            </button>
+            <button
+              @click="triggerManualImport(schedule.id)"
+              class="btn btn-sm btn-dark"
+              :disabled="importScheduleDetailVisible"
+            >
+              <font-awesome-icon icon="play" fixed-width></font-awesome-icon>
+            </button>
+          </div>
+        </UITableBody>
+        <div class="p-3 text-right">
+          <button
+            :disabled="importScheduleDetailVisible"
+            @click="newImport"
+            class="btn btn-info newbutton"
+          >
+            <translate>New Import</translate>
+          </button>
         </div>
       </div>
     </div>
--- a/client/src/components/systemconfiguration/PDFTemplates.vue	Mon Mar 04 08:32:05 2019 +0100
+++ b/client/src/components/systemconfiguration/PDFTemplates.vue	Mon Mar 04 16:01:20 2019 +0100
@@ -11,44 +11,28 @@
       />
     </div>
     <div class="mt-1 border-bottom pb-4">
-      <transition name="fade">
-        <table class="table table-sm table-hover" v-if="templates.length">
-          <thead>
-            <tr>
-              <th><translate>Name</translate></th>
-              <th><translate>Date</translate></th>
-              <th><translate>Country</translate></th>
-              <th></th>
-            </tr>
-          </thead>
-          <transition-group name="fade" tag="tbody">
-            <tr v-for="template in templates" :key="template.name">
-              <td>{{ template.name }}</td>
-              <td>{{ template.time }}</td>
-              <td v-if="template.country">{{ template.country }}</td>
-              <td v-else><i>global</i></td>
-              <td class="text-right">
-                <button
-                  class="btn btn-sm btn-dark"
-                  @click="
-                    deleteTemplate(template);
-                    showSuccessUploadMsg = false;
-                  "
-                >
-                  <font-awesome-icon icon="trash" />
-                </button>
-              </td>
-            </tr>
-          </transition-group>
-        </table>
-      </transition>
-      <button
-        class="btn btn-info mt-2"
-        @click="
-          $refs.uploadTemplate.click();
-          showSuccessUploadMsg = false;
-        "
-      >
+      <UITableHeader
+        :columns="[
+          { id: 'name', title: 'Name', class: 'col-4' },
+          { id: 'date', title: 'Date', class: 'col-4' },
+          { id: 'country', title: 'Country', class: 'col-2' }
+        ]"
+        :sortable="false"
+      />
+      <UITableBody :data="templates" v-slot="{ item: template }">
+        <div class="py-2 col-4">{{ template.name }}</div>
+        <div class="py-2 col-4">{{ template.time }}</div>
+        <div class="py-2 col-2" v-if="template.country">
+          {{ template.country }}
+        </div>
+        <div class="py-2 col-2" v-else><i>global</i></div>
+        <div class="col py-2 text-right">
+          <button class="btn btn-sm btn-dark" @click="deleteTemplate(template)">
+            <font-awesome-icon icon="trash" />
+          </button>
+        </div>
+      </UITableBody>
+      <button class="btn btn-info mt-2" @click="$refs.uploadTemplate.click()">
         <font-awesome-icon
           icon="spinner"
           class="fa-spin fa-fw"
@@ -57,11 +41,6 @@
         <font-awesome-icon icon="upload" class="fa-fw" v-else />
         <translate>Upload new template</translate>
       </button>
-      <div v-if="showSuccessUploadMsg" class="text-center">
-        <p class="text-muted" v-translate>
-          {{ templateToUpload.name }} uploaded successfully
-        </p>
-      </div>
     </div>
   </div>
 </template>
@@ -91,16 +70,14 @@
  * Markus Kottländer <markus@intevation.de>
  */
 import { HTTP } from "@/lib/http";
-import { displayError } from "@/lib/errors.js";
+import { displayError, displayInfo } from "@/lib/errors.js";
 
 export default {
   name: "pdftemplates",
   data() {
     return {
       templates: [],
-      uploading: false,
-      templateToUpload: "",
-      showSuccessUploadMsg: false
+      uploading: false
     };
   },
   methods: {
@@ -135,8 +112,10 @@
           )
             .then(() => {
               this.loadTemplates();
-              this.templateToUpload = template;
-              this.showSuccessUploadMsg = true;
+              displayInfo({
+                message:
+                  template.name + " " + this.$gettext("uploaded successfully")
+              });
             })
             .catch(e => {
               const { status, data } = e.response;
@@ -204,6 +183,10 @@
               );
               if (removeIndex !== -1) {
                 this.templates.splice(removeIndex, 1);
+                displayInfo({
+                  message:
+                    template.name + " " + this.$gettext("deleted successfully")
+                });
               }
             });
           }
--- a/client/src/components/ui/UITableBody.vue	Mon Mar 04 08:32:05 2019 +0100
+++ b/client/src/components/ui/UITableBody.vue	Mon Mar 04 16:01:20 2019 +0100
@@ -8,7 +8,7 @@
   >
     <div
       v-for="(item, index) in data"
-      :key="index"
+      :key="key(index)"
       :class="['border-top row mx-0', { active: active === item }]"
     >
       <slot :item="item" :index="index"></slot>
@@ -46,6 +46,11 @@
     active: {
       type: [Object, Array]
     }
+  },
+  methods: {
+    key(index) {
+      return index;
+    }
   }
 };
 </script>
--- a/client/src/components/usermanagement/Usermanagement.vue	Mon Mar 04 08:32:05 2019 +0100
+++ b/client/src/components/usermanagement/Usermanagement.vue	Mon Mar 04 16:01:20 2019 +0100
@@ -5,82 +5,55 @@
       <div :class="userlistStyle">
         <div class="card shadow-xs">
           <UIBoxHeader icon="users-cog" title="Users" />
-          <div class="card-body">
-            <table id="datatable" :class="tableStyle">
-              <thead>
-                <tr>
-                  <th scope="col" @click="sortBy('role')">
-                    <span
-                      >Role&nbsp;
-                      <font-awesome-icon
-                        v-if="sortCriterion == 'role'"
-                        icon="angle-down"
-                      ></font-awesome-icon>
-                    </span>
-                  </th>
-                  <th scope="col" @click="sortBy('user')">
-                    <span
-                      >Username&nbsp;
-                      <font-awesome-icon
-                        v-if="sortCriterion == 'user'"
-                        icon="angle-down"
-                      ></font-awesome-icon>
-                    </span>
-                  </th>
-                  <th scope="col" @click="sortBy('country')">
-                    <span
-                      >Country&nbsp;
-                      <font-awesome-icon
-                        v-if="sortCriterion == 'country'"
-                        icon="angle-down"
-                      ></font-awesome-icon>
-                    </span>
-                  </th>
-                  <th scope="col" @click="sortBy('email')">
-                    <span
-                      >Email&nbsp;
-                      <font-awesome-icon
-                        v-if="sortCriterion == 'email'"
-                        icon="angle-down"
-                      ></font-awesome-icon>
-                    </span>
-                  </th>
-                  <th scope="col"></th>
-                </tr>
-              </thead>
-              <transition-group name="fade" tag="tbody">
-                <tr v-for="user in users" :key="user.user">
-                  <td @click="selectUser(user.user)">
-                    <font-awesome-icon
-                      v-tooltip="roleLabel(user.role)"
-                      :icon="roleIcon(user.role)"
-                      class="fa-lg"
-                    ></font-awesome-icon>
-                  </td>
-                  <td @click="selectUser(user.user)">{{ user.user }}</td>
-                  <td @click="selectUser(user.user)">{{ user.country }}</td>
-                  <td @click="selectUser(user.user)">{{ user.email }}</td>
-                  <td class="text-right">
-                    <button
-                      @click="sendTestMail(user.user)"
-                      class="btn btn-sm btn-dark mr-1"
-                      v-tooltip="$gettext('Send testmail')"
-                      v-if="user.email"
-                    >
-                      <font-awesome-icon icon="paper-plane"></font-awesome-icon>
-                    </button>
-                    <button
-                      @click="deleteUser(user.user)"
-                      class="btn btn-sm btn-dark"
-                      v-tooltip="$gettext('Delete user')"
-                    >
-                      <font-awesome-icon icon="trash" />
-                    </button>
-                  </td>
-                </tr>
-              </transition-group>
-            </table>
-          </div>
+          <UITableHeader
+            :columns="[
+              { id: 'role', title: 'Role', class: 'col-1' },
+              { id: 'user', title: 'Username', class: 'col-3' },
+              { id: 'country', title: 'Country', class: 'col-2' },
+              { id: 'email', title: 'Email', class: 'col-3' }
+            ]"
+            @sortingChanged="sortBy"
+          />
+          <UITableBody
+            :data="sortedUsers"
+            maxHeight="47rem"
+            :active="currentUser"
+            v-slot="{ item: user }"
+          >
+            <div class="py-2 col-1" @click="selectUser(user.user)">
+              <font-awesome-icon
+                v-tooltip="roleLabel(user.role)"
+                :icon="roleIcon(user.role)"
+                class="fa-lg"
+              ></font-awesome-icon>
+            </div>
+            <div class="py-2 col-3" @click="selectUser(user.user)">
+              {{ user.user }}
+            </div>
+            <div class="py-2 col-2" @click="selectUser(user.user)">
+              {{ user.country }}
+            </div>
+            <div class="py-2 col-3" @click="selectUser(user.user)">
+              {{ user.email }}
+            </div>
+            <div class="py-2 col text-right">
+              <button
+                @click="sendTestMail(user.user)"
+                class="btn btn-sm btn-dark mr-1"
+                v-tooltip="$gettext('Send testmail')"
+                v-if="user.email"
+              >
+                <font-awesome-icon icon="paper-plane"></font-awesome-icon>
+              </button>
+              <button
+                @click="deleteUser(user.user)"
+                class="btn btn-sm btn-dark"
+                v-tooltip="$gettext('Delete user')"
+              >
+                <font-awesome-icon icon="trash" />
+              </button>
+            </div>
+          </UITableBody>
           <div class="d-flex mx-auto align-items-center">
             <button
               @click="prevPage"
@@ -110,70 +83,44 @@
   </div>
 </template>
 
-<style lang="scss" scoped>
-.addbutton {
-  position: absolute;
-  bottom: $offset;
-  right: $offset;
-}
+<style lang="sass" scoped>
+.addbutton
+  position: absolute
+  bottom: $offset
+  right: $offset
 
-.content {
-  width: 100%;
-}
-
-.userdetails {
-  width: 50%;
-}
+.content
+  width: 100%
 
-.main {
-  height: 100%;
-}
-
-.icon {
-  font-size: large;
-}
+.userdetails
+  width: 50%
 
-.userlist {
-  min-width: 520px;
-  height: 100%;
-}
+.main
+  height: 100%
 
-.userlistsmall {
-  width: 100%;
-}
+.icon
+  font-size: large
 
-.userlistextended {
-  width: 100%;
-}
-
-.table {
-  margin: auto;
-}
+.userlist
+  min-width: 520px
+  height: 100%
 
-.table th {
-  cursor: pointer;
-}
+.userlistsmall
+  width: 100%
 
-.table th:first-child {
-  width: 50px;
-}
+.userlistextended
+  width: 100%
 
-.table th,
-td {
-  font-size: $smaller;
-  border-top: 0px !important;
-  text-align: left;
-  padding: $small-offset !important;
-}
-
-.table td {
-  font-size: $smaller;
-  cursor: pointer;
-}
-
-tr span {
-  display: flex;
-}
+.table-body
+  .row
+    > div
+      transition: background-color 0.3s, color 0.3s
+    &.active
+      > div
+        background-color: $color-info
+        color: #fff
+        a
+          color: #fff !important
 </style>
 
 <script>
@@ -205,11 +152,10 @@
   name: "userview",
   data() {
     return {
-      sortCriterion: "user",
-      pageSize: 20,
-      currentPage: 1,
-      userToDelete: "",
-      showDeleteUserPrompt: false
+      sortColumn: "user",
+      sortDirection: "ASC",
+      pageSize: 15,
+      currentPage: 1
     };
   },
   components: {
@@ -217,29 +163,30 @@
     Spacer: () => import("@/components/Spacer")
   },
   computed: {
-    ...mapGetters("usermanagement", ["isUserDetailsVisible"]),
+    ...mapGetters("usermanagement", [
+      "isUserDetailsVisible",
+      "users",
+      "currentUser"
+    ]),
     ...mapState("application", ["showSidebar"]),
-    users() {
-      let users = [...this.$store.getters["usermanagement/users"]];
-      users.sort((a, b) => {
-        if (
-          a[this.sortCriterion].toLowerCase() <
-          b[this.sortCriterion].toLowerCase()
-        )
-          return -1;
-        if (
-          a[this.sortCriterion].toLowerCase() >
-          b[this.sortCriterion].toLowerCase()
-        )
-          return 1;
-        return 0;
-      });
+    sortedUsers() {
       const start = (this.currentPage - 1) * this.pageSize;
-      return users.slice(start, start + this.pageSize);
+      return this.users
+        .sort((a, b) => {
+          if (
+            a[this.sortColumn].toLowerCase() < b[this.sortColumn].toLowerCase()
+          )
+            return this.sortDirection === "ASC" ? -1 : 1;
+          if (
+            a[this.sortColumn].toLowerCase() > b[this.sortColumn].toLowerCase()
+          )
+            return this.sortDirection === "ASC" ? 1 : -1;
+          return 0;
+        })
+        .slice(start, start + this.pageSize);
     },
     pages() {
-      let users = [...this.$store.getters["usermanagement/users"]];
-      return Math.ceil(users.length / this.pageSize);
+      return Math.ceil(this.users.length / this.pageSize);
     },
     tableStyle() {
       return {
@@ -282,29 +229,19 @@
           });
         });
     },
-    tween() {},
     nextPage() {
       if (this.currentPage < this.pages) {
-        document.querySelector("#datatable").classList.add("fadeOut");
-        setTimeout(() => {
-          document.querySelector("#datatable").classList.remove("fadeOut");
-          this.currentPage += 1;
-        }, 10);
+        this.currentPage += 1;
       }
-      return;
     },
     prevPage() {
       if (this.currentPage > 0) {
-        document.querySelector("#datatable").classList.add("fadeOut");
-        setTimeout(() => {
-          document.querySelector("#datatable").classList.remove("fadeOut");
-          this.currentPage -= 1;
-        }, 10);
+        this.currentPage -= 1;
       }
-      return;
     },
-    sortBy(criterion) {
-      this.sortCriterion = criterion;
+    sortBy(sorting) {
+      this.sortColumn = sorting.sortColumn;
+      this.sortDirection = sorting.sortDirection;
     },
     deleteUser(name) {
       this.$store.commit("application/popup", {
--- a/client/src/main.js	Mon Mar 04 08:32:05 2019 +0100
+++ b/client/src/main.js	Mon Mar 04 16:01:20 2019 +0100
@@ -38,7 +38,7 @@
 import "../node_modules/animate.css/animate.min.css";
 import "../node_modules/ol/ol.css";
 import "../node_modules/highlight.js/styles/paraiso-dark.css";
-import "../node_modules/vue-snotify/styles/material.css";
+import "../node_modules/vue-snotify/styles/simple.css";
 
 // fontawesome5 icons
 import {