Merge pull request 'fix: refs #7967 show userErr & useNotify composable' (!1725) from 7939-showUserErr into dev
gitea/salix-front/pipeline/head This commit looks good Details

Reviewed-on: #1725
Reviewed-by: Alex Moreno <alexm@verdnatura.es>
This commit is contained in:
Jorge Penadés 2025-05-12 10:35:45 +00:00
commit 93e29fdde6
9 changed files with 220 additions and 446 deletions

View File

@ -7,7 +7,7 @@ import { QLayout } from 'quasar';
import mainShortcutMixin from './mainShortcutMixin';
import { useCau } from 'src/composables/useCau';
export default boot(({ app }) => {
export default boot(({ app, router }) => {
QForm.mixins = [qFormMixin];
QLayout.mixins = [mainShortcutMixin];
@ -22,6 +22,14 @@ export default boot(({ app }) => {
}
switch (response?.status) {
case 401:
if (!router.currentRoute.value.name.toLowerCase().includes('login')) {
message = 'errors.sessionExpired';
} else message = 'login.loginError';
break;
case 403:
if (!message || message.toLowerCase() === 'access denied')
message = 'errors.accessDenied';
case 422:
if (error.name == 'ValidationError')
message += ` "${responseError.details.context}.${Object.keys(

View File

@ -180,7 +180,11 @@ const col = computed(() => {
)
newColumn.component = 'checkbox';
if ($props.default && !newColumn.component) newColumn.component = $props.default;
if (typeof newColumn.component !== 'string') {
newColumn.attrs = { ...newColumn.component.attrs, autofocus: $props.autofocus };
newColumn.event = { ...newColumn.component.event, ...$props?.eventHandlers };
}
return newColumn;
});

View File

@ -0,0 +1,49 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccountShortToStandard } from 'src/composables/useAccountShortToStandard';
import VnSelectDialog from './VnSelectDialog.vue';
import CreateNewExpenseForm from '../CreateNewExpenseForm.vue';
import FetchData from '../FetchData.vue';
const model = defineModel({ type: [String, Number, Object] });
const { t } = useI18n();
const expenses = ref([]);
const selectDialogRef = useTemplateRef('selectDialogRef');
async function autocompleteExpense(evt) {
const val = evt.target.value;
if (!val || isNaN(val)) return;
const lookup = expenses.value.find(({ id }) => id == useAccountShortToStandard(val));
if (selectDialogRef.value)
selectDialogRef.value.vnSelectDialogRef.vnSelectRef.toggleOption(lookup);
}
</script>
<template>
<VnSelectDialog
v-bind="$attrs"
ref="selectDialogRef"
v-model="model"
:options="expenses"
option-value="id"
:option-label="(x) => `${x.id}: ${x.name}`"
:filter-options="['id', 'name']"
:tooltip="t('Create a new expense')"
:acls="[{ model: 'Expense', props: '*', accessType: 'WRITE' }]"
@keydown.tab.prevent="autocompleteExpense"
>
<template #form>
<CreateNewExpenseForm @on-data-saved="$refs.expensesRef.fetch()" />
</template>
</VnSelectDialog>
<FetchData
ref="expensesRef"
url="Expenses"
auto-load
@on-fetch="(data) => (expenses = data)"
/>
</template>
<i18n>
es:
Create a new expense: Crear nuevo gasto
</i18n>

View File

@ -400,6 +400,8 @@ errors:
updateUserConfig: Error updating user config
tokenConfig: Error fetching token config
writeRequest: The requested operation could not be completed
sessionExpired: Your session has expired. Please log in again
accessDenied: Access denied
claimBeginningQuantity: Cannot import a line with a claimed quantity of 0
login:
title: Login

View File

@ -396,6 +396,8 @@ errors:
updateUserConfig: Error al actualizar la configuración de usuario
tokenConfig: Error al obtener configuración de token
writeRequest: No se pudo completar la operación solicitada
sessionExpired: Tu sesión ha expirado, por favor vuelve a iniciar sesión
accessDenied: Acceso denegado
claimBeginningQuantity: No se puede importar una linea sin una cantidad reclamada
login:
title: Inicio de sesión

View File

@ -73,6 +73,7 @@ const columns = computed(() => [
optionLabel: 'code',
options: companies.value,
},
orderBy: false,
},
{
name: 'warehouse',

View File

@ -1,21 +1,16 @@
<script setup>
import { ref, computed, nextTick } from 'vue';
import { ref, computed, markRaw } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useArrayData } from 'src/composables/useArrayData';
import { getTotal } from 'src/composables/getTotal';
import { toCurrency } from 'src/filters';
import FetchData from 'src/components/FetchData.vue';
import VnSelect from 'src/components/common/VnSelect.vue';
import CrudModel from 'src/components/CrudModel.vue';
import VnInputNumber from 'src/components/common/VnInputNumber.vue';
import VnSelectDialog from 'src/components/common/VnSelectDialog.vue';
import CreateNewExpenseForm from 'src/components/CreateNewExpenseForm.vue';
import { getExchange } from 'src/composables/getExchange';
import { useAccountShortToStandard } from 'src/composables/useAccountShortToStandard';
import VnTable from 'src/components/VnTable/VnTable.vue';
import VnSelectExpense from 'src/components/common/VnSelectExpense.vue';
const { t } = useI18n();
const arrayData = useArrayData();
const route = useRoute();
const invoiceIn = computed(() => arrayData.store.data);
@ -24,100 +19,142 @@ const expenses = ref([]);
const sageTaxTypes = ref([]);
const sageTransactionTypes = ref([]);
const rowsSelected = ref([]);
const invoiceInFormRef = ref();
const invoiceInVatTableRef = ref();
defineProps({
actionIcon: {
type: String,
default: 'add',
},
});
defineProps({ actionIcon: { type: String, default: 'add' } });
function taxRate(invoiceInTax) {
const sageTaxTypeId = invoiceInTax.taxTypeSageFk;
const taxRateSelection = sageTaxTypes.value.find(
(transaction) => transaction.id == sageTaxTypeId,
);
const taxTypeSage = taxRateSelection?.rate ?? 0;
const taxableBase = invoiceInTax?.taxableBase ?? 0;
return (taxTypeSage / 100) * taxableBase;
}
const columns = computed(() => [
{
name: 'expense',
name: 'expenseFk',
label: t('Expense'),
field: (row) => row.expenseFk,
options: expenses.value,
model: 'expenseFk',
optionValue: 'id',
optionLabel: (row) => `${row.id}: ${row.name}`,
component: markRaw(VnSelectExpense),
format: (row) => {
const expense = expenses.value.find((e) => e.id === row.expenseFk);
return expense ? `${expense.id}: ${expense.name}` : row.expenseFk;
},
sortable: true,
align: 'left',
isEditable: true,
create: true,
width: '250px',
},
{
name: 'taxablebase',
name: 'taxableBase',
label: t('Taxable base'),
field: (row) => row.taxableBase,
model: 'taxableBase',
component: 'number',
attrs: {
clearable: true,
'clear-icon': 'close',
},
sortable: true,
align: 'left',
isEditable: true,
create: true,
},
{
name: 'isDeductible',
label: t('invoiceIn.isDeductible'),
field: (row) => row.isDeductible,
model: 'isDeductible',
component: 'checkbox',
align: 'center',
isEditable: true,
create: true,
createAttrs: {
defaultValue: true,
},
width: '100px',
},
{
name: 'sageiva',
name: 'taxTypeSageFk',
label: t('Sage iva'),
field: (row) => row.taxTypeSageFk,
options: sageTaxTypes.value,
model: 'taxTypeSageFk',
optionValue: 'id',
optionLabel: (row) => `${row.id}: ${row.vat}`,
component: 'select',
attrs: {
options: sageTaxTypes.value,
optionValue: 'id',
optionLabel: (row) => `${row.id}: ${row.vat}`,
filterOptions: ['id', 'vat'],
'data-cy': 'vat-sageiva',
},
format: (row) => {
const taxType = sageTaxTypes.value.find((t) => t.id === row.taxTypeSageFk);
return taxType ? `${taxType.id}: ${taxType.vat}` : row.taxTypeSageFk;
},
sortable: true,
align: 'left',
isEditable: true,
create: true,
},
{
name: 'sagetransaction',
name: 'transactionTypeSageFk',
label: t('Sage transaction'),
field: (row) => row.transactionTypeSageFk,
options: sageTransactionTypes.value,
model: 'transactionTypeSageFk',
optionValue: 'id',
optionLabel: (row) => `${row.id}: ${row.transaction}`,
component: 'select',
attrs: {
options: sageTransactionTypes.value,
optionValue: 'id',
optionLabel: (row) => `${row.id}: ${row.transaction}`,
filterOptions: ['id', 'transaction'],
},
format: (row) => {
const transType = sageTransactionTypes.value.find(
(t) => t.id === row.transactionTypeSageFk,
);
return transType
? `${transType.id}: ${transType.transaction}`
: row.transactionTypeSageFk;
},
sortable: true,
align: 'left',
isEditable: true,
create: true,
},
{
name: 'rate',
label: t('Rate'),
sortable: true,
field: (row) => taxRate(row, row.taxTypeSageFk),
sortable: false,
format: (row) => taxRate(row).toFixed(2),
align: 'left',
},
{
name: 'foreignvalue',
name: 'foreignValue',
label: t('Foreign value'),
component: 'number',
sortable: true,
field: (row) => row.foreignValue,
align: 'left',
create: true,
disable: !isNotEuro(currency.value),
},
{
name: 'total',
label: 'Total',
label: t('Total'),
align: 'left',
format: (row) => (Number(row.taxableBase || 0) + Number(taxRate(row))).toFixed(2),
},
]);
const tableRows = computed(
() => invoiceInVatTableRef.value?.CrudModelRef?.formData || [],
);
const taxableBaseTotal = computed(() => {
return getTotal(invoiceInFormRef.value.formData, 'taxableBase');
return getTotal(tableRows.value, 'taxableBase');
});
const taxRateTotal = computed(() => {
return getTotal(invoiceInFormRef.value.formData, null, {
cb: taxRate,
});
return tableRows.value.reduce((sum, row) => sum + Number(taxRate(row)), 0);
});
const combinedTotal = computed(() => {
return +taxableBaseTotal.value + +taxRateTotal.value;
});
const filter = {
const filter = computed(() => ({
fields: [
'id',
'invoiceInFk',
@ -131,389 +168,75 @@ const filter = {
where: {
invoiceInFk: route.params.id,
},
};
}));
const isNotEuro = (code) => code != 'EUR';
function taxRate(invoiceInTax) {
const sageTaxTypeId = invoiceInTax.taxTypeSageFk;
const taxRateSelection = sageTaxTypes.value.find(
(transaction) => transaction.id == sageTaxTypeId,
async function handleForeignValueUpdate(val, row) {
if (!isNotEuro(currency.value)) return;
row.taxableBase = await getExchange(
val,
invoiceIn.value?.currencyFk,
invoiceIn.value?.issued,
);
const taxTypeSage = taxRateSelection?.rate ?? 0;
const taxableBase = invoiceInTax?.taxableBase ?? 0;
return ((taxTypeSage / 100) * taxableBase).toFixed(2);
}
function autocompleteExpense(evt, row, col, ref) {
const val = evt.target.value;
if (!val) return;
const param = isNaN(val) ? row[col.model] : val;
const lookup = expenses.value.find(
({ id }) => id == useAccountShortToStandard(param),
);
ref.vnSelectDialogRef.vnSelectRef.toggleOption(lookup);
}
function setCursor(ref) {
nextTick(() => {
const select = ref.vnSelectDialogRef
? ref.vnSelectDialogRef.vnSelectRef
: ref.vnSelectRef;
select.$el.querySelector('input').setSelectionRange(0, 0);
});
}
</script>
<template>
<FetchData
ref="expensesRef"
url="Expenses"
auto-load
@on-fetch="(data) => (expenses = data)"
/>
<FetchData url="Expenses" auto-load @on-fetch="(data) => (expenses = data)" />
<FetchData url="SageTaxTypes" auto-load @on-fetch="(data) => (sageTaxTypes = data)" />
<FetchData
url="sageTransactionTypes"
auto-load
@on-fetch="(data) => (sageTransactionTypes = data)"
/>
<CrudModel
ref="invoiceInFormRef"
<VnTable
v-if="invoiceIn"
ref="invoiceInVatTableRef"
data-key="InvoiceInTaxes"
url="InvoiceInTaxes"
save-url="InvoiceInTaxes/crud"
:filter="filter"
:data-required="{ invoiceInFk: $route.params.id }"
:insert-on-load="true"
auto-load
v-model:selected="rowsSelected"
:go-to="`/invoice-in/${$route.params.id}/due-day`"
:columns="columns"
:is-editable="true"
:table="{ selection: 'multiple', 'row-key': '$index' }"
footer
:right-search="false"
:column-search="false"
:disable-option="{ card: true }"
class="q-pa-none"
:create="{
urlCreate: 'InvoiceInTaxes',
title: t('Add tax'),
formInitialData: { invoiceInFk: $route.params.id, isDeductible: true },
onDataSaved: () => invoiceInVatTableRef.reload(),
}"
:crud-model="{ goTo: `/invoice-in/${$route.params.id}/due-day` }"
>
<template #body="{ rows }">
<QTable
v-model:selected="rowsSelected"
selection="multiple"
:columns="columns"
:rows="rows"
row-key="$index"
:grid="$q.screen.lt.sm"
>
<template #body-cell-expense="{ row, col }">
<QTd>
<VnSelectDialog
:ref="`expenseRef-${row.$index}`"
v-model="row[col.model]"
:options="col.options"
:option-value="col.optionValue"
:option-label="col.optionLabel"
:filter-options="['id', 'name']"
:tooltip="t('Create a new expense')"
:acls="[
{ model: 'Expense', props: '*', accessType: 'WRITE' },
]"
@keydown.tab.prevent="
autocompleteExpense(
$event,
row,
col,
$refs[`expenseRef-${row.$index}`],
)
"
@update:model-value="
setCursor($refs[`expenseRef-${row.$index}`])
"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
{{ `${scope.opt.id}: ${scope.opt.name}` }}
</QItem>
</template>
<template #form>
<CreateNewExpenseForm
@on-data-saved="$refs.expensesRef.fetch()"
/>
</template>
</VnSelectDialog>
</QTd>
</template>
<template #body-cell-isDeductible="{ row }">
<QTd align="center">
<QCheckbox
v-model="row.isDeductible"
data-cy="isDeductible_checkbox"
/>
</QTd>
</template>
<template #body-cell-taxablebase="{ row }">
<QTd shrink>
<VnInputNumber
clear-icon="close"
v-model="row.taxableBase"
clearable
/>
</QTd>
</template>
<template #body-cell-sageiva="{ row, col }">
<QTd>
<VnSelect
:ref="`sageivaRef-${row.$index}`"
v-model="row[col.model]"
:options="col.options"
:option-value="col.optionValue"
:option-label="col.optionLabel"
:filter-options="['id', 'vat']"
data-cy="vat-sageiva"
@update:model-value="
setCursor($refs[`sageivaRef-${row.$index}`])
"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{ scope.opt.vat }}</QItemLabel>
<QItemLabel>
{{ `#${scope.opt.id}` }}
</QItemLabel>
</QItemSection>
</QItem>
</template>
</VnSelect>
</QTd>
</template>
<template #body-cell-sagetransaction="{ row, col }">
<QTd>
<VnSelect
:ref="`sagetransactionRef-${row.$index}`"
v-model="row[col.model]"
:options="col.options"
:option-value="col.optionValue"
:option-label="col.optionLabel"
:filter-options="['id', 'transaction']"
@update:model-value="
setCursor($refs[`sagetransactionRef-${row.$index}`])
"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{
scope.opt.transaction
}}</QItemLabel>
<QItemLabel>
{{ `#${scope.opt.id}` }}
</QItemLabel>
</QItemSection>
</QItem>
</template>
</VnSelect>
</QTd>
</template>
<template #body-cell-foreignvalue="{ row }">
<QTd shrink>
<VnInputNumber
:class="{
'no-pointer-events': !isNotEuro(currency),
}"
:disable="!isNotEuro(currency)"
v-model="row.foreignValue"
@update:model-value="
async (val) => {
if (!isNotEuro(currency)) return;
row.taxableBase = await getExchange(
val,
row.currencyFk,
invoiceIn.issued,
);
}
"
/>
</QTd>
</template>
<template #bottom-row>
<QTr class="bg">
<QTd />
<QTd />
<QTd>
{{ toCurrency(taxableBaseTotal) }}
</QTd>
<QTd />
<QTd />
<QTd />
<QTd>
{{ toCurrency(taxRateTotal) }}
</QTd>
<QTd />
<QTd>
{{ toCurrency(combinedTotal) }}
</QTd>
</QTr>
</template>
<template #item="props">
<div class="q-pa-xs col-xs-12 col-sm-6 grid-style-transition">
<QCard bordered flat class="q-my-xs">
<QCardSection>
<QCheckbox v-model="props.selected" dense />
</QCardSection>
<QSeparator />
<QList>
<QItem>
<VnSelectDialog
:label="t('Expense')"
class="full-width"
v-model="props.row['expenseFk']"
:options="expenses"
option-value="id"
:option-label="(row) => `${row.id}:${row.name}`"
:filter-options="['id', 'name']"
:tooltip="t('Create a new expense')"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
{{ `${scope.opt.id}: ${scope.opt.name}` }}
</QItem>
</template>
<template #form>
<CreateNewExpenseForm />
</template>
</VnSelectDialog>
</QItem>
<QItem>
<VnInputNumber
:label="t('Taxable base')"
:class="{
'no-pointer-events': isNotEuro(currency),
}"
class="full-width"
:disable="isNotEuro(currency)"
clear-icon="close"
v-model="props.row.taxableBase"
clearable
/>
</QItem>
<QItem>
<VnSelect
:label="t('Sage iva')"
class="full-width"
v-model="props.row['taxTypeSageFk']"
:options="sageTaxTypes"
option-value="id"
:option-label="(row) => `${row.id}:${row.vat}`"
:filter-options="['id', 'vat']"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{
scope.opt.vat
}}</QItemLabel>
<QItemLabel>
{{ `#${scope.opt.id}` }}
</QItemLabel>
</QItemSection>
</QItem>
</template>
</VnSelect>
</QItem>
<QItem>
<VnSelect
class="full-width"
v-model="props.row['transactionTypeSageFk']"
:options="sageTransactionTypes"
option-value="id"
:option-label="
(row) => `${row.id}:${row.transaction}`
"
:filter-options="['id', 'transaction']"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{
scope.opt.transaction
}}</QItemLabel>
<QItemLabel>
{{ `#${scope.opt.id}` }}
</QItemLabel>
</QItemSection>
</QItem>
</template>
</VnSelect>
</QItem>
<QItem>
{{ toCurrency(taxRate(props.row), currency) }}
</QItem>
<QItem>
<VnInputNumber
:label="t('Foreign value')"
class="full-width"
:class="{
'no-pointer-events': !isNotEuro(currency),
}"
:disable="!isNotEuro(currency)"
v-model="props.row.foreignValue"
/>
</QItem>
</QList>
</QCard>
</div>
</template>
</QTable>
<template #column-footer-taxableBase>
{{ toCurrency(taxableBaseTotal) }}
</template>
</CrudModel>
<QPageSticky position="bottom-right" :offset="[25, 25]">
<QBtn
color="primary"
icon="add"
size="lg"
v-shortcut="'+'"
round
@click="invoiceInFormRef.insert()"
>
<QTooltip>{{ t('Add tax') }}</QTooltip>
</QBtn>
</QPageSticky>
<template #column-footer-rate>
{{ toCurrency(taxRateTotal) }}
</template>
<template #column-footer-total>
{{ toCurrency(combinedTotal) }}
</template>
</VnTable>
</template>
<style lang="scss" scoped>
.bg {
background-color: var(--vn-light-gray);
}
@media (max-width: $breakpoint-xs) {
.q-dialog {
.q-card {
&__section:not(:first-child) {
.q-item {
flex-direction: column;
.q-checkbox {
margin-top: 2rem;
}
}
}
}
}
}
.q-item {
min-height: 0;
}
.default-icon {
cursor: pointer;
border-radius: 50px;
background-color: $primary;
}
</style>
<i18n>
es:
Expense: Gasto
Create a new expense: Crear nuevo gasto
Add tax: Crear gasto
Add tax: Añadir Gasto/IVA # Changed label slightly
Taxable base: Base imp.
Sage tax: Sage iva
Sage iva: Sage iva # Kept original label
Sage transaction: Sage transacción
Rate: Tasa
Rate: Cuota # Changed label
Foreign value: Divisa
Total: Total
invoiceIn.isDeductible: Deducible
</i18n>

View File

@ -1,12 +1,11 @@
<script setup>
import { ref } from 'vue';
import { Notify } from 'quasar';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import VnInputPassword from 'src/components/common/VnInputPassword.vue';
import { useSession } from 'src/composables/useSession';
import { useLogin } from 'src/composables/useLogin';
import useNotify from 'src/composables/useNotify';
import VnLogo from 'components/ui/VnLogo.vue';
import VnInput from 'src/components/common/VnInput.vue';
import axios from 'axios';
@ -15,16 +14,14 @@ const session = useSession();
const loginCache = useLogin();
const router = useRouter();
const { t } = useI18n();
const { notify } = useNotify();
const username = ref('');
const password = ref('');
const keepLogin = ref(true);
async function onSubmit() {
const params = {
user: username.value,
password: password.value,
};
const params = { user: username.value, password: password.value };
try {
const { data } = await axios.post('Accounts/login', params);
if (!data) return;
@ -33,11 +30,7 @@ async function onSubmit() {
await session.setLogin(data);
} catch (res) {
if (res.response?.data?.error?.code === 'REQUIRES_2FA') {
Notify.create({
message: t('login.twoFactorRequired'),
icon: 'phoneLink_lock',
type: 'warning',
});
notify(t('login.twoFactorRequired'), 'warning', 'phoneLink_lock');
params.keepLogin = keepLogin.value;
loginCache.setUser(params);
return router.push({
@ -45,10 +38,7 @@ async function onSubmit() {
query: router.currentRoute.value?.query,
});
}
Notify.create({
message: t('login.loginError'),
type: 'negative',
});
throw res;
}
}
</script>

View File

@ -5,33 +5,28 @@ describe('Logout', { testIsolation: true }, () => {
cy.visit(`/#/dashboard`);
cy.waitForElement('.q-page', 6000);
});
describe('by user', () => {
it('should logout', () => {
cy.get('#user').click();
cy.get('#logout').click();
});
it('should logout', () => {
cy.get('#user').click();
cy.get('#logout').click();
});
describe('not user', () => {
beforeEach(() => {
cy.intercept('GET', '**StarredModules**', {
statusCode: 401,
body: {
error: {
statusCode: 401,
name: 'Error',
message: 'Authorization Required',
code: 'AUTHORIZATION_REQUIRED',
},
it('should throw session expired error if token has expired or is not valid during navigation', () => {
cy.intercept('GET', '**StarredModules**', {
statusCode: 401,
body: {
error: {
statusCode: 401,
name: 'Error',
message: 'Authorization Required',
code: 'AUTHORIZATION_REQUIRED',
},
statusMessage: 'AUTHORIZATION_REQUIRED',
}).as('badRequest');
});
},
statusMessage: 'AUTHORIZATION_REQUIRED',
}).as('badRequest');
cy.get('.q-list').should('be.visible').first().should('be.visible').click();
cy.wait('@badRequest');
it('when token not exists', () => {
cy.get('.q-list').should('be.visible').first().should('be.visible').click();
cy.wait('@badRequest');
cy.checkNotification('Authorization Required');
});
cy.checkNotification('Your session has expired. Please log in again');
});
});