0
0
Fork 0

Merge branch 'dev' of https: refs #7553//gitea.verdnatura.es/verdnatura/salix-front into 7553_FixTicketExpedition

This commit is contained in:
Jon Elias 2024-09-11 10:26:52 +02:00
commit d1138529b2
44 changed files with 295 additions and 258 deletions

View File

@ -105,7 +105,7 @@ async function setProvince(id, data) {
option-label="name"
option-value="id"
:rules="validate('postcode.city')"
:roles-allowed-to-create="['deliveryAssistant']"
:acls="[{ model: 'Town', props: '*', accessType: 'WRITE' }]"
:emit-value="false"
clearable
>

View File

@ -36,7 +36,7 @@ const itemComputed = computed(() => {
<QItemSection>
{{ t(itemComputed.title) }}
<QTooltip>
{{ 'Ctrl + Alt + ' + item.keyBinding.toUpperCase() }}
{{ 'Ctrl + Alt + ' + item?.keyBinding?.toUpperCase() }}
</QTooltip>
</QItemSection>
<QItemSection side>

View File

@ -38,7 +38,7 @@ async function onProvinceCreated(_, data) {
hide-selected
v-model="provinceFk"
:rules="validate && validate('postcode.provinceFk')"
:roles-allowed-to-create="['deliveryAssistant']"
:acls="[{ model: 'Province', props: '*', accessType: 'WRITE' }]"
>
<template #option="{ itemProps, opt }">
<QItem v-bind="itemProps">

View File

@ -1,6 +1,7 @@
<script setup>
import { ref, computed } from 'vue';
import { useRole } from 'src/composables/useRole';
import { useAcl } from 'src/composables/useAcl';
import VnSelect from 'src/components/common/VnSelect.vue';
const emit = defineEmits(['update:modelValue']);
@ -11,6 +12,10 @@ const $props = defineProps({
type: Array,
default: () => ['developer'],
},
acls: {
type: Array,
default: () => [],
},
actionIcon: {
type: String,
default: 'add',
@ -22,15 +27,13 @@ const $props = defineProps({
});
const role = useRole();
const acl = useAcl();
const showForm = ref(false);
const isAllowedToCreate = computed(() => {
if ($props.acls.length) return acl.hasAny($props.acls);
return role.hasAny($props.rolesAllowedToCreate);
});
const toggleForm = () => {
showForm.value = !showForm.value;
};
</script>
<template>
@ -41,7 +44,7 @@ const toggleForm = () => {
>
<template v-if="isAllowedToCreate" #append>
<QIcon
@click.stop.prevent="toggleForm()"
@click.stop.prevent="$refs.dialog.show()"
:name="actionIcon"
:size="actionIcon === 'add' ? 'xs' : 'sm'"
:class="['default-icon', { '--add-icon': actionIcon === 'add' }]"
@ -51,7 +54,7 @@ const toggleForm = () => {
>
<QTooltip v-if="tooltip">{{ tooltip }}</QTooltip>
</QIcon>
<QDialog v-model="showForm" transition-show="scale" transition-hide="scale">
<QDialog ref="dialog" transition-show="scale" transition-hide="scale">
<slot name="form" />
</QDialog>
</template>

View File

@ -31,6 +31,7 @@ const props = defineProps({
});
defineEmits(['confirm', ...useDialogPluginComponent.emits]);
defineExpose({ show: () => dialogRef.value.show(), hide: () => dialogRef.value.hide() });
const { dialogRef, onDialogOK } = useDialogPluginComponent();
@ -68,8 +69,10 @@ async function confirm() {
<QSpace />
<QBtn icon="close" :disable="isLoading" flat round dense v-close-popup />
</QCardSection>
<QCardSection class="row items-center">
<QCardSection class="q-pb-none">
<span v-if="message !== false" v-html="message" />
</QCardSection>
<QCardSection class="row items-center q-pt-none">
<slot name="customHTML"></slot>
</QCardSection>
<QCardActions align="right">

View File

@ -119,8 +119,8 @@ watch(
);
watch(
() => [props.url, props.filter],
([url, filter]) => mounted.value && fetch({ url, filter })
() => [props.url, props.filter, props.userParams],
([url, filter, userParams]) => mounted.value && fetch({ url, filter, userParams })
);
const addFilter = async (filter, params) => {

View File

@ -16,14 +16,19 @@ export function useAcl() {
state.setAcls(acls);
}
function hasAny(model, prop, accessType) {
const acls = state.getAcls().value[model];
if (acls)
return ['*', prop].some((key) => {
const acl = acls[key];
function hasAny(acls) {
for (const acl of acls) {
let { model, props, accessType } = acl;
const modelAcls = state.getAcls().value[model];
Array.isArray(props) || (props = [props]);
if (modelAcls)
return ['*', ...props].some((key) => {
const acl = modelAcls[key];
return acl && (acl['*'] || acl[accessType]);
});
}
return false;
}
return {
fetch,

View File

@ -37,6 +37,10 @@ a {
.link {
color: $color-link;
cursor: pointer;
&--white {
color: white;
}
}
.tx-color-link {

View File

@ -98,6 +98,7 @@ globals:
to: To
notes: Notes
refresh: Refresh
weight: Weight
pageTitles:
logIn: Login
summary: Summary

View File

@ -100,6 +100,7 @@ globals:
to: Hasta
notes: Notas
refresh: Actualizar
weight: Peso
pageTitles:
logIn: Inicio de sesión
summary: Resumen

View File

@ -2,7 +2,7 @@
import { computed, onBeforeMount, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useRole } from 'src/composables/useRole';
import { useAcl } from 'src/composables/useAcl';
import axios from 'axios';
import { useQuasar } from 'quasar';
@ -24,7 +24,7 @@ import InvoiceOutDescriptorProxy from 'src/pages/InvoiceOut/Card/InvoiceOutDescr
const { openConfirmationModal } = useVnConfirm();
const { sendEmail } = usePrintService();
const { t } = useI18n();
const { hasAny } = useRole();
const { hasAny } = useAcl();
const session = useSession();
const tokenMultimedia = session.getTokenMultimedia();
@ -284,7 +284,9 @@ const showBalancePdf = ({ id }) => {
>
<VnInput
v-model="scope.value"
:disable="!hasAny(['administrative'])"
:disable="
!hasAny([{ model: 'Receipt', props: '*', accessType: 'WRITE' }])
"
@keypress.enter="scope.set"
autofocus
/>

View File

@ -70,7 +70,7 @@ const getBankEntities = (data, formData) => {
<VnSelectDialog
:label="t('Swift / BIC')"
:options="bankEntitiesOptions"
:roles-allowed-to-create="['salesAssistant', 'hr']"
:acls="[{ model: 'BankEntity', props: '*', accessType: 'WRITE' }]"
:rules="validate('Worker.bankEntity')"
hide-selected
option-label="name"

View File

@ -93,7 +93,7 @@ function handleLocation(data, location) {
<VnRow>
<VnLocation
:rules="validate('Worker.postcode')"
:roles-allowed-to-create="['deliveryAssistant']"
:acls="[{ model: 'Town', props: '*', accessType: 'WRITE' }]"
v-model="data.postcode"
@update:model-value="(location) => handleLocation(data, location)"
/>

View File

@ -86,7 +86,7 @@ function handleLocation(data, location) {
<VnRow>
<VnLocation
:rules="validate('Worker.postcode')"
:roles-allowed-to-create="['deliveryAssistant']"
:acls="[{ model: 'Town', props: '*', accessType: 'WRITE' }]"
v-model="data.location"
@update:model-value="(location) => handleLocation(data, location)"
>

View File

@ -412,7 +412,7 @@ function handleLocation(data, location) {
>
<template #more-create-dialog="{ data }">
<VnLocation
:roles-allowed-to-create="['deliveryAssistant']"
:acls="[{ model: 'Province', props: '*', accessType: 'WRITE' }]"
v-model="data.location"
@update:model-value="(location) => handleLocation(data, location)"
/>

View File

@ -92,7 +92,7 @@ function handleLocation(data, location) {
<VnLocation
:rules="validate('Worker.postcode')"
:roles-allowed-to-create="['deliveryAssistant']"
:acls="[{ model: 'Town', props: '*', accessType: 'WRITE' }]"
v-model="data.location"
@update:model-value="(location) => handleLocation(data, location)"
/>

View File

@ -176,7 +176,7 @@ function handleLocation(data, location) {
<div class="col">
<VnLocation
:rules="validate('Worker.postcode')"
:roles-allowed-to-create="['deliveryAssistant']"
:acls="[{ model: 'Town', props: '*', accessType: 'WRITE' }]"
v-model="data.postalCode"
@update:model-value="(location) => handleLocation(data, location)"
></VnLocation>

View File

@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import axios from 'axios';
import { toCurrency, toDate } from 'src/filters';
import { useRole } from 'src/composables/useRole';
import { useAcl } from 'src/composables/useAcl';
import { downloadFile } from 'src/composables/downloadFile';
import { useArrayData } from 'src/composables/useArrayData';
import { usePrintService } from 'composables/usePrintService';
@ -24,7 +24,7 @@ const $props = defineProps({ id: { type: Number, default: null } });
const { push, currentRoute } = useRouter();
const quasar = useQuasar();
const { hasAny } = useRole();
const { hasAny } = useAcl();
const { t } = useI18n();
const { openReport, sendEmail } = usePrintService();
const arrayData = useArrayData();
@ -195,7 +195,8 @@ async function cloneInvoice() {
push({ path: `/invoice-in/${data.id}/summary` });
}
const isAdministrative = () => hasAny(['administrative']);
const canEditProp = (props) =>
hasAny([{ model: 'InvoiceIn', props, accessType: 'WRITE' }]);
const isAgricultural = () => {
if (!config.value) return false;
@ -283,7 +284,7 @@ const createInvoiceInCorrection = async () => {
<InvoiceInToBook>
<template #content="{ book }">
<QItem
v-if="!entity?.isBooked && isAdministrative()"
v-if="!entity?.isBooked && canEditProp('toBook')"
v-ripple
clickable
@click="book(entityId)"
@ -293,7 +294,7 @@ const createInvoiceInCorrection = async () => {
</template>
</InvoiceInToBook>
<QItem
v-if="entity?.isBooked && isAdministrative()"
v-if="entity?.isBooked && canEditProp('toUnbook')"
v-ripple
clickable
@click="triggerMenu('unbook')"
@ -303,7 +304,7 @@ const createInvoiceInCorrection = async () => {
</QItemSection>
</QItem>
<QItem
v-if="isAdministrative()"
v-if="canEditProp('deleteById')"
v-ripple
clickable
@click="triggerMenu('delete')"
@ -311,7 +312,7 @@ const createInvoiceInCorrection = async () => {
<QItemSection>{{ t('Delete invoice') }}</QItemSection>
</QItem>
<QItem
v-if="isAdministrative()"
v-if="canEditProp('clone')"
v-ripple
clickable
@click="triggerMenu('clone')"

View File

@ -7,8 +7,7 @@ import CardSummary from 'components/ui/CardSummary.vue';
import VnLv from 'src/components/ui/VnLv.vue';
import ItemDescriptorImage from 'src/pages/Item/Card/ItemDescriptorImage.vue';
import VnUserLink from 'src/components/ui/VnUserLink.vue';
import { useRole } from 'src/composables/useRole';
import VnTitle from 'src/components/common/VnTitle.vue';
const $props = defineProps({
id: {
@ -19,23 +18,10 @@ const $props = defineProps({
const route = useRoute();
const { t } = useI18n();
const roleState = useRole();
const entityId = computed(() => $props.id || route.params.id);
const isBuyer = computed(() => {
return roleState.hasAny(['buyer']);
});
const isReplenisher = computed(() => {
return roleState.hasAny(['replenisher']);
});
const isAdministrative = computed(() => {
return roleState.hasAny(['administrative']);
});
const getUrl = (id, param) => `#/Item/${id}/${param}`;
</script>
<template>
<CardSummary
ref="summary"
@ -44,13 +30,15 @@ const isAdministrative = computed(() => {
data-key="ItemSummary"
>
<template #header-left>
<router-link
v-if="route.name !== 'ItemSummary'"
<QBtn
v-if="$route.name !== 'ItemSummary'"
:to="{ name: 'ItemSummary', params: { id: entityId } }"
class="header link"
>
<QIcon name="open_in_new" color="white" size="sm" />
</router-link>
class="header link--white"
icon="open_in_new"
flat
dense
round
/>
</template>
<template #header="{ entity: { item } }">
{{ item.id }} - {{ item.name }}
@ -65,15 +53,10 @@ const isAdministrative = computed(() => {
/>
</QCard>
<QCard class="vn-one">
<component
:is="isBuyer ? 'router-link' : 'span'"
:to="{ name: 'ItemBasicData', params: { id: entityId } }"
class="header"
:class="{ 'header-link': isBuyer }"
>
{{ t('item.summary.basicData') }}
<QIcon v-if="isBuyer" name="open_in_new" />
</component>
<VnTitle
:url="getUrl(entityId, 'basic-data')"
:text="t('item.summary.basicData')"
/>
<VnLv :label="t('item.summary.name')" :value="item.name" />
<VnLv :label="t('item.summary.completeName')" :value="item.longName" />
<VnLv :label="t('item.summary.family')" :value="item.itemType.name" />
@ -104,15 +87,10 @@ const isAdministrative = computed(() => {
</VnLv>
</QCard>
<QCard class="vn-one">
<component
:is="isBuyer ? 'router-link' : 'span'"
:to="{ name: 'ItemBasicData', params: { id: entityId } }"
class="header"
:class="{ 'header-link': isBuyer }"
>
{{ t('item.summary.otherData') }}
<QIcon v-if="isBuyer" name="open_in_new" />
</component>
<VnTitle
:url="getUrl(entityId, 'basic-data')"
:text="t('item.summary.otherData')"
/>
<VnLv
:label="t('item.summary.intrastatCode')"
:value="item.intrastat.id"
@ -137,15 +115,7 @@ const isAdministrative = computed(() => {
/>
</QCard>
<QCard class="vn-one">
<component
:is="isBuyer || isReplenisher ? 'router-link' : 'span'"
:to="{ name: 'ItemTags', params: { id: entityId } }"
class="header"
:class="{ 'header-link': isBuyer || isReplenisher }"
>
{{ t('item.summary.tags') }}
<QIcon v-if="isBuyer || isReplenisher" name="open_in_new" />
</component>
<VnTitle :url="getUrl(entityId, 'tags')" :text="t('item.summary.tags')" />
<VnLv
v-for="(tag, index) in tags"
:key="index"
@ -154,29 +124,14 @@ const isAdministrative = computed(() => {
/>
</QCard>
<QCard class="vn-one" v-if="item.description">
<component
:is="isBuyer ? 'router-link' : 'span'"
:to="{ name: 'ItemBasicData', params: { id: entityId } }"
class="header"
:class="{ 'header-link': isBuyer }"
>
{{ t('item.summary.description') }}
<QIcon v-if="isBuyer" name="open_in_new" />
</component>
<p>
{{ item.description }}
</p>
<VnTitle
:url="getUrl(entityId, 'basic-data')"
:text="t('item.summary.description')"
/>
<p v-text="item.description" />
</QCard>
<QCard class="vn-one">
<component
:is="isBuyer || isAdministrative ? 'router-link' : 'span'"
:to="{ name: 'ItemTax', params: { id: entityId } }"
class="header"
:class="{ 'header-link': isBuyer || isAdministrative }"
>
{{ t('item.summary.tax') }}
<QIcon v-if="isBuyer || isAdministrative" name="open_in_new" />
</component>
<VnTitle :url="getUrl(entityId, 'tax')" :text="t('item.summary.tax')" />
<VnLv
v-for="(tax, index) in item.taxes"
:key="index"
@ -185,15 +140,10 @@ const isAdministrative = computed(() => {
/>
</QCard>
<QCard class="vn-one">
<component
:is="isBuyer ? 'router-link' : 'span'"
:to="{ name: 'ItemBotanical', params: { id: entityId } }"
class="header"
:class="{ 'header-link': isBuyer }"
>
{{ t('item.summary.botanical') }}
<QIcon v-if="isBuyer" name="open_in_new" />
</component>
<VnTitle
:url="getUrl(entityId, 'botanical')"
:text="t('item.summary.botanical')"
/>
<VnLv :label="t('item.summary.genus')" :value="botanical?.genus?.name" />
<VnLv
:label="t('item.summary.specie')"
@ -201,23 +151,19 @@ const isAdministrative = computed(() => {
/>
</QCard>
<QCard class="vn-one">
<component
:is="isBuyer || isReplenisher ? 'router-link' : 'span'"
:to="{ name: 'ItemBarcode', params: { id: entityId } }"
class="header"
:class="{ 'header-link': isBuyer || isReplenisher }"
>
{{ t('item.summary.barcode') }}
<QIcon v-if="isBuyer || isReplenisher" name="open_in_new" />
</component>
<p v-for="(barcode, index) in item.itemBarcode" :key="index">
{{ barcode.code }}
</p>
<VnTitle
:url="getUrl(entityId, 'barcode')"
:text="t('item.summary.barcode')"
/>
<p
v-for="(barcode, index) in item.itemBarcode"
:key="index"
v-text="barcode.code"
/>
</QCard>
</template>
</CardSummary>
</template>
<i18n>
en:
Este artículo necesita una foto: Este artículo necesita una foto

View File

@ -83,7 +83,7 @@ function handleLocation(data, location) {
<VnRow>
<VnLocation
:rules="validate('Worker.postcode')"
:roles-allowed-to-create="['deliveryAssistant']"
:acls="[{ model: 'Town', props: '*', accessType: 'WRITE' }]"
v-model="data.location"
@update:model-value="(location) => handleLocation(data, location)"
>

View File

@ -129,7 +129,7 @@ function handleLocation(data, location) {
<VnRow>
<VnLocation
:rules="validate('Worker.postcode')"
:roles-allowed-to-create="['deliveryAssistant']"
:acls="[{ model: 'Town', props: '*', accessType: 'WRITE' }]"
v-model="data.postCode"
@update:model-value="(location) => handleLocation(data, location)"
>

View File

@ -4,13 +4,11 @@ import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import CardSummary from 'components/ui/CardSummary.vue';
import VnLv from 'src/components/ui/VnLv.vue';
import { useRole } from 'src/composables/useRole';
import { dashIfEmpty } from 'src/filters';
import VnUserLink from 'src/components/ui/VnUserLink.vue';
import VnTitle from 'src/components/common/VnTitle.vue';
const route = useRoute();
const roleState = useRole();
const { t } = useI18n();
const $props = defineProps({
@ -32,13 +30,7 @@ async function setData(data) {
}
}
const isAdministrative = computed(() => {
return roleState.hasAny(['administrative']);
});
function getUrl(section) {
return isAdministrative.value && `#/supplier/${entityId.value}/${section}`;
}
const getUrl = (section) => `#/supplier/${entityId.value}/${section}`;
</script>
<template>

View File

@ -14,6 +14,8 @@ import VnSelect from 'components/common/VnSelect.vue';
import FetchData from 'components/FetchData.vue';
import VnInputTime from 'src/components/common/VnInputTime.vue';
import { useAcl } from 'src/composables/useAcl';
import VnInputNumber from 'src/components/common/VnInputNumber.vue';
import { useArrayData } from 'src/composables/useArrayData';
const props = defineProps({
ticket: {
@ -26,7 +28,7 @@ const { push, currentRoute } = useRouter();
const { dialog, notify } = useQuasar();
const { t } = useI18n();
const { openReport, sendEmail } = usePrintService();
const ticketSummary = useArrayData('TicketSummary');
const { ticket } = toRefs(props);
const ticketId = currentRoute.value.params.id;
const client = ref();
@ -37,6 +39,7 @@ const dialogRef = ref();
const isEditable = ref();
const hasInvoicing = useAcl('invoicing');
const hasPdf = ref();
const weight = ref();
const actions = {
clone: async () => {
const opts = { message: t('Ticket cloned'), type: 'positive' };
@ -59,6 +62,25 @@ const actions = {
push({ name: 'TicketSummary', params: { id: clonedTicketId } });
}
},
setWeight: async () => {
try {
const invoiceIds = (
await axios.post(`Tickets/${ticketId}/setWeight`, {
weight: weight.value,
})
).data;
notify({ message: t('Weight set'), type: 'positive' });
if (invoiceIds.length)
notify({
message: t('invoiceIds', { invoiceIds: invoiceIds.join() }),
type: 'positive',
});
await ticketSummary.fetch({ updateRouter: false });
} catch (e) {
notify({ message: e.message, type: 'negative' });
}
},
remove: async () => {
try {
await axios.post(`Tickets/${ticketId}/setDeleted`);
@ -578,6 +600,12 @@ async function handleInvoiceOutData() {
</QList>
</QMenu>
</QItem>
<QItem @click="$refs.weightDialog.show()" v-ripple clickable>
<QItemSection avatar>
<QIcon name="weight" />
</QItemSection>
<QItemSection>{{ t('Set weight') }}</QItemSection>
</QItem>
<template v-if="!ticket.isDeleted">
<QSeparator />
<QItem @click="openConfirmDialog('remove')" v-ripple clickable>
@ -587,6 +615,20 @@ async function handleInvoiceOutData() {
<QItemSection>{{ t('Delete ticket') }}</QItemSection>
</QItem>
</template>
<VnConfirm
ref="weightDialog"
:title="t('Set weight')"
:message="t('This ticket may be invoiced, do you want to continue?')"
:promise="actions.setWeight"
>
<template #customHTML>
<VnInputNumber
:label="t('globals.weight')"
v-model="weight"
class="full-width"
/>
</template>
</VnConfirm>
</template>
<style lang="scss" scoped>
@ -594,10 +636,12 @@ async function handleInvoiceOutData() {
margin: 1%;
}
</style>
<i18n>
en:
addTurn: Add turn
en:
invoiceIds: "Invoices have been generated with the following ids: {invoiceIds}"
es:
Show Delivery Note...: Ver albarán...
Send Delivery Note...: Enviar albarán...
@ -638,4 +682,8 @@ es:
Are you sure you want to invoice this ticket?: ¿Seguro que quieres facturar este ticket?
You are going to invoice this ticket: Vas a facturar este ticket
Ticket invoiced: Ticket facturado
Set weight: Establecer peso
Weight set: Peso establecido
This ticket may be invoiced, do you want to continue?: Es posible que se facture este ticket, desea continuar?
invoiceIds: "Se han generado las facturas con los siguientes ids: {invoiceIds}"
</i18n>

View File

@ -34,8 +34,7 @@ const $props = defineProps({
const entityId = computed(() => $props.id || route.params.id);
const summaryRef = ref();
const ticket = ref();
const salesLines = ref(null);
const ticket = computed(() => summaryRef.value?.entity);
const editableStates = ref([]);
const ticketUrl = ref();
const grafanaUrl = 'https://grafana.verdnatura.es';
@ -43,12 +42,6 @@ const grafanaUrl = 'https://grafana.verdnatura.es';
onMounted(async () => {
ticketUrl.value = (await getUrl('ticket/')) + entityId.value + '/';
});
async function setData(data) {
if (data) {
ticket.value = data;
salesLines.value = data.sales;
}
}
function formattedAddress() {
if (!ticket.value) return '';
@ -120,7 +113,6 @@ function toTicketUrl(section) {
<CardSummary
ref="summaryRef"
:url="`Tickets/${entityId}/summary`"
@on-fetch="(data) => setData(data)"
data-key="TicketSummary"
>
<template #header="{ entity }">
@ -149,7 +141,7 @@ function toTicketUrl(section) {
/>
</QBtnDropdown>
</template>
<template #body>
<template #body="{ entity }">
<QCard class="vn-one">
<VnTitle
:url="toTicketUrl('basic-data')"
@ -168,14 +160,14 @@ function toTicketUrl(section) {
<VnLv :label="t('ticket.summary.salesPerson')">
<template #value>
<VnUserLink
:name="ticket.client?.salesPersonUser?.name"
:worker-id="ticket.client?.salesPersonFk"
:name="entity.client?.salesPersonUser?.name"
:worker-id="entity.client?.salesPersonFk"
/>
</template>
</VnLv>
<VnLv
:label="t('ticket.summary.agency')"
:value="ticket.agencyMode?.name"
:value="entity.agencyMode?.name"
/>
<VnLv :label="t('ticket.summary.zone')">
<template #value>
@ -187,19 +179,19 @@ function toTicketUrl(section) {
</VnLv>
<VnLv
:label="t('ticket.summary.warehouse')"
:value="ticket.warehouse?.name"
:value="entity.warehouse?.name"
/>
<VnLv
:label="t('ticket.summary.collection')"
:value="ticket.ticketCollections[0]?.collectionFk"
:value="entity.ticketCollections[0]?.collectionFk"
>
<template #value>
<a
:href="`${grafanaUrl}/d/d552ab74-85b4-4e7f-a279-fab7cd9c6124/control-de-expediciones?orgId=1&var-collectionFk=${ticket.ticketCollections[0]?.collectionFk}`"
:href="`${grafanaUrl}/d/d552ab74-85b4-4e7f-a279-fab7cd9c6124/control-de-expediciones?orgId=1&var-collectionFk=${entity.ticketCollections[0]?.collectionFk}`"
target="_blank"
class="grafana"
>
{{ ticket.ticketCollections[0]?.collectionFk }}
{{ entity.ticketCollections[0]?.collectionFk }}
</a>
</template>
</VnLv>
@ -213,18 +205,18 @@ function toTicketUrl(section) {
></VnLv>
<VnLv :label="t('ticket.summary.invoice')">
<template #value>
<span :class="{ link: ticket.refFk }">
{{ dashIfEmpty(ticket.refFk) }}
<span :class="{ link: entity.refFk }">
{{ dashIfEmpty(entity.refFk) }}
<InvoiceOutDescriptorProxy
:id="ticket.invoiceOut.id"
v-if="ticket.refFk"
:id="entity.invoiceOut.id"
v-if="entity.refFk"
/>
</span>
</template>
</VnLv>
<VnLv
:label="t('ticket.summary.weight')"
:value="dashIfEmpty(ticket.weight)"
:value="dashIfEmpty(entity.weight)"
/>
</QCard>
<QCard class="vn-one" style="flex: 2 1">
@ -234,35 +226,35 @@ function toTicketUrl(section) {
/>
<VnLv
:label="t('ticket.summary.shipped')"
:value="toDate(ticket.shipped)"
:value="toDate(entity.shipped)"
/>
<VnLv
:label="t('ticket.summary.landed')"
:value="toDate(ticket.landed)"
:value="toDate(entity.landed)"
/>
<VnLv :label="t('globals.packages')" :value="ticket.packages" />
<VnLv :value="ticket.address.phone">
<VnLv :label="t('globals.packages')" :value="entity.packages" />
<VnLv :value="entity.address.phone">
<template #label>
{{ t('ticket.summary.consigneePhone') }}
<VnLinkPhone :phone-number="ticket.address.phone" />
<VnLinkPhone :phone-number="entity.address.phone" />
</template>
</VnLv>
<VnLv :value="ticket.address.mobile">
<VnLv :value="entity.address.mobile">
<template #label>
{{ t('ticket.summary.consigneeMobile') }}
<VnLinkPhone :phone-number="ticket.address.mobile" />
<VnLinkPhone :phone-number="entity.address.mobile" />
</template>
</VnLv>
<VnLv :value="ticket.client.phone">
<VnLv :value="entity.client.phone">
<template #label>
{{ t('ticket.summary.clientPhone') }}
<VnLinkPhone :phone-number="ticket.client.phone" />
<VnLinkPhone :phone-number="entity.client.phone" />
</template>
</VnLv>
<VnLv :value="ticket.client.mobile">
<VnLv :value="entity.client.mobile">
<template #label>
{{ t('ticket.summary.clientMobile') }}
<VnLinkPhone :phone-number="ticket.client.mobile" />
<VnLinkPhone :phone-number="entity.client.mobile" />
</template>
</VnLv>
<VnLv
@ -279,13 +271,13 @@ function toTicketUrl(section) {
:value="formattedAddress()"
/>
</QCard>
<QCard class="vn-one" v-if="ticket.notes.length">
<QCard class="vn-one" v-if="entity.notes.length">
<VnTitle
:url="toTicketUrl('observation')"
:text="t('ticket.pageTitles.notes')"
/>
<VnLv
v-for="note in ticket.notes"
v-for="note in entity.notes"
:key="note.id"
:label="getNoteValue(note.observationType.description)"
:value="note.description"
@ -306,11 +298,11 @@ function toTicketUrl(section) {
<div class="bodyCard">
<VnLv
:label="t('ticket.summary.subtotal')"
:value="toCurrency(ticket.totalWithoutVat)"
:value="toCurrency(entity.totalWithoutVat)"
/>
<VnLv
:label="t('ticket.summary.vat')"
:value="toCurrency(ticket.totalWithVat - ticket.totalWithoutVat)"
:value="toCurrency(entity.totalWithVat - entity.totalWithoutVat)"
/>
<VnLv
:label="t('ticket.summary.total')"
@ -324,7 +316,7 @@ function toTicketUrl(section) {
:url="toTicketUrl('sale')"
:text="t('ticket.summary.saleLines')"
/>
<QTable :rows="ticket.sales" style="text-align: center">
<QTable :rows="entity.sales" style="text-align: center">
<template #body-cell="{ value }">
<QTd>{{ value }}</QTd>
</template>

View File

@ -635,6 +635,7 @@ onMounted(async () => {
<template #body-cell-state="{ row }">
<QTd>
<QBadge
v-if="row.state"
text-color="black"
:color="row.classColor"
class="q-ma-none"
@ -642,6 +643,7 @@ onMounted(async () => {
>
{{ row.state }}
</QBadge>
<span v-else> {{ dashIfEmpty(row.state) }}</span>
</QTd>
</template>
<template #body-cell-import="{ row }">

View File

@ -55,7 +55,7 @@ onMounted(async () => await getItemPackingTypes());
:data-key="props.dataKey"
:search-button="true"
:hidden-tags="['search']"
:un-removable-params="['warehouseFk', 'dateFuture', 'dateToAdvance']"
:unremovable-params="['warehouseFk', 'dateFuture', 'dateToAdvance']"
>
<template #tags="{ tag, formatFn }">
<div class="q-gutter-x-xs">
@ -119,10 +119,9 @@ onMounted(async () => await getItemPackingTypes());
<QItem>
<QItemSection>
<QCheckbox
:label="t('params.itemPackingTypes')"
v-model="params.itemPackingTypes"
:label="t('params.isFullMovable')"
v-model="params.isFullMovable"
toggle-indeterminate
:false-value="null"
@update:model-value="searchFn()"
/>
</QItemSection>
@ -155,7 +154,7 @@ en:
dateToAdvance: Destination date
futureIpt: Origin IPT
ipt: Destination IPT
itemPackingTypes: 100% movable
isFullMovable: 100% movable
warehouseFk: Warehouse
es:
Horizontal: Horizontal
@ -166,6 +165,6 @@ es:
dateToAdvance: Fecha destino
futureIpt: IPT Origen
ipt: IPT destino
itemPackingTypes: 100% movible
isFullMovable: 100% movible
warehouseFk: Almacén
</i18n>

View File

@ -49,8 +49,8 @@ const exprBuilder = (param, value) => {
};
const userParams = reactive({
futureDated: Date.vnNew().toISOString(),
originDated: Date.vnNew().toISOString(),
futureScopeDays: Date.vnNew().toISOString(),
originScopeDays: Date.vnNew().toISOString(),
warehouseFk: user.value.warehouseFk,
});
@ -62,8 +62,8 @@ const arrayData = useArrayData('FutureTickets', {
const { store } = arrayData;
const params = reactive({
futureDated: Date.vnNew(),
originDated: Date.vnNew(),
futureScopeDays: Date.vnNew(),
originScopeDays: Date.vnNew(),
warehouseFk: user.value.warehouseFk,
});
@ -172,7 +172,7 @@ const ticketColumns = computed(() => [
label: t('futureTickets.availableLines'),
name: 'lines',
field: 'lines',
align: 'left',
align: 'center',
sortable: true,
columnFilter: {
component: VnInput,
@ -234,7 +234,7 @@ const ticketColumns = computed(() => [
{
label: t('futureTickets.futureState'),
name: 'futureState',
align: 'left',
align: 'right',
sortable: true,
columnFilter: null,
format: (val) => dashIfEmpty(val),
@ -458,7 +458,7 @@ onMounted(async () => {
</QTd>
</template>
<template #body-cell-shipped="{ row }">
<QTd>
<QTd class="shipped">
<QBadge
text-color="black"
:color="getDateQBadgeColor(row.shipped)"
@ -505,7 +505,7 @@ onMounted(async () => {
</QTd>
</template>
<template #body-cell-futureShipped="{ row }">
<QTd>
<QTd class="shipped">
<QBadge
text-color="black"
:color="getDateQBadgeColor(row.futureShipped)"
@ -532,6 +532,9 @@ onMounted(async () => {
</template>
<style scoped lang="scss">
.shipped {
min-width: 132px;
}
.vertical-separator {
border-left: 4px solid white !important;
}

View File

@ -68,7 +68,7 @@ onMounted(async () => {
<VnFilterPanel
:data-key="props.dataKey"
:hidden-tags="['search']"
:un-removable-params="['warehouseFk', 'originDated', 'futureDated']"
:un-removable-params="['warehouseFk', 'originScopeDays ', 'futureScopeDays']"
>
<template #tags="{ tag, formatFn }">
<div class="q-gutter-x-xs">
@ -80,8 +80,8 @@ onMounted(async () => {
<QItem class="q-my-sm">
<QItemSection>
<VnInputDate
v-model="params.originDated"
:label="t('params.originDated')"
v-model="params.originScopeDays"
:label="t('params.originScopeDays')"
is-outlined
/>
</QItemSection>
@ -89,8 +89,8 @@ onMounted(async () => {
<QItem class="q-my-sm">
<QItemSection>
<VnInputDate
v-model="params.futureDated"
:label="t('params.futureDated')"
v-model="params.futureScopeDays"
:label="t('params.futureScopeDays')"
is-outlined
/>
</QItemSection>
@ -214,8 +214,8 @@ onMounted(async () => {
en:
iptInfo: IPT
params:
originDated: Origin date
futureDated: Destination date
originScopeDays: Origin date
futureScopeDays: Destination date
futureIpt: Destination IPT
ipt: Origin IPT
warehouseFk: Warehouse
@ -229,8 +229,8 @@ es:
Vertical: Vertical
iptInfo: Encajado
params:
originDated: Fecha origen
futureDated: Fecha destino
originScopeDays: Fecha origen
futureScopeDays: Fecha destino
futureIpt: IPT destino
ipt: IPT Origen
warehouseFk: Almacén

View File

@ -105,7 +105,7 @@ advanceTickets:
futureLines: Líneas
futureImport: Importe
advanceTickets: Adelantar tickets con negativos
advanceTicketTitle: Advance tickets
advanceTicketTitle: Adelantar tickets
advanceTitleSubtitle: '¿Desea adelantar {selectedTickets} tickets?'
noDeliveryZone: No hay una zona de reparto disponible para la fecha de envío seleccionada
moveTicketSuccess: 'Tickets movidos correctamente {ticketsNumber}'

View File

@ -8,7 +8,7 @@ import VnConfirm from 'components/ui/VnConfirm.vue';
import axios from 'axios';
import useNotify from 'src/composables/useNotify.js';
import { useRole } from 'src/composables/useRole';
import { useAcl } from 'src/composables/useAcl';
const $props = defineProps({
travel: {
@ -21,7 +21,6 @@ const { t } = useI18n();
const router = useRouter();
const quasar = useQuasar();
const { notify } = useNotify();
const role = useRole();
const redirectToCreateView = (queryParams) => {
router.push({ name: 'TravelCreate', query: { travelData: queryParams } });
@ -42,9 +41,7 @@ const cloneTravelWithEntries = async () => {
}
};
const isBuyer = computed(() => {
return role.hasAny(['buyer']);
});
const canDelete = computed(() => useAcl().hasAny('Travel','*','WRITE'));
const openDeleteEntryDialog = (id) => {
quasar
@ -81,7 +78,7 @@ const deleteTravel = async (id) => {
</QItemSection>
</QItem>
<QItem
v-if="isBuyer && travel.totalEntries === 0"
v-if="canDelete && travel.totalEntries === 0"
v-ripple
clickable
@click="openDeleteEntryDialog(travel.id)"

View File

@ -3,13 +3,13 @@ import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import { useRole } from 'src/composables/useRole';
import { useAcl } from 'src/composables/useAcl';
import FormModel from 'components/FormModel.vue';
import VnSelect from 'src/components/common/VnSelect.vue';
import { useArrayData } from 'src/composables/useArrayData';
import FetchData from 'components/FetchData.vue';
const { hasAny } = useRole();
const { hasAny } = useAcl();
const { t } = useI18n();
const fetchData = ref();
const originaLockerId = ref();
@ -57,7 +57,11 @@ const init = async (data) => {
option-label="code"
option-value="id"
hide-selected
:readonly="!hasAny(['productionBoss', 'hr'])"
:readonly="
!hasAny([
{ model: 'Worker', props: '__get__locker', accessType: 'READ' },
])
"
/>
</template>
</FormModel>

View File

@ -13,6 +13,7 @@ import WorkerTimeControlCalendar from 'pages/Worker/Card/WorkerTimeControlCalend
import useNotify from 'src/composables/useNotify.js';
import axios from 'axios';
import { useRole } from 'src/composables/useRole';
import { useAcl } from 'src/composables/useAcl';
import { useWeekdayStore } from 'src/stores/useWeekdayStore';
import { useStateStore } from 'stores/useStateStore';
import { useState } from 'src/composables/useState';
@ -26,7 +27,6 @@ import { date } from 'quasar';
const route = useRoute();
const { t, locale } = useI18n();
const { notify } = useNotify();
const { hasAny } = useRole();
const _state = useState();
const user = _state.getUser();
const stateStore = useStateStore();
@ -66,9 +66,11 @@ const arrayData = useArrayData('workerData');
const worker = computed(() => arrayData.store?.data);
const isHr = computed(() => hasAny(['hr']));
const isHr = computed(() => useRole().hasAny(['hr']));
const isHimSelf = computed(() => user.value.id === Number(route.params.id));
const canSend = computed(() => useAcl().hasAny('WorkerTimeControl', 'sendMail', 'WRITE'));
const isHimself = computed(() => user.value.id === Number(route.params.id));
const columns = computed(() => {
return weekdayStore.getLocales?.map((day, index) => {
@ -447,7 +449,7 @@ onMounted(async () => {
<div>
<QBtnGroup push class="q-gutter-x-sm" flat>
<QBtn
v-if="isHimSelf && state"
v-if="isHimself && state"
:label="t('Satisfied')"
color="primary"
type="submit"
@ -455,7 +457,7 @@ onMounted(async () => {
@click="isSatisfied()"
/>
<QBtn
v-if="isHimSelf && state"
v-if="isHimself && state"
:label="t('Not satisfied')"
color="primary"
type="submit"
@ -466,14 +468,14 @@ onMounted(async () => {
</QBtnGroup>
<QBtnGroup push class="q-gutter-x-sm q-ml-none" flat>
<QBtn
v-if="reason && state && (isHimSelf || isHr)"
v-if="reason && state && (isHimself || isHr)"
:label="t('Reason')"
color="primary"
type="submit"
@click="showReasonForm()"
/>
<QBtn
v-if="isHr && state !== 'CONFIRMED' && canResend"
v-if="canSend && state !== 'CONFIRMED' && canResend"
:label="state ? t('Resend') : t('globals.send')"
color="primary"
type="submit"
@ -603,7 +605,7 @@ onMounted(async () => {
<WorkerTimeReasonForm
@on-submit="isUnsatisfied($event)"
:reason="reason"
:is-him-self="isHimSelf"
:is-himself="isHimself"
/>
</QDialog>
</QPage>

View File

@ -9,7 +9,7 @@ const $props = defineProps({
type: String,
default: '',
},
isHimSelf: {
isHimself: {
type: Boolean,
default: false,
},
@ -40,7 +40,7 @@ const closeForm = () => {
v-model="reasonFormData"
type="textarea"
autogrow
:disable="!isHimSelf"
:disable="!isHimself"
/>
</template>
</FormPopup>

View File

@ -262,7 +262,7 @@ async function autofillBic(worker) {
</VnRow>
<VnRow>
<VnLocation
:roles-allowed-to-create="['deliveryAssistant']"
:acls="[{ model: 'Town', props: '*', accessType: 'WRITE' }]"
:options="postcodesOptions"
v-model="data.location"
@update:model-value="(location) => handleLocation(data, location)"
@ -311,7 +311,7 @@ async function autofillBic(worker) {
option-label="name"
option-value="id"
hide-selected
:roles-allowed-to-create="['salesAssistant', 'hr']"
:acls="[{ model: 'BankEntity', props: '*', accessType: 'WRITE' }]"
:disable="data.isFreelance"
@update:model-value="autofillBic(data)"
:filter-options="['bic', 'name']"

View File

@ -60,15 +60,12 @@ export default route(function (/* { store, ssrContext } */) {
await useTokenConfig().fetch();
}
const matches = to.matched;
const hasRequiredRoles = matches.every((route) => {
const hasRequiredAcls = matches.every((route) => {
const meta = route.meta;
if (meta && meta.roles) return useRole().hasAny(meta.roles);
return true;
if (!meta?.acls) return true;
return useAcl().hasAny(meta.acls);
});
if (!hasRequiredRoles) {
return next({ path: '/' });
}
if (!hasRequiredAcls) return next({ path: '/' });
}
next();

View File

@ -80,7 +80,7 @@ export default {
meta: {
title: 'accounts',
icon: 'accessibility',
roles: ['itManagement'],
acls: [{ model: 'Account', props: '*', accessType: '*' }],
},
component: () => import('src/pages/Account/AccountAccounts.vue'),
},
@ -90,7 +90,7 @@ export default {
meta: {
title: 'ldap',
icon: 'account_tree',
roles: ['itManagement'],
acls: [{ model: 'LdapConfig', props: '*', accessType: '*' }],
},
component: () => import('src/pages/Account/AccountLdap.vue'),
},
@ -100,7 +100,7 @@ export default {
meta: {
title: 'samba',
icon: 'preview',
roles: ['itManagement'],
acls: [{ model: 'SambaConfig', props: '*', accessType: '*' }],
},
component: () => import('src/pages/Account/AccountSamba.vue'),
},

View File

@ -62,7 +62,7 @@ export default {
meta: {
title: 'basicData',
icon: 'vn:settings',
roles: ['salesPerson'],
acls: [{ model: 'Claim', props: 'findById', accessType: 'READ' }],
},
component: () => import('src/pages/Claim/Card/ClaimBasicData.vue'),
},
@ -99,7 +99,13 @@ export default {
meta: {
title: 'development',
icon: 'vn:traceability',
roles: ['claimManager'],
acls: [
{
model: 'ClaimDevelopment',
props: '*',
accessType: 'WRITE',
},
],
},
component: () => import('src/pages/Claim/Card/ClaimDevelopment.vue'),
},

View File

@ -84,7 +84,6 @@ export default {
meta: {
title: 'basicData',
icon: 'vn:settings',
roles: ['salesPerson'],
},
component: () =>
import('src/pages/InvoiceIn/Card/InvoiceInBasicData.vue'),

View File

@ -76,7 +76,6 @@ export default {
meta: {
title: 'basicData',
icon: 'vn:settings',
roles: ['salesPerson'],
},
component: () => import('pages/Shelving/Card/ShelvingForm.vue'),
},

View File

@ -54,7 +54,6 @@ export default {
meta: {
title: 'createTicket',
icon: 'vn:ticketAdd',
roles: ['developer'],
},
component: () => import('src/pages/Ticket/TicketCreate.vue'),
},

View File

@ -2,7 +2,7 @@ import axios from 'axios';
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { toLowerCamel } from 'src/filters';
import { useRole } from 'src/composables/useRole';
import { useAcl } from 'src/composables/useAcl';
import routes from 'src/router/modules';
export const useNavigationStore = defineStore('navigationStore', () => {
@ -26,7 +26,7 @@ export const useNavigationStore = defineStore('navigationStore', () => {
'zone',
];
const pinnedModules = ref([]);
const role = useRole();
const acl = useAcl();
function getModules() {
const modulesRoutes = ref([]);
@ -64,7 +64,7 @@ export const useNavigationStore = defineStore('navigationStore', () => {
title: `globals.pageTitles.${title}`,
}));
if (meta && meta.roles && role.hasAny(meta.roles) === false) return;
if (meta && meta.acls && acl.hasAny(meta.acls) === false) return;
const item = {
name: route.name,

View File

@ -52,9 +52,9 @@ describe('Login', () => {
cy.url().should('contain', '/login');
});
it(`should get redirected to dashboard since employee can't create tickets`, () => {
cy.visit('/#/ticket/create', { failOnStatusCode: false });
cy.url().should('contain', '/#/login?redirect=/ticket/create');
it(`should be redirected to dashboard since the employee is not enabled to see ldap`, () => {
cy.visit('/#/account/ldap', { failOnStatusCode: false });
cy.url().should('contain', '/#/login?redirect=/account/ldap');
cy.get('input[aria-label="Username"]').type('employee');
cy.get('input[aria-label="Password"]').type('nightmare');
cy.get('button[type="submit"]').click();

View File

@ -1,19 +1,18 @@
/// <reference types="cypress" />
describe('Ticket descriptor', () => {
const toCloneOpt = '[role="menu"] .q-list > :nth-child(5)';
const setWeightOpt = '[role="menu"] .q-list > :nth-child(6)';
const warehouseValue = ':nth-child(1) > :nth-child(6) > .value > span';
const summaryHeader = '.summaryHeader > div';
const weight = 25;
const weightValue = ':nth-child(10) > .value > span';
beforeEach(() => {
const ticketId = 1;
cy.login('developer');
cy.visit(`/#/ticket/${ticketId}/summary`);
cy.waitForElement('.q-page', 7000);
cy.viewport(1920, 1080);
});
it('should clone the ticket without warehouse', () => {
cy.openLeftMenu();
cy.visit('/#/ticket/1/summary');
cy.openActionsDescriptor();
cy.get(toCloneOpt).click();
cy.clickConfirm();
@ -25,4 +24,15 @@ describe('Ticket descriptor', () => {
cy.wrap(owner.trim()).should('eq', 'Bruce Wayne (1101)');
});
});
it('should set the weight of the ticket', () => {
cy.visit('/#/ticket/10/summary');
cy.openActionsDescriptor();
cy.get(setWeightOpt).click();
cy.intercept('POST', /\/api\/Tickets\/\d+\/setWeight/).as('weight');
cy.get('.q-dialog input').type(weight);
cy.clickConfirm();
cy.wait('@weight');
cy.get(weightValue).contains(weight);
});
});

View File

@ -48,40 +48,62 @@ describe('useAcl', () => {
describe('hasAny', () => {
it('should return false if no roles matched', async () => {
expect(acl.hasAny('Worker', 'updateAttributes', 'WRITE')).toBeFalsy();
expect(
acl.hasAny([
{ model: 'Worker', props: 'updateAttributes', accessType: 'WRITE' },
])
).toBeFalsy();
});
it('should return false if no roles matched', async () => {
expect(acl.hasAny('Worker', 'holidays', 'READ')).toBeTruthy();
expect(
acl.hasAny([{ model: 'Worker', props: 'holidays', accessType: 'READ' }])
).toBeTruthy();
});
describe('*', () => {
it('should return true if an acl matched', async () => {
expect(acl.hasAny('Address', '*', 'WRITE')).toBeTruthy();
expect(
acl.hasAny([{ model: 'Address', props: '*', accessType: 'WRITE' }])
).toBeTruthy();
});
it('should return false if no acls matched', async () => {
expect(acl.hasAny('Worker', '*', 'READ')).toBeFalsy();
expect(
acl.hasAny([{ model: 'Worker', props: '*', accessType: 'READ' }])
).toBeFalsy();
});
});
describe('$authenticated', () => {
it('should return false if no acls matched', async () => {
expect(acl.hasAny('Url', 'getByUser', '*')).toBeFalsy();
expect(
acl.hasAny([{ model: 'Url', props: 'getByUser', accessType: '*' }])
).toBeFalsy();
});
it('should return true if an acl matched', async () => {
expect(acl.hasAny('Url', 'getByUser', 'READ')).toBeTruthy();
expect(
acl.hasAny([{ model: 'Url', props: 'getByUser', accessType: 'READ' }])
).toBeTruthy();
});
});
describe('$everyone', () => {
it('should return false if no acls matched', async () => {
expect(acl.hasAny('TpvTransaction', 'start', 'READ')).toBeFalsy();
expect(
acl.hasAny([
{ model: 'TpvTransaction', props: 'start', accessType: 'READ' },
])
).toBeFalsy();
});
it('should return false if an acl matched', async () => {
expect(acl.hasAny('TpvTransaction', 'start', 'WRITE')).toBeTruthy();
expect(
acl.hasAny([
{ model: 'TpvTransaction', props: 'start', accessType: 'WRITE' },
])
).toBeTruthy();
});
});
});