first commit
This commit is contained in:
4
ui/src/app.config.json
Normal file
4
ui/src/app.config.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Hammond",
|
||||
"description": "Like Clarkson, but better"
|
||||
}
|
||||
35
ui/src/app.vue
Normal file
35
ui/src/app.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script>
|
||||
import appConfig from '@src/app.config'
|
||||
import store from '@state/store'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
// All subcomponent titles will be injected into this template.
|
||||
titleTemplate(title) {
|
||||
title = typeof title === 'function' ? title(this.$store) : title
|
||||
return title ? `${title} | ${appConfig.title}` : appConfig.title
|
||||
},
|
||||
},
|
||||
created() {
|
||||
window.addEventListener('resize', this.onResize)
|
||||
},
|
||||
destroyed() {
|
||||
window.removeEventListener('resize', this.onResize)
|
||||
},
|
||||
methods: {
|
||||
onResize(e) {
|
||||
store.dispatch('utils/checkSize').then((isMobile) => {})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<!--
|
||||
Even when routes use the same component, treat them
|
||||
as distinct and create the component again.
|
||||
-->
|
||||
<RouterView :key="$route.fullPath" />
|
||||
</div>
|
||||
</template>
|
||||
BIN
ui/src/assets/images/logo.png
Normal file
BIN
ui/src/assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
13
ui/src/components/_base-button.unit.js
Normal file
13
ui/src/components/_base-button.unit.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import BaseButton from './_base-button.vue'
|
||||
|
||||
describe('@components/_base-button', () => {
|
||||
it('renders its content', () => {
|
||||
const slotContent = '<span>foo</span>'
|
||||
const { element } = shallowMount(BaseButton, {
|
||||
slots: {
|
||||
default: slotContent,
|
||||
},
|
||||
})
|
||||
expect(element.innerHTML).toContain(slotContent)
|
||||
})
|
||||
})
|
||||
5
ui/src/components/_base-button.vue
Normal file
5
ui/src/components/_base-button.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<b-button :class="$style.button" v-on="$listeners">
|
||||
<slot />
|
||||
</b-button>
|
||||
</template>
|
||||
30
ui/src/components/_base-icon.unit.js
Normal file
30
ui/src/components/_base-icon.unit.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import BaseIcon from './_base-icon.vue'
|
||||
|
||||
describe('@components/_base-icon', () => {
|
||||
it('renders a font-awesome icon', () => {
|
||||
const { element } = mount(BaseIcon, {
|
||||
propsData: {
|
||||
name: 'sync',
|
||||
},
|
||||
})
|
||||
|
||||
expect(element.tagName).toEqual('svg')
|
||||
expect(element.classList).toContain('svg-inline--fa', 'fa-sync', 'fa-w-16')
|
||||
})
|
||||
|
||||
it('renders a custom icon', () => {
|
||||
const { element } = shallowMount(BaseIcon, {
|
||||
...createComponentMocks({
|
||||
style: {
|
||||
iconCustomSomeIcon: 'generated-class-name',
|
||||
},
|
||||
}),
|
||||
propsData: {
|
||||
source: 'custom',
|
||||
name: 'some-icon',
|
||||
},
|
||||
})
|
||||
|
||||
expect(element.className).toEqual('generated-class-name')
|
||||
})
|
||||
})
|
||||
47
ui/src/components/_base-icon.vue
Normal file
47
ui/src/components/_base-icon.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { library as fontAwesomeIconLibrary } from '@fortawesome/fontawesome-svg-core'
|
||||
import camelCase from 'lodash/camelCase'
|
||||
|
||||
// https://fontawesome.com/icons
|
||||
fontAwesomeIconLibrary.add(
|
||||
require('@fortawesome/free-solid-svg-icons/faSync').definition,
|
||||
require('@fortawesome/free-solid-svg-icons/faUser').definition
|
||||
)
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FontAwesomeIcon,
|
||||
},
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
source: {
|
||||
type: String,
|
||||
default: 'font-awesome',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
// Gets a CSS module class, e.g. iconCustomLogo
|
||||
customIconClass() {
|
||||
return this.$style[camelCase('icon-custom-' + this.name)]
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FontAwesomeIcon
|
||||
v-if="source === 'font-awesome'"
|
||||
v-bind="$attrs"
|
||||
:icon="name"
|
||||
/>
|
||||
<span
|
||||
v-else-if="source === 'custom'"
|
||||
v-bind="$attrs"
|
||||
:class="customIconClass"
|
||||
/>
|
||||
</template>
|
||||
45
ui/src/components/_base-input-text.unit.js
Normal file
45
ui/src/components/_base-input-text.unit.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import BaseInputText from './_base-input-text.vue'
|
||||
|
||||
describe('@components/_base-input-text', () => {
|
||||
it('works with v-model', () => {
|
||||
const wrapper = mount(BaseInputText, { propsData: { value: 'aaa' } })
|
||||
const inputWrapper = wrapper.find('input')
|
||||
const inputEl = inputWrapper.element
|
||||
|
||||
// Has the correct starting value
|
||||
expect(inputEl.value).toEqual('aaa')
|
||||
|
||||
// Emits an update event with the correct value when edited
|
||||
inputEl.value = 'bbb'
|
||||
inputWrapper.trigger('input')
|
||||
expect(wrapper.emitted().update).toEqual([['bbb']])
|
||||
|
||||
// Sets the input to the correct value when props change
|
||||
wrapper.setValue('ccc')
|
||||
expect(inputEl.value).toEqual('ccc')
|
||||
})
|
||||
|
||||
it('allows a type of "password"', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mount(BaseInputText, {
|
||||
propsData: { value: 'aaa', type: 'password' },
|
||||
})
|
||||
expect(consoleError).not.toBeCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('does NOT allow a type of "checkbox"', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mount(BaseInputText, {
|
||||
propsData: { value: 'aaa', type: 'checkbox' },
|
||||
})
|
||||
expect(consoleError.mock.calls[0][0]).toContain(
|
||||
'custom validator check failed for prop "type"'
|
||||
)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
48
ui/src/components/_base-input-text.vue
Normal file
48
ui/src/components/_base-input-text.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script>
|
||||
export default {
|
||||
// Disable automatic attribute inheritance, so that $attrs are
|
||||
// passed to the <input>, even if it's not the root element.
|
||||
// https://vuejs.org/v2/guide/components-props.html#Disabling-Attribute-Inheritance
|
||||
inheritAttrs: false,
|
||||
// Change the v-model event name to `update` to avoid changing
|
||||
// the behavior of the native `input` event.
|
||||
// https://vuejs.org/v2/guide/components-custom-events.html#Customizing-Component-v-model
|
||||
model: {
|
||||
event: 'update',
|
||||
},
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
// Only allow types that essentially just render text boxes.
|
||||
validator(value) {
|
||||
return [
|
||||
'email',
|
||||
'number',
|
||||
'password',
|
||||
'search',
|
||||
'tel',
|
||||
'text',
|
||||
'url',
|
||||
].includes(value)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<b-input
|
||||
:type="type"
|
||||
:class="$style.input"
|
||||
v-bind="
|
||||
$attrs
|
||||
// https://vuejs.org/v2/guide/components-props.html#Disabling-Attribute-Inheritance
|
||||
"
|
||||
@input="$emit('update', $event.target.value)"
|
||||
v-on="
|
||||
$listeners
|
||||
// https://vuejs.org/v2/guide/components-custom-events.html#Binding-Native-Events-to-Components
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
139
ui/src/components/_base-link.unit.js
Normal file
139
ui/src/components/_base-link.unit.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import BaseLink from './_base-link.vue'
|
||||
|
||||
const mountBaseLink = (options = {}) => {
|
||||
return mount(BaseLink, {
|
||||
stubs: {
|
||||
RouterLink: {
|
||||
functional: true,
|
||||
render(h, { slots, data }) {
|
||||
return <a data-router-link='true'>{slots().default}</a>
|
||||
},
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
default: 'hello',
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
describe('@components/_base-link', () => {
|
||||
const originalConsoleWarn = global.console.warn
|
||||
let warning
|
||||
beforeEach(() => {
|
||||
warning = undefined
|
||||
global.console.warn = jest.fn((text) => {
|
||||
warning = text
|
||||
})
|
||||
})
|
||||
afterAll(() => {
|
||||
global.console.warn = originalConsoleWarn
|
||||
})
|
||||
|
||||
it('exports a valid component', () => {
|
||||
expect(BaseLink).toBeAComponent()
|
||||
})
|
||||
|
||||
it('warns about missing required props', () => {
|
||||
mountBaseLink()
|
||||
expect(console.warn).toHaveBeenCalledTimes(1)
|
||||
expect(warning).toMatch(/Invalid <BaseLink> props/)
|
||||
})
|
||||
|
||||
it('warns about an invalid href', () => {
|
||||
mountBaseLink({
|
||||
propsData: {
|
||||
href: '/some/local/path',
|
||||
},
|
||||
})
|
||||
expect(console.warn).toHaveBeenCalledTimes(1)
|
||||
expect(warning).toMatch(/Invalid <BaseLink> href/)
|
||||
})
|
||||
|
||||
it('warns about an insecure href', () => {
|
||||
mountBaseLink({
|
||||
propsData: {
|
||||
href: 'http://google.com',
|
||||
},
|
||||
})
|
||||
expect(console.warn).toHaveBeenCalledTimes(1)
|
||||
expect(warning).toMatch(/Insecure <BaseLink> href/)
|
||||
})
|
||||
|
||||
it('renders an anchor element when passed an `href` prop', () => {
|
||||
const externalUrl = 'https://google.com/'
|
||||
const { element } = mountBaseLink({
|
||||
propsData: {
|
||||
href: externalUrl,
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.tagName).toEqual('A')
|
||||
expect(element.href).toEqual(externalUrl)
|
||||
expect(element.target).toEqual('_blank')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
})
|
||||
|
||||
it('renders a RouterLink when passed a `name` prop', () => {
|
||||
const routeName = 'home'
|
||||
const { element, vm } = mountBaseLink({
|
||||
propsData: {
|
||||
name: routeName,
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.dataset.routerLink).toEqual('true')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
expect(vm.routerLinkTo).toEqual({ name: routeName, params: {} })
|
||||
})
|
||||
|
||||
it('renders a RouterLink when passed `name` and `params` props', () => {
|
||||
const routeName = 'home'
|
||||
const routeParams = { foo: 'bar' }
|
||||
const { element, vm } = mountBaseLink({
|
||||
propsData: {
|
||||
name: routeName,
|
||||
params: routeParams,
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.dataset.routerLink).toEqual('true')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
expect(vm.routerLinkTo).toEqual({
|
||||
name: routeName,
|
||||
params: routeParams,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a RouterLink when passed a `to` prop', () => {
|
||||
const routeName = 'home'
|
||||
const { element, vm } = mountBaseLink({
|
||||
propsData: {
|
||||
to: {
|
||||
name: routeName,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.dataset.routerLink).toEqual('true')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
expect(vm.routerLinkTo).toEqual({ name: routeName, params: {} })
|
||||
})
|
||||
|
||||
it('renders a RouterLink when passed a `to` prop with `params`', () => {
|
||||
const routeName = 'home'
|
||||
const routeParams = { foo: 'bar' }
|
||||
const { element, vm } = mountBaseLink({
|
||||
propsData: {
|
||||
to: {
|
||||
name: routeName,
|
||||
params: routeParams,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.dataset.routerLink).toEqual('true')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
expect(vm.routerLinkTo).toEqual({ name: routeName, params: routeParams })
|
||||
})
|
||||
})
|
||||
81
ui/src/components/_base-link.vue
Normal file
81
ui/src/components/_base-link.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script>
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
allowInsecure: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
to: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
params: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
routerLinkTo({ name, params }) {
|
||||
return {
|
||||
name,
|
||||
params,
|
||||
...(this.to || {}),
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.validateProps()
|
||||
},
|
||||
methods: {
|
||||
// Perform more complex prop validations than is possible
|
||||
// inside individual validator functions for each prop.
|
||||
validateProps() {
|
||||
if (process.env.NODE_ENV === 'production') return
|
||||
|
||||
if (this.href) {
|
||||
// Check for non-external URL in href.
|
||||
if (!/^\w+:/.test(this.href)) {
|
||||
return console.warn(
|
||||
`Invalid <BaseLink> href: ${this.href}.\nIf you're trying to link to a local URL, provide at least a name or to`
|
||||
)
|
||||
}
|
||||
// Check for insecure URL in href.
|
||||
if (!this.allowInsecure && !/^(https|mailto|tel):/.test(this.href)) {
|
||||
return console.warn(
|
||||
`Insecure <BaseLink> href: ${this.href}.\nWhen linking to external sites, always prefer https URLs. If this site does not offer SSL, explicitly add the allow-insecure attribute on <BaseLink>.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Check for insufficient props.
|
||||
if (!this.name && !this.to) {
|
||||
return console.warn(
|
||||
`Invalid <BaseLink> props:\n\n${JSON.stringify(
|
||||
this.$props,
|
||||
null,
|
||||
2
|
||||
)}\n\nEither a \`name\` or \`to\` is required for internal links, or an \`href\` for external links.`
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a v-if="href" :href="href" target="_blank" v-bind="$attrs">
|
||||
<slot />
|
||||
</a>
|
||||
<RouterLink v-else :to="routerLinkTo" v-bind="$attrs">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
</template>
|
||||
36
ui/src/components/_globals.js
Normal file
36
ui/src/components/_globals.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// Globally register all base components for convenience, because they
|
||||
// will be used very frequently. Components are registered using the
|
||||
// PascalCased version of their file name.
|
||||
|
||||
import Vue from 'vue'
|
||||
|
||||
// https://webpack.js.org/guides/dependency-management/#require-context
|
||||
const requireComponent = require.context(
|
||||
// Look for files in the current directory
|
||||
'.',
|
||||
// Do not look in subdirectories
|
||||
false,
|
||||
// Only include "_base-" prefixed .vue files
|
||||
/_base-[\w-]+\.vue$/
|
||||
)
|
||||
|
||||
// For each matching file name...
|
||||
requireComponent.keys().forEach((fileName) => {
|
||||
// Get the component config
|
||||
const componentConfig = requireComponent(fileName)
|
||||
// Get the PascalCase version of the component name
|
||||
const componentName = fileName
|
||||
// Remove the "./_" from the beginning
|
||||
.replace(/^\.\/_/, '')
|
||||
// Remove the file extension from the end
|
||||
.replace(/\.\w+$/, '')
|
||||
// Split up kebabs
|
||||
.split('-')
|
||||
// Upper case
|
||||
.map((kebab) => kebab.charAt(0).toUpperCase() + kebab.slice(1))
|
||||
// Concatenated
|
||||
.join('')
|
||||
|
||||
// Globally register the component
|
||||
Vue.component(componentName, componentConfig.default || componentConfig)
|
||||
})
|
||||
122
ui/src/components/createQuickEntry.vue
Normal file
122
ui/src/components/createQuickEntry.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script>
|
||||
import { mapState } from 'vuex'
|
||||
import store from '@state/store'
|
||||
import axios from 'axios'
|
||||
export default {
|
||||
data: function() {
|
||||
return {
|
||||
file: null,
|
||||
tryingToCreate: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('utils', ['isMobile']),
|
||||
uploadButtonLabel() {
|
||||
if (this.isMobile) {
|
||||
if (this.file == null) {
|
||||
return 'Upload/Click Photo'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
} else {
|
||||
if (this.file == null) {
|
||||
return 'Upload Photo'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
createQuickEntry() {
|
||||
if (this.file == null) {
|
||||
return
|
||||
}
|
||||
this.tryingToCreate = true
|
||||
const formData = new FormData()
|
||||
formData.append('file', this.file, this.file.name)
|
||||
axios
|
||||
.post(`/api/quickEntries`, formData)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Quick Entry Created Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.file = null
|
||||
store.dispatch('vehicles/fetchQuickEntries', { force: true }).then((data) => {
|
||||
this.quickEntries = data
|
||||
})
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section box">
|
||||
<div class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<p class="title">Quick Entry</p>
|
||||
<p class="subtitle"
|
||||
>Take a pic of the invoice or the fuel pump display to make an entry later.</p
|
||||
></div
|
||||
>
|
||||
<div class="column is-one-third is-flex is-align-content-center">
|
||||
<form @submit.prevent="createQuickEntry">
|
||||
<div class="columns"
|
||||
><div class="column">
|
||||
<b-field class="file is-primary" :class="{ 'has-name': !!file }">
|
||||
<b-upload v-model="file" class="file-label" accept="image/*">
|
||||
<span class="file-cta">
|
||||
<b-icon class="file-icon" icon="upload"></b-icon>
|
||||
<span class="file-label">{{ uploadButtonLabel }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="file"
|
||||
class="file-name"
|
||||
:class="isMobile ? 'file-name-mobile' : 'file-name-desktop'"
|
||||
>
|
||||
{{ file.name }}
|
||||
</span>
|
||||
</b-upload>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column">
|
||||
<b-button
|
||||
tag="input"
|
||||
native-type="submit"
|
||||
:disabled="tryingToCreate"
|
||||
type="is-primary"
|
||||
value="Upload File"
|
||||
class="control"
|
||||
>
|
||||
Upload File
|
||||
</b-button>
|
||||
</div></div
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.file-name-desktop {
|
||||
max-width: 9em;
|
||||
}
|
||||
.file-name-mobile {
|
||||
max-width: 12em;
|
||||
}
|
||||
</style>
|
||||
57
ui/src/components/nav-bar-routes.unit.js
Normal file
57
ui/src/components/nav-bar-routes.unit.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import NavBarRoutes from './nav-bar-routes.vue'
|
||||
|
||||
const mountRoutes = (options) => {
|
||||
return mount(
|
||||
{
|
||||
render(h) {
|
||||
return (
|
||||
<ul>
|
||||
<NavBarRoutes {...{ props: options.propsData }} />
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
stubs: {
|
||||
BaseLink: {
|
||||
functional: true,
|
||||
render(h, { slots }) {
|
||||
return <a>{slots().default}</a>
|
||||
},
|
||||
},
|
||||
...options.stubs,
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('@components/nav-bar-routes', () => {
|
||||
it('correctly renders routes with text titles', () => {
|
||||
const { element } = mountRoutes({
|
||||
propsData: {
|
||||
routes: [
|
||||
{
|
||||
name: 'aaa',
|
||||
title: 'bbb',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(element.textContent).toEqual('bbb')
|
||||
})
|
||||
|
||||
it('correctly renders routes with function titles', () => {
|
||||
const { element } = mountRoutes({
|
||||
propsData: {
|
||||
routes: [
|
||||
{
|
||||
name: 'aaa',
|
||||
title: () => 'bbb',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(element.textContent).toEqual('bbb')
|
||||
})
|
||||
})
|
||||
62
ui/src/components/nav-bar-routes.vue
Normal file
62
ui/src/components/nav-bar-routes.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
// Allows stubbing BaseLink in unit tests
|
||||
const BaseLink = 'BaseLink'
|
||||
|
||||
export default {
|
||||
// Functional components are stateless, meaning they can't
|
||||
// have data, computed properties, etc and they have no
|
||||
// `this` context.
|
||||
functional: true,
|
||||
props: {
|
||||
routes: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
// Render functions are an alternative to templates
|
||||
render(h, { props, $style = {} }) {
|
||||
function getRouteTitle(route) {
|
||||
return typeof route.title === 'function' ? route.title() : route.title
|
||||
}
|
||||
|
||||
function getRouteBadge(route) {
|
||||
if (!route.badge) {
|
||||
return false
|
||||
}
|
||||
return typeof route.badge === 'function' ? route.badge() : route.badge
|
||||
}
|
||||
|
||||
// Functional components are the only components allowed
|
||||
// to return an array of children, rather than a single
|
||||
// root node.
|
||||
return props.routes.map((route) => {
|
||||
if (getRouteBadge(route) > 0) {
|
||||
return (
|
||||
<BaseLink
|
||||
tag='b-navbar-item'
|
||||
key={route.name}
|
||||
to={route}
|
||||
exact-active-class={$style.active}
|
||||
>
|
||||
<a>{getRouteTitle(route)}</a>
|
||||
<b-tag rounded type='is-danger is-light'>
|
||||
{getRouteBadge(route)}
|
||||
</b-tag>
|
||||
</BaseLink>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<BaseLink
|
||||
tag='b-navbar-item'
|
||||
key={route.name}
|
||||
to={route}
|
||||
exact-active-class={$style.active}
|
||||
>
|
||||
<a>{getRouteTitle(route)}</a>
|
||||
</BaseLink>
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
</script>
|
||||
28
ui/src/components/nav-bar.unit.js
Normal file
28
ui/src/components/nav-bar.unit.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import NavBar from './nav-bar.vue'
|
||||
|
||||
describe('@components/nav-bar', () => {
|
||||
it(`displays the user's name in the profile link`, () => {
|
||||
const { vm } = shallowMount(
|
||||
NavBar,
|
||||
createComponentMocks({
|
||||
store: {
|
||||
auth: {
|
||||
state: {
|
||||
currentUser: {
|
||||
name: 'My Name',
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
loggedIn: () => true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const profileRoute = vm.loggedInNavRoutes.find(
|
||||
(route) => route.name === 'profile'
|
||||
)
|
||||
expect(profileRoute.title()).toEqual('Logged in as My Name')
|
||||
})
|
||||
})
|
||||
81
ui/src/components/nav-bar.vue
Normal file
81
ui/src/components/nav-bar.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script>
|
||||
import { authComputed } from '@state/helpers'
|
||||
import { mapGetters } from 'vuex'
|
||||
import NavBarRoutes from './nav-bar-routes.vue'
|
||||
|
||||
export default {
|
||||
components: { NavBarRoutes },
|
||||
data() {
|
||||
return {
|
||||
persistentNavRoutes: [
|
||||
{
|
||||
name: 'home',
|
||||
title: 'Home',
|
||||
},
|
||||
],
|
||||
loggedInNavRoutes: [
|
||||
{
|
||||
name: 'quickEntries',
|
||||
title: () => 'Quick Entries',
|
||||
badge: () => this.unprocessedQuickEntries.length,
|
||||
},
|
||||
// {
|
||||
// name: 'profile',
|
||||
// title: () => 'Logged in as ' + this.currentUser.name,
|
||||
// },
|
||||
{
|
||||
name: 'settings',
|
||||
title: 'Settings',
|
||||
},
|
||||
{
|
||||
name: 'logout',
|
||||
title: 'Log out',
|
||||
},
|
||||
],
|
||||
loggedOutNavRoutes: [
|
||||
{
|
||||
name: 'login',
|
||||
title: 'Log in',
|
||||
},
|
||||
],
|
||||
adminNavRoutes: [
|
||||
{
|
||||
name: 'site-settings',
|
||||
title: 'Site Settings',
|
||||
},
|
||||
{
|
||||
name: 'users',
|
||||
title: 'Users',
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...authComputed,
|
||||
...mapGetters('vehicles', ['unprocessedQuickEntries']),
|
||||
isAdmin() {
|
||||
return this.loggedIn && this.currentUser.role === 'ADMIN'
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<b-navbar class="" spaced>
|
||||
<template v-slot:brand>
|
||||
<b-navbar-item tag="router-link" :to="{ path: '/' }">
|
||||
<h1 class="title" style="font-family:Arial Black">Hammond</h1>
|
||||
</b-navbar-item>
|
||||
</template>
|
||||
<template v-slot:end>
|
||||
<NavBarRoutes :routes="persistentNavRoutes" />
|
||||
<NavBarRoutes v-if="loggedIn" :routes="loggedInNavRoutes" />
|
||||
<NavBarRoutes v-else :routes="loggedOutNavRoutes" />
|
||||
<b-navbar-dropdown v-if="loggedIn && isAdmin" label="Admin">
|
||||
<NavBarRoutes :routes="adminNavRoutes" />
|
||||
</b-navbar-dropdown>
|
||||
</template>
|
||||
</b-navbar>
|
||||
</div>
|
||||
</template>
|
||||
68
ui/src/components/quickEntryDisplay.vue
Normal file
68
ui/src/components/quickEntryDisplay.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script>
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
import { parseAndFormatDateTime } from '@utils/format-date'
|
||||
|
||||
export default {
|
||||
model: {
|
||||
prop: 'quickEntry',
|
||||
event: 'change',
|
||||
},
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
quickEntry: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('utils', ['isMobile']),
|
||||
...mapGetters('vehicles', ['unprocessedQuickEntries', 'processedQuickEntries']),
|
||||
},
|
||||
methods: {
|
||||
parseAndFormatDateTime(date) {
|
||||
return parseAndFormatDateTime(date)
|
||||
},
|
||||
showQuickEntry(entry) {
|
||||
const h = this.$createElement
|
||||
const vnode = h('p', { class: 'image' }, [
|
||||
h('img', {
|
||||
attrs: {
|
||||
src: `/api/attachments/${entry.attachmentId}/file?access_token=${this.user.token}`,
|
||||
},
|
||||
}),
|
||||
])
|
||||
this.$buefy.modal.open({
|
||||
content: [vnode],
|
||||
})
|
||||
this.$emit('change', entry)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="level">
|
||||
<b-field class="level-right">
|
||||
<b-select
|
||||
v-if="unprocessedQuickEntries.length"
|
||||
v-model="quickEntry"
|
||||
placeholder="Refer quick entry"
|
||||
expanded
|
||||
@input="showQuickEntry($event)"
|
||||
>
|
||||
<option v-for="option in unprocessedQuickEntries" :key="option.id" :value="option">
|
||||
Taken: {{ parseAndFormatDateTime(option.createdAt) }}
|
||||
</option>
|
||||
</b-select>
|
||||
<p class="control">
|
||||
<b-button v-if="quickEntry" type="is-primary" @click="showQuickEntry(quickEntry)"
|
||||
>Show</b-button
|
||||
></p
|
||||
>
|
||||
</b-field>
|
||||
</div></template
|
||||
>
|
||||
68
ui/src/components/shareVehicle.vue
Normal file
68
ui/src/components/shareVehicle.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script>
|
||||
import store from '@state/store'
|
||||
import { sortBy } from 'lodash'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
models: [],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
store
|
||||
.dispatch('users/users')
|
||||
.then((allUsers) => {
|
||||
store.dispatch('vehicles/fetchUsersByVehicleId', { vehicleId: this.vehicle.id }).then((data) => {
|
||||
const arr = []
|
||||
for (const user of allUsers) {
|
||||
const toAdd = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
isShared: false,
|
||||
isOwner: false,
|
||||
}
|
||||
for (const mappedUser of data) {
|
||||
if (mappedUser.userId === user.id) {
|
||||
toAdd.isShared = true
|
||||
toAdd.isOwner = mappedUser.isOwner
|
||||
}
|
||||
}
|
||||
arr.push(toAdd)
|
||||
}
|
||||
this.models = sortBy(arr, ['isOwner', 'name'])
|
||||
})
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
},
|
||||
methods: {
|
||||
changeShareStatus(model) {
|
||||
var url = `/api/vehicles/${this.vehicle.id}/users/${model.id}`
|
||||
if (model.isShared) {
|
||||
axios.post(url, {}).then((data) => {})
|
||||
} else {
|
||||
axios.delete(url).then((data) => {})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="box">
|
||||
<h1 class="subtitle">Share {{ vehicle.nickname }}</h1>
|
||||
<section>
|
||||
<b-field v-for="model in models" :key="model.id">
|
||||
<b-switch v-model="model.isShared" :disabled="model.isOwner" @input="changeShareStatus(model)">
|
||||
{{ model.name }}
|
||||
</b-switch>
|
||||
</b-field>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
149
ui/src/components/statsWidget.vue
Normal file
149
ui/src/components/statsWidget.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script>
|
||||
import { addDays, addMonths } from 'date-fns'
|
||||
import currencyFormtter from 'currency-formatter'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import axios from 'axios'
|
||||
export default {
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
dateRangeOptions: [
|
||||
{ label: 'This week', value: 'this_week' },
|
||||
{ label: 'This month', value: 'this_month' },
|
||||
{ label: 'Past 30 days', value: 'past_30_days' },
|
||||
{ label: 'Past 3 months', value: 'past_3_months' },
|
||||
{ label: 'This year', value: 'this_year' },
|
||||
{ label: 'All Time', value: 'all_time' },
|
||||
],
|
||||
dateRangeOption: 'past_30_days',
|
||||
stats: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('utils', ['isMobile']),
|
||||
summaryObject() {
|
||||
if (this.stats == null) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
label: 'Total Expenditure',
|
||||
value: this.formatCurrency(0, this.user.currency),
|
||||
},
|
||||
{
|
||||
label: 'Fillup Costs',
|
||||
value: `${this.formatCurrency(0, this.user.currency)} (0)`,
|
||||
},
|
||||
{
|
||||
label: 'Other Expenses',
|
||||
value: `${this.formatCurrency(0, this.user.currency)} (0)`,
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
return this.stats.map((x) => {
|
||||
return [
|
||||
{
|
||||
label: 'Total Expenditure',
|
||||
value: this.formatCurrency(x.expenditureTotal, x.currency),
|
||||
},
|
||||
{
|
||||
label: 'Fillup Costs',
|
||||
value: `${this.formatCurrency(x.expenditureFillups, x.currency)} (${x.countFillups})`,
|
||||
},
|
||||
{
|
||||
label: 'Other Expenses',
|
||||
value: `${this.formatCurrency(x.expenditureExpenses, x.currency)} (${x.countExpenses})`,
|
||||
},
|
||||
]
|
||||
})
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
dateRangeOption(newOne, old) {
|
||||
if (newOne === old) {
|
||||
return
|
||||
}
|
||||
this.getStats()
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getStats()
|
||||
},
|
||||
methods: {
|
||||
formatCurrency(number, currencyCode) {
|
||||
if (!currencyCode) {
|
||||
currencyCode = this.me.currency
|
||||
}
|
||||
return currencyFormtter.format(number, { code: currencyCode })
|
||||
},
|
||||
getStats() {
|
||||
axios
|
||||
.get('/api/me/stats', {
|
||||
params: {
|
||||
start: this.getStartDate(),
|
||||
end: new Date(),
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
this.stats = data.data
|
||||
})
|
||||
},
|
||||
getStartDate() {
|
||||
const toDate = new Date()
|
||||
switch (this.dateRangeOption) {
|
||||
case 'this_week':
|
||||
var currentDayOfWeek = toDate.getDay()
|
||||
var toSubtract = 0
|
||||
if (currentDayOfWeek === 0) {
|
||||
toSubtract = -6
|
||||
}
|
||||
if (currentDayOfWeek > 1) {
|
||||
toSubtract = -1 * (currentDayOfWeek - 1)
|
||||
}
|
||||
return addDays(toDate, toSubtract)
|
||||
case 'this_month':
|
||||
return new Date(toDate.getFullYear(), toDate.getMonth(), 1)
|
||||
case 'past_30_days':
|
||||
return addDays(toDate, -30)
|
||||
case 'past_3_months':
|
||||
return addMonths(toDate, -3)
|
||||
case 'this_year':
|
||||
return new Date(toDate.getFullYear(), 1, 1)
|
||||
case 'all_time':
|
||||
return new Date(1969, 4, 20)
|
||||
default:
|
||||
return new Date(1969, 4, 20)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns">
|
||||
<div class="column" :class="isMobile ? 'has-text-centered' : ''"> <h1 class="title">Stats</h1></div>
|
||||
<div class="column">
|
||||
<b-select v-model="dateRangeOption" class="is-pulled-right is-medium">
|
||||
<option v-for="option in dateRangeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</b-select></div
|
||||
>
|
||||
</div>
|
||||
<div v-for="(currencyLevel, index) in summaryObject" :key="index" class="level box">
|
||||
<div v-for="item in currencyLevel" :key="item.label" class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">{{ item.label }}</p>
|
||||
<p class="title is-4">{{ item.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
16
ui/src/design/_colors.scss
Normal file
16
ui/src/design/_colors.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
// CONTENT
|
||||
$color-body-bg: #f9f7f5;
|
||||
$color-text: #444;
|
||||
$color-heading-text: #35495e;
|
||||
|
||||
// LINKS
|
||||
$color-link-text: #39a275;
|
||||
$color-link-text-active: $color-text;
|
||||
|
||||
// INPUTS
|
||||
$color-input-border: lighten($color-heading-text, 50%);
|
||||
|
||||
// BUTTONS
|
||||
$color-button-bg: $color-link-text;
|
||||
$color-button-disabled-bg: darken(desaturate($color-button-bg, 20%), 10%);
|
||||
$color-button-text: white;
|
||||
1
ui/src/design/_durations.scss
Normal file
1
ui/src/design/_durations.scss
Normal file
@@ -0,0 +1 @@
|
||||
$duration-animation-base: 300ms;
|
||||
21
ui/src/design/_fonts.scss
Normal file
21
ui/src/design/_fonts.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
$system-default-font-family: -apple-system, 'BlinkMacSystemFont', 'Segoe UI',
|
||||
'Helvetica', 'Arial', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol';
|
||||
|
||||
$heading-font-family: $system-default-font-family;
|
||||
$heading-font-weight: 600;
|
||||
|
||||
$content-font-family: $system-default-font-family;
|
||||
$content-font-weight: 400;
|
||||
|
||||
%font-heading {
|
||||
font-family: $heading-font-family;
|
||||
font-weight: $heading-font-weight;
|
||||
color: $color-heading-text;
|
||||
}
|
||||
|
||||
%font-content {
|
||||
font-family: $content-font-family;
|
||||
font-weight: $content-font-weight;
|
||||
color: $color-text;
|
||||
}
|
||||
6
ui/src/design/_layers.scss
Normal file
6
ui/src/design/_layers.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
$layer-negative-z-index: -1;
|
||||
$layer-page-z-index: 1;
|
||||
$layer-dropdown-z-index: 2;
|
||||
$layer-modal-z-index: 3;
|
||||
$layer-popover-z-index: 4;
|
||||
$layer-tooltip-z-index: 5;
|
||||
19
ui/src/design/_sizes.scss
Normal file
19
ui/src/design/_sizes.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
// GRID
|
||||
$size-grid-padding: 1.3rem;
|
||||
|
||||
// CONTENT
|
||||
$size-content-width-max: 50rem;
|
||||
$size-content-width-min: 25rem;
|
||||
|
||||
// INPUTS
|
||||
$size-input-padding-vertical: 0.75em;
|
||||
$size-input-padding-horizontal: 1em;
|
||||
$size-input-padding: $size-input-padding-vertical $size-input-padding-horizontal;
|
||||
$size-input-border: 1px;
|
||||
$size-input-border-radius: (1em + $size-input-padding-vertical * 2) / 10;
|
||||
|
||||
// BUTTONS
|
||||
$size-button-padding-vertical: $size-grid-padding / 2;
|
||||
$size-button-padding-horizontal: $size-grid-padding / 1.5;
|
||||
$size-button-padding: $size-button-padding-vertical
|
||||
$size-button-padding-horizontal;
|
||||
416
ui/src/design/_typography.scss
Normal file
416
ui/src/design/_typography.scss
Normal file
@@ -0,0 +1,416 @@
|
||||
// Interpolate v1.0
|
||||
|
||||
// This mixin generates CSS for interpolation of length properties.
|
||||
// It has 5 required values, including the target property, initial
|
||||
// screen size, initial value, final screen size and final value.
|
||||
|
||||
// It has two optional values which include an easing property,
|
||||
// which is a string, representing a CSS animation-timing-function
|
||||
// and finally a number of bending-points, that determines how many
|
||||
// interpolations steps are applied along the easing function.
|
||||
|
||||
// Author: Mike Riethmuller - @MikeRiethmuller
|
||||
// More information: http://codepen.io/MadeByMike/pen/a2249946658b139b7625b2a58cf03a65?editors=0100
|
||||
|
||||
///
|
||||
/// @param {String} $property - The CSS property to interpolate
|
||||
/// @param {Unit} $min-screen - A CSS length unit
|
||||
/// @param {Unit} $min-value - A CSS length unit
|
||||
/// @param {Unit} $max-screen - Value to be parsed
|
||||
/// @param {Unit} $max-value - Value to be parsed
|
||||
/// @param {String} $easing - Value to be parsed
|
||||
/// @param {Integer} $bending-points - Value to be parsed
|
||||
///
|
||||
|
||||
// Examples on line 258
|
||||
|
||||
// Issues:
|
||||
|
||||
// - kubic-bezier requires whitespace
|
||||
// - kubic-bezier cannot parse negative values
|
||||
|
||||
// stylelint-disable scss/dollar-variable-pattern
|
||||
@mixin typography-interpolate(
|
||||
$property,
|
||||
$min-screen,
|
||||
$min-value,
|
||||
$max-screen,
|
||||
$max-value,
|
||||
$easing: 'linear',
|
||||
$bending-points: 2
|
||||
) {
|
||||
// Default Easing 'Linear'
|
||||
$p0: 0;
|
||||
$p1: 0;
|
||||
$p2: 1;
|
||||
$p3: 1;
|
||||
|
||||
// Parse Cubic Bezier string
|
||||
@if (str-slice($easing, 1, 12) == 'kubic-bezier') {
|
||||
// Get the values between the brackets
|
||||
// TODO: Deal with different whitespace
|
||||
$i: str-index($easing, ')'); // Get index of closing bracket
|
||||
$values: str-slice($easing, 14, $i - 1); // Extract values between brackts
|
||||
$list: typography-explode($values, ', '); // Split the values into a list
|
||||
|
||||
@debug ($list);
|
||||
|
||||
// Cast values to numebrs
|
||||
$p0: typography-number(nth($list, 1));
|
||||
$p1: typography-number(nth($list, 2));
|
||||
$p2: typography-number(nth($list, 3));
|
||||
$p3: typography-number(nth($list, 4));
|
||||
}
|
||||
|
||||
@if ($easing == 'ease') {
|
||||
$p0: 0.25;
|
||||
$p1: 1;
|
||||
$p2: 0.25;
|
||||
$p3: 1;
|
||||
}
|
||||
|
||||
@if ($easing == 'ease-in-out') {
|
||||
$p0: 0.42;
|
||||
$p1: 0;
|
||||
$p2: 0.58;
|
||||
$p3: 1;
|
||||
}
|
||||
|
||||
@if ($easing == 'ease-in') {
|
||||
$p0: 0.42;
|
||||
$p1: 0;
|
||||
$p2: 1;
|
||||
$p3: 1;
|
||||
}
|
||||
|
||||
@if ($easing == 'ease-out') {
|
||||
$p0: 0;
|
||||
$p1: 0;
|
||||
$p2: 0.58;
|
||||
$p3: 1;
|
||||
}
|
||||
|
||||
#{$property}: $min-value;
|
||||
|
||||
@if ($easing == 'linear' or $bending-points < 1) {
|
||||
@media screen and (min-width: $min-screen) {
|
||||
#{$property}: typography-calc-interpolation(
|
||||
$min-screen,
|
||||
$min-value,
|
||||
$max-screen,
|
||||
$max-value
|
||||
);
|
||||
}
|
||||
} @else {
|
||||
// Loop through bending points
|
||||
$t: 1 / ($bending-points + 1);
|
||||
$i: 1;
|
||||
$prev-screen: $min-screen;
|
||||
$prev-value: $min-value;
|
||||
|
||||
@while $t * $i <= 1 {
|
||||
$bending-point: $t * $i;
|
||||
$value: typography-cubic-bezier($p0, $p1, $p2, $p3, $bending-point);
|
||||
$screen-int: typography-lerp($min-screen, $max-screen, $bending-point);
|
||||
$value-int: typography-lerp($min-value, $max-value, $value);
|
||||
|
||||
@media screen and (min-width: $prev-screen) {
|
||||
#{$property}: typography-calc-interpolation(
|
||||
$prev-screen,
|
||||
$prev-value,
|
||||
$screen-int,
|
||||
$value-int
|
||||
);
|
||||
}
|
||||
|
||||
$prev-screen: $screen-int;
|
||||
$prev-value: $value-int;
|
||||
$i: $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: $max-screen) {
|
||||
#{$property}: $max-value;
|
||||
}
|
||||
}
|
||||
|
||||
// Requires several helper functions including: pow, calc-interpolation, kubic-bezier, number and explode
|
||||
|
||||
// Math functions:
|
||||
|
||||
// Linear interpolations in CSS as a Sass function
|
||||
// Author: Mike Riethmuller | https://madebymike.com.au/writing/precise-control-responsive-typography/ I
|
||||
|
||||
@function typography-calc-interpolation(
|
||||
$min-screen,
|
||||
$min-value,
|
||||
$max-screen,
|
||||
$max-value
|
||||
) {
|
||||
$a: ($max-value - $min-value) / ($max-screen - $min-screen);
|
||||
$b: $min-value - $a * $min-screen;
|
||||
|
||||
$sign: '+';
|
||||
|
||||
@if ($b < 0) {
|
||||
$sign: '-';
|
||||
$b: abs($b);
|
||||
}
|
||||
|
||||
@return calc(#{$a * 100}vw #{$sign} #{$b});
|
||||
}
|
||||
|
||||
// This is a crude Sass port webkits cubic-bezier function. Looking to simplify this if you can help.
|
||||
@function typography-solve-bexier-x($p1x, $p1y, $p2x, $p2y, $x) {
|
||||
$cx: 3 * $p1x;
|
||||
$bx: 3 * ($p2x - $p1x) - $cx;
|
||||
$ax: 1 - $cx - $bx;
|
||||
|
||||
$t0: 0;
|
||||
$t1: 1;
|
||||
$t2: $x;
|
||||
$x2: 0;
|
||||
$res: 1000;
|
||||
|
||||
@while ($t0 < $t1 or $break) {
|
||||
$x2: (($ax * $t2 + $bx) * $t2 + $cx) * $t2;
|
||||
|
||||
@if (abs($x2 - $x) < $res) {
|
||||
@return $t2;
|
||||
}
|
||||
|
||||
@if ($x > $x2) {
|
||||
$t0: $t2;
|
||||
} @else {
|
||||
$t1: $t2;
|
||||
}
|
||||
$t2: ($t1 - $t0) * 0.5 + $t0;
|
||||
}
|
||||
|
||||
@return $t2;
|
||||
}
|
||||
|
||||
@function typography-cubic-bezier($p1x, $p1y, $p2x, $p2y, $x) {
|
||||
$cy: 3 * $p1y;
|
||||
$by: 3 * ($p2y - $p1y) - $cy;
|
||||
$ay: 1 - $cy - $by;
|
||||
$t: typography-solve-bexier-x($p1x, $p1y, $p2x, $p2y, $x);
|
||||
|
||||
@return (($ay * $t + $by) * $t + $cy) * $t;
|
||||
}
|
||||
|
||||
// A stright up lerp
|
||||
// Credit: Ancient Greeks possibly Hipparchus of Rhodes
|
||||
@function typography-lerp($a, $b, $t) {
|
||||
@return $a + ($b - $a) * $t;
|
||||
}
|
||||
|
||||
// String functions:
|
||||
|
||||
// Cast string to number
|
||||
// Credit: Hugo Giraudel | https://www.sassmeister.com/gist/9fa19d254864f33d4a80
|
||||
@function typography-number($value) {
|
||||
@if type-of($value) == 'number' {
|
||||
@return $value;
|
||||
} @else if type-of($value) != 'string' {
|
||||
$_: log('Value for `to-number` should be a number or a string.');
|
||||
}
|
||||
|
||||
$result: 0;
|
||||
$digits: 0;
|
||||
$minus: str-slice($value, 1, 1) == '-';
|
||||
$numbers: (
|
||||
'0': 0,
|
||||
'1': 1,
|
||||
'2': 2,
|
||||
'3': 3,
|
||||
'4': 4,
|
||||
'5': 5,
|
||||
'6': 6,
|
||||
'7': 7,
|
||||
'8': 8,
|
||||
'9': 9,
|
||||
);
|
||||
|
||||
@for $i from if($minus, 2, 1) through str-length($value) {
|
||||
$character: str-slice($value, $i, $i);
|
||||
|
||||
@if not(index(map-keys($numbers), $character) or $character == '.') {
|
||||
@return to-length(if($minus, -$result, $result), str-slice($value, $i));
|
||||
}
|
||||
|
||||
@if $character == '.' {
|
||||
$digits: 1;
|
||||
} @else if $digits == 0 {
|
||||
$result: $result * 10 + map-get($numbers, $character);
|
||||
} @else {
|
||||
$digits: $digits * 10;
|
||||
$result: $result + map-get($numbers, $character) / $digits;
|
||||
}
|
||||
}
|
||||
|
||||
@return if($minus, -$result, $result);
|
||||
}
|
||||
|
||||
// Explode a string by a delimiter
|
||||
// Credit: https://gist.github.com/danielpchen/3677421ea15dcf2579ff
|
||||
@function typography-explode($string, $delimiter) {
|
||||
$result: ();
|
||||
|
||||
@if $delimiter == '' {
|
||||
@for $i from 1 through str-length($string) {
|
||||
$result: append($result, str-slice($string, $i, $i));
|
||||
}
|
||||
|
||||
@return $result;
|
||||
}
|
||||
$exploding: true;
|
||||
|
||||
@while $exploding {
|
||||
$d-index: str-index($string, $delimiter);
|
||||
|
||||
@if $d-index {
|
||||
@if $d-index > 1 {
|
||||
$result: append($result, str-slice($string, 1, $d-index - 1));
|
||||
$string: str-slice($string, $d-index + str-length($delimiter));
|
||||
} @else if $d-index == 1 {
|
||||
$string: str-slice($string, 1, $d-index + str-length($delimiter));
|
||||
} @else {
|
||||
$result: append($result, $string);
|
||||
$exploding: false;
|
||||
}
|
||||
} @else {
|
||||
$result: append($result, $string);
|
||||
$exploding: false;
|
||||
}
|
||||
}
|
||||
|
||||
@return $result;
|
||||
}
|
||||
|
||||
// Using vertical rhythm methods from https://scotch.io/tutorials/aesthetic-sass-3-typography-and-vertical-rhythm
|
||||
// Using perfect 8/9 for low contrast and perfect fifth 2/3 for high
|
||||
$typography-type-scale: (
|
||||
-1: 0.889rem,
|
||||
0: 1rem,
|
||||
1: 1.125rem,
|
||||
2: 1.266rem,
|
||||
3: 1.424rem
|
||||
);
|
||||
|
||||
@function typography-type-scale($level) {
|
||||
@if map-has-key($typography-type-scale, $level) {
|
||||
@return map-get($typography-type-scale, $level);
|
||||
}
|
||||
|
||||
@warn 'Unknown `#{$level}` in $typography-type-scale.';
|
||||
|
||||
@return null;
|
||||
}
|
||||
|
||||
$typography-type-scale-contrast: (
|
||||
-1: 1rem,
|
||||
0: 1.3333rem,
|
||||
1: 1.777rem,
|
||||
2: 2.369rem,
|
||||
3: 3.157rem
|
||||
);
|
||||
|
||||
@function typography-type-scale-contrast($level) {
|
||||
@if map-has-key($typography-type-scale-contrast, $level) {
|
||||
@return map-get($typography-type-scale-contrast, $level);
|
||||
}
|
||||
|
||||
@warn 'Unknown `#{$level}` in $typography-type-scale-contrast.';
|
||||
|
||||
@return null;
|
||||
}
|
||||
|
||||
$typography-base-font-size: 1rem;
|
||||
$typography-base-line-height: $typography-base-font-size * 1.25;
|
||||
|
||||
$typography-line-heights: (
|
||||
-1: $typography-base-line-height,
|
||||
0: $typography-base-line-height,
|
||||
1: $typography-base-line-height * 1.5,
|
||||
2: $typography-base-line-height * 1.5,
|
||||
3: $typography-base-line-height * 1.5
|
||||
);
|
||||
|
||||
@function typography-line-height($level) {
|
||||
@if map-has-key($typography-line-heights, $level) {
|
||||
@return map-get($typography-line-heights, $level);
|
||||
}
|
||||
|
||||
@warn 'Unknown `#{$level}` in $line-height.';
|
||||
|
||||
@return null;
|
||||
}
|
||||
|
||||
$typography-base-line-height-contrast: $typography-base-line-height;
|
||||
|
||||
$typography-line-heights-contrast: (
|
||||
-1: $typography-base-line-height-contrast,
|
||||
0: $typography-base-line-height-contrast * 2,
|
||||
1: $typography-base-line-height-contrast * 2,
|
||||
2: $typography-base-line-height-contrast * 2,
|
||||
3: $typography-base-line-height * 3
|
||||
);
|
||||
|
||||
@function typography-line-height-contrast($level) {
|
||||
@if map-has-key($typography-line-heights-contrast, $level) {
|
||||
@return map-get($typography-line-heights-contrast, $level);
|
||||
}
|
||||
|
||||
@warn 'Unknown `#{$level}` in $typography-line-heights-contrast.';
|
||||
|
||||
@return null;
|
||||
}
|
||||
|
||||
// Mixing these two sets of mixins ala Rachel:
|
||||
@mixin typography-got-rhythm($level: 0) {
|
||||
@include typography-interpolate(
|
||||
'font-size',
|
||||
$size-content-width-min,
|
||||
typography-type-scale($level),
|
||||
$size-content-width-max,
|
||||
typography-type-scale-contrast($level)
|
||||
);
|
||||
@include typography-interpolate(
|
||||
'line-height',
|
||||
$size-content-width-min,
|
||||
typography-line-height($level),
|
||||
$size-content-width-max,
|
||||
typography-line-height-contrast($level)
|
||||
);
|
||||
}
|
||||
|
||||
%typography-xxlarge {
|
||||
@include typography-got-rhythm(3);
|
||||
|
||||
@extend %font-heading;
|
||||
}
|
||||
|
||||
%typography-xlarge {
|
||||
@include typography-got-rhythm(2);
|
||||
|
||||
@extend %font-heading;
|
||||
}
|
||||
|
||||
%typography-large {
|
||||
@include typography-got-rhythm(1);
|
||||
|
||||
@extend %font-heading;
|
||||
}
|
||||
|
||||
%typography-medium {
|
||||
@include typography-got-rhythm(0);
|
||||
|
||||
@extend %font-content;
|
||||
}
|
||||
|
||||
%typography-small {
|
||||
@include typography-got-rhythm(-1);
|
||||
|
||||
@extend %font-content;
|
||||
}
|
||||
22
ui/src/design/index.scss
Normal file
22
ui/src/design/index.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
@import 'colors';
|
||||
@import 'durations';
|
||||
@import 'fonts';
|
||||
@import 'layers';
|
||||
@import 'sizes';
|
||||
@import 'typography';
|
||||
|
||||
:export {
|
||||
// Any values that need to be accessible from JavaScript
|
||||
// outside of a Vue component can be defined here, prefixed
|
||||
// with `global-` to avoid conflicts with classes. For
|
||||
// example:
|
||||
//
|
||||
// global-grid-padding: $size-grid-padding;
|
||||
//
|
||||
// Then in a JavaScript file, you can import this object
|
||||
// as you would normally with:
|
||||
//
|
||||
// import design from '@design'
|
||||
//
|
||||
// console.log(design['global-grid-padding'])
|
||||
}
|
||||
76
ui/src/main.js
Normal file
76
ui/src/main.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import Vue from 'vue'
|
||||
import Buefy from 'buefy'
|
||||
import router from '@router'
|
||||
import store from '@state/store'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faCheck,
|
||||
faTimes,
|
||||
faArrowUp,
|
||||
faAngleLeft,
|
||||
faAngleRight,
|
||||
faCalendar,
|
||||
faEdit,
|
||||
faAngleDown,
|
||||
faAngleUp,
|
||||
faUpload,
|
||||
faExclamationCircle,
|
||||
faDownload,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
import App from './app.vue'
|
||||
|
||||
// Globally register all `_base`-prefixed components
|
||||
import '@components/_globals'
|
||||
|
||||
import 'buefy/dist/buefy.css'
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
Vue.component('vue-fontawesome', FontAwesomeIcon)
|
||||
library.add(
|
||||
faCheck,
|
||||
faTimes,
|
||||
faArrowUp,
|
||||
faAngleLeft,
|
||||
faAngleRight,
|
||||
faCalendar,
|
||||
faEdit,
|
||||
faAngleDown,
|
||||
faAngleUp,
|
||||
faUpload,
|
||||
faExclamationCircle,
|
||||
faDownload,
|
||||
faEye,
|
||||
faEyeSlash
|
||||
)
|
||||
Vue.use(Buefy, {
|
||||
defaultIconComponent: 'vue-fontawesome',
|
||||
defaultIconPack: 'fas',
|
||||
})
|
||||
|
||||
// Don't warn about using the dev version of Vue in development.
|
||||
Vue.config.productionTip = process.env.NODE_ENV === 'production'
|
||||
|
||||
// If running inside Cypress...
|
||||
if (process.env.VUE_APP_TEST === 'e2e') {
|
||||
// Ensure tests fail when Vue emits an error.
|
||||
Vue.config.errorHandler = window.Cypress.cy.onUncaughtException
|
||||
}
|
||||
|
||||
const app = new Vue({
|
||||
router,
|
||||
store,
|
||||
|
||||
render: (h) => h(App),
|
||||
}).$mount('#app')
|
||||
|
||||
// If running e2e tests...
|
||||
if (process.env.VUE_APP_TEST === 'e2e') {
|
||||
// Attach the app to the window, which can be useful
|
||||
// for manually setting state in Cypress commands
|
||||
// such as `cy.logIn()`.
|
||||
window.__app__ = app
|
||||
}
|
||||
135
ui/src/router/index.js
Normal file
135
ui/src/router/index.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import Vue from 'vue'
|
||||
import VueRouter from 'vue-router'
|
||||
// https://github.com/declandewet/vue-meta
|
||||
import VueMeta from 'vue-meta'
|
||||
// Adds a loading bar at the top during page loads.
|
||||
import NProgress from 'nprogress/nprogress'
|
||||
import store from '@state/store'
|
||||
import routes from './routes'
|
||||
|
||||
Vue.use(VueRouter)
|
||||
Vue.use(VueMeta, {
|
||||
// The component option name that vue-meta looks for meta info on.
|
||||
keyName: 'page',
|
||||
})
|
||||
|
||||
const router = new VueRouter({
|
||||
routes,
|
||||
// Use the HTML5 history API (i.e. normal-looking routes)
|
||||
// instead of routes with hashes (e.g. example.com/#/about).
|
||||
// This may require some server configuration in production:
|
||||
// https://router.vuejs.org/en/essentials/history-mode.html#example-server-configurations
|
||||
mode: 'history',
|
||||
// Simulate native-like scroll behavior when navigating to a new
|
||||
// route and using back/forward buttons.
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Before each route evaluates...
|
||||
router.beforeEach((routeTo, routeFrom, next) => {
|
||||
// If this isn't an initial page load...
|
||||
if (routeFrom.name !== null) {
|
||||
// Start the route progress bar.
|
||||
NProgress.start()
|
||||
}
|
||||
|
||||
// Check if auth is required on this route
|
||||
// (including nested routes).
|
||||
const authRequired = routeTo.matched.some((route) => route.meta.authRequired)
|
||||
|
||||
// If auth isn't required for the route, just continue.
|
||||
if (!authRequired) return next()
|
||||
|
||||
// If auth is required and the user is logged in...
|
||||
if (store.getters['auth/loggedIn']) {
|
||||
// Validate the local user token...
|
||||
return store.dispatch('auth/validate').then((validUser) => {
|
||||
// Then continue if the token still represents a valid user,
|
||||
// otherwise redirect to login.
|
||||
|
||||
if (!validUser) {
|
||||
redirectToLogin()
|
||||
}
|
||||
const rolesRequired = routeTo.matched.some((route) => route.meta.roles)
|
||||
|
||||
if (!rolesRequired) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const roles = routeTo.matched.find((route) => route.meta.roles).meta.roles
|
||||
|
||||
roles.some((x) => x === validUser.role) ? next() : redirectToHome()
|
||||
})
|
||||
}
|
||||
|
||||
// If auth is required and the user is NOT currently logged in,
|
||||
// redirect to login.
|
||||
redirectToLogin()
|
||||
|
||||
function redirectToLogin() {
|
||||
// Pass the original route to the login component
|
||||
next({ name: 'login', query: { redirectFrom: routeTo.fullPath } })
|
||||
}
|
||||
function redirectToHome() {
|
||||
// Pass the original route to the login component
|
||||
next({ name: 'home', query: { redirectFrom: routeTo.fullPath } })
|
||||
}
|
||||
})
|
||||
|
||||
router.beforeResolve(async (routeTo, routeFrom, next) => {
|
||||
// Create a `beforeResolve` hook, which fires whenever
|
||||
// `beforeRouteEnter` and `beforeRouteUpdate` would. This
|
||||
// allows us to ensure data is fetched even when params change,
|
||||
// but the resolved route does not. We put it in `meta` to
|
||||
// indicate that it's a hook we created, rather than part of
|
||||
// Vue Router (yet?).
|
||||
try {
|
||||
// For each matched route...
|
||||
for (const route of routeTo.matched) {
|
||||
await new Promise((resolve, reject) => {
|
||||
// If a `beforeResolve` hook is defined, call it with
|
||||
// the same arguments as the `beforeEnter` hook.
|
||||
if (route.meta && route.meta.beforeResolve) {
|
||||
route.meta.beforeResolve(routeTo, routeFrom, (...args) => {
|
||||
// If the user chose to redirect...
|
||||
if (args.length) {
|
||||
// If redirecting to the same route we're coming from...
|
||||
if (routeFrom.name === args[0].name) {
|
||||
// Complete the animation of the route progress bar.
|
||||
NProgress.done()
|
||||
}
|
||||
// Complete the redirect.
|
||||
next(...args)
|
||||
reject(new Error('Redirected'))
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Otherwise, continue resolving the route.
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
// If a `beforeResolve` hook chose to redirect, just return.
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
|
||||
// If we reach this point, continue resolving the route.
|
||||
next()
|
||||
})
|
||||
|
||||
// When each route is finished evaluating...
|
||||
router.afterEach((routeTo, routeFrom) => {
|
||||
// Complete the animation of the route progress bar.
|
||||
NProgress.done()
|
||||
})
|
||||
|
||||
export default router
|
||||
13
ui/src/router/layouts/main.unit.js
Normal file
13
ui/src/router/layouts/main.unit.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import MainLayout from './main.vue'
|
||||
|
||||
describe('@layouts/main.vue', () => {
|
||||
it('renders its content', () => {
|
||||
const slotContent = '<p>Hello!</p>'
|
||||
const { element } = shallowMount(MainLayout, {
|
||||
slots: {
|
||||
default: slotContent,
|
||||
},
|
||||
})
|
||||
expect(element.innerHTML).toContain(slotContent)
|
||||
})
|
||||
})
|
||||
14
ui/src/router/layouts/main.vue
Normal file
14
ui/src/router/layouts/main.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
import NavBar from '@components/nav-bar.vue'
|
||||
|
||||
export default {
|
||||
components: { NavBar },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template
|
||||
><div>
|
||||
<NavBar />
|
||||
<div class="section container"> <slot /> </div
|
||||
></div>
|
||||
</template>
|
||||
466
ui/src/router/routes.js
Normal file
466
ui/src/router/routes.js
Normal file
@@ -0,0 +1,466 @@
|
||||
import store from '@state/store'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
meta: {
|
||||
authRequired: true,
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('vehicles/fetchVehicles')
|
||||
.then((vehicles) => {
|
||||
// Add the user to `meta.tmp`, so that it can
|
||||
// be provided as a prop.
|
||||
routeTo.meta.tmp.vehicles = vehicles
|
||||
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('users/me')
|
||||
.then((me) => {
|
||||
next()
|
||||
})
|
||||
// Continue to the route.
|
||||
})
|
||||
.catch((ex) => {
|
||||
// If a user with the provided username could not be
|
||||
// found, redirect to the 404 page.
|
||||
console.log(ex)
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
component: () => lazyLoadView(import('@views/home.vue')),
|
||||
props: (route) => ({ user: store.state.auth.currentUser || {} }),
|
||||
},
|
||||
{
|
||||
path: '/initialize',
|
||||
name: 'initialize',
|
||||
component: () => lazyLoadView(import('@views/initialize.vue')),
|
||||
meta: {
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
// If the user is already logged in
|
||||
|
||||
if (store.getters['auth/isInitialized']) {
|
||||
next({ name: 'login' })
|
||||
}
|
||||
|
||||
if (store.getters['auth/loggedIn']) {
|
||||
// Redirect to the home page instead
|
||||
next({ name: 'home' })
|
||||
} else {
|
||||
// Continue to the login page
|
||||
next()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => lazyLoadView(import('@views/login.vue')),
|
||||
meta: {
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
// If the user is already logged in
|
||||
|
||||
if (!store.getters['auth/isInitialized']) {
|
||||
// Redirect to the home page instead
|
||||
console.log('App is not initialized')
|
||||
next({ name: 'initialize' })
|
||||
}
|
||||
|
||||
if (store.getters['auth/loggedIn']) {
|
||||
// Redirect to the home page instead
|
||||
next({ name: 'home' })
|
||||
} else {
|
||||
// Continue to the login page
|
||||
next()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => lazyLoadView(import('@views/profile.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
props: (route) => ({ user: store.state.auth.currentUser || {} }),
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
name: 'site-settings',
|
||||
component: () => lazyLoadView(import('@/src/router/views/siteSettings.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
props: (route) => ({
|
||||
user: store.state.auth.currentUser || {},
|
||||
settings: store.state.utils.settings || {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'users',
|
||||
component: () => lazyLoadView(import('@/src/router/views/users.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
props: (route) => ({
|
||||
user: store.state.auth.currentUser || {},
|
||||
settings: store.state.utils.settings || {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => lazyLoadView(import('@/src/router/views/settings.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
},
|
||||
props: (route) => ({
|
||||
user: store.state.auth.currentUser || {},
|
||||
me: store.state.users.me || {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: '/profile/:username',
|
||||
name: 'username-profile',
|
||||
component: () => lazyLoadView(import('@views/profile.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
// HACK: In order to share data between the `beforeResolve` hook
|
||||
// and the `props` function, we must create an object for temporary
|
||||
// data only used during route resolution.
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('users/fetchUser', { username: routeTo.params.username })
|
||||
.then((user) => {
|
||||
// Add the user to `meta.tmp`, so that it can
|
||||
// be provided as a prop.
|
||||
routeTo.meta.tmp.user = user
|
||||
// Continue to the route.
|
||||
next()
|
||||
})
|
||||
.catch(() => {
|
||||
// If a user with the provided username could not be
|
||||
// found, redirect to the 404 page.
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ user: route.meta.tmp.user }),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/vehicles/create',
|
||||
name: 'vehicle-create',
|
||||
component: () => lazyLoadView(import('@views/createVehicle.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
// HACK: In order to share data between the `beforeResolve` hook
|
||||
// and the `props` function, we must create an object for temporary
|
||||
// data only used during route resolution.
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/vehicles/:vehicleId',
|
||||
name: 'vehicle-detail',
|
||||
component: () => lazyLoadView(import('@views/vehicle.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
// HACK: In order to share data between the `beforeResolve` hook
|
||||
// and the `props` function, we must create an object for temporary
|
||||
// data only used during route resolution.
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('vehicles/fetchVehicleById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
})
|
||||
.then((vehicle) => {
|
||||
// Add the user to `meta.tmp`, so that it can
|
||||
// be provided as a prop.
|
||||
routeTo.meta.tmp.vehicle = vehicle
|
||||
// Continue to the route.
|
||||
next()
|
||||
})
|
||||
.catch(() => {
|
||||
// If a user with the provided username could not be
|
||||
// found, redirect to the 404 page.
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ vehicle: route.meta.tmp.vehicle }),
|
||||
},
|
||||
{
|
||||
path: '/vehicles/:vehicleId/edit',
|
||||
name: 'vehicle-edit',
|
||||
component: () => lazyLoadView(import('@views/createVehicle.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
// HACK: In order to share data between the `beforeResolve` hook
|
||||
// and the `props` function, we must create an object for temporary
|
||||
// data only used during route resolution.
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('vehicles/fetchVehicleById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
})
|
||||
.then((vehicle) => {
|
||||
// Add the user to `meta.tmp`, so that it can
|
||||
// be provided as a prop.
|
||||
routeTo.meta.tmp.vehicle = vehicle
|
||||
// Continue to the route.
|
||||
next()
|
||||
})
|
||||
.catch(() => {
|
||||
// If a user with the provided username could not be
|
||||
// found, redirect to the 404 page.
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ vehicle: route.meta.tmp.vehicle }),
|
||||
},
|
||||
{
|
||||
path: '/vehicles/:vehicleId/fillup',
|
||||
name: 'vehicle-create-fillup',
|
||||
component: () => lazyLoadView(import('@views/createFillup.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
// HACK: In order to share data between the `beforeResolve` hook
|
||||
// and the `props` function, we must create an object for temporary
|
||||
// data only used during route resolution.
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('vehicles/fetchVehicleById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
})
|
||||
.then((vehicle) => {
|
||||
// Add the user to `meta.tmp`, so that it can
|
||||
// be provided as a prop.
|
||||
routeTo.meta.tmp.vehicle = vehicle
|
||||
// Continue to the route.
|
||||
next()
|
||||
})
|
||||
.catch(() => {
|
||||
// If a user with the provided username could not be
|
||||
// found, redirect to the 404 page.
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ vehicle: route.meta.tmp.vehicle }),
|
||||
},
|
||||
{
|
||||
path: '/vehicles/:vehicleId/fillup/:fillupId/edit',
|
||||
name: 'vehicle-edit-fillup',
|
||||
component: () => lazyLoadView(import('@views/createFillup.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
|
||||
.dispatch('vehicles/fetchVehicleById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
})
|
||||
.then((vehicle) => {
|
||||
routeTo.meta.tmp.vehicle = vehicle
|
||||
store
|
||||
|
||||
.dispatch('vehicles/fetchFillupById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
fillupId: routeTo.params.fillupId,
|
||||
})
|
||||
.then((fillup) => {
|
||||
routeTo.meta.tmp.fillup = fillup
|
||||
|
||||
next()
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ vehicle: route.meta.tmp.vehicle, fillup: route.meta.tmp.fillup }),
|
||||
},
|
||||
{
|
||||
path: '/vehicles/:vehicleId/expense',
|
||||
name: 'vehicle-create-expense',
|
||||
component: () => lazyLoadView(import('@views/createExpense.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
// HACK: In order to share data between the `beforeResolve` hook
|
||||
// and the `props` function, we must create an object for temporary
|
||||
// data only used during route resolution.
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('vehicles/fetchVehicleById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
})
|
||||
.then((vehicle) => {
|
||||
// Add the user to `meta.tmp`, so that it can
|
||||
// be provided as a prop.
|
||||
routeTo.meta.tmp.vehicle = vehicle
|
||||
// Continue to the route.
|
||||
next()
|
||||
})
|
||||
.catch(() => {
|
||||
// If a user with the provided username could not be
|
||||
// found, redirect to the 404 page.
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ vehicle: route.meta.tmp.vehicle }),
|
||||
},
|
||||
{
|
||||
path: '/vehicles/:vehicleId/expense/:expenseId/edit',
|
||||
name: 'vehicle-edit-expense',
|
||||
component: () => lazyLoadView(import('@views/createExpense.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
|
||||
.dispatch('vehicles/fetchVehicleById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
})
|
||||
.then((vehicle) => {
|
||||
routeTo.meta.tmp.vehicle = vehicle
|
||||
store
|
||||
|
||||
.dispatch('vehicles/fetchExpenseById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
expenseId: routeTo.params.expenseId,
|
||||
})
|
||||
.then((expense) => {
|
||||
routeTo.meta.tmp.expense = expense
|
||||
|
||||
next()
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ vehicle: route.meta.tmp.vehicle, expense: route.meta.tmp.expense }),
|
||||
},
|
||||
{
|
||||
path: '/quickEntries',
|
||||
name: 'quickEntries',
|
||||
component: () => lazyLoadView(import('@views/quickEntries.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
},
|
||||
props: (route) => ({ user: store.state.auth.currentUser || {} }),
|
||||
},
|
||||
{
|
||||
path: '/logout',
|
||||
name: 'logout',
|
||||
meta: {
|
||||
authRequired: true,
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store.dispatch('auth/logOut').then((data) => {
|
||||
const authRequiredOnPreviousRoute = routeFrom.matched.some(
|
||||
(route) => route.meta.authRequired
|
||||
)
|
||||
// Navigate back to previous page, or home as a fallback
|
||||
next(authRequiredOnPreviousRoute ? { name: 'login' } : { ...routeFrom })
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: '404',
|
||||
component: require('@views/_404.vue').default,
|
||||
// Allows props to be passed to the 404 page through route
|
||||
// params, such as `resource` to define what wasn't found.
|
||||
props: true,
|
||||
},
|
||||
// Redirect any unmatched routes to the 404 page. This may
|
||||
// require some server configuration to work in production:
|
||||
// https://router.vuejs.org/en/essentials/history-mode.html#example-server-configurations
|
||||
{
|
||||
path: '*',
|
||||
redirect: '404',
|
||||
},
|
||||
]
|
||||
|
||||
// Lazy-loads view components, but with better UX. A loading view
|
||||
// will be used if the component takes a while to load, falling
|
||||
// back to a timeout view in case the page fails to load. You can
|
||||
// use this component to lazy-load a route with:
|
||||
//
|
||||
// component: () => lazyLoadView(import('@views/my-view'))
|
||||
//
|
||||
// NOTE: Components loaded with this strategy DO NOT have access
|
||||
// to in-component guards, such as beforeRouteEnter,
|
||||
// beforeRouteUpdate, and beforeRouteLeave. You must either use
|
||||
// route-level guards instead or lazy-load the component directly:
|
||||
//
|
||||
// component: () => import('@views/my-view')
|
||||
//
|
||||
function lazyLoadView(AsyncView) {
|
||||
const AsyncHandler = () => ({
|
||||
component: AsyncView,
|
||||
// A component to use while the component is loading.
|
||||
loading: require('@views/_loading.vue').default,
|
||||
// Delay before showing the loading component.
|
||||
// Default: 200 (milliseconds).
|
||||
delay: 400,
|
||||
// A fallback component in case the timeout is exceeded
|
||||
// when loading the component.
|
||||
error: require('@views/_timeout.vue').default,
|
||||
// Time before giving up trying to load the component.
|
||||
// Default: Infinity (milliseconds).
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
return Promise.resolve({
|
||||
functional: true,
|
||||
render(h, { data, children }) {
|
||||
// Transparently pass any props or children
|
||||
// to the view component.
|
||||
return h(AsyncHandler, data, children)
|
||||
},
|
||||
})
|
||||
}
|
||||
7
ui/src/router/views/_404.unit.js
Normal file
7
ui/src/router/views/_404.unit.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import View404 from './_404.vue'
|
||||
|
||||
describe('@views/404', () => {
|
||||
it('is a valid view', () => {
|
||||
expect(View404).toBeAViewComponent()
|
||||
})
|
||||
})
|
||||
35
ui/src/router/views/_404.vue
Normal file
35
ui/src/router/views/_404.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: '404',
|
||||
meta: [{ name: 'description', content: '404' }],
|
||||
},
|
||||
components: { Layout },
|
||||
props: {
|
||||
resource: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<h1 :class="$style.title">
|
||||
404
|
||||
<template v-if="resource">
|
||||
{{ resource }}
|
||||
</template>
|
||||
Not Found
|
||||
</h1>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.title {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
7
ui/src/router/views/_loading.unit.js
Normal file
7
ui/src/router/views/_loading.unit.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import Loading from './_loading.vue'
|
||||
|
||||
describe('@views/loading', () => {
|
||||
it('is a valid view', () => {
|
||||
expect(Loading).toBeAViewComponent()
|
||||
})
|
||||
})
|
||||
39
ui/src/router/views/_loading.vue
Normal file
39
ui/src/router/views/_loading.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Loading page...',
|
||||
meta: [{ name: 'description', content: 'Loading page...' }],
|
||||
},
|
||||
components: { Layout },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<Transition appear>
|
||||
<BaseIcon :class="$style.loadingIcon" name="sync" spin />
|
||||
</Transition>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@import '@design';
|
||||
|
||||
.loadingIcon {
|
||||
@extend %typography-xxlarge;
|
||||
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
|
||||
// stylelint-disable-next-line selector-class-pattern
|
||||
&:global(.v-enter-active) {
|
||||
transition: opacity 1s;
|
||||
}
|
||||
// stylelint-disable-next-line selector-class-pattern
|
||||
&:global(.v-enter) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
7
ui/src/router/views/_timeout.unit.js
Normal file
7
ui/src/router/views/_timeout.unit.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import Timeout from './_timeout.vue'
|
||||
|
||||
describe('@views/timeout', () => {
|
||||
it('is a valid view', () => {
|
||||
expect(Timeout).toBeAViewComponent()
|
||||
})
|
||||
})
|
||||
46
ui/src/router/views/_timeout.vue
Normal file
46
ui/src/router/views/_timeout.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import Layout from '@layouts/main.vue'
|
||||
import LoadingView from './_loading.vue'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Page timeout',
|
||||
meta: [
|
||||
{ name: 'description', content: 'The page timed out while loading.' },
|
||||
],
|
||||
},
|
||||
components: { Layout, LoadingView },
|
||||
data() {
|
||||
return {
|
||||
offlineConfirmed: false,
|
||||
}
|
||||
},
|
||||
beforeCreate() {
|
||||
axios
|
||||
.head('/api/ping')
|
||||
.then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
.catch(() => {
|
||||
this.offlineConfirmed = true
|
||||
})
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout v-if="offlineConfirmed">
|
||||
<h1 :class="$style.title">
|
||||
The page timed out while loading. Are you sure you're still connected to
|
||||
the Internet?
|
||||
</h1>
|
||||
</Layout>
|
||||
<LoadingView v-else />
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.title {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
243
ui/src/router/views/createExpense.vue
Normal file
243
ui/src/router/views/createExpense.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import QuickEntryDisplay from '@components/quickEntryDisplay.vue'
|
||||
import { mapState } from 'vuex'
|
||||
import axios from 'axios'
|
||||
import store from '@state/store'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Create Expense',
|
||||
},
|
||||
components: { Layout, QuickEntryDisplay },
|
||||
props: {
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
expense: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: function() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tryingToCreate: false,
|
||||
showMore: false,
|
||||
quickEntry: null,
|
||||
myVehicles: [],
|
||||
selectedVehicle: this.vehicle,
|
||||
expenseModel: this.expense,
|
||||
processQuickEntry: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
user() {
|
||||
return store.state.auth.currentUser
|
||||
},
|
||||
...mapState('utils', ['isMobile']),
|
||||
...mapState('users', ['me']),
|
||||
...mapState('vehicles', ['fuelUnitMasters', 'fuelTypeMasters', 'vehicles']),
|
||||
},
|
||||
watch: {
|
||||
quickEntry: function(newOne, old) {
|
||||
if (old == null && newOne !== null) {
|
||||
this.processQuickEntry = true
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.myVehicles = this.vehicles
|
||||
this.selectedVehicle = this.vehicle
|
||||
if (!this.expense.id) {
|
||||
this.expenseModel = this.getEmptyExpense()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getEmptyExpense() {
|
||||
return {
|
||||
vehicleId: this.selectedVehicle.id,
|
||||
amount: null,
|
||||
expenseType: '',
|
||||
odoReading: '',
|
||||
date: new Date(),
|
||||
comments: '',
|
||||
}
|
||||
},
|
||||
|
||||
createExpense() {
|
||||
this.tryingToCreate = true
|
||||
this.expenseModel.vehicleId = this.selectedVehicle.id
|
||||
this.expenseModel.userId = this.me.id
|
||||
|
||||
if (this.expense.id) {
|
||||
axios
|
||||
.put(
|
||||
`/api/vehicles/${this.selectedVehicle.id}/expenses/${this.expense.id}`,
|
||||
this.expenseModel
|
||||
)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Expense Updated Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.expenseModel = this.getEmptyExpense()
|
||||
if (this.processQuickEntry) {
|
||||
store
|
||||
.dispatch('vehicles/setQuickEntryAsProcessed', { id: this.quickEntry.id })
|
||||
.then((data) => {})
|
||||
}
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
} else {
|
||||
axios
|
||||
.post(`/api/vehicles/${this.selectedVehicle.id}/expenses`, this.expenseModel)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Expense Created Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.expenseModel = this.getEmptyExpense()
|
||||
if (this.processQuickEntry) {
|
||||
store
|
||||
.dispatch('vehicles/setQuickEntryAsProcessed', { id: this.quickEntry.id })
|
||||
.then((data) => {
|
||||
this.quickEntry = null
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<h1 class="title">Create Expense</h1>
|
||||
<h1 class="subtitle">
|
||||
{{
|
||||
[
|
||||
selectedVehicle.nickname,
|
||||
selectedVehicle.registration,
|
||||
selectedVehicle.make,
|
||||
selectedVehicle.model,
|
||||
].join(' | ')
|
||||
}}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="column is-one-thirds">
|
||||
<QuickEntryDisplay v-model="quickEntry" :user="user" />
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="createExpense">
|
||||
<b-field label="Select a vehicle">
|
||||
<b-select
|
||||
v-model="selectedVehicle"
|
||||
placeholder="Vehicle"
|
||||
required
|
||||
expanded
|
||||
:disabled="expense.id"
|
||||
>
|
||||
<option v-for="option in myVehicles" :key="option.id" :value="option">
|
||||
{{ option.nickname }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field label="Expense Date">
|
||||
<b-datepicker
|
||||
v-model="expenseModel.date"
|
||||
placeholder="Click to select..."
|
||||
icon="calendar"
|
||||
trap-focus
|
||||
:max-date="new Date()"
|
||||
>
|
||||
</b-datepicker>
|
||||
</b-field>
|
||||
<b-field label="Expense Type*">
|
||||
<b-input v-model="expenseModel.expenseType" expanded required></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Total Amount Paid">
|
||||
<p class="control">
|
||||
<span class="button is-static">{{ me.currency }}</span>
|
||||
</p>
|
||||
<b-input
|
||||
v-model.number="expenseModel.amount"
|
||||
type="number"
|
||||
min="0"
|
||||
expanded
|
||||
step=".01"
|
||||
required
|
||||
></b-input>
|
||||
</b-field>
|
||||
<b-field label="Odometer Reading">
|
||||
<p class="control">
|
||||
<span class="button is-static">{{ me.distanceUnitDetail.short }}</span>
|
||||
</p>
|
||||
<b-input
|
||||
v-model.number="expenseModel.odoReading"
|
||||
type="number"
|
||||
min="0"
|
||||
expanded
|
||||
required
|
||||
></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field>
|
||||
<b-switch v-model="showMore">Fill more details</b-switch>
|
||||
</b-field>
|
||||
<fieldset v-if="showMore">
|
||||
<b-field label="Comments">
|
||||
<b-input v-model="expenseModel.comments" type="textarea" expanded></b-input>
|
||||
</b-field>
|
||||
</fieldset>
|
||||
<b-field>
|
||||
<b-switch v-if="quickEntry" v-model="processQuickEntry"
|
||||
>Mark selected Quick Entry as processed</b-switch
|
||||
>
|
||||
</b-field>
|
||||
<br />
|
||||
<b-field>
|
||||
<b-button
|
||||
tag="input"
|
||||
native-type="submit"
|
||||
:disabled="tryingToCreate"
|
||||
type="is-primary"
|
||||
label="Create Expense"
|
||||
expanded
|
||||
>
|
||||
</b-button>
|
||||
</b-field>
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
299
ui/src/router/views/createFillup.vue
Normal file
299
ui/src/router/views/createFillup.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import QuickEntryDisplay from '@components/quickEntryDisplay.vue'
|
||||
import store from '@state/store'
|
||||
|
||||
import { mapState } from 'vuex'
|
||||
import axios from 'axios'
|
||||
import { round } from 'lodash'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Create Fillup',
|
||||
},
|
||||
components: { Layout, QuickEntryDisplay },
|
||||
props: {
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fillup: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: function() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
authError: null,
|
||||
tryingToCreate: false,
|
||||
showMore: false,
|
||||
quickEntry: null,
|
||||
myVehicles: [],
|
||||
selectedVehicle: this.vehicle,
|
||||
fillupModel: this.fillup,
|
||||
processQuickEntry: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
user() {
|
||||
return store.state.auth.currentUser
|
||||
},
|
||||
...mapState('users', ['me']),
|
||||
...mapState('vehicles', ['fuelUnitMasters', 'fuelTypeMasters', 'vehicles']),
|
||||
},
|
||||
watch: {
|
||||
'fillupModel.fuelQuantity': function(old, newOne) {
|
||||
this.fillupModel.totalAmount = round(
|
||||
this.fillupModel.fuelQuantity * this.fillupModel.perUnitPrice,
|
||||
2
|
||||
)
|
||||
},
|
||||
'fillupModel.perUnitPrice': function(old, newOne) {
|
||||
this.fillupModel.totalAmount = round(
|
||||
this.fillupModel.fuelQuantity * this.fillupModel.perUnitPrice,
|
||||
2
|
||||
)
|
||||
},
|
||||
quickEntry: function(newOne, old) {
|
||||
if (old == null && newOne !== null) {
|
||||
this.processQuickEntry = true
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.myVehicles = this.vehicles
|
||||
this.selectedVehicle = this.vehicle
|
||||
if (!this.fillup.id) {
|
||||
this.fillupModel = this.getEmptyFillup()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getEmptyFillup() {
|
||||
return {
|
||||
vehicleId: this.selectedVehicle.id,
|
||||
fuelUnit: this.selectedVehicle.fuelUnit,
|
||||
perUnitPrice: null,
|
||||
fuelQuantity: null,
|
||||
totalAmount: null,
|
||||
odoReading: '',
|
||||
isTankFull: true,
|
||||
hasMissedFillup: false,
|
||||
date: new Date(),
|
||||
fillingStation: '',
|
||||
comments: '',
|
||||
}
|
||||
},
|
||||
async createFillup() {
|
||||
this.tryingToCreate = true
|
||||
this.fillupModel.vehicleId = this.selectedVehicle.id
|
||||
this.fillupModel.userId = this.me.id
|
||||
if (this.fillup.id) {
|
||||
axios
|
||||
.put(
|
||||
`/api/vehicles/${this.selectedVehicle.id}/fillups/${this.fillup.id}`,
|
||||
this.fillupModel
|
||||
)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Fillup Updated Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.fillupModel = this.getEmptyFillup()
|
||||
if (this.processQuickEntry) {
|
||||
store
|
||||
.dispatch('vehicles/setQuickEntryAsProcessed', { id: this.quickEntry.id })
|
||||
.then((data) => {
|
||||
this.quickEntry = null
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
} else {
|
||||
axios
|
||||
.post(`/api/vehicles/${this.selectedVehicle.id}/fillups`, this.fillupModel)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Fillup Created Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.fillupModel = this.getEmptyFillup()
|
||||
if (this.processQuickEntry) {
|
||||
store
|
||||
.dispatch('vehicles/setQuickEntryAsProcessed', { id: this.quickEntry.id })
|
||||
.then((data) => {})
|
||||
}
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="has-text-centered">
|
||||
<div class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<h1 class="title">Create Fillup</h1>
|
||||
<h1 class="subtitle">
|
||||
{{
|
||||
[
|
||||
selectedVehicle.nickname,
|
||||
selectedVehicle.registration,
|
||||
selectedVehicle.make,
|
||||
selectedVehicle.model,
|
||||
].join(' | ')
|
||||
}}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="column is-one-thirds">
|
||||
<QuickEntryDisplay v-model="quickEntry" :user="user" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form class="" @submit.prevent="createFillup">
|
||||
<b-field label="Select a vehicle">
|
||||
<b-select
|
||||
v-model="selectedVehicle"
|
||||
placeholder="Vehicle"
|
||||
required
|
||||
expanded
|
||||
:disabled="fillup.id"
|
||||
>
|
||||
<option v-for="option in myVehicles" :key="option.id" :value="option">
|
||||
{{ option.nickname }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field label="Fillup Date">
|
||||
<b-datepicker
|
||||
v-model="fillupModel.date"
|
||||
placeholder="Click to select..."
|
||||
icon="calendar"
|
||||
trap-focus
|
||||
:max-date="new Date()"
|
||||
>
|
||||
</b-datepicker>
|
||||
</b-field>
|
||||
<b-field label="Quantity*" addons>
|
||||
<b-input
|
||||
v-model.number="fillupModel.fuelQuantity"
|
||||
type="number"
|
||||
step=".01"
|
||||
min="0"
|
||||
expanded
|
||||
required
|
||||
></b-input>
|
||||
<b-select v-model="fillupModel.fuelUnit" placeholder="Fuel Unit" required>
|
||||
<option v-for="(option, key) in fuelUnitMasters" :key="key" :value="key">
|
||||
{{ option.long }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field :label="'Price per ' + vehicle.fuelUnitDetail.short + '*'"
|
||||
><p class="control">
|
||||
<span class="button is-static">{{ me.currency }}</span>
|
||||
</p>
|
||||
<b-input
|
||||
v-model.number="fillupModel.perUnitPrice"
|
||||
type="number"
|
||||
min="0"
|
||||
step=".01"
|
||||
expanded
|
||||
required
|
||||
></b-input>
|
||||
</b-field>
|
||||
<b-field label="Total Amount Paid">
|
||||
<p class="control">
|
||||
<span class="button is-static">{{ me.currency }}</span>
|
||||
</p>
|
||||
<b-input
|
||||
v-model.number="fillupModel.totalAmount"
|
||||
type="number"
|
||||
min="0"
|
||||
step=".01"
|
||||
expanded
|
||||
required
|
||||
></b-input>
|
||||
</b-field>
|
||||
<b-field label="Odometer Reading">
|
||||
<p class="control">
|
||||
<span class="button is-static">{{ me.distanceUnitDetail.short }}</span>
|
||||
</p>
|
||||
<b-input
|
||||
v-model.number="fillupModel.odoReading"
|
||||
type="number"
|
||||
min="0"
|
||||
expanded
|
||||
required
|
||||
></b-input>
|
||||
</b-field>
|
||||
<b-field>
|
||||
<b-checkbox v-model="fillupModel.isTankFull">Did you get a full tank?</b-checkbox>
|
||||
</b-field>
|
||||
<b-field>
|
||||
<b-checkbox v-model="fillupModel.hasMissedFillup"
|
||||
>Did you miss the fillup entry before this one?</b-checkbox
|
||||
>
|
||||
</b-field>
|
||||
<b-field>
|
||||
<b-switch v-model="showMore">Fill more details</b-switch>
|
||||
</b-field>
|
||||
<fieldset v-if="showMore">
|
||||
<b-field label="Filling Station Name">
|
||||
<b-input v-model="fillupModel.fillingStation" type="text" expanded></b-input>
|
||||
</b-field>
|
||||
<b-field label="Comments">
|
||||
<b-input v-model="fillupModel.comments" type="textarea" expanded></b-input>
|
||||
</b-field>
|
||||
</fieldset>
|
||||
<b-field>
|
||||
<b-switch v-if="quickEntry" v-model="processQuickEntry"
|
||||
>Mark selected Quick Entry as processed</b-switch
|
||||
>
|
||||
</b-field>
|
||||
<br />
|
||||
<b-field>
|
||||
<b-button
|
||||
tag="input"
|
||||
native-type="submit"
|
||||
:disabled="tryingToCreate"
|
||||
type="is-primary"
|
||||
label="Create Fillup"
|
||||
expanded
|
||||
>
|
||||
</b-button>
|
||||
<p v-if="authError">
|
||||
There was an error logging in to your account.
|
||||
</p>
|
||||
</b-field>
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
181
ui/src/router/views/createVehicle.vue
Normal file
181
ui/src/router/views/createVehicle.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import { mapState } from 'vuex'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Create Vehicle',
|
||||
},
|
||||
components: { Layout },
|
||||
props: {
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: function() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
authError: null,
|
||||
tryingToCreate: false,
|
||||
showMore: false,
|
||||
myVehicles: [],
|
||||
vehicleModel: {},
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState('users', ['me']),
|
||||
...mapState('vehicles', ['fuelUnitMasters', 'fuelTypeMasters', 'vehicles']),
|
||||
},
|
||||
watch: {},
|
||||
mounted() {
|
||||
if (!this.vehicle) {
|
||||
this.vehicleModel = this.getEmptyVehicle()
|
||||
} else {
|
||||
this.vehicleModel = this.getEmptyVehicle(this.vehicle)
|
||||
}
|
||||
this.myVehicles = this.vehicles
|
||||
},
|
||||
methods: {
|
||||
getEmptyVehicle(veh) {
|
||||
if (!veh.id) {
|
||||
return {
|
||||
fuelUnit: null,
|
||||
fuelType: null,
|
||||
registration: '',
|
||||
nickname: '',
|
||||
engineSize: null,
|
||||
make: '',
|
||||
model: '',
|
||||
yearOfManufacture: null,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
fuelUnit: veh.fuelUnit,
|
||||
fuelType: veh.fuelType,
|
||||
registration: veh.registration,
|
||||
nickname: veh.nickname,
|
||||
engineSize: veh.engineSize,
|
||||
make: veh.make,
|
||||
model: veh.model,
|
||||
yearOfManufacture: veh.yearOfManufacture,
|
||||
}
|
||||
}
|
||||
},
|
||||
createVehicle() {
|
||||
this.tryingToCreate = true
|
||||
this.vehicleModel.userId = this.me.id
|
||||
if (this.vehicle.id) {
|
||||
axios
|
||||
.put(`/api/vehicles/${this.vehicle.id}`, this.vehicleModel)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Vehicle Updated Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
// this.vehicleModel = this.getEmptyVehicle()
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
} else {
|
||||
axios
|
||||
.post(`/api/vehicles`, this.vehicleModel)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Vehicle Created Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.vehicleModel = this.getEmptyVehicle()
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="columns">
|
||||
<div class="column is-three-quarters">
|
||||
<h1 class="title">Create Vehicle</h1>
|
||||
</div>
|
||||
<div class="column is-one-quarter">
|
||||
<router-link tag="b-button" type="is-primary" to="/">
|
||||
Back to Vehicle
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="createVehicle">
|
||||
<b-field label="Nickname*">
|
||||
<b-input v-model="vehicleModel.nickname" type="text" expanded required></b-input>
|
||||
</b-field>
|
||||
<b-field label="Registration*">
|
||||
<b-input v-model="vehicleModel.registration" type="text" expanded required></b-input>
|
||||
</b-field>
|
||||
<b-field label="Fuel Type*">
|
||||
<b-select v-model.number="vehicleModel.fuelType" placeholder="Fuel Type" required expanded>
|
||||
<option v-for="(option, key) in fuelTypeMasters" :key="key" :value="key">
|
||||
{{ option.long }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Fuel Unit*">
|
||||
<b-select v-model.number="vehicleModel.fuelUnit" placeholder="Fuel Unit" required expanded>
|
||||
<option v-for="(option, key) in fuelUnitMasters" :key="key" :value="key">
|
||||
{{ option.long }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Make / Company*">
|
||||
<b-input v-model="vehicleModel.make" type="text" required expanded></b-input>
|
||||
</b-field>
|
||||
<b-field label="Model*">
|
||||
<b-input v-model="vehicleModel.model" type="text" required expanded></b-input>
|
||||
</b-field>
|
||||
<b-field label="Year Of Manufacture">
|
||||
<b-input v-model.number="vehicleModel.yearOfManufacture" type="number" expanded number></b-input>
|
||||
</b-field>
|
||||
<b-field label="Engine Size (in cc)">
|
||||
<b-input v-model.number="vehicleModel.engineSize" type="number" expanded number></b-input>
|
||||
</b-field>
|
||||
|
||||
<br />
|
||||
<b-field>
|
||||
<b-button tag="input" native-type="submit" :disabled="tryingToCreate" type="is-primary" label="Create Vehicle" expanded>
|
||||
<BaseIcon v-if="tryingToCreate" name="sync" spin />
|
||||
</b-button>
|
||||
<p v-if="authError">
|
||||
There was an error logging in to your account.
|
||||
</p>
|
||||
</b-field>
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
12
ui/src/router/views/home.unit.js
Normal file
12
ui/src/router/views/home.unit.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import Home from './home.vue'
|
||||
|
||||
describe('@views/home', () => {
|
||||
it('is a valid view', () => {
|
||||
expect(Home).toBeAViewComponent()
|
||||
})
|
||||
|
||||
it('renders an element', () => {
|
||||
const { element } = shallowMountView(Home)
|
||||
expect(element.textContent).toContain('Home Page')
|
||||
})
|
||||
})
|
||||
161
ui/src/router/views/home.vue
Normal file
161
ui/src/router/views/home.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script>
|
||||
import currencyFormtter from 'currency-formatter'
|
||||
|
||||
import appConfig from '@src/app.config'
|
||||
import Layout from '@layouts/main.vue'
|
||||
import CreateQuickEntry from '@components/createQuickEntry.vue'
|
||||
import StatsWidget from '@components/statsWidget.vue'
|
||||
|
||||
import { parseAndFormatDate } from '@utils/format-date'
|
||||
// import store from '@state/store'
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Home',
|
||||
meta: [{ name: 'description', content: appConfig.description }],
|
||||
},
|
||||
components: { Layout, CreateQuickEntry, StatsWidget },
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
myVehicles: [],
|
||||
|
||||
tryingToCreate: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('users', ['me']),
|
||||
...mapState('vehicles', ['vehicles']),
|
||||
...mapState('utils', ['isMobile']),
|
||||
...mapGetters('vehicles', ['unprocessedQuickEntries']),
|
||||
},
|
||||
watch: {
|
||||
vehicles(old, newOne) {
|
||||
this.myVehicles = newOne
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.myVehicles = this.vehicles
|
||||
},
|
||||
methods: {
|
||||
formatDate(date) {
|
||||
return parseAndFormatDate(date)
|
||||
},
|
||||
formatCurrency(number) {
|
||||
return currencyFormtter.format(number, { code: this.me.currency })
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<b-notification v-if="myVehicles.length === 0" type="is-warning is-light" :closable="false">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
It seems you have not yet created a vehicle in the system. Start by creating an entry for
|
||||
one of the vehicles you want to track.
|
||||
</div>
|
||||
<div class="column" :class="!isMobile ? 'has-text-right' : ''">
|
||||
<b-button type="is-warning" class="" tag="router-link" :to="`/vehicles/create`"
|
||||
>Create Now</b-button
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
</b-notification>
|
||||
<b-notification
|
||||
v-if="unprocessedQuickEntries.length"
|
||||
type="is-warning is-light"
|
||||
:closable="false"
|
||||
>
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
{{
|
||||
`You have ${unprocessedQuickEntries.length} quick ${
|
||||
unprocessedQuickEntries.length === 1 ? 'entry' : 'entries'
|
||||
} pending to be processed.`
|
||||
}}
|
||||
</div>
|
||||
<div class="column" :class="!isMobile ? 'has-text-right' : ''">
|
||||
<b-button type="is-warning" class="is-small" tag="router-link" :to="`/quickEntries`"
|
||||
>Process Now</b-button
|
||||
></div
|
||||
>
|
||||
</div>
|
||||
</b-notification>
|
||||
<CreateQuickEntry />
|
||||
<StatsWidget :user="me" />
|
||||
<br />
|
||||
<section>
|
||||
<div class="columns" :class="isMobile ? 'has-text-centered' : ''"
|
||||
><div class="column is-three-quarters"> <h1 class="title">Your Vehicles</h1></div>
|
||||
<div class="column is-one-quarter buttons" :class="!isMobile ? 'has-text-right' : ''">
|
||||
<b-button type="is-primary" tag="router-link" :to="`/vehicles/create`"
|
||||
>Add Vehicle</b-button
|
||||
>
|
||||
</div></div
|
||||
>
|
||||
|
||||
<div v-if="myVehicles.length" class="columns">
|
||||
<div v-for="vehicle in myVehicles" :key="vehicle.id" class="column is-4">
|
||||
<b-collapse animation="slide" aria-id="contentIdForA11y3" class="card" :open="!isMobile">
|
||||
<template v-slot:trigger="props">
|
||||
<div class="card-header" role="button" aria-controls="contentIdForA11y3">
|
||||
<div class="card-header-title">
|
||||
<div>{{ `${vehicle.nickname} - ${vehicle.registration}` }} </div>
|
||||
</div>
|
||||
<a class="card-header-icon">
|
||||
<b-icon :icon="props.open ? 'angle-down' : 'angle-up'"> </b-icon>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="vehicle.fillups.length" class="card-content">
|
||||
<div class="content">
|
||||
<table class="table">
|
||||
<div class="columns">
|
||||
<div class="column is-one-third">Last Fillup</div>
|
||||
<div class="column"
|
||||
>{{ formatDate(vehicle.fillups[0].date) }} <br />
|
||||
{{ `${formatCurrency(vehicle.fillups[0].totalAmount)}` }} ({{
|
||||
`${vehicle.fillups[0].fuelQuantity} ${vehicle.fillups[0].fuelUnitDetail.short}`
|
||||
}}
|
||||
@ {{ `${formatCurrency(vehicle.fillups[0].perUnitPrice)}` }})</div
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-one-third">Odometer</div>
|
||||
<div class="column">
|
||||
<template v-if="vehicle.fillups.length">
|
||||
{{ vehicle.fillups[0].odoReading }} {{
|
||||
me.distanceUnitDetail.short
|
||||
}}</template
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="card-footer">
|
||||
<router-link class="card-footer-item" :to="'/vehicles/' + vehicle.id">
|
||||
Details
|
||||
</router-link>
|
||||
<router-link class="card-footer-item" :to="`/vehicles/${vehicle.id}/fillup`">
|
||||
Add Fillup </router-link
|
||||
><router-link class="card-footer-item" :to="`/vehicles/${vehicle.id}/expense`">
|
||||
Add Expense
|
||||
</router-link>
|
||||
</footer>
|
||||
</b-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
</template>
|
||||
261
ui/src/router/views/initialize.vue
Normal file
261
ui/src/router/views/initialize.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import axios from 'axios'
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
import store from '@state/store'
|
||||
|
||||
export default {
|
||||
components: { Layout },
|
||||
page: {
|
||||
title: 'First Setup',
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
migrationMode: '',
|
||||
url: '',
|
||||
testSuccess: false,
|
||||
connectionError: '',
|
||||
isWorking: false,
|
||||
registerModel: {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
distanceUnit: 1,
|
||||
currency: 'INR',
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('auth', ['isInitialized']),
|
||||
...mapState('vehicles', ['currencyMasters', 'distanceUnitMasters']),
|
||||
},
|
||||
mounted() {
|
||||
store.dispatch('vehicles/fetchMasters').then((data) => {})
|
||||
},
|
||||
methods: {
|
||||
resetMigrationMode() {
|
||||
this.migrationMode = ''
|
||||
this.url = ''
|
||||
this.registerModel = {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
distanceUnit: 1,
|
||||
currency: 'INR',
|
||||
}
|
||||
},
|
||||
showSuccessModal() {
|
||||
var message = ''
|
||||
if (this.migrationMode === 'clarkson') {
|
||||
message =
|
||||
'We have successfully migrated the data from Clarkson. You will be redirected to the login screen shortly where you can login using your existing email and password : hammond'
|
||||
}
|
||||
if (this.migrationMode === 'fresh') {
|
||||
message =
|
||||
'You have been registered successfully. You will be redirected to the login screen shortly where you can login and start using the system.'
|
||||
}
|
||||
this.$buefy.toast.open({
|
||||
duration: 10000,
|
||||
message: message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-success',
|
||||
})
|
||||
setTimeout(() => {
|
||||
this.$router
|
||||
.push({ name: 'login' })
|
||||
.then((succ) => {})
|
||||
.catch((err) => console.log('error:', err))
|
||||
}, 10000)
|
||||
},
|
||||
register() {
|
||||
this.isWorking = true
|
||||
axios
|
||||
.post('/api/auth/initialize', this.registerModel)
|
||||
.then((response) => {
|
||||
const success = response.data.success
|
||||
if (success) {
|
||||
store.dispatch('auth/systemInitialized').then((data) => {
|
||||
this.showSuccessModal()
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.testSuccess = false
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => (this.isWorking = false))
|
||||
},
|
||||
testConnection() {
|
||||
if (!this.url) {
|
||||
return
|
||||
}
|
||||
this.isWorking = true
|
||||
axios
|
||||
.post('/api/clarkson/check', { url: this.url })
|
||||
.then((response) => {
|
||||
this.testSuccess = response.data.canMigrate
|
||||
if (!this.testSuccess) {
|
||||
this.connectionError = response.data.message
|
||||
} else {
|
||||
this.connectionError = ''
|
||||
}
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.testSuccess = false
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => (this.isWorking = false))
|
||||
},
|
||||
migrate() {
|
||||
if (!this.url) {
|
||||
return
|
||||
}
|
||||
this.isWorking = true
|
||||
axios
|
||||
.post('/api/clarkson/migrate', { url: this.url })
|
||||
.then((data) => {
|
||||
store.dispatch('auth/systemInitialized').then((data) => {
|
||||
this.showSuccessModal()
|
||||
})
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.testSuccess = false
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => (this.isWorking = false))
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<div v-if="!migrationMode" class="box">
|
||||
<h1 class="title">Migrate from Clarkson</h1>
|
||||
<p>
|
||||
If you have an existing Clarkson deployment and you want to migrate your data from that,
|
||||
press the following button.
|
||||
</p>
|
||||
<br />
|
||||
<b-field>
|
||||
<b-button type="is-primary" @click="migrationMode = 'clarkson'"
|
||||
>Migrate from Clarkson</b-button
|
||||
></b-field
|
||||
>
|
||||
</div>
|
||||
<div v-if="!migrationMode" class="box">
|
||||
<h1 class="title">Fresh Install</h1>
|
||||
<p>
|
||||
If you want a fresh install of Hammond, press the following button.
|
||||
</p>
|
||||
<br />
|
||||
<b-field>
|
||||
<b-button type="is-primary" @click="migrationMode = 'fresh'">Fresh Install</b-button>
|
||||
</b-field>
|
||||
</div>
|
||||
<div v-if="migrationMode === 'clarkson'" class="box content">
|
||||
<h1 class="title">Migrate from Clarkson</h1>
|
||||
<p
|
||||
>You need to make sure that this deployment of Hammond can access the MySQL database used by
|
||||
Clarkson.</p
|
||||
>
|
||||
<p
|
||||
>If that is not directly possible, you can make a copy of that database somewhere accessible
|
||||
from this instance.</p
|
||||
>
|
||||
<p
|
||||
>Once that is done, enter the connection string to the MySQL instance in the following
|
||||
format.</p
|
||||
>
|
||||
<p
|
||||
>All the users imported from Clarkson will have their username as their email in Clarkson
|
||||
database and pasword set to <span class="" style="font-weight:bold">hammond</span></p
|
||||
>
|
||||
<code>
|
||||
user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
|
||||
</code>
|
||||
<br />
|
||||
<br />
|
||||
<b-notification v-if="connectionError" type="is-danger" role="alert" :closable="false">
|
||||
{{ connectionError }}
|
||||
</b-notification>
|
||||
|
||||
<b-field addons label="Mysql Connection String">
|
||||
<b-input v-model="url" required></b-input>
|
||||
</b-field>
|
||||
|
||||
<div class="buttons">
|
||||
<b-button
|
||||
v-if="!testSuccess"
|
||||
type="is-primary"
|
||||
:disabled="isWorking"
|
||||
@click="testConnection"
|
||||
>Test Connection</b-button
|
||||
><b-button v-if="testSuccess" type="is-success" :disabled="isWorking" @click="migrate"
|
||||
>Migrate</b-button
|
||||
>
|
||||
<b-button type="is-danger is-light" @click="resetMigrationMode">Cancel</b-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="migrationMode === 'fresh'" class="box content">
|
||||
<h1 class="title">Setup Admin Users</h1>
|
||||
<form @submit.prevent="register">
|
||||
<b-field label="Your Name">
|
||||
<b-input v-model="registerModel.name" required></b-input>
|
||||
</b-field>
|
||||
<b-field label="Your Email">
|
||||
<b-input v-model="registerModel.email" type="email" required></b-input>
|
||||
</b-field>
|
||||
<b-field label="Your Password">
|
||||
<b-input
|
||||
v-model="registerModel.password"
|
||||
type="password"
|
||||
required
|
||||
minlength="6"
|
||||
password-reveal
|
||||
></b-input>
|
||||
</b-field>
|
||||
<b-field label="Currency">
|
||||
<b-select v-model="registerModel.currency" placeholder="Currency" required expanded>
|
||||
<option v-for="option in currencyMasters" :key="option.code" :value="option.code">
|
||||
{{ `${option.namePlural} (${option.code})` }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field label="Distance Unit">
|
||||
<b-select
|
||||
v-model.number="registerModel.distanceUnit"
|
||||
placeholder="Distance Unit"
|
||||
required
|
||||
expanded
|
||||
>
|
||||
<option v-for="(option, key) in distanceUnitMasters" :key="key" :value="key">
|
||||
{{ `${option.long} (${option.short})` }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<br />
|
||||
<div class="buttons">
|
||||
<b-button type="is-primary" native-type="submit" tag="input"></b-button>
|
||||
|
||||
<b-button type="is-danger is-light" @click="resetMigrationMode">Cancel</b-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
79
ui/src/router/views/login.unit.js
Normal file
79
ui/src/router/views/login.unit.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import Login from './login.vue'
|
||||
|
||||
describe('@views/login', () => {
|
||||
it('is a valid view', () => {
|
||||
expect(Login).toBeAViewComponent()
|
||||
})
|
||||
|
||||
it('redirects to home after successful login', () => {
|
||||
const { vm } = mountLogin()
|
||||
|
||||
vm.username = 'correctUsername'
|
||||
vm.password = 'correctPassword'
|
||||
|
||||
const routerPush = jest.fn()
|
||||
vm.$router = { push: routerPush }
|
||||
vm.$route = { query: {} }
|
||||
|
||||
expect.assertions(2)
|
||||
return vm.tryToLogIn().then(() => {
|
||||
expect(vm.authError).toEqual(null)
|
||||
expect(routerPush).toHaveBeenCalledWith({ name: 'home' })
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects to redirectFrom query, if it exists, after successful login', () => {
|
||||
const { vm } = mountLogin()
|
||||
|
||||
vm.username = 'correctUsername'
|
||||
vm.password = 'correctPassword'
|
||||
|
||||
const routerPush = jest.fn()
|
||||
vm.$router = { push: routerPush }
|
||||
|
||||
const redirectFrom = '/profile?someQuery'
|
||||
vm.$route = { query: { redirectFrom } }
|
||||
|
||||
expect.assertions(2)
|
||||
return vm.tryToLogIn().then(() => {
|
||||
expect(vm.authError).toEqual(null)
|
||||
expect(routerPush).toHaveBeenCalledWith(redirectFrom)
|
||||
})
|
||||
})
|
||||
|
||||
it('displays an error after failed login', () => {
|
||||
const { vm } = mountLogin()
|
||||
|
||||
const routerPush = jest.fn()
|
||||
vm.$router = { push: routerPush }
|
||||
|
||||
expect.assertions(2)
|
||||
return vm.tryToLogIn().then(() => {
|
||||
expect(vm.authError).toBeTruthy()
|
||||
expect(vm.$el.textContent).toContain('error')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function mountLogin() {
|
||||
return shallowMountView(Login, {
|
||||
...createComponentMocks({
|
||||
store: {
|
||||
auth: {
|
||||
actions: {
|
||||
logIn(_, { username, password }) {
|
||||
if (
|
||||
username === 'correctUsername' &&
|
||||
password === 'correctPassword'
|
||||
) {
|
||||
return Promise.resolve('testToken')
|
||||
} else {
|
||||
return Promise.reject(new Error('testError'))
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
92
ui/src/router/views/login.vue
Normal file
92
ui/src/router/views/login.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import { authMethods } from '@state/helpers'
|
||||
import appConfig from '@src/app.config'
|
||||
import store from '@state/store'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Log in',
|
||||
meta: [{ name: 'description', content: `Log in to ${appConfig.title}` }],
|
||||
},
|
||||
components: { Layout },
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
authError: null,
|
||||
tryingToLogIn: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
placeholders() {
|
||||
return process.env.NODE_ENV === 'production'
|
||||
? {}
|
||||
: {
|
||||
username: 'Enter your username',
|
||||
password: 'Enter your password',
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// console.log(process.env.API_BASE_URL)
|
||||
},
|
||||
methods: {
|
||||
...authMethods,
|
||||
// Try to log the user in with the username
|
||||
// and password they provided.
|
||||
tryToLogIn() {
|
||||
this.tryingToLogIn = true
|
||||
// Reset the authError if it existed.
|
||||
this.authError = null
|
||||
return this.logIn({
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
})
|
||||
.then((token) => {
|
||||
this.tryingToLogIn = false
|
||||
store.dispatch('users/me').then((data) => {
|
||||
this.$router.push(this.$route.query.redirectFrom || { name: 'home' })
|
||||
})
|
||||
// Redirect to the originally requested page, or to the home page
|
||||
})
|
||||
.catch((error) => {
|
||||
this.tryingToLogIn = false
|
||||
this.authError = error
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<form @submit.prevent="tryToLogIn">
|
||||
<b-field label="Email">
|
||||
<b-input
|
||||
v-model="username"
|
||||
tag="b-input"
|
||||
name="username"
|
||||
:placeholder="placeholders.username"
|
||||
/></b-field>
|
||||
<b-field label="Password">
|
||||
<b-input
|
||||
v-model="password"
|
||||
tag="b-input"
|
||||
name="password"
|
||||
type="password"
|
||||
:placeholder="placeholders.password"
|
||||
/>
|
||||
</b-field>
|
||||
<b-button tag="input" native-type="submit" :disabled="tryingToLogIn" type="is-primary">
|
||||
<BaseIcon v-if="tryingToLogIn" name="sync" spin />
|
||||
<span v-else>
|
||||
Log in
|
||||
</span>
|
||||
</b-button>
|
||||
<p v-if="authError">
|
||||
There was an error logging in to your account.
|
||||
</p>
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
17
ui/src/router/views/profile.unit.js
Normal file
17
ui/src/router/views/profile.unit.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import Profile from './profile.vue'
|
||||
|
||||
describe('@views/profile', () => {
|
||||
it('is a valid view', () => {
|
||||
expect(Profile).toBeAViewComponentUsing({ user: { name: '' } })
|
||||
})
|
||||
|
||||
it(`includes the provided user's name`, () => {
|
||||
const { element } = shallowMountView(Profile, {
|
||||
propsData: {
|
||||
user: { name: 'My Name' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(element.textContent).toMatch(/My Name\s+Profile/)
|
||||
})
|
||||
})
|
||||
35
ui/src/router/views/profile.vue
Normal file
35
ui/src/router/views/profile.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
|
||||
export default {
|
||||
page() {
|
||||
return {
|
||||
title: this.user.name,
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: `The user profile for ${this.user.name}.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
components: { Layout },
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<h1>
|
||||
<BaseIcon name="user" />
|
||||
{{ user.name }}
|
||||
Profile
|
||||
</h1>
|
||||
<pre>{{ user }}</pre>
|
||||
</Layout>
|
||||
</template>
|
||||
111
ui/src/router/views/quickEntries.vue
Normal file
111
ui/src/router/views/quickEntries.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import { parseAndFormatDateTime } from '@utils/format-date'
|
||||
import store from '@state/store'
|
||||
import { chunk, filter } from 'lodash'
|
||||
import { mapState, mapGetters } from 'vuex'
|
||||
// import axios from 'axios'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Quick Entries',
|
||||
},
|
||||
components: { Layout },
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
showUnprocessedOnly: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
chunkedQuickEntries() {
|
||||
var source = this.showUnprocessedOnly ? filter(this.quickEntries, (x) => x.processDate === null) : this.quickEntries
|
||||
return chunk(source, 3)
|
||||
},
|
||||
...mapState('vehicles', ['vehicles', 'quickEntries']),
|
||||
...mapGetters('vehicles', ['unprocessedQuickEntries']),
|
||||
},
|
||||
created() {
|
||||
store.dispatch('vehicles/fetchQuickEntries', { force: true }).then((data) => {})
|
||||
},
|
||||
methods: {
|
||||
parseAndFormatDateTime(date) {
|
||||
return parseAndFormatDateTime(date)
|
||||
},
|
||||
markProcessed(entry) {
|
||||
store.dispatch('vehicles/setQuickEntryAsProcessed', { id: entry.id }).then((data) => {})
|
||||
},
|
||||
imageModal(url) {
|
||||
const h = this.$createElement
|
||||
const vnode = h('p', { class: 'image' }, [h('img', { attrs: { src: url } })])
|
||||
this.$buefy.modal.open({
|
||||
content: [vnode],
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<h1 class="title">Quick Entries</h1>
|
||||
<b-field>
|
||||
<b-switch v-if="unprocessedQuickEntries.length" v-model="showUnprocessedOnly">Show unprocessed only</b-switch>
|
||||
</b-field>
|
||||
<div v-for="(chunk, index) in chunkedQuickEntries" :key="index" class="tile is-ancestor">
|
||||
<div v-for="entry in chunk" :key="entry.id" class="tile is-parent" :class="{ 'is-4': quickEntries.length <= 3 }">
|
||||
<div class="tile is-child">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-header-title">
|
||||
{{ parseAndFormatDateTime(entry.createdAt) }}
|
||||
</div>
|
||||
<b-tag v-if="entry.processDate === null" class="is-align-content-center" type="is-primary">unprocessed</b-tag>
|
||||
</div>
|
||||
<div class="card-image">
|
||||
<!-- prettier-ignore -->
|
||||
<img
|
||||
class="is-clickable"
|
||||
:src="`/api/attachments/${entry.attachmentId}/file?access_token=${user.token}`"
|
||||
alt="Placeholder image"
|
||||
@click="imageModal(`/api/attachments/${entry.attachmentId}/file?access_token=${user.token}`)"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-content is-flex"
|
||||
><p>{{ entry.comments }}</p></div
|
||||
>
|
||||
<footer class="card-footer">
|
||||
<router-link v-if="entry.processDate === null && vehicles.length" :to="`/vehicles/${vehicles[0].id}/fillup`" class="card-footer-item"
|
||||
>Create Fillup</router-link
|
||||
>
|
||||
<router-link v-if="entry.processDate === null && vehicles.length" :to="`/vehicles/${vehicles[0].id}/expense`" class="card-footer-item"
|
||||
>Create Expense</router-link
|
||||
>
|
||||
<a v-if="entry.processDate === null" class="card-footer-item" @click="markProcessed(entry)">Mark Processed</a>
|
||||
<p v-else>Processed on {{ parseAndFormatDateTime(entry.processDate) }}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!quickEntries.length" class="box">
|
||||
<p>No Quick Entries right now.</p>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.card-equal-height {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.card-equal-height .card-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
207
ui/src/router/views/settings.vue
Normal file
207
ui/src/router/views/settings.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import { mapState } from 'vuex'
|
||||
import store from '@state/store'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Settings',
|
||||
},
|
||||
components: { Layout },
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
me: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
settingsModel: {
|
||||
currency: this.me.currency,
|
||||
distanceUnit: this.me.distanceUnit,
|
||||
},
|
||||
tryingToSave: false,
|
||||
changePassModel: {
|
||||
old: '',
|
||||
new: '',
|
||||
renew: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('vehicles', ['currencyMasters', 'distanceUnitMasters']),
|
||||
passwordValid() {
|
||||
if (this.changePassModel.new === '' || this.changePassModel.renew === '') {
|
||||
return true
|
||||
}
|
||||
|
||||
return this.changePassModel.new === this.changePassModel.renew
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
changePassword() {
|
||||
if (!this.passwordValid) {
|
||||
return
|
||||
}
|
||||
this.tryingToSavePass = true
|
||||
|
||||
axios
|
||||
.post('/api/changePassword', {
|
||||
oldPassword: this.changePassModel.old,
|
||||
newPassword: this.changePassModel.new,
|
||||
})
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Password changed successfully. You will be logged out now.',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.$router
|
||||
.push({ name: 'logout' })
|
||||
.then((succ) => {
|
||||
console.log(succ)
|
||||
})
|
||||
.catch((err) => console.log('error:', err))
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToSavePass = false
|
||||
})
|
||||
},
|
||||
saveSettings() {
|
||||
this.tryingToSave = true
|
||||
store
|
||||
.dispatch(`utils/saveUserSettings`, { settings: this.settingsModel })
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Settings saved successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToSave = false
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<h1 class="title">Your Settings</h1>
|
||||
<div class="columns"
|
||||
><div class="column">
|
||||
<form class="box " @submit.prevent="saveSettings">
|
||||
<h1 class="subtitle">
|
||||
These will be used as default values whenever you create a new fillup or expense.
|
||||
</h1>
|
||||
<b-field label="Currency">
|
||||
<b-select v-model="settingsModel.currency" placeholder="Currency" required expanded>
|
||||
<option v-for="option in currencyMasters" :key="option.code" :value="option.code">
|
||||
{{ `${option.namePlural} (${option.code})` }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field label="Distance Unit">
|
||||
<b-select v-model.number="settingsModel.distanceUnit" placeholder="Distance Unit" required expanded>
|
||||
<option v-for="(option, key) in distanceUnitMasters" :key="key" :value="key">
|
||||
{{ `${option.long} (${option.short})` }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<br />
|
||||
<b-field>
|
||||
<b-button tag="input" native-type="submit" :disabled="tryingToSave" type="is-primary" value="Save" expanded> </b-button>
|
||||
</b-field>
|
||||
</form>
|
||||
</div>
|
||||
<div class="column">
|
||||
<form class="box" @submit.prevent="changePassword">
|
||||
<h1 class="subtitle">Change password</h1>
|
||||
<b-field label="Old Password">
|
||||
<b-input v-model="changePassModel.old" required minlength="6" password-reveal type="password"></b-input>
|
||||
</b-field>
|
||||
<b-field label="New Password">
|
||||
<b-input v-model="changePassModel.new" required minlength="6" password-reveal type="password"></b-input>
|
||||
</b-field>
|
||||
<b-field label="Repeat New Password">
|
||||
<b-input v-model="changePassModel.renew" required minlength="6" password-reveal type="password"></b-input>
|
||||
</b-field>
|
||||
<p v-if="!passwordValid" class="help is-danger">Password values don't match</p>
|
||||
<b-field>
|
||||
<b-button tag="input" native-type="submit" :disabled="!passwordValid" type="is-primary" value="Change Password" expanded> </b-button>
|
||||
</b-field>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="columns">
|
||||
<div class="twelve">
|
||||
<h3 class="title">More Info</h3>
|
||||
<p style="font-style: italic;">
|
||||
This project is under active development which means I release new updates very frequently. I will eventually build the version
|
||||
management/update checking mechanism. Until then it is recommended that you use something like watchtower which will automatically update
|
||||
your containers whenever I release a new version or periodically rebuild the container with the latest image manually.
|
||||
</p>
|
||||
<br />
|
||||
<table class="table is-hoverable">
|
||||
<tr>
|
||||
<td>Current Version</td>
|
||||
<td>2021.05.07</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Website</td>
|
||||
<td><a href="https://github.com/akhilrex/hammond" target="_blank">https://github.com/akhilrex/hammond</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Found a bug</td>
|
||||
<td
|
||||
><a
|
||||
href="https://github.com/akhilrex/hammond/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Report here</a
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Feature Request</td>
|
||||
<td
|
||||
><a
|
||||
href="https://github.com/akhilrex/hammond/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Request here</a
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Support the developer</td>
|
||||
<td><a href="https://www.buymeacoffee.com/akhilrex" target="_blank" rel="noopener noreferrer">Support here</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
95
ui/src/router/views/siteSettings.vue
Normal file
95
ui/src/router/views/siteSettings.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import { mapState } from 'vuex'
|
||||
import store from '@state/store'
|
||||
|
||||
export default {
|
||||
components: { Layout },
|
||||
page: {
|
||||
title: 'Site Settings',
|
||||
},
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
settings: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
settingsModel: {
|
||||
currency: this.settings.currency,
|
||||
distanceUnit: this.settings.distanceUnit,
|
||||
},
|
||||
tryingToSave: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('vehicles', ['currencyMasters', 'distanceUnitMasters']),
|
||||
},
|
||||
methods: {
|
||||
saveSettings() {
|
||||
this.tryingToSave = true
|
||||
store
|
||||
.dispatch(`utils/saveSettings`, { settings: this.settingsModel })
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Settings saved successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToSave = false
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h1 class="title">Site Settings</h1>
|
||||
<h1 class="subtitle">
|
||||
Update site level settings. These will be used as default values for new users.
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<form class="" @submit.prevent="saveSettings">
|
||||
<b-field label="Currency">
|
||||
<b-select v-model="settingsModel.currency" placeholder="Currency" required expanded>
|
||||
<option v-for="option in currencyMasters" :key="option.code" :value="option.code">
|
||||
{{ `${option.namePlural} (${option.code})` }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field label="Distance Unit">
|
||||
<b-select v-model.number="settingsModel.distanceUnit" placeholder="Distance Unit" required expanded>
|
||||
<option v-for="(option, key) in distanceUnitMasters" :key="key" :value="key">
|
||||
{{ `${option.long} (${option.short})` }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<br />
|
||||
<b-field>
|
||||
<b-button tag="input" native-type="submit" :disabled="tryingToSave" type="is-primary" value="Save" expanded> </b-button>
|
||||
</b-field>
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
176
ui/src/router/views/users.vue
Normal file
176
ui/src/router/views/users.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import store from '@state/store'
|
||||
import { mapState } from 'vuex'
|
||||
import axios from 'axios'
|
||||
|
||||
import { parseAndFormatDate } from '@utils/format-date'
|
||||
|
||||
export default {
|
||||
components: { Layout },
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
settings: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
users: [],
|
||||
showUserForm: false,
|
||||
isWorking: false,
|
||||
registerModel: {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
distanceUnit: this.settings.distanceUnit,
|
||||
currency: this.settings.currency,
|
||||
role: 1,
|
||||
},
|
||||
}
|
||||
},
|
||||
page() {
|
||||
return {
|
||||
title: 'User Management ',
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('vehicles', ['currencyMasters', 'distanceUnitMasters', 'roleMasters']),
|
||||
},
|
||||
mounted() {
|
||||
this.getUsers()
|
||||
},
|
||||
methods: {
|
||||
getUsers() {
|
||||
store.dispatch('users/users').then((data) => {
|
||||
this.users = data
|
||||
})
|
||||
},
|
||||
formatDate(date) {
|
||||
return parseAndFormatDate(date)
|
||||
},
|
||||
resetUserForm() {
|
||||
this.registerModel = {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
distanceUnit: this.settings.distanceUnit,
|
||||
currency: this.settings.currency,
|
||||
role: 1,
|
||||
}
|
||||
this.showUserForm = false
|
||||
},
|
||||
register() {
|
||||
this.isWorking = true
|
||||
axios
|
||||
.post('/api/register', this.registerModel)
|
||||
.then((response) => {
|
||||
const success = response.data.success
|
||||
if (success) {
|
||||
this.$buefy.toast.open({
|
||||
duration: 10000,
|
||||
message: 'User Created Successfully',
|
||||
position: 'is-bottom',
|
||||
type: 'is-success',
|
||||
})
|
||||
this.getUsers()
|
||||
this.resetUserForm()
|
||||
}
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => (this.isWorking = false))
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="box">
|
||||
<div class="columns">
|
||||
<div class="column is-three-quarters"> <h1 class="title is-4">Users</h1> </div>
|
||||
<div class="column is-one-quarter">
|
||||
<b-button type="is-primary" @click="showUserForm = true">Add User</b-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showUserForm" class="box content">
|
||||
<h1 class="title">Create New User</h1>
|
||||
<form @submit.prevent="register">
|
||||
<b-field label="Name">
|
||||
<b-input v-model="registerModel.name" required></b-input>
|
||||
</b-field>
|
||||
<b-field label="Email">
|
||||
<b-input v-model="registerModel.email" type="email" required></b-input>
|
||||
</b-field>
|
||||
<b-field label="Password">
|
||||
<b-input
|
||||
v-model="registerModel.password"
|
||||
type="password"
|
||||
required
|
||||
minlength="6"
|
||||
password-reveal
|
||||
></b-input>
|
||||
</b-field>
|
||||
<b-field label="Role">
|
||||
<b-select v-model.number="registerModel.role" placeholder="Role" required expanded>
|
||||
<option v-for="(option, key) in roleMasters" :key="key" :value="key">
|
||||
{{ `${option.long}` }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field label="Currency">
|
||||
<b-select v-model="registerModel.currency" placeholder="Currency" required expanded>
|
||||
<option v-for="option in currencyMasters" :key="option.code" :value="option.code">
|
||||
{{ `${option.namePlural} (${option.code})` }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field label="Distance Unit">
|
||||
<b-select
|
||||
v-model.number="registerModel.distanceUnit"
|
||||
placeholder="Distance Unit"
|
||||
required
|
||||
expanded
|
||||
>
|
||||
<option v-for="(option, key) in distanceUnitMasters" :key="key" :value="key">
|
||||
{{ `${option.long} (${option.short})` }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<br />
|
||||
<div class="buttons">
|
||||
<b-button type="is-primary" native-type="submit" tag="input"></b-button>
|
||||
|
||||
<b-button type="is-danger is-light" @click="resetUserForm">Cancel</b-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<b-table :data="users" hoverable mobile-cards detail-key="id" paginated per-page="10">
|
||||
<b-table-column v-slot="props" field="name" label="Name">
|
||||
{{ `${props.row.name}` }} <template v-if="props.row.id === user.id">(You)</template>
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" field="email" label="Email">
|
||||
{{ `${props.row.email}` }}
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" field="role" label="Role">
|
||||
{{ `${props.row.roleDetail.short}` }}
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" field="createdAt" label="Created" sortable date>
|
||||
{{ formatDate(props.row.createdAt) }}
|
||||
</b-table-column>
|
||||
</b-table>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
411
ui/src/router/views/vehicle.vue
Normal file
411
ui/src/router/views/vehicle.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import { parseAndFormatDate } from '@utils/format-date'
|
||||
import { mapState } from 'vuex'
|
||||
import axios from 'axios'
|
||||
import currencyFormtter from 'currency-formatter'
|
||||
import store from '@state/store'
|
||||
import ShareVehicle from '@components/shareVehicle.vue'
|
||||
export default {
|
||||
page() {
|
||||
return {
|
||||
title: this.vehicle.nickname,
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: `The vehicle profile for ${this.vehicle.nickname}.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
components: { Layout },
|
||||
props: {
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
fillups: [],
|
||||
expenses: [],
|
||||
attachments: [],
|
||||
showAttachmentForm: false,
|
||||
file: null,
|
||||
tryingToUpload: false,
|
||||
title: '',
|
||||
stats: null,
|
||||
users: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('users', ['me']),
|
||||
...mapState('auth', ['currentUser']),
|
||||
...mapState('utils', ['isMobile']),
|
||||
summaryObject() {
|
||||
if (this.stats == null) {
|
||||
return []
|
||||
}
|
||||
return this.stats.map((x) => {
|
||||
return [
|
||||
{
|
||||
label: 'Currency',
|
||||
value: x.currency,
|
||||
},
|
||||
{
|
||||
label: 'Total Expenditure',
|
||||
value: this.formatCurrency(x.expenditureTotal, x.currency),
|
||||
},
|
||||
{
|
||||
label: 'Fillup Costs',
|
||||
value: `${this.formatCurrency(x.expenditureFillups, x.currency)} (${x.countFillups})`,
|
||||
},
|
||||
{
|
||||
label: 'Other Expenses',
|
||||
value: `${this.formatCurrency(x.expenditureExpenses, x.currency)} (${x.countExpenses})`,
|
||||
},
|
||||
{
|
||||
label: 'Avg Fillup Expense',
|
||||
value: `${this.formatCurrency(x.avgFillupCost, x.currency)}`,
|
||||
},
|
||||
{
|
||||
label: 'Avg Fillup Qty',
|
||||
value: `${x.avgFuelQty} ${this.vehicle.fuelUnitDetail.short}`,
|
||||
},
|
||||
{
|
||||
label: 'Avg Fuel Cost',
|
||||
value: `${this.formatCurrency(x.avgFuelPrice, x.currency)} per ${this.vehicle.fuelUnitDetail.short}`,
|
||||
},
|
||||
]
|
||||
})
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
axios
|
||||
.get(`/api/vehicles/${this.vehicle.id}/fillups`)
|
||||
.then((response) => {
|
||||
this.fillups = response.data
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
|
||||
axios
|
||||
.get(`/api/vehicles/${this.vehicle.id}/expenses`)
|
||||
.then((response) => {
|
||||
this.expenses = response.data
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
|
||||
this.fetchAttachments()
|
||||
this.fetchVehicleStats()
|
||||
this.fetchVehicleUsers()
|
||||
},
|
||||
methods: {
|
||||
fetchAttachments() {
|
||||
store
|
||||
.dispatch('vehicles/fetchAttachmentsByVehicleId', { vehicleId: this.vehicle.id })
|
||||
.then((data) => {
|
||||
this.attachments = data
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
},
|
||||
|
||||
fetchVehicleStats() {
|
||||
store
|
||||
.dispatch('vehicles/fetchStatsByVehicleId', { vehicleId: this.vehicle.id })
|
||||
.then((data) => {
|
||||
this.stats = data
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
},
|
||||
fetchVehicleUsers() {
|
||||
store
|
||||
.dispatch('vehicles/fetchUsersByVehicleId', { vehicleId: this.vehicle.id })
|
||||
.then((data) => {
|
||||
this.users = data
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
},
|
||||
addAttachment() {
|
||||
if (this.file == null) {
|
||||
return
|
||||
}
|
||||
this.tryingToUpload = true
|
||||
const formData = new FormData()
|
||||
formData.append('file', this.file, this.file.name)
|
||||
formData.append('title', this.title)
|
||||
axios
|
||||
.post(`/api/vehicles/${this.vehicle.id}/attachments`, formData)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Quick Entry Created Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.file = null
|
||||
this.title = ''
|
||||
this.fetchAttachments()
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToUpload = false
|
||||
})
|
||||
},
|
||||
formatDate(date) {
|
||||
return parseAndFormatDate(date)
|
||||
},
|
||||
formatCurrency(number, currencyCode) {
|
||||
if (!currencyCode) {
|
||||
currencyCode = this.me.currency
|
||||
}
|
||||
return currencyFormtter.format(number, { code: currencyCode })
|
||||
},
|
||||
columnTdAttrs(row, column) {
|
||||
return null
|
||||
},
|
||||
hiddenDesktop(row, column) {
|
||||
return {
|
||||
class: 'is-hidden-desktop',
|
||||
}
|
||||
},
|
||||
hiddenMobile(row, column) {
|
||||
return {
|
||||
class: 'is-hidden-mobile',
|
||||
}
|
||||
},
|
||||
showShareVehicleModal() {
|
||||
this.$buefy.modal.open({
|
||||
parent: this,
|
||||
component: ShareVehicle,
|
||||
hasModalCard: false,
|
||||
props: { vehicle: this.vehicle },
|
||||
onCancel: (x) => {
|
||||
this.fetchVehicleUsers()
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="columns box">
|
||||
<div class="column is-two-thirds" :class="isMobile ? 'has-text-centered' : ''">
|
||||
<p class="title">{{ vehicle.nickname }} - {{ vehicle.registration }}</p>
|
||||
<p class="subtitle">
|
||||
{{ [vehicle.make, vehicle.model, vehicle.fuelTypeDetail.long].join(' | ') }}
|
||||
|
||||
<template v-if="users.length > 1">
|
||||
| Shared with :
|
||||
{{
|
||||
users
|
||||
.map((x) => {
|
||||
if (x.userId === me.id) {
|
||||
return 'You'
|
||||
} else {
|
||||
return x.name
|
||||
}
|
||||
})
|
||||
.join(', ')
|
||||
}}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-one-third buttons has-text-centered">
|
||||
<b-button
|
||||
v-if="vehicle.isOwner"
|
||||
type="is-primary"
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: 'vehicle-edit',
|
||||
props: { vehicle: vehicle },
|
||||
params: { id: vehicle.id },
|
||||
}"
|
||||
>Edit</b-button
|
||||
>
|
||||
<b-button type="is-primary" tag="router-link" :to="`/vehicles/${vehicle.id}/fillup`">Add Fillup</b-button>
|
||||
<b-button type="is-primary" tag="router-link" :to="`/vehicles/${vehicle.id}/expense`">Add Expense</b-button>
|
||||
<b-button v-if="vehicle.isOwner" type="is-primary" @click="showShareVehicleModal">Share</b-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(currencyLevel, index) in summaryObject" :key="index" class="level box">
|
||||
<div v-for="item in currencyLevel" :key="item.label" class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">{{ item.label }}</p>
|
||||
<p class="title is-4">{{ item.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h1 class="title is-4">Past Fillups</h1>
|
||||
|
||||
<b-table :data="fillups" hoverable mobile-cards :detailed="isMobile" detail-key="id" paginated per-page="10">
|
||||
<b-table-column v-slot="props" field="date" label="Date" :td-attrs="columnTdAttrs" sortable date>
|
||||
{{ formatDate(props.row.date) }}
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" field="fuelQuantity" label="Qty." :td-attrs="hiddenMobile" numeric>
|
||||
{{ `${props.row.fuelQuantity} ${props.row.fuelUnitDetail.short}` }}
|
||||
</b-table-column>
|
||||
<b-table-column
|
||||
v-slot="props"
|
||||
field="perUnitPrice"
|
||||
:label="'Price per ' + vehicle.fuelUnitDetail.short"
|
||||
:td-attrs="hiddenMobile"
|
||||
numeric
|
||||
sortable
|
||||
>
|
||||
{{ `${formatCurrency(props.row.perUnitPrice, props.row.currency)}` }}
|
||||
</b-table-column>
|
||||
<b-table-column v-if="isMobile" v-slot="props" field="totalAmount" label="Total" :td-attrs="hiddenDesktop" sortable numeric>
|
||||
{{ `${me.currency} ${props.row.totalAmount}` }} ({{ `${props.row.fuelQuantity} ${props.row.fuelUnitDetail.short}` }} @
|
||||
{{ `${me.currency} ${props.row.perUnitPrice}` }})
|
||||
</b-table-column>
|
||||
<b-table-column v-if="!isMobile" v-slot="props" field="totalAmount" label="Total" :td-attrs="hiddenMobile" sortable numeric>
|
||||
{{ `${formatCurrency(props.row.totalAmount, props.row.currency)}` }}
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" width="20" field="isTankFull" label="Tank Full" :td-attrs="hiddenMobile">
|
||||
<b-icon pack="fas" :icon="props.row.isTankFull ? 'check' : 'times'" type="is-info"> </b-icon>
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" field="odoReading" label="Odometer Reading" :td-attrs="hiddenMobile" numeric>
|
||||
{{ `${props.row.odoReading} ${me.distanceUnitDetail.short}` }}
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" field="fillingStation" label="Fillup Station" :td-attrs="hiddenMobile">
|
||||
{{ `${props.row.fillingStation}` }}
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" field="userId" label="By" :td-attrs="hiddenMobile">
|
||||
{{ `${props.row.user.name}` }}
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props">
|
||||
<b-button
|
||||
type="is-ghost"
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: 'vehicle-edit-fillup',
|
||||
props: { fillup: props.row, vehicle: vehicle },
|
||||
params: { fillupId: props.row.id, id: vehicle.id },
|
||||
}"
|
||||
>
|
||||
<b-icon pack="fas" icon="edit" type="is-info"> </b-icon
|
||||
></b-button>
|
||||
</b-table-column>
|
||||
<template v-slot:empty> No Fillups so far</template>
|
||||
<template v-slot:detail="props">
|
||||
<p>{{ props.row.id }}</p>
|
||||
</template>
|
||||
</b-table>
|
||||
</div>
|
||||
<br />
|
||||
<div class="box">
|
||||
<h1 class="title is-4">Past Expenses</h1>
|
||||
|
||||
<b-table :data="expenses" hoverable mobile-cards paginated per-page="10">
|
||||
<b-table-column v-slot="props" field="date" label="Date" :td-attrs="columnTdAttrs" date>
|
||||
{{ formatDate(props.row.date) }}
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" field="expenseType" label="Expense Type">
|
||||
{{ `${props.row.expenseType}` }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="amount" label="Total" :td-attrs="hiddenMobile" sortable numeric>
|
||||
{{ `${formatCurrency(props.row.amount, props.row.currency)}` }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="odoReading" label="Odometer Reading" :td-attrs="columnTdAttrs" numeric>
|
||||
{{ `${props.row.odoReading} ${me.distanceUnitDetail.short}` }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="userId" label="By" :td-attrs="columnTdAttrs">
|
||||
{{ `${props.row.user.name}` }}
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props">
|
||||
<b-button
|
||||
type="is-ghost"
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: 'vehicle-edit-expense',
|
||||
props: { expense: props.row, vehicle: vehicle },
|
||||
params: { expenseId: props.row.id, id: vehicle.id },
|
||||
}"
|
||||
>
|
||||
<b-icon pack="fas" icon="edit" type="is-info"> </b-icon
|
||||
></b-button>
|
||||
</b-table-column>
|
||||
<template v-slot:empty> No Expenses so far</template>
|
||||
</b-table>
|
||||
</div>
|
||||
<br />
|
||||
<div class="box">
|
||||
<div class="columns">
|
||||
<div class="column is-three-quarters"> <h1 class="title is-4">Attachments</h1></div>
|
||||
<div class="column buttons">
|
||||
<b-button type="is-primary" @click="showAttachmentForm = true">
|
||||
Add Attachment
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showAttachmentForm" class="box">
|
||||
<div class="columns">
|
||||
<div class="column"></div>
|
||||
<div class="column is-two-thirds">
|
||||
<form @submit.prevent="addAttachment">
|
||||
<b-field :grouped="!isMobile">
|
||||
<b-field class="file is-primary" :class="{ 'has-name': !!file }">
|
||||
<b-upload v-model="file" class="file-label" required>
|
||||
<span class="file-cta">
|
||||
<b-icon class="file-icon" icon="upload"></b-icon>
|
||||
<span class="file-label">Choose File</span>
|
||||
</span>
|
||||
<span v-if="file" class="file-name" :class="isMobile ? 'file-name-mobile' : 'file-name-desktop'">
|
||||
{{ file.name }}
|
||||
</span>
|
||||
</b-upload>
|
||||
</b-field>
|
||||
<b-field>
|
||||
<b-input v-model="title" required placeholder="Label for this file"></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field class="buttons">
|
||||
<b-button tag="input" native-type="submit" :disabled="tryingToUpload" type="is-primary" label="Upload File" value="Upload File">
|
||||
</b-button>
|
||||
<b-button
|
||||
tag="input"
|
||||
native-type="submit"
|
||||
:disabled="tryingToUpload"
|
||||
type="is-danger"
|
||||
label="Upload File"
|
||||
value="Cancel"
|
||||
@click="showAttachmentForm = false"
|
||||
>
|
||||
</b-button>
|
||||
</b-field>
|
||||
</b-field>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-table :data="attachments" hoverable mobile-cards>
|
||||
<b-table-column v-slot="props" field="title" label="Title" :td-attrs="columnTdAttrs">
|
||||
{{ `${props.row.title}` }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="originalName" label="Name" :td-attrs="columnTdAttrs">
|
||||
{{ `${props.row.originalName}` }}
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" field="id" label="Download" :td-attrs="columnTdAttrs">
|
||||
<b-button tag="a" :href="`/api/attachments/${props.row.id}/file?access_token=${currentUser.token}`" :download="props.row.originalName">
|
||||
<b-icon type="is-primary" icon="download"></b-icon>
|
||||
</b-button>
|
||||
</b-table-column>
|
||||
<template v-slot:empty> No Attachments so far</template>
|
||||
</b-table>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
10
ui/src/state/helpers.js
Normal file
10
ui/src/state/helpers.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { mapState, mapGetters, mapActions } from 'vuex'
|
||||
|
||||
export const authComputed = {
|
||||
...mapState('auth', {
|
||||
currentUser: (state) => state.currentUser,
|
||||
}),
|
||||
...mapGetters('auth', ['loggedIn']),
|
||||
}
|
||||
|
||||
export const authMethods = mapActions('auth', ['logIn', 'logOut'])
|
||||
95
ui/src/state/modules/auth.js
Normal file
95
ui/src/state/modules/auth.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const state = {
|
||||
currentUser: getSavedState('auth.currentUser'),
|
||||
initialized: getSavedState('system.initialized'),
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
SET_CURRENT_USER(state, newValue) {
|
||||
state.currentUser = newValue
|
||||
saveState('auth.currentUser', newValue)
|
||||
setDefaultAuthHeaders(state)
|
||||
},
|
||||
SET_INITIALIZATION_STATUS(state, newValue) {
|
||||
state.initialized = newValue
|
||||
saveState('system.initialized', newValue)
|
||||
},
|
||||
}
|
||||
|
||||
export const getters = {
|
||||
// Whether the user is currently logged in.
|
||||
loggedIn(state) {
|
||||
return !!state.currentUser
|
||||
},
|
||||
isInitialized(state) {
|
||||
return state.initialized == null || state.initialized.initialized
|
||||
},
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
// This is automatically run in `src/state/store.js` when the app
|
||||
// starts, along with any other actions named `init` in other modules.
|
||||
init({ state, dispatch }) {
|
||||
dispatch('systemInitialized')
|
||||
setDefaultAuthHeaders(state)
|
||||
dispatch('validate')
|
||||
},
|
||||
|
||||
logIn({ commit, dispatch, getters }, { username, password } = {}) {
|
||||
if (getters.loggedIn) return dispatch('validate')
|
||||
|
||||
return axios.post('/api/login', { email: username, password }).then((response) => {
|
||||
const user = response.data
|
||||
commit('SET_CURRENT_USER', user)
|
||||
dispatch('vehicles/fetchMasters', null, { root: true })
|
||||
return user
|
||||
})
|
||||
},
|
||||
|
||||
// Logs out the current user.
|
||||
logOut({ commit }) {
|
||||
commit('SET_CURRENT_USER', null)
|
||||
},
|
||||
|
||||
// Validates the current user's token and refreshes it
|
||||
// with new data from the API.
|
||||
validate({ commit, state }) {
|
||||
if (!state.currentUser) return Promise.resolve(null)
|
||||
|
||||
return axios
|
||||
.post('/api/refresh', { refreshToken: state.currentUser.refreshToken })
|
||||
.then((response) => {
|
||||
const user = response.data
|
||||
commit('SET_CURRENT_USER', user)
|
||||
return user
|
||||
})
|
||||
.catch((ex) => {
|
||||
commit('SET_CURRENT_USER', null)
|
||||
})
|
||||
},
|
||||
|
||||
systemInitialized({ commit, state }) {
|
||||
return axios.get('/api/system/status').then((response) => {
|
||||
const data = response.data
|
||||
commit('SET_INITIALIZATION_STATUS', data)
|
||||
return data
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ===
|
||||
// Private helpers
|
||||
// ===
|
||||
|
||||
function getSavedState(key) {
|
||||
return JSON.parse(window.localStorage.getItem(key))
|
||||
}
|
||||
|
||||
function saveState(key, state) {
|
||||
window.localStorage.setItem(key, JSON.stringify(state))
|
||||
}
|
||||
|
||||
function setDefaultAuthHeaders(state) {
|
||||
axios.defaults.headers.common.Authorization = state.currentUser ? state.currentUser.token : ''
|
||||
}
|
||||
121
ui/src/state/modules/auth.unit.js
Normal file
121
ui/src/state/modules/auth.unit.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import axios from 'axios'
|
||||
import * as authModule from './auth'
|
||||
|
||||
describe('@state/modules/auth', () => {
|
||||
it('exports a valid Vuex module', () => {
|
||||
expect(authModule).toBeAVuexModule()
|
||||
})
|
||||
|
||||
describe('in a store', () => {
|
||||
let store
|
||||
beforeEach(() => {
|
||||
store = createModuleStore(authModule)
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('mutations.SET_CURRENT_USER correctly sets axios default authorization header', () => {
|
||||
axios.defaults.headers.common.Authorization = ''
|
||||
|
||||
store.commit('SET_CURRENT_USER', { token: 'some-token' })
|
||||
expect(axios.defaults.headers.common.Authorization).toEqual('some-token')
|
||||
|
||||
store.commit('SET_CURRENT_USER', null)
|
||||
expect(axios.defaults.headers.common.Authorization).toEqual('')
|
||||
})
|
||||
|
||||
it('mutations.SET_CURRENT_USER correctly saves currentUser in localStorage', () => {
|
||||
let savedCurrentUser = JSON.parse(
|
||||
window.localStorage.getItem('auth.currentUser')
|
||||
)
|
||||
expect(savedCurrentUser).toEqual(null)
|
||||
|
||||
const expectedCurrentUser = { token: 'some-token' }
|
||||
store.commit('SET_CURRENT_USER', expectedCurrentUser)
|
||||
|
||||
savedCurrentUser = JSON.parse(
|
||||
window.localStorage.getItem('auth.currentUser')
|
||||
)
|
||||
expect(savedCurrentUser).toEqual(expectedCurrentUser)
|
||||
})
|
||||
|
||||
it('getters.loggedIn returns true when currentUser is an object', () => {
|
||||
store.commit('SET_CURRENT_USER', {})
|
||||
expect(store.getters.loggedIn).toEqual(true)
|
||||
})
|
||||
|
||||
it('getters.loggedIn returns false when currentUser is null', () => {
|
||||
store.commit('SET_CURRENT_USER', null)
|
||||
expect(store.getters.loggedIn).toEqual(false)
|
||||
})
|
||||
|
||||
it('actions.logIn resolves to a refreshed currentUser when already logged in', () => {
|
||||
expect.assertions(2)
|
||||
|
||||
store.commit('SET_CURRENT_USER', { token: validUserExample.token })
|
||||
return store.dispatch('logIn').then((user) => {
|
||||
expect(user).toEqual(validUserExample)
|
||||
expect(store.state.currentUser).toEqual(validUserExample)
|
||||
})
|
||||
})
|
||||
|
||||
it('actions.logIn commits the currentUser and resolves to the user when NOT already logged in and provided a correct username and password', () => {
|
||||
expect.assertions(2)
|
||||
|
||||
return store
|
||||
.dispatch('logIn', { username: 'admin', password: 'password' })
|
||||
.then((user) => {
|
||||
expect(user).toEqual(validUserExample)
|
||||
expect(store.state.currentUser).toEqual(validUserExample)
|
||||
})
|
||||
})
|
||||
|
||||
it('actions.logIn rejects with 401 when NOT already logged in and provided an incorrect username and password', () => {
|
||||
expect.assertions(1)
|
||||
|
||||
return store
|
||||
.dispatch('logIn', {
|
||||
username: 'bad username',
|
||||
password: 'bad password',
|
||||
})
|
||||
.catch((error) => {
|
||||
expect(error.message).toEqual('Request failed with status code 401')
|
||||
})
|
||||
})
|
||||
|
||||
it('actions.validate resolves to null when currentUser is null', () => {
|
||||
expect.assertions(1)
|
||||
|
||||
store.commit('SET_CURRENT_USER', null)
|
||||
return store.dispatch('validate').then((user) => {
|
||||
expect(user).toEqual(null)
|
||||
})
|
||||
})
|
||||
|
||||
it('actions.validate resolves to null when currentUser contains an invalid token', () => {
|
||||
expect.assertions(2)
|
||||
|
||||
store.commit('SET_CURRENT_USER', { token: 'invalid-token' })
|
||||
return store.dispatch('validate').then((user) => {
|
||||
expect(user).toEqual(null)
|
||||
expect(store.state.currentUser).toEqual(null)
|
||||
})
|
||||
})
|
||||
|
||||
it('actions.validate resolves to a user when currentUser contains a valid token', () => {
|
||||
expect.assertions(2)
|
||||
|
||||
store.commit('SET_CURRENT_USER', { token: validUserExample.token })
|
||||
return store.dispatch('validate').then((user) => {
|
||||
expect(user).toEqual(validUserExample)
|
||||
expect(store.state.currentUser).toEqual(validUserExample)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const validUserExample = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
name: 'Vue Master',
|
||||
token: 'valid-token-for-admin',
|
||||
}
|
||||
81
ui/src/state/modules/index.js
Normal file
81
ui/src/state/modules/index.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Register each file as a corresponding Vuex module. Module nesting
|
||||
// will mirror [sub-]directory hierarchy and modules are namespaced
|
||||
// as the camelCase equivalent of their file name.
|
||||
|
||||
import camelCase from 'lodash/camelCase'
|
||||
|
||||
const modulesCache = {}
|
||||
const storeData = { modules: {} }
|
||||
|
||||
;(function updateModules() {
|
||||
// Allow us to dynamically require all Vuex module files.
|
||||
// https://webpack.js.org/guides/dependency-management/#require-context
|
||||
const requireModule = require.context(
|
||||
// Search for files in the current directory.
|
||||
'.',
|
||||
// Search for files in subdirectories.
|
||||
true,
|
||||
// Include any .js files that are not this file or a unit test.
|
||||
/^((?!index|\.unit\.).)*\.js$/
|
||||
)
|
||||
|
||||
// For every Vuex module...
|
||||
requireModule.keys().forEach((fileName) => {
|
||||
const moduleDefinition =
|
||||
requireModule(fileName).default || requireModule(fileName)
|
||||
|
||||
// Skip the module during hot reload if it refers to the
|
||||
// same module definition as the one we have cached.
|
||||
if (modulesCache[fileName] === moduleDefinition) return
|
||||
|
||||
// Update the module cache, for efficient hot reloading.
|
||||
modulesCache[fileName] = moduleDefinition
|
||||
|
||||
// Get the module path as an array.
|
||||
const modulePath = fileName
|
||||
// Remove the "./" from the beginning.
|
||||
.replace(/^\.\//, '')
|
||||
// Remove the file extension from the end.
|
||||
.replace(/\.\w+$/, '')
|
||||
// Split nested modules into an array path.
|
||||
.split(/\//)
|
||||
// camelCase all module namespaces and names.
|
||||
.map(camelCase)
|
||||
|
||||
// Get the modules object for the current path.
|
||||
const { modules } = getNamespace(storeData, modulePath)
|
||||
|
||||
// Add the module to our modules object.
|
||||
modules[modulePath.pop()] = {
|
||||
// Modules are namespaced by default.
|
||||
namespaced: true,
|
||||
...moduleDefinition,
|
||||
}
|
||||
})
|
||||
|
||||
// If the environment supports hot reloading...
|
||||
if (module.hot) {
|
||||
// Whenever any Vuex module is updated...
|
||||
module.hot.accept(requireModule.id, () => {
|
||||
// Update `storeData.modules` with the latest definitions.
|
||||
updateModules()
|
||||
// Trigger a hot update in the store.
|
||||
require('../store').default.hotUpdate({ modules: storeData.modules })
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
// Recursively get the namespace of a Vuex module, even if nested.
|
||||
function getNamespace(subtree, path) {
|
||||
if (path.length === 1) return subtree
|
||||
|
||||
const namespace = path.shift()
|
||||
subtree.modules[namespace] = {
|
||||
modules: {},
|
||||
namespaced: true,
|
||||
...subtree.modules[namespace],
|
||||
}
|
||||
return getNamespace(subtree.modules[namespace], path)
|
||||
}
|
||||
|
||||
export default storeData.modules
|
||||
88
ui/src/state/modules/users.js
Normal file
88
ui/src/state/modules/users.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const state = {
|
||||
cached: [],
|
||||
me: null,
|
||||
}
|
||||
|
||||
export const getters = {}
|
||||
|
||||
export const mutations = {
|
||||
CACHE_USER(state, newUser) {
|
||||
state.cached.push(newUser)
|
||||
},
|
||||
CACHE_MY_USER(state, newUser) {
|
||||
state.me = newUser
|
||||
},
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
init({ dispatch, rootState }) {
|
||||
const { currentUser } = rootState.auth
|
||||
if (currentUser != null) {
|
||||
dispatch('me')
|
||||
}
|
||||
},
|
||||
forceMe({ commit, state }) {
|
||||
return axios
|
||||
.get('/api/me')
|
||||
.then((response) => {
|
||||
commit('CACHE_MY_USER', response.data)
|
||||
return response.data
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
commit('CACHE_MY_USER', null)
|
||||
} else {
|
||||
console.warn(error)
|
||||
}
|
||||
return null
|
||||
})
|
||||
},
|
||||
users() {
|
||||
return axios
|
||||
.get('/api/users')
|
||||
.then((response) => {
|
||||
return response.data
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
} else {
|
||||
console.warn(error)
|
||||
}
|
||||
return null
|
||||
})
|
||||
},
|
||||
me({ commit, state }) {
|
||||
if (state.me) {
|
||||
return Promise.resolve(state.me)
|
||||
}
|
||||
return axios
|
||||
.get('/api/me')
|
||||
.then((response) => {
|
||||
commit('CACHE_MY_USER', response.data)
|
||||
return response.data
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
commit('CACHE_MY_USER', null)
|
||||
} else {
|
||||
console.warn(error)
|
||||
}
|
||||
return null
|
||||
})
|
||||
},
|
||||
fetchUser({ commit, state, rootState }, { username }) {
|
||||
// 1. Check if we already have the user as a current user.
|
||||
const { currentUser } = rootState.auth
|
||||
if (currentUser && currentUser.username === username) {
|
||||
return Promise.resolve(currentUser)
|
||||
}
|
||||
|
||||
// 2. Check if we've already fetched and cached the user.
|
||||
const matchedUser = state.cached.find((user) => user.username === username)
|
||||
if (matchedUser) {
|
||||
return Promise.resolve(matchedUser)
|
||||
}
|
||||
},
|
||||
}
|
||||
64
ui/src/state/modules/users.unit.js
Normal file
64
ui/src/state/modules/users.unit.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as usersModule from './users'
|
||||
|
||||
describe('@state/modules/users', () => {
|
||||
it('exports a valid Vuex module', () => {
|
||||
expect(usersModule).toBeAVuexModule()
|
||||
})
|
||||
|
||||
describe('in a store when logged in', () => {
|
||||
let store
|
||||
beforeEach(() => {
|
||||
store = createModuleStore(usersModule, {
|
||||
currentUser: validUserExample,
|
||||
})
|
||||
})
|
||||
|
||||
it('actions.fetchUser returns the current user without fetching it again', () => {
|
||||
expect.assertions(2)
|
||||
|
||||
const axios = require('axios')
|
||||
const originalAxiosGet = axios.get
|
||||
axios.get = jest.fn()
|
||||
|
||||
return store.dispatch('fetchUser', { username: 'admin' }).then((user) => {
|
||||
expect(user).toEqual(validUserExample)
|
||||
expect(axios.get).not.toHaveBeenCalled()
|
||||
axios.get = originalAxiosGet
|
||||
})
|
||||
})
|
||||
|
||||
it('actions.fetchUser rejects with 400 when provided a bad username', () => {
|
||||
expect.assertions(1)
|
||||
|
||||
return store
|
||||
.dispatch('fetchUser', { username: 'bad-username' })
|
||||
.catch((error) => {
|
||||
expect(error.response.status).toEqual(400)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('in a store when logged out', () => {
|
||||
let store
|
||||
beforeEach(() => {
|
||||
store = createModuleStore(usersModule)
|
||||
})
|
||||
|
||||
it('actions.fetchUser rejects with 401', () => {
|
||||
expect.assertions(1)
|
||||
|
||||
return store
|
||||
.dispatch('fetchUser', { username: 'admin' })
|
||||
.catch((error) => {
|
||||
expect(error.response.status).toEqual(401)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const validUserExample = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
name: 'Vue Master',
|
||||
token: 'valid-token-for-admin',
|
||||
}
|
||||
49
ui/src/state/modules/utils.js
Normal file
49
ui/src/state/modules/utils.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const state = {
|
||||
isMobile: false,
|
||||
settings: null,
|
||||
}
|
||||
export const mutations = {
|
||||
CACHE_ISMOBILE(state, isMobile) {
|
||||
state.isMobile = isMobile
|
||||
},
|
||||
CACHE_SETTINGS(state, settings) {
|
||||
state.settings = settings
|
||||
},
|
||||
}
|
||||
export const getters = {}
|
||||
export const actions = {
|
||||
init({ dispatch, rootState }) {
|
||||
dispatch('checkSize')
|
||||
const { currentUser } = rootState.auth
|
||||
if (currentUser) {
|
||||
dispatch('getSettings')
|
||||
}
|
||||
},
|
||||
checkSize({ commit }) {
|
||||
commit('CACHE_ISMOBILE', window.innerWidth < 600)
|
||||
return window.innerWidth < 600
|
||||
},
|
||||
getSettings({ commit }) {
|
||||
return axios.get(`/api/settings`).then((response) => {
|
||||
const data = response.data
|
||||
commit('CACHE_SETTINGS', data)
|
||||
return data
|
||||
})
|
||||
},
|
||||
saveSettings({ commit, dispatch }, { settings }) {
|
||||
return axios.post(`/api/settings`, { ...settings }).then((response) => {
|
||||
const data = response.data
|
||||
dispatch('getSettings')
|
||||
return data
|
||||
})
|
||||
},
|
||||
saveUserSettings({ commit, dispatch }, { settings }) {
|
||||
return axios.post(`/api/me/settings`, { ...settings }).then((response) => {
|
||||
const data = response.data
|
||||
dispatch('users/forceMe', {}, { root: true }).then((data) => {})
|
||||
return data
|
||||
})
|
||||
},
|
||||
}
|
||||
150
ui/src/state/modules/vehicles.js
Normal file
150
ui/src/state/modules/vehicles.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import axios from 'axios'
|
||||
import { filter } from 'lodash'
|
||||
|
||||
import parseISO from 'date-fns/parseISO'
|
||||
export const state = {
|
||||
vehicles: [],
|
||||
roleMasters: [],
|
||||
fuelUnitMasters: [],
|
||||
distanceUnitMasters: [],
|
||||
currencyMasters: [],
|
||||
fuelTypeMasters: [],
|
||||
quickEntries: [],
|
||||
vehicleStats: new Map(),
|
||||
}
|
||||
|
||||
export const getters = {
|
||||
unprocessedQuickEntries: (state) => {
|
||||
return filter(state.quickEntries, (o) => o.processDate == null)
|
||||
},
|
||||
processedQuickEntries: (state) => {
|
||||
return filter(state.quickEntries, (o) => o.processDate != null)
|
||||
},
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
CACHE_VEHICLE(state, newVehicles) {
|
||||
state.vehicles = newVehicles
|
||||
},
|
||||
CACHE_VEHICLE_STATS(state, stats) {
|
||||
state.vehicleStats.set(stats.vehicleId, stats)
|
||||
},
|
||||
CACHE_FUEL_UNIT_MASTERS(state, masters) {
|
||||
state.fuelUnitMasters = masters
|
||||
},
|
||||
CACHE_DISTANCE_UNIT_MASTERS(state, masters) {
|
||||
state.distanceUnitMasters = masters
|
||||
},
|
||||
CACHE_FUEL_TYPE_MASTERS(state, masters) {
|
||||
state.fuelTypeMasters = masters
|
||||
},
|
||||
CACHE_CURRENCY_MASTERS(state, masters) {
|
||||
state.currencyMasters = masters
|
||||
},
|
||||
CACHE_QUICK_ENTRIES(state, entries) {
|
||||
state.quickEntries = entries
|
||||
},
|
||||
CACHE_ROLE_MASTERS(state, roles) {
|
||||
state.roleMasters = roles
|
||||
},
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
init({ dispatch, rootState }) {
|
||||
const { currentUser } = rootState.auth
|
||||
if (currentUser) {
|
||||
dispatch('fetchVehicles')
|
||||
dispatch('fetchMasters')
|
||||
dispatch('fetchQuickEntries', { force: true })
|
||||
}
|
||||
},
|
||||
fetchMasters({ commit, state, rootState }) {
|
||||
return axios.get('/api/masters').then((response) => {
|
||||
const fuelUnitMasters = response.data.fuelUnits
|
||||
const fuelTypeMasters = response.data.fuelTypes
|
||||
commit('CACHE_FUEL_UNIT_MASTERS', fuelUnitMasters)
|
||||
commit('CACHE_FUEL_TYPE_MASTERS', fuelTypeMasters)
|
||||
commit('CACHE_CURRENCY_MASTERS', response.data.currencies)
|
||||
commit('CACHE_DISTANCE_UNIT_MASTERS', response.data.distanceUnits)
|
||||
commit('CACHE_ROLE_MASTERS', response.data.roles)
|
||||
return response.data
|
||||
})
|
||||
},
|
||||
fetchVehicles({ commit, state, rootState }) {
|
||||
return axios.get('/api/me/vehicles').then((response) => {
|
||||
const data = response.data
|
||||
commit('CACHE_VEHICLE', data)
|
||||
return data
|
||||
})
|
||||
},
|
||||
fetchQuickEntries({ commit, state, rootState }, { force }) {
|
||||
if (state.quickEntries && !force) {
|
||||
return Promise.resolve(state.quickEntries)
|
||||
}
|
||||
return axios.get('/api/me/quickEntries').then((response) => {
|
||||
const data = response.data
|
||||
commit('CACHE_QUICK_ENTRIES', data)
|
||||
return data
|
||||
})
|
||||
},
|
||||
fetchVehicleById({ commit, state, rootState }, { vehicleId }) {
|
||||
const matchedVehicle = state.vehicles.find((vehicle) => vehicle.id === vehicleId)
|
||||
if (matchedVehicle) {
|
||||
return Promise.resolve(matchedVehicle)
|
||||
}
|
||||
return axios.get('/api/vehicles/' + vehicleId).then((response) => {
|
||||
const data = response.data
|
||||
// commit('CACHE_VEHICLE', data)
|
||||
return data
|
||||
})
|
||||
},
|
||||
fetchFillupById({ commit, state, rootState }, { vehicleId, fillupId }) {
|
||||
return axios.get(`/api/vehicles/${vehicleId}/fillups/${fillupId}`).then((response) => {
|
||||
const data = response.data
|
||||
data.date = parseISO(data.date)
|
||||
return data
|
||||
})
|
||||
},
|
||||
fetchExpenseById({ commit, state, rootState }, { vehicleId, expenseId }) {
|
||||
return axios.get(`/api/vehicles/${vehicleId}/expenses/${expenseId}`).then((response) => {
|
||||
const data = response.data
|
||||
data.date = parseISO(data.date)
|
||||
return data
|
||||
})
|
||||
},
|
||||
fetchAttachmentsByVehicleId({ commit, state, rootState }, { vehicleId }) {
|
||||
return axios.get(`/api/vehicles/${vehicleId}/attachments`).then((response) => {
|
||||
const data = response.data
|
||||
|
||||
return data
|
||||
})
|
||||
},
|
||||
fetchUsersByVehicleId({ commit, state, rootState }, { vehicleId, force }) {
|
||||
return axios.get(`/api/vehicles/${vehicleId}/users`).then((response) => {
|
||||
const data = response.data
|
||||
// data.vehicleId = vehicleId
|
||||
// commit('CACHE_VEHICLE_STATS', data)
|
||||
|
||||
return data
|
||||
})
|
||||
},
|
||||
fetchStatsByVehicleId({ commit, state, rootState }, { vehicleId, force }) {
|
||||
if (state.vehicleStats.has(vehicleId) && !force) {
|
||||
return Promise.resolve(state.vehicleStats.get(vehicleId))
|
||||
}
|
||||
return axios.get(`/api/vehicles/${vehicleId}/stats`).then((response) => {
|
||||
const data = response.data
|
||||
data.vehicleId = vehicleId
|
||||
commit('CACHE_VEHICLE_STATS', data)
|
||||
|
||||
return data
|
||||
})
|
||||
},
|
||||
setQuickEntryAsProcessed({ commit, state, rootState, dispatch }, { id }) {
|
||||
return axios.post(`/api/quickEntries/${id}/process`, {}).then((response) => {
|
||||
const data = response.data
|
||||
dispatch('fetchQuickEntries', { force: true })
|
||||
return data
|
||||
})
|
||||
},
|
||||
}
|
||||
21
ui/src/state/store.js
Normal file
21
ui/src/state/store.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import dispatchActionForAllModules from '@utils/dispatch-action-for-all-modules'
|
||||
|
||||
import modules from './modules'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
const store = new Vuex.Store({
|
||||
modules,
|
||||
// Enable strict mode in development to get a warning
|
||||
// when mutating state outside of a mutation.
|
||||
// https://vuex.vuejs.org/guide/strict.html
|
||||
strict: process.env.NODE_ENV !== 'production',
|
||||
})
|
||||
|
||||
export default store
|
||||
|
||||
// Automatically run the `init` action for every module,
|
||||
// if one exists.
|
||||
dispatchActionForAllModules('init')
|
||||
40
ui/src/utils/dispatch-action-for-all-modules.js
Normal file
40
ui/src/utils/dispatch-action-for-all-modules.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import allModules from '@state/modules'
|
||||
import store from '@state/store'
|
||||
|
||||
export default function dispatchActionForAllModules(
|
||||
actionName,
|
||||
{ modules = allModules, modulePrefix = '', flags = {} } = {}
|
||||
) {
|
||||
// For every module...
|
||||
for (const moduleName in modules) {
|
||||
const moduleDefinition = modules[moduleName]
|
||||
|
||||
// If the action is defined on the module...
|
||||
if (moduleDefinition.actions && moduleDefinition.actions[actionName]) {
|
||||
// Dispatch the action if the module is namespaced. Otherwise,
|
||||
// set a flag to dispatch the action globally at the end.
|
||||
if (moduleDefinition.namespaced) {
|
||||
store.dispatch(`${modulePrefix}${moduleName}/${actionName}`)
|
||||
} else {
|
||||
flags.dispatchGlobal = true
|
||||
}
|
||||
}
|
||||
|
||||
// If there are any nested sub-modules...
|
||||
if (moduleDefinition.modules) {
|
||||
// Also dispatch the action for these sub-modules.
|
||||
dispatchActionForAllModules(actionName, {
|
||||
modules: moduleDefinition.modules,
|
||||
modulePrefix: modulePrefix + moduleName + '/',
|
||||
flags,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the root and at least one non-namespaced module
|
||||
// was found with the action...
|
||||
if (!modulePrefix && flags.dispatchGlobal) {
|
||||
// Dispatch the action globally.
|
||||
store.dispatch(actionName)
|
||||
}
|
||||
}
|
||||
138
ui/src/utils/dispatch-action-for-all-modules.unit.js
Normal file
138
ui/src/utils/dispatch-action-for-all-modules.unit.js
Normal file
@@ -0,0 +1,138 @@
|
||||
describe('@utils/dispatch-action-for-all-modules', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
it('dispatches actions from NOT namespaced modules', () => {
|
||||
jest.doMock('@state/modules', () => ({
|
||||
moduleA: {
|
||||
actions: {
|
||||
someAction: jest.fn(),
|
||||
otherAction: jest.fn(),
|
||||
},
|
||||
},
|
||||
moduleB: {
|
||||
actions: {
|
||||
someAction: jest.fn(),
|
||||
otherAction: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
require('./dispatch-action-for-all-modules').default('someAction')
|
||||
|
||||
const { moduleA, moduleB } = require('@state/modules')
|
||||
expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1)
|
||||
expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1)
|
||||
expect(moduleA.actions.otherAction).not.toHaveBeenCalled()
|
||||
expect(moduleB.actions.otherAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches actions from namespaced modules', () => {
|
||||
jest.doMock('@state/modules', () => ({
|
||||
moduleA: {
|
||||
namespaced: true,
|
||||
actions: {
|
||||
someAction: jest.fn(),
|
||||
otherAction: jest.fn(),
|
||||
},
|
||||
},
|
||||
moduleB: {
|
||||
namespaced: true,
|
||||
actions: {
|
||||
someAction: jest.fn(),
|
||||
otherAction: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
require('./dispatch-action-for-all-modules').default('someAction')
|
||||
|
||||
const { moduleA, moduleB } = require('@state/modules')
|
||||
expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1)
|
||||
expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1)
|
||||
expect(moduleA.actions.otherAction).not.toHaveBeenCalled()
|
||||
expect(moduleB.actions.otherAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches actions from deeply nested NOT namespaced modules', () => {
|
||||
jest.doMock('@state/modules', () => ({
|
||||
moduleA: {
|
||||
actions: {
|
||||
someAction: jest.fn(),
|
||||
otherAction: jest.fn(),
|
||||
},
|
||||
modules: {
|
||||
moduleB: {
|
||||
actions: {
|
||||
someAction: jest.fn(),
|
||||
otherAction: jest.fn(),
|
||||
},
|
||||
modules: {
|
||||
moduleC: {
|
||||
actions: {
|
||||
someAction: jest.fn(),
|
||||
otherAction: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
require('./dispatch-action-for-all-modules').default('someAction')
|
||||
|
||||
const { moduleA } = require('@state/modules')
|
||||
const { moduleB } = moduleA.modules
|
||||
const { moduleC } = moduleB.modules
|
||||
expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1)
|
||||
expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1)
|
||||
expect(moduleC.actions.someAction).toHaveBeenCalledTimes(1)
|
||||
expect(moduleA.actions.otherAction).not.toHaveBeenCalled()
|
||||
expect(moduleB.actions.otherAction).not.toHaveBeenCalled()
|
||||
expect(moduleC.actions.otherAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches actions from deeply nested namespaced modules', () => {
|
||||
jest.doMock('@state/modules', () => ({
|
||||
moduleA: {
|
||||
namespaced: true,
|
||||
actions: {
|
||||
someAction: jest.fn(),
|
||||
otherAction: jest.fn(),
|
||||
},
|
||||
modules: {
|
||||
moduleB: {
|
||||
namespaced: true,
|
||||
actions: {
|
||||
someAction: jest.fn(),
|
||||
otherAction: jest.fn(),
|
||||
},
|
||||
modules: {
|
||||
moduleC: {
|
||||
namespaced: true,
|
||||
actions: {
|
||||
someAction: jest.fn(),
|
||||
otherAction: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
require('./dispatch-action-for-all-modules').default('someAction')
|
||||
|
||||
const { moduleA } = require('@state/modules')
|
||||
const { moduleB } = moduleA.modules
|
||||
const { moduleC } = moduleB.modules
|
||||
expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1)
|
||||
expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1)
|
||||
expect(moduleC.actions.someAction).toHaveBeenCalledTimes(1)
|
||||
expect(moduleA.actions.otherAction).not.toHaveBeenCalled()
|
||||
expect(moduleB.actions.otherAction).not.toHaveBeenCalled()
|
||||
expect(moduleC.actions.otherAction).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
8
ui/src/utils/format-date-relative.js
Normal file
8
ui/src/utils/format-date-relative.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// https://date-fns.org/docs/formatDistance
|
||||
import formatDistance from 'date-fns/formatDistance'
|
||||
// https://date-fns.org/docs/isToday
|
||||
import isToday from 'date-fns/isToday'
|
||||
|
||||
export default function formatDateRelative(fromDate, toDate = new Date()) {
|
||||
return formatDistance(fromDate, toDate) + (isToday(toDate) ? ' ago' : '')
|
||||
}
|
||||
30
ui/src/utils/format-date-relative.unit.js
Normal file
30
ui/src/utils/format-date-relative.unit.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import formatDateRelative from './format-date-relative'
|
||||
|
||||
describe('@utils/format-date-relative', () => {
|
||||
it('correctly compares dates years apart', () => {
|
||||
const fromDate = new Date(2002, 5, 1)
|
||||
const toDate = new Date(2017, 4, 10)
|
||||
const timeAgoInWords = formatDateRelative(fromDate, toDate)
|
||||
expect(timeAgoInWords).toEqual('almost 15 years')
|
||||
})
|
||||
|
||||
it('correctly compares dates months apart', () => {
|
||||
const fromDate = new Date(2017, 8, 1)
|
||||
const toDate = new Date(2017, 11, 10)
|
||||
const timeAgoInWords = formatDateRelative(fromDate, toDate)
|
||||
expect(timeAgoInWords).toEqual('3 months')
|
||||
})
|
||||
|
||||
it('correctly compares dates days apart', () => {
|
||||
const fromDate = new Date(2017, 11, 1)
|
||||
const toDate = new Date(2017, 11, 10)
|
||||
const timeAgoInWords = formatDateRelative(fromDate, toDate)
|
||||
expect(timeAgoInWords).toEqual('9 days')
|
||||
})
|
||||
|
||||
it('compares to now when passed only one date', () => {
|
||||
const fromDate = new Date(2010, 11, 1)
|
||||
const timeAgoInWords = formatDateRelative(fromDate)
|
||||
expect(timeAgoInWords).toContain('years ago')
|
||||
})
|
||||
})
|
||||
15
ui/src/utils/format-date.js
Normal file
15
ui/src/utils/format-date.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// https://date-fns.org/docs/format
|
||||
import format from 'date-fns/format'
|
||||
import parseISO from 'date-fns/parseISO'
|
||||
|
||||
export default function formatDate(date) {
|
||||
return format(date, 'MMM do, yyyy')
|
||||
}
|
||||
|
||||
export function parseAndFormatDate(date) {
|
||||
return format(parseISO(date), 'MMM dd, yyyy')
|
||||
}
|
||||
|
||||
export function parseAndFormatDateTime(date) {
|
||||
return format(parseISO(date), 'MMM dd, yyyy hh:mm aa')
|
||||
}
|
||||
21
ui/src/utils/format-date.unit.js
Normal file
21
ui/src/utils/format-date.unit.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import formatDate from './format-date'
|
||||
|
||||
describe('@utils/format-date', () => {
|
||||
it('correctly compares dates years apart', () => {
|
||||
const date = new Date(2002, 5, 1)
|
||||
const formattedDate = formatDate(date)
|
||||
expect(formattedDate).toEqual('Jun 1st, 2002')
|
||||
})
|
||||
|
||||
it('correctly compares dates months apart', () => {
|
||||
const date = new Date(2017, 8, 1)
|
||||
const formattedDate = formatDate(date)
|
||||
expect(formattedDate).toEqual('Sep 1st, 2017')
|
||||
})
|
||||
|
||||
it('correctly compares dates days apart', () => {
|
||||
const date = new Date(2017, 11, 11)
|
||||
const formattedDate = formatDate(date)
|
||||
expect(formattedDate).toEqual('Dec 11th, 2017')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user