Merge pull request '5241-virtual_pos' (!44) from 5241-virtual_pos into dev
gitea/salix-front/pipeline/head This commit looks good Details

Reviewed-on: #44
Reviewed-by: Alexandre Riera <alexandre@verdnatura.es>
This commit is contained in:
Alexandre Riera 2023-03-23 13:53:45 +00:00
commit 7cada9ba36
12 changed files with 471 additions and 16 deletions

View File

@ -9,7 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added... - (Clientes) => Añadida nueva sección "Pagos Web" para gestionar los pagos de todos los clientes
- (Tickets) => Añadida opción en el menú desplegable del ticket para enviar SMS al cliente
- (Reclamaciones) => Añadida nueva sección "Registros de auditoría"
- (Trabajadores) => Añadido módulo de trabajadores
- (General) => Añadida barra de búsqueda general en los listados principales
### Changed ### Changed

View File

@ -11,6 +11,7 @@
"assets/*": ["src/assets/*"], "assets/*": ["src/assets/*"],
"boot/*": ["src/boot/*"], "boot/*": ["src/boot/*"],
"stores/*": ["src/stores/*"], "stores/*": ["src/stores/*"],
"filters/*": ["src/filters/*"],
"vue$": ["node_modules/vue/dist/vue.runtime.esm-bundler.js"] "vue$": ["node_modules/vue/dist/vue.runtime.esm-bundler.js"]
} }
}, },

View File

@ -141,8 +141,11 @@ function formatValue(value) {
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item> <q-item>
<div v-if="tags.length === 0" class="text-grey centered font-xs"> <div
{{ t(`You didn't enter any filter`) }} v-if="tags.length === 0"
class="text-grey font-xs text-center full-width"
>
{{ t(`No filters applied`) }}
</div> </div>
<div> <div>
<q-chip <q-chip
@ -194,7 +197,7 @@ function formatValue(value) {
<i18n> <i18n>
es: es:
You didn't enter any filter: No has introducido ningún filtro No filters applied: No se han aplicado filtros
Applied filters: Filtros aplicados Applied filters: Filtros aplicados
Remove filters: Eliminar filtros Remove filters: Eliminar filtros
Refresh: Refrescar Refresh: Refrescar

View File

@ -51,14 +51,7 @@ const props = defineProps({
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const arrayData = useArrayData(props.dataKey, { const arrayData = useArrayData(props.dataKey, { ...props });
url: props.url,
filter: props.filter,
where: props.where,
limit: props.limit,
order: props.order,
userParams: props.userParams,
});
const store = arrayData.store; const store = arrayData.store;
const searchText = ref(''); const searchText = ref('');

View File

@ -30,9 +30,20 @@ export function useArrayData(key, userOptions) {
}); });
function setOptions() { function setOptions() {
const allowedOptions = [
'url',
'filter',
'where',
'order',
'limit',
'skip',
'userParams',
'userFilter'
];
if (typeof userOptions === 'object') { if (typeof userOptions === 'object') {
for (const option in userOptions) { for (const option in userOptions) {
if (userOptions[option] == null) continue; const isEmpty = userOptions[option] == null || userOptions[option] == ''
if (isEmpty || !allowedOptions.includes(option)) continue;
if (Object.prototype.hasOwnProperty.call(store, option)) { if (Object.prototype.hasOwnProperty.call(store, option)) {
store[option] = userOptions[option]; store[option] = userOptions[option];
@ -62,6 +73,7 @@ export function useArrayData(key, userOptions) {
Object.assign(params, store.userParams); Object.assign(params, store.userParams);
store.isLoading = true
const response = await axios.get(store.url, { const response = await axios.get(store.url, {
signal: canceller.signal, signal: canceller.signal,
params, params,
@ -82,6 +94,8 @@ export function useArrayData(key, userOptions) {
updateStateParams(); updateStateParams();
} }
store.isLoading = false
canceller = null; canceller = null;
} }
@ -139,7 +153,8 @@ export function useArrayData(key, userOptions) {
}); });
} }
const totalRows = computed(() => store.data && store.data.length | 0); const totalRows = computed(() => store.data && store.data.length || 0);
const isLoading = computed(() => store.isLoading || false)
return { return {
fetch, fetch,
@ -152,5 +167,6 @@ export function useArrayData(key, userOptions) {
hasMoreData, hasMoreData,
totalRows, totalRows,
updateStateParams, updateStateParams,
isLoading
}; };
} }

View File

@ -60,6 +60,7 @@ export default {
pageTitles: { pageTitles: {
customers: 'Customers', customers: 'Customers',
list: 'List', list: 'List',
webPayments: 'Web Payments',
createCustomer: 'Create customer', createCustomer: 'Create customer',
summary: 'Summary', summary: 'Summary',
basicData: 'Basic Data', basicData: 'Basic Data',

View File

@ -60,9 +60,10 @@ export default {
pageTitles: { pageTitles: {
customers: 'Clientes', customers: 'Clientes',
list: 'Listado', list: 'Listado',
webPayments: 'Pagos Web',
createCustomer: 'Crear cliente', createCustomer: 'Crear cliente',
summary: 'Resumen',
basicData: 'Datos básicos', basicData: 'Datos básicos',
summary: 'Resumen'
}, },
list: { list: {
phone: 'Teléfono', phone: 'Teléfono',

View File

@ -0,0 +1,294 @@
<script setup>
import axios from 'axios';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { useStateStore } from 'stores/useStateStore';
import { useArrayData } from 'composables/useArrayData';
import Paginate from 'components/PaginateData.vue';
import VnConfirm from 'components/ui/VnConfirm.vue';
import CustomerDescriptorProxy from './Card/CustomerDescriptorProxy.vue';
import { toDate, toCurrency } from 'filters/index';
import CustomerPaymentsFilter from './CustomerPaymentsFilter.vue';
const stateStore = useStateStore();
const quasar = useQuasar();
const { t } = useI18n();
const arrayData = useArrayData('CustomerTransactions');
async function confirm(transaction) {
quasar
.dialog({
component: VnConfirm,
componentProps: {
data: transaction,
title: t('Confirm transaction'),
promise: confirmTransaction,
},
})
.onOk((row) => (row.isConfirmed = true));
}
async function confirmTransaction({ id }) {
await axios.post('Clients/confirmTransaction', { id });
quasar.notify({
message: t('Payment confirmed'),
type: 'positive',
});
}
const grid = ref(false);
const columns = computed(() => [
{
name: 'id',
label: t('Transaction ID'),
field: (row) => row.id,
sortable: true,
},
{
name: 'customerId',
label: t('Customer ID'),
field: (row) => row.clientFk,
align: 'right',
sortable: true,
},
{
name: 'customer',
label: t('Customer Name'),
field: (row) => row.customerName,
},
{
name: 'state',
label: t('State'),
field: (row) => row.isConfirmed,
format: (value) => (value ? t('Confirmed') : t('Unconfirmed')),
align: 'left',
sortable: true,
},
{
name: 'dated',
label: t('Dated'),
field: (row) => toDate(row.created),
sortable: true,
},
{
name: 'amount',
label: t('Amount'),
field: (row) => row.amount,
format: (value) => toCurrency(value),
sortable: true,
},
{
name: 'actions',
label: t('Actions'),
grid: false,
},
]);
const isLoading = computed(() => arrayData.isLoading.value);
function stateColor(row) {
if (row.isConfirmed) return 'positive';
return 'primary';
}
</script>
<template>
<template v-if="stateStore.isHeaderMounted()">
<Teleport to="#actions-append">
<div class="row q-gutter-x-sm">
<q-btn
flat
@click="stateStore.toggleRightDrawer()"
round
dense
icon="menu"
>
<q-tooltip bottom anchor="bottom right">
{{ t('globals.collapseMenu') }}
</q-tooltip>
</q-btn>
</div>
</Teleport>
</template>
<q-drawer v-model="stateStore.rightDrawer" side="right" :width="256" show-if-above>
<q-scroll-area class="fit text-grey-8">
<CustomerPaymentsFilter data-key="CustomerTransactions" />
</q-scroll-area>
</q-drawer>
<q-page class="column items-center q-pa-md">
<div class="card-list">
<q-toolbar class="q-pa-none">
<q-toolbar-title>{{ t('Web Payments') }}</q-toolbar-title>
<q-btn
@click="arrayData.refresh()"
:loading="isLoading"
icon="refresh"
color="primary"
class="q-mr-sm"
round
dense
></q-btn>
<q-btn @click="grid = !grid" icon="list" color="primary" round dense>
<q-tooltip>{{ t('Change view') }}</q-tooltip>
</q-btn>
</q-toolbar>
<paginate
data-key="CustomerTransactions"
url="Clients/transactions"
order="created DESC"
:limit="20"
:offset="50"
auto-load
>
<template #body="{ rows }">
<q-table
:dense="$q.screen.lt.md"
:columns="columns"
:rows="rows"
row-key="id"
:pagination="{ rowsPerPage: 0 }"
:grid="grid || $q.screen.lt.sm"
class="q-mt-xs"
hide-pagination
>
<template #body-cell-actions="{ row }">
<q-td auto-width class="text-center">
<q-btn
v-if="!row.isConfirmed"
icon="check"
@click="confirm(row)"
color="primary"
size="md"
round
flat
dense
>
<q-tooltip>{{ t('Confirm transaction') }}</q-tooltip>
</q-btn>
</q-td>
</template>
<template #body-cell-customerId="{ row }">
<q-td align="right">
<span class="link">
{{ row.clientFk }}
<CustomerDescriptorProxy :id="row.clientFk" />
</span>
</q-td>
</template>
<template #body-cell-state="{ row }">
<q-td auto-width class="text-center">
<q-badge :color="stateColor(row)">
{{
row.isConfirmed
? t('Confirmed')
: t('Unconfirmed')
}}
</q-badge>
</q-td>
</template>
<template #item="{ cols, row }">
<div class="q-mb-md col-12">
<q-card>
<q-item class="q-pa-none items-start">
<q-item-section class="q-pa-md">
<q-list>
<template
v-for="col of cols"
:key="col.name"
>
<q-item
v-if="col.grid !== false"
class="q-pa-none"
>
<q-item-section>
<q-item-label caption>
{{ col.label }}
</q-item-label>
<q-item-label
v-if="col.name == 'state'"
>
<q-badge
:color="
stateColor(row)
"
>
{{ col.value }}
</q-badge>
</q-item-label>
<q-item-label
v-if="col.name != 'state'"
>
{{ col.value }}
</q-item-label>
</q-item-section>
</q-item>
</template>
</q-list>
<!-- <q-list>
<q-item class="q-pa-none">
<q-item-section>
<q-item-label caption>
<q-skeleton />
</q-item-label>
<q-item-label
><q-skeleton type="text"
/></q-item-label>
</q-item-section>
</q-item>
</q-list> -->
</q-item-section>
<template v-if="!row.isConfirmed">
<q-separator vertical />
<q-card-actions
vertical
class="justify-between"
>
<q-btn
icon="check"
@click="confirm(row)"
color="primary"
size="md"
round
flat
dense
>
<q-tooltip>{{
t('Confirm transaction')
}}</q-tooltip>
</q-btn>
</q-card-actions>
</template>
</q-item>
</q-card>
</div>
</template>
</q-table>
</template>
</paginate>
</div>
</q-page>
</template>
<style lang="scss" scoped>
.card-list {
width: 100%;
max-width: 60em;
}
</style>
<i18n>
es:
Web Payments: Pagos Web
Confirm transaction: Confirmar transacción
Transaction ID: ID transacción
Customer ID: ID cliente
Customer Name: Nombre cliente
State: Estado
Dated: Fecha
Amount: Importe
Actions: Acciones
Confirmed: Confirmada
Unconfirmed: Sin confirmar
Change view: Cambiar vista
Payment confirmed: Pago confirmado
</i18n>

View File

@ -0,0 +1,78 @@
<script setup>
import { useI18n } from 'vue-i18n';
import VnFilterPanel from 'src/components/ui/VnFilterPanel.vue';
const { t } = useI18n();
const props = defineProps({
dataKey: {
type: String,
required: true,
},
});
</script>
<template>
<VnFilterPanel :data-key="props.dataKey" :search-button="true">
<template #tags="{ tag, formatFn }">
<div class="q-gutter-x-xs">
<strong>{{ t(`params.${tag.label}`) }}: </strong>
<span>{{ formatFn(tag.value) }}</span>
</div>
</template>
<template #body="{ params }">
<q-list dense>
<q-item>
<q-item-section>
<q-input
:label="t('Order ID')"
v-model="params.orderFk"
lazy-rules
>
<template #prepend>
<q-icon name="vn:basket" size="sm"></q-icon>
</template>
</q-input>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-input
:label="t('Customer ID')"
v-model="params.clientFk"
lazy-rules
>
<template #prepend>
<q-icon name="vn:client" size="sm"></q-icon>
</template>
</q-input>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-input :label="t('Amount')" v-model="params.amount" lazy-rules>
<template #prepend>
<q-icon name="euro" size="sm"></q-icon>
</template>
</q-input>
</q-item-section>
</q-item>
</q-list>
</template>
</VnFilterPanel>
</template>
<i18n>
en:
params:
orderFk: Order
clientFk: Customer
amount: Amount
es:
params:
orderFk: Pedido
clientFk: Cliente
amount: Importe
Order ID: ID pedido
Customer ID: ID cliente
Amount: Importe
</i18n>

View File

@ -10,7 +10,7 @@ export default {
component: RouterView, component: RouterView,
redirect: { name: 'CustomerMain' }, redirect: { name: 'CustomerMain' },
menus: { menus: {
main: ['CustomerList', 'CustomerCreate'], main: ['CustomerList', 'CustomerPayments', 'CustomerCreate'],
card: ['CustomerBasicData'], card: ['CustomerBasicData'],
}, },
children: [ children: [
@ -29,6 +29,15 @@ export default {
}, },
component: () => import('src/pages/Customer/CustomerList.vue') component: () => import('src/pages/Customer/CustomerList.vue')
}, },
{
path: 'payments',
name: 'CustomerPayments',
meta: {
title: 'webPayments',
icon: 'vn:onlinepayment',
},
component: () => import('src/pages/Customer/CustomerPayments.vue')
},
{ {
path: 'create', path: 'create',
name: 'CustomerCreate', name: 'CustomerCreate',

View File

@ -18,6 +18,7 @@ export const useArrayDataStore = defineStore('arrayDataStore', () => {
skip: 0, skip: 0,
order: '', order: '',
data: ref(), data: ref(),
isLoading: false
}; };
} }

View File

@ -0,0 +1,54 @@
import { vi, describe, expect, it, beforeAll, afterEach } from 'vitest';
import { createWrapper, axios } from 'app/test/vitest/helper';
import CustomerPayments from 'pages/Customer/CustomerPayments.vue';
describe('CustomerPayments', () => {
let vm;
beforeAll(() => {
vm = createWrapper(CustomerPayments, {
global: {
stubs: ['Paginate'],
mocks: {
fetch: vi.fn(),
},
}
}).vm;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('confirmTransaction()', () => {
it('should make a POST request and then call to quasar notify method', async () => {
vi.spyOn(axios, 'post').mockResolvedValue({ data: true });
vi.spyOn(vm.quasar, 'notify');
await vm.confirmTransaction({ id: 1 });
expect(vm.quasar.notify).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Payment confirmed',
type: 'positive'
})
);
});
});
describe('stateColor()', () => {
it('should return "positive" when isConfirmed property is truthy', async () => {
const result = await vm.stateColor({ isConfirmed: true });
expect(result).toEqual('positive');
});
it('should return "primary" when isConfirmed property is falsy', async () => {
const result = await vm.stateColor({ isConfirmed: false });
expect(result).toEqual('primary');
});
});
});