changeset 2481:3cf5d27a6c8b octree-diff

Merged defualt into octree-diff branch.
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Fri, 01 Mar 2019 11:06:27 +0100
parents 242104c338ff (current diff) 9de710bdb535 (diff)
children 620038ade708
files client/src/components/importqueue/Importqueue.vue client/src/components/importqueue/Importqueuedetail.vue client/src/components/staging/Staging.vue client/src/components/staging/StagingDetail.vue client/src/components/ui/box/Header.vue
diffstat 36 files changed, 1905 insertions(+), 1853 deletions(-) [+]
line wrap: on
line diff
--- a/client/src/assets/application.scss	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/assets/application.scss	Fri Mar 01 11:06:27 2019 +0100
@@ -57,6 +57,14 @@
   border: 1px solid red;
 }
 
+.debug2 {
+  border: 1px solid magenta;
+}
+
+.debug3 {
+  border: 1px solid greenyellow;
+}
+
 %fully-centered {
   position: absolute;
   top: 50%;
@@ -120,10 +128,12 @@
   font-weight: bold;
 }
 
-.fade-enter-active, .fade-leave-active {
-  transition: opacity .3s;
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.3s;
 }
-.fade-enter, .fade-leave-to {
+.fade-enter,
+.fade-leave-to {
   opacity: 0;
 }
 
--- a/client/src/components/App.vue	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/components/App.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -37,9 +37,6 @@
 .small {
   width: $icon-width;
 }
-.wide {
-  width: 600px;
-}
 
 .userinterface {
   position: absolute;
@@ -90,7 +87,9 @@
     ...mapState("user", ["isAuthenticated"]),
     ...mapState("application", ["contextBoxContent", "showSearchbar"]),
     isMapVisible() {
-      return /stretches|review|bottlenecks|mainview/.test(this.routeName);
+      return /importoverview|stretches|review|bottlenecks|mainview/.test(
+        this.routeName
+      );
     },
     routeName() {
       const routeName = this.$route.name;
--- a/client/src/components/Bottlenecks.vue	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/components/Bottlenecks.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -158,7 +158,7 @@
       "showSearchbarLastState",
       "showSplitscreen"
     ]),
-    ...mapState("bottlenecks", ["bottlenecks"]),
+    ...mapState("bottlenecks", ["bottlenecksList"]),
     sortIcon() {
       return this.sortDirection === "ASC"
         ? "sort-amount-down"
@@ -170,7 +170,7 @@
       return formatSurveyDate(date);
     },
     filteredAndSortedBottlenecks() {
-      return this.bottlenecks
+      return this.bottlenecksList
         .filter(bn => {
           return bn.properties.name
             .toLowerCase()
@@ -287,7 +287,7 @@
     }
   },
   mounted() {
-    this.$store.dispatch("bottlenecks/loadBottlenecks");
+    this.$store.dispatch("bottlenecks/loadBottlenecksList");
   }
 };
 </script>
--- a/client/src/components/Contextbox.vue	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/components/Contextbox.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -3,6 +3,9 @@
     <Bottlenecks v-if="contextBoxContent === 'bottlenecks'"></Bottlenecks>
     <Staging v-if="contextBoxContent === 'staging'"></Staging>
     <Stretches v-if="contextBoxContent === 'stretches'"></Stretches>
+    <ImportOverview
+      v-if="contextBoxContent === 'importoverview'"
+    ></ImportOverview>
   </div>
 </template>
 
@@ -25,9 +28,10 @@
 export default {
   name: "contextbox",
   components: {
-    Bottlenecks: () => import("./Bottlenecks"),
-    Staging: () => import("./staging/Staging.vue"),
-    Stretches: () => import("./ImportStretches.vue")
+    Bottlenecks: () => import("@/components/Bottlenecks"),
+    Stretches: () => import("@/components/ImportStretches.vue"),
+    ImportOverview: () =>
+      import("@/components/importoverview/ImportOverview.vue")
   },
   computed: {
     ...mapState("application", [
@@ -71,7 +75,7 @@
   background: #fff;
 }
 .contextbox > div:last-child {
-  width: 600px;
+  width: 660px;
 }
 
 .contextboxcollapsed {
@@ -80,8 +84,7 @@
 }
 
 .contextboxextended {
-  max-width: 700px;
-  max-height: 640px;
+  max-width: 660px;
 }
 
 .close-contextbox {
--- a/client/src/components/ImportSoundingresults.vue	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/components/ImportSoundingresults.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -2,21 +2,30 @@
   <div class="main d-flex flex-column">
     <div class="d-flex flex-row">
       <Spacer></Spacer>
-      <div class="card shadow-xs mt-3 mr-3 w-100 importsoundingresultscard">
+      <div class="card shadow-xs mt-3 mr-3 w-100 h-100">
         <UIBoxHeader icon="upload" title="Import Soundingresults" />
-        <div v-if="editState" class="ml-auto mr-auto mt-4 w-95">
-          <div class="d-flex flex-column">
-            <div class="d-flex flex-row">
-              <div class="mt-1 text-left w-50 ml-2 mr-4">
+        <div v-if="editState">
+          <div
+            v-for="(message, index) in messages"
+            :key="index"
+            class="alert alert-warning small rounded-0"
+          >
+            {{ message }}
+          </div>
+          <div class="container">
+            <div class="row">
+              <div class="col-5">
                 <small class="text-muted">
                   <translate>Bottleneck</translate>
                 </small>
                 <select v-model="bottleneck" class="custom-select">
                   <option
                     v-for="bottleneck in availableBottlenecks"
-                    :key="bottleneck"
-                    >{{ bottleneck }}</option
+                    :value="bottleneck"
+                    :key="bottleneck.properties.objnam"
                   >
+                    {{ bottleneck.properties.objnam }}
+                  </option>
                 </select>
                 <span class="text-danger">
                   <small v-if="!bottleneck">
@@ -24,7 +33,7 @@
                   </small>
                 </span>
               </div>
-              <div class="d-flex flex-column mt-1 text-left w-50 mr-2">
+              <div class="col-2">
                 <small class="text-muted">
                   <translate>Projection</translate>&nbsp;(EPSG)
                 </small>
@@ -41,9 +50,7 @@
                   </small>
                 </span>
               </div>
-            </div>
-            <div class="d-flex flex-row">
-              <div class="mt-1 text-left w-50 ml-2 mr-4">
+              <div class="col-2">
                 <small class="text-muted">
                   <translate>Depthreference</translate>
                 </small>
@@ -53,7 +60,7 @@
                   id="depthreference"
                 >
                   <option
-                    v-for="option in this.$options.depthReferenceOptions"
+                    v-for="option in this.depthReferenceOptions"
                     :key="option"
                     >{{ option }}</option
                   >
@@ -64,7 +71,7 @@
                   </small>
                 </span>
               </div>
-              <div class="mt-1 text-left w-50 mr-2">
+              <div class="col-3">
                 <small class="text-muted"> <translate>Date</translate> </small>
                 <input
                   id="importdate"
@@ -82,15 +89,11 @@
                 </span>
               </div>
             </div>
-          </div>
-          <div class="ml-2 mt-2 text-left">
-            <small v-for="(message, index) in messages" :key="index">
-              {{ message }}
-            </small>
+            <div class="row"></div>
           </div>
         </div>
-        <div class="w-95 ml-auto mr-auto mt-4 mb-4">
-          <div v-if="uploadState" class="d-flex flex-row input-group mb-4">
+        <div class="container py-5">
+          <div v-if="uploadState" class="input-group">
             <div class="custom-file">
               <input
                 accept=".zip"
@@ -104,29 +107,31 @@
               </label>
             </div>
           </div>
-          <div class="buttons text-right" v-if="editState">
+          <div class="d-flex justify-content-between" v-if="editState">
             <a
               download="meta.json"
               :href="dataLink"
-              class="btn btn-outline-info pull-left mt-4"
+              class="btn btn-outline-info"
             >
               <translate>Download Meta.json</translate>
             </a>
-            <button
-              @click="deleteTempData"
-              class="btn btn-danger mt-4"
-              type="button"
-            >
-              <translate>Cancel Upload</translate>
-            </button>
-            <button
-              :disabled="disableUploadButton"
-              @click="confirm"
-              class="btn btn-info mt-4"
-              type="button"
-            >
-              <translate>Confirm</translate>
-            </button>
+            <span>
+              <button
+                @click="deleteTempData"
+                class="btn btn-danger"
+                type="button"
+              >
+                <translate>Cancel Upload</translate>
+              </button>
+              <button
+                :disabled="disableUploadButton"
+                @click="confirm"
+                class="btn btn-info ml-2"
+                type="button"
+              >
+                <translate>Confirm</translate>
+              </button>
+            </span>
           </div>
         </div>
       </div>
@@ -179,7 +184,7 @@
     initialState() {
       this.importState = IMPORTSTATE.UPLOAD;
       this.depthReference = "";
-      this.bottleneck = "";
+      this.bottleneck = null;
       this.projection = "";
       this.importDate = "";
       this.uploadLabel = this.$gettext("choose .zip- file");
@@ -225,7 +230,9 @@
           if (response.data.meta) {
             const { bottleneck, date, epsg } = response.data.meta;
             const depthReference = response.data.meta["depth-reference"];
-            this.bottleneck = bottleneck;
+            this.bottleneck = this.bottlenecks.find(
+              bn => bn.properties.objnam === bottleneck
+            );
             this.depthReference = depthReference;
             this.importDate = new Date(date).toISOString().split("T")[0];
             this.projection = epsg;
@@ -246,7 +253,8 @@
     confirm() {
       let formData = new FormData();
       formData.append("token", this.token);
-      if (this.bottleneck) formData.append("bottleneck", this.bottleneck);
+      if (this.bottleneck)
+        formData.append("bottleneck", this.bottleneck.properties.objnam);
       if (this.importDate)
         formData.append("date", this.importDate.split("T")[0]);
       if (this.depthReference)
@@ -262,7 +270,9 @@
         .then(() => {
           displayInfo({
             title: this.$gettext("Import"),
-            message: this.$gettext("Starting import for ") + this.bottleneck
+            message:
+              this.$gettext("Starting import for ") +
+              this.bottleneck.properties.objnam
           });
           this.initialState();
         })
@@ -298,7 +308,7 @@
       return this.disableUpload;
     },
     availableBottlenecks() {
-      return this.bottlenecks.map(x => x.properties.name);
+      return this.bottlenecks;
     },
     editState() {
       return this.importState === IMPORTSTATE.EDIT;
@@ -318,63 +328,23 @@
         encodeURIComponent(
           JSON.stringify({
             depthReference: this.depthReference,
-            bottleneck: this.bottleneck,
+            bottleneck: this.bottleneck.properties.objnam,
             date: this.importDate
           })
         )
       );
+    },
+    depthReferenceOptions() {
+      if (
+        this.bottleneck &&
+        this.bottleneck.properties.reference_water_levels
+      ) {
+        return Object.keys(
+          JSON.parse(this.bottleneck.properties.reference_water_levels)
+        );
+      }
+      return [];
     }
-  },
-  depthReferenceOptions: [
-    "",
-    // "NAP",
-    // "KP",
-    // "FZP",
-    // "ADR",
-    // "TAW",
-    // "PUL",
-    // "NGM",
-    // "ETRS",
-    // "POT",
-    // "LDC",
-    // "HDC",
-    // "ZPG",
-    // "GLW",
-    // "HSW",
-    // "LNW",
-    // "HNW",
-    // "IGN",
-    // "WGS",
-    "RN" //,
-    // "HBO"
-  ]
+  }
 };
 </script>
-
-<style lang="scss" scoped>
-.importsoundingresultscard {
-  height: 100%;
-}
-
-.projectionLabel {
-  margin-left: $small-offset;
-}
-
-.depthreferencelabel {
-  margin-left: $small-offset;
-}
-
-.offset-r {
-  margin-right: $small-offset;
-}
-
-.buttons button {
-  margin-left: $offset !important;
-}
-
-.label-text {
-  width: 5rem;
-  text-align: left;
-  line-height: 2.25rem;
-}
-</style>
--- a/client/src/components/ImportStretches.vue	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/components/ImportStretches.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -346,8 +346,8 @@
       this.countryCode = properties.countries;
       this.source = properties["source_organization"];
       this.edit = true;
-      this.startrhm = this.sanitizeRHM(properties.lower);
-      this.endrhm = this.sanitizeRHM(properties.upper);
+      this.startrhm = properties.lower;
+      this.endrhm = properties.upper;
       this.idEditable = false;
     },
     deleteStretch(stretch) {
@@ -535,8 +535,8 @@
     },
     pointsValid() {
       if (!this.startrhm || !this.endrhm) return true;
-      const start = this.startrhm.replace(/\D+/, "") * 1;
-      const end = this.endrhm.replace(/\D+/, "") * 1;
+      const start = this.startrhm.replace(/\D+/g, "") * 1;
+      const end = this.endrhm.replace(/\D+/g, "") * 1;
       const result = start < end;
       return result;
     }
--- a/client/src/components/Maplayer.vue	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/components/Maplayer.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -399,7 +399,6 @@
     this.updateBottleneckFilter("does_not_exist", "1999-10-01");
     this.$store.dispatch("map/disableIdentifyTool");
     this.$store.dispatch("map/enableIdentifyTool");
-    this.$store.dispatch("bottlenecks/loadBottlenecks");
   }
 };
 </script>
--- a/client/src/components/Search.vue	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/components/Search.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -77,7 +77,7 @@
 
 .searchgroup {
   margin-left: -3px;
-  width: 571px;
+  width: 630px;
   overflow: hidden;
 }
 
--- a/client/src/components/Sidebar.vue	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/components/Sidebar.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -28,7 +28,7 @@
           <span class="fix-trans-space" v-translate>Bottlenecks</span>
         </router-link>
         <div v-if="isWaterwayAdmin">
-          <router-link to="/review" class="position-relative">
+          <router-link to="/imports/overview" class="position-relative">
             <font-awesome-icon
               class="fa-fw mr-2"
               fixed-width
@@ -126,16 +126,6 @@
             <span class="fix-trans-space" v-translate>Logs</span>
           </router-link>
         </div>
-        <div v-if="isWaterwayAdmin">
-          <router-link to="/importqueue">
-            <font-awesome-icon
-              class="fa-fw mr-2"
-              fixed-width
-              icon="tasks"
-            ></font-awesome-icon>
-            <span class="fix-trans-space" v-translate>Importqueue</span>
-          </router-link>
-        </div>
         <hr class="m-0" />
         <a @click="logoff" href="#" class="logout">
           <font-awesome-icon
@@ -212,13 +202,15 @@
     }
   },
   mounted() {
-    this.$store.dispatch("imports/getStaging").catch(error => {
-      const { status, data } = error.response;
-      displayError({
-        title: "Backend Error",
-        message: `${status}: ${data.message || data}`
+    setTimeout(() => {
+      this.$store.dispatch("imports/getStaging").catch(error => {
+        const { status, data } = error.response;
+        displayError({
+          title: "Backend Error",
+          message: `${status}: ${data.message || data}`
+        });
       });
-    });
+    }, 15000);
   }
 };
 </script>
@@ -255,9 +247,13 @@
   .indicator {
     left: auto;
     right: 10px;
-    top: 10px;
+    top: 12px;
     border-radius: 0.25rem;
   }
+  &.router-link-exact-active .indicator {
+    background: #fff;
+    color: #333;
+  }
 }
 
 .menu a svg path {
--- a/client/src/components/fairway/Fairwayprofile.vue	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/components/fairway/Fairwayprofile.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -142,22 +142,26 @@
     ...mapGetters("fairwayprofile", ["totalLength"]),
     ...mapState("application", ["showSplitscreen"]),
     ...mapState("fairwayprofile", [
-      "startPoint",
+      "additionalSurvey",
+      "currentProfile",
       "endPoint",
-      "currentProfile",
-      "additionalSurvey",
+      "fairwayData",
       "minAlt",
       "maxAlt",
-      "fairwayData",
-      "waterLevels",
+      "profileLoading",
+      "referenceWaterLevel",
       "selectedWaterLevel",
-      "profileLoading"
+      "startPoint",
+      "waterLevels"
     ]),
     ...mapState("bottlenecks", [
       "selectedBottleneck",
       "selectedSurvey",
       "surveysLoading"
     ]),
+    relativeWaterLevelDelta() {
+      return this.selectedWaterLevel.value - this.referenceWaterLevel;
+    },
     currentLevel: {
       get() {
         return this.selectedWaterLevel.date;
@@ -192,6 +196,9 @@
       return [0, this.totalLength];
     },
     yScaleRight() {
+      //ToDO calcReleativeDepth(this.maxAlt) to get the
+      // maximum depth according to the actual waterlevel
+      // additionally: take the one which is higher reference or current waterlevel
       const DELTA = this.maxAlt * 1.1 - this.maxAlt;
       return [this.maxAlt * 1 + DELTA, -DELTA];
     }
@@ -223,6 +230,25 @@
     formatSurveyDate(value) {
       return formatSurveyDate(value);
     },
+    calcRelativeDepth(depth) {
+      /* takes a depth value and substracts the delta of the relative waterlevel
+       * say the reference level is above the current level, the ground is nearer,
+       * thus, the depth is lower.
+       *
+       * E.g.:
+       *
+       * Reference waterlevel 5m, current 4m => delta = -1m
+       * If the distance to the ground was 3m from the 5m mark
+       * it is now only 2m from the current waterlevel.
+       *
+       *  Vice versa:
+       *
+       *  If the reference level is 5m and the current 6m => delta = +1m
+       *  The ground is one meter farer away from the current waterlevel
+       *
+       */
+      return depth - this.relativeWaterLevelDelta;
+    },
     drawDiagram() {
       this.coordinatesSelect = null;
       const chartDiv = document.querySelector(".fairwayprofile");
--- a/client/src/components/fairway/Profiles.vue	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/components/fairway/Profiles.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -25,7 +25,7 @@
             <translate>Select Bottleneck</translate>
           </option>
           <option
-            v-for="bn in bottlenecks"
+            v-for="bn in bottlenecksList"
             :key="bn.properties.name"
             :value="bn.properties.name"
             >{{ bn.properties.name }}</option
@@ -259,7 +259,11 @@
     ...mapGetters("map", ["getVSourceByName"]),
     ...mapState("application", ["showProfiles"]),
     ...mapState("map", ["lineTool", "polygonTool", "cutTool"]),
-    ...mapState("bottlenecks", ["bottlenecks", "surveys", "surveysLoading"]),
+    ...mapState("bottlenecks", [
+      "bottlenecksList",
+      "surveys",
+      "surveysLoading"
+    ]),
     ...mapState("fairwayprofile", [
       "previousCuts",
       "startPoint",
@@ -452,7 +456,7 @@
       displayInfo({ title: this.$gettext("Profile deleted!") });
     },
     moveToBottleneck() {
-      const bottleneck = this.bottlenecks.find(
+      const bottleneck = this.bottlenecksList.find(
         bn => bn.properties.name === this.selectedBottleneck
       );
       if (!bottleneck) return;
@@ -462,6 +466,9 @@
         preventZoomOut: true
       });
     }
+  },
+  mounted() {
+    this.$store.dispatch("bottlenecks/loadBottlenecksList");
   }
 };
 </script>
--- a/client/src/components/identify/Identify.vue	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/components/identify/Identify.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -135,20 +135,40 @@
       return this.featureId(feature);
     },
     featureProps(feature) {
+      let featureId = this.featureId(feature);
+
       // create array with {key, val} objects
       let propsArray = [];
       Object.keys(feature.getProperties()).forEach(key => {
-        // avoid cyclic object value
-        if (key !== feature.getGeometryName())
-          propsArray.push({ key, val: feature.getProperties()[key] });
+        // skip geometry (would lead to cyclic object error)
+        if (key !== feature.getGeometryName()) {
+          let val = feature.getProperties()[key];
+
+          // if val is a valid json object string, spread its values into the array
+          let jsonObj = this.getObjectFromString(val);
+          if (jsonObj) {
+            Object.keys(jsonObj).forEach(key => {
+              propsArray.push({ key, val: jsonObj[key] });
+            });
+          } else {
+            // otherwise just put the key value pair into the array
+            propsArray.push({ key, val });
+          }
+        }
       });
 
       // change labels and remove unneeded properties
-      if (formatter.hasOwnProperty(this.featureId(feature))) {
-        propsArray = propsArray
-          .map(formatter[this.featureId(feature)].props)
-          .filter(p => p); // remove empty entries
+      // for all features
+      propsArray = propsArray.map(formatter.all);
+      // feature specific
+      if (
+        formatter.hasOwnProperty(featureId) &&
+        formatter[featureId].hasOwnProperty("props")
+      ) {
+        propsArray = propsArray.map(formatter[featureId].props);
       }
+      // remove empty entries
+      propsArray = propsArray.filter(p => p);
 
       // remove underscores in labels that where not previously changed already
       propsArray = propsArray.map(prop => {
@@ -156,6 +176,22 @@
       });
 
       return propsArray;
+    },
+    getObjectFromString(val) {
+      // JSON.parse() accepts integers and null as valid json. So to be sure to
+      // get an object, we cannot just try JSON.parse() but we need to check if
+      // the given value is a string and starts with a {.
+      if (
+        Object.prototype.toString.call(val) === "[object String]" &&
+        val[0] === "{"
+      ) {
+        try {
+          return JSON.parse(val);
+        } catch (e) {
+          return null;
+        }
+      }
+      return null;
     }
   }
 };
--- a/client/src/components/identify/formatter.js	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/components/identify/formatter.js	Fri Mar 01 11:06:27 2019 +0100
@@ -1,66 +1,42 @@
 const formatter = {
+  all(p) {
+    if (p.key === "objnam") p.key = "Name";
+    if (p.key === "staging_done") p.val = p.val ? "yes" : "no";
+    if (p.key === "date_info") {
+      p.val = new Date(p.val).toLocaleString();
+    }
+    return p;
+  },
   bottlenecks_geoserver: {
     label: "Bottleneck",
     props: p => {
       if (p.key === "bottleneck_id") p.key = "ID";
-      if (p.key === "objnam") p.key = "Name";
       if (p.key === "responsible_country") p.key = "Responsible Country";
-      if (p.key === "date_info") {
-        p.val = new Date(p.val).toLocaleString();
-      }
 
       // remove certain props
-      let propsToRemove = ["nobjnm"];
+      let propsToRemove = ["nobjnm", "reference_water_levels"];
       if (propsToRemove.indexOf(p.key) !== -1) return null;
 
       return p;
     }
   },
   fairway_dimensions: {
-    label: "Fairway Dimensions",
-    props: p => {
-      if (p.key === "staging_done") p.val = p.val ? "yes" : "no";
-      if (p.key === "date_info") {
-        p.val = new Date(p.val).toLocaleString();
-      }
-
-      // remove certain props
-      let propsToRemove = [];
-      if (propsToRemove.indexOf(p.key) !== -1) return null;
-
-      return p;
-    }
+    label: "Fairway Dimensions"
   },
   waterway_area: {
-    label: "Waterway Area",
-    props: p => p
+    label: "Waterway Area"
   },
   distance_marks_geoserver: {
-    label: "Distance Mark",
-    props: p => p
+    label: "Distance Mark"
   },
   waterway_axis: {
-    label: "Waterway Axis",
-    props: p => {
-      if (p.key === "objnam") p.key = "Name";
-      return p;
-    }
+    label: "Waterway Axis"
   },
   waterway_profiles: {
-    label: "Waterway Profile",
-    props: p => {
-      if (p.key === "staging_done") p.val = p.val ? "yes" : "no";
-      if (p.key === "date_info") {
-        p.key = "Date info";
-        p.val = new Date(p.val).toLocaleString();
-      }
-
-      // remove certain props
-      let propsToRemove = [];
-      if (propsToRemove.indexOf(p.key) !== -1) return null;
-
-      return p;
-    }
+    label: "Waterway Profile"
+  },
+  stretches_geoserver: {
+    label: "Stretch"
   }
 };
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/importoverview/ImportOverview.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -0,0 +1,134 @@
+<template>
+  <div>
+    <UIBoxHeader
+      icon="clipboard-check"
+      title="Staging Area"
+      :closeCallback="$parent.close"
+    />
+    <div class="d-flex flex-row w-100 justify-content-end">
+      <button
+        class="btn btn-sm btn-dark align-self-start mt-3 mr-3"
+        @click="refresh"
+      >
+        <translate>Refresh</translate>
+      </button>
+    </div>
+    <div class="d-flex flex-row w-100 border-bottom">
+      <font-awesome-icon
+        class="pointer"
+        @click="toggleStaging()"
+        v-if="stagingVisible && staging.length > 0"
+        icon="angle-up"
+        fixed-width
+      ></font-awesome-icon>
+      <font-awesome-icon
+        class="pointer"
+        @click="toggleStaging()"
+        v-if="!stagingVisible && staging.length > 0"
+        icon="angle-down"
+        fixed-width
+      ></font-awesome-icon>
+      <span style="width:1.25em;" v-if="!(staging.length > 0)"></span>
+      <Staging v-if="stagingVisible && staging.length > 0"></Staging>
+      <div v-else class="d-flex flex-row">
+        <h6>
+          <small><translate>Review</translate></small>
+        </h6>
+        <small class="ml-3" v-if="!(staging.length > 0)"
+          ><translate>Nothing to review</translate></small
+        >
+      </div>
+    </div>
+    <div class="mt-2">
+      <div class="d-flex flex-row">
+        <font-awesome-icon
+          class="pointer"
+          @click="toggleLogs()"
+          v-if="logsVisible"
+          icon="angle-up"
+          fixed-width
+        ></font-awesome-icon>
+        <font-awesome-icon
+          class="pointer"
+          @click="toggleLogs()"
+          v-if="!logsVisible"
+          icon="angle-down"
+          fixed-width
+        ></font-awesome-icon>
+        <Logs v-if="logsVisible"></Logs>
+        <div v-else>
+          <h6>
+            <small><translate>Logs</translate></small>
+          </h6>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Thomas Junk <thomas.junk@intevation.de>
+ */
+import { displayError } from "@/lib/errors.js";
+import { mapState } from "vuex";
+
+export default {
+  name: "importoverview",
+  components: {
+    Staging: () => import("./staging/Staging.vue"),
+    Logs: () => import("./importlogs/Logs.vue")
+  },
+  computed: {
+    ...mapState("imports", ["stagingVisible", "logsVisible", "staging"])
+  },
+  methods: {
+    toggleStaging() {
+      this.$store.commit("imports/setStagingVisibility", !this.stagingVisible);
+    },
+    toggleLogs() {
+      this.$store.commit("imports/setLogsVisibility", !this.logsVisible);
+    },
+    refresh() {
+      this.loadImportQueue();
+      this.loadLogs();
+    },
+    loadImportQueue() {
+      this.$store.dispatch("imports/getStaging").catch(error => {
+        const { status, data } = error.response;
+        displayError({
+          title: "Backend Error",
+          message: `${status}: ${data.message || data}`
+        });
+      });
+    },
+    loadLogs() {
+      this.$store
+        .dispatch("imports/getImports")
+        .then(() => {})
+        .catch(error => {
+          const { status, data } = error.response;
+          displayError({
+            title: this.$gettext("Backend Error"),
+            message: `${status}: ${data.message || data}`
+          });
+        });
+    }
+  },
+  mounted() {
+    this.refresh();
+  }
+};
+</script>
+
+<style></style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/importoverview/importlogs/LogDetail.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -0,0 +1,356 @@
+<template>
+  <div class="entry d-flex flex-column py-1 border-bottom">
+    <div class="d-flex flex-row position-relative">
+      <small @click="showDetails(job.id)" class="jobid ml-2 mt-1 mr-2">
+        {{ job.id }}
+      </small>
+      <small @click="showDetails(job.id)" class="enqueued mt-1  mr-2">
+        {{ formatDateTime(job.enqueued) }}
+      </small>
+      <small @click="showDetails(job.id)" class="kind mt-1 mr-2">
+        {{ job.kind.toUpperCase() }}
+      </small>
+      <small @click="showDetails(job.id)" class="user mt-1 mr-2">
+        {{ job.user }}
+      </small>
+      <small @click="showDetails(job.id)" class="signer mt-1 mr-2">
+        {{ job.signer }}
+      </small>
+      <small @click="showDetails(job.id)" class="state mt-1 mr-2">
+        <span :class="{ 'text-danger': job.state.toUpperCase() == 'FAILED' }"
+          >{{ job.state
+          }}<font-awesome-icon
+            v-if="job.warnings"
+            class="ml-1 text-warning"
+            icon="exclamation-triangle"
+            fixed-width
+          ></font-awesome-icon>
+        </span>
+        <span v-if="!job.warnings" style="margin-right: 1.6em;"></span>
+      </small>
+      <div @click="showDetails(job.id)" class="mt-1 text-info detailsbutton">
+        <font-awesome-icon
+          class="pointer"
+          v-if="show"
+          icon="angle-up"
+          fixed-width
+        ></font-awesome-icon>
+        <font-awesome-icon
+          class="pointer"
+          v-if="loading"
+          icon="spinner"
+          fixed-width
+        ></font-awesome-icon>
+        <font-awesome-icon
+          class="pointer"
+          v-if="!show && !loading"
+          icon="angle-down"
+          fixed-width
+        ></font-awesome-icon>
+      </div>
+    </div>
+    <div class="detailstable d-flex flex-row">
+      <div :class="collapse">
+        <table class="table table-responsive">
+          <thead>
+            <tr>
+              <th class="type pb-0">
+                <small class="condensed"><translate>Kind</translate></small>
+              </th>
+              <th class="datetime  pb-0">
+                <a href="#" @click="sortAsc = !sortAsc" class="sort-link"
+                  ><small class="condensed"><translate>Date</translate></small>
+                  <small class="condensed"
+                    ><font-awesome-icon
+                      :icon="sortIcon"
+                      class="ml-1"
+                    ></font-awesome-icon></small
+                ></a>
+              </th>
+              <th class="message pb-0">
+                <small class="condensed"><translate>Message</translate></small>
+              </th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr
+              v-for="(entry, index) in sortedEntries"
+              :key="index"
+              class="detailsrow"
+            >
+              <td class="type">
+                <span
+                  :class="[
+                    'condensed',
+                    {
+                      'text-danger': entry.kind.toUpperCase() == 'ERROR',
+                      'text-warning': entry.kind.toUpperCase() == 'WARN'
+                    }
+                  ]"
+                  >{{ entry.kind.toUpperCase() }}</span
+                >
+              </td>
+              <td class="datetime">
+                <span
+                  :class="[
+                    'condensed',
+                    {
+                      'text-danger': entry.kind.toUpperCase() == 'ERROR',
+                      'text-warning': entry.kind.toUpperCase() == 'WARN'
+                    }
+                  ]"
+                  >{{ formatDateTime(entry.time) }}</span
+                >
+              </td>
+              <td class="message">
+                <span
+                  :class="[
+                    'condensed',
+                    {
+                      'text-danger': entry.kind.toUpperCase() == 'ERROR',
+                      'text-warning': entry.kind.toUpperCase() == 'WARN'
+                    }
+                  ]"
+                  >{{ entry.message }}</span
+                >
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Thomas Junk <thomas.junk@intevation.de>
+ */
+
+import { HTTP } from "@/lib/http.js";
+import { displayError } from "@/lib/errors.js";
+import locale2 from "locale2";
+
+export default {
+  name: "importqueuedetail",
+  props: ["job", "reload"],
+  data() {
+    return {
+      loading: false,
+      show: false,
+      entries: [],
+      sortAsc: true
+    };
+  },
+  mounted() {
+    this.openSpecificDetail();
+  },
+  watch: {
+    $route() {
+      this.openSpecificDetail();
+    },
+    reload() {
+      if (this.reload) {
+        this.entries = [];
+        this.show = false;
+      }
+    }
+  },
+  methods: {
+    openSpecificDetail() {
+      const { id } = this.$route.params;
+      if (id == this.job.id) {
+        this.showDetails(id);
+      } else {
+        this.show = false;
+      }
+    },
+    formatDate(date) {
+      return date
+        ? new Date(date).toLocaleDateString(locale2, {
+            day: "2-digit",
+            month: "2-digit",
+            year: "numeric"
+          })
+        : "";
+    },
+    formatDateTime(date) {
+      if (!date) return "";
+      const d = new Date(date);
+      return (
+        d.toLocaleDateString(locale2, {
+          day: "2-digit",
+          month: "2-digit",
+          year: "numeric"
+        }) +
+        " - " +
+        d.toLocaleTimeString(locale2, {
+          hour12: false
+        })
+      );
+    },
+    showDetails(id) {
+      if (this.show) {
+        this.show = false;
+        return;
+      }
+      if (this.entries.length === 0) {
+        this.loading = true;
+        HTTP.get("/imports/" + id, {
+          headers: { "X-Gemma-Auth": localStorage.getItem("token") }
+        })
+          .then(response => {
+            const { entries } = response.data;
+            this.entries = entries;
+            this.show = true;
+            this.loading = false;
+          })
+          .catch(error => {
+            const { status, data } = error.response;
+            displayError({
+              title: this.$gettext("Backend Error"),
+              message: `${status}: ${data.message || data}`
+            });
+          });
+      } else {
+        this.show = true;
+      }
+    }
+  },
+  computed: {
+    sortedEntries() {
+      let sorted = this.entries.slice();
+      sorted.sort((r1, r2) => {
+        let d1 = new Date(r1.time);
+        let d2 = new Date(r2.time);
+        if (d2 < d1) {
+          return !this.sortAsc ? -1 : 1;
+        }
+        if (d2 > d1) {
+          return !this.sortAsc ? 1 : -1;
+        }
+        return 0;
+      });
+      return sorted;
+    },
+    sortIcon() {
+      return this.sortAsc ? "sort-amount-down" : "sort-amount-up";
+    },
+    icon() {
+      return {
+        "angle-up": !this.show,
+        "angle-down": this.show
+      };
+    },
+    collapse() {
+      return {
+        details: true,
+        collapse: true,
+        show: this.show,
+        "w-100": true
+      };
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.condensed {
+  font-stretch: condensed;
+}
+
+.entry {
+  background-color: white;
+  cursor: pointer;
+  width: 100%;
+}
+
+.entry:hover {
+  background-color: #efefef;
+  transition: 1.6s;
+}
+
+.detailstable {
+  margin-left: $offset;
+  margin-right: $large-offset;
+}
+
+.detailsbutton {
+  position: absolute;
+  top: 0;
+  right: 0;
+  height: 100%;
+}
+.jobid {
+  width: 5%;
+}
+
+.user {
+  width: 15%;
+}
+
+.signer {
+  width: 15%;
+}
+
+.kind {
+  width: 10%;
+}
+
+.state {
+  width: 15%;
+}
+
+.details {
+  width: 50%;
+}
+
+.detailsrow {
+  line-height: 0.1em;
+}
+
+.type {
+  width: 65px;
+  white-space: nowrap;
+  padding-left: 0px;
+  border-top: 0px;
+  padding-bottom: $small-offset;
+}
+
+.datetime {
+  width: 200px;
+  white-space: nowrap;
+  padding-left: 0px;
+  border-top: 0px;
+  padding-bottom: $small-offset;
+}
+
+.message {
+  min-width: 700px;
+  white-space: nowrap;
+  padding-left: 0px;
+  border-top: 0px;
+  padding-bottom: $small-offset;
+}
+
+thead,
+tbody {
+  display: block;
+}
+
+tbody {
+  height: 150px;
+  overflow-y: auto;
+  overflow-x: auto;
+}
+</style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/importoverview/importlogs/Logs.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -0,0 +1,132 @@
+<template>
+  <div class="w-95">
+    <div class="text-left">
+      <h6><translate>Logs</translate></h6>
+    </div>
+    <div class="d-flex justify-content-between flex-row">
+      <button @click="setFilter('failed')" :class="failedStyle">
+        <translate>Failed</translate>
+      </button>
+      <button @click="setFilter('pending')" :class="pendingStyle">
+        <translate>Pending</translate>
+      </button>
+      <button @click="setFilter('rejected')" :class="rejectedStyle">
+        <translate>Rejected</translate>
+      </button>
+      <button @click="setFilter('accepted')" :class="acceptedStyle">
+        <translate>Accepted</translate>
+      </button>
+      <button @click="setFilter('warning')" :class="warningStyle">
+        <translate>Warning</translate>
+      </button>
+    </div>
+    <div class="mt-3 logdetails">
+      <div v-for="job in imports" :key="job.id" class="d-flex flex-row">
+        <LogDetail :job="job"></LogDetail>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Thomas Junk <thomas.junk@intevation.de>
+ */
+
+import { mapState } from "vuex";
+
+export default {
+  name: "logsection",
+  components: {
+    LogDetail: () => import("./LogDetail.vue")
+  },
+  data() {
+    return {
+      failed: false,
+      pending: false,
+      rejected: false,
+      accepted: false,
+      warning: false
+    };
+  },
+  computed: {
+    ...mapState("imports", ["imports"]),
+    pendingStyle() {
+      return {
+        btn: true,
+        "btn-sm": true,
+        "btn-light": !this.pending,
+        "btn-info": this.pending
+      };
+    },
+    failedStyle() {
+      return {
+        btn: true,
+        "btn-sm": true,
+        "btn-light": !this.failed,
+        "btn-info": this.failed
+      };
+    },
+    rejectedStyle() {
+      return {
+        btn: true,
+        "btn-sm": true,
+        "btn-light": !this.rejected,
+        "btn-info": this.rejected
+      };
+    },
+    acceptedStyle() {
+      return {
+        btn: true,
+        "btn-sm": true,
+        "btn-light": !this.accepted,
+        "btn-info": this.accepted
+      };
+    },
+    warningStyle() {
+      return {
+        btn: true,
+        "btn-sm": true,
+        "btn-light": !this.warning,
+        "btn-info": this.warning
+      };
+    }
+  },
+  methods: {
+    setFilter(name) {
+      this[name] = !this[name];
+      const allSet =
+        this.failed &&
+        this.pending &&
+        this.accepted &&
+        this.rejected &&
+        this.warning;
+      if (allSet) {
+        this.warning = false;
+        this.successful = false;
+        this.failed = false;
+        this.pending = false;
+        this.accepted = false;
+        this.rejected = false;
+      }
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.logdetails {
+  overflow-y: auto;
+  height: 650px;
+}
+</style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/importoverview/staging/Staging.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -0,0 +1,91 @@
+<template>
+  <div class="w-100">
+    <div class="d-flex justify-content-between flex-row w-100 border-bottom">
+      <h6><translate>Review</translate></h6>
+      <button class="btn btn-sm btn-info align-self-end" @click="save">
+        <translate>Confirm</translate>
+      </button>
+    </div>
+    <StagingDetail
+      class="mb-3 border-bottom"
+      :key="data.id"
+      v-for="data in filteredData"
+      :data="data"
+    ></StagingDetail>
+  </div>
+</template>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Thomas Junk <thomas.junk@intevation.de>
+ * Markus Kottländer <markus@intevation.de>
+ */
+import { mapState, mapGetters } from "vuex";
+import { displayError, displayInfo } from "@/lib/errors.js";
+
+export default {
+  name: "stagingsection",
+  computed: {
+    ...mapState("imports", ["staging"]),
+    ...mapGetters("imports", ["processedReviews"]),
+    filteredData() {
+      return this.staging;
+    }
+  },
+  methods: {
+    loadImportQueue() {
+      this.$store.dispatch("imports/getStaging").catch(error => {
+        const { status, data } = error.response;
+        displayError({
+          title: "Backend Error",
+          message: `${status}: ${data.message || data}`
+        });
+      });
+    },
+    save() {
+      if (!this.processedReviews.length) return;
+      this.$store
+        .dispatch("imports/confirmReview", this.processedReviews)
+        .then(response => {
+          this.loadImportQueue();
+          const messages = response.data
+            .map(x => {
+              if (x.message) return x.message;
+              if (x.error) return x.error;
+            })
+            .join("\n\n");
+          displayInfo({
+            title: "Staging Area",
+            message: messages,
+            options: {
+              timeout: 0,
+              buttons: [{ text: "Ok", action: null, bold: true }]
+            }
+          });
+        })
+        .catch(error => {
+          const { status, data } = error.response;
+          displayError({
+            title: "Backend Error",
+            message: `${status}: ${data.message || data}`
+          });
+        });
+    }
+  },
+  components: {
+    StagingDetail: () => import("./StagingDetail.vue")
+  }
+};
+</script>
+
+<style></style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/importoverview/staging/StagingDetail.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -0,0 +1,518 @@
+<template>
+  <div :class="detail">
+    <div class="d-flex flex-row">
+      <div class="mt-auto d-flex flex-row mb-auto small name text-left">
+        <a
+          v-if="isSoundingResult(data.kind.toUpperCase())"
+          class="text-left"
+          @click="zoomTo()"
+          href="#"
+          >{{ data.summary.bottleneck }}</a
+        >
+        <span v-if="isBottleneck(data.kind.toUpperCase())" class="text-left"
+          ><translate>Bottlenecks</translate> ({{
+            data.summary.bottlenecks.length
+          }})</span
+        >
+        <a
+          v-if="isApprovedGaugeMeasurement(data.kind.toUpperCase())"
+          class="text-left"
+          ><translate>Approved Gauge Measurements</translate> ({{
+            data.summary.length
+          }})</a
+        >
+        <span
+          class="text-left"
+          v-if="isFairwayDimension(data.kind.toUpperCase())"
+          >{{ data.summary["source-organization"] }} (LOS:
+          {{ data.summary.los }})</span
+        >
+        <a
+          href="#"
+          class="text-left"
+          @click="zoomToStretch(data.summary.stretch)"
+          v-if="isStretch(data.kind.toUpperCase())"
+          >{{ data.summary.stretch }}</a
+        >
+      </div>
+      <div class="mt-auto mb-auto small text-left type">
+        {{ data.kind.toUpperCase() }}
+      </div>
+      <div v-if="data.summary" class="mt-auto mb-auto small text-left date">
+        {{ formatSurveyDate(data.summary.date) }}
+      </div>
+      <div v-else class="mt-auto mb-auto small text-left date">-</div>
+      <div class="mt-auto mb-auto small text-left imported">
+        {{ formatSurveyDate(data.enqueued.split("T")[0]) }}
+      </div>
+      <div class="mt-auto mb-auto small text-left username">
+        {{ data.user }}
+      </div>
+      <div class="controls d-flex flex-row justify-content-end">
+        <div>
+          <button
+            :class="{
+              'ml-3': true,
+              'mr-3': true,
+              btn: true,
+              'btn-sm': true,
+              'btn-outline-success': needsApproval(data) || isRejected(data),
+              'btn-success': isApproved(data)
+            }"
+            @click="toggleApproval(data.id, $options.STATES.APPROVED)"
+          >
+            <font-awesome-icon icon="check"></font-awesome-icon>
+          </button>
+        </div>
+        <div>
+          <button
+            :class="{
+              'mr-3': true,
+              btn: true,
+              'btn-sm': true,
+              'btn-outline-danger': needsApproval(data) || isApproved(data),
+              'btn-danger': isRejected(data)
+            }"
+            @click="toggleApproval(data.id, $options.STATES.REJECTED)"
+          >
+            <font-awesome-icon icon="times" class="pointer"></font-awesome-icon>
+          </button>
+        </div>
+        <div
+          v-if="
+            !isBottleneck(data.kind.toUpperCase()) ||
+              isApprovedGaugeMeasurement(data.kind.toUpperCase())
+          "
+          class="expander"
+        ></div>
+        <div v-if="isBottleneck(data.kind.toUpperCase())">
+          <div class="mt-auto mb-auto text-info text-left">
+            <font-awesome-icon
+              class="pointer"
+              @click="showDetails()"
+              v-if="show"
+              icon="angle-up"
+              fixed-width
+            ></font-awesome-icon>
+            <font-awesome-icon
+              class="pointer"
+              @click="showDetails()"
+              v-if="loading"
+              icon="spinner"
+              fixed-width
+            ></font-awesome-icon>
+            <font-awesome-icon
+              @click="showDetails()"
+              class="pointer"
+              v-if="!show && !loading"
+              icon="angle-down"
+              fixed-width
+            ></font-awesome-icon>
+          </div>
+        </div>
+        <div v-if="isApprovedGaugeMeasurement(data.kind.toUpperCase())">
+          <div
+            @click="showAGMDetails = !showAGMDetails"
+            class="mt-auto mb-auto text-info text-left"
+          >
+            <font-awesome-icon
+              class="pointer"
+              v-if="showAGMDetails"
+              icon="angle-up"
+              fixed-width
+            ></font-awesome-icon>
+            <font-awesome-icon
+              class="pointer"
+              v-if="!showAGMDetails"
+              icon="angle-down"
+              fixed-width
+            ></font-awesome-icon>
+          </div>
+        </div>
+        <div v-else class="empty"></div>
+      </div>
+    </div>
+    <div v-if="show && bottlenecks.length > 0" class="bottlenecksdetails">
+      <div
+        v-for="(bottleneck, index) in bottlenecks"
+        :key="index"
+        class="d-flex flex-row"
+      >
+        <div class="d-flex flex-column">
+          <div class="d-flex flex-row">
+            <a @click="moveToBottleneck(index)" class="small" href="#">{{
+              bottleneck.properties.objnam
+            }}</a>
+            <div
+              @click="showBottleneckDetails(index)"
+              class="small mt-auto mb-auto text-info text-left"
+            >
+              <font-awesome-icon
+                class="pointer"
+                v-if="showBottleneckDetail === index"
+                icon="angle-up"
+                fixed-width
+              ></font-awesome-icon>
+              <font-awesome-icon
+                class="pointer"
+                v-if="!(showBottleneckDetail === index)"
+                icon="angle-down"
+                fixed-width
+              ></font-awesome-icon>
+            </div>
+          </div>
+
+          <div class="d-flex flex-row" v-if="showBottleneckDetail === index">
+            <table>
+              <tr
+                v-for="(info, index) in Object.keys(bottleneck.properties)"
+                :key="index"
+                class="mr-1 small text-muted"
+              >
+                <td class="condensed text-left">{{ info }}</td>
+                <td class="condensed pl-3 text-left">
+                  {{ bottleneck.properties[info] }}
+                </td>
+              </tr>
+            </table>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div v-if="showAGMDetails">
+      <div class="pl-3 d-flex flex-row">
+        <span class="condensed agmcode text-left"
+          ><small><translate>ISRS Code</translate></small></span
+        >
+        <span class="condensed agmdetail text-left"
+          ><small><translate>Date of measurement</translate></small></span
+        >
+      </div>
+      <div class="diffs">
+        <div v-for="(result, index) in data.summary" :key="index">
+          <div class="pl-3 d-flex flex-row">
+            <span
+              v-if="result.versions.length == 1"
+              class="condensed agmcode text-left"
+              ><small
+                >{{ result["fk-gauge-id"] }}
+                <translate>( New )</translate></small
+              ></span
+            >
+            <span
+              v-if="result.versions.length == 2"
+              class="condensed agmcode text-left"
+              ><small>{{ result["fk-gauge-id"] }}</small></span
+            >
+            <span class="condensed agmdetail text-left"
+              ><small>{{ formatDateTime(result["measure-date"]) }}</small></span
+            >
+            <div
+              @click="toggleDiff(index)"
+              class="small ml-auto mt-auto mb-auto text-info text-left"
+            >
+              <font-awesome-icon
+                class="pointer"
+                v-if="showDiff == index"
+                icon="angle-up"
+                fixed-width
+              ></font-awesome-icon>
+              <font-awesome-icon
+                class="pointer"
+                v-if="showDiff != index"
+                icon="angle-down"
+                fixed-width
+              ></font-awesome-icon>
+            </div>
+          </div>
+          <div v-if="showDiff == index" class="pl-3 d-flex flex-row">
+            <div>
+              <div class="d-flex flex-row condensed pl-3 text-left">
+                <div class="header border-bottom agmdetailskeys">
+                  <small><translate>Value</translate></small>
+                </div>
+                <div
+                  v-if="result.versions.length == 2"
+                  class="header border-bottom agmdetailsvalues"
+                >
+                  <small><translate>Old</translate></small>
+                </div>
+                <div class="header border-bottom agmdetailsvalues">
+                  <small><translate>New</translate></small>
+                </div>
+              </div>
+              <div
+                class="d-flex flex-row condensed pl-3 text-left"
+                v-for="(entry, index) in Object.keys(result.versions[0])"
+                :key="index"
+              >
+                <div
+                  v-if="
+                    result.versions.length == 1 ||
+                      result.versions[0][entry] != result.versions[1][entry]
+                  "
+                  class="agmdetailskeys"
+                >
+                  <small>{{ entry }}</small>
+                </div>
+                <div
+                  v-if="
+                    result.versions.length == 1 ||
+                      result.versions[0][entry] != result.versions[1][entry]
+                  "
+                  class="agmdetailsvalues"
+                >
+                  <small>{{ result.versions[0][entry] }}</small>
+                </div>
+                <div
+                  v-if="
+                    result.versions.length == 2 &&
+                      result.versions[0][entry] != result.versions[1][entry]
+                  "
+                  class="agmdetailsvalues"
+                >
+                  <small>{{ result.versions[1][entry] }}</small>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Thomas Junk <thomas.junk@intevation.de>
+ */
+
+import { formatSurveyDate, formatDateTime } from "@/lib/date.js";
+import { STATES } from "@/store/imports.js";
+import { HTTP } from "@/lib/http";
+import { WFS } from "ol/format.js";
+import { or as orFilter, equalTo as equalToFilter } from "ol/format/filter.js";
+import { displayError } from "@/lib/errors.js";
+import { mapState } from "vuex";
+import { LAYERS } from "@/store/map.js";
+
+const NO_DIFF = -1;
+const NO_BOTTLENECK = -1;
+
+export default {
+  name: "stagingdetail",
+  props: ["data"],
+  data() {
+    return {
+      showDiff: NO_DIFF,
+      showAGMDetails: false,
+      showBottleneckDetail: NO_BOTTLENECK,
+      show: false,
+      loading: false,
+      bottlenecks: []
+    };
+  },
+  mounted() {
+    this.bottlenecks = [];
+    const { id } = this.$route.params;
+    this.$store.commit("imports/setImportToReview", id);
+    if (this.open) this.showDetails();
+  },
+  computed: {
+    ...mapState("imports", ["importToReview"]),
+    open() {
+      return this.importToReview == this.data.id;
+    },
+    detail() {
+      return [
+        "pb-2",
+        "pt-2",
+        "d-flex",
+        "flex-column",
+        "w-100",
+        {
+          highlight: this.open && this.needsApproval(this.data)
+        }
+      ];
+    }
+  },
+  watch: {
+    showAGMDetails() {
+      if (!this.showAGMDetails) this.showDiff = NO_DIFF;
+    },
+    open() {
+      this.show = this.open;
+    },
+    $route() {
+      const { id } = this.$route.params;
+      this.$store.commit("imports/setImportToReview", id);
+      if (this.open) this.showDetails();
+    }
+  },
+  methods: {
+    showBottleneckDetails(index) {
+      if (index == this.showBottleneckDetail) {
+        this.showBottleneckDetail = NO_BOTTLENECK;
+        return;
+      }
+      this.showBottleneckDetail = index;
+    },
+    toggleDiff(number) {
+      if (this.showDiff !== number || this.showDiff == -1) {
+        this.showDiff = number;
+      } else {
+        this.showDiff = -1;
+      }
+    },
+    zoomToStretch(name) {
+      this.$store.commit("map/setLayerVisible", LAYERS.STRETCHES);
+      this.$store
+        .dispatch("imports/loadStretch", name)
+        .then(response => {
+          if (response.data.features.length < 1)
+            throw new Error("no feaures found for: " + name);
+          this.moveToExtent(response.data.features[0]);
+        })
+        .catch(error => {
+          console.log(error);
+          const { status, data } = error.response;
+          displayError({
+            title: this.$gettext("Backend Error"),
+            message: `${status}: ${data.message || data}`
+          });
+        });
+    },
+    showDetails() {
+      if (!this.isBottleneck(this.data.kind.toUpperCase())) return;
+      if (this.show) {
+        this.show = false;
+        return;
+      }
+      if (this.bottlenecks.length > 0) {
+        this.show = true;
+        return;
+      }
+      this.loading = true;
+      const generateFilter = () => {
+        const { bottlenecks } = this.data.summary;
+        if (bottlenecks.length === 1)
+          return equalToFilter("bottleneck_id", bottlenecks[0]);
+        const orExpressions = bottlenecks.map(x => {
+          return equalToFilter("bottleneck_id", x);
+        });
+        return orFilter(...orExpressions);
+      };
+      const filterExpression = generateFilter();
+      const bottleneckFeatureCollectionRequest = new WFS().writeGetFeature({
+        srsName: "EPSG:4326",
+        featureNS: "gemma",
+        featurePrefix: "gemma",
+        featureTypes: ["bottlenecks_geoserver"],
+        outputFormat: "application/json",
+        filter: filterExpression
+      });
+      HTTP.post(
+        "/internal/wfs",
+        new XMLSerializer().serializeToString(
+          bottleneckFeatureCollectionRequest
+        ),
+        {
+          headers: {
+            "X-Gemma-Auth": localStorage.getItem("token"),
+            "Content-type": "text/xml; charset=UTF-8"
+          }
+        }
+      )
+        .then(response => {
+          this.bottlenecks = response.data.features;
+          this.show = true;
+          this.loading = false;
+        })
+        .catch(error => {
+          const { status, data } = error.response;
+          displayError({
+            title: this.$gettext("Backend Error"),
+            message: `${status}: ${data.message || data}`
+          });
+        });
+    },
+    isFairwayDimension(kind) {
+      return kind === "FD";
+    },
+    isApprovedGaugeMeasurement(kind) {
+      return kind === "AGM";
+    },
+    isBottleneck(kind) {
+      return kind === "BN" || kind === "UBN";
+    },
+    isStretch(kind) {
+      return kind === "ST";
+    },
+    isSoundingResult(kind) {
+      return kind === "SR";
+    },
+    formatSurveyDate(date) {
+      return formatSurveyDate(date);
+    },
+    formatDateTime(date) {
+      return formatDateTime(date);
+    },
+    needsApproval(item) {
+      return item.status === STATES.NEEDSAPPROVAL;
+    },
+    isRejected(item) {
+      return item.status === STATES.REJECTED;
+    },
+    isApproved(item) {
+      return item.status === STATES.APPROVED;
+    },
+    moveToBottleneck(index) {
+      this.$store.commit("map/setLayerVisible", LAYERS.BOTTLENECKS);
+      this.moveToExtent(this.bottlenecks[index]);
+    },
+    moveToExtent(feature) {
+      this.$store.commit("map/moveToExtent", {
+        feature: feature,
+        zoom: 17,
+        preventZoomOut: true
+      });
+    },
+    moveMap(coordinates) {
+      this.$store.commit("map/moveMap", {
+        coordinates: coordinates,
+        zoom: 17,
+        preventZoomOut: true
+      });
+    },
+    zoomTo() {
+      const { lat, lon, bottleneck, date } = this.data.summary;
+      const coordinates = [lat, lon];
+      this.moveMap(coordinates);
+      this.$store
+        .dispatch("bottlenecks/setSelectedBottleneck", bottleneck)
+        .then(() => {
+          this.$store.commit("bottlenecks/setSelectedSurveyByDate", date);
+        });
+    },
+    toggleApproval(id, newStatus) {
+      this.$store.commit("imports/toggleApproval", {
+        id: id,
+        newStatus: newStatus
+      });
+    }
+  },
+  STATES: STATES
+};
+</script>
+
+<style lang="scss" scoped></style>
--- a/client/src/components/importqueue/Importqueue.vue	Thu Feb 28 17:28:54 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,351 +0,0 @@
-<template>
-  <div class="d-flex flex-row">
-    <Spacer></Spacer>
-    <div class="mt-3 importqueuecard flex-grow-1">
-      <div class="card shadow-xs">
-        <UIBoxHeader icon="tasks" title="Importqueue" />
-        <div class="card-body importcardbody">
-          <div class="card-body importcardbody">
-            <div class="searchandfilter 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="filters">
-                <button @click="setFilter('failed')" :class="failedStyle">
-                  <translate>Failed</translate>
-                </button>
-                <button @click="setFilter('pending')" :class="pendingStyle">
-                  <translate>Pending</translate>
-                </button>
-                <button @click="setFilter('rejected')" :class="rejectedStyle">
-                  <translate>Rejected</translate>
-                </button>
-                <button @click="setFilter('accepted')" :class="acceptedStyle">
-                  <translate>Accepted</translate>
-                </button>
-                <button @click="setFilter('warning')" :class="warningStyle">
-                  <translate>Warning</translate>
-                </button>
-              </div>
-            </div>
-            <div class="text-left d-flex flex-row border-bottom entries">
-              <div class="header py-1 jobid mr-2">
-                <translate>Id</translate>
-              </div>
-              <div class="header py-1 enqueued mr-2">
-                <translate>Enqueued</translate>
-              </div>
-              <div class="header py-1 kind mr-2">
-                <translate>Kind</translate>
-              </div>
-              <div class="header py-1 user mr-2">
-                <translate>User</translate>
-              </div>
-              <div class="header py-1 signer mr-2">
-                <translate>Signer</translate>
-              </div>
-              <div class="header py-1 state mr-2">
-                <translate>State</translate>
-              </div>
-            </div>
-            <div class="importqueuedetail">
-              <div
-                class="text-left"
-                v-for="job in filteredImports"
-                :key="job.id"
-              >
-                <Importqueuedetail
-                  :reload="reload"
-                  :job="job"
-                ></Importqueuedetail>
-              </div>
-            </div>
-            <div>
-              <button @click="refresh" class="btn btn-info refresh">
-                <translate>Refresh</translate>
-              </button>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script>
-/* This is Free Software under GNU Affero General Public License v >= 3.0
- * without warranty, see README.md and license for details.
- *
- * SPDX-License-Identifier: AGPL-3.0-or-later
- * License-Filename: LICENSES/AGPL-3.0.txt
- *
- * Copyright (C) 2018 by via donau
- *   – Österreichische Wasserstraßen-Gesellschaft mbH
- * Software engineering by Intevation GmbH
- *
- * Author(s):
- * Markus Kottländer <markus@intevation.de>
- */
-import { displayError } from "@/lib/errors.js";
-import { mapState } from "vuex";
-import { HTTP } from "@/lib/http.js";
-
-export default {
-  name: "importqueue",
-  components: {
-    Importqueuedetail: () => import("./Importqueuedetail"),
-    Spacer: () => import("@/components/Spacer")
-  },
-  data() {
-    return {
-      reload: false,
-      searchQuery: "",
-      successful: false,
-      failed: false,
-      pending: false,
-      rejected: false,
-      accepted: false,
-      warning: false
-    };
-  },
-  mounted() {
-    this.loadQueue();
-  },
-  methods: {
-    setFilter(name) {
-      this[name] = !this[name];
-      const allSet =
-        this.failed &&
-        this.pending &&
-        this.accepted &&
-        this.rejected &&
-        this.warning;
-      if (allSet) {
-        this.warning = false;
-        this.successful = false;
-        this.failed = false;
-        this.pending = false;
-        this.accepted = false;
-        this.rejected = false;
-      }
-    },
-    loadQueue() {
-      this.reload = true;
-      this.$store
-        .dispatch("imports/getImports")
-        .then(() => {
-          this.reload = false;
-        })
-        .catch(error => {
-          const { status, data } = error.response;
-          displayError({
-            title: this.$gettext("Backend Error"),
-            message: `${status}: ${data.message || data}`
-          });
-        });
-    },
-    refresh() {
-      this.loadQueue();
-    },
-    showDetails(id) {
-      HTTP.get("/imports/" + id, {
-        headers: { "X-Gemma-Auth": localStorage.getItem("token") }
-      })
-        .then(response => {
-          const { entries } = response.data;
-          this.entries = entries;
-          this.$modal.show("details");
-        })
-        .catch(error => {
-          const { status, data } = error.response;
-          displayError({
-            title: this.$gettext("Backend Error"),
-            message: `${status}: ${data.message || data}`
-          });
-        });
-    },
-    close() {
-      this.$modal.hide("details");
-    }
-  },
-  computed: {
-    ...mapState("imports", ["imports"]),
-    ...mapState("application", ["showSidebar"]),
-    sortIcon() {
-      return this.sortAsc ? "sort-amount-down" : "sort-amount-up";
-    },
-    filteredImports() {
-      const filtered = this.imports
-        .filter(element => {
-          if (!this.searchQuery) return true;
-          return [(element.kind, element.user, element.enqueued)].some(x => {
-            return x.toLowerCase().includes(this.searchQuery.toLowerCase());
-          });
-        })
-        .filter(y => {
-          if (
-            !this.failed &&
-            !this.pending &&
-            !this.accepted &&
-            !this.rejected &&
-            !this.warning
-          )
-            return true;
-          let filterCriteria = [];
-          if (this.failed) filterCriteria.push("failed");
-          if (this.pending) filterCriteria.push("pending");
-          if (this.accepted) filterCriteria.push("accepted");
-          if (this.rejected) filterCriteria.push("declined");
-          const result = filterCriteria.map(selectedState => {
-            return y.state === selectedState;
-          });
-          if (this.warning) return result.some(x => x) || y.warnings;
-          return result.some(x => x);
-        });
-      return filtered;
-    },
-    pendingStyle() {
-      return {
-        btn: true,
-        "btn-light": !this.pending,
-        "btn-dark": this.pending
-      };
-    },
-    failedStyle() {
-      return {
-        btn: true,
-        "btn-light": !this.failed,
-        "btn-dark": this.failed
-      };
-    },
-    rejectedStyle() {
-      return {
-        btn: true,
-        "btn-light": !this.rejected,
-        "btn-dark": this.rejected
-      };
-    },
-    acceptedStyle() {
-      return {
-        btn: true,
-        "btn-light": !this.accepted,
-        "btn-dark": this.accepted
-      };
-    },
-    warningStyle() {
-      return {
-        btn: true,
-        "btn-light": !this.warning,
-        "btn-dark": this.warning
-      };
-    }
-  }
-};
-</script>
-
-<style lang="scss" scoped>
-.importqueuedetail {
-  margin-bottom: 3rem;
-}
-.entries {
-  width: 100%;
-}
-
-.jobid {
-  width: 15%;
-}
-
-.enqueued {
-  width: 15%;
-}
-
-.user {
-  width: 15%;
-}
-
-.signer {
-  width: 15%;
-}
-
-.kind {
-  width: 10%;
-}
-
-.state {
-  width: 15%;
-}
-
-.details thead {
-  display: block;
-}
-.details tbody {
-  display: block;
-}
-
-.details tbody {
-  height: 260px;
-  overflow-y: auto;
-  overflow-x: hidden;
-}
-
-.closebutton {
-  top: $small-offset;
-}
-
-.refresh {
-  position: absolute;
-  right: $offset;
-  bottom: $offset;
-}
-
-.importqueuecard {
-  width: 97%;
-  margin-right: $offset;
-  min-height: 20rem;
-}
-
-.card-body {
-  width: 100%;
-  margin-left: auto;
-  margin-right: auto;
-}
-
-.searchandfilter {
-  position: relative;
-  margin-bottom: $xx-large-offset;
-}
-
-.filters {
-  position: absolute;
-  right: 0;
-}
-
-.filters button {
-  margin-right: $small-offset;
-}
-
-.table td,
-.table th {
-  border-top: 0 !important;
-  text-align: left;
-  padding: $small-offset !important;
-}
-
-.searchgroup {
-  position: absolute;
-  left: 0;
-  width: 45%;
-}
-</style>
--- a/client/src/components/importqueue/Importqueuedetail.vue	Thu Feb 28 17:28:54 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,359 +0,0 @@
-<template>
-  <div class="entry d-flex flex-column py-1 border-bottom">
-    <div class="d-flex flex-row position-relative">
-      <div @click="showDetails(job.id)" class="jobid ml-2 mt-1 mr-2">
-        {{ job.id }}
-      </div>
-      <div @click="showDetails(job.id)" class="enqueued mt-1  mr-2">
-        {{ formatDateTime(job.enqueued) }}
-      </div>
-      <div @click="showDetails(job.id)" class="kind mt-1 mr-2">
-        {{ job.kind.toUpperCase() }}
-      </div>
-      <div @click="showDetails(job.id)" class="user mt-1 mr-2">
-        {{ job.user }}
-      </div>
-      <div @click="showDetails(job.id)" class="signer mt-1 mr-2">
-        {{ job.signer }}
-      </div>
-      <div @click="showDetails(job.id)" class="state mt-1 mr-2">
-        <span :class="{ 'text-danger': job.state.toUpperCase() == 'FAILED' }"
-          >{{ job.state
-          }}<font-awesome-icon
-            v-if="job.warnings"
-            class="ml-1 text-warning"
-            icon="exclamation-triangle"
-            fixed-width
-          ></font-awesome-icon
-        ></span>
-      </div>
-      <div @click="showDetails(job.id)" class="mt-1 text-info detailsbutton">
-        <font-awesome-icon
-          class="pointer"
-          v-if="show"
-          icon="angle-up"
-          fixed-width
-        ></font-awesome-icon>
-        <font-awesome-icon
-          class="pointer"
-          v-if="loading"
-          icon="spinner"
-          fixed-width
-        ></font-awesome-icon>
-        <font-awesome-icon
-          class="pointer"
-          v-if="!show && !loading"
-          icon="angle-down"
-          fixed-width
-        ></font-awesome-icon>
-      </div>
-    </div>
-    <div class="detailstable d-flex flex-row">
-      <div :class="collapse">
-        <table class="table table-responsive">
-          <thead>
-            <tr>
-              <th class="type pb-0">
-                <small class="condensed"><translate>Kind</translate></small>
-              </th>
-              <th class="datetime  pb-0">
-                <a href="#" @click="sortAsc = !sortAsc" class="sort-link"
-                  ><small class="condensed"><translate>Date</translate></small>
-                  <small class="condensed"
-                    ><font-awesome-icon
-                      :icon="sortIcon"
-                      class="ml-1"
-                    ></font-awesome-icon></small
-                ></a>
-              </th>
-              <th class="message pb-0">
-                <small class="condensed"><translate>Message</translate></small>
-              </th>
-            </tr>
-          </thead>
-          <tbody>
-            <tr
-              v-for="(entry, index) in sortedEntries"
-              :key="index"
-              class="detailsrow"
-            >
-              <td class="type">
-                <span
-                  :class="[
-                    'condensed',
-                    {
-                      'text-danger': entry.kind.toUpperCase() == 'ERROR',
-                      'text-warning': entry.kind.toUpperCase() == 'WARN'
-                    }
-                  ]"
-                  >{{ entry.kind.toUpperCase() }}</span
-                >
-              </td>
-              <td class="datetime">
-                <span
-                  :class="[
-                    'condensed',
-                    {
-                      'text-danger': entry.kind.toUpperCase() == 'ERROR',
-                      'text-warning': entry.kind.toUpperCase() == 'WARN'
-                    }
-                  ]"
-                  >{{ formatDateTime(entry.time) }}</span
-                >
-              </td>
-              <td class="message">
-                <span
-                  :class="[
-                    'condensed',
-                    {
-                      'text-danger': entry.kind.toUpperCase() == 'ERROR',
-                      'text-warning': entry.kind.toUpperCase() == 'WARN'
-                    }
-                  ]"
-                  >{{ entry.message }}</span
-                >
-              </td>
-            </tr>
-          </tbody>
-        </table>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script>
-/* This is Free Software under GNU Affero General Public License v >= 3.0
- * without warranty, see README.md and license for details.
- *
- * SPDX-License-Identifier: AGPL-3.0-or-later
- * License-Filename: LICENSES/AGPL-3.0.txt
- *
- * Copyright (C) 2018 by via donau
- *   – Österreichische Wasserstraßen-Gesellschaft mbH
- * Software engineering by Intevation GmbH
- *
- * Author(s):
- * Thomas Junk <thomas.junk@intevation.de>
- */
-
-import { HTTP } from "@/lib/http.js";
-import { displayError } from "@/lib/errors.js";
-import locale2 from "locale2";
-
-export default {
-  name: "importqueuedetail",
-  props: ["job", "reload"],
-  data() {
-    return {
-      loading: false,
-      show: false,
-      entries: [],
-      sortAsc: true
-    };
-  },
-  mounted() {
-    this.openSpecificDetail();
-  },
-  watch: {
-    $route() {
-      this.openSpecificDetail();
-    },
-    reload() {
-      if (this.reload) {
-        this.entries = [];
-        this.show = false;
-      }
-    }
-  },
-  methods: {
-    openSpecificDetail() {
-      const { id } = this.$route.params;
-      if (id == this.job.id) {
-        this.showDetails(id);
-      } else {
-        this.show = false;
-      }
-    },
-    formatDate(date) {
-      return date
-        ? new Date(date).toLocaleDateString(locale2, {
-            day: "2-digit",
-            month: "2-digit",
-            year: "numeric"
-          })
-        : "";
-    },
-    formatDateTime(date) {
-      if (!date) return "";
-      const d = new Date(date);
-      return (
-        d.toLocaleDateString(locale2, {
-          day: "2-digit",
-          month: "2-digit",
-          year: "numeric"
-        }) +
-        " - " +
-        d.toLocaleTimeString(locale2, {
-          hour12: false
-        })
-      );
-    },
-    showDetails(id) {
-      if (this.show) {
-        this.show = false;
-        return;
-      }
-      if (this.entries.length === 0) {
-        this.loading = true;
-        HTTP.get("/imports/" + id, {
-          headers: { "X-Gemma-Auth": localStorage.getItem("token") }
-        })
-          .then(response => {
-            const { entries } = response.data;
-            this.entries = entries;
-            this.show = true;
-            this.loading = false;
-          })
-          .catch(error => {
-            const { status, data } = error.response;
-            displayError({
-              title: this.$gettext("Backend Error"),
-              message: `${status}: ${data.message || data}`
-            });
-          });
-      } else {
-        this.show = true;
-      }
-    }
-  },
-  computed: {
-    sortedEntries() {
-      let sorted = this.entries.slice();
-      sorted.sort((r1, r2) => {
-        let d1 = new Date(r1.time);
-        let d2 = new Date(r2.time);
-        if (d2 < d1) {
-          return !this.sortAsc ? -1 : 1;
-        }
-        if (d2 > d1) {
-          return !this.sortAsc ? 1 : -1;
-        }
-        return 0;
-      });
-      return sorted;
-    },
-    sortIcon() {
-      return this.sortAsc ? "sort-amount-down" : "sort-amount-up";
-    },
-    icon() {
-      return {
-        "angle-up": !this.show,
-        "angle-down": this.show
-      };
-    },
-    collapse() {
-      return {
-        details: true,
-        collapse: true,
-        show: this.show,
-        "w-100": true
-      };
-    }
-  }
-};
-</script>
-
-<style lang="scss" scoped>
-.condensed {
-  font-stretch: condensed;
-}
-
-.entry {
-  background-color: white;
-  cursor: pointer;
-  width: 100%;
-}
-
-.entry:hover {
-  background-color: #efefef;
-  transition: 1.6s;
-}
-
-.detailstable {
-  margin-left: $offset;
-  margin-right: $large-offset;
-}
-
-.detailsbutton {
-  position: absolute;
-  top: 0;
-  right: 0;
-  height: 100%;
-}
-.jobid {
-  width: 15%;
-}
-
-.enqueued {
-  width: 15%;
-}
-
-.user {
-  width: 15%;
-}
-
-.signer {
-  width: 15%;
-}
-
-.kind {
-  width: 10%;
-}
-
-.state {
-  width: 15%;
-}
-
-.details {
-  width: 50%;
-}
-
-.detailsrow {
-  line-height: 0.1em;
-}
-
-.type {
-  width: 65px;
-  white-space: nowrap;
-  padding-left: 0px;
-  border-top: 0px;
-  padding-bottom: $small-offset;
-}
-
-.datetime {
-  width: 200px;
-  white-space: nowrap;
-  padding-left: 0px;
-  border-top: 0px;
-  padding-bottom: $small-offset;
-}
-
-.message {
-  min-width: 700px;
-  white-space: nowrap;
-  padding-left: 0px;
-  border-top: 0px;
-  padding-bottom: $small-offset;
-}
-
-thead,
-tbody {
-  display: block;
-}
-
-tbody {
-  height: 150px;
-  overflow-y: auto;
-  overflow-x: auto;
-}
-</style>
--- a/client/src/components/staging/Staging.vue	Thu Feb 28 17:28:54 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,193 +0,0 @@
-<template>
-  <div class="w-90 stagingcard">
-    <UIBoxHeader
-      icon="clipboard-check"
-      title="Staging Area"
-      :closeCallback="$parent.close"
-    />
-    <div class="mt-3 pl-3 pr-3">
-      <div class="mt-3 text-left flex-row d-flex border-bottom">
-        <div class="header text-left name"><translate>Name</translate></div>
-        <div class="header text-left type"><translate>Type</translate></div>
-        <div class="header text-left date"><translate>Date</translate></div>
-        <div class="header text-left imported">
-          <translate>Imported</translate>
-        </div>
-        <div class="header text-left username">
-          <translate>Username</translate>
-        </div>
-        <div class="ml-3 controls"></div>
-      </div>
-      <div class="mt-3 stagingdetails details" v-if="filteredData.length > 0">
-        <StagingDetail
-          class="mb-3 border-bottom"
-          :key="data.id"
-          v-for="data in filteredData"
-          :data="data"
-        ></StagingDetail>
-      </div>
-    </div>
-    <div class="mt-3 p-3" v-if="filteredData.length > 0">
-      <button @click="confirmReview" class="confirm-button btn btn-info">
-        <translate>Confirm</translate>
-      </button>
-    </div>
-    <div v-else class="mr-auto ml-auto"><translate>No results.</translate></div>
-    <div class="mt-1 p-3">
-      <button @click="loadData" class="refresh btn btn-dark">Refresh</button>
-    </div>
-  </div>
-</template>
-
-<script>
-/* This is Free Software under GNU Affero General Public License v >= 3.0
- * without warranty, see README.md and license for details.
- *
- * SPDX-License-Identifier: AGPL-3.0-or-later
- * License-Filename: LICENSES/AGPL-3.0.txt
- *
- * Copyright (C) 2018 by via donau
- *   – Österreichische Wasserstraßen-Gesellschaft mbH
- * Software engineering by Intevation GmbH
- *
- * Author(s):
- * Thomas Junk <thomas.junk@intevation.de>
- * Markus Kottländer <markus@intevation.de>
- */
-import { mapState } from "vuex";
-import { HTTP } from "@/lib/http.js";
-import { displayError, displayInfo } from "@/lib/errors.js";
-import { STATES } from "@/store/imports.js";
-
-export default {
-  data() {
-    return {};
-  },
-  components: {
-    StagingDetail: () => import("./StagingDetail")
-  },
-  mounted() {
-    this.loadData();
-  },
-  computed: {
-    ...mapState("application", ["searchQuery"]),
-    ...mapState("imports", ["staging"]),
-    filteredData() {
-      return this.staging.filter(data => {
-        const result = [data.id + "", data.enqueued, data.kind, data.user].some(
-          x => x.toLowerCase().includes(this.searchQuery.toLowerCase())
-        );
-        return result;
-      });
-    }
-  },
-  methods: {
-    loadData() {
-      this.$store.dispatch("imports/getStaging").catch(error => {
-        const { status, data } = error.response;
-        displayError({
-          title: "Backend Error",
-          message: `${status}: ${data.message || data}`
-        });
-      });
-    },
-    confirmReview() {
-      const reviewResults = this.staging
-        .filter(x => x.status !== STATES.NEEDSAPPROVAL)
-        .map(r => {
-          return {
-            id: r.id,
-            state: r.status
-          };
-        });
-      if (!reviewResults.length) return;
-      HTTP.patch("/imports", reviewResults, {
-        headers: {
-          "X-Gemma-Auth": localStorage.getItem("token"),
-          "Content-type": "application/json"
-        }
-      })
-        .then(response => {
-          const messages = response.data
-            .map(x => {
-              if (x.message) return x.message;
-              if (x.error) return x.error;
-            })
-            .join("\n\n");
-          displayInfo({
-            title: "Staging Area",
-            message: messages,
-            options: {
-              timeout: 0,
-              buttons: [{ text: "Ok", action: null, bold: true }]
-            }
-          });
-          this.loadData();
-        })
-        .catch(error => {
-          const { status, data } = error.response;
-          displayError({
-            title: "Backend Error",
-            message: `${status}: ${data.message || data}`
-          });
-        });
-    }
-  },
-  STATES: STATES
-};
-</script>
-
-<style lang="scss" scoped>
-.stagingdetails {
-  overflow-y: auto;
-  max-height: 250px;
-}
-.name {
-  width: 180px;
-}
-
-.date {
-  width: 90px;
-}
-
-.type {
-  width: 40px;
-}
-
-.imported {
-  width: 90px;
-}
-
-.username {
-  width: 150px;
-}
-
-.controls {
-  width: 60px;
-}
-
-.refresh {
-  position: absolute;
-  left: $offset;
-  bottom: $offset;
-}
-.table th,
-td {
-  font-size: 0.9rem;
-  border-top: 0px !important;
-  border-bottom-width: 1px;
-  text-align: left;
-  padding: 0.5rem !important;
-}
-
-.stagingcard {
-  position: relative;
-  min-height: 150px;
-}
-
-.confirm-button {
-  position: absolute;
-  right: $offset;
-  bottom: $offset;
-}
-</style>
--- a/client/src/components/staging/StagingDetail.vue	Thu Feb 28 17:28:54 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,583 +0,0 @@
-<template>
-  <div :class="detail">
-    <div class="d-flex flex-row">
-      <div class="mt-auto d-flex flex-row mb-auto small name text-left">
-        <a
-          v-if="isSoundingResult(data.kind.toUpperCase())"
-          class="text-left"
-          @click="zoomTo()"
-          href="#"
-          >{{ data.summary.bottleneck }}</a
-        >
-        <span v-if="isBottleneck(data.kind.toUpperCase())" class="text-left"
-          ><translate>Bottlenecks</translate> ({{
-            data.summary.bottlenecks.length
-          }})</span
-        >
-        <a
-          v-if="isApprovedGaugeMeasurement(data.kind.toUpperCase())"
-          class="text-left"
-          ><translate>Approved Gauge Measurements</translate> ({{
-            data.summary.length
-          }})</a
-        >
-        <span
-          class="text-left"
-          v-if="isFairwayDimension(data.kind.toUpperCase())"
-          >{{ data.summary["source-organization"] }} (LOS:
-          {{ data.summary.los }})</span
-        >
-        <a
-          href="#"
-          class="text-left"
-          @click="zoomToStretch(data.summary.stretch)"
-          v-if="isStretch(data.kind.toUpperCase())"
-          >{{ data.summary.stretch }}</a
-        >
-      </div>
-      <div class="mt-auto mb-auto small text-left type">
-        {{ data.kind.toUpperCase() }}
-      </div>
-      <div v-if="data.summary" class="mt-auto mb-auto small text-left date">
-        {{ formatSurveyDate(data.summary.date) }}
-      </div>
-      <div v-else class="mt-auto mb-auto small text-left date">-</div>
-      <div class="mt-auto mb-auto small text-left imported">
-        {{ formatSurveyDate(data.enqueued.split("T")[0]) }}
-      </div>
-      <div class="mt-auto mb-auto small text-left username">
-        {{ data.user }}
-      </div>
-      <div class="controls d-flex flex-row justify-content-end">
-        <div>
-          <button
-            :class="{
-              'ml-3': true,
-              'mr-3': true,
-              btn: true,
-              'btn-sm': true,
-              'btn-outline-success': needsApproval(data) || isRejected(data),
-              'btn-success': isApproved(data)
-            }"
-            @click="toggleApproval(data.id, $options.STATES.APPROVED)"
-          >
-            <font-awesome-icon icon="check"></font-awesome-icon>
-          </button>
-        </div>
-        <div>
-          <button
-            :class="{
-              'mr-3': true,
-              btn: true,
-              'btn-sm': true,
-              'btn-outline-danger': needsApproval(data) || isApproved(data),
-              'btn-danger': isRejected(data)
-            }"
-            @click="toggleApproval(data.id, $options.STATES.REJECTED)"
-          >
-            <font-awesome-icon icon="times" class="pointer"></font-awesome-icon>
-          </button>
-        </div>
-        <div
-          v-if="
-            !isBottleneck(data.kind.toUpperCase()) ||
-              isApprovedGaugeMeasurement(data.kind.toUpperCase())
-          "
-          class="expander"
-        ></div>
-        <div v-if="isBottleneck(data.kind.toUpperCase())">
-          <div class="mt-auto mb-auto text-info text-left">
-            <font-awesome-icon
-              class="pointer"
-              @click="showDetails()"
-              v-if="show"
-              icon="angle-up"
-              fixed-width
-            ></font-awesome-icon>
-            <font-awesome-icon
-              class="pointer"
-              @click="showDetails()"
-              v-if="loading"
-              icon="spinner"
-              fixed-width
-            ></font-awesome-icon>
-            <font-awesome-icon
-              @click="showDetails()"
-              class="pointer"
-              v-if="!show && !loading"
-              icon="angle-down"
-              fixed-width
-            ></font-awesome-icon>
-          </div>
-        </div>
-        <div v-if="isApprovedGaugeMeasurement(data.kind.toUpperCase())">
-          <div
-            @click="showAGMDetails = !showAGMDetails"
-            class="mt-auto mb-auto text-info text-left"
-          >
-            <font-awesome-icon
-              class="pointer"
-              v-if="showAGMDetails"
-              icon="angle-up"
-              fixed-width
-            ></font-awesome-icon>
-            <font-awesome-icon
-              class="pointer"
-              v-if="!showAGMDetails"
-              icon="angle-down"
-              fixed-width
-            ></font-awesome-icon>
-          </div>
-        </div>
-        <div v-else class="empty"></div>
-      </div>
-    </div>
-    <div v-if="show && bottlenecks.length > 0" class="bottlenecksdetails">
-      <div
-        v-for="(bottleneck, index) in bottlenecks"
-        :key="index"
-        class="d-flex flex-row"
-      >
-        <div class="d-flex flex-column">
-          <div class="d-flex flex-row">
-            <a @click="moveToBottleneck(index)" class="small" href="#">{{
-              bottleneck.properties.objnam
-            }}</a>
-            <div
-              @click="showBottleneckDetails(index)"
-              class="small mt-auto mb-auto text-info text-left"
-            >
-              <font-awesome-icon
-                class="pointer"
-                v-if="showBottleneckDetail === index"
-                icon="angle-up"
-                fixed-width
-              ></font-awesome-icon>
-              <font-awesome-icon
-                class="pointer"
-                v-if="!(showBottleneckDetail === index)"
-                icon="angle-down"
-                fixed-width
-              ></font-awesome-icon>
-            </div>
-          </div>
-
-          <div class="d-flex flex-row" v-if="showBottleneckDetail === index">
-            <table>
-              <tr
-                v-for="(info, index) in Object.keys(bottleneck.properties)"
-                :key="index"
-                class="mr-1 small text-muted"
-              >
-                <td class="condensed text-left">{{ info }}</td>
-                <td class="condensed pl-3 text-left">
-                  {{ bottleneck.properties[info] }}
-                </td>
-              </tr>
-            </table>
-          </div>
-        </div>
-      </div>
-    </div>
-    <div v-if="showAGMDetails">
-      <div class="pl-3 d-flex flex-row">
-        <span class="condensed agmcode text-left"
-          ><small><translate>ISRS Code</translate></small></span
-        >
-        <span class="condensed agmdetail text-left"
-          ><small><translate>Date of measurement</translate></small></span
-        >
-      </div>
-      <div class="diffs">
-        <div v-for="(result, index) in data.summary" :key="index">
-          <div class="pl-3 d-flex flex-row">
-            <span
-              v-if="result.versions.length == 1"
-              class="condensed agmcode text-left"
-              ><small
-                >{{ result["fk-gauge-id"] }}
-                <translate>( New )</translate></small
-              ></span
-            >
-            <span
-              v-if="result.versions.length == 2"
-              class="condensed agmcode text-left"
-              ><small>{{ result["fk-gauge-id"] }}</small></span
-            >
-            <span class="condensed agmdetail text-left"
-              ><small>{{ formatDateTime(result["measure-date"]) }}</small></span
-            >
-            <div
-              @click="toggleDiff(index)"
-              class="small ml-auto mt-auto mb-auto text-info text-left"
-            >
-              <font-awesome-icon
-                class="pointer"
-                v-if="showDiff == index"
-                icon="angle-up"
-                fixed-width
-              ></font-awesome-icon>
-              <font-awesome-icon
-                class="pointer"
-                v-if="showDiff != index"
-                icon="angle-down"
-                fixed-width
-              ></font-awesome-icon>
-            </div>
-          </div>
-          <div v-if="showDiff == index" class="pl-3 d-flex flex-row">
-            <div>
-              <div class="d-flex flex-row condensed pl-3 text-left">
-                <div class="header border-bottom agmdetailskeys">
-                  <small><translate>Value</translate></small>
-                </div>
-                <div
-                  v-if="result.versions.length == 2"
-                  class="header border-bottom agmdetailsvalues"
-                >
-                  <small><translate>Old</translate></small>
-                </div>
-                <div class="header border-bottom agmdetailsvalues">
-                  <small><translate>New</translate></small>
-                </div>
-              </div>
-              <div
-                class="d-flex flex-row condensed pl-3 text-left"
-                v-for="(entry, index) in Object.keys(result.versions[0])"
-                :key="index"
-              >
-                <div
-                  v-if="
-                    result.versions.length == 1 ||
-                      result.versions[0][entry] != result.versions[1][entry]
-                  "
-                  class="agmdetailskeys"
-                >
-                  <small>{{ entry }}</small>
-                </div>
-                <div
-                  v-if="
-                    result.versions.length == 1 ||
-                      result.versions[0][entry] != result.versions[1][entry]
-                  "
-                  class="agmdetailsvalues"
-                >
-                  <small>{{ result.versions[0][entry] }}</small>
-                </div>
-                <div
-                  v-if="
-                    result.versions.length == 2 &&
-                      result.versions[0][entry] != result.versions[1][entry]
-                  "
-                  class="agmdetailsvalues"
-                >
-                  <small>{{ result.versions[1][entry] }}</small>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script>
-/* This is Free Software under GNU Affero General Public License v >= 3.0
- * without warranty, see README.md and license for details.
- *
- * SPDX-License-Identifier: AGPL-3.0-or-later
- * License-Filename: LICENSES/AGPL-3.0.txt
- *
- * Copyright (C) 2018 by via donau
- *   – Österreichische Wasserstraßen-Gesellschaft mbH
- * Software engineering by Intevation GmbH
- *
- * Author(s):
- * Thomas Junk <thomas.junk@intevation.de>
- */
-
-import { formatSurveyDate, formatDateTime } from "@/lib/date.js";
-import { STATES } from "@/store/imports.js";
-import { HTTP } from "@/lib/http";
-import { WFS } from "ol/format.js";
-import { or as orFilter, equalTo as equalToFilter } from "ol/format/filter.js";
-import { displayError } from "@/lib/errors.js";
-import { mapState } from "vuex";
-import { LAYERS } from "@/store/map.js";
-
-const NO_DIFF = -1;
-const NO_BOTTLENECK = -1;
-
-export default {
-  name: "stagingdetail",
-  props: ["data"],
-  data() {
-    return {
-      showDiff: NO_DIFF,
-      showAGMDetails: false,
-      showBottleneckDetail: NO_BOTTLENECK,
-      show: false,
-      loading: false,
-      bottlenecks: []
-    };
-  },
-  mounted() {
-    this.bottlenecks = [];
-    const { id } = this.$route.params;
-    this.$store.commit("imports/setImportToReview", id);
-    if (this.open) this.showDetails();
-  },
-  computed: {
-    ...mapState("imports", ["importToReview"]),
-    open() {
-      return this.importToReview == this.data.id;
-    },
-    detail() {
-      return [
-        "pb-2",
-        "pt-2",
-        "d-flex",
-        "flex-column",
-        "w-100",
-        {
-          highlight: this.open && this.needsApproval(this.data)
-        }
-      ];
-    }
-  },
-  watch: {
-    showAGMDetails() {
-      if (!this.showAGMDetails) this.showDiff = NO_DIFF;
-    },
-    open() {
-      this.show = this.open;
-    },
-    $route() {
-      const { id } = this.$route.params;
-      this.$store.commit("imports/setImportToReview", id);
-      if (this.open) this.showDetails();
-    }
-  },
-  methods: {
-    showBottleneckDetails(index) {
-      if (index == this.showBottleneckDetail) {
-        this.showBottleneckDetail = NO_BOTTLENECK;
-        return;
-      }
-      this.showBottleneckDetail = index;
-    },
-    toggleDiff(number) {
-      if (this.showDiff !== number || this.showDiff == -1) {
-        this.showDiff = number;
-      } else {
-        this.showDiff = -1;
-      }
-    },
-    zoomToStretch(name) {
-      this.$store.commit("map/setLayerVisible", LAYERS.STRETCHES);
-      this.$store
-        .dispatch("imports/loadStretch", name)
-        .then(response => {
-          if (response.data.features.length < 1)
-            throw new Error("no feaures found for: " + name);
-          this.moveToExtent(response.data.features[0]);
-        })
-        .catch(error => {
-          console.log(error);
-          const { status, data } = error.response;
-          displayError({
-            title: this.$gettext("Backend Error"),
-            message: `${status}: ${data.message || data}`
-          });
-        });
-    },
-    showDetails() {
-      if (!this.isBottleneck(this.data.kind.toUpperCase())) return;
-      if (this.show) {
-        this.show = false;
-        return;
-      }
-      if (this.bottlenecks.length > 0) {
-        this.show = true;
-        return;
-      }
-      this.loading = true;
-      const generateFilter = () => {
-        const { bottlenecks } = this.data.summary;
-        if (bottlenecks.length === 1)
-          return equalToFilter("bottleneck_id", bottlenecks[0]);
-        const orExpressions = bottlenecks.map(x => {
-          return equalToFilter("bottleneck_id", x);
-        });
-        return orFilter(...orExpressions);
-      };
-      const filterExpression = generateFilter();
-      const bottleneckFeatureCollectionRequest = new WFS().writeGetFeature({
-        srsName: "EPSG:4326",
-        featureNS: "gemma",
-        featurePrefix: "gemma",
-        featureTypes: ["bottlenecks_geoserver"],
-        outputFormat: "application/json",
-        filter: filterExpression
-      });
-      HTTP.post(
-        "/internal/wfs",
-        new XMLSerializer().serializeToString(
-          bottleneckFeatureCollectionRequest
-        ),
-        {
-          headers: {
-            "X-Gemma-Auth": localStorage.getItem("token"),
-            "Content-type": "text/xml; charset=UTF-8"
-          }
-        }
-      )
-        .then(response => {
-          this.bottlenecks = response.data.features;
-          this.show = true;
-          this.loading = false;
-        })
-        .catch(error => {
-          const { status, data } = error.response;
-          displayError({
-            title: this.$gettext("Backend Error"),
-            message: `${status}: ${data.message || data}`
-          });
-        });
-    },
-    isFairwayDimension(kind) {
-      return kind === "FD";
-    },
-    isApprovedGaugeMeasurement(kind) {
-      return kind === "AGM";
-    },
-    isBottleneck(kind) {
-      return kind === "BN" || kind === "UBN";
-    },
-    isStretch(kind) {
-      return kind === "ST";
-    },
-    isSoundingResult(kind) {
-      return kind === "SR";
-    },
-    formatSurveyDate(date) {
-      return formatSurveyDate(date);
-    },
-    formatDateTime(date) {
-      return formatDateTime(date);
-    },
-    needsApproval(item) {
-      return item.status === STATES.NEEDSAPPROVAL;
-    },
-    isRejected(item) {
-      return item.status === STATES.REJECTED;
-    },
-    isApproved(item) {
-      return item.status === STATES.APPROVED;
-    },
-    moveToBottleneck(index) {
-      this.$store.commit("map/setLayerVisible", LAYERS.BOTTLENECKS);
-      this.moveToExtent(this.bottlenecks[index]);
-    },
-    moveToExtent(feature) {
-      this.$store.commit("map/moveToExtent", {
-        feature: feature,
-        zoom: 17,
-        preventZoomOut: true
-      });
-    },
-    moveMap(coordinates) {
-      this.$store.commit("map/moveMap", {
-        coordinates: coordinates,
-        zoom: 17,
-        preventZoomOut: true
-      });
-    },
-    zoomTo() {
-      const { lat, lon, bottleneck, date } = this.data.summary;
-      const coordinates = [lat, lon];
-      this.moveMap(coordinates);
-      this.$store
-        .dispatch("bottlenecks/setSelectedBottleneck", bottleneck)
-        .then(() => {
-          this.$store.commit("bottlenecks/setSelectedSurveyByDate", date);
-        });
-    },
-    toggleApproval(id, newStatus) {
-      this.$store.commit("imports/toggleApproval", {
-        id: id,
-        newStatus: newStatus
-      });
-    }
-  },
-  STATES: STATES
-};
-</script>
-
-<style lang="scss" scoped>
-.expander {
-  margin-right: 1.25rem;
-}
-
-.bottlenecksdetails {
-  overflow-y: auto;
-  max-height: 250px;
-}
-.diffs {
-  overflow-y: auto;
-  max-height: 250px;
-}
-
-.highlight {
-  background-color: #f9f9f9;
-}
-
-.condensed {
-  font-stretch: condensed;
-}
-
-.empty {
-  margin-right: 20px;
-}
-
-.name {
-  width: 180px;
-}
-
-.date {
-  width: 90px;
-}
-
-.type {
-  width: 40px;
-}
-
-.imported {
-  width: 90px;
-}
-
-.username {
-  width: 150px;
-}
-
-.controls {
-  width: 60px;
-}
-
-.agmcode {
-  width: 200px;
-}
-
-.agmdate {
-  width: 100px;
-}
-
-.agmdetailskeys {
-  width: 130px;
-}
-
-.agmdetailsvalues {
-  width: 200px;
-}
-</style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/ui/UIBoxHeader.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -0,0 +1,63 @@
+<template>
+  <h6 class="box-header">
+    <span class="box-title">
+      <font-awesome-icon
+        :icon="icon"
+        class="box-icon"
+        v-if="icon"
+        fixed-width
+      />
+      {{ $gettext(title) }}
+    </span>
+    <span class="box-close" @click="closeCallback" v-if="closeCallback">
+      <font-awesome-icon icon="times" />
+    </span>
+  </h6>
+</template>
+
+<style lang="sass">
+.box-header
+  display: flex
+  justify-content: space-between
+  align-items: center
+  min-height: 35px
+  padding-left: .5rem
+  border-bottom: 1px solid #dee2e6
+  color: $color-info
+  margin-bottom: 0
+  padding: 0.25rem
+  font-weight: bold
+  .box-title
+    padding-left: 0.25rem
+    .box-icon
+      margin-right: 0.25rem
+  .box-close
+    color: #888
+    padding: 3px 7px
+    border-radius: 0.25rem
+    cursor: pointer
+    transition: background-color 0.3s, color 0.3s
+    &:hover
+      color: #666
+      background-color: #eee
+</style>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Markus Kottländer <markus.kottlaender@intevation.de>
+ */
+
+export default {
+  props: ["icon", "title", "closeCallback"]
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/ui/UITableBody.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -0,0 +1,51 @@
+<template>
+  <transition-group
+    name="fade"
+    tag="div"
+    class="table-body text-left small"
+    :style="'overflow-y: auto; max-height: ' + maxHeight"
+    v-if="data.length"
+  >
+    <div
+      v-for="(item, index) in data"
+      :key="index"
+      :class="['border-top row mx-0', { active: active === item }]"
+    >
+      <slot :item="item" :index="index"></slot>
+    </div>
+  </transition-group>
+  <div v-else class="small text-center py-3 border-top">
+    <translate>No results.</translate>
+  </div>
+</template>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Markus Kottländer <markus.kottlaender@intevation.de>
+ */
+
+export default {
+  props: {
+    data: {
+      type: Array
+    },
+    maxHeight: {
+      type: String,
+      default: "18rem"
+    },
+    active: {
+      type: [Object, Array]
+    }
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/ui/UITableHeader.vue	Fri Mar 01 11:06:27 2019 +0100
@@ -0,0 +1,94 @@
+<template>
+  <div
+    :class="['table-header row no-gutters bg-light', { sortable: sortable }]"
+  >
+    <a
+      v-for="column in columns"
+      @click.prevent="sortBy(column.id)"
+      :key="column.id"
+      :class="[
+        'd-flex py-2 align-items-center justify-content-center small col ' +
+          column.class,
+        { active: sortColumn === column.id }
+      ]"
+    >
+      <span
+        :style="'opacity: ' + (sortColumn === column.id ? '1' : '0.3')"
+        v-if="sortable"
+      >
+        <font-awesome-icon
+          :icon="sortIcon(column.id)"
+          class="ml-1"
+          fixed-width
+        />
+      </span>
+      {{ $gettext(column.title) }}
+    </a>
+    <div v-if="extraColumnForButtons" class="col"></div>
+  </div>
+</template>
+
+<style lang="sass">
+.table-header
+  > a
+    border-right: solid 1px #e7e8e9
+    background-color: #f8f9fa
+    a
+      outline: none
+      &:hover
+        text-decoration: none
+        background-color: #f8f9fa
+  &.sortable
+    a
+      cursor: pointer
+      &:hover,
+      &.active
+        background: #e7e8e9
+</style>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Markus Kottländer <markus.kottlaender@intevation.de>
+ */
+export default {
+  props: {
+    columns: { type: Array },
+    sortable: { type: Boolean, default: true },
+    extraColumnForButtons: { type: Boolean, default: true }
+  },
+  data() {
+    return {
+      sortColumn: null,
+      sortDirection: "ASC"
+    };
+  },
+  methods: {
+    sortIcon(id) {
+      if (this.sortColumn === id) {
+        return "sort-" + (this.sortDirection === "ASC" ? "down" : "up");
+      }
+      return "sort";
+    },
+    sortBy(id) {
+      if (this.sortable) {
+        this.sortColumn = id;
+        this.sortDirection = this.sortDirection === "ASC" ? "DESC" : "ASC";
+        this.$emit("sortingChanged", {
+          sortColumn: this.sortColumn,
+          sortDirection: this.sortDirection
+        });
+      }
+    }
+  }
+};
+</script>
--- a/client/src/components/ui/box/Header.vue	Thu Feb 28 17:28:54 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,63 +0,0 @@
-<template>
-  <h6 class="box-header">
-    <span class="box-title">
-      <font-awesome-icon
-        :icon="icon"
-        class="box-icon"
-        v-if="icon"
-        fixed-width
-      />
-      {{ $gettext(title) }}
-    </span>
-    <span class="box-close" @click="closeCallback" v-if="closeCallback">
-      <font-awesome-icon icon="times" />
-    </span>
-  </h6>
-</template>
-
-<style lang="sass">
-.box-header
-  display: flex
-  justify-content: space-between
-  align-items: center
-  min-height: 35px
-  padding-left: .5rem
-  border-bottom: 1px solid #dee2e6
-  color: $color-info
-  margin-bottom: 0
-  padding: 0.25rem
-  font-weight: bold
-  .box-title
-    padding-left: 0.25rem
-    .box-icon
-      margin-right: 0.25rem
-  .box-close
-    color: #888
-    padding: 3px 7px
-    border-radius: 0.25rem
-    cursor: pointer
-    transition: background-color 0.3s, color 0.3s
-    &:hover
-      color: #666
-      background-color: #eee
-</style>
-
-<script>
-/* This is Free Software under GNU Affero General Public License v >= 3.0
- * without warranty, see README.md and license for details.
- *
- * SPDX-License-Identifier: AGPL-3.0-or-later
- * License-Filename: LICENSES/AGPL-3.0.txt
- *
- * Copyright (C) 2018 by via donau
- *   – Österreichische Wasserstraßen-Gesellschaft mbH
- * Software engineering by Intevation GmbH
- *
- * Author(s):
- * Markus Kottländer <markus.kottlaender@intevation.de>
- */
-
-export default {
-  props: ["icon", "title", "closeCallback"]
-};
-</script>
--- a/client/src/main.js	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/main.js	Fri Mar 01 11:06:27 2019 +0100
@@ -29,7 +29,9 @@
 import store from "./store";
 import translations from "./locale/translations.json";
 import { supportedLanguages, defaultLanguage } from "./locale/languages.js";
-import Header from "@/components/ui/box/Header";
+import UIBoxHeader from "@/components/ui/UIBoxHeader";
+import UITableHeader from "@/components/ui/UITableHeader";
+import UITableBody from "@/components/ui/UITableBody";
 
 // styles
 import "../node_modules/bootstrap/dist/css/bootstrap.min.css";
@@ -74,8 +76,11 @@
   faRuler,
   faSearch,
   faShip,
+  faSort,
   faSortAmountDown,
   faSortAmountUp,
+  faSortDown,
+  faSortUp,
   faSpinner,
   faStar,
   faTasks,
@@ -126,6 +131,9 @@
   faRuler,
   faSearch,
   faShip,
+  faSort,
+  faSortDown,
+  faSortUp,
   faSortAmountDown,
   faSortAmountUp,
   faSpinner,
@@ -154,7 +162,9 @@
 
 // register global components
 Vue.component("font-awesome-icon", FontAwesomeIcon);
-Vue.component("UIBoxHeader", Header);
+Vue.component("UIBoxHeader", UIBoxHeader);
+Vue.component("UITableHeader", UITableHeader);
+Vue.component("UITableBody", UITableBody);
 
 // global vue config
 Vue.config.productionTip = false;
--- a/client/src/router.js	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/router.js	Fri Mar 01 11:06:27 2019 +0100
@@ -81,22 +81,6 @@
       }
     },
     {
-      path: "/importqueue/:id?",
-      name: "importqueue",
-      component: () => import("./components/importqueue/Importqueue.vue"),
-      meta: {
-        requiresAuth: true
-      },
-      beforeEnter: (to, from, next) => {
-        const isWaterwayAdmin = store.getters["user/isWaterwayAdmin"];
-        if (!isWaterwayAdmin) {
-          next("/");
-        } else {
-          next();
-        }
-      }
-    },
-    {
       path: "/importsoundingresults",
       name: "importsoundingresults",
       component: () => import("./components/ImportSoundingresults.vue"),
@@ -168,6 +152,7 @@
         requiresAuth: true
       },
       beforeEnter: (to, from, next) => {
+        store.commit("application/searchQuery", "");
         store.commit("application/showContextBox", false);
         store.commit("application/contextBoxContent", "");
         store.commit("application/showSearchbar", false);
@@ -182,6 +167,7 @@
         requiresAuth: true
       },
       beforeEnter: (to, from, next) => {
+        store.commit("application/searchQuery", "");
         store.commit("application/showContextBox", true);
         store.commit("application/contextBoxContent", "bottlenecks");
         store.commit("application/showSearchbar", true);
@@ -189,8 +175,8 @@
       }
     },
     {
-      path: "/review/:id?",
-      name: "review",
+      path: "/imports/overview/:id?",
+      name: "importoverview",
       component: Main,
       meta: {
         requiresAuth: true
@@ -201,7 +187,7 @@
           next("/");
         } else {
           store.commit("application/showContextBox", true);
-          store.commit("application/contextBoxContent", "staging");
+          store.commit("application/contextBoxContent", "importoverview");
           store.commit("application/showSearchbar", true);
           next();
         }
@@ -219,6 +205,7 @@
         if (!isSysadmin) {
           next("/");
         } else {
+          store.commit("application/searchQuery", "");
           store.commit("application/showContextBox", true);
           store.commit("application/contextBoxContent", "stretches");
           store.commit("application/showSearchbar", true);
--- a/client/src/store/bottlenecks.js	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/store/bottlenecks.js	Fri Mar 01 11:06:27 2019 +0100
@@ -21,6 +21,7 @@
 const init = () => {
   return {
     bottlenecks: [],
+    bottlenecksList: [],
     selectedBottleneck: null,
     surveys: [],
     selectedSurvey: null,
@@ -36,6 +37,9 @@
     setBottlenecks: (state, bottlenecks) => {
       state.bottlenecks = bottlenecks;
     },
+    setBottlenecksList: (state, bottlenecksList) => {
+      state.bottlenecksList = bottlenecksList;
+    },
     setSelectedBottleneck: (state, name) => {
       state.selectedBottleneck = name;
     },
@@ -102,13 +106,43 @@
         }
       });
     },
+    loadBottlenecksList({ commit }) {
+      return new Promise((resolve, reject) => {
+        var bottleneckFeatureCollectionRequest = new WFS().writeGetFeature({
+          srsName: "EPSG:4326",
+          featureNS: "gemma",
+          featurePrefix: "gemma",
+          featureTypes: ["bottleneck_overview"],
+          outputFormat: "application/json"
+        });
+        HTTP.post(
+          "/internal/wfs",
+          new XMLSerializer().serializeToString(
+            bottleneckFeatureCollectionRequest
+          ),
+          {
+            headers: {
+              "X-Gemma-Auth": localStorage.getItem("token"),
+              "Content-type": "text/xml; charset=UTF-8"
+            }
+          }
+        )
+          .then(response => {
+            commit("setBottlenecksList", response.data.features);
+            resolve(response);
+          })
+          .catch(error => {
+            reject(error);
+          });
+      });
+    },
     loadBottlenecks({ commit }) {
       return new Promise((resolve, reject) => {
         var bottleneckFeatureCollectionRequest = new WFS().writeGetFeature({
           srsName: "EPSG:4326",
           featureNS: "gemma",
           featurePrefix: "gemma",
-          featureTypes: ["bottleneck_overview"],
+          featureTypes: ["bottlenecks_geoserver"],
           outputFormat: "application/json"
         });
         HTTP.post(
--- a/client/src/store/imports.js	Thu Feb 28 17:28:54 2019 +0100
+++ b/client/src/store/imports.js	Fri Mar 01 11:06:27 2019 +0100
@@ -30,7 +30,9 @@
     stretches: [],
     imports: [],
     staging: [],
-    importToReview: null
+    importToReview: null,
+    stagingVisible: true,
+    logsVisible: true
   };
 };
 
@@ -67,6 +69,18 @@
   init,
   namespaced: true,
   state: init(),
+  getters: {
+    processedReviews: state => {
+      return state.staging
+        .filter(x => x.status !== STATES.NEEDSAPPROVAL)
+        .map(r => {
+          return {
+            id: r.id,
+            state: r.status
+          };
+        });
+    }
+  },
   mutations: {
     setStretches: (state, stretches) => {
       state.stretches = stretches;
@@ -74,6 +88,12 @@
     setImports: (state, imports) => {
       state.imports = imports;
     },
+    setStagingVisibility: (state, visibility) => {
+      state.stagingVisible = visibility;
+    },
+    setLogsVisibility: (state, visibility) => {
+      state.logsVisible = visibility;
+    },
     setStaging: (state, staging) => {
       const enriched = staging.map(x => {
         return { ...x, status: STATES.NEEDSAPPROVAL };
@@ -138,9 +158,11 @@
           });
       });
     },
-    getImports({ commit }) {
+    getImports({ commit }, filter) {
+      let queryParams = "";
+      if (filter) queryParams = "?states=" + filter.join(",");
       return new Promise((resolve, reject) => {
-        HTTP.get("/imports", {
+        HTTP.get("/imports" + queryParams, {
           headers: { "X-Gemma-Auth": localStorage.getItem("token") }
         })
           .then(response => {
@@ -165,6 +187,22 @@
             reject(error);
           });
       });
+    },
+    confirmReview({ state }, reviewResults) {
+      return new Promise((resolve, reject) => {
+        HTTP.patch("/imports", reviewResults, {
+          headers: {
+            "X-Gemma-Auth": localStorage.getItem("token"),
+            "Content-type": "application/json"
+          }
+        })
+          .then(response => {
+            resolve(response);
+          })
+          .catch(error => {
+            reject(error);
+          });
+      });
     }
   }
 };
--- a/schema/README.md	Thu Feb 28 17:28:54 2019 +0100
+++ b/schema/README.md	Fri Mar 01 11:06:27 2019 +0100
@@ -1,3 +1,5 @@
+The gemma database schema requires PostgreSQL >= 11.
+
 `dot.tmpl` is a template file for `postgresql_autodoc`
 to be more similiar to UML and leave out some less useful labels.
 
--- a/schema/gemma.sql	Thu Feb 28 17:28:54 2019 +0100
+++ b/schema/gemma.sql	Fri Mar 01 11:06:27 2019 +0100
@@ -473,56 +473,6 @@
         PRIMARY KEY (bottleneck_id, riverbed)
     )
 
-    -- Published view for GeoServer
-    CREATE VIEW bottlenecks_geoserver AS
-    WITH fairway_availability_latest AS (
-        SELECT DISTINCT ON (bottleneck_id) bottleneck_id,date_info,critical
-        FROM fairway_availability
-        ORDER BY bottleneck_id, date_info DESC NULLS LAST),
-    gauge_measurements_waterlevel AS (
-        SELECT DISTINCT ON (fk_gauge_id) fk_gauge_id,measure_date,predicted,water_level
-        FROM gauge_measurements WHERE predicted ='false'
-        ORDER BY fk_gauge_id, measure_date DESC NULLS LAST)
-    SELECT
-        b.id,
-        b.bottleneck_id,
-        b.objnam,
-        b.nobjnm,
-        b.stretch,
-        b.area,
-        b.rb,
-        b.lb,
-        b.responsible_country,
-        b.revisiting_time,
-        b.limiting,
-        b.date_info,
-        b.source_organization,
-        g.location AS gauge_isrs_code,
-        g.objname AS gauge_objname,
-        rwl_ldc.value AS ldc,
-        rwl_mw.value AS mw,
-        rwl_hdc.value AS hdc,
-        fal.date_info AS fa_date_info,
-        fal.critical AS fa_critical,
-        gmw.water_level as gm_waterlevel
-    FROM bottlenecks b, gauges g,
-        (SELECT gauge_id, value FROM gauges_reference_water_levels
-           WHERE depth_reference = 'LDC') rwl_ldc,
-        (SELECT gauge_id, value FROM gauges_reference_water_levels
-           WHERE depth_reference = 'MW') rwl_mw,
-        (SELECT gauge_id, value FROM gauges_reference_water_levels
-           WHERE depth_reference = 'HDC') rwl_hdc
-        LEFT JOIN LATERAL (
-            SELECT bottleneck_id,date_info,critical
-            FROM  fairway_availability_latest
-            WHERE b.id=bottleneck_id) fal ON TRUE
-        LEFT JOIN LATERAL (
-            SELECT water_level
-            FROM  gauge_measurements_waterlevel
-            WHERE b.fk_g_fid=fk_gauge_id) gmw ON TRUE
-    WHERE b.fk_g_fid = g.location AND g.location = rwl_ldc.gauge_id
-      AND g.location = rwl_mw.gauge_id AND g.location = rwl_hdc.gauge_id
-
     CREATE TABLE sounding_results (
         id int PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
         bottleneck_id int NOT NULL REFERENCES bottlenecks(id),
@@ -630,6 +580,52 @@
       SELECT bottleneck_id, max(date_info) AS current FROM sounding_results
       GROUP BY bottleneck_id) sr ON sr.bottleneck_id = bn.id
     ORDER BY objnam
+
+    -- Published view for GeoServer
+    CREATE VIEW bottlenecks_geoserver AS
+    WITH fairway_availability_latest AS (
+        SELECT DISTINCT ON (bottleneck_id) bottleneck_id,date_info,critical
+        FROM fairway_availability
+        ORDER BY bottleneck_id, date_info DESC NULLS LAST),
+    gauge_measurements_waterlevel AS (
+        SELECT DISTINCT ON (fk_gauge_id)
+            fk_gauge_id, measure_date, predicted, water_level
+        FROM gauge_measurements WHERE predicted ='false'
+        ORDER BY fk_gauge_id, measure_date DESC NULLS LAST)
+    SELECT
+        b.id,
+        b.bottleneck_id,
+        b.objnam,
+        b.nobjnm,
+        b.stretch,
+        b.area,
+        b.rb,
+        b.lb,
+        b.responsible_country,
+        b.revisiting_time,
+        b.limiting,
+        b.date_info,
+        b.source_organization,
+        g.location AS gauge_isrs_code,
+        g.objname AS gauge_objname,
+        json_object_agg(r.depth_reference, r.value) AS reference_water_levels,
+        fal.date_info AS fa_date_info,
+        fal.critical AS fa_critical,
+        gmw.water_level as gm_waterlevel
+    FROM bottlenecks b LEFT JOIN gauges g ON b.fk_g_fid = g.location
+        LEFT JOIN LATERAL (
+            SELECT gauge_id,depth_reference,value
+            FROM gauges_reference_water_levels
+            ) r ON r.gauge_id = b.fk_g_fid
+        LEFT JOIN LATERAL (
+            SELECT bottleneck_id,date_info,critical
+            FROM  fairway_availability_latest
+            WHERE b.id=bottleneck_id) fal ON TRUE
+        LEFT JOIN LATERAL (
+            SELECT water_level
+            FROM  gauge_measurements_waterlevel
+            WHERE b.fk_g_fid=fk_gauge_id) gmw ON TRUE
+    GROUP BY b.id, g.location, fal.date_info, fal.critical, gmw.water_level;
 ;
 
 -- Configure primary keys for geoserver views
--- a/schema/isrs_functions.sql	Thu Feb 28 17:28:54 2019 +0100
+++ b/schema/isrs_functions.sql	Fri Mar 01 11:06:27 2019 +0100
@@ -26,7 +26,7 @@
     area geometry
 ) RETURNS geometry
 AS $$
-    WITH
+    WITH RECURSIVE
         -- Get coordinates of location codes
         points_geog AS (
             SELECT geom FROM waterway.distance_marks_virtual
@@ -37,9 +37,7 @@
             SELECT best_utm(ST_Collect(geom::geometry)) AS z
                 FROM points_geog),
         axis AS (
-            -- Transform and sew together contiguous axis chunks
-            SELECT ST_LineMerge(ST_Collect(ST_Transform(
-                    wtwaxs::geometry, z))) AS wtwaxs
+            SELECT id, ST_Transform(wtwaxs::geometry, z) AS wtwaxs
                 FROM waterway.waterway_axis, utm_zone),
         -- In order to guarantee the following ST_Covers to work,
         -- snap distance mark coordinates to axis
@@ -47,16 +45,49 @@
             SELECT ST_ClosestPoint(
                     wtwaxs,
                     ST_Transform(points_geog.geom::geometry, z)) AS geom
-                FROM axis, points_geog, utm_zone),
-        axis_segment AS (
-            -- select the contiguous axis on which distance marks lie
-            SELECT line
-                FROM (
-                    SELECT (ST_Dump(wtwaxs)).geom AS line
-                        FROM axis) AS lines,
+                FROM points_geog, utm_zone, (
+                    SELECT ST_Collect(wtwaxs) AS wtwaxs
+                        FROM axis) AS ax),
+        axis_snapped AS (
+            -- Iteratively connect non-contiguous axis chunks
+            -- to find the contiguous axis on which given distance marks lie
+            (SELECT ARRAY[id] AS ids, wtwaxs
+                FROM axis, points
+                WHERE ST_Intersects(
+                    ST_Buffer(axis.wtwaxs, 0.0001), points.geom)
+                FETCH FIRST ROW ONLY)
+            UNION
+            -- Connect endpoint of next linestring with closest
+            -- endpoint of merged linestring until a contiguous
+            -- linestring connecting both distance marks is build up
+            (SELECT refids || id,
+                    ST_LineMerge(ST_Collect(ARRAY(
+                        -- Linestring build up so far
+                        SELECT refgeom
+                        UNION
+                        -- Fill eventual gap
+                        SELECT ST_MakeLine(
+                            ST_ClosestPoint(
+                                ST_Boundary(refgeom), ST_Boundary(geom)),
+                            ST_ClosestPoint(
+                                ST_Boundary(geom), ST_Boundary(refgeom)))
+                        UNION
+                        -- Linestring to be added
+                        SELECT geom)))
+                FROM axis_snapped AS axis_snapped (refids, refgeom),
+                    axis AS axis (id, geom),
                     (SELECT ST_Collect(points.geom) AS pts
                         FROM points) AS points
-                WHERE ST_Covers(ST_Buffer(lines.line, 0.0001), points.pts)),
+                WHERE id <> ALL(refids)
+                    AND NOT ST_Covers(ST_Buffer(refgeom, 0.0001), points.pts)
+                ORDER BY ST_Distance(ST_Boundary(refgeom), ST_Boundary(geom))
+                FETCH FIRST ROW ONLY)),
+        axis_segment AS (
+            -- Fetch end result from snapping
+            SELECT wtwaxs AS line
+                FROM axis_snapped
+                WHERE array_length(ids, 1) = (
+                    SELECT max(array_length(ids, 1)) FROM axis_snapped)),
         axis_substring AS (
             -- Use linear referencing to clip axis between distance marks.
             -- Simplification is used to work-around the problem, that
@@ -78,29 +109,34 @@
             -- polygons, which intersect with the axis. The union is to avoid
             -- problems with invalid/self-intersecting multipolygons
             SELECT ST_Union(a_dmp.geom) AS area
-                FROM axis_substring, utm_zone,
-                    ST_Dump(ST_Transform(area, z)) AS a_dmp
+                FROM axis_substring, utm_zone, LATERAL (
+                    SELECT ST_MakeValid(ST_Transform(geom, z)) AS geom
+                        FROM ST_Dump(area)) AS a_dmp
                 WHERE ST_Intersects(a_dmp.geom, axis_substring.line)
             ),
+        rotated_ends AS (
+            SELECT ST_Collect(ST_Scale(
+                    ST_Translate(e,
+                        (ST_X(p1) - ST_X(p2)) / 2,
+                        (ST_Y(p1) - ST_Y(p2)) / 2),
+                    ST_Point(d, d), p1)) AS blade
+                FROM axis_substring, area_subset,
+                    LATERAL (SELECT i, ST_PointN(line, i) AS p1
+                        FROM (VALUES (1), (-1)) AS idx (i)) AS ep,
+                    ST_Rotate(ST_PointN(line, i*2), pi()/2, p1) AS ep2 (p2),
+                    ST_Makeline(p1, p2) AS e (e),
+                    LATERAL (SELECT (ST_MaxDistance(p1, area) / ST_Length(e))
+                            * 2) AS d (d)),
         range_area AS (
-            -- Create a buffer around the clipped axis, as large as it could
-            -- potentially be intersecting with the area polygon that
-            -- intersects with the clipped axis. Get the intersection of that
-            -- buffer with the area polygon, which can potentially
-            -- be a multipolygon.
-            SELECT (ST_Dump(ST_Intersection(
-                    ST_Buffer(
-                        axis_substring.line,
-                        ST_MaxDistance(
-                            axis_substring.line,
-                            area_subset.area),
-                        'endcap=flat'),
-                    area_subset.area))).geom
-                FROM axis_substring, area_subset)
+            -- Split area by orthogonal lines at the ends of the clipped axis
+            SELECT (ST_Dump(ST_CollectionExtract(
+                    ST_Split(area, blade), 3))).geom
+                FROM area_subset, rotated_ends)
         -- From the polygons returned by the last CTE, select only those
         -- around the clipped axis
-        SELECT ST_Collect(ST_Transform(range_area.geom, ST_SRID(area)))
+        SELECT ST_Multi(ST_Transform(ST_Union(range_area.geom), ST_SRID(area)))
             FROM axis_substring, range_area
-            WHERE ST_Intersects(range_area.geom, axis_substring.line)
+            WHERE ST_Intersects(ST_Buffer(range_area.geom, -0.0001),
+                axis_substring.line)
     $$
     LANGUAGE sql;
--- a/schema/isrs_tests.sql	Thu Feb 28 17:28:54 2019 +0100
+++ b/schema/isrs_tests.sql	Fri Mar 01 11:06:27 2019 +0100
@@ -42,7 +42,6 @@
     ) IS NULL,
     'ISRSrange_area returns NULL, if given area does not intersect with axis');
 
-\set test_area 'POLYGON((-1 1, 2 1, 2 -1, -1 -1, -1 1))'
 SELECT ok(
     ST_DWithin(
         (SELECT geom FROM waterway.distance_marks_virtual
@@ -50,8 +49,8 @@
         ST_Boundary(ISRSrange_area(isrsrange(
                 ('AT', 'XXX', '00001', '00000', 0)::isrs,
                 ('AT', 'XXX', '00001', '00000', 1)::isrs),
-            ST_SetSRID(:'test_area'::geometry,
-                4326)))::geography,
+            (SELECT ST_Collect(CAST(area AS geometry))
+                FROM waterway.waterway_area))),
         1)
     AND
     ST_DWithin(
@@ -60,11 +59,12 @@
         ST_Boundary(ISRSrange_area(isrsrange(
                 ('AT', 'XXX', '00001', '00000', 0)::isrs,
                 ('AT', 'XXX', '00001', '00000', 1)::isrs),
-            ST_SetSRID(:'test_area'::geometry,
-                4326)))::geography,
+            (SELECT ST_Collect(CAST(area AS geometry))
+                FROM waterway.waterway_area))),
         1),
     'Resulting polygon almost ST_Touches points corresponding to stretch');
 
+\set test_area 'POLYGON((-1 1, 2 1, 2 -1, -1 -1, -1 1))'
 SELECT ok(
     2 = ST_NumGeometries(
         ISRSrange_area(
@@ -100,3 +100,12 @@
                             0)),
                 4326))),
     'Self-intersecting multipolygon leads to one polygon in result');
+
+SELECT ok(
+    ISRSrange_area(
+        isrsrange(
+            ('AT', 'XXX', '00001', '00000', 0)::isrs,
+            ('AT', 'XXX', '00001', '00000', 2)::isrs),
+        (SELECT ST_Collect(CAST(area AS geometry))
+            FROM waterway.waterway_area)) IS NOT NULL,
+    'Area generated from non-matching distance mark and non-contiguous axis');
--- a/schema/run_tests.sh	Thu Feb 28 17:28:54 2019 +0100
+++ b/schema/run_tests.sh	Fri Mar 01 11:06:27 2019 +0100
@@ -28,7 +28,7 @@
     -c 'SET client_min_messages TO WARNING' \
     -c "DROP ROLE IF EXISTS $TEST_ROLES" \
     -f tap_tests_data.sql \
-    -c 'SELECT plan(57)' \
+    -c 'SELECT plan(58)' \
     -f isrs_tests.sql \
     -f auth_tests.sql \
     -f manage_users_tests.sql \
--- a/schema/tap_tests_data.sql	Thu Feb 28 17:28:54 2019 +0100
+++ b/schema/tap_tests_data.sql	Fri Mar 01 11:06:27 2019 +0100
@@ -76,18 +76,46 @@
     ('AT', 'XXX', '00001', '00000', 1)::isrs,
     ST_SetSRID('POINT(1 0)'::geometry, 4326),
     'someENC'
+), (
+    ('AT', 'XXX', '00001', '00000', 2)::isrs,
+    ST_SetSRID('POINT(1.6 0)'::geometry, 4326),
+    'someENC'
 );
 
 INSERT INTO waterway.waterway_axis (wtwaxs, objnam) VALUES (
     ST_SetSRID(ST_CurveToLine(
-        'CIRCULARSTRING(0 0, 0.5 0.5, 1 0, 1.5 -0.2, 2 0)'::geometry),
+        'CIRCULARSTRING(0 0, 0.5 0.5, 0.6 0.4)'),
         4326),
     'testriver'
 ), (
+    ST_SetSRID(ST_CurveToLine('CIRCULARSTRING(0.6 0.4, 1 0, 1.5 0)'), 4326),
+    'testriver'
+), (
     ST_SetSRID('LINESTRING(0.5 0.5, 1 1)'::geometry, 4326),
     'testriver'
+), (
+    ST_SetSRID('LINESTRING(1.5 0.1, 2 0)'::geometry, 4326),
+    'testriver'
 );
 
+-- Simulate waterway area as non-intersecting buffers around axis
+WITH RECURSIVE
+buffer AS (
+    SELECT id, ST_Buffer(wtwaxs, 10000, 'endcap=flat')::geometry AS buf
+        FROM waterway.waterway_axis),
+cleaned AS (
+    (SELECT ARRAY[id] AS ids, buf AS cbuf, buf AS others
+        FROM buffer ORDER BY id FETCH FIRST ROW ONLY)
+    UNION
+    (SELECT ids || id,
+            ST_Difference(buf, others),
+            ST_Union(buf, others)
+        FROM cleaned, buffer
+        WHERE id <> ALL(ids)
+        ORDER BY id ASC, ids DESC
+        FETCH FIRST ROW ONLY))
+INSERT INTO waterway.waterway_area (area) SELECT cbuf FROM cleaned;
+
 INSERT INTO users.templates (template_name, country, template_data)
     VALUES ('AT', 'AT', '\x'), ('RO', 'RO', '\x');