0
1
Fork 0

#4922 invoices & orders

This commit is contained in:
Juan Ferrer 2022-12-13 18:29:04 +01:00
parent 7e26aa773c
commit 0234e14c6b
20 changed files with 774 additions and 34 deletions

View File

@ -33,7 +33,9 @@ module.exports = configure(function (ctx) {
// https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-css
css: [
'app.scss'
'app.scss',
'width.scss',
'responsive.scss'
],
// https://github.com/quasarframework/quasar/tree/dev/extras

View File

@ -1,7 +1,10 @@
import { boot } from 'quasar/wrappers'
import { appStore } from 'stores/app'
import { userStore } from 'stores/user'
export default boot(({ app }) => {
const myApp = appStore()
app.config.globalProperties.$app = myApp
const props = app.config.globalProperties
props.$app = appStore()
props.$user = userStore()
props.$actions = document.createElement('div')
})

View File

@ -20,7 +20,15 @@ export default async ({ app }) => {
function errorHandler (err, vm) {
let message
let tMessage
const res = err.response
let res = err.response
// XXX: Compatibility with old JSON service
if (err.name === 'JsonException') {
res = {
status: err.statusCode,
data: { error: { message: err.message } }
}
}
if (res) {
const status = res.status

View File

@ -13,4 +13,6 @@ export default boot(({ app }) => {
// Set i18n instance on app
app.use(i18n)
window.i18n = i18n.global
})

View File

@ -8,6 +8,11 @@
font-family: 'Open Sans';
src: url(./opensans.ttf) format('truetype');
}
@mixin mobile {
@media screen and (max-width: 960px) {
@content;
}
}
body {
font-family: 'Poppins', 'Verdana', 'Sans';
@ -25,3 +30,6 @@ a.link {
border-radius: 7px;
box-shadow: 0 0 3px rgba(0, 0, 0, .1);
}
.q-page-sticky.fixed-bottom-right {
margin: 18px;
}

View File

@ -23,3 +23,11 @@ $positive : #21BA45;
$negative : #C10015;
$info : #31CCEC;
$warning : #F2C037;
// Width
$width-xs: 400px;
$width-sm: 544px;
$width-md: 800px;
$width-lg: 1280px;
$width-xl: 1600px;

5
src/css/responsive.scss Normal file
View File

@ -0,0 +1,5 @@
@mixin mobile {
@media screen and (max-width: 1023px) {
@content;
}
}

25
src/css/width.scss Normal file
View File

@ -0,0 +1,25 @@
%margin-auto {
margin-left: auto;
margin-right: auto;
}
.vn-w-xs {
@extend %margin-auto;
max-width: $width-xs;
}
.vn-w-sm {
@extend %margin-auto;
max-width: $width-sm;
}
.vn-w-md {
@extend %margin-auto;
max-width: $width-md;
}
.vn-w-lg {
@extend %margin-auto;
max-width: $width-lg;
}
.vn-w-xl {
@extend %margin-auto;
max-width: $width-xl;
}

View File

@ -8,5 +8,56 @@ export default {
somethingWentWrong: 'Something went wrong',
loginFailed: 'Login failed',
authenticationRequired: 'Authentication required',
notFound: 'Not found'
notFound: 'Not found',
today: 'Today',
yesterday: 'Yesterday',
tomorrow: 'Tomorrow',
date: {
days: [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday'
],
daysShort: [
'Sun',
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat'
],
months: [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
],
shortMonths: [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
]
}
}

View File

@ -8,5 +8,56 @@ export default {
somethingWentWrong: 'Algo salió mal',
loginFailed: 'Usuario o contraseña incorrectos',
authenticationRequired: 'Autenticación requerida',
notFound: 'No encontrado'
notFound: 'No encontrado',
today: 'Hoy',
yesterday: 'Ayer',
tomorrow: 'Mañana',
date: {
days: [
'Domingo',
'Lunes',
'Martes',
'Miércoles',
'Jueves',
'Viernes',
'Sábado'
],
daysShort: [
'Do',
'Lu',
'Mi',
'Mi',
'Ju',
'Vi',
'Sa'
],
months: [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre'
],
shortMonths: [
'Ene',
'Feb',
'Mar',
'Abr',
'May',
'Jun',
'Jul',
'Ago',
'Sep',
'Oct',
'Nov',
'Dic'
]
}
}

View File

@ -100,9 +100,9 @@ export class Connection extends JsonConnection {
* @return {ResultSet} The result
*/
async execQuery (query, params) {
const sql = query.replace(/#\w+/g, function (key) {
const sql = query.replace(/#\w+/g, key => {
const value = params[key.substring(1)]
return value ? this.renderValue(params) : key
return value ? this.renderValue(value) : key
})
return await this.execSql(sql)

View File

@ -60,7 +60,13 @@ export class JsonConnection extends VnObject {
* Called when REST response is received.
*/
async sendWithUrl (method, url, params) {
const urlParams = new URLSearchParams(params)
const urlParams = new URLSearchParams()
for (const key in params) {
if (params[key] != null) {
urlParams.set(key, params[key])
}
}
return this.request({
method,
url,
@ -81,7 +87,9 @@ export class JsonConnection extends VnObject {
const headers = config.headers
if (headers) {
for (const header in headers) { request.setRequestHeader(header, headers[header]) }
for (const header in headers) {
request.setRequestHeader(header, headers[header])
}
}
const promise = new Promise((resolve, reject) => {
@ -143,8 +151,9 @@ export class JsonConnection extends VnObject {
data = jsData
} else {
let exception = jsData.exception
const error = jsData.error
const err = new JsonException()
err.statusCode = request.status
if (exception) {
exception = exception
@ -158,14 +167,8 @@ export class JsonConnection extends VnObject {
err.file = jsData.file
err.line = jsData.line
err.trace = jsData.trace
err.statusCode = request.status
} else if (error) {
err.message = error.message
err.code = error.code
err.statusCode = request.status
} else {
err.message = request.statusText
err.statusCode = request.status
}
throw err

View File

@ -12,6 +12,8 @@
<q-toolbar-title>
Home
</q-toolbar-title>
<div id="actions" ref="actions">
</div>
</q-toolbar>
</q-header>
<q-drawer
@ -118,6 +120,8 @@
</style>
<style lang="scss">
@import "src/css/responsive";
.q-drawer {
.q-item {
padding-left: 38px;
@ -126,6 +130,26 @@
padding-left: 50px;
}
}
.q-page-container > * {
padding: 16px;
}
@include mobile {
#actions > div {
.q-btn {
border-radius: 50%;
padding: 10px;
&__content {
& > .q-icon {
margin-right: 0;
}
& > .block {
display: none !important;
}
}
}
}
}
</style>
<script>
@ -151,6 +175,7 @@ export default defineComponent({
},
async mounted () {
this.$refs.actions.appendChild(this.$actions)
await this.user.loadData()
await this.$app.loadConfig()
await this.fetchData()

74
src/lib/filters.js Normal file
View File

@ -0,0 +1,74 @@
import { date as qdate, format } from 'quasar'
const { pad } = format
export function currency (val) {
return typeof val === 'number' ? val.toFixed(2) + '€' : val
}
export function date (val, format) {
if (val == null) return val
if (!(val instanceof Date)) {
val = new Date(val)
}
return qdate.formatDate(val, format, window.i18n.tm('date'))
}
export function relDate (val) {
if (val == null) return val
if (!(val instanceof Date)) {
val = new Date(val)
}
const dif = qdate.getDateDiff(new Date(), val, 'days')
let day
switch (dif) {
case 0:
day = 'today'
break
case 1:
day = 'yesterday'
break
case -1:
day = 'tomorrow'
break
}
if (day) {
day = window.i18n.t(day)
} else {
if (dif > 0 && dif <= 7) {
day = qdate.formatDate(val, 'ddd', window.i18n.tm('date'))
} else {
day = qdate.formatDate(val, 'ddd, MMMM Do', window.i18n.tm('date'))
}
}
return day
}
export function relTime (val) {
if (val == null) return val
if (!(val instanceof Date)) {
val = new Date(val)
}
return relDate(val) + ' ' + qdate.formatDate(val, 'H:mm:ss')
}
export function elapsedTime (val) {
if (val == null) return val
if (!(val instanceof Date)) {
val = new Date(val)
}
const now = (new Date()).getTime()
val = Math.floor((now - val.getTime()) / 1000)
const hours = Math.floor(val / 3600)
val -= hours * 3600
const minutes = Math.floor(val / 60)
val -= minutes * 60
const seconds = val
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}`
}

View File

@ -1,5 +1,5 @@
<template>
<div>
<div style="padding: 0;">
<div class="q-pa-sm row items-start">
<div
class="new-card q-pa-sm"
@ -17,12 +17,12 @@
</q-card>
</div>
</div>
<q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-page-sticky>
<q-btn
fab
icon="add_shopping_cart"
color="accent"
to="/catalog"
to="/ecomerce/catalog"
:title="$t('startOrder')"
/>
</q-page-sticky>

View File

@ -0,0 +1,165 @@
<template>
<Teleport :to="$actions">
<q-select
v-model="year"
:options="years"
color="white"
dark
standout
dense
rounded />
</Teleport>
<div class="vn-w-sm">
<div
v-if="!invoices?.length"
class="text-subtitle1 text-center text-grey-7 q-pa-md">
{{$t('noInvoicesFound')}}
</div>
<q-card v-if="invoices?.length">
<q-table
:columns="columns"
:pagination="pagination"
:rows="invoices"
row-key="id"
hide-header
hide-bottom>
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="ref" :props="props">
{{ props.row.ref }}
</q-td>
<q-td key="issued" :props="props">
{{ date(props.row.issued, 'ddd, MMMM Do') }}
</q-td>
<q-td key="amount" :props="props">
{{ currency(props.row.amount) }}
</q-td>
<q-td key="hasPdf" :props="props">
<q-btn
v-if="props.row.hasPdf"
icon="download"
:title="$t('downloadInvoicePdf')"
:href="invoiceUrl(props.row.id)"
target="_blank"
flat
round/>
<q-icon
v-else
name="warning"
:title="$t('notDownloadable')"
color="warning"
size="24px"/>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</template>
<script>
import { date, currency } from 'src/lib/filters.js'
export default {
name: 'OrdersPendingIndex',
data () {
const curYear = (new Date()).getFullYear()
const years = []
for (let year = curYear - 5; year <= curYear; year++) {
years.push(year)
}
return {
columns: [
{ name: 'ref', label: 'serial', field: 'ref', align: 'left' },
{ name: 'issued', label: 'issued', field: 'issued', align: 'left' },
{ name: 'amount', label: 'amount', field: 'amount' },
{ name: 'hasPdf', label: 'download', field: 'hasPdf', align: 'center' }
],
pagination: {
rowsPerPage: 0
},
year: curYear,
years,
invoices: null
}
},
async mounted () {
await this.loadData()
},
watch: {
async year () {
await this.loadData()
}
},
methods: {
date,
currency,
async loadData () {
const params = {
from: new Date(this.year, 0),
to: new Date(this.year, 11, 31, 23, 59, 59)
}
this._invoices = await this.$jApi.query(
`SELECT id, ref, issued, amount, hasPdf
FROM myInvoice
WHERE issued BETWEEN #from AND #to
ORDER BY issued DESC
LIMIT 500`,
params
)
},
invoiceUrl (id) {
return '?' + new URLSearchParams({
srv: 'rest:dms/invoice',
invoice: id,
access_token: this.$user.token
}).toString()
}
}
}
</script>
<i18n lang="yaml">
en-US:
noInvoicesFound: No invoices found
serial: Serial
issued: Date
amount: Import
downloadInvoicePdf: Download invoice PDF
notDownloadable: Not available for download, request the invoice to your salesperson
es-ES:
noInvoicesFound: No se han encontrado facturas
serial: Serie
issued: Fecha
amount: Importe
downloadInvoicePdf: Descargar factura en PDF
notDownloadable: No disponible para descarga, solicita la factura a tu comercial
ca-ES:
noInvoicesFound: No s'han trobat factures
serial: Sèrie
issued: Data
amount: Import
downloadInvoicePdf: Descarregar PDF
notDownloadable: No disponible per cescarrega, sol·licita la factura al teu comercial
fr-FR:
noInvoicesFound: Aucune facture trouvée
serial: Série
issued: Date
amount: Montant
downloadInvoicePdf: Télécharger le PDF
notDownloadable: Non disponible en téléchargement, demander la facture à votre commercial
pt-PT:
noInvoicesFound: Nenhuma fatura encontrada
serial: Serie
issued: Data
amount: Importe
downloadInvoicePdf: Baixar PDF
notDownloadable: Não disponível para download, solicite a fatura ao seu comercial
</i18n>

View File

@ -1,55 +1,139 @@
<template>
<div class="vn-pp row justify-center">
<Teleport :to="$actions">
<div class="balance">
<span class="label">{{$t('balance')}}</span>
<span
class="amount"
:class="{negative: debt < 0}">
{{currency(debt || 0)}}
</span>
<q-icon
name="info"
:title="$t('paymentInfo')"
class="info"
size="24px"/>
</div>
<q-btn
icon="payments"
:label="$t('makePayment')"
@click="onPayClick()"
rounded
no-caps/>
<q-btn
to="/ecomerce/basket"
icon="shopping_cart"
:label="$t('shoppingCart')"
rounded
no-caps/>
</Teleport>
<div class="vn-w-sm">
<div
v-if="orders && !orders.length"
v-if="!orders?.length"
class="text-subtitle1 text-center text-grey-7 q-pa-md">
{{$t('noOrdersFound')}}
</div>
<q-card
v-if="orders && orders.length"
class="vn-w-md">
<q-list bordered separator>
<q-card v-if="orders?.length">
<q-list bordered separator padding >
<q-item
v-for="order in orders"
:key="order.id"
:to="`/order/${order.id}/`"
:to="`ticket/${order.id}`"
clickable
v-ripple>
<q-item-section>
<q-item-label>{{order.landed}}</q-item-label>
<q-item-label>
{{date(order.landed, 'ddd, MMMM Do')}}
</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-label caption>{{order.nickname}}</q-item-label>
<q-item-label caption>{{order.agency}}</q-item-label>
</q-item-section>
<q-item-section side top>
{{order.taxableBase}}
{{order.total}}
</q-item-section>
</q-item>
</q-list>
</q-card>
<q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-page-sticky>
<q-btn
fab
icon="add_shopping_cart"
color="accent"
to="/catalog"
to="/ecomerce/catalog"
:title="$t('startOrder')"/>
</q-page-sticky>
</div>
</template>
<style lang="scss" scoped>
.balance {
margin-right: 8px;
white-space: nowrap;
display: inline-block;
& > * {
vertical-align: middle;
}
& > .amount {
padding: 4px;
margin: 0 4px;
&.negative {
background-color: #e55;
border-radius: 2px;
box-shadow: 0 0 5px #333;
}
}
& > .info {
cursor: pointer;
}
}
</style>
<script>
import { date, currency } from 'src/lib/filters.js'
import { tpvStore } from 'stores/tpv'
export default {
name: 'OrdersPendingIndex',
data () {
return {
orders: null
orders: null,
debt: 0,
tpv: tpvStore()
}
},
async mounted () {
await this.tpv.check(this.$route)
this.orders = await this.$jApi.query(
'CALL myTicket_list(NULL, NULL)'
)
this.debt = await this.$jApi.getValue(
'SELECT -myClient_getDebt(NULL)'
)
},
methods: {
date,
currency,
async onPayClick () {
let amount = -this.debt
amount = amount <= 0 ? null : amount
let defaultAmountStr = ''
if (amount !== null) {
defaultAmountStr = amount
}
amount = prompt(this.$t('amountToPay'), defaultAmountStr)
if (amount != null) {
amount = parseFloat(amount.replace(',', '.'))
await this.tpv.pay(amount)
}
}
}
}
</script>
@ -58,7 +142,11 @@ export default {
en-US:
startOrder: Start order
noOrdersFound: No orders found
makePayment: Make payment
shoppingCart: Shopping cart
es-ES:
startOrder: Empezar pedido
noOrdersFound: No se encontrado pedidos
makePayment: Realizar pago
shoppingCart: Cesta de la compra
</i18n>

View File

@ -0,0 +1,127 @@
<template>
<Teleport :to="$actions">
<q-btn
icon="print"
:label="$t('printDeliveryNote')"
@click="onPrintClick()"
rounded
no-caps/>
</Teleport>
<div>
<q-card class="vn-w-sm">
<q-card-section>
<div class="text-h6">#{{ticket.id}}</div>
</q-card-section>
<q-card-section>
<div class="text-h6">{{$t('shippingInformation')}}</div>
<div>{{$t('preparation')}} {{date(ticket.shipped, 'ddd, MMMM Do')}}</div>
<div>{{$t('delivery')}} {{date(ticket.shipped, 'ddd, MMMM Do')}}</div>
<div>{{$t(ticket.method != 'PICKUP' ? 'agency' : 'warehouse')}} {{ticket.agency}}</div>
</q-card-section>
<q-card-section>
<div class="text-h6">{{$t('deliveryAddress')}}</div>
<div>{{ticket.nickname}}</div>
<div>{{ticket.street}}</div>
<div>{{ticket.postalCode}} {{ticket.city}} ({{ticket.province}})</div>
</q-card-section>
<q-separator inset />
<q-list v-for="row in rows" :key="row.itemFk">
<q-item>
<q-item-section avatar>
<q-avatar size="68px">
<img :src="`${$app.imageUrl}/catalog/200x200/${row.image}`">
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label lines="1">
{{row.concept}}
</q-item-label>
<q-item-label lines="1" caption>
{{row.value5}} {{row.value6}} {{row.value7}}
</q-item-label>
<q-item-label lines="1">
{{row.quantity}} x {{currency(row.price)}}
</q-item-label>
</q-item-section>
<q-item-section side class="total">
<q-item-label>
<span class="discount" v-if="row.discount">
{{currency(discountSubtotal(row))}} -
{{currency(row.discount)}} =
</span>
{{currency(subtotal(row))}}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card>
</div>
</template>
<style lang="scss" scoped>
.total {
justify-content: flex-end;
}
</style>
<script>
import { date, currency } from 'src/lib/filters.js'
export default {
name: 'OrdersConfirmedView',
data () {
return {
ticket: {},
rows: null,
services: null,
packages: null
}
},
async mounted () {
const params = {
ticket: parseInt(this.$route.params.id)
}
this.ticket = await this.$jApi.getObject(
'CALL myTicket_get(#ticket)',
params
)
this.rows = await this.$jApi.query(
'CALL myTicket_getRows(#ticket)',
params
)
this.services = await this.$jApi.query(
'CALL myTicket_getServices(#ticket)',
params
)
this.packages = await this.$jApi.query(
'CALL myTicket_getPackages(#ticket)',
params
)
},
methods: {
date,
currency,
discountSubtotal (line) {
return line.quantity * line.price
},
subtotal (line) {
const discount = line.discount
return this.discountSubtotal(line) * ((100 - discount) / 100)
},
onPrintClick () {
const params = new URLSearchParams({
access_token: this.$user.token,
recipientId: this.$user.id,
type: 'deliveryNote'
})
window.open(`/api/Tickets/${this.ticket.id}/delivery-note-pdf?${params.toString()}`)
}
}
}
</script>

View File

@ -34,6 +34,14 @@ const routes = [
name: 'orders',
path: '/ecomerce/orders',
component: () => import('pages/Ecomerce/Orders.vue')
}, {
name: 'ticket',
path: '/ecomerce/ticket/:id',
component: () => import('pages/Ecomerce/Ticket.vue')
}, {
name: 'invoices',
path: '/ecomerce/invoices',
component: () => import('pages/Ecomerce/Invoices.vue')
}
]
},

87
src/stores/tpv.js Normal file
View File

@ -0,0 +1,87 @@
import { defineStore } from 'pinia'
import { jApi } from 'boot/axios'
export const tpvStore = defineStore('tpv', {
actions: {
async check (route) {
const order = route.query.tpvOrder
const status = route.query.tpvStatus
if (!(order && status)) return null
await jApi.execQuery(
'CALL myTpvTransaction_end(#order, #status)',
{ order, status }
)
if (status === 'ko') {
const retry = confirm('retryPayQuestion')
if (retry) { this.retryPay(order) }
}
return status
},
async pay (amount, company) {
await this.realPay(amount * 100, company)
},
async retryPay (order) {
const payment = await jApi.getObject(
`SELECT t.amount, m.companyFk
FROM myTpvTransaction t
JOIN tpvMerchant m ON m.id = t.merchantFk
WHERE t.id = #order`,
{ order }
)
await this.realPay(payment.amount, payment.companyFk)
},
async realPay (amount, company) {
if (!isNumeric(amount) || amount <= 0) {
throw new Error('payAmountError')
}
const json = await jApi.send('tpv/transaction', {
amount: parseInt(amount),
urlOk: this.makeUrl('ok'),
urlKo: this.makeUrl('ko'),
company
})
const postValues = json.postValues
const form = document.createElement('form')
form.method = 'POST'
form.action = json.url
document.body.appendChild(form)
for (const field in postValues) {
const input = document.createElement('input')
input.type = 'hidden'
input.name = field
form.appendChild(input)
if (postValues[field]) { input.value = postValues[field] }
}
form.submit()
},
makeUrl (status) {
let path = location.protocol + '//' + location.hostname
path += location.port ? ':' + location.port : ''
path += location.pathname
path += location.search ? location.search : ''
path += '#/ecomerce/orders'
path += '?' + new URLSearchParams({
tpvStatus: status,
tpvOrder: '_transactionId_'
}).toString()
return path
}
}
})
function isNumeric (n) {
return !isNaN(parseFloat(n)) && isFinite(n)
}