Merge branch 'beta' into feature/admin-users

This commit is contained in:
Javier Segarra 2025-03-23 22:30:43 +00:00
commit 1189f85e9b
22 changed files with 687 additions and 240 deletions

View File

@ -59,7 +59,7 @@
"salix": "cd ../salix && gulp back", "salix": "cd ../salix && gulp back",
"db": "cd ../salix && gulp docker", "db": "cd ../salix && gulp docker",
"cy:open": "npm run db && cypress open", "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", "test:unit": "vitest",
"build": "rm -rf dist/ ; quasar build", "build": "rm -rf dist/ ; quasar build",
"clean": "rm -rf dist/", "clean": "rm -rf dist/",

View File

@ -23,7 +23,7 @@ module.exports = configure(function (ctx) {
// app boot file (/src/boot) // app boot file (/src/boot)
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-webpack/boot-files // 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 // https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-css
css: ['app.scss', 'width.scss', 'responsive.scss'], 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> <script setup>
import { computed, ref } from 'vue'; import { computed, ref, useAttrs, nextTick } from 'vue';
import { useI18n } from 'vue-i18n'; 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([ const emit = defineEmits([
'update:modelValue', 'update:modelValue',
'update:options', 'update:options',
'keyup.enter', 'keyup.enter',
'remove' 'remove',
'blur'
]); ]);
const props = defineProps({ const $props = defineProps({
modelValue: { modelValue: {
type: [String, Number], type: [String, Number],
default: null default: null
@ -25,24 +30,43 @@ const props = defineProps({
clearable: { clearable: {
type: Boolean, type: Boolean,
default: true 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 vnInputRef = ref(null);
const value = computed({ const value = computed({
get() { get() {
return props.modelValue; return $props.modelValue;
}, },
set(value) { set(value) {
if ($props.emptyToNull && value === '') value = null;
emit('update:modelValue', value); emit('update:modelValue', value);
} }
}); });
const hover = ref(false); const hover = ref(false);
const styleAttrs = computed(() => { const styleAttrs = computed(() => {
return props.isOutlined return $props.isOutlined
? { dense: true, outlined: true, rounded: true } ? {
dense: true,
outlined: true,
rounded: true
}
: {}; : {};
}); });
@ -51,66 +75,119 @@ const focus = () => {
}; };
defineExpose({ defineExpose({
focus focus,
vnInputRef
}); });
const inputRules = [ const mixinRules = [
requiredFieldRule,
...($attrs.rules ?? []),
val => { val => {
const { maxlength } = vnInputRef.value;
if (maxlength && +val.length > maxlength)
return t(`maxLength`, { value: maxlength });
const { min, max } = vnInputRef.value.$attrs; 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 (Math.floor(val) < min) return t('inputMin', { value: min });
} if (!max) return null;
if (max > 0) { if (max > 0) {
if (Math.floor(val) > max) return t('inputMax', { value: max }); 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> </script>
<template> <template>
<div <div @mouseover="hover = true" @mouseleave="hover = false">
:rules="$attrs.required ? [requiredFieldRule] : null"
@mouseover="hover = true"
@mouseleave="hover = false"
>
<QInput <QInput
ref="vnInputRef" ref="vnInputRef"
v-model="value" v-model="value"
v-bind="{ ...$attrs, ...styleAttrs }" v-bind="{ ...$attrs, ...styleAttrs }"
:type="$attrs.type" :type="$attrs.type"
:class="{ required: $attrs.required }" :class="{ required: isRequired }"
@keyup.enter="emit('keyup.enter')"
@blur="emit('blur')"
@keydown="handleKeydown"
:clearable="false" :clearable="false"
:rules="inputRules" :rules="mixinRules"
:lazy-rules="true" :lazy-rules="true"
hide-bottom-space hide-bottom-space
@keyup.enter="emit('keyup.enter')" :data-cy="$attrs['data-cy'] ?? $attrs.label + '_input'"
> >
<template <template #prepend v-if="$slots.prepend">
v-if="$slots.prepend"
#prepend
>
<slot name="prepend" /> <slot name="prepend" />
</template> </template>
<template #append> <template #append>
<slot
v-if="$slots.append && !$attrs.disabled"
name="append"
/>
<QIcon <QIcon
v-if="hover && value && !$attrs.disabled && props.clearable"
name="close" name="close"
size="xs" size="xs"
:style="{
visibility:
hover &&
value &&
!$attrs.disabled &&
!$attrs.readonly &&
$props.clearable
? 'visible'
: 'hidden'
}"
@click=" @click="
() => { () => {
value = null; value = null;
vnInputRef.focus();
emit('remove'); emit('remove');
} }
" "
/> ></QIcon>
<QIcon <QIcon
v-if="info" name="match_case"
name="info" 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"> <QTooltip max-width="350px">
{{ info }} {{ info }}
</QTooltip> </QTooltip>
@ -120,20 +197,44 @@ const inputRules = [
</div> </div>
</template> </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"> <i18n lang="yaml">
en-US: en-US:
inputMin: Must be more than {value} inputMin: Must be more than {value}
maxLength: The value exceeds {value} characters
inputMax: Must be less than {value} inputMax: Must be less than {value}
Convert to uppercase: Convert to uppercase
es-ES: es-ES:
inputMin: Debe ser mayor a {value} inputMin: Debe ser mayor a {value}
maxLength: El valor excede los {value} carácteres
inputMax: Debe ser menor a {value} inputMax: Debe ser menor a {value}
Convert to uppercase: Convertir a mayúsculas
ca-ES: ca-ES:
inputMin: Ha de ser més gran que {value} 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: fr-FR:
inputMin: Doit être supérieur à {value} 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: pt-PT:
inputMin: Deve ser maior que {value} 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> </i18n>

View File

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

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', introduceSearchTerm: 'Introdueix un terme de cerca',
noOrdersFound: `No s'han trobat comandes`, noOrdersFound: `No s'han trobat comandes`,
send: 'Enviar', send: 'Enviar',
fieldRequired: 'Aquest camp és obligatori',
noChanges: 'No shan fet canvis',
// Image related translations // Image related translations
'Cant lock cache': 'No es pot bloquejar la memòria cau', 'Cant lock cache': 'No es pot bloquejar la memòria cau',
'Bad file format': 'Format de fitxer no reconegut', 'Bad file format': 'Format de fitxer no reconegut',

View File

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

View File

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

View File

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

View File

@ -133,6 +133,8 @@ export default {
introduceSearchTerm: 'Digite um termo de pesquisa', introduceSearchTerm: 'Digite um termo de pesquisa',
noOrdersFound: 'Nenhum pedido encontrado', noOrdersFound: 'Nenhum pedido encontrado',
send: 'Enviar', send: 'Enviar',
fieldRequired: 'Campo obrigatório',
noChanges: 'Sem alterações',
// Image related translations // Image related translations
'Cant lock cache': 'O cache não pôde ser bloqueado', 'Cant lock cache': 'O cache não pôde ser bloqueado',
'Bad file format': 'Formato de arquivo inválido', '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 { useI18n } from 'vue-i18n';
import VnInput from 'src/components/common/VnInput.vue'; import VnInput from 'src/components/common/VnInput.vue';
import VnSelect from 'src/components/common/VnSelect.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 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 { useUserStore } from 'stores/user';
import { useAppStore } from 'stores/app'; import { useAppStore } from 'stores/app';
@ -12,54 +13,76 @@ import { storeToRefs } from 'pinia';
const userStore = useUserStore(); const userStore = useUserStore();
const { t } = useI18n(); const { t } = useI18n();
const jApi = inject('jApi'); const api = inject('api');
const appStore = useAppStore(); const appStore = useAppStore();
const { isHeaderMounted } = storeToRefs(appStore); const { isHeaderMounted } = storeToRefs(appStore);
const { user } = storeToRefs(userStore); const { user } = storeToRefs(userStore);
const { notify } = useNotify();
const vnFormRef = ref(null); const vnFormRef = ref(null);
const vnFormRef2 = ref(null);
const changePasswordFormDialog = ref(null); const changePasswordFormDialog = ref(null);
const showChangePasswordForm = ref(false); const showChangePasswordForm = ref(false);
const langOptions = ref([]); const langOptions = ref([]);
const pks = computed(() => ({ id: userStore?.user?.id })); const formInitialData = ref({});
const fetchConfigDataSql = { const showForm = ref(false);
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 fetchLanguagesSql = async () => { const fetchLanguages = async () => {
try { try {
const data = await jApi.query( const filter = { fields: ['code', 'name'], where: { isActive: true } };
'SELECT code, name FROM language WHERE isActive' const { data } = await api.get('/languages', {
); params: { filter: JSON.stringify(filter) }
});
langOptions.value = data; langOptions.value = data;
} catch (error) { } catch (error) {
console.error(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 => { const updateUserNickname = async nickname => {
try { try {
await vnFormRef.value.submit(); await submitAccountData(nickname);
await submitNickname(nickname);
user.value.nickname = nickname; user.value.nickname = nickname;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
}; };
const formatMailData = data => {
data.isToBeMailed = Boolean(data.isToBeMailed);
};
const updateConfigLang = async lang => { const updateConfigLang = async lang => {
try { try {
await vnFormRef.value.submit(); if (!lang) return;
await submitAccountData({ lang });
userStore.updateUserLang(lang); userStore.updateUserLang(lang);
const siteLocaleLang = appStore.localeOptions.find( const siteLocaleLang = appStore.localeOptions.find(
locale => locale.value === lang 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> </script>
<template> <template>
@ -92,14 +140,14 @@ onMounted(() => fetchLanguagesSql());
@click="showChangePasswordForm = true" @click="showChangePasswordForm = true"
/> />
</Teleport> </Teleport>
<VnForm <FormModel
v-if="showForm"
ref="vnFormRef" ref="vnFormRef"
:save-fn="submitFormFn"
:form-initial-data="formInitialData"
:title="t('personalInformation')" :title="t('personalInformation')"
:fetch-form-data-sql="fetchConfigDataSql" :show-bottom-actions="false"
:pks="pks" :defaultActions="false"
table="myUser"
schema="account"
:default-actions="false"
> >
<template #form="{ data }"> <template #form="{ data }">
<VnInput <VnInput
@ -111,14 +159,16 @@ onMounted(() => fetchLanguagesSql());
<VnInput <VnInput
v-model="data.email" v-model="data.email"
:label="t('email')" :label="t('email')"
@keyup.enter="vnFormRef.submit()" @keyup.enter="submitAccountData({ email: data.email })"
@blur="vnFormRef.submit()" @blur="submitAccountData({ email: data.email })"
/> />
<VnInput <VnInput
v-model="data.nickname" v-model="data.nickname"
:label="t('nickname')" :label="t('nickname')"
@keyup.enter="updateUserNickname(data.nickname)" @keyup.enter="
@blur="updateUserNickname(data.nickname)" updateUserNickname({ nickname: data.nickname })
"
@blur="updateUserNickname({ nickname: data.nickname })"
data-cy="configViewNickname" data-cy="configViewNickname"
/> />
<VnSelect <VnSelect
@ -130,30 +180,17 @@ onMounted(() => fetchLanguagesSql());
@update:model-value="updateConfigLang(data.lang)" @update:model-value="updateConfigLang(data.lang)"
data-cy="configViewLang" data-cy="configViewLang"
/> />
<QCheckbox
v-model="data.isToBeMailed"
:label="t('isToBeMailed')"
:toggle-indeterminate="false"
@update:model-value="
submitIsToBeMailed(data.isToBeMailed)
"
dense
/>
</template> </template>
<template #extraForm> </FormModel>
<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>
</QPage> </QPage>
<QDialog <QDialog
ref="changePasswordFormDialog" ref="changePasswordFormDialog"

View File

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

View File

@ -5,19 +5,24 @@ import { useRouter } from 'vue-router';
import CardList from 'src/components/ui/CardList.vue'; import CardList from 'src/components/ui/CardList.vue';
import VnList from 'src/components/ui/VnList.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 useNotify from 'src/composables/useNotify.js';
import { useVnConfirm } from 'src/composables/useVnConfirm.js'; import { useVnConfirm } from 'src/composables/useVnConfirm.js';
import { useAppStore } from 'stores/app'; import { useAppStore } from 'stores/app';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useUserStore } from 'stores/user';
const router = useRouter(); const router = useRouter();
const jApi = inject('jApi'); const jApi = inject('jApi');
const api = inject('api');
const { notify } = useNotify(); const { notify } = useNotify();
const { t } = useI18n(); const { t } = useI18n();
const { openConfirmationModal } = useVnConfirm(); const { openConfirmationModal } = useVnConfirm();
const appStore = useAppStore(); const appStore = useAppStore();
const userStore = useUserStore();
const { isHeaderMounted } = storeToRefs(appStore); const { isHeaderMounted } = storeToRefs(appStore);
const fetchAddressesRef = ref(null);
const addresses = ref([]); const addresses = ref([]);
const defaultAddress = ref(null); 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 () => { const changeDefaultAddress = async () => {
if (!clientId.value) return; if (!clientId.value) return;
await jApi.execQuery( await jApi.execQuery(
@ -65,32 +57,46 @@ const changeDefaultAddress = async () => {
notify(t('defaultAddressModified'), 'positive'); notify(t('defaultAddressModified'), 'positive');
}; };
const removeAddress = async id => { async function removeAddress(address) {
try { try {
await jApi.execQuery( await api.patch(
`START TRANSACTION; `/Clients/${userStore?.user?.id}/updateAddress/${address.id}`,
UPDATE hedera.myAddress SET isActive = FALSE
WHERE ((id = #id));
SELECT isActive FROM hedera.myAddress WHERE ((id = #id));
COMMIT`,
{ {
id ...address,
isActive: false
} }
); );
getActiveAddresses(); fetchAddressesRef.value.fetch();
notify(t('dataSaved'), 'positive'); notify(t('dataSaved'), 'positive');
} catch (error) { } catch (error) {
console.error('Error removing address:', error); console.error('Error removing address:', error);
} }
}; }
onMounted(async () => { onMounted(async () => {
getDefaultAddress(); getDefaultAddress();
getActiveAddresses();
}); });
</script> </script>
<template> <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"> <Teleport v-if="isHeaderMounted" to="#actions">
<QBtn <QBtn
:label="t('addAddress')" :label="t('addAddress')"
@ -145,7 +151,7 @@ onMounted(async () => {
openConfirmationModal( openConfirmationModal(
null, null,
t('confirmDeleteAddress'), t('confirmDeleteAddress'),
() => removeAddress(address.id) () => removeAddress(address)
) )
" "
> >

View File

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

View File

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

View File

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

View File

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

View File

@ -90,5 +90,6 @@ Cypress.Commands.add('setConfirmDialog', () => {
}); });
Cypress.Commands.add('checkNotify', (status, content) => { 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');
}); });