diff --git a/.eslintrc.js b/.eslintrc.js index 09dc09c1e..c8bdecb1a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -64,7 +64,7 @@ module.exports = { }, overrides: [ { - files: ['test/cypress/**/*.spec.{js,ts}'], + files: ['test/cypress/**/*.*'], extends: [ // Add Cypress-specific lint rules, globals and Cypress plugin // See https://github.com/cypress-io/eslint-plugin-cypress#rules diff --git a/cypress.config.js b/cypress.config.js index 31aad6a86..2b5b40d08 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -7,7 +7,7 @@ module.exports = defineConfig({ screenshotsFolder: 'test/cypress/screenshots', supportFile: 'test/cypress/support/index.js', videosFolder: 'test/cypress/videos', - video: true, + video: false, specPattern: 'test/cypress/integration/*.spec.js', experimentalRunAllSpecs: true, component: { diff --git a/src/components/CrudModel.vue b/src/components/CrudModel.vue new file mode 100644 index 000000000..ed869a565 --- /dev/null +++ b/src/components/CrudModel.vue @@ -0,0 +1,322 @@ +<script setup> +import axios from 'axios'; +import { computed, ref, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useQuasar } from 'quasar'; +import { useValidator } from 'src/composables/useValidator'; +import { useStateStore } from 'stores/useStateStore'; +import VnPaginate from 'components/ui/VnPaginate.vue'; +import VnConfirm from 'components/ui/VnConfirm.vue'; +import SkeletonTable from 'components/ui/SkeletonTable.vue'; + +const quasar = useQuasar(); +const stateStore = useStateStore(); +const { t } = useI18n(); +const { validate } = useValidator(); + +const $props = defineProps({ + model: { + type: String, + default: '', + }, + url: { + type: String, + default: '', + }, + saveUrl: { + type: String, + default: null, + }, + primaryKey: { + type: String, + default: 'id', + }, + dataRequired: { + type: Object, + default: () => {}, + }, + defaultSave: { + type: Boolean, + default: true, + }, + defaultReset: { + type: Boolean, + default: true, + }, + defaultRemove: { + type: Boolean, + default: true, + }, + selected: { + type: Object, + default: null, + }, + saveFn: { + type: Function, + default: null, + }, +}); + +const isLoading = ref(false); +const hasChanges = ref(false); +const originalData = ref(); +const vnPaginateRef = ref(); +const formData = ref(); +const formUrl = computed(() => $props.url); + +const emit = defineEmits(['onFetch', 'update:selected']); + +defineExpose({ + reload, + insert, + remove, + onSubmit, + reset, + hasChanges, +}); + +function tMobile(...args) { + if (!quasar.platform.is.mobile) return t(...args); +} + +async function fetch(data) { + if (data && Array.isArray(data)) { + let $index = 0; + data.map((d) => (d.$index = $index++)); + } + + originalData.value = data && JSON.parse(JSON.stringify(data)); + formData.value = data && JSON.parse(JSON.stringify(data)); + watch(formData, () => (hasChanges.value = true), { deep: true }); + + emit('onFetch', data); +} + +async function reset() { + await fetch(originalData.value); + hasChanges.value = false; +} +// eslint-disable-next-line vue/no-dupe-keys +function filter(value, update, filterOptions) { + update( + () => { + const { options, filterFn, field } = filterOptions; + + options.value = filterFn(options, value, field); + }, + (ref) => { + ref.setOptionIndex(-1); + ref.moveOptionSelection(1, true); + } + ); +} + +async function onSubmit() { + if (!hasChanges.value) { + return quasar.notify({ + type: 'negative', + message: t('globals.noChanges'), + }); + } + isLoading.value = true; + await saveChanges(); +} + +async function saveChanges(data) { + if ($props.saveFn) return $props.saveFn(data, getChanges); + const changes = data || getChanges(); + try { + await axios.post($props.saveUrl || $props.url + '/crud', changes); + } catch (e) { + return (isLoading.value = false); + } + originalData.value = JSON.parse(JSON.stringify(formData.value)); + if (changes.creates?.length) await vnPaginateRef.value.fetch(); + + hasChanges.value = false; + isLoading.value = false; +} + +async function insert() { + const $index = formData.value.length + ? formData.value[formData.value.length - 1].$index + 1 + : 0; + formData.value.push(Object.assign({ $index }, $props.dataRequired)); + hasChanges.value = true; +} + +async function remove(data) { + if (!data.length) + return quasar.notify({ + type: 'warning', + message: t('globals.noChanges'), + }); + + const pk = $props.primaryKey; + let ids = data.map((d) => d[pk]).filter(Boolean); + let preRemove = data.map((d) => (d[pk] ? null : d.$index)).filter(Boolean); + let newData = formData.value; + + if (preRemove.length) { + newData = newData.filter( + (form) => !preRemove.some((index) => index == form.$index) + ); + const changes = getChanges(); + if (!changes.creates?.length && !changes.updates?.length) + hasChanges.value = false; + fetch(newData); + } + if (ids.length) { + quasar + .dialog({ + component: VnConfirm, + componentProps: { + title: t('confirmDeletion'), + message: t('confirmDeletionMessage'), + newData, + ids, + }, + }) + .onOk(async () => { + await saveChanges({ deletes: ids }); + newData = newData.filter((form) => !ids.some((id) => id == form[pk])); + fetch(newData); + }); + } + emit('update:selected', []); +} + +function getChanges() { + const updates = []; + const creates = []; + + const pk = $props.primaryKey; + + for (const [i, row] of formData.value.entries()) { + if (!row[pk]) { + creates.push(row); + } else if (originalData.value) { + const data = getDifferences(originalData.value[i], row); + if (!isEmpty(data)) { + updates.push({ + data, + where: { [pk]: row[pk] }, + }); + } + } + } + const changes = { updates, creates }; + + for (let prop in changes) { + if (changes[prop].length === 0) changes[prop] = undefined; + } + + return changes; +} + +function getDifferences(obj1, obj2) { + let diff = {}; + delete obj1.$index; + delete obj2.$index; + + for (let key in obj1) { + if (obj2[key] && obj1[key] !== obj2[key]) { + diff[key] = obj2[key]; + } + } + for (let key in obj2) { + if (obj1[key] === undefined || obj1[key] !== obj2[key]) { + diff[key] = obj2[key]; + } + } + return diff; +} + +function isEmpty(obj) { + if (obj == null) return true; + if (obj === undefined) return true; + if (Object.keys(obj).length === 0) return true; + + if (obj.length > 0) return false; +} + +async function reload() { + vnPaginateRef.value.fetch(); +} + +watch(formUrl, async () => { + originalData.value = null; + reset(); +}); +</script> +<template> + <VnPaginate + :url="url" + v-bind="$attrs" + @on-fetch="fetch" + :skeleton="false" + ref="vnPaginateRef" + > + <template #body v-if="formData"> + <slot + name="body" + :rows="formData" + :validate="validate" + :filter="filter" + ></slot> + </template> + </VnPaginate> + <SkeletonTable v-if="!formData" /> + <Teleport to="#st-actions" v-if="stateStore?.isSubToolbarShown()"> + <QBtnGroup push class="q-gutter-x-sm"> + <slot name="moreActions" /> + <QBtn + :label="tMobile('globals.remove')" + color="primary" + icon="delete" + flat + @click="remove(selected)" + :disable="!selected?.length" + :title="t('globals.remove')" + v-if="$props.defaultRemove" + /> + <QBtn + :label="tMobile('globals.reset')" + color="primary" + icon="restart_alt" + flat + @click="reset" + :disable="!hasChanges" + :title="t('globals.reset')" + v-if="$props.defaultReset" + /> + <QBtn + :label="tMobile('globals.save')" + color="primary" + icon="save" + @click="onSubmit" + :disable="!hasChanges" + :title="t('globals.save')" + v-if="$props.defaultSave" + /> + </QBtnGroup> + </Teleport> + <QInnerLoading + :showing="isLoading" + :label="t && t('globals.pleaseWait')" + color="primary" + /> +</template> + +<i18n> + { + "en": { + "confirmDeletion": "Confirm deletion", + "confirmDeletionMessage": "Are you sure you want to delete this?" + }, + "es": { + "confirmDeletion": "Confirmar eliminación", + "confirmDeletionMessage": "Seguro que quieres eliminar?" + } + } +</i18n> diff --git a/src/components/FormModel.vue b/src/components/FormModel.vue index 9d0916a8e..540c37d01 100644 --- a/src/components/FormModel.vue +++ b/src/components/FormModel.vue @@ -4,12 +4,14 @@ import { onMounted, onUnmounted, computed, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useQuasar } from 'quasar'; import { useState } from 'src/composables/useState'; +import { useStateStore } from 'stores/useStateStore'; import { useValidator } from 'src/composables/useValidator'; import SkeletonForm from 'components/ui/SkeletonForm.vue'; const quasar = useQuasar(); -const { t } = useI18n(); const state = useState(); +const stateStore = useStateStore(); +const { t } = useI18n(); const { validate } = useValidator(); const $props = defineProps({ @@ -29,6 +31,10 @@ const $props = defineProps({ type: String, default: null, }, + defaultActions: { + type: Boolean, + default: true, + }, }); const emit = defineEmits(['onFetch']); @@ -45,17 +51,21 @@ onUnmounted(() => { const isLoading = ref(false); const hasChanges = ref(false); -const formData = computed(() => state.get($props.model)); const originalData = ref(); +const formData = computed(() => state.get($props.model)); const formUrl = computed(() => $props.url); +function tMobile(...args) { + if (!quasar.platform.is.mobile) return t(...args); +} + async function fetch() { const { data } = await axios.get($props.url, { params: { filter: $props.filter }, }); state.set($props.model, data); - originalData.value = Object.assign({}, data); + originalData.value = data && JSON.parse(JSON.stringify(data)); watch(formData.value, () => (hasChanges.value = true)); @@ -72,13 +82,18 @@ async function save() { isLoading.value = true; await axios.patch($props.urlUpdate || $props.url, formData.value); - originalData.value = formData.value; + originalData.value = JSON.parse(JSON.stringify(formData.value)); hasChanges.value = false; isLoading.value = false; } function reset() { state.set($props.model, originalData.value); + originalData.value = JSON.parse(JSON.stringify(originalData.value)); + + watch(formData.value, () => (hasChanges.value = true)); + + emit('onFetch', state.get($props.model)); hasChanges.value = false; } // eslint-disable-next-line vue/no-dupe-keys @@ -109,20 +124,31 @@ watch(formUrl, async () => { </QBanner> <QForm v-if="formData" @submit="save" @reset="reset" class="q-pa-md"> <slot name="form" :data="formData" :validate="validate" :filter="filter"></slot> - <div class="q-mt-lg"> - <slot name="actions"> - <QBtn :label="t('globals.save')" type="submit" color="primary" /> - <QBtn - :label="t('globals.reset')" - type="reset" - class="q-ml-sm" - color="primary" - flat - :disable="!hasChanges" - /> - </slot> - </div> </QForm> + <Teleport to="#st-actions" v-if="stateStore?.isSubToolbarShown()"> + <div v-if="$props.defaultActions"> + <QBtnGroup push class="q-gutter-x-sm"> + <slot name="moreActions" /> + <QBtn + :label="tMobile('globals.reset')" + color="primary" + icon="restart_alt" + flat + @click="reset" + :disable="!hasChanges" + :title="t('globals.reset')" + /> + <QBtn + :label="tMobile('globals.save')" + color="primary" + icon="save" + @click="save" + :disable="!hasChanges" + :title="t('globals.save')" + /> + </QBtnGroup> + </div> + </Teleport> <SkeletonForm v-if="!formData" /> <QInnerLoading :showing="isLoading" diff --git a/src/components/common/VnSelectFilter.vue b/src/components/common/VnSelectFilter.vue new file mode 100644 index 000000000..9ccb94e33 --- /dev/null +++ b/src/components/common/VnSelectFilter.vue @@ -0,0 +1,76 @@ +<script setup> +import { ref, toRefs, watch, computed } from 'vue'; +const emit = defineEmits(['update:modelValue', 'update:options']); + +const $props = defineProps({ + modelValue: { + type: [String, Number], + default: null, + }, + options: { + type: Array, + default: () => [], + }, + optionLabel: { + type: String, + default: '', + }, +}); +const { optionLabel, options } = toRefs($props); +const myOptions = ref([]); +const myOptionsOriginal = ref([]); +function setOptions(data) { + myOptions.value = JSON.parse(JSON.stringify(data)); + myOptionsOriginal.value = JSON.parse(JSON.stringify(data)); +} +setOptions(options.value); + +const filter = (val, options) => { + const search = val.toLowerCase(); + + if (val === '') return options; + return options.filter((row) => { + const id = row.id; + const name = row[$props.optionLabel].toLowerCase(); + + const idMatches = id == search; + const nameMatches = name.indexOf(search) > -1; + + return idMatches || nameMatches; + }); +}; + +const filterHandler = (val, update) => { + update(() => { + myOptions.value = filter(val, myOptionsOriginal.value); + }); +}; + +watch(options, (newValue) => { + setOptions(newValue); +}); + +const value = computed({ + get() { + return $props.modelValue; + }, + set(value) { + emit('update:modelValue', value); + }, +}); +</script> + +<template> + <QSelect + v-model="value" + :options="myOptions" + :option-label="optionLabel" + v-bind="$attrs" + emit-value + map-options + use-input + @filter="filterHandler" + clearable + clear-icon="close" + /> +</template> diff --git a/src/components/ui/CardDescriptor.vue b/src/components/ui/CardDescriptor.vue index 65a89ee28..f63b75de6 100644 --- a/src/components/ui/CardDescriptor.vue +++ b/src/components/ui/CardDescriptor.vue @@ -29,12 +29,14 @@ const $props = defineProps({ const slots = useSlots(); const { t } = useI18n(); +const entity = ref(); -onMounted(() => fetch()); +onMounted(async () => { + await fetch(); +}); const emit = defineEmits(['onFetch']); -const entity = ref(); async function fetch() { const params = {}; diff --git a/src/components/ui/SkeletonTable.vue b/src/components/ui/SkeletonTable.vue new file mode 100644 index 000000000..d58253f90 --- /dev/null +++ b/src/components/ui/SkeletonTable.vue @@ -0,0 +1,50 @@ +<template> + <div class="q-pa-md w"> + <div class="row q-gutter-md q-mb-md"> + <div class="col-1"> + <QSkeleton type="rect" square /> + </div> + <div class="col"> + <QSkeleton type="rect" square /> + </div> + <div class="col"> + <QSkeleton type="rect" square /> + </div> + <div class="col"> + <QSkeleton type="rect" square /> + </div> + <div class="col"> + <QSkeleton type="rect" square /> + </div> + <div class="col"> + <QSkeleton type="rect" square /> + </div> + </div> + + <div class="row q-gutter-md q-mb-md" v-for="n in 5" :key="n"> + <div class="col-1"> + <QSkeleton type="QInput" square /> + </div> + <div class="col"> + <QSkeleton type="QInput" square /> + </div> + <div class="col"> + <QSkeleton type="QInput" square /> + </div> + <div class="col"> + <QSkeleton type="QInput" square /> + </div> + <div class="col"> + <QSkeleton type="QInput" square /> + </div> + <div class="col"> + <QSkeleton type="QInput" square /> + </div> + </div> + </div> +</template> +<style lang="scss" scoped> +.w { + width: 80vw; +} +</style> diff --git a/src/components/ui/VnPaginate.vue b/src/components/ui/VnPaginate.vue index 434ebf232..2475f56e9 100644 --- a/src/components/ui/VnPaginate.vue +++ b/src/components/ui/VnPaginate.vue @@ -46,6 +46,10 @@ const props = defineProps({ type: Number, default: 500, }, + skeleton: { + type: Boolean, + default: true, + }, }); const emit = defineEmits(['onFetch', 'onPaginate']); @@ -144,7 +148,10 @@ async function onLoad(...params) { {{ t('No results found') }} </h5> </div> - <div v-if="props.autoLoad && !store.data" class="card-list q-gutter-y-md"> + <div + v-if="props.skeleton && props.autoLoad && !store.data" + class="card-list q-gutter-y-md" + > <QCard class="card" v-for="$index in $props.limit" :key="$index"> <QItem v-ripple class="q-pa-none items-start cursor-pointer q-hoverable"> <QItemSection class="q-pa-md"> @@ -164,7 +171,7 @@ async function onLoad(...params) { </QCard> </div> </div> - <QInfiniteScroll v-if="store.data" @load="onLoad" :offset="offset"> + <QInfiniteScroll v-if="store.data" @load="onLoad" :offset="offset" class="full-width"> <slot name="body" :rows="store.data"></slot> <div v-if="isLoading" class="info-row q-pa-md text-center"> <QSpinner color="orange" size="md" /> diff --git a/src/composables/useArrayData.js b/src/composables/useArrayData.js index c7808f9a8..4535cde0f 100644 --- a/src/composables/useArrayData.js +++ b/src/composables/useArrayData.js @@ -38,11 +38,11 @@ export function useArrayData(key, userOptions) { 'limit', 'skip', 'userParams', - 'userFilter' + 'userFilter', ]; if (typeof userOptions === 'object') { for (const option in userOptions) { - const isEmpty = userOptions[option] == null || userOptions[option] == '' + const isEmpty = userOptions[option] == null || userOptions[option] == ''; if (isEmpty || !allowedOptions.includes(option)) continue; if (Object.prototype.hasOwnProperty.call(store, option)) { @@ -73,7 +73,7 @@ export function useArrayData(key, userOptions) { Object.assign(params, store.userParams); - store.isLoading = true + store.isLoading = true; const response = await axios.get(store.url, { signal: canceller.signal, params, @@ -94,7 +94,7 @@ export function useArrayData(key, userOptions) { updateStateParams(); } - store.isLoading = false + store.isLoading = false; canceller = null; } @@ -153,8 +153,8 @@ export function useArrayData(key, userOptions) { }); } - const totalRows = computed(() => store.data && store.data.length || 0); - const isLoading = computed(() => store.isLoading || false) + const totalRows = computed(() => (store.data && store.data.length) || 0); + const isLoading = computed(() => store.isLoading || false); return { fetch, @@ -167,6 +167,6 @@ export function useArrayData(key, userOptions) { hasMoreData, totalRows, updateStateParams, - isLoading + isLoading, }; } diff --git a/src/composables/useValidator.js b/src/composables/useValidator.js index ef2dcbd90..bc48332a2 100644 --- a/src/composables/useValidator.js +++ b/src/composables/useValidator.js @@ -3,15 +3,13 @@ import { useI18n } from 'vue-i18n'; import axios from 'axios'; import validator from 'validator'; - const models = ref(null); export function useValidator() { if (!models.value) fetch(); function fetch() { - axios.get('Schemas/ModelInfo') - .then(response => models.value = response.data) + axios.get('Schemas/ModelInfo').then((response) => (models.value = response.data)); } function validate(propertyRule) { @@ -38,19 +36,18 @@ export function useValidator() { const { t } = useI18n(); const validations = function (validation) { - return { presence: (value) => { let message = `Value can't be empty`; if (validation.message) - message = t(validation.message) || validation.message + message = t(validation.message) || validation.message; - return !validator.isEmpty(value ? String(value) : '') || message + return !validator.isEmpty(value ? String(value) : '') || message; }, length: (value) => { const options = { min: validation.min || validation.is, - max: validation.max || validation.is + max: validation.max || validation.is, }; value = String(value); @@ -69,14 +66,14 @@ export function useValidator() { }, numericality: (value) => { if (validation.int) - return validator.isInt(value) || 'Value should be integer' - return validator.isNumeric(value) || 'Value should be a number' + return validator.isInt(value) || 'Value should be integer'; + return validator.isNumeric(value) || 'Value should be a number'; }, - custom: (value) => validation.bindedFunction(value) || 'Invalid value' + custom: (value) => validation.bindedFunction(value) || 'Invalid value', }; }; return { - validate + validate, }; -} \ No newline at end of file +} diff --git a/src/css/app.scss b/src/css/app.scss index 3c8cc50b6..0f04c9ad8 100644 --- a/src/css/app.scss +++ b/src/css/app.scss @@ -32,10 +32,16 @@ body.body--light { --vn-text: #000000; --vn-gray: #f5f5f5; --vn-label: #5f5f5f; + --vn-dark: white; } body.body--dark { --vn-text: #ffffff; --vn-gray: #313131; --vn-label: #a8a8a8; + --vn-dark: #292929; +} + +.bg-vn-dark { + background-color: var(--vn-dark); } diff --git a/src/i18n/en/index.js b/src/i18n/en/index.js index 62704bf8c..cfd20716b 100644 --- a/src/i18n/en/index.js +++ b/src/i18n/en/index.js @@ -266,6 +266,7 @@ export default { lines: 'Lines', rma: 'RMA', photos: 'Photos', + development: 'Development', log: 'Audit logs', notes: 'Notes', }, diff --git a/src/i18n/es/index.js b/src/i18n/es/index.js index 1cef961db..532c1bb3b 100644 --- a/src/i18n/es/index.js +++ b/src/i18n/es/index.js @@ -264,6 +264,7 @@ export default { basicData: 'Datos básicos', lines: 'Líneas', rma: 'RMA', + development: 'Trazabilidad', photos: 'Fotos', log: 'Registros de auditoría', notes: 'Notas', diff --git a/src/pages/Claim/Card/ClaimCard.vue b/src/pages/Claim/Card/ClaimCard.vue index 9f1ecc416..03b9889f0 100644 --- a/src/pages/Claim/Card/ClaimCard.vue +++ b/src/pages/Claim/Card/ClaimCard.vue @@ -44,17 +44,6 @@ onMounted(async () => { <LeftMenu source="card" /> <QSeparator /> <QList> - <QItem - active-class="text-primary" - clickable - v-ripple - :href="`${salixUrl}/development`" - > - <QItemSection avatar> - <QIcon name="vn:traceability"></QIcon> - </QItemSection> - <QItemSection>{{ t('Development') }}</QItemSection> - </QItem> <QItem active-class="text-primary" clickable @@ -68,8 +57,13 @@ onMounted(async () => { </QScrollArea> </QDrawer> <QPageContainer> - <QPage class="q-pa-md"> - <RouterView></RouterView> + <QPage> + <QToolbar class="bg-vn-dark justify-end"> + <div id="st-data"></div> + <QSpace /> + <div id="st-actions"></div> + </QToolbar> + <div class="q-pa-md"><RouterView></RouterView></div> </QPage> </QPageContainer> </template> @@ -80,6 +74,5 @@ es: You can search by claim id or customer name: Puedes buscar por id de la reclamación o nombre del cliente Details: Detalles Notes: Notas - Development: Trazabilidad Action: Acción </i18n> diff --git a/src/pages/Claim/Card/ClaimDescriptor.vue b/src/pages/Claim/Card/ClaimDescriptor.vue index 914de2eb2..af7e84d38 100644 --- a/src/pages/Claim/Card/ClaimDescriptor.vue +++ b/src/pages/Claim/Card/ClaimDescriptor.vue @@ -3,6 +3,8 @@ import { ref, computed } from 'vue'; import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; import { toDate } from 'src/filters'; +import { useState } from 'src/composables/useState'; + import TicketDescriptorProxy from 'pages/Ticket/Card/TicketDescriptorProxy.vue'; import WorkerDescriptorProxy from 'src/pages/Worker/Card/WorkerDescriptorProxy.vue'; import ClaimDescriptorMenu from 'pages/Claim/Card/ClaimDescriptorMenu.vue'; @@ -19,6 +21,7 @@ const $props = defineProps({ }); const route = useRoute(); +const state = useState(); const { t } = useI18n(); const entityId = computed(() => { @@ -67,6 +70,7 @@ function stateColor(code) { const data = ref(useCardDescription()); const setData = (entity) => { data.value = useCardDescription(entity.client.name, entity.id); + state.set('ClaimDescriptor', entity); }; </script> diff --git a/src/pages/Claim/Card/ClaimDevelopment.vue b/src/pages/Claim/Card/ClaimDevelopment.vue new file mode 100644 index 000000000..ea4b178b5 --- /dev/null +++ b/src/pages/Claim/Card/ClaimDevelopment.vue @@ -0,0 +1,203 @@ +<script setup> +import { ref, computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useRoute } from 'vue-router'; +import CrudModel from 'components/CrudModel.vue'; +import FetchData from 'components/FetchData.vue'; +import VnSelectFilter from 'components/common/VnSelectFilter.vue'; + +const route = useRoute(); +const { t } = useI18n(); + +const claimDevelopmentForm = ref(); +const claimReasons = ref([]); +const claimResults = ref([]); +const claimResponsibles = ref([]); +const claimRedeliveries = ref([]); +const workers = ref([]); +const selected = ref([]); + +const developmentsFilter = { + fields: [ + 'id', + 'claimFk', + 'claimReasonFk', + 'claimResultFk', + 'claimResponsibleFk', + 'workerFk', + 'claimRedeliveryFk', + ], + where: { + claimFk: route.params.id, + }, +}; + +const columns = computed(() => [ + { + name: 'claimReason', + label: t('Reason'), + field: (row) => row.claimReasonFk, + sortable: true, + options: claimReasons.value, + required: true, + model: 'claimReasonFk', + optionValue: 'id', + optionLabel: 'description', + }, + { + name: 'claimResult', + label: t('Result'), + field: (row) => row.claimResultFk, + sortable: true, + options: claimResults.value, + required: true, + model: 'claimResultFk', + optionValue: 'id', + optionLabel: 'description', + }, + { + name: 'claimResponsible', + label: t('Responsible'), + field: (row) => row.claimResponsibleFk, + sortable: true, + options: claimResponsibles.value, + required: true, + model: 'claimResponsibleFk', + optionValue: 'id', + optionLabel: 'description', + }, + { + name: 'worker', + label: t('Worker'), + field: (row) => row.workerFk, + sortable: true, + options: workers.value, + model: 'workerFk', + optionValue: 'id', + optionLabel: 'nickname', + }, + { + name: 'claimRedelivery', + label: t('Redelivery'), + field: (row) => row.claimRedeliveryFk, + sortable: true, + options: claimRedeliveries.value, + required: true, + model: 'claimRedeliveryFk', + optionValue: 'id', + optionLabel: 'description', + }, +]); +</script> +<template> + <FetchData + url="ClaimReasons" + order="description" + @on-fetch="(data) => (claimReasons = data)" + auto-load + /> + <FetchData + url="ClaimResults" + order="description" + @on-fetch="(data) => (claimResults = data)" + auto-load + /> + <FetchData + url="ClaimResponsibles" + order="description" + @on-fetch="(data) => (claimResponsibles = data)" + auto-load + /> + <FetchData + url="ClaimRedeliveries" + order="description" + @on-fetch="(data) => (claimRedeliveries = data)" + auto-load + /> + <FetchData + url="Workers/activeWithInheritedRole" + :where="{ role: 'employee' }" + @on-fetch="(data) => (workers = data)" + auto-load + /> + <CrudModel + data-key="ClaimDevelopments" + url="ClaimDevelopments" + model="claimDevelopment" + :filter="developmentsFilter" + ref="claimDevelopmentForm" + :data-required="{ claimFk: route.params.id }" + v-model:selected="selected" + auto-load + > + <template #body="{ rows }"> + <QTable + :columns="columns" + :rows="rows" + :pagination="{ rowsPerPage: 0 }" + row-key="$index" + selection="multiple" + hide-pagination + v-model:selected="selected" + :grid="$q.screen.lt.md" + > + <template #body-cell="{ row, col }"> + <QTd auto-width> + <VnSelectFilter + :label="col.label" + v-model="row[col.model]" + :options="col.options" + :option-value="col.optionValue" + :option-label="col.optionLabel" + /> + </QTd> + </template> + <template #item="props"> + <div class="q-pa-xs col-xs-12 col-sm-6 grid-style-transition"> + <QCard bordered flat> + <QCardSection> + <QCheckbox v-model="props.selected" dense /> + </QCardSection> + <QSeparator /> + <QList dense> + <QItem v-for="col in props.cols" :key="col.name"> + <QItemSection> + <VnSelectFilter + :label="col.label" + v-model="props.row[col.model]" + :options="col.options" + :option-value="col.optionValue" + :option-label="col.optionLabel" + dense + /> + </QItemSection> + </QItem> + </QList> + </QCard> + </div> + </template> + </QTable> + </template> + </CrudModel> + <QPageSticky position="bottom-right" :offset="[25, 25]"> + <QBtn fab color="primary" icon="add" @click="claimDevelopmentForm.insert()" /> + </QPageSticky> +</template> + +<style lang="scss" scoped> +.grid-style-transition { + transition: transform 0.28s, background-color 0.28s; +} +.maxwidth { + width: 100%; +} +</style> + +<i18n> +es: + Reason: Motivo + Result: Consecuencia + Responsible: Responsable + Worker: Trabajador + Redelivery: Devolución +</i18n> diff --git a/src/pages/Claim/Card/ClaimLines.vue b/src/pages/Claim/Card/ClaimLines.vue index 9d2a12804..8680ff922 100644 --- a/src/pages/Claim/Card/ClaimLines.vue +++ b/src/pages/Claim/Card/ClaimLines.vue @@ -6,9 +6,8 @@ import { useQuasar } from 'quasar'; import { useRoute } from 'vue-router'; import { useArrayData } from 'composables/useArrayData'; import { useStateStore } from 'stores/useStateStore'; -import VnPaginate from 'components/ui/VnPaginate.vue'; +import CrudModel from 'components/CrudModel.vue'; import FetchData from 'components/FetchData.vue'; -import VnConfirm from 'components/ui/VnConfirm.vue'; import { toDate, toCurrency, toPercentage } from 'filters/index'; import VnDiscount from 'components/common/vnDiscount.vue'; @@ -17,6 +16,7 @@ import ClaimLinesImport from './ClaimLinesImport.vue'; const quasar = useQuasar(); const route = useRoute(); const { t } = useI18n(); + const stateStore = useStateStore(); const arrayData = useArrayData('ClaimLines'); const store = arrayData.store; @@ -36,6 +36,7 @@ const linesFilter = { }, }; +const claimLinesForm = ref(); const claim = ref(null); async function onFetchClaim(data) { claim.value = data; @@ -46,6 +47,7 @@ async function onFetchClaim(data) { const amount = ref(0); const amountClaimed = ref(0); async function onFetch(rows) { + if (!rows || rows.length) return; amount.value = rows.reduce( (acumulator, { sale }) => acumulator + sale.price * sale.quantity, 0 @@ -141,47 +143,6 @@ function onUpdateDiscount(response) { }); } -async function confirmRemove() { - const rows = selected.value; - const count = rows.length; - - if (count === 0) { - return quasar.notify({ - message: 'You must select at least one row', - type: 'warning', - }); - } - - quasar - .dialog({ - component: VnConfirm, - componentProps: { - title: t('Delete claimed sales'), - message: t('You are about to remove {count} rows', count, { count }), - data: { rows }, - promise: remove, - }, - }) - .onOk(() => { - for (const row of rows) { - const orgData = store.data; - const index = orgData.findIndex((item) => item.id === row.id); - store.data.splice(index, 1); - selected.value = []; - } - }); -} - -async function remove({ rows }) { - if (!rows.length) return; - const body = { deletes: rows.map((row) => row.id) }; - await axios.post(`ClaimBeginnings/crud`, body); - quasar.notify({ - type: 'positive', - message: t('globals.rowRemoved'), - }); -} - function showImportDialog() { quasar .dialog({ @@ -191,10 +152,8 @@ function showImportDialog() { } </script> <template> - <QPageSticky position="top" :offset="[0, 0]" expand> + <Teleport to="#st-data" v-if="stateStore.isSubToolbarShown()"> <QToolbar class="bg-dark text-white"> - <QToolbarTitle> {{ t('Claimed lines') }} </QToolbarTitle> - <QSpace /> <div class="row q-gutter-md"> <div> {{ t('Amount') }} @@ -211,7 +170,7 @@ function showImportDialog() { </div> </div> </QToolbar> - </QPageSticky> + </Teleport> <FetchData :url="`Claims/${route.params.id}`" @@ -221,11 +180,16 @@ function showImportDialog() { /> <div class="column items-center"> <div class="list"> - <VnPaginate + <CrudModel data-key="ClaimLines" + ref="claimLinesForm" :url="`Claims/${route.params.id}/lines`" + save-url="ClaimBeginnings/crud" :filter="linesFilter" @on-fetch="onFetch" + v-model:selected="selected" + :default-save="false" + :default-reset="false" auto-load > <template #body="{ rows }"> @@ -361,46 +325,12 @@ function showImportDialog() { </template> </QTable> </template> - </VnPaginate> + </CrudModel> </div> </div> - <Teleport - v-if="stateStore.isHeaderMounted() && !$q.screen.lt.sm" - to="#actions-prepend" - > - <div class="row q-gutter-x-sm"> - <QBtn - v-if="selected.length > 0" - @click="confirmRemove" - icon="delete" - color="primary" - flat - dense - rounded - > - <QTooltip bottom> {{ t('globals.remove') }} </QTooltip> - </QBtn> - <QBtn @click="showImportDialog" icon="add" color="primary" flat dense rounded> - <QTooltip bottom> {{ t('globals.add') }} </QTooltip> - </QBtn> - <QSeparator vertical /> - </div> - </Teleport> - <!-- v-if="quasar.platform.is.mobile" --> - <QPageSticky v-if="$q.screen.lt.sm" position="bottom" :offset="[0, 0]" expand> - <QToolbar class="bg-primary text-white q-pa-none"> - <QTabs class="full-width" align="justify" inline-label narrow-indicator> - <QTab @click="showImportDialog" icon="add" :label="t('globals.add')" /> - <QSeparator vertical inset /> - <QTab - @click="confirmRemove" - icon="delete" - :label="t('globals.remove')" - :disable="selected.length === 0" - /> - </QTabs> - </QToolbar> + <QPageSticky position="bottom-right" :offset="[25, 25]"> + <QBtn fab color="primary" icon="add" @click="showImportDialog()" /> </QPageSticky> </template> @@ -421,7 +351,6 @@ en: You are about to remove <strong>{count}</strong> row | You are about to remove <strong>{count}</strong> rows' es: - Claimed lines: Líneas reclamadas Delivered: Entregado Quantity: Cantidad Claimed: Reclamada diff --git a/src/pages/Claim/Card/ClaimNotes.vue b/src/pages/Claim/Card/ClaimNotes.vue index 94b53c8a2..84d350e38 100644 --- a/src/pages/Claim/Card/ClaimNotes.vue +++ b/src/pages/Claim/Card/ClaimNotes.vue @@ -25,7 +25,7 @@ const body = { }; </script> <template> - <div class="col items-center"> + <div class="column items-center"> <VnNotes :add-note="true" :id="id" diff --git a/src/pages/Claim/Card/ClaimPhoto.vue b/src/pages/Claim/Card/ClaimPhoto.vue index 839fbefc9..483dbffc1 100644 --- a/src/pages/Claim/Card/ClaimPhoto.vue +++ b/src/pages/Claim/Card/ClaimPhoto.vue @@ -4,7 +4,6 @@ import { ref, computed } from 'vue'; import { useQuasar } from 'quasar'; import { useRouter } from 'vue-router'; import { useI18n } from 'vue-i18n'; -import { useStateStore } from 'stores/useStateStore'; import { useSession } from 'composables/useSession'; import VnConfirm from 'components/ui/VnConfirm.vue'; import FetchData from 'components/FetchData.vue'; @@ -12,7 +11,6 @@ import FetchData from 'components/FetchData.vue'; const router = useRouter(); const quasar = useQuasar(); const { t } = useI18n(); -const stateStore = useStateStore(); const session = useSession(); const token = session.getToken(); @@ -237,59 +235,20 @@ function onDrag() { </div> </div> - <Teleport - v-if="stateStore.isHeaderMounted() && !quasar.platform.is.mobile" - to="#actions-prepend" - > - <div class="row q-gutter-x-sm"> - <label for="fileInput"> - <QBtn - @click="inputFile.nativeEl.click()" - icon="add" - color="primary" - dense - rounded - > - <QInput - ref="inputFile" - type="file" - style="display: none" - multiple - v-model="files" - @update:model-value="create()" - /> - <QTooltip bottom> {{ t('globals.add') }} </QTooltip> - </QBtn> - </label> - <QSeparator vertical /> - </div> - </Teleport> - - <QPageSticky - v-if="quasar.platform.is.mobile" - position="bottom" - :offset="[0, 0]" - expand - > - <QToolbar class="bg-primary text-white q-pa-none"> - <QTabs class="full-width" align="justify" inline-label narrow-indicator> - <QTab - @click="inputFile.nativeEl.click()" - icon="add_circle" - :label="t('globals.add')" - > - <QInput - ref="inputFile" - type="file" - style="display: none" - multiple - v-model="files" - @update:model-value="create()" - /> - <QTooltip bottom> {{ t('globals.add') }} </QTooltip> - </QTab> - </QTabs> - </QToolbar> + <QPageSticky position="bottom-right" :offset="[25, 25]"> + <label for="fileInput"> + <QBtn fab @click="inputFile.nativeEl.click()" icon="add" color="primary"> + <QInput + ref="inputFile" + type="file" + style="display: none" + multiple + v-model="files" + @update:model-value="create()" + /> + <QTooltip bottom> {{ t('globals.add') }} </QTooltip> + </QBtn> + </label> </QPageSticky> <!-- MULTIMEDIA DIALOG START--> diff --git a/src/pages/Claim/Card/ClaimRma.vue b/src/pages/Claim/Card/ClaimRma.vue index c59c11845..bba901d7b 100644 --- a/src/pages/Claim/Card/ClaimRma.vue +++ b/src/pages/Claim/Card/ClaimRma.vue @@ -1,48 +1,34 @@ <script setup> import axios from 'axios'; -import { ref } from 'vue'; +import { watch, ref, computed, onUnmounted, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'; import { useQuasar } from 'quasar'; -import { useRoute } from 'vue-router'; -import { useArrayData } from 'src/composables/useArrayData'; -import { useStateStore } from 'stores/useStateStore'; -import VnPaginate from 'src/components/ui/VnPaginate.vue'; -import FetchData from 'components/FetchData.vue'; -import VnConfirm from 'src/components/ui/VnConfirm.vue'; +import CrudModel from 'components/CrudModel.vue'; +import { useState } from 'src/composables/useState'; import { toDate } from 'src/filters'; const quasar = useQuasar(); -const route = useRoute(); +const state = useState(); const { t } = useI18n(); -const stateStore = useStateStore(); -const arrayData = useArrayData('ClaimRma'); +const selected = ref([]); +const claimRmaRef = ref(); +const claim = computed(() => state.get('ClaimDescriptor')); -const claim = ref(); -const claimFilter = { - fields: ['rma'], -}; - -async function onFetch(data) { - claim.value = data; - - const filter = { - include: { - relation: 'worker', - scope: { - include: { - relation: 'user', - }, +const claimRmaFilter = { + include: { + relation: 'worker', + scope: { + include: { + relation: 'user', }, }, - order: 'created DESC', - where: { - code: claim.value.rma, - }, - }; - - arrayData.applyFilter({ filter }); -} + }, + order: 'created DESC', + where: { + code: claim.value?.rma, + }, +}; async function addRow() { if (!claim.value.rma) { @@ -56,7 +42,7 @@ async function addRow() { }; await axios.post(`ClaimRmas`, formData); - await arrayData.refresh(); + await claimRmaRef.value.reload(); quasar.notify({ type: 'positive', @@ -65,38 +51,33 @@ async function addRow() { }); } -function confirmRemove(id) { - quasar - .dialog({ - component: VnConfirm, - componentProps: { - data: { id }, - promise: remove, - }, - }) - .onOk(async () => await arrayData.refresh()); -} - -async function remove({ id }) { - await axios.delete(`ClaimRmas/${id}`); - quasar.notify({ - type: 'positive', - message: t('globals.rowRemoved'), - }); -} +onMounted(() => { + if (claim.value) claimRmaRef.value.reload(); +}); +watch( + claim, + () => { + claimRmaRef.value.reload(); + }, + { deep: true } +); </script> <template> - <FetchData - :url="`Claims/${route.params.id}`" - :filter="claimFilter" - @on-fetch="onFetch" - auto-load - /> <div class="column items-center"> <div class="list"> - <VnPaginate data-key="ClaimRma" url="ClaimRmas"> + <CrudModel + data-key="ClaimRma" + url="ClaimRmas" + model="ClaimRma" + :filter="claimRmaFilter" + v-model:selected="selected" + ref="claimRmaRef" + :default-save="false" + :default-reset="false" + :default-remove="false" + > <template #body="{ rows }"> - <QCard class="card"> + <QCard> <template v-for="(row, index) of rows" :key="row.id"> <QItem class="q-pa-none items-start"> <QItemSection class="q-pa-md"> @@ -107,7 +88,7 @@ async function remove({ id }) { {{ t('claim.rma.user') }} </QItemLabel> <QItemLabel> - {{ row.worker.user.name }} + {{ row?.worker?.user?.name }} </QItemLabel> </QItemSection> </QItem> @@ -133,7 +114,7 @@ async function remove({ id }) { round color="orange" icon="vn:bin" - @click="confirmRemove(row.id)" + @click="claimRmaRef.remove([row])" > <QTooltip>{{ t('globals.remove') }}</QTooltip> </QBtn> @@ -143,33 +124,11 @@ async function remove({ id }) { </template> </QCard> </template> - </VnPaginate> + </CrudModel> </div> </div> - - <Teleport - v-if="stateStore.isHeaderMounted() && !quasar.platform.is.mobile" - to="#actions-prepend" - > - <div class="row q-gutter-x-sm"> - <QBtn @click="addRow()" icon="add" color="primary" dense rounded> - <QTooltip bottom> {{ t('globals.add') }} </QTooltip> - </QBtn> - <QSeparator vertical /> - </div> - </Teleport> - - <QPageSticky - v-if="quasar.platform.is.mobile" - position="bottom" - :offset="[0, 0]" - expand - > - <QToolbar class="bg-primary text-white q-pa-none"> - <QTabs class="full-width" align="justify" inline-label narrow-indicator> - <QTab @click="addRow()" icon="add_circle" :label="t('globals.add')" /> - </QTabs> - </QToolbar> + <QPageSticky position="bottom-right" :offset="[25, 25]"> + <QBtn fab color="primary" icon="add" @click="addRow()" /> </QPageSticky> </template> @@ -178,16 +137,6 @@ async function remove({ id }) { width: 100%; max-width: 60em; } -.q-toolbar { - background-color: $grey-9; -} -.sticky-page { - padding-top: 66px; -} - -.q-page-sticky { - z-index: 2998; -} </style> <i18n> diff --git a/src/pages/Customer/Card/CustomerCard.vue b/src/pages/Customer/Card/CustomerCard.vue index 3a1c28d18..c833d0cf9 100644 --- a/src/pages/Customer/Card/CustomerCard.vue +++ b/src/pages/Customer/Card/CustomerCard.vue @@ -1,11 +1,13 @@ <script setup> import { useI18n } from 'vue-i18n'; import { useStateStore } from 'stores/useStateStore'; +import { useRoute } from 'vue-router'; import CustomerDescriptor from './CustomerDescriptor.vue'; import LeftMenu from 'components/LeftMenu.vue'; import VnSearchbar from 'src/components/ui/VnSearchbar.vue'; const stateStore = useStateStore(); +const route = useRoute(); const { t } = useI18n(); </script> <template> @@ -25,8 +27,13 @@ const { t } = useI18n(); </QScrollArea> </QDrawer> <QPageContainer> - <QPage class="q-pa-md"> - <RouterView></RouterView> + <QPage> + <QToolbar class="bg-vn-dark justify-end"> + <div id="st-data"></div> + <QSpace /> + <div id="st-actions"></div> + </QToolbar> + <div class="q-pa-md"><RouterView></RouterView></div> </QPage> </QPageContainer> </template> diff --git a/src/pages/InvoiceOut/Card/InvoiceOutCard.vue b/src/pages/InvoiceOut/Card/InvoiceOutCard.vue index 5cbe72396..d24eb3ef2 100644 --- a/src/pages/InvoiceOut/Card/InvoiceOutCard.vue +++ b/src/pages/InvoiceOut/Card/InvoiceOutCard.vue @@ -25,8 +25,13 @@ const { t } = useI18n(); </QScrollArea> </QDrawer> <QPageContainer> - <QPage class="q-pa-md"> - <RouterView></RouterView> + <QPage> + <QToolbar class="bg-vn-dark justify-end"> + <div id="st-data"></div> + <QSpace /> + <div id="st-actions"></div> + </QToolbar> + <div class="q-pa-md"><RouterView></RouterView></div> </QPage> </QPageContainer> </template> diff --git a/src/pages/Ticket/Card/TicketCard.vue b/src/pages/Ticket/Card/TicketCard.vue index e0ad5054d..91921f827 100644 --- a/src/pages/Ticket/Card/TicketCard.vue +++ b/src/pages/Ticket/Card/TicketCard.vue @@ -25,8 +25,13 @@ const { t } = useI18n(); </QScrollArea> </QDrawer> <QPageContainer> - <QPage class="q-pa-md"> - <RouterView></RouterView> + <QPage> + <QToolbar class="bg-vn-dark justify-end"> + <div id="st-data"></div> + <QSpace /> + <div id="st-actions"></div> + </QToolbar> + <div class="q-pa-md"><RouterView></RouterView></div> </QPage> </QPageContainer> </template> diff --git a/src/pages/Wagon/WagonCreate.vue b/src/pages/Wagon/WagonCreate.vue index 3f7824975..123e01d36 100644 --- a/src/pages/Wagon/WagonCreate.vue +++ b/src/pages/Wagon/WagonCreate.vue @@ -20,7 +20,7 @@ const $props = defineProps({ }); const entityId = computed(() => $props.id || route.params.id); -let wagonTypes; +let wagonTypes = []; let originalData = {}; const wagon = ref({}); const filteredWagonTypes = ref(wagonTypes); diff --git a/src/pages/Worker/Card/WorkerCard.vue b/src/pages/Worker/Card/WorkerCard.vue index 3d6b46e11..972eb52ec 100644 --- a/src/pages/Worker/Card/WorkerCard.vue +++ b/src/pages/Worker/Card/WorkerCard.vue @@ -25,8 +25,13 @@ const { t } = useI18n(); </QScrollArea> </QDrawer> <QPageContainer> - <QPage class="q-pa-md"> - <RouterView></RouterView> + <QPage> + <QToolbar class="bg-vn-dark justify-end"> + <div id="st-data"></div> + <QSpace /> + <div id="st-actions"></div> + </QToolbar> + <div class="q-pa-md"><RouterView></RouterView></div> </QPage> </QPageContainer> </template> diff --git a/src/pages/Worker/Card/WorkerSummary.vue b/src/pages/Worker/Card/WorkerSummary.vue index 05ccdc373..7c8accc5d 100644 --- a/src/pages/Worker/Card/WorkerSummary.vue +++ b/src/pages/Worker/Card/WorkerSummary.vue @@ -1,6 +1,5 @@ <script setup> -import axios from 'axios'; -import { ref, onMounted, computed, onUpdated } from 'vue'; +import { ref, onMounted, computed } from 'vue'; import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; import CardSummary from 'components/ui/CardSummary.vue'; diff --git a/src/router/modules/claim.js b/src/router/modules/claim.js index 40aaef73b..9df1dd64e 100644 --- a/src/router/modules/claim.js +++ b/src/router/modules/claim.js @@ -18,6 +18,7 @@ export default { 'ClaimPhotos', 'ClaimLog', 'ClaimNotes', + 'ClaimDevelopment', ], }, children: [ @@ -101,6 +102,16 @@ export default { }, component: () => import('src/pages/Claim/Card/ClaimPhoto.vue'), }, + { + name: 'ClaimDevelopment', + path: 'development', + meta: { + title: 'development', + icon: 'vn:traceability', + roles: ['claimManager'], + }, + component: () => import('src/pages/Claim/Card/ClaimDevelopment.vue'), + }, { name: 'ClaimLog', path: 'log', diff --git a/src/stores/useStateStore.js b/src/stores/useStateStore.js index 8704c46e4..74b65e71f 100644 --- a/src/stores/useStateStore.js +++ b/src/stores/useStateStore.js @@ -30,6 +30,13 @@ export const useStateStore = defineStore('stateStore', () => { return rightDrawer.value; } + function isSubToolbarShown() { + return ( + !!document.querySelector('#st-data') && + !!document.querySelector('#st-actions') + ); + } + return { leftDrawer, rightDrawer, @@ -39,5 +46,6 @@ export const useStateStore = defineStore('stateStore', () => { toggleRightDrawer, isLeftDrawerShown, isRightDrawerShown, + isSubToolbarShown, }; }); diff --git a/test/cypress/integration/claimDevelopment.spec.js b/test/cypress/integration/claimDevelopment.spec.js new file mode 100755 index 000000000..77ce2bb45 --- /dev/null +++ b/test/cypress/integration/claimDevelopment.spec.js @@ -0,0 +1,53 @@ +/// <reference types="cypress" /> +describe('ClaimDevelopment', () => { + const claimId = 1; + const firstLineReason = 'tbody > :nth-child(1) > :nth-child(2)'; + const thirdRow = 'tbody > :nth-child(3)'; + + beforeEach(() => { + cy.viewport(1920, 1080); + cy.login('developer'); + cy.visit(`/#/claim/${claimId}/development`); + }); + + it('should reset line', () => { + cy.selectOption(firstLineReason, 'Novato'); + cy.resetCard(); + cy.getValue(firstLineReason).should('have.text', 'Prisas'); + }); + + it('should edit line', () => { + cy.selectOption(firstLineReason, 'Novato'); + cy.saveCard(); + + cy.reload(); + cy.getValue(firstLineReason).should('have.text', 'Novato'); + + //Restart data + cy.selectOption(firstLineReason, 'Prisas'); + cy.saveCard(); + }); + + it('should add and remove new line', () => { + //add row + cy.addCard(); + cy.get(thirdRow).should('exist'); + + const rowData = [false, 'Novato', 'Roces', 'Compradores', 'employeeNick', 'Tour']; + cy.fillRow(thirdRow, rowData); + cy.saveCard(); + cy.validateRow(thirdRow, rowData); + + cy.reload(); + cy.validateRow(thirdRow, rowData); + + //remove row + cy.fillRow(thirdRow, [true]); + cy.removeCard(); + cy.clickConfirm(); + cy.get(thirdRow).should('not.exist'); + + cy.reload(); + cy.get(thirdRow).should('not.exist'); + }); +}); diff --git a/test/cypress/support/commands.js b/test/cypress/support/commands.js index a3a61c423..2eb14e463 100755 --- a/test/cypress/support/commands.js +++ b/test/cypress/support/commands.js @@ -40,4 +40,91 @@ Cypress.Commands.add('login', (user) => { window.localStorage.setItem('token', response.body.token); }); }); + +Cypress.Commands.add('waitForElement', (element) => { + cy.get(element, { timeout: 2000 }).should('be.visible'); +}); + +Cypress.Commands.add('getValue', (selector) => { + cy.get(selector).then(($el) => { + if ($el.find('.q-checkbox__inner').length > 0) { + return cy.get(selector + '.q-checkbox__inner'); + } + // Si es un QSelect + else if ($el.find('.q-select__dropdown-icon').length) { + return cy.get( + selector + + '> .q-field > .q-field__inner > .q-field__control > .q-field__control-container > .q-field__native > span' + ); + } else { + // Puedes añadir un log o lanzar un error si el elemento no es reconocido + cy.log('Elemento no soportado'); + } + }); +}); + +// Fill Inputs +Cypress.Commands.add('selectOption', (selector, option) => { + cy.get(selector).find('.q-select__dropdown-icon').click(); + cy.get('.q-menu .q-item').contains(option).click(); +}); + +Cypress.Commands.add('checkOption', (selector) => { + cy.wrap(selector).find('.q-checkbox__inner').click(); +}); + +// Global buttons +Cypress.Commands.add('saveCard', () => { + cy.get('[title="Save"]').click(); + cy.get('[title="Save"]').should('have.class', 'disabled'); +}); +Cypress.Commands.add('resetCard', () => { + cy.get('[title="Reset"]').click(); +}); +Cypress.Commands.add('removeCard', () => { + cy.get('[title="Remove"]').click(); +}); +Cypress.Commands.add('addCard', () => { + cy.waitForElement('tbody'); + cy.get('.q-page-sticky > div > .q-btn').click(); +}); +Cypress.Commands.add('clickConfirm', () => { + cy.get('.q-btn--unelevated > .q-btn__content > .block').click(); +}); + +Cypress.Commands.add('fillRow', (rowSelector, data) => { + // Usar el selector proporcionado para obtener la fila deseada + cy.waitForElement('tbody'); + cy.get(rowSelector).as('currentRow'); + + data.forEach((value, index) => { + if (value === null) return; + cy.get('@currentRow') + .find('td') + .eq(index) + .then((td) => { + if (td.find('.q-select__dropdown-icon').length) { + cy.selectOption(td, value); + } + if (td.find('.q-checkbox__inner').length && value) { + cy.checkOption(td); + } + }); + }); +}); + +Cypress.Commands.add('validateRow', (rowSelector, expectedValues) => { + cy.waitForElement('tbody'); + cy.get(rowSelector).within(() => { + for (const [index, value] of expectedValues.entries()) { + cy.log('CHECKING ', index, value); + if (typeof value == 'boolean') { + const prefix = value ? '' : 'not.'; + cy.getValue(`:nth-child(${index + 1})`).should(`${prefix}be.checked`); + continue; + } + cy.getValue(`:nth-child(${index + 1})`).should('have.text', value); + } + }); +}); // registerCommands(); diff --git a/test/vitest/__tests__/components/common/CrudModel.spec.js b/test/vitest/__tests__/components/common/CrudModel.spec.js new file mode 100644 index 000000000..6ce93e59c --- /dev/null +++ b/test/vitest/__tests__/components/common/CrudModel.spec.js @@ -0,0 +1,120 @@ +import { createWrapper } from 'app/test/vitest/helper'; +import CrudModel from 'components/CrudModel.vue'; +import { vi, afterEach, beforeEach, beforeAll, describe, expect, it } from 'vitest'; + +describe('CrudModel', () => { + let vm; + beforeAll(() => { + vm = createWrapper(CrudModel, { + global: { + stubs: [ + 'vnPaginate', + 'useState', + 'arrayData', + 'useStateStore', + 'vue-i18n', + ], + mocks: { + validate: vi.fn(), + }, + }, + propsData: { + dataRequired: { + fk: 1, + }, + dataKey: 'crudModelKey', + model: 'crudModel', + url: 'crudModelUrl', + }, + }).vm; + }); + + beforeEach(() => { + vm.fetch([]); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('insert()', () => { + it('should new element in list with index 0 if formData not has data', () => { + vm.insert(); + + expect(vm.formData.length).toEqual(1); + expect(vm.formData[0].fk).toEqual(1); + expect(vm.formData[0].$index).toEqual(0); + }); + }); + + describe('getChanges()', () => { + it('should return correct updates and creates', async () => { + vm.fetch([ + { id: 1, name: 'New name one' }, + { id: 2, name: 'New name two' }, + { id: 3, name: 'Bruce Wayne' }, + ]); + + vm.originalData = [ + { id: 1, name: 'Tony Starks' }, + { id: 2, name: 'Jessica Jones' }, + { id: 3, name: 'Bruce Wayne' }, + ]; + + vm.insert(); + const result = vm.getChanges(); + + const expected = { + creates: [ + { + $index: 3, + fk: 1, + }, + ], + updates: [ + { + data: { + name: 'New name one', + }, + where: { + id: 1, + }, + }, + { + data: { + name: 'New name two', + }, + where: { + id: 2, + }, + }, + ], + }; + + expect(result).toEqual(expected); + }); + }); + + describe('getDifferences()', () => { + it('should return the differences between two objects', async () => { + const obj1 = { + a: 1, + b: 2, + c: 3, + }; + const obj2 = { + a: null, + b: 4, + d: 5, + }; + + const result = vm.getDifferences(obj1, obj2); + + expect(result).toEqual({ + a: null, + b: 4, + d: 5, + }); + }); + }); +}); diff --git a/test/vitest/__tests__/pages/Claims/ClaimLines.spec.js b/test/vitest/__tests__/pages/Claims/ClaimLines.spec.js index dda59a98d..6dd6b89e6 100644 --- a/test/vitest/__tests__/pages/Claims/ClaimLines.spec.js +++ b/test/vitest/__tests__/pages/Claims/ClaimLines.spec.js @@ -5,7 +5,6 @@ import ClaimLines from 'pages/Claim/Card/ClaimLines.vue'; describe('ClaimLines', () => { let vm; - beforeAll(() => { vm = createWrapper(ClaimLines, { global: { @@ -13,25 +12,26 @@ describe('ClaimLines', () => { mocks: { fetch: vi.fn(), }, - } + }, }).vm; }); beforeEach(() => { vm.claim = { id: 1, - ticketFk: 1 - } + ticketFk: 1, + }; vm.store.data = [ { id: 1, quantity: 10, sale: { - id: 1, discount: 0 - } - } - ] - }) + id: 1, + discount: 0, + }, + }, + ]; + }); afterEach(() => { vi.clearAllMocks(); @@ -42,13 +42,17 @@ describe('ClaimLines', () => { vi.spyOn(axios, 'post').mockResolvedValue({ data: true }); vi.spyOn(vm.quasar, 'notify'); - const canceller = new AbortController() + const canceller = new AbortController(); await vm.updateDiscount({ saleFk: 1, discount: 5, canceller }); - const expectedData = { salesIds: [1], newDiscount: 5 } - expect(axios.post).toHaveBeenCalledWith('Tickets/1/updateDiscount', expectedData, { - signal: canceller.signal - }) + const expectedData = { salesIds: [1], newDiscount: 5 }; + expect(axios.post).toHaveBeenCalledWith( + 'Tickets/1/updateDiscount', + expectedData, + { + signal: canceller.signal, + } + ); }); }); @@ -56,37 +60,14 @@ describe('ClaimLines', () => { it('should make a POST request and then set the discount on the original row', async () => { vi.spyOn(vm.quasar, 'notify'); - vm.onUpdateDiscount({ discount: 5, rowIndex: 0 }); - const firstRow = vm.store.data[0] + const firstRow = vm.store.data[0]; - expect(firstRow.sale.discount).toEqual(5) + expect(firstRow.sale.discount).toEqual(5); expect(vm.quasar.notify).toHaveBeenCalledWith( expect.objectContaining({ message: 'Discount updated', - type: 'positive' - }) - ); - }); - }); - - describe('remove()', () => { - it('should make a POST request and then call to the quasar notify() method', async () => { - vi.spyOn(axios, 'post').mockResolvedValue({ data: true }); - vi.spyOn(vm.quasar, 'notify'); - - await vm.remove({ - rows: [ - { id: 1 } - ] - }); - const expectedData = { deletes: [1] } - - expect(axios.post).toHaveBeenCalledWith('ClaimBeginnings/crud', expectedData) - expect(vm.quasar.notify).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Row removed', - type: 'positive' + type: 'positive', }) ); }); diff --git a/test/vitest/__tests__/pages/Wagons/WagonCreate.spec.js b/test/vitest/__tests__/pages/Wagons/WagonCreate.spec.js index bc6b92639..f195c183f 100644 --- a/test/vitest/__tests__/pages/Wagons/WagonCreate.spec.js +++ b/test/vitest/__tests__/pages/Wagons/WagonCreate.spec.js @@ -7,9 +7,11 @@ describe('WagonCreate', () => { const entityId = 1; beforeAll(() => { - vmEdit = createWrapper(WagonCreate, {propsData: { + vmEdit = createWrapper(WagonCreate, { + propsData: { id: entityId, - }}).vm; + }, + }).vm; vmCreate = createWrapper(WagonCreate).vm; }); @@ -29,9 +31,7 @@ describe('WagonCreate', () => { await vmCreate.onSubmit(); - expect(axios.patch).toHaveBeenCalledWith( - `Wagons`, vmCreate.wagon - ); + expect(axios.patch).toHaveBeenCalledWith(`Wagons`, vmCreate.wagon); }); it('should update a wagon', async () => { @@ -46,9 +46,7 @@ describe('WagonCreate', () => { await vmEdit.onSubmit(); - expect(axios.patch).toHaveBeenCalledWith( - `Wagons`, vmEdit.wagon - ); + expect(axios.patch).toHaveBeenCalledWith(`Wagons`, vmEdit.wagon); }); }); @@ -88,16 +86,12 @@ describe('WagonCreate', () => { describe('fetch()', () => { it('should fetch data', async () => { - vi.spyOn(axios, 'get').mockResolvedValue({ data: true }); + vi.spyOn(axios, 'get').mockResolvedValue({ data: [] }); await vmEdit.fetch(); - expect(axios.get).toHaveBeenCalledWith( - `WagonTypes` - ); - expect(axios.get).toHaveBeenCalledWith( - `Wagons/${entityId}` - ); + expect(axios.get).toHaveBeenCalledWith(`WagonTypes`); + expect(axios.get).toHaveBeenCalledWith(`Wagons/${entityId}`); }); }); }); diff --git a/test/vitest/helper.js b/test/vitest/helper.js index 8a6fb1415..8f4dc3221 100644 --- a/test/vitest/helper.js +++ b/test/vitest/helper.js @@ -5,6 +5,7 @@ import { vi } from 'vitest'; import { i18n } from 'src/boot/i18n'; import { Notify, Dialog } from 'quasar'; import axios from 'axios'; +import * as useValidator from 'src/composables/useValidator'; installQuasarPlugin({ plugins: { @@ -34,6 +35,10 @@ vi.mock('vue-router', () => ({ }), })); +vi.spyOn(useValidator, 'useValidator').mockImplementation(() => { + return { validate: vi.fn(), fetch: vi.fn() }; +}); + class FormDataMock { append() { vi.fn(); @@ -64,6 +69,10 @@ export function createWrapper(component, options) { global: { plugins: [i18n, pinia], }, + mocks: { + t: (tKey) => tKey, + $t: (tKey) => tKey, + }, }; const mountOptions = Object.assign({}, defaultOptions);