Catalog improvements, global state

This commit is contained in:
Juan Ferrer 2019-06-26 13:19:03 +02:00
parent d4fdddc898
commit 03a7734cf3
17 changed files with 1507 additions and 113 deletions

View File

@ -0,0 +1,31 @@
{
"name": "Buy",
"base": "PersistedModel",
"options": {
"mysql": {
"table": "buy"
}
},
"properties": {
"id": {
"type": "Number",
"id": true,
"description": "Identifier"
},
"itemFk": {
"type": "Number",
"required": true
},
"price3": {
"type": "Number",
"required": true
}
},
"relations": {
"item": {
"type": "belongsTo",
"model": "Item",
"foreignKey": "itemFk"
}
}
}

View File

@ -40,6 +40,11 @@
"model": "Item", "model": "Item",
"foreignKey": "itemFk" "foreignKey": "itemFk"
}, },
"buy": {
"type": "belongsTo",
"model": "Buy",
"foreignKey": "tableId"
},
"warehouse": { "warehouse": {
"type": "belongsTo", "type": "belongsTo",
"model": "Warehouse", "model": "Warehouse",

View File

@ -20,6 +20,9 @@
}, },
"icon": { "icon": {
"type": "String" "type": "String"
},
"color": {
"type": "String"
} }
}, },
"relations": { "relations": {

View File

@ -208,27 +208,32 @@ module.exports = Self => {
} }
// Obtains items data // Obtains items data
/*
let inbounds = await $.Inbound.find({
fields: ['itemFk', 'available', 'dated'],
include: 'item',
where: Object.assign(
{itemFk: {inq: itemIds}},
inboundWhere
)
});
*/
let items = await Self.find({ let items = await Self.find({
fields: ['id', 'longName', 'subname', 'image'],
where: {id: {inq: itemIds}}, where: {id: {inq: itemIds}},
include: [ include: [
{ {
relation: 'tags', relation: 'tags',
scope: {include: 'tag'} scope: {
fields: ['value', 'tagFk'],
where: {priority: {gt: 4}},
order: 'priority',
include: {
relation: 'tag',
scope: {fields: ['name']}
}
}
}, { }, {
relation: 'inbounds', relation: 'inbounds',
scope: { scope: {
fields: ['available', 'dated'], fields: ['available', 'dated', 'tableId'],
where: inboundWhere where: inboundWhere,
order: 'dated DESC',
include: {
relation: 'buy',
scope: {fields: ['id', 'price3']}
},
} }
} }
], ],
@ -236,9 +241,19 @@ module.exports = Self => {
order: order order: order
}); });
for (let item of items)
item.available = sum(item.inbounds(), 'available');
return {items, tags}; return {items, tags};
}; };
// Array functions
function sum(array, key) {
if (!Array.isArray(array)) return 0;
return array.reduce((a, c) => a + c[key], 0);
}
function toMap(objects, key) { function toMap(objects, key) {
let map = new Map(); let map = new Map();
for (let object of objects) for (let object of objects)

View File

@ -62,6 +62,9 @@
"AlertLevel": { "AlertLevel": {
"dataSource": "vn" "dataSource": "vn"
}, },
"Buy": {
"dataSource": "vn"
},
"Client": { "Client": {
"dataSource": "vn" "dataSource": "vn"
}, },

1232
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,8 @@
"scripts": { "scripts": {
"lint": "eslint --ext .js,.vue src", "lint": "eslint --ext .js,.vue src",
"test": "echo \"No test specified\" && exit 0", "test": "echo \"No test specified\" && exit 0",
"dev": "quasar dev" "dev": "quasar dev",
"back": "nodemon --inspect --watch back back/server/server.js"
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "^1.1.2", "@quasar/extras": "^1.1.2",
@ -25,6 +26,7 @@
"eslint": "^5.16.0", "eslint": "^5.16.0",
"eslint-loader": "^2.1.1", "eslint-loader": "^2.1.1",
"eslint-plugin-vue": "^5.0.0", "eslint-plugin-vue": "^5.0.0",
"nodemon": "^1.19.1",
"strip-ansi": "=3.0.1" "strip-ansi": "=3.0.1"
}, },
"engines": { "engines": {

View File

@ -6,7 +6,8 @@ module.exports = function (ctx) {
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
boot: [ boot: [
'i18n', 'i18n',
'axios' 'axios',
'state'
], ],
css: [ css: [
@ -54,6 +55,7 @@ module.exports = function (ctx) {
'QSelect', 'QSelect',
'QSeparator', 'QSeparator',
'QSpinner', 'QSpinner',
'QTooltip',
'QImg', 'QImg',
'QPageSticky', 'QPageSticky',
'QPopupProxy' 'QPopupProxy'

View File

@ -3,5 +3,4 @@ import axios from 'axios'
export default async ({ Vue }) => { export default async ({ Vue }) => {
Vue.prototype.$axios = axios Vue.prototype.$axios = axios
axios.defaults.baseURL = `//${location.hostname}:3000/api/` axios.defaults.baseURL = `//${location.hostname}:3000/api/`
Vue.prototype.$imageBase = '//verdnatura.es/vn-image-data'
} }

13
src/boot/state.js Normal file
View File

@ -0,0 +1,13 @@
export default async ({ app, Vue }) => {
Vue.prototype.$imageBase = '//verdnatura.es/vn-image-data'
let state = Vue.observable({
userId: 1437,
userName: null,
title: null,
subtitle: null,
search: null
})
Vue.prototype.$state = state
}

View File

@ -87,9 +87,10 @@ export default {
// Catalog // Catalog
more: 'More', more: 'More',
buy: 'Buy', buy: 'Buy',
deleteFilter: 'Delete filter',
viewMore: 'View more', viewMore: 'View more',
viewLess: 'View less', viewLess: 'View less',
availableFromPrice: '{0} desde {1}€', from: 'from',
deliveryDate: 'Delivery date', deliveryDate: 'Delivery date',
configureOrder: 'Configure order', configureOrder: 'Configure order',
categories: 'Categories', categories: 'Categories',

View File

@ -88,9 +88,10 @@ export default {
// Catalog // Catalog
more: 'Más', more: 'Más',
buy: 'Comprar', buy: 'Comprar',
deleteFilter: 'Eliminar filtro',
viewMore: 'Ver más', viewMore: 'Ver más',
viewLess: 'Ver menos', viewLess: 'Ver menos',
availableFromPrice: '{0} desde {1}€', from: 'desde',
deliveryDate: 'Fecha de entrega', deliveryDate: 'Fecha de entrega',
configureOrder: 'Configurar pedido', configureOrder: 'Configurar pedido',
categories: 'Categorias', categories: 'Categorias',

View File

@ -1,7 +1,7 @@
<template> <template>
<q-layout view="lHh LpR fFf" class="bg-grey-3"> <q-layout view="lHh LpR fFf" class="bg-grey-3">
<q-header elevated> <q-header elevated>
<q-toolbar> <q-toolbar :style="{background: $state.titleColor}">
<q-btn <q-btn
flat flat
dense dense
@ -11,16 +11,37 @@
> >
<q-icon name="menu" /> <q-icon name="menu" />
</q-btn> </q-btn>
<q-toolbar-title> <q-toolbar-title class="title">
{{$t($router.currentRoute.name)}} {{$state.title}}
</q-toolbar-title><!-- <div
<q-input dark dense standout v-model="search"> v-if="$state.subtitle"
class="subtitle text-caption">
{{$state.subtitle}}
</div>
</q-toolbar-title>
<q-input
v-if="$router.currentRoute.name == 'catalog'"
dark
dense
standout
v-model="$state.search">
<template v-slot:append> <template v-slot:append>
<q-icon v-if="search === ''" name="search" /> <q-icon v-if="$state.search === ''" name="search" />
<q-icon v-else name="clear" class="cursor-pointer" @click="search = ''" /> <q-icon v-else name="clear" class="cursor-pointer" @click="$state.search = ''" />
</template> </template>
</q-input>--> </q-input>
<q-btn flat class="q-ml-md" :label="$t('login')" :to="{name: 'login'}" /> <q-btn flat
v-if="$router.currentRoute.name == 'catalog'"
class="q-ml-md"
:label="$t('configureOrder')"
:to="{name: 'orders'}"
/>
<q-btn flat
v-if="!$state.userId"
class="q-ml-md"
:label="$t('login')"
:to="{name: 'login'}"
/>
</q-toolbar> </q-toolbar>
</q-header> </q-header>
<q-drawer <q-drawer
@ -74,6 +95,19 @@
</q-layout> </q-layout>
</template> </template>
<style lang="stylus" scoped>
.logo
height: 2.5em;
display: block;
.title
line-height 1.2em
.subtitle
line-height 1.2em
.q-toolbar
transition-property background
transition-duration 200ms
</style>
<script> <script>
import { openURL } from 'quasar' import { openURL } from 'quasar'
@ -94,10 +128,3 @@ export default {
} }
} }
</script> </script>
<style scoped>
.logo {
height: 2.5em;
display: block;
}
</style>

View File

@ -6,14 +6,15 @@
side="right" side="right"
elevated> elevated>
<div class="q-pa-md"> <div class="q-pa-md">
<div style="color: #555;" class="q-mb-xs"> <div class="q-mb-xs text-caption text-grey-7">
{{$t('category')}} {{$t('category')}}
<q-icon <q-icon
v-if="category" v-if="category"
style="font-size: 1.3em;" style="font-size: 1.3em;"
name="cancel" name="cancel"
class="cursor-pointer" class="cursor-pointer"
@click="category = null" :title="$t('deleteFilter')"
:to="{params: {category: null}}"
/> />
</div> </div>
<div> <div>
@ -24,7 +25,7 @@
:class="{active: category == cat.id}" :class="{active: category == cat.id}"
:key="cat.id" :key="cat.id"
:title="cat.name" :title="cat.name"
@click="category = cat.id"> :to="{params: {category: cat.id}}">
<img <img
class="category-img" class="category-img"
:src="`statics/category/${cat.id}.svg`"> :src="`statics/category/${cat.id}.svg`">
@ -43,17 +44,6 @@
</div> </div>
<q-separator /> <q-separator />
<div class="q-pa-md"> <div class="q-pa-md">
<q-input
type="search"
clearable
:label="$t('search')"
stack-label
v-model="searchTmp"
@change="search = searchTmp">
<template v-slot:append>
<q-icon name="search" />
</template>
</q-input>
<q-select <q-select
v-model="order" v-model="order"
input-debounce="0" input-debounce="0"
@ -73,11 +63,6 @@
</q-icon> </q-icon>
</template> </template>
</q-input> </q-input>
<q-btn
color="accent"
style="width: 100%"
:label="$t('configureOrder')"
/>
</div> </div>
<q-separator /> <q-separator />
<div class="q-pa-md" <div class="q-pa-md"
@ -85,12 +70,13 @@
<div class="q-mb-md" <div class="q-mb-md"
v-for="tag in tags" v-for="tag in tags"
:key="tag.uid"> :key="tag.uid">
<div class="q-mb-xs text-grey-7"> <div class="q-mb-xs text-caption text-grey-7">
{{tag.name}} {{tag.name}}
<q-icon <q-icon
v-if="tag.filter.length || tag.filter.min || tag.filter.max" v-if="tag.filter.length || tag.filter.min || tag.filter.max"
style="font-size: 1.3em;" style="font-size: 1.3em;"
name="cancel" name="cancel"
:title="$t('deleteFilter')"
class="cursor-pointer" class="cursor-pointer"
@click="onResetTagFilterClick(tag)" @click="onResetTagFilterClick(tag)"
/> />
@ -104,7 +90,7 @@
:dense="true" :dense="true"
:val="value" :val="value"
:label="value" :label="value"
@input="loadItems()" @input="loadItems"
/> />
</div> </div>
<div v-if="tag.values.length > tag.showCount"> <div v-if="tag.values.length > tag.showCount">
@ -124,6 +110,7 @@
</span> </span>
</div> </div>
</div> </div>
<div class="q-mx-md">
<q-range <q-range
class="q-mt-lg" class="q-mt-lg"
v-if="tag.useRange" v-if="tag.useRange"
@ -131,18 +118,21 @@
:min="tag.min" :min="tag.min"
:max="tag.max" :max="tag.max"
:step="tag.step" :step="tag.step"
@change="loadItems()" @input="loadItemsDelayed"
@change="loadItems"
label-always label-always
markers markers
snap snap
/> />
</div> </div>
</div> </div>
</div>
</q-drawer> </q-drawer>
<q-infinite-scroll <q-infinite-scroll
@load="onLoad" @load="onLoad"
scroll-taget="html" scroll-taget="html"
:offset="500"> :offset="800"
:disable="disableScroll">
<div class="q-pa-md row justify-center q-gutter-md"> <div class="q-pa-md row justify-center q-gutter-md">
<q-spinner <q-spinner
v-if="isLoading" v-if="isLoading"
@ -153,24 +143,24 @@
class="my-card" class="my-card"
v-for="item in items" v-for="item in items"
:key="item.id"> :key="item.id">
<img :src="imageUrl + item.image" /> <img :src="`${$imageBase}/catalog/200x200/${item.image}`" />
<q-card-section> <q-card-section>
<div class="name text-h6">{{item.longName}}</div> <div class="name text-subtitle1">{{item.longName}}</div>
<div class="text-uppercase text-subtitle1 text-grey-7"> <div class="text-uppercase text-subtitle1 text-grey-7">
{{item.subName}} {{item.subName}}
</div> </div>
</q-card-section> </q-card-section>
<q-card-section> <q-card-section class="tags">
<div><span class="text-grey-7">{{item.tag5}}</span> {{item.value5}}</div> <div v-for="tag in item.tags" :key="tag.tagFk">
<div><span class="text-grey-7">{{item.tag6}}</span> {{item.value6}}</div> <span class="text-grey-7">{{tag.tag.name}}</span> {{tag.value}}
<div><span class="text-grey-7">{{item.tag7}}</span> {{item.value7}}</div> </div>
<div><span class="text-grey-7">{{item.tag8}}</span> {{item.value8}}</div>
</q-card-section> </q-card-section>
<q-card-section class="text-right text-subtitle1"> <q-card-actions class="actions justify-between">
{{$t('availableFromPrice', [30, 1.5])}} <div class="q-pl-sm">
</q-card-section> <span class="available bg-green text-white">{{item.available}}</span>
<q-card-actions class="justify-end"> {{$t('from')}}
<q-btn flat>{{$t('more')}}</q-btn> <span class="price">{{item.inbounds[0].buy && item.inbounds[0].buy.price3 | currency}}</span>
</div>
<q-btn flat>{{$t('buy')}}</q-btn> <q-btn flat>{{$t('buy')}}</q-btn>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
@ -188,6 +178,8 @@
.my-card .my-card
width 100% width 100%
max-width 21em max-width 21em
height 38em
overflow hidden
.name .name
white-space nowrap white-space nowrap
overflow hidden overflow hidden
@ -201,6 +193,19 @@
background: rgba(0, 0, 0, .08) background: rgba(0, 0, 0, .08)
.category-img .category-img
height 3.5em height 3.5em
.tags
max-height 6.2em
overflow hidden
.available
padding .15em
border-radius .2em
font-size 1.3em
.price
font-size 1.3em
.actions
position absolute
bottom 0
width 100%
</style> </style>
<script> <script>
@ -214,21 +219,21 @@ export default {
data () { data () {
return { return {
uid: 0, uid: 0,
searchTmp: null,
search: null, search: null,
date: date.formatDate(new Date(), 'YYYY/MM/DD'), date: date.formatDate(new Date(), 'YYYY/MM/DD'),
category: null, category: null,
categories: [], categories: [],
tags: [],
type: null, type: null,
types: [], types: [],
orgTypes: [], orgTypes: [],
isLoading: false, isLoading: false,
imageUrl: 'https://verdnatura.es/vn-image-data/catalog/200x200/',
items: null, items: null,
rightDrawerOpen: this.$q.platform.is.desktop, rightDrawerOpen: this.$q.platform.is.desktop,
pageSize: 9, pageSize: 24,
limit: null, limit: null,
maxTags: 5, maxTags: 5,
disableScroll: true,
order: { order: {
label: this.$t('name'), label: this.$t('name'),
value: 'name' value: 'name'
@ -237,7 +242,7 @@ export default {
{ {
label: this.$t('name'), label: this.$t('name'),
value: 'longName' value: 'longName'
}, { }, /* {
label: this.$t('priceAsc'), label: this.$t('priceAsc'),
value: 'price ASC' value: 'price ASC'
}, { }, {
@ -246,65 +251,105 @@ export default {
}, { }, {
label: this.$t('available'), label: this.$t('available'),
value: 'available' value: 'available'
}, { }, */ {
label: this.$t('siceAsc'), label: this.$t('siceAsc'),
value: 'size ASC' value: 'size ASC'
}, { }, {
label: this.$t('sizeDesc'), label: this.$t('sizeDesc'),
value: 'size DESC' value: 'size DESC'
} }
], ]
tags: [],
tagFilter: [],
prevFilters: {}
} }
}, },
mounted () { mounted () {
this.$axios.get('ItemCategories') this.$axios.get('ItemCategories')
.then(res => (this.categories = res.data)) .then(res => (this.categories = res.data))
this.loadItems() },
beforeDestroy () {
this.clearTimeoutAndRequest()
}, },
watch: { watch: {
type () {
this.search = null
this.searchTmp = null
this.loadItems()
},
order () { order () {
this.loadItems() this.loadItems()
}, },
search () {
this.loadItems()
},
date () { date () {
this.loadItems() this.loadItems()
}, },
'$route.params.type': function (type) { type (type) {
this.type = { id: type } this.$router.push({
params: {
category: this.category,
type: type && type.id
}
})
this.$state.subtitle = type && type.name
this.$state.search = ''
this.loadItems()
}, },
category (category) { '$state.search': function (value) {
if (!value) this.loadItems()
else this.loadItemsDelayed()
},
'$route.params.category': function (categoryId) {
let category = this.categories.find(i => i.id === categoryId)
if (category) {
this.$state.title = category.name
this.$state.titleColor = `#${category.color}`
} else {
this.$state.title = this.$t(this.$router.currentRoute.name)
this.$state.titleColor = null
}
this.category = categoryId
this.type = null this.type = null
let params = { filter: { where: { categoryFk: category } } } let params = { filter: { where: { categoryFk: categoryId } } }
this.$axios.get('ItemTypes', { params }) this.$axios.get('ItemTypes', { params })
.then(res => (this.orgTypes = res.data)) .then(res => (this.orgTypes = res.data))
},
'$route.params.type': function (type) {
if (!this.type) this.type = { id: type }
} }
}, },
methods: { methods: {
loadItemsDelayed () {
this.clearTimeoutAndRequest()
this.timeout = setTimeout(() => this.loadItems(), 500)
},
clearTimeoutAndRequest () {
if (this.timeout) {
clearTimeout(this.timeout)
this.timeout = null
}
if (this.source) {
this.source.cancel()
this.source = null
}
},
loadItems () { loadItems () {
this.items = [] this.items = []
this.isLoading = true this.isLoading = true
this.limit = this.pageSize this.limit = this.pageSize
this.disableScroll = false
this.loadItemsBase() this.loadItemsBase()
.finally(() => (this.isLoading = false)) .finally(() => {
this.isLoading = false
})
}, },
onLoad (index, done) { onLoad (index, done) {
if (this.isLoading) return done()
this.limit += this.pageSize this.limit += this.pageSize
console.log('onLoad')
this.loadItemsBase() this.loadItemsBase()
.finally(() => done()) .finally(() => done())
}, },
loadItemsBase () { loadItemsBase () {
this.clearTimeoutAndRequest()
if (!this.type) {
return Promise.resolve(true)
}
let typeFk let typeFk
let tagFilter = [] let tagFilter = []
@ -321,13 +366,12 @@ export default {
typeFk = this.type.id typeFk = this.type.id
} }
if (this.source) this.source.cancel()
this.source = CancelToken.source() this.source = CancelToken.source()
let params = { let params = {
dated: this.date, dated: this.date,
typeFk: typeFk, typeFk: typeFk,
search: this.search, search: this.$state.search,
order: this.order.value, order: this.order.value,
tagFilter: tagFilter, tagFilter: tagFilter,
limit: this.limit limit: this.limit
@ -338,18 +382,23 @@ export default {
} }
return this.$axios.get('Items/catalog', config) return this.$axios.get('Items/catalog', config)
.then(res => this.onItemsGet(res)) .then(res => this.onItemsGet(res))
.catch(err => { if (!err.__CANCEL__) throw err })
.finally(() => (this.cancel = null)) .finally(() => (this.cancel = null))
}, },
onItemsGet (res) { onItemsGet (res) {
for (let tag of res.data.tags) { for (let tag of res.data.tags) {
tag.uid = this.uid++ tag.uid = this.uid++
tag.useRange = tag.isQuantitative &&
tag.values.length > this.maxTags &&
(!tag.filter || !tag.filter.length)
if (!tag.filter) { if (tag.filter) {
tag.useRange =
tag.filter.max ||
tag.filter.min
} else {
tag.useRange = tag.isQuantitative &&
tag.values.length > this.maxTags
this.resetTagFilter(tag) this.resetTagFilter(tag)
} }
if (tag.values) { if (tag.values) {
tag.initialCount = this.maxTags tag.initialCount = this.maxTags
if (Array.isArray(tag.filter)) { if (Array.isArray(tag.filter)) {
@ -361,6 +410,7 @@ export default {
this.items = res.data.items this.items = res.data.items
this.tags = res.data.tags this.tags = res.data.tags
this.disableScroll = this.items.length < this.limit
}, },
resetTagFilter (tag) { resetTagFilter (tag) {
if (tag.useRange) { if (tag.useRange) {

View File

@ -87,7 +87,6 @@ export default {
data () { data () {
return { return {
yearFilter: null, yearFilter: null,
userId: 1437,
years: [], years: [],
orders: [], orders: [],
tickets: [] tickets: []
@ -96,7 +95,7 @@ export default {
mounted () { mounted () {
let params = { filter: { let params = { filter: {
where: { where: {
clientFk: this.userId, clientFk: this.$state.userId,
isConfirmed: false isConfirmed: false
}, },
include: 'address', include: 'address',

View File

@ -22,5 +22,16 @@ export default function (/* { store, ssrContext } */) {
base: process.env.VUE_ROUTER_BASE base: process.env.VUE_ROUTER_BASE
}) })
Router.afterEach((to, from) => {
let app = Router.app
let $state = app.$state
if (from.name !== to.name) {
$state.title = app.$t(to.name)
$state.titleColor = null
$state.subtitle = null
}
})
return Router return Router
} }

View File

@ -14,7 +14,7 @@ const routes = [
component: () => import('pages/Index.vue') component: () => import('pages/Index.vue')
}, { }, {
name: 'catalog', name: 'catalog',
path: '/catalog/:type?', path: '/catalog/:category?/:type?',
component: () => import('pages/Catalog.vue') component: () => import('pages/Catalog.vue')
}, { }, {
name: 'orders', name: 'orders',