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

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

View File

@@ -0,0 +1,5 @@
<template>
<b-button :class="$style.button" v-on="$listeners">
<slot />
</b-button>
</template>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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