Application
This is commercial project and is not available on GitHub.
Code fragments and screens are available.
[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.
[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.
10/2018
/* 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');
});
});
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')
}
}
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)
}
}
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
}
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
}
<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>