Address, Config, Orders

This commit is contained in:
Juan Ferrer 2019-07-01 23:47:53 +02:00
parent 03a7734cf3
commit 73a8d045a6
23 changed files with 1299 additions and 353 deletions

View File

@ -0,0 +1,47 @@
{
"name": "Account",
"base": "PersistedModel",
"options": {
"mysql": {
"table": "account.user"
}
},
"properties": {
"id": {
"type": "number",
"required": true
},
"name": {
"type": "string",
"required": true
},
"roleFk": {
"type": "number",
"mysql": {
"columnName": "role"
}
},
"nickname": {
"type": "string"
},
"password": {
"type": "string",
"required": true
},
"active": {
"type": "boolean"
},
"email": {
"type": "string"
},
"lang": {
"type": "string"
},
"created": {
"type": "date"
},
"updated": {
"type": "date"
}
}
}

View File

@ -0,0 +1,32 @@
{
"name": "Country",
"description": "Worldwide countries",
"base": "PersistedModel",
"options": {
"mysql": {
"table": "country"
}
},
"properties": {
"id": {
"type": "Number",
"id": true,
"description": "Identifier"
},
"country": {
"type": "string",
"required": true
},
"code": {
"type": "string"
}
},
"acls": [
{
"accessType": "READ",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW"
}
]
}

View File

@ -13,6 +13,10 @@ module.exports = Self => {
arg: 'typeFk',
type: 'Number',
description: 'The item type id'
}, {
arg: 'categoryFk',
type: 'Number',
description: 'The item category id'
}, {
arg: 'search',
type: 'String',
@ -42,7 +46,7 @@ module.exports = Self => {
}
});
Self.catalog = async (dated, typeFk, search, order, limit, tagFilter) => {
Self.catalog = async (dated, typeFk, categoryFk, search, order, limit, tagFilter) => {
let $ = Self.app.models;
let itemIds;
@ -63,11 +67,27 @@ module.exports = Self => {
if (/^[0-9]+$/.test(search)) {
itemIds = [parseInt(search)];
} else {
if (typeFk || search) {
let where = {};
let inbounds = await $.Inbound.find({
fields: ['itemFk'],
where: inboundWhere
});
itemIds = toValues(inbounds, 'itemFk');
if (typeFk)
if (categoryFk || typeFk || search) {
let where = {
id: {inq: itemIds}
};
if (typeFk) {
where.typeFk = typeFk;
} else if (categoryFk) {
let types = await $.ItemType.find({
fields: ['id'],
where: {categoryFk}
});
where.typeFk = {inq: toValues(types, 'id')};
}
if (search)
where.longName = {like: `%${search}%`};
@ -79,15 +99,6 @@ module.exports = Self => {
let items = await Self.find(filter);
itemIds = items.map(i => i.id);
}
let where = Object.assign({}, inboundWhere);
if (itemIds) where.itemFk = {inq: itemIds};
let inbounds = await $.Inbound.find({
fields: ['itemFk'],
where
});
itemIds = toValues(inbounds, 'itemFk');
}
// Applies tag filters
@ -129,6 +140,9 @@ module.exports = Self => {
// Obtains distinct tags and it's distinct values
let tags = [];
if (typeFk || search) {
let tagValues = await $.ItemTag.find({
fields: ['tagFk', 'value', 'intValue', 'priority'],
where: {
@ -162,7 +176,7 @@ module.exports = Self => {
}
let tagIds = [...tagValueMap.keys()];
let tags = await $.Tag.find({
tags = await $.Tag.find({
fields: ['id', 'name', 'isQuantitative', 'unit'],
where: {
id: {inq: tagIds}
@ -206,12 +220,15 @@ module.exports = Self => {
filter
});
}
}
// Obtains items data
let items = await Self.find({
fields: ['id', 'longName', 'subname', 'image'],
fields: ['id', 'longName', 'subName', 'image'],
where: {id: {inq: itemIds}},
limit: limit,
order: order,
include: [
{
relation: 'tags',
@ -236,13 +253,14 @@ module.exports = Self => {
},
}
}
],
limit: limit,
order: order
]
});
for (let item of items)
for (let item of items) {
item.inbound = item.inbounds()[0];
item.buy = item.inbound && item.inbound.buy();
item.available = sum(item.inbounds(), 'available');
}
return {items, tags};
};

View File

@ -0,0 +1,28 @@
{
"name": "Language",
"base": "PersistedModel",
"options": {
"mysql": {
"table": "hedera.language"
}
},
"properties": {
"code": {
"type": "string",
"required": true,
"id": true
},
"name": {
"type": "string",
"required": true
},
"orgName": {
"type": "string",
"required": true
},
"isActive": {
"type": "boolean",
"required": true
}
}
}

View File

@ -0,0 +1,37 @@
{
"name": "UserPassword",
"description": "User password requirements",
"base": "PersistedModel",
"options": {
"mysql": {
"table": "account.userPassword"
}
},
"properties": {
"id": {
"type": "Number",
"id": true,
"description": "Identifier"
},
"length": {
"type": "Number",
"required": true
},
"nAlpha": {
"type": "Number",
"required": true
},
"nUpper": {
"type": "Number",
"required": true
},
"nDigits": {
"type": "Number",
"required": true
},
"nPunct": {
"type": "Number",
"required": true
}
}
}

View File

@ -53,6 +53,9 @@
}
}
},
"Account": {
"dataSource": "vn"
},
"Address": {
"dataSource": "vn"
},
@ -68,6 +71,9 @@
"Client": {
"dataSource": "vn"
},
"Country": {
"dataSource": "vn"
},
"DeliveryMethod": {
"dataSource": "vn"
},
@ -86,6 +92,9 @@
"ItemType": {
"dataSource": "vn"
},
"Language": {
"dataSource": "vn"
},
"New": {
"dataSource": "vn"
},
@ -119,6 +128,9 @@
"TicketTracking": {
"dataSource": "vn"
},
"UserPassword": {
"dataSource": "vn"
},
"Warehouse": {
"dataSource": "vn"
}

View File

@ -27,42 +27,51 @@ module.exports = function (ctx) {
framework: {
components: [
'QBadge',
'QLayout',
'QHeader',
'QDate',
'QDrawer',
'QPageContainer',
'QPage',
'QToolbar',
'QToolbarTitle',
'QBtn',
'QIcon',
'QList',
'QInfiniteScroll',
'QItem',
'QItemSection',
'QItemLabel',
'QCarousel',
'QCarouselControl',
'QCarouselSlide',
'QCard',
'QCardSection',
'QCardActions',
'QRating',
'QCheckbox',
'QDate',
'QDialog',
'QDrawer',
'QExpansionItem',
'QHeader',
'QIcon',
'QImg',
'QInfiniteScroll',
'QInput',
'QItem',
'QItemSection',
'QItemLabel',
'QList',
'QLayout',
'QPageContainer',
'QPage',
'QPageSticky',
'QPopupProxy',
'QRadio',
'QRating',
'QRange',
'QSelect',
'QSeparator',
'QSpinner',
'QTooltip',
'QImg',
'QPageSticky',
'QPopupProxy'
'QTab',
'QTabs',
'QTabPanel',
'QTabPanels',
'QRouteTab',
'QToolbar',
'QToolbarTitle',
'QTooltip'
],
directives: [
'Ripple'
'Ripple',
'ClosePopup'
],
// Quasar plugins

View File

@ -7,7 +7,8 @@ export default async ({ app, Vue }) => {
userName: null,
title: null,
subtitle: null,
search: null
search: null,
rightDrawerOpen: true
})
Vue.prototype.$state = state
}

View File

@ -1 +1,10 @@
// app global css
.vn-w-md
width 30em
.vn-pp
padding 1em
@media (max-width: 960px)
.vn-pp
padding 1em 0

View File

@ -5,6 +5,16 @@ export default {
password: 'Password',
remember: 'Don not close session',
search: 'Search',
accept: 'Accept',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
add: 'Add',
create: 'create',
dataSaved: 'Data saved!',
noDataFound: 'No data found',
areYouSureDelete: 'Are you sure you want to delete?',
fieldIsRequired: 'Field is required',
date: {
days: [
'Sunday',
@ -64,9 +74,14 @@ export default {
orders: 'Orders',
conditions: 'Conditions',
about: 'About us',
config: 'Configuration',
user: 'User',
addresses: 'Addresses',
addressEdit: 'Edit address',
register: 'Register',
// Home
recentNews: 'Recent news',
startOrder: 'Start order',
weThinkForYou: 'We think for you',
@ -86,6 +101,8 @@ export default {
// Catalog
more: 'More',
noItemsFound: 'No items found',
pleaseSetFilter: 'Please, set a filter using the right menu',
buy: 'Buy',
deleteFilter: 'Delete filter',
viewMore: 'View more',
@ -101,6 +118,7 @@ export default {
relevancy: 'Relevancy',
priceAsc: 'Acending price',
priceDesc: 'Descending price',
novelty: 'Novelty',
available: 'Available',
siceAsc: 'Ascending size',
sizeDesc: 'Descencing size',
@ -109,6 +127,7 @@ export default {
ordersMadeAt: 'Orders made at',
pendingConfirmtion: 'Pending confirmation',
noOrdersFound: 'No orders found',
pending: 'Pending',
confirmed: 'Confirmed',
packages: '{n} packages',
@ -118,10 +137,45 @@ export default {
// About
aboutDesc: 'Verdnatura offers all services for your florist.',
// Config
userName: 'Username',
nickname: 'Nickname',
language: 'Language',
receiveInvoiceByEmail: 'Receive invoices by email',
passwordRequirements: 'Password requirements',
charsLong: '{0} characters long',
alphabeticChars: '{0} alphabetic chars',
upperLetters: '{0} uppercase letters',
digits: '{0} digits',
simbols: '{0} symbols. Ex: $%&.',
oldPassword: 'Old password',
newPassword: 'New password',
repeatPassword: 'Repeat password',
changePassword: 'Change password',
passwordCannotBeEmpty: 'Password cannot be empty',
passwordsDontMatch: 'Passwords do not match',
passwordChanged: 'Password changed successfully!',
// Addresses
setAsDefault: 'Set as default',
addressSetAsDefault: 'Address set as default',
addressRemoved: 'Address removed',
areYouSureDeleteAddress: 'Are you sure you want to delete the address?',
addressCreated: 'Address created successfully!',
// Address
consignatary: 'Consignatary',
street: 'Street',
city: 'City',
postalCode: 'Postal code',
province: 'Province',
country: 'Country',
phone: 'Phone',
mobile: 'Mobile',
// Register
registerAsNew: 'Registrarse como nuevo usuario',
notYetUser: 'You are not yet a user, register now and start enjoying everything that Verdnatura offers you.',
repeatPassword: 'Repeat password',
receiveOffers: 'Receive offers and promotions by e-mail',
userRegistered: 'User registered successfully'
}

View File

@ -5,6 +5,16 @@ export default {
password: 'Contraseña',
remember: 'No cerrar sesión',
search: 'Buscar',
accept: 'Aceptar',
cancel: 'Cancelar',
save: 'Guardar',
delete: 'Borrar',
add: 'Añadir',
create: 'Crear',
dataSaved: '¡Datos guardados!',
noDataFound: 'No se han encontrado datos',
areYouSureDelete: '¿Seguro que quieres eliminar?',
fieldIsRequired: 'Campo requerido',
date: {
days: [
'Domingo',
@ -65,9 +75,14 @@ export default {
orders: 'Pedidos',
conditions: 'Condiciones',
about: 'Sobre nosotros',
config: 'Configuración',
user: 'Usuario',
addresses: 'Direcciones',
addressEdit: 'Editar dirección',
register: 'Registrarse',
// Home
recentNews: 'Noticias recientes',
startOrder: 'Empezar pedido',
weThinkForYou: 'Pensamos para tí',
@ -87,6 +102,8 @@ export default {
// Catalog
more: 'Más',
noItemsFound: 'No se han encontrado artículos',
pleaseSetFilter: 'Por favor, establece un filtro usando el menú de la derecha',
buy: 'Comprar',
deleteFilter: 'Eliminar filtro',
viewMore: 'Ver más',
@ -102,6 +119,7 @@ export default {
relevancy: 'Relevancia',
priceAsc: 'Precio ascendente',
priceDesc: 'Precio descendente',
novelty: 'Novedad',
available: 'Disponible',
siceAsc: 'Medida ascendente',
sizeDesc: 'Medida descendente',
@ -110,6 +128,7 @@ export default {
ordersMadeAt: 'Pedidos realizados en',
pendingConfirmtion: 'Pendientes de confirmar',
noOrdersFound: 'No se han encontrado pedidos',
pending: 'Pendientes',
confirmed: 'Confirmados',
packages: '{n} bultos',
@ -119,10 +138,45 @@ export default {
// About
aboutDesc: 'Verdnatura te ofrece todos los servicios que necesita tu floristería.',
// Config
userName: 'Nombre de usuario',
nickname: 'Nombre a mostrar',
language: 'Idioma',
receiveInvoiceByEmail: 'Recibir facturas por correo eletrónico',
passwordRequirements: 'Requisitos de contraseña',
charsLong: '{0} carácteres de longitud',
alphabeticChars: '{0} carácteres alfabéticos',
upperLetters: '{0} letras mayúsculas',
digits: '{0} dígitos',
simbols: '{0} símbolos. Ej: $%&.',
oldPassword: 'Antigua contraseña',
newPassword: 'Nueva contraseña',
repeatPassword: 'Repetir contraseña',
changePassword: 'Cambiar contraseña',
passwordCannotBeEmpty: 'La contraseña no puede estar vacía',
passwordsDontMatch: 'Las contraseñas no coinciden',
passwordChanged: '¡Contraseña modificada correctamente!',
// Addresses
setAsDefault: 'Establecer como predeterminada',
addressSetAsDefault: 'Dirección establecida como predeterminada',
addressRemoved: 'Dirección eliminada',
areYouSureDeleteAddress: '¿Seguro que quieres eliminar la dirección?',
addressCreated: '¡Dirección creada correctamente!',
// Address
consignatary: 'Consignatario',
street: 'Dirección',
city: 'City',
postalCode: 'Código postal',
province: 'Provincia',
country: 'País',
phone: 'Teléfono',
mobile: 'Móvil',
// Register
registerAsNew: 'Registrarse como nuevo usuario',
notYetUser: '¿Todavía no eres usuari@?, registrate y empieza a disfrutar de todo lo que Verdnatura puede ofrecerte.',
repeatPassword: 'Repetir contraseña',
receiveOffers: 'Recibir ofertas y promociones por correo electrónico',
userRegistered: 'Usuario registrado correctamente'
}

View File

@ -20,6 +20,7 @@
<q-checkbox v-model="remember" :label="$t('remember')" />
</q-card-section>
<q-card-actions class="justify-center">
<q-btn flat :label="$t('register')" to="/register" />
<q-btn flat :label="$t('enter')" @click="login()" />
</q-card-actions>
</q-card>

View File

@ -19,36 +19,50 @@
{{$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>
<q-icon v-if="$state.search === ''" name="search" />
<q-icon v-else name="clear" class="cursor-pointer" @click="$state.search = ''" />
</template>
</q-input>
<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'}"
to="/login"
/>
<q-input
v-if="$router.currentRoute.name == 'catalog'"
v-model="$state.search"
debounce="500"
class="q-mr-sm"
dark
dense
standout>
<template v-slot:append>
<q-icon
v-if="$state.search === ''"
name="search"
/>
<q-icon
v-else
name="clear"
class="cursor-pointer"
@click="$state.search = ''"
/>
</template>
</q-input>
<q-btn
v-if="$router.currentRoute.name == 'catalog'"
@click="$state.rightDrawerOpen = !$state.rightDrawerOpen"
aria-label="Menu"
flat
dense
round>
<q-icon name="menu" />
</q-btn>
</q-toolbar>
</q-header>
<q-drawer
v-model="leftDrawerOpen"
content-class="bg-grey-2"
behavior="mobile"
elevated
>
overlay>
<div class="q-pa-md shadow-1 q-mb-md bg-grey-10" style="color: white;">
<img src="statics/logo-dark.svg" alt="Verdnatura" class="logo q-mb-md"/>
<div class="row items-center full-width justify-between">
@ -82,11 +96,21 @@
<q-item-label>{{$t('about')}}</q-item-label>
</q-item-section>
</q-item>
<q-item clickable :to="{name: 'register'}">
<q-expansion-item
expand-icon-toggle
expand-separator
:label="$t('config')">
<q-item clickable :to="{name: 'config'}">
<q-item-section>
<q-item-label>{{$t('register')}}</q-item-label>
<q-item-label>{{$t('user')}}</q-item-label>
</q-item-section>
</q-item>
<q-item clickable :to="{name: 'addresses'}">
<q-item-section>
<q-item-label>{{$t('addresses')}}</q-item-label>
</q-item-section>
</q-item>
</q-expansion-item>
</q-list>
</q-drawer>
<q-page-container>
@ -123,7 +147,7 @@ export default {
openURL,
logout () {
localStorage.removeItem('token')
this.$router.push('login')
this.$router.push('/login')
}
}
}

171
src/pages/Address.vue Normal file
View File

@ -0,0 +1,171 @@
<template>
<div class="vn-pp row justify-center">
<q-card class="vn-w-md">
<q-card-section class="q-gutter-md">
<q-input v-model="address.nickname" :label="$t('consignatary')" />
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input v-model="address.street" :label="$t('street')" />
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input v-model="address.city" :label="$t('city')" />
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input v-model="address.postalCode" :label="$t('postalCode')" />
</q-card-section>
<q-card-section class="q-gutter-md">
<q-select
v-model="country"
:label="$t('country')"
:options="countries"
option-value="id"
option-label="country"/>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-select
v-model="province"
:label="$t('province')"
:options="provinces"
option-value="id"
option-label="name"/>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input v-model="address.phone" :label="$t('phone')" />
</q-card-section>
<q-card-actions class="justify-center">
<q-btn
:label="$t('cancel')"
to="/addresses"
flat/>
<q-btn
v-if="addressId"
:label="$t('save')"
@click="onSave"
flat/>
<q-btn
v-if="!addressId"
:label="$t('create')"
@click="onSave"
flat/>
</q-card-actions>
</q-card>
</div>
</template>
<style lang="stylus" scoped>
</style>
<script>
export default {
name: 'Address',
data () {
return {
address: {},
addressId: null,
provinces: null,
countries: null,
province: null,
country: null
}
},
mounted () {
let filter = {
fields: ['id', 'country']
}
this.$axios.get('Countries', { params: { filter } })
.then(res => (this.countries = res.data))
this.loadAddress()
},
watch: {
address () {
this.updateCountry()
},
countries () {
this.updateCountry()
},
provinces () {
this.updateProvince()
},
country () {
this.loadProvinces()
},
province (value) {
if (!this.address.provinceFk) return
this.address.provinceFk = value && value.id
},
'this.$route.params.address': function () {
this.loadAddress()
}
},
methods: {
loadAddress () {
this.addressId = this.$route.params.address
this.address = {}
if (this.addressId) {
let filter = {
fields: [
'id',
'nickname',
'street',
'city',
'postalCode',
'provinceFk',
'phone'
],
include: {
relation: 'province',
scope: {
fields: ['countryFk']
}
}
}
this.$axios.get(`Addresses/${this.addressId}`, { params: { filter } })
.then(res => (this.address = res.data))
} else {
this.address = {
clientFk: this.$state.userId
}
}
},
loadProvinces () {
this.provinces = null
if (!this.country) return
let filter = {
fields: ['id', 'name'],
where: { countryFk: this.country.id }
}
this.$axios.get('Provinces', { params: { filter } })
.then(res => (this.provinces = res.data))
},
updateCountry () {
if (!this.countries || !this.address.province) return
this.country = this.countries.find(
i => i.id === this.address.province.countryFk)
},
updateProvince () {
if (!this.provinces) return
let province = this.provinces.find(
i => i.id === this.address.provinceFk)
if (province) this.province = province
},
onSave () {
if (this.addressId) {
this.$axios.patch(`Addresses/${this.addressId}`, this.address)
.then(res => {
this.$q.notify(this.$t('dataSaved'))
this.$router.push('/addresses')
})
} else {
this.$axios.post(`Addresses`, this.address)
.then(res => {
this.$q.notify(this.$t('addressCreated'))
this.$router.push('/addresses')
})
}
}
}
}
</script>

141
src/pages/Addresses.vue Normal file
View File

@ -0,0 +1,141 @@
<template>
<div class="vn-pp row justify-center">
<q-card class="vn-w-md">
<q-list bordered separator>
<q-item v-if="addresses && !addresses.length">
{{$t('noDataFound')}}
</q-item>
<q-item
v-for="(address, index) in addresses"
:key="address.id"
:to="`/address/${address.id}`"
clickable
v-ripple>
<q-item-section side top>
<q-radio
v-model="defaultAddress"
:val="address.id"
:title="$t('setAsDefault')"/>
</q-item-section>
<q-item-section>
<q-item-label class="ellipsis">
{{address.nickname}}
</q-item-label>
<q-item-label class="ellipsis" caption>
{{address.street}}
</q-item-label>
<q-item-label class="ellipsis" caption>
{{address.postalCode}} {{address.city}}
</q-item-label>
</q-item-section>
<q-item-section side top>
<q-btn
icon="delete"
@click="deleteAddress(index, $event)"
:title="$t('delete')"
size="12px"
flat
dense
round/>
</q-item-section>
</q-item>
</q-list>
</q-card>
<q-dialog v-model="confirm" persistent>
<q-card>
<q-card-section class="row items-center">
{{$t('areYouSureDeleteAddress')}}
</q-card-section>
<q-card-actions align="right">
<q-btn
:label="$t('cancel')"
v-close-popup
flat
color="primary"/>
<q-btn
:label="$t('accept')"
@click="confirmDeletion(deleteIndex)"
v-close-popup
flat
color="primary"/>
</q-card-actions>
</q-card>
</q-dialog>
<q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-btn
fab
icon="add"
color="accent"
to="/address"
:title="$t('add')"
/>
</q-page-sticky>
</div>
</template>
<style lang="stylus" scoped>
</style>
<script>
export default {
name: 'Addresses',
data () {
return {
addresses: null,
defaultAddress: null,
confirm: false
}
},
mounted () {
let filter = {
fields: [
'id',
'nickname',
'street',
'city',
'postalCode'
],
where: {
clientFk: this.$state.userId,
isActive: true
},
order: 'nickname'
}
this.$axios.get('Addresses', { params: { filter } })
.then(res => (this.addresses = res.data))
filter = {
fields: ['defaultAddressFk']
}
this.$axios.get(`Clients/${this.$state.userId}`, { params: { filter } })
.then(res => (this.defaultAddress = res.data.defaultAddressFk))
},
watch: {
defaultAddress (value, oldValue) {
if (!oldValue) return
let data = { defaultAddressFk: value }
this.$axios.patch(`Clients/${this.$state.userId}`, data)
.then(res => {
this.$q.notify(this.$t('addressSetAsDefault'))
})
}
},
methods: {
deleteAddress (index, event) {
event.preventDefault()
event.stopPropagation()
this.confirm = true
this.deleteIndex = index
},
confirmDeletion (index) {
let address = this.addresses[index]
let data = { isActive: false }
this.$axios.patch(`Addresses/${address.id}`, data)
.then(res => {
this.addresses.splice(index, 1)
this.$q.notify(this.$t('addressRemoved'))
})
}
}
}
</script>

View File

@ -1,7 +1,7 @@
<template>
<div>
<div style="padding-bottom: 5em;">
<q-drawer
v-model="rightDrawerOpen"
v-model="$state.rightDrawerOpen"
content-class="bg-grey-2"
side="right"
elevated>
@ -14,7 +14,7 @@
name="cancel"
class="cursor-pointer"
:title="$t('deleteFilter')"
:to="{params: {category: null}}"
@click="$router.push({params: {category: null}})"
/>
</div>
<div>
@ -25,7 +25,7 @@
:class="{active: category == cat.id}"
:key="cat.id"
:title="cat.name"
:to="{params: {category: cat.id}}">
:to="{params: {category: cat.id, type: null}}">
<img
class="category-img"
:src="`statics/category/${cat.id}.svg`">
@ -40,6 +40,7 @@
clearable
:label="$t('family')"
@filter="filterType"
@input="$router.push({params: {type: type && type.id}})"
/>
</div>
<q-separator />
@ -66,14 +67,14 @@
</div>
<q-separator />
<div class="q-pa-md"
v-if="type || search">
v-if="typeId || search">
<div class="q-mb-md"
v-for="tag in tags"
:key="tag.uid">
<div class="q-mb-xs text-caption text-grey-7">
{{tag.name}}
<q-icon
v-if="tag.filter.length || tag.filter.min || tag.filter.max"
v-if="tag.hasFilter"
style="font-size: 1.3em;"
name="cancel"
:title="$t('deleteFilter')"
@ -90,7 +91,7 @@
:dense="true"
:val="value"
:label="value"
@input="loadItems"
@input="onCheck(tag)"
/>
</div>
<div v-if="tag.values.length > tag.showCount">
@ -118,8 +119,9 @@
:min="tag.min"
:max="tag.max"
:step="tag.step"
@input="loadItemsDelayed"
@change="loadItems"
:color="tag.hasFilter ? 'primary' : 'grey-6'"
@input="onRangeChange(tag, true)"
@change="onRangeChange(tag)"
label-always
markers
snap
@ -139,29 +141,45 @@
color="primary"
size="50px">
</q-spinner>
<div
v-if="items && !items.length"
class="text-subtitle1 text-grey-7 q-pa-md">
{{$t('noItemsFound')}}
</div>
<div
v-if="!items && !isLoading"
class="text-subtitle1 text-grey-7 q-pa-md">
{{$t('pleaseSetFilter')}}
</div>
<q-card
class="my-card"
v-for="item in items"
:key="item.id">
<img :src="`${$imageBase}/catalog/200x200/${item.image}`" />
<q-card-section>
<div class="name text-subtitle1">{{item.longName}}</div>
<div class="text-uppercase text-subtitle1 text-grey-7">
<div class="name text-subtitle1">
{{item.longName}}
</div>
<div class="sub-name text-uppercase text-subtitle1 text-grey-7 ellipsize q-pt-xs">
{{item.subName}}
</div>
</q-card-section>
<q-card-section class="tags">
<div class="tags q-pt-xs">
<div v-for="tag in item.tags" :key="tag.tagFk">
<span class="text-grey-7">{{tag.tag.name}}</span> {{tag.value}}
</div>
</div>
</q-card-section>
<q-card-actions class="actions justify-between">
<div class="q-pl-sm">
<span class="available bg-green text-white">{{item.available}}</span>
{{$t('from')}}
<span class="price">{{item.inbounds[0].buy && item.inbounds[0].buy.price3 | currency}}</span>
<span class="price">{{item.buy && item.buy.price3 | currency}}</span>
</div>
<q-btn flat>{{$t('buy')}}</q-btn>
<q-btn
icon="add_shopping_cart"
:title="$t('buy')"
flat>
</q-btn>
</q-card-actions>
</q-card>
</div>
@ -171,16 +189,27 @@
</div>
</template>
</q-infinite-scroll>
<q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-btn
fab
icon="shopping_basket"
color="accent"
:to="{name: 'orders'}"
:title="$t('orders')"
/>
</q-page-sticky>
</div>
</template>
<style lang="stylus" scoped>
.my-card
width 100%
max-width 21em
height 38em
max-width 17.5em
height 32.5em
overflow hidden
.name
.name, .sub-name
line-height 1.3em
.ellipsize
white-space nowrap
overflow hidden
text-overflow ellipsis
@ -194,7 +223,7 @@
.category-img
height 3.5em
.tags
max-height 6.2em
max-height 4.6em
overflow hidden
.available
padding .15em
@ -223,26 +252,35 @@ export default {
date: date.formatDate(new Date(), 'YYYY/MM/DD'),
category: null,
categories: [],
tags: [],
type: null,
typeId: null,
types: [],
orgTypes: [],
tags: [],
isLoading: false,
items: null,
rightDrawerOpen: this.$q.platform.is.desktop,
pageSize: 24,
limit: null,
pageSize: 30,
maxTags: 5,
disableScroll: true,
order: {
label: this.$t('name'),
value: 'name'
label: this.$t('relevancy'),
value: 'relevancy DESC, longName'
},
orderOptions: [
{
label: this.$t('relevancy'),
value: 'relevancy DESC, longName'
}, {
label: this.$t('name'),
value: 'longName'
}, /* {
}, {
label: this.$t('siceAsc'),
value: 'size ASC'
}, {
label: this.$t('sizeDesc'),
value: 'size DESC'
} /* {
label: this.$t('priceAsc'),
value: 'price ASC'
}, {
@ -251,71 +289,92 @@ export default {
}, {
label: this.$t('available'),
value: 'available'
}, */ {
label: this.$t('siceAsc'),
value: 'size ASC'
}, {
label: this.$t('sizeDesc'),
value: 'size DESC'
}
label: this.$t('novelty'),
value: 'dated DESC'
} */
]
}
},
mounted () {
this.$axios.get('ItemCategories')
.then(res => (this.categories = res.data))
this.onRouteChange(this.$route)
},
beforeDestroy () {
this.clearTimeoutAndRequest()
},
beforeRouteUpdate (to, from, next) {
this.onRouteChange(to)
next()
},
watch: {
categories () {
this.refreshTitle()
},
orgTypes () {
this.refreshTitle()
},
order () {
this.loadItems()
},
date () {
this.loadItems()
},
type (type) {
this.$router.push({
params: {
category: this.category,
type: type && type.id
category (value) {
this.orgTypes = []
if (value) {
let filter = {
where: { categoryFk: value },
order: 'order DESC, name'
}
this.$axios.get('ItemTypes', { params: { filter } })
.then(res => (this.orgTypes = res.data))
}
})
this.$state.subtitle = type && type.name
this.$state.search = ''
this.loadItems()
},
'$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
let params = { filter: { where: { categoryFk: categoryId } } }
this.$axios.get('ItemTypes', { params })
.then(res => (this.orgTypes = res.data))
},
'$route.params.type': function (type) {
if (!this.type) this.type = { id: type }
let location = { params: this.$route.params }
if (value) location.query = { search: value }
this.$router.push(location)
}
},
methods: {
loadItemsDelayed () {
this.clearTimeoutAndRequest()
this.timeout = setTimeout(() => this.loadItems(), 500)
onRouteChange (route) {
let { category, type } = route.params
category = parseInt(category) || null
type = parseInt(type) || null
this.category = category
this.typeId = category ? type : null
this.search = route.query.search || ''
this.$state.search = this.search
this.tags = []
this.refreshTitle()
this.loadItems()
},
refreshTitle () {
let title = this.$t(this.$router.currentRoute.name)
let titleColor
let subtitle
if (this.category) {
let category = this.categories.find(i => i.id === this.category) || {}
title = category.name
titleColor = `#${category.color}`
}
if (this.typeId) {
this.type = this.orgTypes.find(i => i.id === this.typeId)
subtitle = title
title = this.type && this.type.name
} else {
this.type = null
}
Object.assign(this.$state, { title, titleColor, subtitle })
},
clearTimeoutAndRequest () {
if (this.timeout) {
@ -327,34 +386,36 @@ export default {
this.source = null
}
},
loadItemsDelayed () {
this.clearTimeoutAndRequest()
this.timeout = setTimeout(() => this.loadItems(), 500)
},
loadItems () {
this.items = []
this.items = null
this.isLoading = true
this.limit = this.pageSize
this.disableScroll = false
this.loadItemsBase()
.finally(() => {
this.isLoading = false
})
.finally(() => (this.isLoading = false))
},
onLoad (index, done) {
if (this.isLoading) return done()
this.limit += this.pageSize
this.loadItemsBase()
.finally(() => done())
.finally(done)
},
loadItemsBase () {
this.clearTimeoutAndRequest()
if (!this.type) {
if (!(this.category || this.typeId || this.search)) {
this.tags = []
return Promise.resolve(true)
}
let typeFk
let tagFilter = []
for (let tag of this.tags) {
if (this.hasFilters(tag)) {
if (tag.hasFilter) {
tagFilter.push({
tagFk: tag.id,
values: tag.filter
@ -362,19 +423,16 @@ export default {
}
}
if (this.type) {
typeFk = this.type.id
}
this.source = CancelToken.source()
let params = {
dated: this.date,
typeFk: typeFk,
search: this.$state.search,
typeFk: this.typeId,
categoryFk: this.category,
search: this.search,
order: this.order.value,
tagFilter: tagFilter,
limit: this.limit
limit: this.limit,
tagFilter
}
let config = {
params,
@ -382,14 +440,20 @@ export default {
}
return this.$axios.get('Items/catalog', config)
.then(res => this.onItemsGet(res))
.catch(err => { if (!err.__CANCEL__) throw err })
.catch(err => this.onItemsError(err))
.finally(() => (this.cancel = null))
},
onItemsError (err) {
if (err.__CANCEL__) return
this.disableScroll = true
throw err
},
onItemsGet (res) {
for (let tag of res.data.tags) {
tag.uid = this.uid++
if (tag.filter) {
tag.hasFilter = true
tag.useRange =
tag.filter.max ||
tag.filter.min
@ -412,11 +476,23 @@ export default {
this.tags = res.data.tags
this.disableScroll = this.items.length < this.limit
},
onRangeChange (tag, delay) {
tag.hasFilter = true
if (!delay) this.loadItems()
else this.loadItemsDelayed()
},
onCheck (tag) {
tag.hasFilter = tag.filter.length > 0
this.loadItems()
},
resetTagFilter (tag) {
tag.hasFilter = false
if (tag.useRange) {
tag.filter = {
min: null,
max: null
min: tag.min,
max: tag.max
}
} else {
tag.filter = []
@ -426,10 +502,6 @@ export default {
this.resetTagFilter(tag)
this.loadItems()
},
hasFilters (tag) {
let filter = tag.filter
return filter.length || filter.min || filter.max
},
filterType (val, update) {
if (val === '') {
update(() => { this.types = this.orgTypes })

164
src/pages/Config.vue Normal file
View File

@ -0,0 +1,164 @@
<template>
<div class="vn-pp row justify-center">
<q-card class="vn-w-md">
<q-card-section class="q-gutter-md">
<q-input
v-model="user.name"
:label="$t('userName')"
readonly/>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input v-model="user.email" :label="$t('email')" />
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input v-model="user.nickname" :label="$t('nickname')" />
</q-card-section>
<q-card-section class="q-gutter-md">
<q-select
v-model="user.lang"
:label="$t('language')"
:options="languages"
option-value="id"
option-label="name" />
</q-card-section>
<q-card-section class="q-gutter-md">
<q-checkbox v-model="receiveInvoices" :label="$t('receiveInvoiceByEmail')" />
</q-card-section>
<q-card-actions class="justify-center">
<q-btn flat :label="$t('changePassword')" :to="{ query: { changePassword: true } }"/>
<q-btn flat :label="$t('save')" @click="onSave"/>
</q-card-actions>
</q-card>
<q-dialog v-model="changePassword" persistent>
<q-card style="width: 25em;">
<q-card-section>
<q-input
v-model="oldPassword"
:label="$t('oldPassword')"
type="password"
:rules="[val => !!val || $t('fieldIsRequired')]"
autofocus
dense/>
<q-input
v-model="newPassword"
:label="$t('newPassword')"
type="password"
:rules="[val => !!val || $t('passwordCannotBeEmpty')]"
dense>
<template v-slot:append>
<q-icon name="warning">
<q-tooltip>
<div>
{{$t('passwordRequirements')}}
</div>
<ul class="q-pl-md">
<li>{{$t('charsLong', [requirements.length])}}</li>
<li>{{$t('alphabeticChars', [requirements.nAlpha])}}</li>
<li>{{$t('upperLetters', [requirements.nUpper])}}</li>
<li>{{$t('digits', [requirements.nDigits])}}</li>
<li>{{$t('simbols', [requirements.nPunct])}}</li>
</ul>
</q-tooltip>
</q-icon>
</template>
</q-input>
<q-input
v-model="repeatPassword"
:label="$t('repeatPassword')"
type="password"
:rules="[val => val === newPassword || $t('passwordsDontMatch')]"
dense/>
</q-card-section>
<q-card-actions align="right">
<q-btn
:label="$t('cancel')"
to="/config"
flat
color="primary"/>
<q-btn
:label="$t('accept')"
@click="onChangePassword()"
flat
color="primary"/>
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
<style lang="stylus" scoped>
</style>
<script>
export default {
name: 'Config',
props: {
changePassword: {
type: Boolean,
required: false
}
},
data () {
return {
user: {},
receiveInvoices: false,
languages: null,
requirements: {},
oldPassword: '',
newPassword: '',
repeatPassword: ''
}
},
mounted () {
let filter = {
fields: [
'id',
'name',
'nickname',
'lang',
'email'
]
}
this.$axios.get(`Accounts/${this.$state.userId}`, { params: { filter } })
.then(res => (this.user = res.data))
filter = {
fields: [
'code',
'name',
'orgName'
],
where: { isActive: true }
}
this.$axios.get(`Languages`, { params: { filter } })
.then(res => (this.languages = res.data))
filter = {
fields: [
'id',
'length',
'nAlpha',
'nUpper',
'nDigits',
'nPunct'
]
}
this.$axios.get(`UserPasswords`, { params: { filter } })
.then(res => (this.requirements = res.data[0]))
},
methods: {
onChangePassword: function () {
this.$q.notify(this.$t('passwordChanged'))
this.$router.push('/config')
this.oldPassword = ''
this.newPassword = ''
this.repeatPassword = ''
},
onSave () {
this.$axios.patch(`Accounts/${this.$state.userId}`, this.user)
.then(res => (this.$q.notify(this.$t('dataSaved'))))
}
}
}
</script>

97
src/pages/Confirmed.vue Normal file
View File

@ -0,0 +1,97 @@
<template>
<div class="my-list q-pa-md">
<q-card>
<q-card-section>
<q-select v-model="yearFilter" :options="years" :label="$t('ordersMadeAt')" />
</q-card-section>
</q-card>
<q-card class="q-mt-md">
<q-list bordered separator>
<q-item v-if="!tickets.length">
{{$t('noOrdersFound')}}
</q-item>
<q-item
v-for="ticket in tickets"
:key="ticket.id"
clickable
v-ripple>
<q-item-section>
<q-item-label>{{formatDate(ticket.landed)}}</q-item-label>
<q-item-label caption>#{{ticket.id}}</q-item-label>
<q-item-label caption>{{ticket.address.nickname}}</q-item-label>
<q-item-label caption>{{ticket.address.city}}</q-item-label>
</q-item-section>
<q-item-section side top>
<q-item-label>
485.50
</q-item-label>
<q-badge
v-if="ticket.state"
color="teal"
:label="ticket.state.state.name"/>
<q-item-label v-if="ticket.packages" caption>
{{$t('packages', {n: ticket.packages})}}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card>
</div>
</template>
<style lang="stylus" scoped>
.my-list
max-width 30em
margin auto
</style>
<script>
import { date } from 'quasar'
export default {
name: 'Orders',
data () {
return {
yearFilter: null,
years: [],
tickets: []
}
},
mounted () {
let now = new Date()
let curYear = now.getFullYear()
for (let i = 0; i <= 5; i++) {
this.years.push(curYear - i)
}
this.yearFilter = curYear
},
watch: {
yearFilter (year) {
let start = new Date(year, 0)
let end = new Date(year + 1, 0)
let params = { filter: {
where: {
clientFk: this.$state.userId,
landed: { between: [start, end] }
},
include: [
'address',
{ state: 'state' }
],
order: 'landed DESC'
} }
this.$axios.get('Tickets', { params })
.then(res => (this.tickets = res.data))
}
},
methods: {
formatDate (value) {
return date.formatDate(value, 'ddd, MMMM Do', this.$t('date'))
}
}
}
</script>

View File

@ -29,6 +29,7 @@
</div>
</q-carousel-slide>
</q-carousel>
<h2 class="text-center text-grey-7">{{$t('recentNews')}}</h2>
<div class="q-pa-sm row items-start">
<div
class="new-card q-pa-sm"
@ -48,7 +49,7 @@
<q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-btn
fab
icon="shopping_basket"
icon="add_shopping_cart"
color="accent"
:to="{name: 'catalog'}"
:title="$t('startOrder')"
@ -113,7 +114,6 @@ export default {
name: 'PageIndex',
data () {
return {
slideIndex: 0,
slide: null,
slides: [
{
@ -154,13 +154,14 @@ export default {
this.$axios.get('News', { params })
.then(res => (this.news = res.data))
this.slide = this.slides[this.slideIndex].image
this.slide = this.slides[0].image
this.interval = setInterval(() => {
this.slideIndex = (this.slideIndex + 1) % this.slides.length
this.slide = this.slides[this.slideIndex].image
let index = this.slides.findIndex(i => i.image === this.slide)
let nextIndex = (index + 1) % this.slides.length
this.slide = this.slides[nextIndex].image
}, 8000)
},
destroyed () {
beforeDestroy () {
clearInterval(this.interval)
}
}

View File

@ -1,69 +1,16 @@
<template>
<div class="my-list q-pa-md">
<q-card>
<q-list bordered separator>
<q-item-label header>{{$t('pendingConfirmtion')}}</q-item-label>
<q-item v-if="!orders.length">
{{$t('noOrdersFound')}}
</q-item>
<q-item
v-for="order in orders"
:key="order.id"
clickable
v-ripple>
<q-item-section>
<q-item-label>{{formatDate(order.landed)}}</q-item-label>
<q-item-label caption>#{{order.id}}</q-item-label>
<q-item-label caption>{{order.address.nickname}}</q-item-label>
<q-item-label caption>{{order.address.city}}</q-item-label>
</q-item-section>
<q-item-section side top>
485.50
</q-item-section>
</q-item>
</q-list>
</q-card>
<q-card class="q-mt-md">
<q-card-section>
<q-select v-model="yearFilter" :options="years" :label="$t('ordersMadeAt')" />
</q-card-section>
</q-card>
<q-card class="q-mt-md">
<q-list bordered separator>
<q-item-label header>{{$t('confirmed')}}</q-item-label>
<q-item v-if="!tickets.length">
{{$t('noOrdersFound')}}
</q-item>
<q-item
v-for="ticket in tickets"
:key="ticket.id"
clickable
v-ripple>
<q-item-section>
<q-item-label>{{formatDate(ticket.landed)}}</q-item-label>
<q-item-label caption>#{{ticket.id}}</q-item-label>
<q-item-label caption>{{ticket.address.nickname}}</q-item-label>
<q-item-label caption>{{ticket.address.city}}</q-item-label>
</q-item-section>
<q-item-section side top>
<q-item-label>
485.50
</q-item-label>
<q-badge
v-if="ticket.state"
color="teal"
:label="ticket.state.state.name"/>
<q-item-label v-if="ticket.packages" caption>
{{$t('packages', {n: ticket.packages})}}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card>
<div>
<q-tabs
inline-label
class="bg-secondary text-white shadow-2">
<q-route-tab to="/orders/pending" icon="av_timer" :label="$t('pending')" />
<q-route-tab to="/orders/confirmed" icon="check" :label="$t('confirmed')" />
</q-tabs>
<router-view></router-view>
<q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-btn
fab
icon="shopping_basket"
icon="add_shopping_cart"
color="accent"
:to="{name: 'catalog'}"
:title="$t('startOrder')"
@ -72,72 +19,12 @@
</div>
</template>
<style lang="stylus" scoped>
.my-list
max-width 30em
margin auto
</style>
<script>
import { date } from 'quasar'
export default {
name: 'Orders',
data () {
return {
yearFilter: null,
years: [],
orders: [],
tickets: []
}
},
mounted () {
let params = { filter: {
where: {
clientFk: this.$state.userId,
isConfirmed: false
},
include: 'address',
order: 'created DESC',
limit: 20
} }
this.$axios.get('Orders', { params })
.then(res => (this.orders = res.data))
let now = new Date()
let curYear = now.getFullYear()
for (let i = 0; i <= 5; i++) {
this.years.push(curYear - i)
}
this.yearFilter = curYear
},
watch: {
yearFilter (year) {
let start = new Date(year, 0)
let end = new Date(year + 1, 0)
let params = { filter: {
where: {
clientFk: this.userId,
landed: { between: [start, end] }
},
include: [
'address',
{ state: 'state' }
],
order: 'landed DESC'
} }
this.$axios.get('Tickets', { params })
.then(res => (this.tickets = res.data))
}
},
methods: {
formatDate (value) {
return date.formatDate(value, 'ddd, MMMM Do', this.$t('date'))
}
this.$router.replace('/orders/pending')
}
}
</script>

64
src/pages/Pending.vue Normal file
View File

@ -0,0 +1,64 @@
<template>
<div class="my-list q-pa-md">
<q-card>
<q-list bordered separator>
<q-item v-if="!orders.length">
{{$t('noOrdersFound')}}
</q-item>
<q-item
v-for="order in orders"
:key="order.id"
clickable
v-ripple>
<q-item-section>
<q-item-label>{{formatDate(order.landed)}}</q-item-label>
<q-item-label caption>#{{order.id}}</q-item-label>
<q-item-label caption>{{order.address.nickname}}</q-item-label>
<q-item-label caption>{{order.address.city}}</q-item-label>
</q-item-section>
<q-item-section side top>
485.50
</q-item-section>
</q-item>
</q-list>
</q-card>
</div>
</template>
<style lang="stylus" scoped>
.my-list
max-width 30em
margin auto
</style>
<script>
import { date } from 'quasar'
export default {
name: 'Orders',
data () {
return {
orders: []
}
},
mounted () {
let params = { filter: {
where: {
clientFk: this.$state.userId,
isConfirmed: false
},
include: 'address',
order: 'created DESC',
limit: 20
} }
this.$axios.get('Orders', { params })
.then(res => (this.orders = res.data))
},
methods: {
formatDate (value) {
return date.formatDate(value, 'ddd, MMMM Do', this.$t('date'))
}
}
}
</script>

View File

@ -23,14 +23,13 @@ export default function (/* { store, ssrContext } */) {
})
Router.afterEach((to, from) => {
if (from.name === to.name) return
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
}
Object.assign(app.$state, {
title: app.$t(to.name),
titleColor: null,
subtitle: null
})
})
return Router

View File

@ -19,7 +19,16 @@ const routes = [
}, {
name: 'orders',
path: '/orders',
component: () => import('pages/Orders.vue')
component: () => import('pages/Orders.vue'),
children: [
{
path: 'pending',
component: () => import('pages/Pending.vue')
}, {
path: 'confirmed',
component: () => import('pages/Confirmed.vue')
}
]
}, {
name: 'conditions',
path: '/conditions',
@ -32,6 +41,21 @@ const routes = [
name: 'register',
path: '/register',
component: () => import('pages/Register.vue')
}, {
name: 'config',
path: '/config',
component: () => import('pages/Config.vue'),
props: route => ({
changePassword: String(route.query.changePassword) === 'true'
})
}, {
name: 'addresses',
path: '/addresses',
component: () => import('pages/Addresses.vue')
}, {
name: 'addressEdit',
path: '/address/:address?',
component: () => import('pages/Address.vue')
}
]
}, {