first commit

This commit is contained in:
Akhil Gupta
2021-05-29 15:20:50 +05:30
commit d25c30a7b2
194 changed files with 49873 additions and 0 deletions

135
ui/src/router/index.js Normal file
View 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

View 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)
})
})

View 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
View 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)
},
})
}

View File

@@ -0,0 +1,7 @@
import View404 from './_404.vue'
describe('@views/404', () => {
it('is a valid view', () => {
expect(View404).toBeAViewComponent()
})
})

View 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>

View File

@@ -0,0 +1,7 @@
import Loading from './_loading.vue'
describe('@views/loading', () => {
it('is a valid view', () => {
expect(Loading).toBeAViewComponent()
})
})

View 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>

View File

@@ -0,0 +1,7 @@
import Timeout from './_timeout.vue'
describe('@views/timeout', () => {
it('is a valid view', () => {
expect(Timeout).toBeAViewComponent()
})
})

View 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>

View 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>

View 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>

View 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>

View 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')
})
})

View 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 }}&nbsp;{{
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>

View 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>

View 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'))
}
},
},
},
},
}),
})
}

View 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>

View 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/)
})
})

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>