colorBar
routes-planner tablet view

Routes Planner

Description

[Content will be translated soon] Planer tras to panel administracyjny umożliwiający zarządzenie zamówieniami dodawanymi przez pracowników firmy i przydzielania ich do trasy (grupy). Dodane zamówienia są widoczne jako interaktywne markery na mapie OpenStreetMap, można w nie kliknąć, aby przejrzeć lub edytować zamówienie.

Użytkownik po zalogowaniu ma możliwość zarządzania zamówieniami lub trasami, można je filtrować, sortować i przeszukiwać.

W aplikacji wykorzystywana jest także geolokalizacja, dzięki której na podstawie danych adresowych zamówienia możliwe jest ustalenie jego koordynatów GPS.

Technologies

[Content will be translated soon] Jest to druga iteracja aplikacji, która jest wykorzystywana nieprzerwanie od 2014 roku. Pierwsza wersja była napisana w jQuery, jQuery UI, leaflet.js a jej backend był oparty na prostym API RESTOwym w PHP.

Aktualnie front aplikacji został oparty na Vue.js i Vuex, natomiast za wygląd aplikacji odpowiedzialne jest Buefy. Buefy to zestaw komponentów dla Vue.js ostylowanych w Bulmie. Paczka Buefy została wybrana głównie ze względu na komponent table, który pozwala na dynamiczne ładowanie zawartości z serwera z paginacją, jest bardzo dobrze udokumentowany, daje duże możliwości konfiguracji i wprowadzania własnych zmian.

Backend aplikacji to ApiPlatform, umożliwiający wygodne tworzenie RESTowego API w oparciu o Symfony 4. Autentykacja odbywa się za pomocą JWT (Json Web Token), którego token przechowywany jest w LocalStorage.

Gwarancję poprawnego działania i dalszego rozwoju aplikacji zapewniają testy E2E wykonane w Cypress.

Vue.jsvuexPHPJavaScriptApiPlatform

Released

calendar

10/2018

Routes Planner desktop viewRoutes Planner tablet viewRoutes Planner phone view

Application

This is commercial project and is not available on GitHub.
Code fragments and screens are available.

colorBar

Code examples

/* Cypress test */

/// <reference types="Cypress" />

const apiURL = window.Cypress.config().apiURL;
const resetDbURL = window.Cypress.config().resetDbURL;
const testAdminUser = window.Cypress.config().testAdminUser;

const tempAdminUser = {
  login: 'jan.nowak',
  password: 'ppp'
};

describe('Login as admin, test module USERS and logout.', () => {
  localStorage.clear();

  afterEach(function() {
    if (this.currentTest.state === 'failed') {
      Cypress.runner.stop();
    }
  });

  it('resets database', () => {
    cy.visit(resetDbURL, {
      timeout: 30000
    });

    cy.get('.test-data-generator')
      .find('.new-test-user-login')
      .should('not.be.empty');
  });

  it('login an admin user', () => {
    cy.visit('/#/login');

    cy.url().should('include', '/login');

    cy.server();

    cy.route('POST', apiURL + 'login_check').as('postLogin');
    cy.route('GET', apiURL + 'orders*').as('getOrdersAfterLogin');

    cy.get('.LoginView form')
      .find('[type="text"]')
      .type(testAdminUser.login);

    cy.get('.LoginView form')
      .find('[type="password"]')
      .type(`${testAdminUser.password}{enter}`);

    cy.wait('@postLogin')
      .its('status')
      .should('eq', 200);

    cy.url()
      .should('include', '/orders')
      .should(() => {
        expect(localStorage.getItem('token')).to.exist;
        expect(localStorage.getItem('user')).to.exist;
      });

    cy.wait('@getOrdersAfterLogin')
      .its('status')
      .should('eq', 200);
  });

  it('select users module', () => {
    cy.get('.navbar')
      .find('.navbar-item--users')
      .click();

    cy.get('.RouterView').should('have.class', 'UsersView');
  });

  it('add an user', () => {
    cy.server();
    cy.route('POST', apiURL + 'users').as('addUser');
    cy.route('GET', apiURL + 'users*').as('getUsersAfterAdd');

    cy.get('.navbar')
      .find('.button--new-user')
      .click();

    cy.get('.NewUserModal')
      .find('.field--username input')
      .type(tempAdminUser.login);
    cy.get('.NewUserModal')
      .find('.field--plainPassword input')
      .type(tempAdminUser.password);
    cy.get('.NewUserModal')
      .find('.field--isAdmin label')
      .click();
    cy.get('.NewUserModal')
      .find('.button--save')
      .click();

    cy.wait('@addUser')
      .its('status')
      .should('eq', 201);

    cy.get('.ViewUserModal').should('be.visible');
    cy.get('.ViewUserModal')
      .find('.button--cancel')
      .click();
    cy.get('.ViewUserModal').should('be.not.visible');

    cy.wait('@getUsersAfterAdd')
      .its('status')
      .should('eq', 200);

    cy.get('.UsersTable tbody tr:first-child')
      .find('[data-label="Użytkownik"] a')
      .should('contain', tempAdminUser.login);
    cy.get('.UsersTable tbody tr:first-child')
      .find('[data-label="Liczba zamówień"]')
      .should('contain', 0);
    cy.get('.UsersTable tbody tr:first-child')
      .find('[data-label="Konto aktywne"]')
      .should('contain', 'tak');
    cy.get('.UsersTable tbody tr:first-child')
      .find('[data-label="Admin"]')
      .should('contain', 'tak');
  });

  it('edit an user', () => {
    cy.server();

    cy.route('PUT', apiURL + 'users/*').as('editUser');
    cy.route('GET', apiURL + 'users*').as('getUsersAfterEdit');

    cy.get('.UsersTable tbody tr:first-child')
      .find('[data-label="Użytkownik"] a')
      .click();

    cy.get('.ViewUserModal')
      .find('.button--edit')
      .click();

    cy.get('.ViewUserModal')
      .find('.field--username input')
      .clear()
      .type(tempAdminUser.login);

    cy.get('.ViewUserModal')
      .find('.button--save')
      .click();

    cy.wait('@editUser')
      .its('status')
      .should('eq', 200);

    cy.wait('@getUsersAfterEdit')
      .its('status')
      .should('eq', 200);

    cy.get('.ViewUserModal')
      .find('.button--cancel')
      .click();

    cy.get('.UsersTable tbody tr:first-child')
      .find('[data-label="Użytkownik"] a')
      .should('contain', tempAdminUser.login);
  });

  it('delete an user', () => {
    cy.server();

    cy.route('DELETE', apiURL + 'users/*').as('destroyUser');
    cy.route('GET', apiURL + 'users*').as('getUsersAfterDestroy');

    cy.get('.UsersTable tbody tr:first-child')
      .find('[data-label="Użytkownik"] a')
      .click();

    cy.get('.ViewUserModal')
      .find('.button.button--delete')
      .click();

    cy.get('.dialog.is-active')
      .find('.button.is-danger')
      .click();

    cy.wait('@destroyUser')
      .its('status')
      .should('eq', 204);

    cy.wait('@getUsersAfterDestroy')
      .its('status')
      .should('eq', 200);

    cy.get('.UsersTable tbody tr:first-child')
      .find('[data-label="Użytkownik"] a')
      .should('not.contain', tempAdminUser.login);

    cy.url().should('include', '/users');
  });

  it('logout', () => {
    cy.get('.navbar')
      .find('.button--logout')
      .click()
      .should(() => {
        expect(localStorage.getItem('token')).to.not.exist;
        expect(localStorage.getItem('user')).to.not.exist;
      });

    cy.url().should('include', '/login');
  });
});
/cypress/integration/user_actions.spec.js
import bus from '@/EventBus'
import { mapState } from 'vuex'
import OrdersTable from '@/components/tables/OrdersTable'
import RoutesTable from '@/components/tables/RoutesTable'
import Map from '@/components/Map'
import Footer from '@/components/Footer'

export default {
  name: 'OrdersView',
  components: {
    OrdersTable,
    RoutesTable,
    Map,
    Footer
  },
  data () {
    return {
      totalNewOrders: null,
      searchText: ''
    }
  },
  computed: {
    ...mapState('orders', [
      'currentTab',
      'viewMode'
    ])
  },
  methods: {
    addTestOrder () {
      const testOrder = {
        clientName: 'Jan Kowalski',
        postalCode: '12-123',
        post: 'Gdańsk',
        phone: '123-561-345',
        broker: 'pośrednik ABC',
        isInvoice: false,
        creator: 3
      }
      this.isLoading = true
      this.$store.dispatch('orders/createOrder', testOrder)
        .then(order => {
          this.isLoading = false
          this.$snackbar.open(`Zamówienie #${order.id} zostało dodane.`)
          bus.$emit('order-created')
        })
        .catch((error) => {
          console.log('error', error)
          if (error.response.status === 400) {
            alert(error.response.data.violations[0].message + ': ' + error.response.data.violations[0].propertyPath)
            // console.log('msgs', error.response.data.violations)
          }
        })
    },
    selectTab (tabName) {
      this.$store.dispatch('orders/selectTab', tabName)
    },
    selectViewMode (mode) {
      this.$store.dispatch('orders/selectViewMode', mode)
    },
    search () {
      this.$store.dispatch('orders/search', this.searchText)
    },
    checkIsSearchReseted () {
      if (this.searchText === '') this.$store.dispatch('orders/search', this.searchText)
    },
    ordersTableRowSelect (rows) {
      bus.$emit('orders-table-row-select', rows)
    },
    routesTableRowSelect (rows) {
      bus.$emit('routes-table-row-select', rows)
    }
  },
  mounted () {
    this.selectTab('new')
  },
  created () {
    bus.$on('new-orders-loaded', totalNewOrders => { this.totalNewOrders = totalNewOrders })
  },
  destroyed () {
    bus.$off('tab-change')
  }
}
src/components/views/OrdersView.vue
import moment from 'moment'
import bus from '@/EventBus'
import UserService from '@/services/UserService'

export default {
  data () {
    return {
      users: [], // current loaded
      checkedRows: [],
      perPage: localStorage.getItem('users-table-per-page') || 10,
      perPageOptions: [ 5, 10, 25, 50, 100 ],
      pageNr: 1,
      sortParams: {
        field: 'id',
        order: 'desc'
      },
      total: 0,
      isLoading: false
    }
  },
  methods: {
    changePage (pageNr) {
      this.pageNr = pageNr
      this.loadUsers()
    },
    changePerPage (num) {
      this.perPage = num
      localStorage.setItem('users-table-per-page', this.perPage)
      this.loadUsers()
    },
    changeSortParams (field, order) {
      this.sortParams = { field, order }
      this.loadUsers()
    },
    loadUsers () {
      this.isLoading = true
      UserService.getAll(this.sortParams, this.perPage, this.pageNr)
        .then(res => this.loadUsersSuccess(res.data))
        .catch(err => this.loadUsersFailure(err))
    },
    loadUsersSuccess (data) {
      this.users = []
      this.total = data['hydra:totalItems']
      if (this.total / this.perPage > 1000) {
        this.total = this.perPage * 1000
      }
      this.isLoading = false
      if (this.total === 0) return false
      data['hydra:member'].forEach(item => {
        this.users.push(item)
      })
      this.checkedRows = []
      bus.$emit('users-loaded')
    },
    loadUsersFailure (err) {
      this.users = []
      this.isLoading = false
      throw { error: err }
    }
  },
  filters: {
    date (value, format) {
      return moment(value).format(format)
    },
    humanDate (value) {
      return moment(value).fromNow()
    }
  },
  created () {
    moment.locale('pl')

    this.events = [
      'user-created',
      'user-updated',
      'user-deleted'
    ]
    bus.$on(this.events, () => {
      this.loadUsers()
    })
    bus.$on(['users-loaded'], () => {
      this.checkedRows = []
    })
  },
  mounted () {
    this.loadUsers()
  },
  destroyed () {
    bus.$off(this.events)
  }
}
src/components/tables/UsersTable.vue
import axios from '@/axios'
import { isBoolean } from 'util'

const find = id => {
  const path = `routes?id=${id}`
  return axios.get(path)
}

const getAll = ({ field, order }, perPage = 10, pageNr = 1, filter, searchText) => {
  let filterSegment = ''
  let searchSegment = ''

  if (filter) {
    if (isBoolean(filter.value)) filterSegment += `&${filter.by}[exists]=${filter.value}`
    else if (Array.isArray(filter.value)) filter.value.forEach(value => {
      filterSegment += `&${filter.by}[]=${value}`
    })
    else filterSegment += `&${filter.by}=${filter.value}`
  }

  if (searchText) searchSegment = `&search=${searchText}`

  const path = `routes?order[${field}]=${order}&itemsPerPage=${perPage}&page=${pageNr}${filterSegment}${searchSegment}`
  return axios.get(path)
}

const update = route => {
  const path = `routes/${route.id}`
  // if (route.route) route.route = `/api/routes/${route.route.id}`
  if (route.orders) delete (route.orders)
  return axios.put(path, route)
}

const create = route => {
  const path = `routes`
  return axios.post(path, route)
}

const destroy = route => {
  const path = `routes/${route.id}`
  return axios.delete(path)
}

export default {
  find,
  getAll,
  update,
  create,
  destroy
}
src/services/RouteService.js
import bus from '@/EventBus'
import OrderService from '@/services/OrderService'
import RouteService from '@/services/RouteService'
import LocationService from '@/services/LocationService'

// initial state
const state = {
  currentOrder: null,
  searchText: '',
  currentTab: null,
  viewMode: 'orders'
}

// actions
const actions = {
  selectTab ({ commit }, tabName) {
    commit('SET_CURRENT_TAB', tabName)
    bus.$emit('tab-change')
  },
  selectViewMode ({ commit }, viewMode) {
    commit('SET_CURRENT_VIEW_MODE', viewMode)
    bus.$emit('change-view-mode')
  },
  search ({ commit }, text) {
    commit('SET_SEARCH_TEXT', text)
    bus.$emit('search')
  },
  setCurrentOrder ({ commit }, order) {
    if (order && !order.logs) {
      return OrderService.find(order.id).then(res => {
        commit('SET_CURRENT_ORDER', res.data['hydra:member'][0])
      })
    } else commit('SET_CURRENT_ORDER', order)
    commit('SET_CURRENT_ORDER', order)
  },
  updateOrder ({ commit, state }, order) {
    commit('SET_CURRENT_ORDER', order)
    return OrderService.update(state.currentOrder)
      .then(res => {
        commit('SET_CURRENT_ORDER', res.data)
        return state.currentOrder
      })
  },
  archiveOrder ({ commit, state }, order) {
    commit('SET_CURRENT_ORDER', order)
    commit('SET_ORDER_STATUS', 'archived')
    return OrderService.update(state.currentOrder)
      .then(res => {
        commit('SET_CURRENT_ORDER', res.data)
        return state.currentOrder
      })
  },
  createOrder ({ commit, state }, { order, user }) {
    commit('SET_CURRENT_ORDER', order)
    commit('SET_NEW_ORDER_PARAMS', user)
    return OrderService.create(state.currentOrder)
      .then(res => {
        commit('SET_CURRENT_ORDER', res.data)
        return state.currentOrder
      })
  },
  addToExisitingRoute ({ commit, state }, { order, route }) {
    commit('SET_CURRENT_ORDER', order)
    commit('ADD_ROUTE_TO_ORDER', route)
    commit('SET_ORDER_STATUS', route.status)
    return OrderService.update(state.currentOrder)
      .then(res => {
        commit('SET_CURRENT_ORDER', res.data)
        return state.currentOrder
      })
  },
  removeFromRoute ({ commit, state }, order) {
    commit('SET_CURRENT_ORDER', order)
    commit('REMOVE_ROUTE_FROM_ORDER')
    commit('SET_ORDER_STATUS', 'new')
    return OrderService.update(state.currentOrder)
      .then(res => {
        commit('SET_CURRENT_ORDER', res.data)
        return state.currentOrder
      })
  },
  addToNewRoute ({ commit, state }, { order, routeName }) {
    commit('SET_CURRENT_ORDER', order)
    return RouteService.create({ name: routeName, status: 'planned' })
      .then(res => {
        commit('ADD_ROUTE_TO_ORDER', res.data)
        commit('SET_ORDER_STATUS', res.data.status)
        return OrderService.update(state.currentOrder)
          .then(res => {
            commit('SET_CURRENT_ORDER', res.data)
            return state.currentOrder
          })
      })
    
  },
  deleteOrder ({ commit, state }, order) {
    commit('SET_CURRENT_ORDER', order)
    return OrderService.destroy(state.currentOrder)
      .then(() => {
        commit('SET_CURRENT_ORDER', null)
        return order
      })
  },
  findLocation () {
    return LocationService.find(state.currentOrder).then(locations => locations.data)  
  },
  updateOrderLocation ({ commit }, location) {
    commit('SET_ORDER_LOCATION', location)
    return OrderService.update(state.currentOrder)
      .then(res => {
        commit('SET_CURRENT_ORDER', res.data)
        return state.currentOrder
      })
  },
  clearLocation ({ commit }) {
    commit('CLEAR_ORDER_LOCATION')
    return OrderService.update(state.currentOrder)
      .then(res => {
        commit('SET_CURRENT_ORDER', res.data)
        return state.currentOrder
      })
  }
}

// mutations
const mutations = {
  SET_CURRENT_TAB (state, tabName) {
    state.currentTab = tabName
  },
  SET_CURRENT_VIEW_MODE (state, mode) {
    state.viewMode = mode
  },
  SET_SEARCH_TEXT (state, text) {
    state.searchText = text
  },
  SET_CURRENT_ORDER (state, order) {
    state.currentOrder = Object.assign({}, order) // remove reference
  },
  SET_NEW_ORDER_PARAMS (state, user) {
    state.currentOrder.isInvoice = false
    state.currentOrder.status = 'new'
    state.currentOrder.creator = user
  },
  ADD_ROUTE_TO_ORDER (state, route) {
    state.currentOrder.route = route
  },
  REMOVE_ROUTE_FROM_ORDER (state) {
    state.currentOrder.route = null
  },
  SET_ORDER_STATUS (state, status) {
    state.currentOrder.status = status
  },
  SET_ORDER_LOCATION (state, location) {
    state.currentOrder.lat = location.lat
    state.currentOrder.lon = location.lon
  },
  CLEAR_ORDER_LOCATION (state) {
    state.currentOrder.lat = null
    state.currentOrder.lon = null
  }
}

export default {
  namespaced: true,
  state,
  actions,
  mutations
}
src/store/modules/orders.js
<template>
  <form @submit.prevent="save">
    <b-loading :active="isLoading"></b-loading>
    <div class="modal-card">
      <header class="modal-card-head">
        <p class="modal-card-title">Dodawanie zamówienia</p>
        <button type="button" class="delete" aria-label="close" @click="$parent.$emit('close')"></button>
      </header>
      <section class="modal-card-body">
        <div class="group">
          <div class="field field--client-name is-horizontal">
            <div class="field-label">
              Nazwa klienta:
            </div>
            <editable
              :isEditMode="true"
              :value="order.clientName"
              required
              placeholder="Imię Nazwisko / Nazwa firmy"
              type="text"
              @input="order.clientName = $event"/>
          </div>
          <div class="field field--client-phone is-horizontal">
            <div class="field-label">
              Telefon:
            </div>
            <editable
              :isEditMode="true"
              :value="order.phone"
              required
              placeholder="600-123-123"
              type="text"
              @input="order.phone = $event"/>
          </div>
          <div class="field field--client-postcode is-horizontal">
            <div class="field-label">
              Kod pocztowy:
            </div>
            <editable
              :isEditMode="true"
              :value="order.postalCode"
              required
              placeholder="00-000"
              type="text"
              @input="order.postalCode = $event"/>
          </div>
          <div class="field field--client-post is-horizontal">
            <div class="field-label">
              Poczta:
            </div>
            <editable
              :isEditMode="true"
              :value="order.post"
              required
              placeholder="Poczta"
              type="text"
              @input="order.post = $event"/>
          </div>
          <hr>
          <div class="field field--client-broker is-horizontal">
            <div class="field-label">
              Pośrednik:
            </div>
            <editable
              :isEditMode="true"
              :value="order.broker"
              required
              placeholder="Nazwa pośrednika"
              type="text"
              @input="order.broker = $event"/>
          </div>
        </div>
      </section>
      <footer class="modal-card-foot">
        <button type="submit" class="button is-success button--add"><i class="mdi">save</i> Dodaj</button>
        <button class="button is-white button--cancel" type="button" @click="$parent.$emit('close')">Anuluj</button>
      </footer>
    </div>
  </form>
</template>

<script>
import bus from '@/EventBus'
import { mapGetters } from 'vuex'
import EditableField from '@/components/EditableField'

export default {
  name: 'NewOrderModal',
  components: {
    editable: EditableField
  },
  data () {
    return {
      order: {},
      isLoading: false
    }
  },
  computed: {
    ...mapGetters('auth', [
      'user'
    ])
  },
  methods: {
    save () {
      this.isLoading = true
      this.$store.dispatch('orders/createOrder', { order: this.order, user: this.user.id })
        .then(order => {
          this.isLoading = false
          this.$store.dispatch('modals/hideNewOrderModal')
          this.$store.dispatch('modals/showViewOrderModal', order)
          this.$snackbar.open(`Zamówienie #${order.id} zostało dodane.`)
          bus.$emit('order-created')
        })
    }
  }
}
</script>
src/components/modals/NewOrderModal.vue
colorBar

Screens & Screencasts

desktop view
desktop view
tablet view
tablet view
phone view
phone view