Merge branch 'beta' into taro/confirmed-orders
gitea/hedera-web/pipeline/pr-beta This commit looks good Details

This commit is contained in:
taro 2025-03-27 19:40:49 -03:00
commit 62eb1069ca
31 changed files with 7997 additions and 5873 deletions

View File

@ -1,2 +1,5 @@
debian
node_modules
node_modules
.quasar
build
.vscode

View File

@ -59,7 +59,7 @@
"salix": "cd ../salix && gulp back",
"db": "cd ../salix && gulp docker",
"cy:open": "npm run db && cypress open",
"test:e2e": "npm run db && cypress run",
"test:e2e": "npm run db && cypress run --headed --config video=false",
"test:unit": "vitest",
"build": "rm -rf dist/ ; quasar build",
"clean": "rm -rf dist/",

File diff suppressed because it is too large Load Diff

View File

@ -23,7 +23,7 @@ module.exports = configure(function (ctx) {
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-webpack/boot-files
boot: ['i18n', 'axios', 'vnDate', 'error-handler', 'app'],
boot: ['i18n', 'axios', 'vnDate', 'app'],
// https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-css
css: ['app.scss', 'width.scss', 'responsive.scss'],

View File

@ -1,66 +0,0 @@
export default async ({ app }) => {
/*
window.addEventListener('error',
e => onWindowError(e));
window.addEventListener('unhandledrejection',
e => onWindowRejection(e));
,onWindowError(event) {
errorHandler(event.error);
}
,onWindowRejection(event) {
errorHandler(event.reason);
}
*/
app.config.errorHandler = (err, vm, info) => {
errorHandler(err, vm)
}
function errorHandler (err, vm) {
let message
let tMessage
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
if (status >= 400 && status < 500) {
switch (status) {
case 401:
tMessage = 'loginFailed'
break
case 403:
tMessage = 'authenticationRequired'
vm.$router.push('/login')
break
case 404:
tMessage = 'notFound'
break
default:
message = res.data.error.message
}
} else if (status >= 500) {
tMessage = 'internalServerError'
}
} else {
tMessage = 'somethingWentWrong'
console.error(err)
}
if (tMessage) {
message = vm.$t(tMessage)
}
vm.$q.notify({
message,
type: 'negative'
})
}
}

View File

@ -0,0 +1,66 @@
<script setup>
import { onMounted, inject } from 'vue';
const $props = defineProps({
autoLoad: {
type: Boolean,
default: false
},
url: {
type: String,
default: ''
},
filter: {
type: Object,
default: null
},
where: {
type: Object,
default: null
},
sortBy: {
type: String,
default: ''
},
limit: {
type: [String, Number],
default: ''
},
params: {
type: Object,
default: null
}
});
const emit = defineEmits(['onFetch']);
const api = inject('api');
defineExpose({ fetch });
onMounted(async () => {
if ($props.autoLoad) {
await fetch();
}
});
async function fetch(fetchFilter = {}) {
try {
const filter = { ...fetchFilter, ...$props.filter }; // eslint-disable-line vue/no-dupe-keys
if ($props.where && !fetchFilter.where) filter.where = $props.where;
if ($props.sortBy) filter.order = $props.sortBy;
if ($props.limit) filter.limit = $props.limit;
const { data } = await api.get($props.url, {
params: { filter: JSON.stringify(filter), ...$props.params }
});
emit('onFetch', data);
return data;
} catch (e) {
//
}
}
</script>
<template>
<template></template>
</template>

View File

@ -0,0 +1,231 @@
<script setup>
import { ref, inject, onMounted, computed, Teleport, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAppStore } from 'stores/app';
import { storeToRefs } from 'pinia';
import useNotify from 'src/composables/useNotify.js';
const props = defineProps({
title: {
type: String,
default: ''
},
table: {
type: String,
default: ''
},
schema: {
type: String,
default: ''
},
// Objeto con los datos iniciales del form, si este objeto es definido, no se ejecuta la query fetch
formInitialData: {
type: Object,
default: () => {}
},
autoLoad: {
type: Boolean,
default: true
},
defaultActions: {
type: Boolean,
default: true
},
showBottomActions: {
type: Boolean,
default: false
},
saveFn: {
type: Function,
default: null
},
separationBetweenInputs: {
type: String,
default: 'xs'
},
url: {
type: String,
default: ''
},
urlUpdate: {
type: String,
default: null
},
urlCreate: {
type: String,
default: null
},
observeFormChanges: {
type: Boolean,
default: true,
description:
'Esto se usa principalmente para permitir guardar sin hacer cambios (Útil para la feature de clonar ya que en este caso queremos poder guardar de primeras)'
},
mapper: {
type: Function,
default: null
},
filter: {
type: Object,
default: null
}
});
const emit = defineEmits(['onDataSaved', 'onDataFetched']);
const api = inject('api');
const { t } = useI18n();
const { notify } = useNotify();
const appStore = useAppStore();
const { isHeaderMounted } = storeToRefs(appStore);
const isLoading = ref(false);
const formData = ref({});
const formModelRef = ref(null);
const hasChanges = ref(!props.observeFormChanges);
const isResetting = ref(false);
const originalData = ref(null);
const separationBetweenInputs = computed(() => {
return `q-gutter-y-${props.separationBetweenInputs}`;
});
const onSubmitSuccess = () => {
emit('onDataSaved');
notify(t('dataSaved'), 'positive');
};
onMounted(async () => {
if (!props.formInitialData) {
if (props.autoLoad && props.url) await fetch();
originalData.value = { ...formData.value };
} else {
formData.value = { ...props.formInitialData };
originalData.value = { ...props.formInitialData };
}
if (props.observeFormChanges) {
watch(
() => formData.value,
(newVal, oldVal) => {
if (!oldVal) return;
hasChanges.value =
!isResetting.value &&
JSON.stringify(newVal) !==
JSON.stringify(originalData.value);
isResetting.value = false;
},
{ deep: true }
);
}
});
async function fetch() {
try {
let { data } = await api.get(props.url, {
params: { filter: JSON.stringify(props.filter) }
});
if (Array.isArray(data)) data = data[0] ?? {};
formData.value = { ...data };
emit('onDataFetched', formData.value);
} catch (e) {
throw e;
}
}
const submitForm = async evt => {
const isFormValid = await formModelRef.value.validate();
if (isFormValid) await save(evt);
};
async function save() {
if (props.observeFormChanges && !hasChanges.value)
return notify('noChanges', 'negative');
isLoading.value = true;
try {
const body = props.mapper
? props.mapper(formData.value, originalData.value)
: formData.value;
const method = props.urlCreate ? 'post' : 'patch';
const url = props.urlCreate || props.urlUpdate || props.url;
await Promise.resolve(
props.saveFn ? props.saveFn(body) : api[method](url, body)
);
onSubmitSuccess();
hasChanges.value = false;
} finally {
isLoading.value = false;
}
}
defineExpose({
formData,
submitForm
});
</script>
<template>
<QCard class="form-container" v-bind="$attrs">
<QForm ref="formModelRef" class="form" :class="separationBetweenInputs">
<span v-if="title" class="text-h6 text-bold">
{{ title }}
</span>
<slot name="form" :data="formData" />
<slot name="extraForm" :data="formData" />
<component
v-if="isHeaderMounted"
:is="showBottomActions ? 'div' : Teleport"
to="#actions"
class="flex row justify-end q-gutter-x-sm"
:class="{ 'q-mt-md': showBottomActions }"
>
<QBtn
v-if="defaultActions && showBottomActions"
:label="t('cancel')"
:icon="showBottomActions ? undefined : 'check'"
rounded
no-caps
flat
v-close-popup
>
<QTooltip>{{ t('cancel') }}</QTooltip>
</QBtn>
<QBtn
v-if="defaultActions"
:label="t('save')"
:icon="showBottomActions ? undefined : 'check'"
rounded
no-caps
flat
:loading="isLoading"
:disabled="!showBottomActions && !hasChanges"
@click="submitForm()"
data-cy="formModelDefaultSaveButton"
>
<QTooltip>{{ t('save') }}</QTooltip>
</QBtn>
<slot name="actions" :data="formData" />
</component>
</QForm>
</QCard>
</template>
<style lang="scss" scoped>
.form-container {
width: 100%;
height: max-content;
padding: 32px;
max-width: 544px;
display: flex;
justify-content: center;
}
.form {
display: flex;
flex-direction: column;
width: 100%;
}
</style>

View File

@ -1,15 +1,20 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, useAttrs, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRequired } from 'src/composables/useRequired';
const $attrs = useAttrs();
const { isRequired, requiredFieldRule } = useRequired($attrs);
const { t } = useI18n();
const emit = defineEmits([
'update:modelValue',
'update:options',
'keyup.enter',
'remove'
'remove',
'blur'
]);
const props = defineProps({
const $props = defineProps({
modelValue: {
type: [String, Number],
default: null
@ -25,24 +30,43 @@ const props = defineProps({
clearable: {
type: Boolean,
default: true
},
emptyToNull: {
type: Boolean,
default: true
},
insertable: {
type: Boolean,
default: false
},
maxlength: {
type: Number,
default: null
},
uppercase: {
type: Boolean,
default: false
}
});
const { t } = useI18n();
const requiredFieldRule = val => !!val || t('globals.fieldRequired');
const vnInputRef = ref(null);
const value = computed({
get() {
return props.modelValue;
return $props.modelValue;
},
set(value) {
if ($props.emptyToNull && value === '') value = null;
emit('update:modelValue', value);
}
});
const hover = ref(false);
const styleAttrs = computed(() => {
return props.isOutlined
? { dense: true, outlined: true, rounded: true }
return $props.isOutlined
? {
dense: true,
outlined: true,
rounded: true
}
: {};
});
@ -51,66 +75,119 @@ const focus = () => {
};
defineExpose({
focus
focus,
vnInputRef
});
const inputRules = [
const mixinRules = [
requiredFieldRule,
...($attrs.rules ?? []),
val => {
const { maxlength } = vnInputRef.value;
if (maxlength && +val.length > maxlength)
return t(`maxLength`, { value: maxlength });
const { min, max } = vnInputRef.value.$attrs;
if (min >= 0) {
if (!min) return null;
if (min >= 0)
if (Math.floor(val) < min) return t('inputMin', { value: min });
}
if (!max) return null;
if (max > 0) {
if (Math.floor(val) > max) return t('inputMax', { value: max });
}
}
];
const handleKeydown = e => {
if (e.key === 'Backspace') return;
if ($props.insertable && e.key.match(/[0-9]/)) {
handleInsertMode(e);
}
};
const handleInsertMode = e => {
e.preventDefault();
const input = e.target;
const cursorPos = input.selectionStart;
const { maxlength } = vnInputRef.value;
let currentValue = value.value;
if (!currentValue) currentValue = e.key;
const newValue = e.key;
if (newValue && !isNaN(newValue) && cursorPos < maxlength) {
value.value =
currentValue.substring(0, cursorPos) +
newValue +
currentValue.substring(cursorPos + 1);
}
nextTick(() => {
input.setSelectionRange(cursorPos + 1, cursorPos + 1);
});
};
const handleUppercase = () => {
value.value = value.value?.toUpperCase() || '';
};
</script>
<template>
<div
:rules="$attrs.required ? [requiredFieldRule] : null"
@mouseover="hover = true"
@mouseleave="hover = false"
>
<div @mouseover="hover = true" @mouseleave="hover = false">
<QInput
ref="vnInputRef"
v-model="value"
v-bind="{ ...$attrs, ...styleAttrs }"
:type="$attrs.type"
:class="{ required: $attrs.required }"
:class="{ required: isRequired }"
@keyup.enter="emit('keyup.enter')"
@blur="emit('blur')"
@keydown="handleKeydown"
:clearable="false"
:rules="inputRules"
:rules="mixinRules"
:lazy-rules="true"
hide-bottom-space
@keyup.enter="emit('keyup.enter')"
:data-cy="$attrs['data-cy'] ?? $attrs.label + '_input'"
>
<template
v-if="$slots.prepend"
#prepend
>
<template #prepend v-if="$slots.prepend">
<slot name="prepend" />
</template>
<template #append>
<slot
v-if="$slots.append && !$attrs.disabled"
name="append"
/>
<QIcon
v-if="hover && value && !$attrs.disabled && props.clearable"
name="close"
size="xs"
:style="{
visibility:
hover &&
value &&
!$attrs.disabled &&
!$attrs.readonly &&
$props.clearable
? 'visible'
: 'hidden'
}"
@click="
() => {
value = null;
vnInputRef.focus();
emit('remove');
}
"
/>
></QIcon>
<QIcon
v-if="info"
name="info"
name="match_case"
size="xs"
v-if="
!$attrs.disabled && !$attrs.readonly && $props.uppercase
"
@click="handleUppercase"
class="uppercase-icon"
>
<QTooltip>
{{ t('Convert to uppercase') }}
</QTooltip>
</QIcon>
<slot name="append" v-if="$slots.append && !$attrs.disabled" />
<QIcon v-if="info" name="info">
<QTooltip max-width="350px">
{{ info }}
</QTooltip>
@ -120,20 +197,44 @@ const inputRules = [
</div>
</template>
<style>
.uppercase-icon {
transition:
color 0.3s,
transform 0.2s;
cursor: pointer;
}
.uppercase-icon:hover {
color: #ed9937;
transform: scale(1.2);
}
</style>
<i18n lang="yaml">
en-US:
inputMin: Must be more than {value}
maxLength: The value exceeds {value} characters
inputMax: Must be less than {value}
Convert to uppercase: Convert to uppercase
es-ES:
inputMin: Debe ser mayor a {value}
maxLength: El valor excede los {value} carácteres
inputMax: Debe ser menor a {value}
Convert to uppercase: Convertir a mayúsculas
ca-ES:
inputMin: Ha de ser més gran que {value}
inputMax: Ha de ser menys que {value}
maxLength: El valor excedeix els {value} caràcters
inputMax: Ha de ser menor que {value}
Convert to uppercase: Convertir a majúscules
fr-FR:
inputMin: Doit être supérieur à {value}
inputMax: Doit être supérieur à {value}
maxLength: La valeur dépasse {value} caractères
inputMax: Doit être inférieur à {value}
Convert to uppercase: Convertir en majuscules
pt-PT:
inputMin: Deve ser maior que {value}
inputMax: Deve ser maior que {value}
maxLength: O valor excede {value} caracteres
inputMax: Deve ser menor que {value}
Convert to uppercase: Converter para maiúsculas
</i18n>

View File

@ -67,7 +67,7 @@ const $props = defineProps({
});
const { t } = useI18n();
const requiredFieldRule = val => val ?? t('globals.fieldRequired');
const requiredFieldRule = val => val ?? t('fieldRequired');
const { optionLabel, optionValue, options } = toRefs($props);
const myOptions = ref([]);

View File

@ -0,0 +1,119 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import VnInput from 'src/components/common/VnInput.vue';
import { fetch } from 'src/composables/serviceUtils';
const props = defineProps({
searchFn: {
type: Function,
default: null
},
placeholder: {
type: String,
default: ''
},
url: {
type: String,
default: null
},
filter: {
type: Object,
default: null
},
searchField: {
type: String,
default: 'search'
},
exprBuilder: {
type: Function,
default: null
}
});
const emit = defineEmits(['onSearch', 'onSearchError']);
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const searchTerm = ref('');
const search = async () => {
try {
router.replace({
query: searchTerm.value ? { search: searchTerm.value } : {}
});
if (!searchTerm.value) {
emit('onSearchError');
return;
}
if (props.url) {
const params = {
filter: props.filter,
[props.searchField]: searchTerm.value
};
const { data } = await fetch({
url: props.url,
params,
exprBuilder: props.exprBuilder
});
emit('onSearch', data);
}
} catch (error) {
console.error('Error searching:', error);
emit('onSearchError');
}
};
onMounted(() => {
if (route.query.search) {
searchTerm.value = route.query.search;
search();
}
});
</script>
<template>
<VnInput
v-model="searchTerm"
@keyup.enter.stop="search()"
:placeholder="props.placeholder || t('search')"
bg-color="white"
is-outlined
:clearable="false"
class="searchbar"
data-cy="searchBar"
>
<template #prepend>
<QIcon name="search" class="cursor-pointer" @click="search()" />
</template>
</VnInput>
</template>
<style lang="scss" scoped>
@import 'src/css/responsive';
.searchbar {
@include mobile {
max-width: 120px;
}
}
</style>
<i18n lang="yaml">
en-US:
search: Search
es-ES:
search: Buscar
ca-ES:
search: Cercar
fr-FR:
search: Rechercher
pt-PT:
search: Pesquisar
</i18n>

View File

@ -0,0 +1,116 @@
import { api } from '@/boot/axios';
function buildFilter(params, builderFunc) {
let and = [];
for (let param in params) {
let value = params[param];
if (value == null) continue;
let expr = builderFunc(param, value);
if (expr) and.push(expr);
}
return simplifyOperation(and, 'and');
}
const simplifyOperation = (operation, operator) => {
switch (operation.length) {
case 0:
return undefined;
case 1:
return operation[0];
default:
return { [operator]: operation };
}
};
async function fetch({
url,
append = false,
params,
exprBuilder,
mapKey,
existingData = [],
existingMap = new Map(),
oneRecord = false
}) {
if (!url) return;
let exprFilter;
if (exprBuilder) {
exprFilter = buildFilter(params, (param, value) => {
if (param === 'filter') return;
const res = exprBuilder(param, value);
if (res) delete params[param];
return res;
});
}
if (params.filter?.where || exprFilter) {
params.filter.where = { ...params.filter.where, ...exprFilter };
}
if (!params?.filter?.order?.length) {
delete params?.filter?.order;
}
params.filter = JSON.stringify(params.filter);
const response = await api.get(url, { params });
const processedData = processData(response.data, {
mapKey,
map: !!mapKey,
append,
oneRecord,
existingData,
existingMap
});
return { response, data: processedData.data, map: processedData.map };
}
function processData(data, options) {
const {
mapKey,
map = true,
append = true,
oneRecord = false,
existingData = [],
existingMap = new Map()
} = options;
let resultData = [...existingData];
let resultMap = new Map(existingMap);
if (oneRecord) {
return Array.isArray(data) ? data[0] : data;
}
if (!append) {
resultData = [];
resultMap = new Map();
}
if (!Array.isArray(data)) {
resultData = data;
} else if (!map && append) {
resultData.push(...data);
} else {
for (const row of data) {
const key = row[mapKey];
const val = { ...row, key };
if (key && resultMap.has(key)) {
const { position } = resultMap.get(key);
val.position = position;
resultMap.set(key, val);
resultData[position] = val;
} else {
val.position = resultMap.size;
resultMap.set(key, val);
resultData.push(val);
}
}
}
return { data: resultData, map: resultMap };
}
export { buildFilter, fetch, processData };

View File

@ -0,0 +1,17 @@
import { useI18n } from 'vue-i18n';
export function useRequired($attrs) {
const { t } = useI18n();
const isRequired =
typeof $attrs['required'] === 'boolean'
? $attrs['required']
: Object.keys($attrs).includes('required');
const requiredFieldRule = val =>
isRequired ? !!val || t('fieldRequired') : null;
return {
isRequired,
requiredFieldRule
};
}

View File

@ -134,6 +134,8 @@ export default {
introduceSearchTerm: 'Introdueix un terme de cerca',
noOrdersFound: `No s'han trobat comandes`,
send: 'Enviar',
fieldRequired: 'Aquest camp és obligatori',
noChanges: 'No shan fet canvis',
// Image related translations
'Cant lock cache': 'No es pot bloquejar la memòria cau',
'Bad file format': 'Format de fitxer no reconegut',

View File

@ -167,6 +167,8 @@ export default {
introduceSearchTerm: 'Enter a search term',
noOrdersFound: 'No orders found',
send: 'Send',
fieldRequired: 'Field required',
noChanges: 'No changes',
// Image related translations
'Cant lock cache': 'The cache could not be blocked',
'Bad file format': 'Unrecognized file format',

View File

@ -166,6 +166,8 @@ export default {
introduceSearchTerm: 'Introduce un término de búsqueda',
noOrdersFound: 'No se encontrado pedidos',
send: 'Enviar',
fieldRequired: 'Campo requerido',
noChanges: 'No se han hecho cambios',
// Image related translations
'Cant lock cache': 'La caché no pudo ser bloqueada',
'Bad file format': 'Formato de archivo no reconocido',

View File

@ -134,6 +134,8 @@ export default {
introduceSearchTerm: 'Entrez un terme de recherche',
noOrdersFound: 'Aucune commande trouvée',
send: 'Envoyer',
fieldRequired: 'Champ obligatoire',
noChanges: 'Aucun changement',
// Image related translations
'Cant lock cache': "Le cache n'a pas pu être verrouillé",
'Bad file format': 'Format de fichier non reconnu',

View File

@ -133,6 +133,8 @@ export default {
introduceSearchTerm: 'Digite um termo de pesquisa',
noOrdersFound: 'Nenhum pedido encontrado',
send: 'Enviar',
fieldRequired: 'Campo obrigatório',
noChanges: 'Sem alterações',
// Image related translations
'Cant lock cache': 'O cache não pôde ser bloqueado',
'Bad file format': 'Formato de arquivo inválido',

View File

@ -3,8 +3,9 @@ import { ref, inject, onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import VnInput from 'src/components/common/VnInput.vue';
import VnSelect from 'src/components/common/VnSelect.vue';
import VnForm from 'src/components/common/VnForm.vue';
import ChangePasswordForm from 'src/components/ui/ChangePasswordForm.vue';
import FormModel from 'src/components/common/FormModel.vue';
import useNotify from 'src/composables/useNotify.js';
import { useUserStore } from 'stores/user';
import { useAppStore } from 'stores/app';
@ -12,54 +13,76 @@ import { storeToRefs } from 'pinia';
const userStore = useUserStore();
const { t } = useI18n();
const jApi = inject('jApi');
const api = inject('api');
const appStore = useAppStore();
const { isHeaderMounted } = storeToRefs(appStore);
const { user } = storeToRefs(userStore);
const { notify } = useNotify();
const vnFormRef = ref(null);
const vnFormRef2 = ref(null);
const changePasswordFormDialog = ref(null);
const showChangePasswordForm = ref(false);
const langOptions = ref([]);
const pks = computed(() => ({ id: userStore?.user?.id }));
const fetchConfigDataSql = {
query: `
SELECT u.id, u.name, u.email, u.nickname,
u.lang, c.isToBeMailed, c.id clientFk
FROM account.myUser u
LEFT JOIN myClient c
ON u.id = c.id`,
params: {}
};
const formInitialData = ref({});
const showForm = ref(false);
const fetchLanguagesSql = async () => {
const fetchLanguages = async () => {
try {
const data = await jApi.query(
'SELECT code, name FROM language WHERE isActive'
);
const filter = { fields: ['code', 'name'], where: { isActive: true } };
const { data } = await api.get('/languages', {
params: { filter: JSON.stringify(filter) }
});
langOptions.value = data;
} catch (error) {
console.error(error);
}
};
const fetchFormInitialData = async () => {
try {
const filter = {
where: { id: user?.value?.id },
fields: ['id', 'name', 'isToBeMailed'],
include: {
relation: 'user',
scope: {
fields: ['nickname', 'lang', 'email']
}
}
};
const { data } = await api.get('/Clients', {
params: { filter: JSON.stringify(filter) }
});
const { user: userData, ...restOfData } = data[0];
formInitialData.value = {
...restOfData,
nickname: userData?.nickname,
lang: userData?.lang,
email: userData?.email
};
} catch (error) {
console.error(error);
} finally {
showForm.value = true;
}
};
const updateUserNickname = async nickname => {
try {
await vnFormRef.value.submit();
await submitAccountData(nickname);
await submitNickname(nickname);
user.value.nickname = nickname;
} catch (error) {
console.error(error);
}
};
const formatMailData = data => {
data.isToBeMailed = Boolean(data.isToBeMailed);
};
const updateConfigLang = async lang => {
try {
await vnFormRef.value.submit();
if (!lang) return;
await submitAccountData({ lang });
userStore.updateUserLang(lang);
const siteLocaleLang = appStore.localeOptions.find(
locale => locale.value === lang
@ -70,7 +93,32 @@ const updateConfigLang = async lang => {
}
};
onMounted(() => fetchLanguagesSql());
const submitAccountData = async data => {
try {
const params = {
...data
};
await api.patch(`/VnUsers/${user?.value?.id}`, params);
notify(t('dataSaved'), 'positive');
} catch (error) {
console.error(error);
}
};
const submitIsToBeMailed = async isToBeMailed => {
try {
const payload = { isToBeMailed };
await api.patch(`/Clients/${user.value.id}`, payload);
notify(t('dataSaved'), 'positive');
} catch (error) {
console.error(error);
}
};
onMounted(async () => {
fetchLanguages();
fetchFormInitialData();
});
</script>
<template>
@ -92,14 +140,14 @@ onMounted(() => fetchLanguagesSql());
@click="showChangePasswordForm = true"
/>
</Teleport>
<VnForm
<FormModel
v-if="showForm"
ref="vnFormRef"
:save-fn="submitFormFn"
:form-initial-data="formInitialData"
:title="t('personalInformation')"
:fetch-form-data-sql="fetchConfigDataSql"
:pks="pks"
table="myUser"
schema="account"
:default-actions="false"
:show-bottom-actions="false"
:defaultActions="false"
>
<template #form="{ data }">
<VnInput
@ -111,14 +159,16 @@ onMounted(() => fetchLanguagesSql());
<VnInput
v-model="data.email"
:label="t('email')"
@keyup.enter="vnFormRef.submit()"
@blur="vnFormRef.submit()"
@keyup.enter="submitAccountData({ email: data.email })"
@blur="submitAccountData({ email: data.email })"
/>
<VnInput
v-model="data.nickname"
:label="t('nickname')"
@keyup.enter="updateUserNickname(data.nickname)"
@blur="updateUserNickname(data.nickname)"
@keyup.enter="
updateUserNickname({ nickname: data.nickname })
"
@blur="updateUserNickname({ nickname: data.nickname })"
data-cy="configViewNickname"
/>
<VnSelect
@ -130,30 +180,17 @@ onMounted(() => fetchLanguagesSql());
@update:model-value="updateConfigLang(data.lang)"
data-cy="configViewLang"
/>
<QCheckbox
v-model="data.isToBeMailed"
:label="t('isToBeMailed')"
:toggle-indeterminate="false"
@update:model-value="
submitIsToBeMailed(data.isToBeMailed)
"
dense
/>
</template>
<template #extraForm>
<VnForm
class="no-form-container q-mt-md"
ref="vnFormRef2"
:pks="pks"
table="myClient"
schema="hedera"
:fetch-form-data-sql="fetchConfigDataSql"
:default-actions="false"
@on-data-fetched="$event => formatMailData($event)"
>
<template #form="{ data }">
<QCheckbox
v-model="data.isToBeMailed"
:label="t('isToBeMailed')"
:toggle-indeterminate="false"
@update:model-value="vnFormRef2.submit()"
dense
/>
</template>
</VnForm>
</template>
</VnForm>
</FormModel>
</QPage>
<QDialog
ref="changePasswordFormDialog"

View File

@ -5,32 +5,26 @@ import { useRouter, useRoute } from 'vue-router';
import VnInput from 'src/components/common/VnInput.vue';
import VnSelect from 'src/components/common/VnSelect.vue';
import VnForm from 'src/components/common/VnForm.vue';
import FormModel from 'src/components/common/FormModel.vue';
import { useUserStore } from 'stores/user';
import { useAppStore } from 'stores/app';
import { storeToRefs } from 'pinia';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const jApi = inject('jApi');
const api = inject('api');
const appStore = useAppStore();
const userStore = useUserStore();
const { isHeaderMounted } = storeToRefs(appStore);
const vnFormRef = ref(null);
const countriesOptions = ref([]);
const provincesOptions = ref([]);
const pks = { id: route.params.id };
const isEditMode = route.params.id !== '0';
const fetchAddressDataSql = {
query: `
SELECT a.id, a.street, a.nickname, a.city, a.postalCode, a.provinceFk, p.countryFk
FROM myAddress a
LEFT JOIN vn.province p ON p.id = a.provinceFk
WHERE a.id = #address
`,
params: { address: route.params.id }
};
const editAddressData = ref(null);
const showForm = ref(false);
watch(
() => vnFormRef?.value?.formData?.countryFk,
@ -40,23 +34,49 @@ watch(
const goBack = () => router.push({ name: 'addressesList' });
const getCountries = async () => {
countriesOptions.value = await jApi.query(
`SELECT id, name FROM vn.country
ORDER BY name`
);
const filter = { fields: ['id', 'name'], order: 'name' };
const { data } = await api.get('Countries', {
params: { filter: JSON.stringify(filter) }
});
countriesOptions.value = data;
};
const getProvinces = async countryFk => {
if (!countryFk) return;
provincesOptions.value = await jApi.query(
`SELECT id, name FROM vn.province
WHERE countryFk = #id
ORDER BY name`,
{ id: countryFk }
);
const filter = {
where: { countryFk },
fields: ['id', 'name'],
order: 'name'
};
const { data } = await api.get('Provinces', {
params: { filter: JSON.stringify(filter) }
});
provincesOptions.value = data;
};
onMounted(() => getCountries());
const getAddressDetails = async () => {
const { data } = await api.get(`Addresses/${route.params.id}`);
if (!data) return;
const { nickname, street, city, postalCode, province, provinceFk } = data;
editAddressData.value = {
nickname,
street,
city,
postalCode,
countryFk: province?.countryFk,
provinceFk
};
};
onMounted(async () => {
if (isEditMode) {
await getAddressDetails();
}
getCountries();
showForm.value = true;
});
</script>
<template>
@ -74,42 +94,49 @@ onMounted(() => getCountries());
</QTooltip>
</QBtn>
</Teleport>
<VnForm
<FormModel
v-if="showForm"
ref="vnFormRef"
:fetch-form-data-sql="fetchAddressDataSql"
:columns-to-ignore-update="['countryFk']"
:create-model-default="{
field: 'clientFk',
value: 'account.myUser_getId()'
}"
:pks="pks"
:is-edit-mode="isEditMode"
:url-create="
!isEditMode
? `Clients/${userStore?.user?.id}/createAddress`
: ''
"
:url-update="
isEditMode
? `Clients/${userStore?.user?.id}/updateAddress/${route?.params?.id}`
: ''
"
:form-initial-data="editAddressData"
:title="t(isEditMode ? 'editAddress' : 'addAddress')"
table="myAddress"
schema="hedera"
@on-data-saved="goBack()"
:show-bottom-actions="false"
>
<template #form="{ data }">
<VnInput
v-model="data.nickname"
:label="t('name')"
data-cy="addressFormNickname"
required
/>
<VnInput
v-model="data.street"
:label="t('address')"
data-cy="addressFormStreet"
required
/>
<VnInput
v-model="data.city"
:label="t('city')"
data-cy="addressFormCity"
required
/>
<VnInput
v-model="data.postalCode"
type="number"
:label="t('postalCode')"
data-cy="addressFormPostcode"
required
/>
<VnSelect
v-model="data.countryFk"
@ -117,15 +144,17 @@ onMounted(() => getCountries());
:options="countriesOptions"
@update:model-value="data.provinceFk = null"
data-cy="addressFormCountry"
required
/>
<VnSelect
v-model="data.provinceFk"
:label="t('province')"
:options="provincesOptions"
data-cy="addressFormProvince"
required
/>
</template>
</VnForm>
</FormModel>
</QPage>
</template>

View File

@ -5,19 +5,24 @@ import { useRouter } from 'vue-router';
import CardList from 'src/components/ui/CardList.vue';
import VnList from 'src/components/ui/VnList.vue';
import FetchData from 'src/components/common/FetchData.vue';
import useNotify from 'src/composables/useNotify.js';
import { useVnConfirm } from 'src/composables/useVnConfirm.js';
import { useAppStore } from 'stores/app';
import { storeToRefs } from 'pinia';
import { useUserStore } from 'stores/user';
const router = useRouter();
const jApi = inject('jApi');
const api = inject('api');
const { notify } = useNotify();
const { t } = useI18n();
const { openConfirmationModal } = useVnConfirm();
const appStore = useAppStore();
const userStore = useUserStore();
const { isHeaderMounted } = storeToRefs(appStore);
const fetchAddressesRef = ref(null);
const addresses = ref([]);
const defaultAddress = ref(null);
@ -38,19 +43,6 @@ const getDefaultAddress = async () => {
}
};
const getActiveAddresses = async () => {
try {
addresses.value = await jApi.query(
`SELECT a.id, a.nickname, p.name province, a.postalCode, a.city, a.street, a.isActive
FROM myAddress a
LEFT JOIN vn.province p ON p.id = a.provinceFk
WHERE a.isActive`
);
} catch (error) {
console.error('Error getting active addresses:', error);
}
};
const changeDefaultAddress = async () => {
if (!clientId.value) return;
await jApi.execQuery(
@ -65,32 +57,46 @@ const changeDefaultAddress = async () => {
notify(t('defaultAddressModified'), 'positive');
};
const removeAddress = async id => {
async function removeAddress(address) {
try {
await jApi.execQuery(
`START TRANSACTION;
UPDATE hedera.myAddress SET isActive = FALSE
WHERE ((id = #id));
SELECT isActive FROM hedera.myAddress WHERE ((id = #id));
COMMIT`,
await api.patch(
`/Clients/${userStore?.user?.id}/updateAddress/${address.id}`,
{
id
...address,
isActive: false
}
);
getActiveAddresses();
fetchAddressesRef.value.fetch();
notify(t('dataSaved'), 'positive');
} catch (error) {
console.error('Error removing address:', error);
}
};
}
onMounted(async () => {
getDefaultAddress();
getActiveAddresses();
});
</script>
<template>
<FetchData
v-if="userStore?.user?.id"
ref="fetchAddressesRef"
url="Addresses"
:filter="{
where: { clientFk: userStore.user.id, isActive: true },
fields: [
'id',
'nickname',
'postalCode',
'city',
'street',
'isActive'
]
}"
auto-load
@on-fetch="data => (addresses = data)"
/>
<Teleport v-if="isHeaderMounted" to="#actions">
<QBtn
:label="t('addAddress')"
@ -145,7 +151,7 @@ onMounted(async () => {
openConfirmationModal(
null,
t('confirmDeleteAddress'),
() => removeAddress(address.id)
() => removeAddress(address)
)
"
>

View File

@ -3,28 +3,46 @@ import { ref } from 'vue';
import CardList from 'src/components/ui/CardList.vue';
import VnImg from 'src/components/ui/VnImg.vue';
import VnSearchBar from 'src/components/ui/VnSearchBar.vue';
import VnList from 'src/components/ui/VnList.vue';
import VnSearchBar from 'src/components/ui/NewVnSearchBar.vue';
import { useAppStore } from 'stores/app';
import { storeToRefs } from 'pinia';
const appStore = useAppStore();
const { isHeaderMounted } = storeToRefs(appStore);
const loading = ref(false);
const items = ref([]);
const query = `SELECT i.id, i.longName, i.size, i.category,
i.value5, i.value6, i.value7,
i.image, im.updated
FROM vn.item i
LEFT JOIN image im
ON im.collectionFk = 'catalog'
AND im.name = i.image
WHERE (i.longName LIKE CONCAT('%', #search, '%')
OR i.id = #search) AND i.isActive
ORDER BY i.longName LIMIT 50`;
const itemFilterProps = {
include: [
{
relation: 'itemType',
scope: {
fields: ['id', 'name']
}
},
{
relation: 'intrastat',
scope: {
fields: ['id', 'description']
}
},
{
relation: 'origin',
scope: {
fields: ['id', 'name']
}
},
{
relation: 'production',
scope: {
fields: ['id', 'name']
}
}
],
isActive: true,
order: 'name ASC'
};
const onSearch = data => (items.value = data || []);
</script>
@ -32,9 +50,10 @@ const onSearch = data => (items.value = data || []);
<template>
<Teleport v-if="isHeaderMounted" to="#actions">
<VnSearchBar
:sql-query="query"
url="Items/filter"
@on-search="onSearch"
@on-search-error="items = []"
:filter="itemFilterProps"
/>
</Teleport>
<QPage class="vn-w-xs">
@ -68,7 +87,7 @@ const onSearch = data => (items.value = data || []);
</template>
<template #content>
<span class="text-bold q-mb-sm">
{{ item.longName }}
{{ item.name }}
</span>
<span>
{{ item.value5 }} {{ item.value6 }}

View File

@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import CardList from 'src/components/ui/CardList.vue';
import VnSearchBar from 'src/components/ui/VnSearchBar.vue';
import VnSearchBar from 'src/components/ui/NewVnSearchBar.vue';
import VnList from 'src/components/ui/VnList.vue';
import { useAppStore } from 'stores/app';
@ -16,16 +16,11 @@ const router = useRouter();
const userStore = useUserStore();
const appStore = useAppStore();
const { isHeaderMounted } = storeToRefs(appStore);
const loading = ref(false);
const users = ref([]);
const query = `SELECT u.id, u.name, u.nickname, u.active
FROM account.user u
WHERE u.name LIKE CONCAT('%', #user, '%')
OR u.nickname LIKE CONCAT('%', #user, '%')
OR u.id = #user
ORDER BY u.name LIMIT 200`;
const filter = {
fields: ['id', 'name', 'nickname', 'active']
};
const onSearch = data => (users.value = data || []);
@ -38,15 +33,35 @@ const supplantUser = async user => {
console.error('Error supplanting user:', error);
}
};
const usersExprBuilder = (param, value) => {
switch (param) {
case 'search':
return /^\d+$/.test(value)
? { id: value }
: {
or: [
{ name: { like: `%${value}%` } },
{ nickname: { like: `%${value}%` } }
]
};
case 'name':
case 'nickname':
return { [param]: { like: `%${value}%` } };
case 'roleFk':
return { [param]: value };
}
};
</script>
<template>
<Teleport v-if="isHeaderMounted" to="#actions">
<VnSearchBar
:sql-query="query"
search-field="user"
url="/VnUsers/preview"
@on-search="onSearch"
@on-search-error="users = []"
:expr-builder="usersExprBuilder"
:filter="filter"
data-cy="usersViewSearchBar"
/>
</Teleport>

View File

@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
import VnTable from 'src/components/ui/VnTable.vue';
const jApi = inject('jApi');
const api = inject('api');
const { t } = useI18n();
const packages = ref([]);
@ -43,7 +43,12 @@ const columns = computed(() => [
const getPackages = async () => {
try {
const data = await jApi.query('CALL vn.agencyVolume()');
const { data } = await api.post(
'applications/agencyVolume/execute-proc',
{
schema: 'vn'
}
);
packages.value = data;
} catch (error) {
console.error(error);

View File

@ -1,16 +1,15 @@
<script setup>
import { ref, onMounted, inject } from 'vue';
const jApi = inject('jApi');
const api = inject('api');
const news = ref([]);
const showPreview = ref(false);
const selectedImageSrc = ref('');
const fetchData = async () => {
news.value = await jApi.query(
`SELECT title, text, image, id
FROM news
ORDER BY priority, created DESC`
);
const newsResponse = await api.get('News');
news.value = newsResponse.data;
};
const showImagePreview = src => {

View File

@ -1,5 +1,5 @@
<script setup>
import { ref, inject, onMounted } from 'vue';
import { ref, inject } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
@ -11,8 +11,10 @@ import { useVnConfirm } from 'src/composables/useVnConfirm.js';
import useNotify from 'src/composables/useNotify.js';
import { useAppStore } from 'stores/app';
import { storeToRefs } from 'pinia';
import { onUserId } from 'src/utils/onUserId';
const jApi = inject('jApi');
const api = inject('api');
const { t } = useI18n();
const { openConfirmationModal } = useVnConfirm();
const { notify } = useNotify();
@ -23,18 +25,48 @@ const router = useRouter();
const loading = ref(false);
const orders = ref([]);
const getOrders = async () => {
const getOrders = async (clientFk) => {
try {
loading.value = true;
orders.value = await jApi.query(
`SELECT o.id, o.sent, o.deliveryMethodFk, o.taxableBase,
a.nickname, am.description agency
FROM myOrder o
JOIN myAddress a ON a.id = o.addressFk
JOIN vn.agencyMode am ON am.id = o.agencyModeFk
WHERE NOT o.isConfirmed
ORDER BY o.sent DESC`
);
const filter = {
where: {
clientFk,
isConfirmed: false,
source_app: 'WEB',
},
include: [
{
relation: 'address',
scope: {
fields: ['nickname', 'city'],
}
},
{
relation: 'agencyMode',
scope: {
fields: ['description'],
}
},
],
fields: [
'id',
'landed',
'delivery_method_id',
'taxableBase',
'addressFk',
'agencyModeFk'
]
};
const { data: salixOrders } = await api.get('Orders', {
params: {
filter: JSON.stringify(filter)
}
});
orders.value = salixOrders;
loading.value = false;
} catch (error) {
console.error('Error getting orders:', error);
@ -43,14 +75,7 @@ const getOrders = async () => {
const removeOrder = async (id, index) => {
try {
await jApi.execQuery(
`START TRANSACTION;
DELETE FROM hedera.myOrder WHERE ((id = #id));
COMMIT`,
{
id
}
);
await api.delete(`Orders/${id}`);
orders.value.splice(index, 1);
notify(t('dataSaved'), 'positive');
} catch (error) {
@ -63,9 +88,8 @@ const loadOrder = orderId => {
router.push({ name: 'catalog' });
};
onMounted(async () => {
getOrders();
});
onUserId(getOrders);
</script>
<template>
@ -93,11 +117,11 @@ onMounted(async () => {
>
<template #content>
<QItemLabel class="text-bold q-mb-sm">
{{ formatDateTitle(order.sent) }}
{{ formatDateTitle(order.landed) }}
</QItemLabel>
<QItemLabel> #{{ order.id }} </QItemLabel>
<QItemLabel>{{ order.nickname }}</QItemLabel>
<QItemLabel>{{ order.agency }}</QItemLabel>
<QItemLabel>{{ order.address.nickname }}</QItemLabel>
<QItemLabel>{{ order.agencyMode.description }}</QItemLabel>
<QItemLabel>{{ currency(order.taxableBase) }}</QItemLabel>
</template>
<template #actions>

View File

@ -32,7 +32,10 @@ export const useAppStore = defineStore('hedera', {
}),
actions: {
async getMenuLinks() {
const sections = await jApi.query('SELECT * FROM myMenu');
const { data: sections } = await api.get('MyMenus');
if (!sections) return;
const sectionMap = new Map();
for (const section of sections) {
sectionMap.set(section.id, section);

View File

@ -38,8 +38,8 @@ export const useUserStore = defineStore('user', () => {
router.push({ name: 'login' });
}
} else {
await fetchTokenConfig();
await fetchUser();
await fetchTokenConfig();
await supplantInit();
startInterval();
}
@ -248,11 +248,10 @@ export const useUserStore = defineStore('user', () => {
const fetchUser = async (userType = 'user') => {
try {
const userData = await jApi.getObject(
'SELECT id, nickname, name, lang FROM account.myUser'
);
if (userType === 'user') mainUser.value = userData;
else supplantedUser.value = userData;
const userData = await api.get('VnUsers/getCurrentUserData');
if (userType === 'user') mainUser.value = userData.data;
else supplantedUser.value = userData.data;
} catch (error) {
console.error('Error fetching user: ', error);
}

View File

@ -46,15 +46,28 @@ describe('PendingOrders', () => {
.should('contain', data.postcode);
};
it('should fail if we enter a wrong postcode', () => {
cy.dataCy('newAddressBtn').should('exist');
cy.dataCy('newAddressBtn').click();
cy.dataCy('formModelDefaultSaveButton').should('exist');
cy.dataCy('formModelDefaultSaveButton').should('be.disabled');
const addressFormData = getRandomAddressFormData();
fillFormWithData(addressFormData);
cy.dataCy('formModelDefaultSaveButton').should('not.be.disabled');
cy.dataCy('formModelDefaultSaveButton').click();
cy.checkNotify('negative');
});
it('should create a new address', () => {
cy.dataCy('newAddressBtn').should('exist');
cy.dataCy('newAddressBtn').click();
cy.dataCy('formDefaultSaveButton').should('exist');
cy.dataCy('formDefaultSaveButton').should('be.disabled');
cy.dataCy('formModelDefaultSaveButton').should('exist');
cy.dataCy('formModelDefaultSaveButton').should('be.disabled');
const addressFormData = getRandomAddressFormData();
addressFormData.postcode = '46460'; // Usamos un postcode válido
fillFormWithData(addressFormData);
cy.dataCy('formDefaultSaveButton').should('not.be.disabled');
cy.dataCy('formDefaultSaveButton').click();
cy.dataCy('formModelDefaultSaveButton').should('not.be.disabled');
cy.dataCy('formModelDefaultSaveButton').click();
cy.checkNotify('positive', 'Datos guardados');
verifyAddressCardData(addressFormData);
});
@ -71,9 +84,10 @@ describe('PendingOrders', () => {
});
// Fill form with new data
const addressFormData = getRandomAddressFormData();
addressFormData.postcode = '46460'; // Usamos un postcode válido
fillFormWithData(addressFormData);
cy.dataCy('formDefaultSaveButton').should('not.be.disabled');
cy.dataCy('formDefaultSaveButton').click();
cy.dataCy('formModelDefaultSaveButton').should('not.be.disabled');
cy.dataCy('formModelDefaultSaveButton').click();
cy.checkNotify('positive', 'Datos guardados');
verifyAddressCardData(addressFormData);
});

View File

@ -90,5 +90,6 @@ Cypress.Commands.add('setConfirmDialog', () => {
});
Cypress.Commands.add('checkNotify', (status, content) => {
cy.dataCy(`${status}Notify`).should('contain', content);
if (content) cy.dataCy(`${status}Notify`).should('contain', content);
else cy.dataCy(`${status}Notify`).should('exist');
});

View File

@ -7,12 +7,11 @@ const userStore = useUserStore();
export const onUserId = (cb) => watch(
() => userStore?.user?.id,
async userId => {
if (userId) {
try {
await cb(userId);
} catch (error) {
console.error(error);
}
if (!userId) return;
try {
await cb(userId);
} catch (error) {
console.error(error);
}
},
{ immediate: true }