diff --git a/cypress.config.js b/cypress.config.js index 1924144f6..a9e27fcfd 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -14,8 +14,8 @@ export default defineConfig({ downloadsFolder: 'test/cypress/downloads', video: false, specPattern: 'test/cypress/integration/**/*.spec.js', - experimentalRunAllSpecs: true, - watchForFileChanges: true, + experimentalRunAllSpecs: false, + watchForFileChanges: false, reporter: 'cypress-mochawesome-reporter', reporterOptions: { charts: true, diff --git a/quasar.config.js b/quasar.config.js index 6d545c026..9467c92af 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -30,7 +30,6 @@ export default configure(function (/* ctx */) { // --> boot files are part of "main.js" // https://v2.quasar.dev/quasar-cli/boot-files boot: ['i18n', 'axios', 'vnDate', 'validations', 'quasar', 'quasar.defaults'], - // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css css: ['app.scss'], diff --git a/src/boot/qformMixin.js b/src/boot/qformMixin.js index 97d80c670..cb31391b3 100644 --- a/src/boot/qformMixin.js +++ b/src/boot/qformMixin.js @@ -9,19 +9,19 @@ export default { if (!form) return; try { const inputsFormCard = form.querySelectorAll( - `input:not([disabled]):not([type="checkbox"])` + `input:not([disabled]):not([type="checkbox"])`, ); if (inputsFormCard.length) { focusFirstInput(inputsFormCard[0]); } const textareas = document.querySelectorAll( - 'textarea:not([disabled]), [contenteditable]:not([disabled])' + 'textarea:not([disabled]), [contenteditable]:not([disabled])', ); if (textareas.length) { focusFirstInput(textareas[textareas.length - 1]); } const inputs = document.querySelectorAll( - 'form#formModel input:not([disabled]):not([type="checkbox"])' + 'form#formModel input:not([disabled]):not([type="checkbox"])', ); const input = inputs[0]; if (!input) return; diff --git a/src/boot/quasar.js b/src/boot/quasar.js index 547517682..a8c397b83 100644 --- a/src/boot/quasar.js +++ b/src/boot/quasar.js @@ -51,4 +51,5 @@ export default boot(({ app }) => { await useCau(response, message); }; + app.provide('app', app); }); diff --git a/src/components/CrudModel.vue b/src/components/CrudModel.vue index d569dfda1..93a2ac96a 100644 --- a/src/components/CrudModel.vue +++ b/src/components/CrudModel.vue @@ -64,6 +64,10 @@ const $props = defineProps({ type: Function, default: null, }, + beforeSaveFn: { + type: Function, + default: null, + }, goTo: { type: String, default: '', @@ -176,7 +180,11 @@ async function saveChanges(data) { hasChanges.value = false; return; } - const changes = data || getChanges(); + let changes = data || getChanges(); + if ($props.beforeSaveFn) { + changes = await $props.beforeSaveFn(changes, getChanges); + } + try { await axios.post($props.saveUrl || $props.url + '/crud', changes); } finally { @@ -229,12 +237,12 @@ async function remove(data) { componentProps: { title: t('globals.confirmDeletion'), message: t('globals.confirmDeletionMessage'), - newData, + data: { deletes: ids }, ids, + promise: saveChanges, }, }) .onOk(async () => { - await saveChanges({ deletes: ids }); newData = newData.filter((form) => !ids.some((id) => id == form[pk])); fetch(newData); }); @@ -374,6 +382,8 @@ watch(formUrl, async () => { @click="onSubmit" :disable="!hasChanges" :title="t('globals.save')" + v-shortcut="'s'" + shortcut="s" data-cy="crudModelDefaultSaveBtn" /> <slot name="moreAfterActions" /> diff --git a/src/components/FilterTravelForm.vue b/src/components/FilterTravelForm.vue index 9fc91457a..ab50d0899 100644 --- a/src/components/FilterTravelForm.vue +++ b/src/components/FilterTravelForm.vue @@ -181,6 +181,7 @@ const selectTravel = ({ id }) => { color="primary" :disabled="isLoading" :loading="isLoading" + data-cy="save-filter-travel-form" /> </div> <QTable @@ -191,9 +192,10 @@ const selectTravel = ({ id }) => { :no-data-label="t('Enter a new search')" class="q-mt-lg" @row-click="(_, row) => selectTravel(row)" + data-cy="table-filter-travel-form" > <template #body-cell-id="{ row }"> - <QTd auto-width @click.stop> + <QTd auto-width @click.stop data-cy="travelFk-travel-form"> <QBtn flat color="blue">{{ row.id }}</QBtn> <TravelDescriptorProxy :id="row.id" /> </QTd> diff --git a/src/components/FormModel.vue b/src/components/FormModel.vue index 59141d374..5a59f301e 100644 --- a/src/components/FormModel.vue +++ b/src/components/FormModel.vue @@ -113,7 +113,7 @@ const defaultButtons = computed(() => ({ color: 'primary', icon: 'save', label: 'globals.save', - click: () => myForm.value.submit(), + click: () => myForm.value.onSubmit(false), type: 'submit', }, reset: { @@ -207,7 +207,8 @@ async function fetch() { } } -async function save() { +async function save(prevent = false) { + if (prevent) return; if ($props.observeFormChanges && !hasChanges.value) return notify('globals.noChanges', 'negative'); @@ -293,7 +294,7 @@ defineExpose({ <QForm ref="myForm" v-if="formData" - @submit="save" + @submit="save(!!$event)" @reset="reset" class="q-pa-md" :style="maxWidth ? 'max-width: ' + maxWidth : ''" diff --git a/src/components/FormModelPopup.vue b/src/components/FormModelPopup.vue index afdc6efca..98b611743 100644 --- a/src/components/FormModelPopup.vue +++ b/src/components/FormModelPopup.vue @@ -15,23 +15,30 @@ defineProps({ type: String, default: '', }, + showSaveAndContinueBtn: { + type: Boolean, + default: false, + }, }); const { t } = useI18n(); const formModelRef = ref(null); const closeButton = ref(null); - +const isSaveAndContinue = ref(false); const onDataSaved = (formData, requestResponse) => { - if (closeButton.value) closeButton.value.click(); + if (closeButton.value && isSaveAndContinue) closeButton.value.click(); emit('onDataSaved', formData, requestResponse); }; const isLoading = computed(() => formModelRef.value?.isLoading); +const reset = computed(() => formModelRef.value?.reset); defineExpose({ isLoading, onDataSaved, + isSaveAndContinue, + reset, }); </script> @@ -59,15 +66,22 @@ defineExpose({ flat :disabled="isLoading" :loading="isLoading" - @click="emit('onDataCanceled')" - v-close-popup data-cy="FormModelPopup_cancel" + v-close-popup z-max + @click=" + isSaveAndContinue = false; + emit('onDataCanceled'); + " /> <QBtn + :flat="showSaveAndContinueBtn" :label="t('globals.save')" :title="t('globals.save')" - type="submit" + @click=" + formModelRef.save(); + isSaveAndContinue = false; + " color="primary" class="q-ml-sm" :disabled="isLoading" @@ -75,6 +89,21 @@ defineExpose({ data-cy="FormModelPopup_save" z-max /> + <QBtn + v-if="showSaveAndContinueBtn" + :label="t('globals.isSaveAndContinue')" + :title="t('globals.isSaveAndContinue')" + color="primary" + class="q-ml-sm" + :disabled="isLoading" + :loading="isLoading" + data-cy="FormModelPopup_isSaveAndContinue" + z-max + @click=" + isSaveAndContinue = true; + formModelRef.save(); + " + /> </div> </template> </FormModel> diff --git a/src/components/ItemsFilterPanel.vue b/src/components/ItemsFilterPanel.vue index 48f607a30..b6209d8e2 100644 --- a/src/components/ItemsFilterPanel.vue +++ b/src/components/ItemsFilterPanel.vue @@ -328,7 +328,6 @@ en: active: Is active visible: Is visible floramondo: Is floramondo - salesPersonFk: Buyer categoryFk: Category es: @@ -339,7 +338,6 @@ es: active: Activo visible: Visible floramondo: Floramondo - salesPersonFk: Comprador categoryFk: Categoría Plant: Planta natural Flower: Flor fresca diff --git a/src/components/LeftMenuItem.vue b/src/components/LeftMenuItem.vue index a3112b17f..c0cee44fe 100644 --- a/src/components/LeftMenuItem.vue +++ b/src/components/LeftMenuItem.vue @@ -26,6 +26,7 @@ const itemComputed = computed(() => { :to="{ name: itemComputed.name }" clickable v-ripple + :data-cy="`${itemComputed.name}-menu-item`" > <QItemSection avatar v-if="itemComputed.icon"> <QIcon :name="itemComputed.icon" /> diff --git a/src/components/RefundInvoiceForm.vue b/src/components/RefundInvoiceForm.vue index 590acede0..6dcb8b390 100644 --- a/src/components/RefundInvoiceForm.vue +++ b/src/components/RefundInvoiceForm.vue @@ -9,6 +9,7 @@ import VnSelect from 'components/common/VnSelect.vue'; import FormPopup from './FormPopup.vue'; import axios from 'axios'; import useNotify from 'src/composables/useNotify.js'; +import VnCheckbox from 'src/components/common/VnCheckbox.vue'; const $props = defineProps({ invoiceOutData: { @@ -131,15 +132,11 @@ const refund = async () => { :required="true" /> </VnRow ><VnRow> - <div> - <QCheckbox - :label="t('Inherit warehouse')" - v-model="invoiceParams.inheritWarehouse" - /> - <QIcon name="info" class="cursor-info q-ml-sm" size="sm"> - <QTooltip>{{ t('Inherit warehouse tooltip') }}</QTooltip> - </QIcon> - </div> + <VnCheckbox + v-model="invoiceParams.inheritWarehouse" + :label="t('Inherit warehouse')" + :info="t('Inherit warehouse tooltip')" + /> </VnRow> </template> </FormPopup> diff --git a/src/components/TransferInvoiceForm.vue b/src/components/TransferInvoiceForm.vue index aa71070d6..c4ef1454a 100644 --- a/src/components/TransferInvoiceForm.vue +++ b/src/components/TransferInvoiceForm.vue @@ -10,6 +10,7 @@ import VnSelect from 'components/common/VnSelect.vue'; import FormPopup from './FormPopup.vue'; import axios from 'axios'; import useNotify from 'src/composables/useNotify.js'; +import VnCheckbox from './common/VnCheckbox.vue'; const $props = defineProps({ invoiceOutData: { @@ -186,15 +187,11 @@ const makeInvoice = async () => { /> </VnRow> <VnRow> - <div> - <QCheckbox - :label="t('Bill destination client')" - v-model="checked" - /> - <QIcon name="info" class="cursor-info q-ml-sm" size="sm"> - <QTooltip>{{ t('transferInvoiceInfo') }}</QTooltip> - </QIcon> - </div> + <VnCheckbox + v-model="checked" + :label="t('Bill destination client')" + :info="t('transferInvoiceInfo')" + /> </VnRow> </template> </FormPopup> diff --git a/src/components/VnTable/VnColumn.vue b/src/components/VnTable/VnColumn.vue index 9e9bfad69..44364cca1 100644 --- a/src/components/VnTable/VnColumn.vue +++ b/src/components/VnTable/VnColumn.vue @@ -1,9 +1,8 @@ <script setup> import { markRaw, computed } from 'vue'; -import { QIcon, QCheckbox } from 'quasar'; +import { QIcon, QCheckbox, QToggle } from 'quasar'; import { dashIfEmpty } from 'src/filters'; -/* basic input */ import VnSelect from 'components/common/VnSelect.vue'; import VnSelectCache from 'components/common/VnSelectCache.vue'; import VnInput from 'components/common/VnInput.vue'; @@ -12,8 +11,11 @@ import VnInputDate from 'components/common/VnInputDate.vue'; import VnInputTime from 'components/common/VnInputTime.vue'; import VnComponent from 'components/common/VnComponent.vue'; import VnUserLink from 'components/ui/VnUserLink.vue'; +import VnSelectEnum from '../common/VnSelectEnum.vue'; +import VnCheckbox from '../common/VnCheckbox.vue'; const model = defineModel(undefined, { required: true }); +const emit = defineEmits(['blur']); const $props = defineProps({ column: { type: Object, @@ -39,10 +41,18 @@ const $props = defineProps({ type: Object, default: null, }, + autofocus: { + type: Boolean, + default: false, + }, showLabel: { type: Boolean, default: null, }, + eventHandlers: { + type: Object, + default: null, + }, }); const defaultSelect = { @@ -99,7 +109,8 @@ const defaultComponents = { }, }, checkbox: { - component: markRaw(QCheckbox), + ref: 'checkbox', + component: markRaw(VnCheckbox), attrs: ({ model }) => { const defaultAttrs = { disable: !$props.isEditable, @@ -115,6 +126,10 @@ const defaultComponents = { }, forceAttrs: { label: $props.showLabel && $props.column.label, + autofocus: true, + }, + events: { + blur: () => emit('blur'), }, }, select: { @@ -125,12 +140,19 @@ const defaultComponents = { component: markRaw(VnSelect), ...defaultSelect, }, + selectEnum: { + component: markRaw(VnSelectEnum), + ...defaultSelect, + }, icon: { component: markRaw(QIcon), }, userLink: { component: markRaw(VnUserLink), }, + toggle: { + component: markRaw(QToggle), + }, }; const value = computed(() => { @@ -160,7 +182,28 @@ const col = computed(() => { return newColumn; }); -const components = computed(() => $props.components ?? defaultComponents); +const components = computed(() => { + const sourceComponents = $props.components ?? defaultComponents; + + return Object.keys(sourceComponents).reduce((acc, key) => { + const component = sourceComponents[key]; + + if (!component || typeof component !== 'object') { + acc[key] = component; + return acc; + } + + acc[key] = { + ...component, + attrs: { + ...(component.attrs || {}), + autofocus: $props.autofocus, + }, + event: { ...component?.event, ...$props?.eventHandlers }, + }; + return acc; + }, {}); +}); </script> <template> <div class="row no-wrap"> diff --git a/src/components/VnTable/VnFilter.vue b/src/components/VnTable/VnFilter.vue index 426f5c716..2dad8fe52 100644 --- a/src/components/VnTable/VnFilter.vue +++ b/src/components/VnTable/VnFilter.vue @@ -1,14 +1,12 @@ <script setup> import { markRaw, computed } from 'vue'; -import { QCheckbox } from 'quasar'; +import { QCheckbox, QToggle } from 'quasar'; import { useArrayData } from 'composables/useArrayData'; - -/* basic input */ import VnSelect from 'components/common/VnSelect.vue'; import VnInput from 'components/common/VnInput.vue'; import VnInputDate from 'components/common/VnInputDate.vue'; import VnInputTime from 'components/common/VnInputTime.vue'; -import VnTableColumn from 'components/VnTable/VnColumn.vue'; +import VnColumn from 'components/VnTable/VnColumn.vue'; const $props = defineProps({ column: { @@ -27,6 +25,10 @@ const $props = defineProps({ type: String, default: 'table', }, + customClass: { + type: String, + default: '', + }, }); defineExpose({ addFilter, props: $props }); @@ -34,7 +36,7 @@ defineExpose({ addFilter, props: $props }); const model = defineModel(undefined, { required: true }); const arrayData = useArrayData( $props.dataKey, - $props.searchUrl ? { searchUrl: $props.searchUrl } : null + $props.searchUrl ? { searchUrl: $props.searchUrl } : null, ); const columnFilter = computed(() => $props.column?.columnFilter); @@ -46,19 +48,18 @@ const enterEvent = { const defaultAttrs = { filled: !$props.showTitle, - class: 'q-px-xs q-pb-xs q-pt-none fit', dense: true, }; const forceAttrs = { - label: $props.showTitle ? '' : columnFilter.value?.label ?? $props.column.label, + label: $props.showTitle ? '' : (columnFilter.value?.label ?? $props.column.label), }; const selectComponent = { component: markRaw(VnSelect), event: updateEvent, attrs: { - class: 'q-px-sm q-pb-xs q-pt-none fit', + class: `q-pt-none fit ${$props.customClass}`, dense: true, filled: !$props.showTitle, }, @@ -109,14 +110,24 @@ const components = { component: markRaw(QCheckbox), event: updateEvent, attrs: { - dense: true, - class: $props.showTitle ? 'q-py-sm q-mt-md' : 'q-px-md q-py-xs fit', + class: $props.showTitle ? 'q-py-sm' : 'q-px-md q-py-xs fit', 'toggle-indeterminate': true, + size: 'sm', }, forceAttrs, }, select: selectComponent, rawSelect: selectComponent, + toggle: { + component: markRaw(QToggle), + event: updateEvent, + attrs: { + class: $props.showTitle ? 'q-py-sm' : 'q-px-md q-py-xs fit', + 'toggle-indeterminate': true, + size: 'sm', + }, + forceAttrs, + }, }; async function addFilter(value, name) { @@ -132,19 +143,8 @@ async function addFilter(value, name) { await arrayData.addFilter({ params: { [field]: value } }); } -function alignRow() { - switch ($props.column.align) { - case 'left': - return 'justify-start items-start'; - case 'right': - return 'justify-end items-end'; - default: - return 'flex-center'; - } -} - const showFilter = computed( - () => $props.column?.columnFilter !== false && $props.column.name != 'tableActions' + () => $props.column?.columnFilter !== false && $props.column.name != 'tableActions', ); const onTabPressed = async () => { @@ -152,13 +152,8 @@ const onTabPressed = async () => { }; </script> <template> - <div - v-if="showFilter" - class="full-width" - :class="alignRow()" - style="max-height: 45px; overflow: hidden" - > - <VnTableColumn + <div v-if="showFilter" class="full-width flex-center" style="overflow: hidden"> + <VnColumn :column="$props.column" default="input" v-model="model" @@ -168,3 +163,8 @@ const onTabPressed = async () => { /> </div> </template> +<style lang="scss" scoped> +label.vn-label-padding > .q-field__inner > .q-field__control { + padding: inherit !important; +} +</style> diff --git a/src/components/VnTable/VnOrder.vue b/src/components/VnTable/VnOrder.vue index 8ffdfe2bc..e3795cc4b 100644 --- a/src/components/VnTable/VnOrder.vue +++ b/src/components/VnTable/VnOrder.vue @@ -41,6 +41,7 @@ async function orderBy(name, direction) { break; } if (!direction) return await arrayData.deleteOrder(name); + await arrayData.addOrder(name, direction); } @@ -51,45 +52,60 @@ defineExpose({ orderBy }); @mouseenter="hover = true" @mouseleave="hover = false" @click="orderBy(name, model?.direction)" - class="row items-center no-wrap cursor-pointer" + class="row items-center no-wrap cursor-pointer title" > <span :title="label">{{ label }}</span> - <QChip - v-if="name" - :label="!vertical ? model?.index : ''" - :icon=" - (model?.index || hover) && !vertical - ? model?.direction == 'DESC' - ? 'arrow_downward' - : 'arrow_upward' - : undefined - " - :size="vertical ? '' : 'sm'" - :class="[ - model?.index ? 'color-vn-text' : 'bg-transparent', - vertical ? 'q-px-none' : '', - ]" - class="no-box-shadow" - :clickable="true" - style="min-width: 40px" - > - <div - class="column flex-center" - v-if="vertical" - :style="!model?.index && 'color: #5d5d5d'" + <sup v-if="name && model?.index"> + <QChip + :label="!vertical ? model?.index : ''" + :icon=" + (model?.index || hover) && !vertical + ? model?.direction == 'DESC' + ? 'arrow_downward' + : 'arrow_upward' + : undefined + " + :size="vertical ? '' : 'sm'" + :class="[ + model?.index ? 'color-vn-text' : 'bg-transparent', + vertical ? 'q-px-none' : '', + ]" + class="no-box-shadow" + :clickable="true" + style="min-width: 40px; max-height: 30px" > - {{ model?.index }} - <QIcon - :name=" - model?.index - ? model?.direction == 'DESC' - ? 'arrow_downward' - : 'arrow_upward' - : 'swap_vert' - " - size="xs" - /> - </div> - </QChip> + <div + class="column flex-center" + v-if="vertical" + :style="!model?.index && 'color: #5d5d5d'" + > + {{ model?.index }} + <QIcon + :name=" + model?.index + ? model?.direction == 'DESC' + ? 'arrow_downward' + : 'arrow_upward' + : 'swap_vert' + " + size="xs" + /> + </div> + </QChip> + </sup> </div> </template> +<style lang="scss" scoped> +.title { + display: flex; + justify-content: center; + align-items: center; + height: 30px; + width: 100%; + color: var(--vn-label-color); +} +sup { + vertical-align: super; /* Valor predeterminado */ + /* También puedes usar otros valores como "baseline", "top", "text-top", etc. */ +} +</style> diff --git a/src/components/VnTable/VnTable.vue b/src/components/VnTable/VnTable.vue index 04b7c0a46..3e1923b4c 100644 --- a/src/components/VnTable/VnTable.vue +++ b/src/components/VnTable/VnTable.vue @@ -1,22 +1,37 @@ <script setup> -import { ref, onBeforeMount, onMounted, computed, watch, useAttrs } from 'vue'; +import { + ref, + onBeforeMount, + onMounted, + onUnmounted, + computed, + watch, + h, + render, + inject, + useAttrs, +} from 'vue'; +import { useArrayData } from 'src/composables/useArrayData'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import { useQuasar } from 'quasar'; import { useStateStore } from 'stores/useStateStore'; import { useFilterParams } from 'src/composables/useFilterParams'; +import { dashIfEmpty } from 'src/filters'; import CrudModel from 'src/components/CrudModel.vue'; import FormModelPopup from 'components/FormModelPopup.vue'; -import VnTableColumn from 'components/VnTable/VnColumn.vue'; +import VnColumn from 'components/VnTable/VnColumn.vue'; import VnFilter from 'components/VnTable/VnFilter.vue'; import VnTableChip from 'components/VnTable/VnChip.vue'; import VnVisibleColumn from 'src/components/VnTable/VnVisibleColumn.vue'; import VnLv from 'components/ui/VnLv.vue'; import VnTableOrder from 'src/components/VnTable/VnOrder.vue'; import VnTableFilter from './VnTableFilter.vue'; +import { getColAlign } from 'src/composables/getColAlign'; +const arrayData = useArrayData(useAttrs()['data-key']); const $props = defineProps({ columns: { type: Array, @@ -42,10 +57,6 @@ const $props = defineProps({ type: [Function, Boolean], default: null, }, - rowCtrlClick: { - type: [Function, Boolean], - default: null, - }, redirect: { type: String, default: null, @@ -114,7 +125,19 @@ const $props = defineProps({ type: Boolean, default: false, }, + withFilters: { + type: Boolean, + default: true, + }, + overlay: { + type: Boolean, + default: false, + }, + createComplement: { + type: Object, + }, }); + const { t } = useI18n(); const stateStore = useStateStore(); const route = useRoute(); @@ -132,10 +155,18 @@ const showForm = ref(false); const splittedColumns = ref({ columns: [] }); const columnsVisibilitySkipped = ref(); const createForm = ref(); +const createRef = ref(null); const tableRef = ref(); const params = ref(useFilterParams($attrs['data-key']).params); const orders = ref(useFilterParams($attrs['data-key']).orders); +const app = inject('app'); +const editingRow = ref(null); +const editingField = ref(null); +const isTableMode = computed(() => mode.value == TABLE_MODE); +const showRightIcon = computed(() => $props.rightSearch || $props.rightSearchIcon); +const selectRegex = /select/; +const emit = defineEmits(['onFetch', 'update:selected', 'saveChanges']); const tableModes = [ { icon: 'view_column', @@ -156,7 +187,8 @@ onBeforeMount(() => { hasParams.value = urlParams && Object.keys(urlParams).length !== 0; }); -onMounted(() => { +onMounted(async () => { + if ($props.isEditable) document.addEventListener('click', clickHandler); mode.value = quasar.platform.is.mobile && !$props.disableOption?.card ? CARD_MODE @@ -178,14 +210,25 @@ onMounted(() => { } }); +onUnmounted(async () => { + if ($props.isEditable) document.removeEventListener('click', clickHandler); +}); + watch( () => $props.columns, (value) => splitColumns(value), { immediate: true }, ); -const isTableMode = computed(() => mode.value == TABLE_MODE); -const showRightIcon = computed(() => $props.rightSearch || $props.rightSearchIcon); +defineExpose({ + create: createForm, + reload, + redirect: redirectFn, + selected, + CrudModelRef, + params, + tableRef, +}); function splitColumns(columns) { splittedColumns.value = { @@ -231,16 +274,6 @@ const rowClickFunction = computed(() => { return () => {}; }); -const rowCtrlClickFunction = computed(() => { - if ($props.rowCtrlClick != undefined) return $props.rowCtrlClick; - if ($props.redirect) - return (evt, { id }) => { - stopEventPropagation(evt); - window.open(`/#/${$props.redirect}/${id}`, '_blank'); - }; - return () => {}; -}); - function redirectFn(id) { router.push({ path: `/${$props.redirect}/${id}` }); } @@ -262,21 +295,6 @@ function columnName(col) { return name; } -function getColAlign(col) { - return 'text-' + (col.align ?? 'left'); -} - -const emit = defineEmits(['onFetch', 'update:selected', 'saveChanges']); -defineExpose({ - create: createForm, - reload, - redirect: redirectFn, - selected, - CrudModelRef, - params, - tableRef, -}); - function handleOnDataSaved(_) { if (_.onDataSaved) _.onDataSaved({ CrudModelRef: CrudModelRef.value }); else $props.create.onDataSaved(_); @@ -304,6 +322,215 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) { } } } + +function isEditableColumn(column) { + const isEditableCol = column?.isEditable ?? true; + const isVisible = column?.visible ?? true; + const hasComponent = column?.component; + + return $props.isEditable && isVisible && hasComponent && isEditableCol; +} + +function hasEditableFormat(column) { + if (isEditableColumn(column)) return 'editable-text'; +} + +const clickHandler = async (event) => { + const clickedElement = event.target.closest('td'); + + const isDateElement = event.target.closest('.q-date'); + const isTimeElement = event.target.closest('.q-time'); + const isQselectDropDown = event.target.closest('.q-select__dropdown-icon'); + + if (isDateElement || isTimeElement || isQselectDropDown) return; + + if (clickedElement === null) { + destroyInput(editingRow.value, editingField.value); + return; + } + const rowIndex = clickedElement.getAttribute('data-row-index'); + const colField = clickedElement.getAttribute('data-col-field'); + const column = $props.columns.find((col) => col.name === colField); + + if (editingRow.value !== null && editingField.value !== null) { + if (editingRow.value === rowIndex && editingField.value === colField) { + return; + } + + destroyInput(editingRow.value, editingField.value); + } + if (isEditableColumn(column)) + await renderInput(Number(rowIndex), colField, clickedElement); +}; + +async function handleTabKey(event, rowIndex, colField) { + if (editingRow.value == rowIndex && editingField.value == colField) + destroyInput(editingRow.value, editingField.value); + + const direction = event.shiftKey ? -1 : 1; + const { nextRowIndex, nextColumnName } = await handleTabNavigation( + rowIndex, + colField, + direction, + ); + + if (nextRowIndex < 0 || nextRowIndex >= arrayData.store.data.length) return; + + event.preventDefault(); + await renderInput(nextRowIndex, nextColumnName, null); +} + +async function renderInput(rowId, field, clickedElement) { + editingField.value = field; + editingRow.value = rowId; + + const originalColumn = $props.columns.find((col) => col.name === field); + const column = { ...originalColumn, ...{ label: '' } }; + const row = CrudModelRef.value.formData[rowId]; + const oldValue = CrudModelRef.value.formData[rowId][column?.name]; + + if (!clickedElement) + clickedElement = document.querySelector( + `[data-row-index="${rowId}"][data-col-field="${field}"]`, + ); + + Array.from(clickedElement.childNodes).forEach((child) => { + child.style.visibility = 'hidden'; + child.style.position = 'relative'; + }); + + const isSelect = selectRegex.test(column?.component); + if (isSelect) column.attrs = { ...column.attrs, 'emit-value': false }; + + const node = h(VnColumn, { + row: row, + class: 'temp-input', + column: column, + modelValue: row[column.name], + componentProp: 'columnField', + autofocus: true, + focusOnMount: true, + eventHandlers: { + 'update:modelValue': async (value) => { + if (isSelect && value) { + row[column.name] = value[column.attrs?.optionValue ?? 'id']; + row[column?.name + 'TextValue'] = + value[column.attrs?.optionLabel ?? 'name']; + await column?.cellEvent?.['update:modelValue']?.( + value, + oldValue, + row, + ); + } else row[column.name] = value; + await column?.cellEvent?.['update:modelValue']?.(value, oldValue, row); + }, + keyup: async (event) => { + if (event.key === 'Enter') handleBlur(rowId, field, clickedElement); + }, + keydown: async (event) => { + switch (event.key) { + case 'Tab': + await handleTabKey(event, rowId, field); + event.stopPropagation(); + break; + case 'Escape': + destroyInput(rowId, field, clickedElement); + break; + default: + break; + } + }, + click: (event) => { + column?.cellEvent?.['click']?.(event, row); + }, + }, + }); + + node.appContext = app._context; + render(node, clickedElement); + + if (['checkbox', 'toggle', undefined].includes(column?.component)) + node.el?.querySelector('span > div').focus(); +} + +function destroyInput(rowIndex, field, clickedElement) { + if (!clickedElement) + clickedElement = document.querySelector( + `[data-row-index="${rowIndex}"][data-col-field="${field}"]`, + ); + if (clickedElement) { + render(null, clickedElement); + Array.from(clickedElement.childNodes).forEach((child) => { + child.style.visibility = 'visible'; + child.style.position = ''; + }); + } + if (editingRow.value !== rowIndex || editingField.value !== field) return; + editingRow.value = null; + editingField.value = null; +} + +function handleBlur(rowIndex, field, clickedElement) { + destroyInput(rowIndex, field, clickedElement); +} + +async function handleTabNavigation(rowIndex, colName, direction) { + const columns = $props.columns; + const totalColumns = columns.length; + let currentColumnIndex = columns.findIndex((col) => col.name === colName); + + let iterations = 0; + let newColumnIndex = currentColumnIndex; + + do { + iterations++; + newColumnIndex = (newColumnIndex + direction + totalColumns) % totalColumns; + + if (isEditableColumn(columns[newColumnIndex])) break; + } while (iterations < totalColumns); + + if (iterations >= totalColumns) { + return; + } + + if (direction === 1 && newColumnIndex <= currentColumnIndex) { + rowIndex++; + } else if (direction === -1 && newColumnIndex >= currentColumnIndex) { + rowIndex--; + } + return { nextRowIndex: rowIndex, nextColumnName: columns[newColumnIndex].name }; +} + +function getCheckboxIcon(value) { + switch (typeof value) { + case 'boolean': + return value ? 'check' : 'close'; + case 'number': + return value === 0 ? 'close' : 'check'; + case 'undefined': + return 'indeterminate_check_box'; + default: + return 'indeterminate_check_box'; + } +} + +function getToggleIcon(value) { + if (value === null) return 'help_outline'; + return value ? 'toggle_on' : 'toggle_off'; +} + +function formatColumnValue(col, row, dashIfEmpty) { + if (col?.format) { + if (selectRegex.test(col?.component) && row[col?.name + 'TextValue']) { + return dashIfEmpty(row[col?.name + 'TextValue']); + } else { + return col.format(row, dashIfEmpty); + } + } else { + return dashIfEmpty(row[col?.name]); + } +} +const checkbox = ref(null); </script> <template> <QDrawer @@ -311,7 +538,7 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) { v-model="stateStore.rightDrawer" side="right" :width="256" - show-if-above + :overlay="$props.overlay" > <QScrollArea class="fit"> <VnTableFilter @@ -332,7 +559,7 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) { <CrudModel v-bind="$attrs" :class="$attrs['class'] ?? 'q-px-md'" - :limit="$attrs['limit'] ?? 20" + :limit="$attrs['limit'] ?? 100" ref="CrudModelRef" @on-fetch="(...args) => emit('onFetch', ...args)" :search-url="searchUrl" @@ -348,8 +575,12 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) { <QTable ref="tableRef" v-bind="table" - class="vnTable" - :class="{ 'last-row-sticky': $props.footer }" + :class="[ + 'vnTable', + table ? 'selection-cell' : '', + $props.footer ? 'last-row-sticky' : '', + ]" + wrap-cells :columns="splittedColumns.columns" :rows="rows" v-model:selected="selected" @@ -363,11 +594,13 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) { @row-click="(_, row) => rowClickFunction && rowClickFunction(row)" @update:selected="emit('update:selected', $event)" @selection="(details) => handleSelection(details, rows)" + :hide-selected-banner="true" > <template #top-left v-if="!$props.withoutHeader"> - <slot name="top-left"></slot> + <slot name="top-left"> </slot> </template> <template #top-right v-if="!$props.withoutHeader"> + <slot name="top-right"></slot> <VnVisibleColumn v-if="isTableMode" v-model="splittedColumns.columns" @@ -381,6 +614,7 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) { dense :options="tableModes.filter((mode) => !mode.disable)" /> + <QBtn v-if="showRightIcon" icon="filter_alt" @@ -392,32 +626,38 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) { <template #header-cell="{ col }"> <QTh v-if="col.visible ?? true" - :style="col.headerStyle" - :class="col.headerClass" + class="body-cell" + :style="col?.width ? `max-width: ${col?.width}` : ''" + style="padding: inherit" > <div - class="column ellipsis" - :class="`text-${col?.align ?? 'left'}`" - :style="$props.columnSearch ? 'height: 75px' : ''" + class="no-padding" + :style=" + withFilters && $props.columnSearch ? 'height: 75px' : '' + " > - <div class="row items-center no-wrap" style="height: 30px"> + <div class="text-center" style="height: 30px"> <QTooltip v-if="col.toolTip">{{ col.toolTip }}</QTooltip> <VnTableOrder v-model="orders[col.orderBy ?? col.name]" :name="col.orderBy ?? col.name" - :label="col?.label" + :label="col?.labelAbbreviation ?? col?.label" :data-key="$attrs['data-key']" :search-url="searchUrl" /> </div> <VnFilter - v-if="$props.columnSearch" + v-if=" + $props.columnSearch && + col.columnSearch !== false && + withFilters + " :column="col" :show-title="true" :data-key="$attrs['data-key']" v-model="params[columnName(col)]" :search-url="searchUrl" - class="full-width" + customClass="header-filter" /> </div> </QTh> @@ -435,32 +675,63 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) { </QTd> </template> <template #body-cell="{ col, row, rowIndex }"> - <!-- Columns --> <QTd - auto-width - class="no-margin" - :class="[getColAlign(col), col.columnClass]" - :style="col.style" + class="no-margin q-px-xs" v-if="col.visible ?? true" - @click.ctrl=" - ($event) => - rowCtrlClickFunction && rowCtrlClickFunction($event, row) - " + :style="{ + 'max-width': col?.width ?? false, + position: 'relative', + }" + :class="[ + col.columnClass, + 'body-cell no-margin no-padding', + getColAlign(col), + ]" + :data-row-index="rowIndex" + :data-col-field="col?.name" > - <slot - :name="`column-${col.name}`" - :col="col" - :row="row" - :row-index="rowIndex" + <div + class="no-padding no-margin peter" + style=" + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + " > - <VnTableColumn - :column="col" + <slot + :name="`column-${col.name}`" + :col="col" :row="row" - :is-editable="col.isEditable ?? isEditable" - v-model="row[col.name]" - component-prop="columnField" - /> - </slot> + :row-index="rowIndex" + > + <QIcon + v-if="col?.component === 'toggle'" + :name=" + col?.getIcon + ? col.getIcon(row[col?.name]) + : getToggleIcon(row[col?.name]) + " + style="color: var(--vn-text-color)" + :class="hasEditableFormat(col)" + size="14px" + /> + <QIcon + v-else-if="col?.component === 'checkbox'" + :name="getCheckboxIcon(row[col?.name])" + style="color: var(--vn-text-color)" + :class="hasEditableFormat(col)" + size="14px" + /> + <span + v-else + :class="hasEditableFormat(col)" + :style="col?.style ? col.style(row) : null" + style="bottom: 0" + > + {{ formatColumnValue(col, row, dashIfEmpty) }} + </span> + </slot> + </div> </QTd> </template> <template #body-cell-tableActions="{ col, row }"> @@ -563,7 +834,7 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) { :row="row" :row-index="index" > - <VnTableColumn + <VnColumn :column="col" :row="row" :is-editable="false" @@ -603,14 +874,17 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) { </component> </template> <template #bottom-row="{ cols }" v-if="$props.footer"> - <QTr v-if="rows.length" style="height: 30px"> + <QTr v-if="rows.length" style="height: 45px"> + <QTh v-if="table.selection" /> <QTh v-for="col of cols.filter((cols) => cols.visible ?? true)" :key="col?.id" - class="text-center" :class="getColAlign(col)" > - <slot :name="`column-footer-${col.name}`" /> + <slot + :name="`column-footer-${col.name}`" + :isEditableColumn="isEditableColumn(col)" + /> </QTh> </QTr> </template> @@ -654,32 +928,53 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) { {{ createForm?.title }} </QTooltip> </QPageSticky> - <QDialog v-model="showForm" transition-show="scale" transition-hide="scale"> + <QDialog + v-model="showForm" + transition-show="scale" + transition-hide="scale" + :full-width="createComplement?.isFullWidth ?? false" + @before-hide=" + () => { + if (createRef.isSaveAndContinue) { + showForm = true; + createForm.formInitialData = { ...create.formInitialData }; + } + } + " + data-cy="vn-table-create-dialog" + > <FormModelPopup + ref="createRef" v-bind="createForm" :model="$attrs['data-key'] + 'Create'" @on-data-saved="(_, res) => createForm.onDataSaved(res)" > <template #form-inputs="{ data }"> - <div class="grid-create"> - <slot - v-for="column of splittedColumns.create" - :key="column.name" - :name="`column-create-${column.name}`" - :data="data" - :column-name="column.name" - :label="column.label" - > - <VnTableColumn - :column="column" - :row="{}" - default="input" - v-model="data[column.name]" - :show-label="true" - component-prop="columnCreate" - /> - </slot> - <slot name="more-create-dialog" :data="data" /> + <div :style="createComplement?.containerStyle"> + <div> + <slot name="previous-create-dialog" :data="data" /> + </div> + <div class="grid-create" :style="createComplement?.columnGridStyle"> + <slot + v-for="column of splittedColumns.create" + :key="column.name" + :name="`column-create-${column.name}`" + :data="data" + :column-name="column.name" + :label="column.label" + > + <VnColumn + :column="column" + :row="{}" + default="input" + v-model="data[column.name]" + :show-label="true" + component-prop="columnCreate" + :data-cy="`${column.name}-create-popup`" + /> + </slot> + <slot name="more-create-dialog" :data="data" /> + </div> </div> </template> </FormModelPopup> @@ -697,6 +992,42 @@ es: </i18n> <style lang="scss"> +.selection-cell { + table td:first-child { + padding: 0px; + } +} +.side-padding { + padding-left: 1px; + padding-right: 1px; +} +.editable-text:hover { + border-bottom: 1px dashed var(--q-primary); + @extend .side-padding; +} +.editable-text { + border-bottom: 1px dashed var(--vn-label-color); + @extend .side-padding; +} +.cell-input { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + padding-top: 0px !important; +} +.q-field--labeled .q-field__native, +.q-field--labeled .q-field__prefix, +.q-field--labeled .q-field__suffix { + padding-top: 20px; +} + +.body-cell { + padding-left: 2px !important; + padding-right: 2px !important; + position: relative; +} .bg-chip-secondary { background-color: var(--vn-page-color); color: var(--vn-text-color); @@ -713,7 +1044,7 @@ es: .grid-three { display: grid; - grid-template-columns: repeat(auto-fit, minmax(350px, max-content)); + grid-template-columns: repeat(auto-fit, minmax(300px, max-content)); max-width: 100%; grid-gap: 20px; margin: 0 auto; @@ -722,7 +1053,6 @@ es: .grid-create { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, max-content)); - max-width: 100%; grid-gap: 20px; margin: 0 auto; } @@ -738,7 +1068,9 @@ es: } } } - +.q-table tbody tr td { + position: relative; +} .q-table { th { padding: 0; @@ -838,4 +1170,15 @@ es: .q-table__middle.q-virtual-scroll.q-virtual-scroll--vertical.scroll { background-color: var(--vn-section-color); } +.temp-input { + top: 0; + position: absolute; + width: 100%; + height: 100%; + display: flex; +} + +label.header-filter > .q-field__inner > .q-field__control { + padding: inherit; +} </style> diff --git a/src/components/VnTable/VnTableFilter.vue b/src/components/VnTable/VnTableFilter.vue index 63b84cd59..79b903e54 100644 --- a/src/components/VnTable/VnTableFilter.vue +++ b/src/components/VnTable/VnTableFilter.vue @@ -29,25 +29,29 @@ function columnName(col) { <VnFilterPanel v-bind="$attrs" :search-button="true" :disable-submit-event="true"> <template #body="{ params, orders, searchFn }"> <div - class="row no-wrap flex-center" + class="container" v-for="col of columns.filter((c) => c.columnFilter ?? true)" :key="col.id" > - <VnFilter - ref="tableFilterRef" - :column="col" - :data-key="$attrs['data-key']" - v-model="params[columnName(col)]" - :search-url="searchUrl" - /> - <VnTableOrder - v-if="col?.columnFilter !== false && col?.name !== 'tableActions'" - v-model="orders[col.orderBy ?? col.name]" - :name="col.orderBy ?? col.name" - :data-key="$attrs['data-key']" - :search-url="searchUrl" - :vertical="true" - /> + <div class="filter"> + <VnFilter + ref="tableFilterRef" + :column="col" + :data-key="$attrs['data-key']" + v-model="params[columnName(col)]" + :search-url="searchUrl" + /> + </div> + <div class="order"> + <VnTableOrder + v-if="col?.columnFilter !== false && col?.name !== 'tableActions'" + v-model="orders[col.orderBy ?? col.name]" + :name="col.orderBy ?? col.name" + :data-key="$attrs['data-key']" + :search-url="searchUrl" + :vertical="true" + /> + </div> </div> <slot name="moreFilterPanel" @@ -68,3 +72,21 @@ function columnName(col) { </template> </VnFilterPanel> </template> +<style lang="scss" scoped> +.container { + display: flex; + justify-content: center; + align-items: center; + height: 45px; + gap: 10px; +} + +.filter { + width: 70%; + height: 40px; + text-align: center; +} +.order { + width: 10%; +} +</style> diff --git a/src/components/VnTable/VnVisibleColumn.vue b/src/components/VnTable/VnVisibleColumn.vue index dad950d73..6d15c585e 100644 --- a/src/components/VnTable/VnVisibleColumn.vue +++ b/src/components/VnTable/VnVisibleColumn.vue @@ -32,16 +32,21 @@ const areAllChecksMarked = computed(() => { function setUserConfigViewData(data, isLocal) { if (!data) return; - // Importante: El name de las columnas de la tabla debe conincidir con el name de las variables que devuelve la view config if (!isLocal) localColumns.value = []; - // Array to Object + const skippeds = $props.skip.reduce((a, v) => ({ ...a, [v]: v }), {}); for (let column of columns.value) { - const { label, name } = column; + const { label, name, labelAbbreviation } = column; if (skippeds[name]) continue; column.visible = data[name] ?? true; - if (!isLocal) localColumns.value.push({ name, label, visible: column.visible }); + if (!isLocal) + localColumns.value.push({ + name, + label, + labelAbbreviation, + visible: column.visible, + }); } } @@ -152,7 +157,11 @@ onMounted(async () => { <QCheckbox v-for="col in localColumns" :key="col.name" - :label="col.label ?? col.name" + :label=" + col?.labelAbbreviation + ? col.labelAbbreviation + ` (${col.label ?? col.name})` + : (col.label ?? col.name) + " v-model="col.visible" /> </div> diff --git a/src/components/__tests__/UserPanel.spec.js b/src/components/__tests__/UserPanel.spec.js index ac20f911e..9e449745a 100644 --- a/src/components/__tests__/UserPanel.spec.js +++ b/src/components/__tests__/UserPanel.spec.js @@ -1,61 +1,65 @@ -import { vi, describe, expect, it, beforeEach, beforeAll, afterEach } from 'vitest'; +import { vi, describe, expect, it, beforeEach, afterEach } from 'vitest'; import { createWrapper } from 'app/test/vitest/helper'; import UserPanel from 'src/components/UserPanel.vue'; import axios from 'axios'; import { useState } from 'src/composables/useState'; +vi.mock('src/utils/quasarLang', () => ({ + default: vi.fn(), +})); + describe('UserPanel', () => { - let wrapper; - let vm; - let state; + let wrapper; + let vm; + let state; - beforeEach(() => { - wrapper = createWrapper(UserPanel, {}); - state = useState(); - state.setUser({ - id: 115, - name: 'itmanagement', - nickname: 'itManagementNick', - lang: 'en', - darkMode: false, - companyFk: 442, - warehouseFk: 1, - }); - wrapper = wrapper.wrapper; - vm = wrapper.vm; + beforeEach(() => { + wrapper = createWrapper(UserPanel, {}); + state = useState(); + state.setUser({ + id: 115, + name: 'itmanagement', + nickname: 'itManagementNick', + lang: 'en', + darkMode: false, + companyFk: 442, + warehouseFk: 1, }); + wrapper = wrapper.wrapper; + vm = wrapper.vm; + }); - afterEach(() => { - vi.clearAllMocks(); - }); + afterEach(() => { + vi.clearAllMocks(); + }); - it('should fetch warehouses data on mounted', async () => { - const fetchData = wrapper.findComponent({ name: 'FetchData' }); - expect(fetchData.props('url')).toBe('Warehouses'); - expect(fetchData.props('autoLoad')).toBe(true); - }); + it('should fetch warehouses data on mounted', async () => { + const fetchData = wrapper.findComponent({ name: 'FetchData' }); + expect(fetchData.props('url')).toBe('Warehouses'); + expect(fetchData.props('autoLoad')).toBe(true); + }); - it('should toggle dark mode correctly and update preferences', async () => { - await vm.saveDarkMode(true); - expect(axios.patch).toHaveBeenCalledWith('/UserConfigs/115', { darkMode: true }); - expect(vm.user.darkMode).toBe(true); - vm.updatePreferences(); - expect(vm.darkMode).toBe(true); - }); + it('should toggle dark mode correctly and update preferences', async () => { + await vm.saveDarkMode(true); + expect(axios.patch).toHaveBeenCalledWith('/UserConfigs/115', { darkMode: true }); + expect(vm.user.darkMode).toBe(true); + await vm.updatePreferences(); + expect(vm.darkMode).toBe(true); + }); - it('should change user language and update preferences', async () => { - const userLanguage = 'es'; - await vm.saveLanguage(userLanguage); - expect(axios.patch).toHaveBeenCalledWith('/VnUsers/115', { lang: userLanguage }); - expect(vm.user.lang).toBe(userLanguage); - vm.updatePreferences(); - expect(vm.locale).toBe(userLanguage); - }); + it('should change user language and update preferences', async () => { + const userLanguage = 'es'; + await vm.saveLanguage(userLanguage); + expect(axios.patch).toHaveBeenCalledWith('/VnUsers/115', { lang: userLanguage }); + expect(vm.user.lang).toBe(userLanguage); + await vm.updatePreferences(); + expect(vm.locale).toBe(userLanguage); + }); - it('should update user data', async () => { - const key = 'name'; - const value = 'itboss'; - await vm.saveUserData(key, value); - expect(axios.post).toHaveBeenCalledWith('UserConfigs/setUserConfig', { [key]: value }); - }); -}); + it('should update user data', async () => { + const key = 'name'; + const value = 'itboss'; + await vm.saveUserData(key, value); + expect(axios.post).toHaveBeenCalledWith('UserConfigs/setUserConfig', { [key]: value }); + }); +}); \ No newline at end of file diff --git a/src/components/common/VnCheckbox.vue b/src/components/common/VnCheckbox.vue new file mode 100644 index 000000000..27131d45e --- /dev/null +++ b/src/components/common/VnCheckbox.vue @@ -0,0 +1,43 @@ +<script setup> +import { computed } from 'vue'; + +const model = defineModel({ type: [Number, Boolean] }); +const $props = defineProps({ + info: { + type: String, + default: null, + }, +}); + +const checkboxModel = computed({ + get() { + if (typeof model.value === 'number') { + return model.value !== 0; + } + return model.value; + }, + set(value) { + if (typeof model.value === 'number') { + model.value = value ? 1 : 0; + } else { + model.value = value; + } + }, +}); +</script> +<template> + <div> + <QCheckbox v-bind="$attrs" v-on="$attrs" v-model="checkboxModel" /> + <QIcon + v-if="info" + v-bind="$attrs" + class="cursor-info q-ml-sm" + name="info" + size="sm" + > + <QTooltip> + {{ info }} + </QTooltip> + </QIcon> + </div> +</template> diff --git a/src/components/common/VnColor.vue b/src/components/common/VnColor.vue new file mode 100644 index 000000000..8a5a787b0 --- /dev/null +++ b/src/components/common/VnColor.vue @@ -0,0 +1,32 @@ +<script setup> +const $props = defineProps({ + colors: { + type: String, + default: '{"value": []}', + }, +}); + +const colorArray = JSON.parse($props.colors)?.value; +const maxHeight = 30; +const colorHeight = maxHeight / colorArray?.length; +</script> +<template> + <div v-if="colors" class="color-div" :style="{ height: `${maxHeight}px` }"> + <div + v-for="(color, index) in colorArray" + :key="index" + :style="{ + backgroundColor: `#${color}`, + height: `${colorHeight}px`, + }" + > + + </div> + </div> +</template> +<style scoped> +.color-div { + display: flex; + flex-direction: column; +} +</style> diff --git a/src/components/common/VnComponent.vue b/src/components/common/VnComponent.vue index 580bcf348..d9d1ea26b 100644 --- a/src/components/common/VnComponent.vue +++ b/src/components/common/VnComponent.vue @@ -17,6 +17,8 @@ const $props = defineProps({ }, }); +const emit = defineEmits(['blur']); + const componentArray = computed(() => { if (typeof $props.prop === 'object') return [$props.prop]; return $props.prop; @@ -54,6 +56,7 @@ function toValueAttrs(attrs) { v-bind="mix(toComponent).attrs" v-on="mix(toComponent).event ?? {}" v-model="model" + @blur="emit('blur')" /> </span> </template> diff --git a/src/components/common/VnInput.vue b/src/components/common/VnInput.vue index 78f08a479..aeb4a31fd 100644 --- a/src/components/common/VnInput.vue +++ b/src/components/common/VnInput.vue @@ -11,6 +11,7 @@ const emit = defineEmits([ 'update:options', 'keyup.enter', 'remove', + 'blur', ]); const $props = defineProps({ @@ -136,6 +137,7 @@ const handleUppercase = () => { :type="$attrs.type" :class="{ required: isRequired }" @keyup.enter="emit('keyup.enter')" + @blur="emit('blur')" @keydown="handleKeydown" :clearable="false" :rules="mixinRules" @@ -143,7 +145,7 @@ const handleUppercase = () => { hide-bottom-space :data-cy="$attrs.dataCy ?? $attrs.label + '_input'" > - <template #prepend> + <template #prepend v-if="$slots.prepend"> <slot name="prepend" /> </template> <template #append> @@ -168,11 +170,11 @@ const handleUppercase = () => { } " ></QIcon> - + <QIcon name="match_case" size="xs" - v-if="!$attrs.disabled && !($attrs.readonly) && $props.uppercase" + v-if="!$attrs.disabled && !$attrs.readonly && $props.uppercase" @click="handleUppercase" class="uppercase-icon" > @@ -180,7 +182,7 @@ const handleUppercase = () => { {{ t('Convert to uppercase') }} </QTooltip> </QIcon> - + <slot name="append" v-if="$slots.append && !$attrs.disabled" /> <QIcon v-if="info" name="info"> <QTooltip max-width="350px"> @@ -194,13 +196,15 @@ const handleUppercase = () => { <style> .uppercase-icon { - transition: color 0.3s, transform 0.2s; - cursor: pointer; + transition: + color 0.3s, + transform 0.2s; + cursor: pointer; } .uppercase-icon:hover { - color: #ed9937; - transform: scale(1.2); + color: #ed9937; + transform: scale(1.2); } </style> <i18n> @@ -214,4 +218,4 @@ const handleUppercase = () => { maxLength: El valor excede los {value} carácteres inputMax: Debe ser menor a {value} Convert to uppercase: Convertir a mayúsculas -</i18n> \ No newline at end of file +</i18n> diff --git a/src/components/common/VnInputDate.vue b/src/components/common/VnInputDate.vue index a8888aad8..73c825e1e 100644 --- a/src/components/common/VnInputDate.vue +++ b/src/components/common/VnInputDate.vue @@ -42,7 +42,7 @@ const formattedDate = computed({ if (value.at(2) == '/') value = value.split('/').reverse().join('/'); value = date.formatDate( new Date(value).toISOString(), - 'YYYY-MM-DDTHH:mm:ss.SSSZ' + 'YYYY-MM-DDTHH:mm:ss.SSSZ', ); } const [year, month, day] = value.split('-').map((e) => parseInt(e)); @@ -55,7 +55,7 @@ const formattedDate = computed({ orgDate.getHours(), orgDate.getMinutes(), orgDate.getSeconds(), - orgDate.getMilliseconds() + orgDate.getMilliseconds(), ); } } @@ -64,7 +64,7 @@ const formattedDate = computed({ }); const popupDate = computed(() => - model.value ? date.formatDate(new Date(model.value), 'YYYY/MM/DD') : model.value + model.value ? date.formatDate(new Date(model.value), 'YYYY/MM/DD') : model.value, ); onMounted(() => { // fix quasar bug @@ -73,7 +73,7 @@ onMounted(() => { watch( () => model.value, (val) => (formattedDate.value = val), - { immediate: true } + { immediate: true }, ); const styleAttrs = computed(() => { diff --git a/src/components/common/VnInputNumber.vue b/src/components/common/VnInputNumber.vue index 165cfae3d..274f78b21 100644 --- a/src/components/common/VnInputNumber.vue +++ b/src/components/common/VnInputNumber.vue @@ -8,6 +8,7 @@ defineProps({ }); const model = defineModel({ type: [Number, String] }); +const emit = defineEmits(['blur']); </script> <template> <VnInput @@ -24,5 +25,6 @@ const model = defineModel({ type: [Number, String] }); model = parseFloat(val).toFixed(decimalPlaces); } " + @blur="emit('blur')" /> </template> diff --git a/src/components/common/VnPopupProxy.vue b/src/components/common/VnPopupProxy.vue new file mode 100644 index 000000000..f386bfff8 --- /dev/null +++ b/src/components/common/VnPopupProxy.vue @@ -0,0 +1,38 @@ +<script setup> +import { ref } from 'vue'; + +defineProps({ + label: { + type: String, + default: '', + }, + icon: { + type: String, + required: true, + default: null, + }, + color: { + type: String, + default: 'primary', + }, + tooltip: { + type: String, + default: null, + }, +}); +const popupProxyRef = ref(null); +</script> + +<template> + <QBtn :color="$props.color" :icon="$props.icon" :label="$t($props.label)"> + <template #default> + <slot name="extraIcon"></slot> + <QPopupProxy ref="popupProxyRef" style="max-width: none"> + <QCard> + <slot :popup="popupProxyRef"></slot> + </QCard> + </QPopupProxy> + <QTooltip>{{ $t($props.tooltip) }}</QTooltip> + </template> + </QBtn> +</template> diff --git a/src/components/common/VnSelect.vue b/src/components/common/VnSelect.vue index c850f2e53..339f90e0e 100644 --- a/src/components/common/VnSelect.vue +++ b/src/components/common/VnSelect.vue @@ -171,7 +171,8 @@ onMounted(() => { }); const arrayDataKey = - $props.dataKey ?? ($props.url?.length > 0 ? $props.url : $attrs.name ?? $attrs.label); + $props.dataKey ?? + ($props.url?.length > 0 ? $props.url : ($attrs.name ?? $attrs.label)); const arrayData = useArrayData(arrayDataKey, { url: $props.url, @@ -220,7 +221,7 @@ async function fetchFilter(val) { optionFilterValue.value ?? (new RegExp(/\d/g).test(val) ? optionValue.value - : optionFilter.value ?? optionLabel.value); + : (optionFilter.value ?? optionLabel.value)); let defaultWhere = {}; if ($props.filterOptions.length) { @@ -239,7 +240,7 @@ async function fetchFilter(val) { const { data } = await arrayData.applyFilter( { filter: filterOptions }, - { updateRouter: false } + { updateRouter: false }, ); setOptions(data); return data; @@ -272,7 +273,7 @@ async function filterHandler(val, update) { ref.setOptionIndex(-1); ref.moveOptionSelection(1, true); } - } + }, ); } @@ -308,7 +309,7 @@ function handleKeyDown(event) { if (inputValue) { const matchingOption = myOptions.value.find( (option) => - option[optionLabel.value].toLowerCase() === inputValue.toLowerCase() + option[optionLabel.value].toLowerCase() === inputValue.toLowerCase(), ); if (matchingOption) { @@ -320,11 +321,11 @@ function handleKeyDown(event) { } const focusableElements = document.querySelectorAll( - 'a:not([disabled]), button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), details:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])' + 'a:not([disabled]), button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), details:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])', ); const currentIndex = Array.prototype.indexOf.call( focusableElements, - event.target + event.target, ); if (currentIndex >= 0 && currentIndex < focusableElements.length - 1) { focusableElements[currentIndex + 1].focus(); diff --git a/src/components/common/VnSelectCache.vue b/src/components/common/VnSelectCache.vue index 29cf22dc5..f0f3357f6 100644 --- a/src/components/common/VnSelectCache.vue +++ b/src/components/common/VnSelectCache.vue @@ -14,7 +14,7 @@ const $props = defineProps({ }, }); const options = ref([]); - +const emit = defineEmits(['blur']); onBeforeMount(async () => { const { url, optionValue, optionLabel } = useAttrs(); const findBy = $props.find ?? url?.charAt(0)?.toLocaleLowerCase() + url?.slice(1, -1); @@ -35,5 +35,5 @@ onBeforeMount(async () => { }); </script> <template> - <VnSelect v-bind="$attrs" :options="$attrs.options ?? options" /> + <VnSelect v-bind="$attrs" :options="$attrs.options ?? options" @blur="emit('blur')" /> </template> diff --git a/src/components/common/VnSelectDialog.vue b/src/components/common/VnSelectDialog.vue index a4cd0011d..41730b217 100644 --- a/src/components/common/VnSelectDialog.vue +++ b/src/components/common/VnSelectDialog.vue @@ -37,7 +37,6 @@ const isAllowedToCreate = computed(() => { defineExpose({ vnSelectDialogRef: select }); </script> - <template> <VnSelect ref="select" @@ -67,7 +66,6 @@ defineExpose({ vnSelectDialogRef: select }); </template> </VnSelect> </template> - <style lang="scss" scoped> .default-icon { cursor: pointer; diff --git a/src/components/common/VnSelectSupplier.vue b/src/components/common/VnSelectSupplier.vue index f86db4f2d..5b52ae75b 100644 --- a/src/components/common/VnSelectSupplier.vue +++ b/src/components/common/VnSelectSupplier.vue @@ -1,9 +1,7 @@ <script setup> -import { computed } from 'vue'; import VnSelect from 'components/common/VnSelect.vue'; const model = defineModel({ type: [String, Number, Object] }); -const url = 'Suppliers'; </script> <template> @@ -11,11 +9,13 @@ const url = 'Suppliers'; :label="$t('globals.supplier')" v-bind="$attrs" v-model="model" - :url="url" + url="Suppliers" option-value="id" option-label="nickname" :fields="['id', 'name', 'nickname', 'nif']" + :filter-options="['id', 'name', 'nickname', 'nif']" sort-by="name ASC" + data-cy="vnSupplierSelect" > <template #option="scope"> <QItem v-bind="scope.itemProps"> diff --git a/src/components/common/VnSelectTravelExtended.vue b/src/components/common/VnSelectTravelExtended.vue new file mode 100644 index 000000000..46538f5f9 --- /dev/null +++ b/src/components/common/VnSelectTravelExtended.vue @@ -0,0 +1,50 @@ +<script setup> +import VnSelectDialog from './VnSelectDialog.vue'; +import FilterTravelForm from 'src/components/FilterTravelForm.vue'; +import { useI18n } from 'vue-i18n'; +import { toDate } from 'src/filters'; +const { t } = useI18n(); + +const $props = defineProps({ + data: { + type: Object, + required: true, + }, + onFilterTravelSelected: { + type: Function, + required: true, + }, +}); +</script> +<template> + <VnSelectDialog + :label="t('entry.basicData.travel')" + v-bind="$attrs" + url="Travels/filter" + :fields="['id', 'warehouseInName']" + option-value="id" + option-label="warehouseInName" + map-options + hide-selected + :required="true" + action-icon="filter_alt" + :roles-allowed-to-create="['buyer']" + > + <template #form> + <FilterTravelForm @travel-selected="onFilterTravelSelected(data, $event)" /> + </template> + <template #option="scope"> + <QItem v-bind="scope.itemProps"> + <QItemSection> + <QItemLabel> + {{ scope.opt?.agencyModeName }} - + {{ scope.opt?.warehouseInName }} + ({{ toDate(scope.opt?.shipped) }}) → + {{ scope.opt?.warehouseOutName }} + ({{ toDate(scope.opt?.landed) }}) + </QItemLabel> + </QItemSection> + </QItem> + </template> + </VnSelectDialog> +</template> diff --git a/src/components/ui/CardDescriptor.vue b/src/components/ui/CardDescriptor.vue index 275d919d6..e6e7e6fa0 100644 --- a/src/components/ui/CardDescriptor.vue +++ b/src/components/ui/CardDescriptor.vue @@ -6,6 +6,7 @@ import { useArrayData } from 'composables/useArrayData'; import { useSummaryDialog } from 'src/composables/useSummaryDialog'; import { useState } from 'src/composables/useState'; import { useRoute } from 'vue-router'; +import { useClipboard } from 'src/composables/useClipboard'; import VnMoreOptions from './VnMoreOptions.vue'; const $props = defineProps({ @@ -29,10 +30,6 @@ const $props = defineProps({ type: String, default: null, }, - module: { - type: String, - default: null, - }, summary: { type: Object, default: null, @@ -46,6 +43,7 @@ const $props = defineProps({ const state = useState(); const route = useRoute(); const { t } = useI18n(); +const { copyText } = useClipboard(); const { viewSummary } = useSummaryDialog(); let arrayData; let store; @@ -57,7 +55,7 @@ defineExpose({ getData }); onBeforeMount(async () => { arrayData = useArrayData($props.dataKey, { url: $props.url, - filter: $props.filter, + userFilter: $props.filter, skip: 0, oneRecord: true, }); @@ -103,6 +101,14 @@ function getValueFromPath(path) { return current; } +function copyIdText(id) { + copyText(id, { + component: { + copyValue: id, + }, + }); +} + const emit = defineEmits(['onFetch']); const iconModule = computed(() => route.matched[1].meta.icon); @@ -148,7 +154,9 @@ const toModule = computed(() => {{ t('components.smartCard.openSummary') }} </QTooltip> </QBtn> - <RouterLink :to="{ name: `${module}Summary`, params: { id: entity.id } }"> + <RouterLink + :to="{ name: `${dataKey}Summary`, params: { id: entity.id } }" + > <QBtn class="link" color="white" @@ -184,9 +192,22 @@ const toModule = computed(() => </slot> </div> </QItemLabel> - <QItem dense> + <QItem> <QItemLabel class="subtitle" caption> #{{ getValueFromPath(subtitle) ?? entity.id }} + <QBtn + round + flat + dense + size="sm" + icon="content_copy" + color="primary" + @click.stop="copyIdText(entity.id)" + > + <QTooltip> + {{ t('globals.copyId') }} + </QTooltip> + </QBtn> </QItemLabel> </QItem> </QList> @@ -294,3 +315,11 @@ const toModule = computed(() => } } </style> +<i18n> + en: + globals: + copyId: Copy ID + es: + globals: + copyId: Copiar ID +</i18n> diff --git a/src/components/ui/SkeletonDescriptor.vue b/src/components/ui/SkeletonDescriptor.vue index 9679751f5..f9188221a 100644 --- a/src/components/ui/SkeletonDescriptor.vue +++ b/src/components/ui/SkeletonDescriptor.vue @@ -1,53 +1,32 @@ +<script setup> +defineProps({ + hasImage: { + type: Boolean, + default: false, + }, +}); +</script> <template> - <div id="descriptor-skeleton"> + <div id="descriptor-skeleton" class="bg-vn-page"> <div class="row justify-between q-pa-sm"> - <QSkeleton square size="40px" /> - <QSkeleton square size="40px" /> - <QSkeleton square height="40px" width="20px" /> + <QSkeleton square size="30px" v-for="i in 3" :key="i" /> </div> - <div class="col justify-between q-pa-sm q-gutter-y-xs"> - <QSkeleton square height="40px" width="150px" /> - <QSkeleton square height="30px" width="70px" /> + <div class="q-pa-xs" v-if="hasImage"> + <QSkeleton square height="200px" width="100%" /> </div> - <div class="col q-pl-sm q-pa-sm q-mb-md"> - <div class="row justify-between"> - <QSkeleton type="text" square height="30px" width="20%" /> - <QSkeleton type="text" square height="30px" width="60%" /> - </div> - <div class="row justify-between"> - <QSkeleton type="text" square height="30px" width="20%" /> - <QSkeleton type="text" square height="30px" width="60%" /> - </div> - <div class="row justify-between"> - <QSkeleton type="text" square height="30px" width="20%" /> - <QSkeleton type="text" square height="30px" width="60%" /> - </div> - <div class="row justify-between"> - <QSkeleton type="text" square height="30px" width="20%" /> - <QSkeleton type="text" square height="30px" width="60%" /> - </div> - <div class="row justify-between"> - <QSkeleton type="text" square height="30px" width="20%" /> - <QSkeleton type="text" square height="30px" width="60%" /> - </div> - <div class="row justify-between"> - <QSkeleton type="text" square height="30px" width="20%" /> - <QSkeleton type="text" square height="30px" width="60%" /> + <div class="col justify-between q-pa-md q-gutter-y-xs"> + <QSkeleton square height="25px" width="150px" /> + <QSkeleton square height="15px" width="70px" /> + </div> + <div class="q-pl-sm q-pa-sm q-mb-md"> + <div class="row q-gutter-x-sm q-pa-none q-ma-none" v-for="i in 5" :key="i"> + <QSkeleton type="text" square height="20px" width="30%" /> + <QSkeleton type="text" square height="20px" width="60%" /> </div> </div> - <QCardActions> - <QSkeleton size="40px" /> - <QSkeleton size="40px" /> - <QSkeleton size="40px" /> - <QSkeleton size="40px" /> - <QSkeleton size="40px" /> + <QCardActions class="q-gutter-x-sm justify-between"> + <QSkeleton size="40px" v-for="i in 5" :key="i" /> </QCardActions> </div> </template> - -<style lang="scss" scoped> -#descriptor-skeleton .q-card__actions { - justify-content: space-between; -} -</style> diff --git a/src/components/ui/VnConfirm.vue b/src/components/ui/VnConfirm.vue index a02b56bdb..c6f539879 100644 --- a/src/components/ui/VnConfirm.vue +++ b/src/components/ui/VnConfirm.vue @@ -82,7 +82,7 @@ function cancel() { @click="cancel()" /> </QCardSection> - <QCardSection class="q-pb-none"> + <QCardSection class="q-pb-none" data-cy="VnConfirm_message"> <span v-if="message !== false" v-html="message" /> </QCardSection> <QCardSection class="row items-center q-pt-none"> @@ -95,6 +95,7 @@ function cancel() { :disable="isLoading" flat @click="cancel()" + data-cy="VnConfirm_cancel" /> <QBtn :label="t('globals.confirm')" diff --git a/src/components/ui/VnFilterPanel.vue b/src/components/ui/VnFilterPanel.vue index 80128018a..d6b525dc8 100644 --- a/src/components/ui/VnFilterPanel.vue +++ b/src/components/ui/VnFilterPanel.vue @@ -293,6 +293,9 @@ const getLocale = (label) => { /> </template> <style scoped lang="scss"> +.q-field__label.no-pointer-events.absolute.ellipsis { + margin-left: 6px !important; +} .list { width: 256px; } diff --git a/src/components/ui/VnMoreOptions.vue b/src/components/ui/VnMoreOptions.vue index 39e84be2b..8a1c7a0f2 100644 --- a/src/components/ui/VnMoreOptions.vue +++ b/src/components/ui/VnMoreOptions.vue @@ -11,7 +11,7 @@ <QTooltip> {{ $t('components.cardDescriptor.moreOptions') }} </QTooltip> - <QMenu ref="menuRef"> + <QMenu ref="menuRef" data-cy="descriptor-more-opts-menu"> <QList> <slot name="menu" :menu-ref="$refs.menuRef" /> </QList> diff --git a/src/components/ui/VnNotes.vue b/src/components/ui/VnNotes.vue index 5b1d6e726..ec6289a67 100644 --- a/src/components/ui/VnNotes.vue +++ b/src/components/ui/VnNotes.vue @@ -18,7 +18,12 @@ import VnInput from 'components/common/VnInput.vue'; const emit = defineEmits(['onFetch']); -const $attrs = useAttrs(); +const originalAttrs = useAttrs(); + +const $attrs = computed(() => { + const { style, ...rest } = originalAttrs; + return rest; +}); const isRequired = computed(() => { return Object.keys($attrs).includes('required') diff --git a/src/components/ui/VnStockValueDisplay.vue b/src/components/ui/VnStockValueDisplay.vue new file mode 100644 index 000000000..d8f43323b --- /dev/null +++ b/src/components/ui/VnStockValueDisplay.vue @@ -0,0 +1,41 @@ +<script setup> +import { toPercentage } from 'filters/index'; + +import { computed } from 'vue'; + +const props = defineProps({ + value: { + type: Number, + required: true, + }, +}); + +const valueClass = computed(() => + props.value === 0 ? 'neutral' : props.value > 0 ? 'positive' : 'negative', +); +const iconName = computed(() => + props.value === 0 ? 'equal' : props.value > 0 ? 'arrow_upward' : 'arrow_downward', +); +const formattedValue = computed(() => props.value); +</script> +<template> + <span :class="valueClass"> + <QIcon :name="iconName" size="sm" class="value-icon" /> + {{ toPercentage(formattedValue) }} + </span> +</template> + +<style lang="scss" scoped> +.positive { + color: $secondary; +} +.negative { + color: $negative; +} +.neutral { + color: $primary; +} +.value-icon { + margin-right: 4px; +} +</style> diff --git a/src/composables/checkEntryLock.js b/src/composables/checkEntryLock.js new file mode 100644 index 000000000..f964dea27 --- /dev/null +++ b/src/composables/checkEntryLock.js @@ -0,0 +1,65 @@ +import { useQuasar } from 'quasar'; +import { useI18n } from 'vue-i18n'; +import { useRouter } from 'vue-router'; +import axios from 'axios'; +import VnConfirm from 'components/ui/VnConfirm.vue'; + +export async function checkEntryLock(entryFk, userFk) { + const { t } = useI18n(); + const quasar = useQuasar(); + const { push } = useRouter(); + const { data } = await axios.get(`Entries/${entryFk}`, { + params: { + filter: JSON.stringify({ + fields: ['id', 'locked', 'lockerUserFk'], + include: { relation: 'user', scope: { fields: ['id', 'nickname'] } }, + }), + }, + }); + const entryConfig = await axios.get('EntryConfigs/findOne'); + + if (data?.lockerUserFk && data?.locked) { + const now = new Date(Date.vnNow()).getTime(); + const lockedTime = new Date(data.locked).getTime(); + const timeDiff = (now - lockedTime) / 1000; + const isMaxTimeLockExceeded = entryConfig.data.maxLockTime > timeDiff; + + if (data?.lockerUserFk !== userFk && isMaxTimeLockExceeded) { + quasar + .dialog({ + component: VnConfirm, + componentProps: { + 'data-cy': 'entry-lock-confirm', + title: t('entry.lock.title'), + message: t('entry.lock.message', { + userName: data?.user?.nickname, + time: timeDiff / 60, + }), + }, + }) + .onOk( + async () => + await axios.patch(`Entries/${entryFk}`, { + locked: Date.vnNow(), + lockerUserFk: userFk, + }), + ) + .onCancel(() => { + push({ path: `summary` }); + }); + } + } else { + await axios + .patch(`Entries/${entryFk}`, { + locked: Date.vnNow(), + lockerUserFk: userFk, + }) + .then( + quasar.notify({ + message: t('entry.lock.success'), + color: 'positive', + group: false, + }), + ); + } +} diff --git a/src/composables/getColAlign.js b/src/composables/getColAlign.js new file mode 100644 index 000000000..c0338a984 --- /dev/null +++ b/src/composables/getColAlign.js @@ -0,0 +1,21 @@ +export function getColAlign(col) { + let align; + switch (col.component) { + case 'select': + align = 'left'; + break; + case 'number': + align = 'right'; + break; + case 'date': + case 'checkbox': + align = 'center'; + break; + default: + align = col?.align; + } + + if (/^is[A-Z]/.test(col.name) || /^has[A-Z]/.test(col.name)) align = 'center'; + + return 'text-' + (align ?? 'center'); +} diff --git a/src/composables/useRole.js b/src/composables/useRole.js index 3ec65dd0a..ff54b409c 100644 --- a/src/composables/useRole.js +++ b/src/composables/useRole.js @@ -27,6 +27,15 @@ export function useRole() { return false; } + function likeAny(roles) { + const roleStore = state.getRoles(); + for (const role of roles) { + if (!roleStore.value.findIndex((rs) => rs.startsWith(role)) !== -1) + return true; + } + + return false; + } function isEmployee() { return hasAny(['employee']); } @@ -35,6 +44,7 @@ export function useRole() { isEmployee, fetch, hasAny, + likeAny, state, }; } diff --git a/src/css/app.scss b/src/css/app.scss index 59e945f05..0c5dc97fa 100644 --- a/src/css/app.scss +++ b/src/css/app.scss @@ -21,7 +21,10 @@ body.body--light { .q-header .q-toolbar { color: var(--vn-text-color); } + + --vn-color-negative: $negative; } + body.body--dark { --vn-header-color: #5d5d5d; --vn-page-color: #222; @@ -37,6 +40,8 @@ body.body--dark { --vn-text-color-contrast: black; background-color: var(--vn-page-color); + + --vn-color-negative: $negative; } a { @@ -75,7 +80,6 @@ a { text-decoration: underline; } -// Removes chrome autofill background input:-webkit-autofill, select:-webkit-autofill { color: var(--vn-text-color); @@ -149,11 +153,6 @@ select:-webkit-autofill { cursor: pointer; } -.vn-table-separation-row { - height: 16px !important; - background-color: var(--vn-section-color) !important; -} - /* Estilo para el asterisco en campos requeridos */ .q-field.required .q-field__label:after { content: ' *'; @@ -230,10 +229,12 @@ input::-webkit-inner-spin-button { max-width: 100%; } -.q-table__container { - /* ===== Scrollbar CSS ===== / - / Firefox */ +.remove-bg { + filter: brightness(1.1); + mix-blend-mode: multiply; +} +.q-table__container { * { scrollbar-width: auto; scrollbar-color: var(--vn-label-color) transparent; @@ -274,8 +275,6 @@ input::-webkit-inner-spin-button { font-size: 11pt; } td { - font-size: 11pt; - border-top: 1px solid var(--vn-page-color); border-collapse: collapse; } } @@ -319,9 +318,6 @@ input::-webkit-inner-spin-button { max-width: fit-content; } -.row > .column:has(.q-checkbox) { - max-width: fit-content; -} .q-field__inner { .q-field__control { min-height: auto !important; diff --git a/src/css/quasar.variables.scss b/src/css/quasar.variables.scss index d6e992437..22c6d2b56 100644 --- a/src/css/quasar.variables.scss +++ b/src/css/quasar.variables.scss @@ -13,7 +13,7 @@ // Tip: Use the "Theme Builder" on Quasar's documentation website. // Tip: to add new colors https://quasar.dev/style/color-palette/#adding-your-own-colors $primary: #ec8916; -$secondary: $primary; +$secondary: #89be34; $positive: #c8e484; $negative: #fb5252; $info: #84d0e2; @@ -30,7 +30,9 @@ $color-spacer: #7979794d; $border-thin-light: 1px solid $color-spacer-light; $primary-light: #f5b351; $dark-shadow-color: black; -$layout-shadow-dark: 0 0 10px 2px #00000033, 0 0px 10px #0000003d; +$layout-shadow-dark: + 0 0 10px 2px #00000033, + 0 0px 10px #0000003d; $spacing-md: 16px; $color-font-secondary: #777; $width-xs: 400px; diff --git a/src/i18n/locale/en.yml b/src/i18n/locale/en.yml index d615eef4c..e3b690042 100644 --- a/src/i18n/locale/en.yml +++ b/src/i18n/locale/en.yml @@ -33,6 +33,7 @@ globals: reset: Reset close: Close cancel: Cancel + isSaveAndContinue: Save and continue clone: Clone confirm: Confirm assign: Assign @@ -155,6 +156,7 @@ globals: changeState: Change state raid: 'Raid {daysInForward} days' isVies: Vies + noData: No data available pageTitles: logIn: Login addressEdit: Update address @@ -167,6 +169,7 @@ globals: workCenters: Work centers modes: Modes zones: Zones + negative: Negative zonesList: List deliveryDays: Delivery days upcomingDeliveries: Upcoming deliveries @@ -174,6 +177,7 @@ globals: alias: Alias aliasUsers: Users subRoles: Subroles + myAccount: Mi cuenta inheritedRoles: Inherited Roles customers: Customers customerCreate: New customer @@ -406,6 +410,106 @@ cau: subtitle: By sending this ticket, all the data related to the error, the section, the user, etc., are already sent. inputLabel: Explain why this error should not appear askPrivileges: Ask for privileges +entry: + list: + newEntry: New entry + tableVisibleColumns: + isExcludedFromAvailable: Exclude from inventory + isOrdered: Ordered + isConfirmed: Ready to label + isReceived: Received + isRaid: Raid + landed: Date + supplierFk: Supplier + reference: Ref/Alb/Guide + invoiceNumber: Invoice + agencyModeId: Agency + isBooked: Booked + companyFk: Company + evaNotes: Notes + warehouseOutFk: Origin + warehouseInFk: Destiny + entryTypeDescription: Entry type + invoiceAmount: Import + travelFk: Travel + summary: + invoiceAmount: Amount + commission: Commission + currency: Currency + invoiceNumber: Invoice number + ordered: Ordered + booked: Booked + excludedFromAvailable: Inventory + travelReference: Reference + travelAgency: Agency + travelShipped: Shipped + travelDelivered: Delivered + travelLanded: Landed + travelReceived: Received + buys: Buys + stickers: Stickers + package: Package + packing: Pack. + grouping: Group. + buyingValue: Buying value + import: Import + pvp: PVP + basicData: + travel: Travel + currency: Currency + commission: Commission + observation: Observation + booked: Booked + excludedFromAvailable: Inventory + buys: + observations: Observations + packagingFk: Box + color: Color + printedStickers: Printed stickers + notes: + observationType: Observation type + latestBuys: + tableVisibleColumns: + image: Picture + itemFk: Item ID + weightByPiece: Weight/Piece + isActive: Active + family: Family + entryFk: Entry + freightValue: Freight value + comissionValue: Commission value + packageValue: Package value + isIgnored: Is ignored + price2: Grouping + price3: Packing + minPrice: Min + ektFk: Ekt + packingOut: Package out + landing: Landing + isExcludedFromAvailable: Exclude from inventory + isRaid: Raid + invoiceNumber: Invoice + reference: Ref/Alb/Guide + params: + isExcludedFromAvailable: Excluir del inventario + isOrdered: Pedida + isConfirmed: Lista para etiquetar + isReceived: Recibida + isRaid: Redada + landed: Fecha + supplierFk: Proveedor + invoiceNumber: Nº Factura + reference: Ref/Alb/Guía + agencyModeId: Agencia + isBooked: Asentado + companyFk: Empresa + travelFk: Envio + evaNotes: Notas + warehouseOutFk: Origen + warehouseInFk: Destino + entryTypeDescription: Tipo entrada + invoiceAmount: Importe + dated: Fecha ticket: params: ticketFk: Ticket ID diff --git a/src/i18n/locale/es.yml b/src/i18n/locale/es.yml index ea5fa9e41..1dbe25366 100644 --- a/src/i18n/locale/es.yml +++ b/src/i18n/locale/es.yml @@ -33,9 +33,11 @@ globals: reset: Restaurar close: Cerrar cancel: Cancelar + isSaveAndContinue: Guardar y continuar clone: Clonar confirm: Confirmar assign: Asignar + replace: Sustituir back: Volver yes: Si no: No @@ -48,6 +50,7 @@ globals: rowRemoved: Fila eliminada pleaseWait: Por favor espera... noPinnedModules: No has fijado ningún módulo + split: Split summary: basicData: Datos básicos daysOnward: Días adelante @@ -55,8 +58,8 @@ globals: today: Hoy yesterday: Ayer dateFormat: es-ES - microsip: Abrir en MicroSIP noSelectedRows: No tienes ninguna línea seleccionada + microsip: Abrir en MicroSIP downloadCSVSuccess: Descarga de CSV exitosa reference: Referencia agency: Agencia @@ -76,8 +79,10 @@ globals: requiredField: Campo obligatorio class: clase type: Tipo - reason: motivo + reason: Motivo + removeSelection: Eliminar selección noResults: Sin resultados + results: resultados system: Sistema notificationSent: Notificación enviada warehouse: Almacén @@ -155,6 +160,7 @@ globals: changeState: Cambiar estado raid: 'Redada {daysInForward} días' isVies: Vies + noData: Datos no disponibles pageTitles: logIn: Inicio de sesión addressEdit: Modificar consignatario @@ -166,6 +172,7 @@ globals: agency: Agencia workCenters: Centros de trabajo modes: Modos + negative: Tickets negativos zones: Zonas zonesList: Listado deliveryDays: Días de entrega @@ -286,9 +293,9 @@ globals: buyRequest: Peticiones de compra wasteBreakdown: Deglose de mermas itemCreate: Nuevo artículo - tax: 'IVA' - botanical: 'Botánico' - barcode: 'Código de barras' + tax: IVA + botanical: Botánico + barcode: Código de barras itemTypeCreate: Nueva familia family: Familia lastEntries: Últimas entradas @@ -352,7 +359,7 @@ globals: from: Desde to: Hasta supplierFk: Proveedor - supplierRef: Ref. proveedor + supplierRef: Nº factura serial: Serie amount: Importe awbCode: AWB @@ -397,6 +404,87 @@ cau: subtitle: Al enviar este cau ya se envían todos los datos relacionados con el error, la sección, el usuario, etc inputLabel: Explique el motivo por el que no deberia aparecer este fallo askPrivileges: Solicitar permisos +entry: + list: + newEntry: Nueva entrada + tableVisibleColumns: + isExcludedFromAvailable: Excluir del inventario + isOrdered: Pedida + isConfirmed: Lista para etiquetar + isReceived: Recibida + isRaid: Redada + landed: Fecha + supplierFk: Proveedor + invoiceNumber: Nº Factura + reference: Ref/Alb/Guía + agencyModeId: Agencia + isBooked: Asentado + companyFk: Empresa + travelFk: Envio + evaNotes: Notas + warehouseOutFk: Origen + warehouseInFk: Destino + entryTypeDescription: Tipo entrada + invoiceAmount: Importe + summary: + invoiceAmount: Importe + commission: Comisión + currency: Moneda + invoiceNumber: Núm. factura + ordered: Pedida + booked: Contabilizada + excludedFromAvailable: Inventario + travelReference: Referencia + travelAgency: Agencia + travelShipped: F. envio + travelWarehouseOut: Alm. salida + travelDelivered: Enviada + travelLanded: F. entrega + travelReceived: Recibida + buys: Compras + stickers: Etiquetas + package: Embalaje + packing: Pack. + grouping: Group. + buyingValue: Coste + import: Importe + pvp: PVP + basicData: + travel: Envío + currency: Moneda + observation: Observación + commission: Comisión + booked: Asentado + excludedFromAvailable: Inventario + buys: + observations: Observaciónes + packagingFk: Embalaje + color: Color + printedStickers: Etiquetas impresas + notes: + observationType: Tipo de observación + latestBuys: + tableVisibleColumns: + image: Foto + itemFk: Id Artículo + weightByPiece: Peso (gramos)/tallo + isActive: Activo + family: Familia + entryFk: Entrada + freightValue: Porte + comissionValue: Comisión + packageValue: Embalaje + isIgnored: Ignorado + price2: Grouping + price3: Packing + minPrice: Min + ektFk: Ekt + packingOut: Embalaje envíos + landing: Llegada + isExcludedFromAvailable: Excluir del inventario + isRaid: Redada + invoiceNumber: Nº Factura + reference: Ref/Alb/Guía ticket: params: ticketFk: ID de ticket @@ -410,6 +498,38 @@ ticket: freightItemName: Nombre packageItemName: Embalaje longName: Descripción + pageTitles: + tickets: Tickets + list: Listado + ticketCreate: Nuevo ticket + summary: Resumen + basicData: Datos básicos + boxing: Encajado + sms: Sms + notes: Notas + sale: Lineas del pedido + dms: Gestión documental + negative: Tickets negativos + volume: Volumen + observation: Notas + ticketAdvance: Adelantar tickets + futureTickets: Tickets a futuro + expedition: Expedición + purchaseRequest: Petición de compra + weeklyTickets: Tickets programados + saleTracking: Líneas preparadas + services: Servicios + tracking: Estados + components: Componentes + pictures: Fotos + packages: Bultos + list: + nickname: Alias + state: Estado + shipped: Enviado + landed: Entregado + salesPerson: Comercial + total: Total card: customerId: ID cliente customerCard: Ficha del cliente @@ -456,15 +576,11 @@ ticket: consigneeStreet: Dirección create: address: Dirección -order: - field: - salesPersonFk: Comercial - form: - clientFk: Cliente - addressFk: Dirección - agencyModeFk: Agencia - list: - newOrder: Nuevo Pedido +invoiceOut: + card: + issued: Fecha emisión + customerCard: Ficha del cliente + ticketList: Listado de tickets summary: issued: Fecha dued: Fecha límite @@ -475,6 +591,71 @@ order: fee: Cuota tickets: Tickets totalWithVat: Importe + globalInvoices: + errors: + chooseValidClient: Selecciona un cliente válido + chooseValidCompany: Selecciona una empresa válida + chooseValidPrinter: Selecciona una impresora válida + chooseValidSerialType: Selecciona una tipo de serie válida + fillDates: La fecha de la factura y la fecha máxima deben estar completas + invoiceDateLessThanMaxDate: La fecha de la factura no puede ser menor que la fecha máxima + invoiceWithFutureDate: Existe una factura con una fecha futura + noTicketsToInvoice: No existen tickets para facturar + criticalInvoiceError: Error crítico en la facturación proceso detenido + invalidSerialTypeForAll: El tipo de serie debe ser global cuando se facturan todos los clientes + table: + addressId: Id dirección + streetAddress: Dirección fiscal + statusCard: + percentageText: '{getPercentage}% {getAddressNumber} de {getNAddresses}' + pdfsNumberText: '{nPdfs} de {totalPdfs} PDFs' + negativeBases: + clientId: Id cliente + base: Base + active: Activo + hasToInvoice: Facturar + verifiedData: Datos comprobados + comercial: Comercial + errors: + downloadCsvFailed: Error al descargar CSV +order: + field: + salesPersonFk: Comercial + form: + clientFk: Cliente + addressFk: Dirección + agencyModeFk: Agencia + list: + newOrder: Nuevo Pedido + summary: + basket: Cesta + notConfirmed: No confirmada + created: Creado + createdFrom: Creado desde + address: Dirección + total: Total + vat: IVA + state: Estado + alias: Alias + items: Artículos + orderTicketList: Tickets del pedido + amount: Monto + confirm: Confirmar + confirmLines: Confirmar lineas +shelving: + list: + parking: Parking + priority: Prioridad + newShelving: Nuevo Carro + summary: + recyclable: Reciclable +parking: + pickingOrder: Orden de recogida + row: Fila + column: Columna + searchBar: + info: Puedes buscar por código de parking + label: Buscar parking... department: chat: Chat bossDepartment: Jefe de departamento @@ -635,8 +816,8 @@ wagon: volumeNotEmpty: El volumen no puede estar vacío typeNotEmpty: El tipo no puede estar vacío maxTrays: Has alcanzado el número máximo de bandejas - minHeightBetweenTrays: 'La distancia mínima entre bandejas es ' - maxWagonHeight: 'La altura máxima del vagón es ' + minHeightBetweenTrays: La distancia mínima entre bandejas es + maxWagonHeight: La altura máxima del vagón es uncompleteTrays: Hay bandejas sin completar params: label: Etiqueta @@ -653,7 +834,6 @@ supplier: tableVisibleColumns: nif: NIF/CIF account: Cuenta - summary: responsible: Responsable verified: Verificado @@ -784,7 +964,7 @@ components: cardDescriptor: mainList: Listado principal summary: Resumen - moreOptions: 'Más opciones' + moreOptions: Más opciones leftMenu: addToPinned: Añadir a fijados removeFromPinned: Eliminar de fijados diff --git a/src/pages/Account/Alias/Card/AliasDescriptor.vue b/src/pages/Account/Alias/Card/AliasDescriptor.vue index a5793407e..671ef7fbc 100644 --- a/src/pages/Account/Alias/Card/AliasDescriptor.vue +++ b/src/pages/Account/Alias/Card/AliasDescriptor.vue @@ -51,7 +51,6 @@ const removeAlias = () => { <CardDescriptor ref="descriptor" :url="`MailAliases/${entityId}`" - module="Alias" data-key="Alias" title="alias" > diff --git a/src/pages/Account/Card/AccountDescriptor.vue b/src/pages/Account/Card/AccountDescriptor.vue index e354f694c..49328fe87 100644 --- a/src/pages/Account/Card/AccountDescriptor.vue +++ b/src/pages/Account/Card/AccountDescriptor.vue @@ -23,8 +23,7 @@ onMounted(async () => { <CardDescriptor ref="descriptor" :url="`VnUsers/preview`" - :filter="filter" - module="Account" + :filter="{ ...filter, where: { id: entityId } }" data-key="Account" title="nickname" > diff --git a/src/pages/Account/Card/AccountDescriptorMenu.vue b/src/pages/Account/Card/AccountDescriptorMenu.vue index ab16e07ff..3b40f4224 100644 --- a/src/pages/Account/Card/AccountDescriptorMenu.vue +++ b/src/pages/Account/Card/AccountDescriptorMenu.vue @@ -12,6 +12,7 @@ import VnInputPassword from 'src/components/common/VnInputPassword.vue'; import VnChangePassword from 'src/components/common/VnChangePassword.vue'; import { useQuasar } from 'quasar'; import { useRouter } from 'vue-router'; +import VnCheckbox from 'src/components/common/VnCheckbox.vue'; const $props = defineProps({ hasAccount: { @@ -121,18 +122,14 @@ onMounted(() => { :promise="sync" > <template #customHTML> - {{ shouldSyncPassword }} - <QCheckbox - :label="t('account.card.actions.sync.checkbox')" + <VnCheckbox v-model="shouldSyncPassword" - class="full-width" + :label="t('account.card.actions.sync.checkbox')" + :info="t('account.card.actions.sync.tooltip')" clearable clear-icon="close" - > - <QIcon style="padding-left: 10px" color="primary" name="info" size="sm"> - <QTooltip>{{ t('account.card.actions.sync.tooltip') }}</QTooltip> - </QIcon></QCheckbox - > + color="primary" + /> <VnInputPassword v-if="shouldSyncPassword" :label="t('login.password')" diff --git a/src/pages/Account/Role/Card/RoleDescriptor.vue b/src/pages/Account/Role/Card/RoleDescriptor.vue index dfcc8efc8..517517af0 100644 --- a/src/pages/Account/Role/Card/RoleDescriptor.vue +++ b/src/pages/Account/Role/Card/RoleDescriptor.vue @@ -35,7 +35,6 @@ const removeRole = async () => { <CardDescriptor url="VnRoles" :filter="{ where: { id: entityId } }" - module="Role" data-key="Role" :summary="$props.summary" > diff --git a/src/pages/Claim/Card/ClaimDescriptor.vue b/src/pages/Claim/Card/ClaimDescriptor.vue index f55b0c48b..4551c58fe 100644 --- a/src/pages/Claim/Card/ClaimDescriptor.vue +++ b/src/pages/Claim/Card/ClaimDescriptor.vue @@ -46,7 +46,6 @@ onMounted(async () => { <CardDescriptor :url="`Claims/${entityId}`" :filter="filter" - module="Claim" title="client.name" data-key="Claim" > @@ -86,7 +85,7 @@ onMounted(async () => { /> </template> </VnLv> - <VnLv :label="t('claim.zone')"> + <VnLv v-if="entity.ticket?.zone?.id" :label="t('claim.zone')"> <template #value> <span class="link"> {{ entity.ticket?.zone?.name }} @@ -98,11 +97,10 @@ onMounted(async () => { :label="t('claim.province')" :value="entity.ticket?.address?.province?.name" /> - <VnLv :label="t('claim.ticketId')"> + <VnLv v-if="entity.ticketFk" :label="t('claim.ticketId')"> <template #value> <span class="link"> {{ entity.ticketFk }} - <TicketDescriptorProxy :id="entity.ticketFk" /> </span> </template> diff --git a/src/pages/Claim/Card/ClaimNotes.vue b/src/pages/Claim/Card/ClaimNotes.vue index 134ee33ab..cc6e33779 100644 --- a/src/pages/Claim/Card/ClaimNotes.vue +++ b/src/pages/Claim/Card/ClaimNotes.vue @@ -1,5 +1,5 @@ <script setup> -import { computed } from 'vue'; +import { computed, useAttrs } from 'vue'; import { useRoute } from 'vue-router'; import { useState } from 'src/composables/useState'; import VnNotes from 'src/components/ui/VnNotes.vue'; @@ -7,6 +7,7 @@ import VnNotes from 'src/components/ui/VnNotes.vue'; const route = useRoute(); const state = useState(); const user = state.getUser(); +const $attrs = useAttrs(); const $props = defineProps({ id: { type: [Number, String], default: null }, diff --git a/src/pages/Claim/ClaimList.vue b/src/pages/Claim/ClaimList.vue index ba74ba212..c05583314 100644 --- a/src/pages/Claim/ClaimList.vue +++ b/src/pages/Claim/ClaimList.vue @@ -131,7 +131,7 @@ const STATE_COLOR = { prefix="claim" :array-data-props="{ url: 'Claims/filter', - order: ['cs.priority ASC', 'created ASC'], + order: 'cs.priority ASC, created ASC', }" > <template #advanced-menu> diff --git a/src/pages/Customer/Card/CustomerAddress.vue b/src/pages/Customer/Card/CustomerAddress.vue index d8a1543cd..f1799d0cc 100644 --- a/src/pages/Customer/Card/CustomerAddress.vue +++ b/src/pages/Customer/Card/CustomerAddress.vue @@ -117,7 +117,7 @@ const toCustomerAddressEdit = (addressId) => { data-key="CustomerAddresses" order="id DESC" ref="vnPaginateRef" - :user-filter="addressFilter" + :filter="addressFilter" :url="`Clients/${route.params.id}/addresses`" /> <div class="full-width flex justify-center"> @@ -189,11 +189,11 @@ const toCustomerAddressEdit = (addressId) => { <QSeparator class="q-mx-lg" - v-if="item.observations.length" + v-if="item?.observations?.length" vertical /> - <div v-if="item.observations.length"> + <div v-if="item?.observations?.length"> <div :key="obIndex" class="flex q-mb-sm" diff --git a/src/pages/Customer/Card/CustomerConsumption.vue b/src/pages/Customer/Card/CustomerConsumption.vue index 50750cf12..eef9d55b5 100644 --- a/src/pages/Customer/Card/CustomerConsumption.vue +++ b/src/pages/Customer/Card/CustomerConsumption.vue @@ -61,6 +61,23 @@ const columns = computed(() => [ columnFilter: false, cardVisible: true, }, + { + align: 'left', + name: 'buyerId', + label: t('customer.params.buyerId'), + component: 'select', + attrs: { + url: 'TicketRequests/getItemTypeWorker', + optionLabel: 'nickname', + optionValue: 'id', + + fields: ['id', 'nickname'], + sortBy: ['nickname ASC'], + optionFilter: 'firstName', + }, + cardVisible: false, + visible: false, + }, { name: 'description', align: 'left', @@ -74,6 +91,7 @@ const columns = computed(() => [ name: 'quantity', label: t('globals.quantity'), cardVisible: true, + visible: true, columnFilter: { inWhere: true, }, @@ -138,11 +156,11 @@ const updateDateParams = (value, params) => { const campaign = campaignList.value.find((c) => c.id === value); if (!campaign) return; - const { dated, previousDays, scopeDays } = campaign; - const _date = new Date(dated); - const [from, to] = dateRange(_date); - params.from = new Date(from.setDate(from.getDate() - previousDays)).toISOString(); - params.to = new Date(to.setDate(to.getDate() + scopeDays)).toISOString(); + const { dated, scopeDays } = campaign; + const from = new Date(dated); + from.setDate(from.getDate() - scopeDays); + params.from = from; + params.to = dated; return params; }; </script> @@ -200,29 +218,62 @@ const updateDateParams = (value, params) => { <div v-if="row.subName" class="subName"> {{ row.subName }} </div> - <FetchedTags :item="row" :max-length="3" /> + <FetchedTags :item="row" /> </template> <template #moreFilterPanel="{ params }"> <div class="column no-wrap flex-center q-gutter-y-md q-mt-xs q-pr-xl"> + <VnSelect + :filled="true" + class="q-px-sm q-pt-none fit" + url="ItemTypes" + v-model="params.typeId" + :label="t('item.list.typeName')" + :fields="['id', 'name', 'categoryFk']" + :include="'category'" + :sortBy="'name ASC'" + dense + @update:model-value="(data) => updateDateParams(data, params)" + > + <template #option="scope"> + <QItem v-bind="scope.itemProps"> + <QItemSection> + <QItemLabel>{{ scope.opt?.name }}</QItemLabel> + <QItemLabel caption>{{ + scope.opt?.category?.name + }}</QItemLabel> + </QItemSection> + </QItem> + </template> + </VnSelect> + <VnSelect + :filled="true" + class="q-px-sm q-pt-none fit" + url="ItemCategories" + v-model="params.categoryId" + :label="t('item.list.category')" + :fields="['id', 'name']" + :sortBy="'name ASC'" + dense + @update:model-value="(data) => updateDateParams(data, params)" + /> <VnSelect v-model="params.campaign" :options="campaignList" :label="t('globals.campaign')" :filled="true" class="q-px-sm q-pt-none fit" - dense - option-label="code" + :option-label="(opt) => t(opt.code)" + :fields="['id', 'code', 'dated', 'scopeDays']" @update:model-value="(data) => updateDateParams(data, params)" + dense > <template #option="scope"> <QItem v-bind="scope.itemProps"> <QItemSection> - <QItemLabel> - {{ scope.opt?.code }} - {{ - new Date(scope.opt?.dated).getFullYear() - }}</QItemLabel - > + <QItemLabel> {{ t(scope.opt?.code) }} </QItemLabel> + <QItemLabel caption> + {{ new Date(scope.opt?.dated).getFullYear() }} + </QItemLabel> </QItemSection> </QItem> </template> @@ -247,7 +298,19 @@ const updateDateParams = (value, params) => { </template> <i18n> +en: + + valentinesDay: Valentine's Day + mothersDay: Mother's Day + allSaints: All Saints' Day es: Enter a new search: Introduce una nueva búsqueda Group by items: Agrupar por artículos + valentinesDay: Día de San Valentín + mothersDay: Día de la Madre + allSaints: Día de Todos los Santos + Campaign consumption: Consumo campaña + Campaign: Campaña + From: Desde + To: Hasta </i18n> diff --git a/src/pages/Customer/Card/CustomerDescriptor.vue b/src/pages/Customer/Card/CustomerDescriptor.vue index a646ad299..89f9d9449 100644 --- a/src/pages/Customer/Card/CustomerDescriptor.vue +++ b/src/pages/Customer/Card/CustomerDescriptor.vue @@ -55,7 +55,6 @@ const debtWarning = computed(() => { <template> <CardDescriptor - module="Customer" :url="`Clients/${entityId}/getCard`" :summary="$props.summary" data-key="Customer" @@ -110,7 +109,21 @@ const debtWarning = computed(() => { > <QTooltip>{{ t('customer.card.isDisabled') }}</QTooltip> </QIcon> - <QIcon v-if="entity.isFreezed" name="vn:frozen" size="xs" color="primary"> + + <QIcon + v-if="entity?.substitutionAllowed" + name="help" + size="xs" + color="primary" + > + <QTooltip>{{ t('Allowed substitution') }}</QTooltip> + </QIcon> + <QIcon + v-if="customer?.isFreezed" + name="vn:frozen" + size="xs" + color="primary" + > <QTooltip>{{ t('customer.card.isFrozen') }}</QTooltip> </QIcon> <QIcon diff --git a/src/pages/Customer/Card/CustomerDescriptorMenu.vue b/src/pages/Customer/Card/CustomerDescriptorMenu.vue index fb78eab69..aea45721c 100644 --- a/src/pages/Customer/Card/CustomerDescriptorMenu.vue +++ b/src/pages/Customer/Card/CustomerDescriptorMenu.vue @@ -61,6 +61,16 @@ const openCreateForm = (type) => { .join('&'); useOpenURL(`/#/${type}/list?${params}`); }; +const updateSubstitutionAllowed = async () => { + try { + await axios.patch(`Clients/${route.params.id}`, { + substitutionAllowed: !$props.customer.substitutionAllowed, + }); + notify('globals.notificationSent', 'positive'); + } catch (error) { + notify(error.message, 'positive'); + } +}; </script> <template> @@ -69,6 +79,13 @@ const openCreateForm = (type) => { {{ t('globals.pageTitles.createTicket') }} </QItemSection> </QItem> + <QItem v-ripple clickable> + <QItemSection @click="updateSubstitutionAllowed()">{{ + $props.customer.substitutionAllowed + ? t('Disable substitution') + : t('Allow substitution') + }}</QItemSection> + </QItem> <QItem v-ripple clickable> <QItemSection @click="showSmsDialog()">{{ t('Send SMS') }}</QItemSection> </QItem> diff --git a/src/pages/Customer/Card/CustomerFiscalData.vue b/src/pages/Customer/Card/CustomerFiscalData.vue index b256c001a..bd887acb7 100644 --- a/src/pages/Customer/Card/CustomerFiscalData.vue +++ b/src/pages/Customer/Card/CustomerFiscalData.vue @@ -9,6 +9,7 @@ import VnRow from 'components/ui/VnRow.vue'; import VnInput from 'src/components/common/VnInput.vue'; import VnSelect from 'src/components/common/VnSelect.vue'; import VnLocation from 'src/components/common/VnLocation.vue'; +import VnCheckbox from 'src/components/common/VnCheckbox.vue'; const { t } = useI18n(); const route = useRoute(); @@ -110,14 +111,11 @@ function handleLocation(data, location) { </VnRow> <VnRow> <QCheckbox :label="t('Has to invoice')" v-model="data.hasToInvoice" /> - <div> - <QCheckbox :label="t('globals.isVies')" v-model="data.isVies" /> - <QIcon name="info" class="cursor-info q-ml-sm" size="sm"> - <QTooltip> - {{ t('whenActivatingIt') }} - </QTooltip> - </QIcon> - </div> + <VnCheckbox + v-model="data.isVies" + :label="t('globals.isVies')" + :info="t('whenActivatingIt')" + /> </VnRow> <VnRow> @@ -129,17 +127,11 @@ function handleLocation(data, location) { </VnRow> <VnRow> - <div> - <QCheckbox - :label="t('Is equalizated')" - v-model="data.isEqualizated" - /> - <QIcon class="cursor-info q-ml-sm" name="info" size="sm"> - <QTooltip> - {{ t('inOrderToInvoice') }} - </QTooltip> - </QIcon> - </div> + <VnCheckbox + v-model="data.isEqualizated" + :label="t('Is equalizated')" + :info="t('inOrderToInvoice')" + /> <QCheckbox :label="t('Daily invoice')" v-model="data.hasDailyInvoice" /> </VnRow> diff --git a/src/pages/Customer/CustomerFilter.vue b/src/pages/Customer/CustomerFilter.vue index eae97d1be..21de8fa9b 100644 --- a/src/pages/Customer/CustomerFilter.vue +++ b/src/pages/Customer/CustomerFilter.vue @@ -1,4 +1,3 @@ - <script setup> import { useI18n } from 'vue-i18n'; import VnFilterPanel from 'src/components/ui/VnFilterPanel.vue'; @@ -52,11 +51,7 @@ const exprBuilder = (param, value) => { </QItem> <QItem class="q-mb-sm"> <QItemSection> - <VnInput - :label="t('globals.name')" - v-model="params.name" - is-outlined - /> + <VnInput :label="t('Name')" v-model="params.name" is-outlined /> </QItemSection> </QItem> <QItem class="q-mb-sm"> diff --git a/src/pages/Customer/CustomerList.vue b/src/pages/Customer/CustomerList.vue index 3c638b612..b8c1a743e 100644 --- a/src/pages/Customer/CustomerList.vue +++ b/src/pages/Customer/CustomerList.vue @@ -264,6 +264,7 @@ const columns = computed(() => [ align: 'left', name: 'isActive', label: t('customer.summary.isActive'), + component: 'checkbox', chip: { color: null, condition: (value) => !value, @@ -302,6 +303,7 @@ const columns = computed(() => [ align: 'left', name: 'isFreezed', label: t('customer.extendedList.tableVisibleColumns.isFreezed'), + component: 'checkbox', chip: { color: null, condition: (value) => value, @@ -419,7 +421,7 @@ function handleLocation(data, location) { <VnTable ref="tableRef" :data-key="dataKey" - url="Clients/filter" + url="Clients/extendedListFilter" :create="{ urlCreate: 'Clients/createWithUser', title: t('globals.pageTitles.customerCreate'), diff --git a/src/pages/Customer/Defaulter/CustomerDefaulter.vue b/src/pages/Customer/Defaulter/CustomerDefaulter.vue index eca2ad596..dc4ac9162 100644 --- a/src/pages/Customer/Defaulter/CustomerDefaulter.vue +++ b/src/pages/Customer/Defaulter/CustomerDefaulter.vue @@ -9,7 +9,7 @@ import WorkerDescriptorProxy from 'src/pages/Worker/Card/WorkerDescriptorProxy.v import VnSubToolbar from 'src/components/ui/VnSubToolbar.vue'; import VnInput from 'src/components/common/VnInput.vue'; import CustomerDefaulterAddObservation from './CustomerDefaulterAddObservation.vue'; -import DepartmentDescriptorProxy from 'src/pages/Department/Card/DepartmentDescriptorProxy.vue'; +import DepartmentDescriptorProxy from 'src/pages/Worker/Department/Card/DepartmentDescriptorProxy.vue'; import VnTable from 'src/components/VnTable/VnTable.vue'; import { useArrayData } from 'src/composables/useArrayData'; diff --git a/src/pages/Customer/components/CustomerNewPayment.vue b/src/pages/Customer/components/CustomerNewPayment.vue index c2c38b55a..7f45cd7db 100644 --- a/src/pages/Customer/components/CustomerNewPayment.vue +++ b/src/pages/Customer/components/CustomerNewPayment.vue @@ -84,7 +84,7 @@ function setPaymentType(accounting) { viewReceipt.value = isCash.value; if (accountingType.value.daysInFuture) initialData.payed.setDate( - initialData.payed.getDate() + accountingType.value.daysInFuture + initialData.payed.getDate() + accountingType.value.daysInFuture, ); maxAmount.value = accountingType.value && accountingType.value.maxAmount; diff --git a/src/pages/Customer/locale/en.yml b/src/pages/Customer/locale/en.yml index 118f04a31..b6d495335 100644 --- a/src/pages/Customer/locale/en.yml +++ b/src/pages/Customer/locale/en.yml @@ -107,6 +107,9 @@ customer: defaulterSinced: Defaulted Since hasRecovery: Has Recovery socialName: Social name + typeId: Type + buyerId: Buyer + categoryId: Category city: City phone: Phone postcode: Postcode diff --git a/src/pages/Customer/locale/es.yml b/src/pages/Customer/locale/es.yml index 7c33ffee8..f50d049da 100644 --- a/src/pages/Customer/locale/es.yml +++ b/src/pages/Customer/locale/es.yml @@ -108,6 +108,9 @@ customer: hasRecovery: Tiene recobro socialName: Razón social campaign: Campaña + typeId: Familia + buyerId: Comprador + categoryId: Reino city: Ciudad phone: Teléfono postcode: Código postal diff --git a/src/pages/Entry/Card/EntryBasicData.vue b/src/pages/Entry/Card/EntryBasicData.vue index 689eea686..6462ed24a 100644 --- a/src/pages/Entry/Card/EntryBasicData.vue +++ b/src/pages/Entry/Card/EntryBasicData.vue @@ -1,30 +1,32 @@ <script setup> -import { ref } from 'vue'; +import { onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; import { useRole } from 'src/composables/useRole'; +import { useState } from 'src/composables/useState'; +import { checkEntryLock } from 'src/composables/checkEntryLock'; import FetchData from 'components/FetchData.vue'; import FormModel from 'components/FormModel.vue'; import VnRow from 'components/ui/VnRow.vue'; import VnInput from 'src/components/common/VnInput.vue'; import VnSelect from 'src/components/common/VnSelect.vue'; -import VnSelectDialog from 'src/components/common/VnSelectDialog.vue'; -import FilterTravelForm from 'src/components/FilterTravelForm.vue'; import VnInputNumber from 'src/components/common/VnInputNumber.vue'; -import { toDate } from 'src/filters'; +import VnSelectTravelExtended from 'src/components/common/VnSelectTravelExtended.vue'; import VnSelectSupplier from 'src/components/common/VnSelectSupplier.vue'; const route = useRoute(); const { t } = useI18n(); const { hasAny } = useRole(); const isAdministrative = () => hasAny(['administrative']); +const state = useState(); +const user = state.getUser().fn(); const companiesOptions = ref([]); const currenciesOptions = ref([]); -const onFilterTravelSelected = (formData, id) => { - formData.travelFk = id; -}; +onMounted(() => { + checkEntryLock(route.params.id, user.id); +}); </script> <template> @@ -52,46 +54,24 @@ const onFilterTravelSelected = (formData, id) => { > <template #form="{ data }"> <VnRow> + <VnSelectTravelExtended + :data="data" + v-model="data.travelFk" + :onFilterTravelSelected="(data, result) => (data.travelFk = result)" + /> <VnSelectSupplier v-model="data.supplierFk" hide-selected :required="true" - map-options /> - <VnSelectDialog - :label="t('entry.basicData.travel')" - v-model="data.travelFk" - url="Travels/filter" - :fields="['id', 'warehouseInName']" - option-value="id" - option-label="warehouseInName" - map-options - hide-selected - :required="true" - action-icon="filter_alt" - > - <template #form> - <FilterTravelForm - @travel-selected="onFilterTravelSelected(data, $event)" - /> - </template> - <template #option="scope"> - <QItem v-bind="scope.itemProps"> - <QItemSection> - <QItemLabel> - {{ scope.opt?.agencyModeName }} - - {{ scope.opt?.warehouseInName }} - ({{ toDate(scope.opt?.shipped) }}) → - {{ scope.opt?.warehouseOutName }} - ({{ toDate(scope.opt?.landed) }}) - </QItemLabel> - </QItemSection> - </QItem> - </template> - </VnSelectDialog> </VnRow> <VnRow> <VnInput v-model="data.reference" :label="t('globals.reference')" /> + <VnInputNumber + v-model="data.invoiceAmount" + :label="t('entry.summary.invoiceAmount')" + :positive="false" + /> </VnRow> <VnRow> <VnInput @@ -113,8 +93,7 @@ const onFilterTravelSelected = (formData, id) => { <VnInputNumber :label="t('entry.summary.commission')" v-model="data.commission" - step="1" - autofocus + :step="1" :positive="false" /> <VnSelect @@ -161,7 +140,7 @@ const onFilterTravelSelected = (formData, id) => { :label="t('entry.summary.excludedFromAvailable')" /> <QCheckbox - v-if="isAdministrative()" + :disable="!isAdministrative()" v-model="data.isBooked" :label="t('entry.basicData.booked')" /> diff --git a/src/pages/Entry/Card/EntryBuys.vue b/src/pages/Entry/Card/EntryBuys.vue index 6194ce5b8..e159c5356 100644 --- a/src/pages/Entry/Card/EntryBuys.vue +++ b/src/pages/Entry/Card/EntryBuys.vue @@ -1,478 +1,799 @@ <script setup> -import { ref, computed } from 'vue'; -import { useRoute, useRouter } from 'vue-router'; +import { useStateStore } from 'stores/useStateStore'; +import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; -import { QBtn } from 'quasar'; +import { onMounted, ref } from 'vue'; -import VnPaginate from 'src/components/ui/VnPaginate.vue'; -import VnSelect from 'components/common/VnSelect.vue'; -import VnInput from 'src/components/common/VnInput.vue'; -import FetchedTags from 'components/ui/FetchedTags.vue'; -import VnConfirm from 'components/ui/VnConfirm.vue'; +import { useState } from 'src/composables/useState'; + +import FetchData from 'src/components/FetchData.vue'; +import VnTable from 'src/components/VnTable/VnTable.vue'; import ItemDescriptorProxy from 'src/pages/Item/Card/ItemDescriptorProxy.vue'; -import VnSubToolbar from 'src/components/ui/VnSubToolbar.vue'; - -import { useQuasar } from 'quasar'; -import { toCurrency } from 'src/filters'; +import FetchedTags from 'src/components/ui/FetchedTags.vue'; +import VnColor from 'src/components/common/VnColor.vue'; +import VnSelect from 'src/components/common/VnSelect.vue'; +import ItemDescriptor from 'src/pages/Item/Card/ItemDescriptor.vue'; import axios from 'axios'; -import useNotify from 'src/composables/useNotify.js'; +import VnSelectEnum from 'src/components/common/VnSelectEnum.vue'; +import { checkEntryLock } from 'src/composables/checkEntryLock'; +import SkeletonDescriptor from 'src/components/ui/SkeletonDescriptor.vue'; -const quasar = useQuasar(); -const route = useRoute(); -const router = useRouter(); -const { t } = useI18n(); -const { notify } = useNotify(); - -const rowsSelected = ref([]); -const entryBuysPaginateRef = ref(null); -const originalRowDataCopy = ref(null); - -const getInputEvents = (colField, props) => { - return colField === 'packagingFk' - ? { 'update:modelValue': () => saveChange(colField, props) } - : { - 'keyup.enter': () => saveChange(colField, props), - blur: () => saveChange(colField, props), - }; -}; - -const tableColumnComponents = computed(() => ({ - item: { - component: QBtn, - props: { - color: 'primary', - flat: true, - }, - event: () => ({}), +const $props = defineProps({ + id: { + type: Number, + default: null, }, - quantity: { - component: VnInput, - props: { - type: 'number', - min: 0, - class: 'input-number', - dense: true, - }, - event: getInputEvents, + editableMode: { + type: Boolean, + default: true, }, - packagingFk: { - component: VnSelect, - props: { - 'option-value': 'id', - 'option-label': 'id', - 'emit-value': true, - 'map-options': true, - 'use-input': true, - 'hide-selected': true, - url: 'Packagings', - fields: ['id'], - where: { freightItemFk: true }, - 'sort-by': 'id ASC', - dense: true, - }, - event: getInputEvents, + tableHeight: { + type: String, + default: null, }, - stickers: { - component: VnInput, - props: { - type: 'number', - min: 0, - class: 'input-number', - dense: true, - }, - event: getInputEvents, - }, - printedStickers: { - component: VnInput, - props: { - type: 'number', - min: 0, - class: 'input-number', - dense: true, - }, - event: getInputEvents, - }, - weight: { - component: VnInput, - props: { - type: 'number', - min: 0, - dense: true, - }, - event: getInputEvents, - }, - packing: { - component: VnInput, - props: { - type: 'number', - min: 0, - dense: true, - }, - event: getInputEvents, - }, - grouping: { - component: VnInput, - props: { - type: 'number', - min: 0, - dense: true, - }, - event: getInputEvents, - }, - buyingValue: { - component: VnInput, - props: { - type: 'number', - min: 0, - dense: true, - }, - event: getInputEvents, - }, - price2: { - component: VnInput, - props: { - type: 'number', - min: 0, - dense: true, - }, - event: getInputEvents, - }, - price3: { - component: VnInput, - props: { - type: 'number', - min: 0, - dense: true, - }, - event: getInputEvents, - }, - import: { - component: 'span', - props: {}, - event: () => ({}), - }, -})); - -const entriesTableColumns = computed(() => { - return [ - { - label: t('globals.item'), - field: 'itemFk', - name: 'item', - align: 'left', - }, - { - label: t('globals.quantity'), - field: 'quantity', - name: 'quantity', - align: 'left', - }, - { - label: t('entry.summary.package'), - field: 'packagingFk', - name: 'packagingFk', - align: 'left', - }, - { - label: t('entry.summary.stickers'), - field: 'stickers', - name: 'stickers', - align: 'left', - }, - { - label: t('entry.buys.printedStickers'), - field: 'printedStickers', - name: 'printedStickers', - align: 'left', - }, - { - label: t('globals.weight'), - field: 'weight', - name: 'weight', - align: 'left', - }, - { - label: t('entry.summary.packing'), - field: 'packing', - name: 'packing', - align: 'left', - }, - { - label: t('entry.summary.grouping'), - field: 'grouping', - name: 'grouping', - align: 'left', - }, - { - label: t('entry.summary.buyingValue'), - field: 'buyingValue', - name: 'buyingValue', - align: 'left', - format: (value) => toCurrency(value), - }, - { - label: t('item.fixedPrice.groupingPrice'), - field: 'price2', - name: 'price2', - align: 'left', - }, - { - label: t('item.fixedPrice.packingPrice'), - field: 'price3', - name: 'price3', - align: 'left', - }, - { - label: t('entry.summary.import'), - name: 'import', - align: 'left', - format: (_, row) => toCurrency(row.buyingValue * row.quantity), - }, - ]; }); -const copyOriginalRowsData = (rows) => { - originalRowDataCopy.value = JSON.parse(JSON.stringify(rows)); -}; - -const saveChange = async (field, { rowIndex, row }) => { - if (originalRowDataCopy.value[rowIndex][field] == row[field]) return; - await axios.patch(`Buys/${row.id}`, row); - originalRowDataCopy.value[rowIndex][field] = row[field]; -}; - -const openRemoveDialog = async () => { - quasar - .dialog({ - component: VnConfirm, - componentProps: { - title: t('Confirm deletion'), - message: t( - `Are you sure you want to delete this buy${ - rowsSelected.value.length > 1 ? 's' : '' - }?` - ), - data: rowsSelected.value, +const state = useState(); +const user = state.getUser().fn(); +const stateStore = useStateStore(); +const { t } = useI18n(); +const route = useRoute(); +const selectedRows = ref([]); +const entityId = ref($props.id ?? route.params.id); +const entryBuysRef = ref(); +const footerFetchDataRef = ref(); +const footer = ref({}); +const columns = [ + { + align: 'center', + labelAbbreviation: 'NV', + label: t('Ignore'), + toolTip: t('Ignored for available'), + name: 'isIgnored', + component: 'checkbox', + attrs: { + toggleIndeterminate: false, + }, + create: true, + width: '25px', + }, + { + label: t('Buyer'), + name: 'workerFk', + component: 'select', + attrs: { + url: 'Workers/search', + fields: ['id', 'nickname'], + optionLabel: 'nickname', + optionValue: 'id', + }, + visible: false, + }, + { + label: t('Family'), + name: 'itemTypeFk', + component: 'select', + attrs: { + url: 'itemTypes', + fields: ['id', 'name'], + optionLabel: 'name', + optionValue: 'id', + }, + visible: false, + }, + { + name: 'id', + isId: true, + visible: false, + isEditable: false, + columnFilter: false, + }, + { + name: 'entryFk', + isId: true, + visible: false, + isEditable: false, + disable: true, + create: true, + columnFilter: false, + }, + { + align: 'center', + label: 'Id', + name: 'itemFk', + component: 'number', + isEditable: false, + width: '40px', + }, + { + labelAbbreviation: '', + label: 'Color', + name: 'hex', + columnSearch: false, + isEditable: false, + width: '5px', + component: 'select', + attrs: { + url: 'Inks', + fields: ['id', 'name'], + }, + }, + { + align: 'center', + label: t('Article'), + name: 'name', + component: 'select', + attrs: { + url: 'Items', + fields: ['id', 'name'], + optionLabel: 'name', + optionValue: 'id', + }, + width: '85px', + isEditable: false, + }, + { + align: 'center', + label: t('Article'), + name: 'itemFk', + visible: false, + create: true, + columnFilter: false, + }, + { + align: 'center', + labelAbbreviation: t('Siz.'), + label: t('Size'), + toolTip: t('Size'), + component: 'number', + name: 'size', + width: '35px', + isEditable: false, + style: () => { + return { color: 'var(--vn-label-color)' }; + }, + }, + { + align: 'center', + labelAbbreviation: t('Sti.'), + label: t('Stickers'), + toolTip: t('Printed Stickers/Stickers'), + name: 'stickers', + component: 'input', + create: true, + attrs: { + positive: false, + }, + cellEvent: { + 'update:modelValue': async (value, oldValue, row) => { + row['quantity'] = value * row['packing']; + row['amount'] = row['quantity'] * row['buyingValue']; }, - }) - .onOk(async () => { - await deleteBuys(); - const notifyMessage = t( - `Buy${rowsSelected.value.length > 1 ? 's' : ''} deleted` - ); - notify(notifyMessage, 'positive'); - }); -}; + }, + width: '35px', + }, + { + align: 'center', + label: t('Bucket'), + name: 'packagingFk', + component: 'select', + attrs: { + url: 'packagings', + fields: ['id'], + optionLabel: 'id', + }, + create: true, + width: '40px', + }, + { + align: 'center', + label: 'Kg', + name: 'weight', + component: 'number', + create: true, + width: '35px', + format: (row, dashIfEmpty) => parseFloat(row['weight']).toFixed(1), + }, + { + labelAbbreviation: 'P', + label: 'Packing', + toolTip: 'Packing', + name: 'packing', + component: 'number', + create: true, + cellEvent: { + 'update:modelValue': async (value, oldValue, row) => { + const oldPacking = oldValue === 1 || oldValue === null ? 1 : oldValue; + row['weight'] = (row['weight'] * value) / oldPacking; + row['quantity'] = row['stickers'] * value; + row['amount'] = row['quantity'] * row['buyingValue']; + }, + }, + width: '20px', + style: (row) => { + if (row.groupingMode === 'grouping') + return { color: 'var(--vn-label-color)' }; + }, + }, + { + labelAbbreviation: 'GM', + label: t('Grouping selector'), + toolTip: t('Grouping selector'), + name: 'groupingMode', + component: 'toggle', + attrs: { + 'toggle-indeterminate': true, + trueValue: 'grouping', + falseValue: 'packing', + indeterminateValue: null, + }, + size: 'xs', + width: '25px', + create: true, + rightFilter: false, + getIcon: (value) => { + switch (value) { + case 'grouping': + return 'toggle_on'; + case 'packing': + return 'toggle_off'; + default: + return 'minimize'; + } + }, + }, + { + align: 'center', + labelAbbreviation: 'G', + label: 'Grouping', + toolTip: 'Grouping', + name: 'grouping', + component: 'number', + width: '20px', + create: true, + style: (row) => { + if (row.groupingMode === 'packing') return { color: 'var(--vn-label-color)' }; + }, + }, + { + align: 'center', + label: t('Quantity'), + name: 'quantity', + component: 'number', + attrs: { + positive: false, + }, + cellEvent: { + 'update:modelValue': async (value, oldValue, row) => { + row['amount'] = value * row['buyingValue']; + }, + }, + width: '45px', + create: true, + style: getQuantityStyle, + }, + { + align: 'center', + labelAbbreviation: t('Cost'), + label: t('Buying value'), + toolTip: t('Buying value'), + name: 'buyingValue', + create: true, + component: 'number', + attrs: { + positive: false, + }, + cellEvent: { + 'update:modelValue': async (value, oldValue, row) => { + row['amount'] = row['quantity'] * value; + }, + }, + width: '45px', + format: (row) => parseFloat(row['buyingValue']).toFixed(3), + }, + { + align: 'center', + label: t('Amount'), + name: 'amount', + width: '45px', + component: 'number', + attrs: { + positive: false, + }, + isEditable: false, + format: (row) => parseFloat(row['amount']).toFixed(2), + style: getAmountStyle, + }, + { + align: 'center', + labelAbbreviation: t('Pack.'), + label: t('Package'), + toolTip: t('Package'), + name: 'price2', + component: 'number', + width: '35px', + create: true, + format: (row) => parseFloat(row['price2']).toFixed(2), + }, + { + align: 'center', + label: t('Box'), + name: 'price3', + component: 'number', + cellEvent: { + 'update:modelValue': async (value, oldValue, row) => { + row['price2'] = row['price2'] * (value / oldValue); + }, + }, + width: '35px', + create: true, + format: (row) => parseFloat(row['price3']).toFixed(2), + }, + { + align: 'center', + labelAbbreviation: 'Min.', + label: t('Minimum price'), + toolTip: t('Minimum price'), + name: 'minPrice', + component: 'number', + cellEvent: { + 'update:modelValue': async (value, oldValue, row) => { + await axios.patch(`Items/${row['itemFk']}`, { + minPrice: value, + }); + }, + }, + width: '35px', + style: (row) => { + if (!row?.hasMinPrice) return { color: 'var(--vn-label-color)' }; + }, + format: (row) => parseFloat(row['minPrice']).toFixed(2), + }, + { + align: 'center', + labelAbbreviation: 'CM', + label: t('Check min price'), + toolTip: t('Check min price'), + name: 'hasMinPrice', + attrs: { + toggleIndeterminate: false, + }, + component: 'checkbox', + cellEvent: { + 'update:modelValue': async (value, oldValue, row) => { + await axios.patch(`Items/${row['itemFk']}`, { + hasMinPrice: value, + }); + }, + }, + width: '25px', + }, + { + align: 'center', + labelAbbreviation: t('P.Sen'), + label: t('Packing sent'), + toolTip: t('Packing sent'), + name: 'packingOut', + component: 'number', + isEditable: false, + width: '40px', + }, + { + align: 'center', + labelAbbreviation: t('Com.'), + label: t('Comment'), + toolTip: t('Comment'), + name: 'comment', + component: 'input', + isEditable: false, + width: '50px', + }, + { + align: 'center', + labelAbbreviation: 'Prod.', + label: t('Producer'), + toolTip: t('Producer'), + name: 'subName', + isEditable: false, + width: '45px', + style: () => { + return { color: 'var(--vn-label-color)' }; + }, + }, + { + align: 'center', + label: t('Tags'), + name: 'tags', + width: '125px', + columnSearch: false, + }, + { + align: 'center', + labelAbbreviation: 'Comp.', + label: t('Company'), + toolTip: t('Company'), + name: 'company_name', + component: 'input', + isEditable: false, + width: '35px', + }, +]; -const deleteBuys = async () => { - await axios.post('Buys/deleteBuys', { buys: rowsSelected.value }); - entryBuysPaginateRef.value.fetch(); -}; +function getQuantityStyle(row) { + if (row?.quantity !== row?.stickers * row?.packing) + return { color: 'var(--q-negative)' }; +} +function getAmountStyle(row) { + if (row?.isChecked) return { color: 'var(--q-positive)' }; + return { color: 'var(--vn-label-color)' }; +} -const importBuys = () => { - router.push({ name: 'EntryBuysImport' }); -}; +async function beforeSave(data, getChanges) { + try { + const changes = data.updates; + if (!changes) return data; + const patchPromises = []; -const toggleGroupingMode = async (buy, mode) => { - const groupingMode = mode === 'grouping' ? mode : 'packing'; - const newGroupingMode = buy.groupingMode === groupingMode ? null : groupingMode; - const params = { - groupingMode: newGroupingMode, - }; - await axios.patch(`Buys/${buy.id}`, params); - buy.groupingMode = newGroupingMode; -}; + for (const change of changes) { + let patchData = {}; -const lockIconType = (groupingMode, mode) => { - if (mode === 'packing') { - return groupingMode === 'packing' ? 'lock' : 'lock_open'; - } else { - return groupingMode === 'grouping' ? 'lock' : 'lock_open'; + if ('hasMinPrice' in change.data) { + patchData.hasMinPrice = change.data?.hasMinPrice; + delete change.data.hasMinPrice; + } + if ('minPrice' in change.data) { + patchData.minPrice = change.data?.minPrice; + delete change.data.minPrice; + } + + if (Object.keys(patchData).length > 0) { + const promise = axios + .get('Buys/findOne', { + params: { + filter: { + fields: ['itemFk'], + where: { id: change.where.id }, + }, + }, + }) + .then((buy) => { + return axios.patch(`Items/${buy.data.itemFk}`, patchData); + }) + .catch((error) => { + console.error('Error processing change: ', change, error); + }); + + patchPromises.push(promise); + } + } + + await Promise.all(patchPromises); + + data.updates = changes.filter((change) => Object.keys(change.data).length > 0); + + return data; + } catch (error) { + console.error('Error in beforeSave:', error); + throw error; } -}; +} + +function invertQuantitySign(rows, sign) { + for (const row of rows) { + if (sign > 0) row.quantity = Math.abs(row.quantity); + else if (row.quantity > 0) row.quantity = -row.quantity; + } +} +function setIsChecked(rows, value) { + for (const row of rows) { + row.isChecked = value; + } + footerFetchDataRef.value.fetch(); +} + +async function setBuyUltimate(itemFk, data) { + if (!itemFk) return; + const buyUltimate = await axios.get(`Entries/getBuyUltimate`, { + params: { + itemFk, + warehouseFk: user.warehouseFk, + date: Date.vnNew(), + }, + }); + const buyUltimateData = buyUltimate.data[0]; + + const allowedKeys = columns + .filter((col) => col.create === true) + .map((col) => col.name); + + allowedKeys.forEach((key) => { + if (buyUltimateData.hasOwnProperty(key) && key !== 'entryFk') { + data[key] = buyUltimateData[key]; + } + }); +} + +onMounted(() => { + stateStore.rightDrawer = false; + if ($props.editableMode) checkEntryLock(entityId.value, user.id); +}); </script> - <template> - <VnSubToolbar> - <template #st-actions> - <QBtnGroup push style="column-gap: 10px"> - <slot name="moreBeforeActions" /> - <QBtn - :label="t('globals.remove')" - color="primary" - icon="delete" - flat - @click="openRemoveDialog()" - :disable="!rowsSelected?.length" - :title="t('globals.remove')" - /> - </QBtnGroup> - </template> - </VnSubToolbar> - <VnPaginate - ref="entryBuysPaginateRef" - data-key="EntryBuys" - :url="`Entries/${route.params.id}/getBuys`" - @on-fetch="copyOriginalRowsData($event)" - auto-load - > - <template #body="{ rows }"> - <QTable - :rows="rows" - :columns="entriesTableColumns" - selection="multiple" - row-key="id" - class="full-width q-mt-md" - :grid="$q.screen.lt.md" - v-model:selected="rowsSelected" - :no-data-label="t('globals.noResults')" + <Teleport to="#st-data" v-if="stateStore?.isSubToolbarShown() && editableMode"> + <QBtnGroup push style="column-gap: 1px"> + <QBtnDropdown + label="+/-" + color="primary" + flat + :title="t('Invert quantity value')" + :disable="!selectedRows.length" + data-cy="change-quantity-sign" > - <template #body="props"> - <QTr> - <QTd> - <QCheckbox v-model="props.selected" /> - </QTd> - <QTd - v-for="col in props.cols" - :key="col.name" - style="max-width: 100px" - > - <component - :is="tableColumnComponents[col.name].component" - v-bind="tableColumnComponents[col.name].props" - v-model="props.row[col.field]" - v-on=" - tableColumnComponents[col.name].event( - col.field, - props - ) - " + <QList> + <QItem> + <QItemSection> + <QBtn + flat + @click="invertQuantitySign(selectedRows, -1)" + data-cy="set-negative-quantity" > - <template - v-if=" - col.name === 'grouping' || col.name === 'packing' - " - #append - > - <QBtn - :icon=" - lockIconType(props.row.groupingMode, col.name) - " - @click="toggleGroupingMode(props.row, col.name)" - class="cursor-pointer" - size="sm" - flat - dense - unelevated - push - :style="{ - 'font-variation-settings': `'FILL' ${ - lockIconType( - props.row.groupingMode, - col.name - ) === 'lock' - ? 1 - : 0 - }`, - }" - /> - </template> - <template - v-if="col.name === 'item' || col.name === 'import'" - > - {{ col.value }} - </template> - <ItemDescriptorProxy - v-if="col.name === 'item'" - :id="props.row.item.id" - /> - </component> - </QTd> - </QTr> - <QTr no-hover class="full-width infoRow" style="column-span: all"> - <QTd /> - <QTd cols> - <span>{{ props.row.item.itemType.code }}</span> - </QTd> - <QTd> - <span>{{ props.row.item.size }}</span> - </QTd> - <QTd> - <span>{{ toCurrency(props.row.item.minPrice) }}</span> - </QTd> - <QTd colspan="7"> - <span>{{ props.row.item.concept }}</span> - <span v-if="props.row.item.subName" class="subName"> - {{ props.row.item.subName }} - </span> - <FetchedTags :item="props.row.item" /> - </QTd> - </QTr> - </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"> - <component - :is="tableColumnComponents[col.name].component" - v-bind="tableColumnComponents[col.name].props" - v-model="props.row[col.field]" - v-on=" - tableColumnComponents[col.name].event( - col.field, - props - ) - " - class="full-width" - > - <template - v-if=" - col.name === 'item' || - col.name === 'import' - " - > - {{ col.label + ': ' + col.value }} - </template> - </component> - </QItem> - </QList> - </QCard> - </div> - </template> - </QTable> + <span style="font-size: large">-</span> + </QBtn> + </QItemSection> + </QItem> + <QItem> + <QItemSection> + <QBtn + flat + @click="invertQuantitySign(selectedRows, 1)" + data-cy="set-positive-quantity" + > + <span style="font-size: large">+</span> + </QBtn> + </QItemSection> + </QItem> + </QList> + </QBtnDropdown> + <QBtnDropdown + icon="price_check" + color="primary" + flat + :title="t('Check buy amount')" + :disable="!selectedRows.length" + data-cy="check-buy-amount" + > + <QList> + <QItem> + <QItemSection> + <QBtn + size="sm" + icon="check" + flat + @click="setIsChecked(selectedRows, true)" + data-cy="check-amount" + /> + </QItemSection> + </QItem> + <QItem> + <QItemSection> + <QBtn + size="sm" + icon="close" + flat + @click="setIsChecked(selectedRows, false)" + data-cy="uncheck-amount" + /> + </QItemSection> + </QItem> + </QList> + </QBtnDropdown> + </QBtnGroup> + </Teleport> + <FetchData + ref="footerFetchDataRef" + :url="`Entries/${entityId}/getBuyList`" + :params="{ groupBy: 'GROUP BY b.entryFk' }" + @on-fetch="(data) => (footer = data[0])" + auto-load + /> + <VnTable + ref="entryBuysRef" + data-key="EntryBuys" + :url="`Entries/${entityId}/getBuyList`" + order="name DESC" + save-url="Buys/crud" + :disable-option="{ card: true }" + v-model:selected="selectedRows" + @on-fetch="() => footerFetchDataRef.fetch()" + :table=" + editableMode + ? { + 'row-key': 'id', + selection: 'multiple', + } + : {} + " + :create=" + editableMode + ? { + urlCreate: 'Buys', + title: t('Create buy'), + onDataSaved: () => { + entryBuysRef.reload(); + }, + formInitialData: { entryFk: entityId, isIgnored: false }, + showSaveAndContinueBtn: true, + } + : null + " + :create-complement="{ + isFullWidth: true, + containerStyle: { + display: 'flex', + 'flex-wrap': 'wrap', + gap: '16px', + position: 'relative', + height: '450px', + }, + columnGridStyle: { + 'max-width': '50%', + flex: 1, + 'margin-right': '30px', + }, + }" + :is-editable="editableMode" + :without-header="!editableMode" + :with-filters="editableMode" + :right-search="editableMode" + :row-click="false" + :columns="columns" + :beforeSaveFn="beforeSave" + class="buyList" + :table-height="$props.tableHeight ?? '84vh'" + auto-load + footer + data-cy="entry-buys" + > + <template #column-hex="{ row }"> + <VnColor :colors="row?.hexJson" style="height: 100%; min-width: 2000px" /> </template> - </VnPaginate> - - <QPageSticky :offset="[20, 20]"> - <QBtn fab icon="upload" color="primary" @click="importBuys()" /> - <QTooltip class="text-no-wrap"> - {{ t('Import buys') }} - </QTooltip> - </QPageSticky> + <template #column-name="{ row }"> + <span class="link"> + {{ row?.name }} + <ItemDescriptorProxy :id="row?.itemFk" /> + </span> + </template> + <template #column-tags="{ row }"> + <FetchedTags :item="row" :columns="3" /> + </template> + <template #column-stickers="{ row }"> + <span :class="editableMode ? 'editable-text' : ''"> + <span style="color: var(--vn-label-color)"> + {{ row.printedStickers }} + </span> + <span>/{{ row.stickers }}</span> + </span> + </template> + <template #column-footer-stickers> + <div> + <span style="color: var(--vn-label-color)"> + {{ footer?.printedStickers }}</span + > + <span>/</span> + <span data-cy="footer-stickers">{{ footer?.stickers }}</span> + </div> + </template> + <template #column-footer-weight> + {{ footer?.weight }} + </template> + <template #column-footer-quantity> + <span :style="getQuantityStyle(footer)" data-cy="footer-quantity"> + {{ footer?.quantity }} + </span> + </template> + <template #column-footer-amount> + <span :style="getAmountStyle(footer)" data-cy="footer-amount"> + {{ footer?.amount }} + </span> + </template> + <template #column-create-itemFk="{ data }"> + <VnSelect + url="Items/search" + v-model="data.itemFk" + :label="t('Article')" + :fields="['id', 'name', 'size', 'producerName']" + :filter-options="['id', 'name', 'size', 'producerName']" + option-label="name" + option-value="id" + @update:modelValue=" + async (value) => { + await setBuyUltimate(value, data); + } + " + :required="true" + data-cy="itemFk-create-popup" + sort-by="nickname DESC" + > + <template #option="scope"> + <QItem v-bind="scope.itemProps"> + <QItemSection> + <QItemLabel> + {{ scope.opt.name }} + </QItemLabel> + <QItemLabel caption> + #{{ scope.opt.id }}, {{ scope.opt?.size }}, + {{ scope.opt?.producerName }} + </QItemLabel> + </QItemSection> + </QItem> + </template> + </VnSelect> + </template> + <template #column-create-groupingMode="{ data }"> + <VnSelectEnum + :label="t('Grouping mode')" + v-model="data.groupingMode" + schema="vn" + table="buy" + column="groupingMode" + option-value="groupingMode" + option-label="groupingMode" + /> + </template> + <template #previous-create-dialog="{ data }"> + <div + style="position: absolute" + :class="{ 'centered-container': !data.itemFk }" + > + <ItemDescriptor :id="data.itemFk" v-if="data.itemFk" /> + <div v-else> + <span>{{ t('globals.noData') }}</span> + </div> + </div> + </template> + </VnTable> </template> - -<style lang="scss" scoped> -.q-table--horizontal-separator tbody tr:nth-child(odd) > td { - border-bottom-width: 0px; - border-top-width: 2px; - border-color: var(--vn-text-color); -} -.infoRow > td { - color: var(--vn-label-color); -} -</style> - <i18n> es: - Import buys: Importar compras - Buy deleted: Compra eliminada - Buys deleted: Compras eliminadas - Confirm deletion: Confirmar eliminación - Are you sure you want to delete this buy?: Seguro que quieres eliminar esta compra? - Are you sure you want to delete this buys?: Seguro que quieres eliminar estas compras? + Article: Artículo + Siz.: Med. + Size: Medida + Sti.: Eti. + Bucket: Cubo + Quantity: Cantidad + Amount: Importe + Pack.: Paq. + Package: Paquete + Box: Caja + P.Sen: P.Env + Packing sent: Packing envíos + Com.: Ref. + Comment: Referencia + Minimum price: Precio mínimo + Stickers: Etiquetas + Printed Stickers/Stickers: Etiquetas impresas/Etiquetas + Cost: Cost. + Buying value: Coste + Producer: Productor + Company: Compañia + Tags: Etiquetas + Grouping mode: Modo de agrupación + C.min: P.min + Ignore: Ignorar + Ignored for available: Ignorado para disponible + Grouping selector: Selector de grouping + Check min price: Marcar precio mínimo + Create buy: Crear compra + Invert quantity value: Invertir valor de cantidad + Check buy amount: Marcar como correcta la cantidad de compra </i18n> +<style lang="scss" scoped> +.centered-container { + display: flex; + justify-content: center; + align-items: center; + position: absolute; + width: 40%; + height: 100%; +} +</style> diff --git a/src/pages/Entry/Card/EntryDescriptor.vue b/src/pages/Entry/Card/EntryDescriptor.vue index 19d13e51a..69b300cb2 100644 --- a/src/pages/Entry/Card/EntryDescriptor.vue +++ b/src/pages/Entry/Card/EntryDescriptor.vue @@ -1,12 +1,19 @@ <script setup> import { ref, computed, onMounted } from 'vue'; -import { useRoute } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import { useI18n } from 'vue-i18n'; -import CardDescriptor from 'components/ui/CardDescriptor.vue'; -import VnLv from 'src/components/ui/VnLv.vue'; import { toDate } from 'src/filters'; import { getUrl } from 'src/composables/getUrl'; -import EntryDescriptorMenu from './EntryDescriptorMenu.vue'; +import { useQuasar } from 'quasar'; +import { usePrintService } from 'composables/usePrintService'; +import CardDescriptor from 'components/ui/CardDescriptor.vue'; +import VnLv from 'src/components/ui/VnLv.vue'; +import TravelDescriptorProxy from 'src/pages/Travel/Card/TravelDescriptorProxy.vue'; +import axios from 'axios'; + +const quasar = useQuasar(); +const { push } = useRouter(); +const { openReport } = usePrintService(); const $props = defineProps({ id: { @@ -83,12 +90,63 @@ const getEntryRedirectionFilter = (entry) => { to, }); }; + +function showEntryReport() { + openReport(`Entries/${entityId.value}/entry-order-pdf`); +} + +function showNotification(type, message) { + quasar.notify({ + type: type, + message: t(message), + }); +} + +async function recalculateRates(entity) { + try { + const entryConfig = await axios.get('EntryConfigs/findOne'); + if (entryConfig.data?.inventorySupplierFk === entity.supplierFk) { + showNotification( + 'negative', + 'Cannot recalculate prices because this is an inventory entry', + ); + return; + } + + await axios.post(`Entries/${entityId.value}/recalcEntryPrices`); + showNotification('positive', 'Entry prices recalculated'); + } catch (error) { + showNotification('negative', 'Failed to recalculate rates'); + console.error(error); + } +} + +async function cloneEntry() { + try { + const response = await axios.post(`Entries/${entityId.value}/cloneEntry`); + push({ path: `/entry/${response.data}` }); + showNotification('positive', 'Entry cloned'); + } catch (error) { + showNotification('negative', 'Failed to clone entry'); + console.error(error); + } +} + +async function deleteEntry() { + try { + await axios.post(`Entries/${entityId.value}/deleteEntry`); + push({ path: `/entry/list` }); + showNotification('positive', 'Entry deleted'); + } catch (error) { + showNotification('negative', 'Failed to delete entry'); + console.error(error); + } +} </script> <template> <CardDescriptor ref="entryDescriptorRef" - module="Entry" :url="`Entries/${entityId}`" :userFilter="entryFilter" title="supplier.nickname" @@ -96,15 +154,56 @@ const getEntryRedirectionFilter = (entry) => { width="lg-width" > <template #menu="{ entity }"> - <EntryDescriptorMenu :id="entity.id" /> + <QItem + v-ripple + clickable + @click="showEntryReport(entity)" + data-cy="show-entry-report" + > + <QItemSection>{{ t('Show entry report') }}</QItemSection> + </QItem> + <QItem + v-ripple + clickable + @click="recalculateRates(entity)" + data-cy="recalculate-rates" + > + <QItemSection>{{ t('Recalculate rates') }}</QItemSection> + </QItem> + <QItem v-ripple clickable @click="cloneEntry(entity)" data-cy="clone-entry"> + <QItemSection>{{ t('Clone') }}</QItemSection> + </QItem> + <QItem v-ripple clickable @click="deleteEntry(entity)" data-cy="delete-entry"> + <QItemSection>{{ t('Delete') }}</QItemSection> + </QItem> </template> <template #body="{ entity }"> - <VnLv :label="t('globals.agency')" :value="entity.travel?.agency?.name" /> - <VnLv :label="t('shipped')" :value="toDate(entity.travel?.shipped)" /> - <VnLv :label="t('landed')" :value="toDate(entity.travel?.landed)" /> + <VnLv :label="t('Travel')"> + <template #value> + <span class="link" v-if="entity?.travelFk"> + {{ entity.travel?.agency?.name }} + {{ entity.travel?.warehouseOut?.code }} → + {{ entity.travel?.warehouseIn?.code }} + <TravelDescriptorProxy :id="entity?.travelFk" /> + </span> + </template> + </VnLv> <VnLv - :label="t('globals.warehouseOut')" - :value="entity.travel?.warehouseOut?.name" + :label="t('entry.summary.travelShipped')" + :value="toDate(entity.travel?.shipped)" + /> + <VnLv + :label="t('entry.summary.travelLanded')" + :value="toDate(entity.travel?.landed)" + /> + <VnLv :label="t('entry.summary.currency')" :value="entity?.currency?.code" /> + <VnLv + :label="t('entry.summary.invoiceAmount')" + :value="entity?.invoiceAmount" + /> + <VnLv + :label="t('entry.summary.entryType')" + :value="entity?.entryType?.description" /> </template> <template #icons="{ entity }"> @@ -131,6 +230,14 @@ const getEntryRedirectionFilter = (entry) => { }}</QTooltip > </QIcon> + <QIcon + v-if="!entity?.travelFk" + name="vn:deletedTicket" + size="xs" + color="primary" + > + <QTooltip>{{ t('This entry is deleted') }}</QTooltip> + </QIcon> </QCardActions> </template> <template #actions="{ entity }"> @@ -143,21 +250,6 @@ const getEntryRedirectionFilter = (entry) => { > <QTooltip>{{ t('Supplier card') }}</QTooltip> </QBtn> - <QBtn - :to="{ - name: 'TravelMain', - query: { - params: JSON.stringify({ - agencyModeFk: entity.travel?.agencyModeFk, - }), - }, - }" - size="md" - icon="local_airport" - color="primary" - > - <QTooltip>{{ t('All travels with current agency') }}</QTooltip> - </QBtn> <QBtn :to="{ name: 'EntryMain', @@ -177,10 +269,24 @@ const getEntryRedirectionFilter = (entry) => { </template> <i18n> es: + Travel: Envío Supplier card: Ficha del proveedor All travels with current agency: Todos los envíos con la agencia actual All entries with current supplier: Todas las entradas con el proveedor actual Show entry report: Ver informe del pedido Inventory entry: Es inventario Virtual entry: Es una redada + shipped: Enviado + landed: Recibido + This entry is deleted: Esta entrada está eliminada + Cannot recalculate prices because this is an inventory entry: No se pueden recalcular los precios porque es una entrada de inventario + Entry deleted: Entrada eliminada + Entry cloned: Entrada clonada + Entry prices recalculated: Precios de la entrada recalculados + Failed to recalculate rates: No se pudieron recalcular las tarifas + Failed to clone entry: No se pudo clonar la entrada + Failed to delete entry: No se pudo eliminar la entrada + Recalculate rates: Recalcular tarifas + Clone: Clonar + Delete: Eliminar </i18n> diff --git a/src/pages/Entry/Card/EntryDescriptorMenu.vue b/src/pages/Entry/Card/EntryDescriptorMenu.vue index 03cd53358..dc759c7a8 100644 --- a/src/pages/Entry/Card/EntryDescriptorMenu.vue +++ b/src/pages/Entry/Card/EntryDescriptorMenu.vue @@ -54,8 +54,8 @@ const transferEntry = async () => { <i18n> en: transferEntryDialog: The entries will be transferred to the next day - transferEntry: Transfer Entry + transferEntry: Partial delay es: transferEntryDialog: Se van a transferir las compras al dia siguiente - transferEntry: Transferir Entrada + transferEntry: Retraso parcial </i18n> diff --git a/src/pages/Entry/Card/EntryFilter.js b/src/pages/Entry/Card/EntryFilter.js index 3ff62cf27..d9fd1c2be 100644 --- a/src/pages/Entry/Card/EntryFilter.js +++ b/src/pages/Entry/Card/EntryFilter.js @@ -9,6 +9,7 @@ export default { 'shipped', 'agencyModeFk', 'warehouseOutFk', + 'warehouseInFk', 'daysInForward', ], include: [ @@ -21,13 +22,13 @@ export default { { relation: 'warehouseOut', scope: { - fields: ['name'], + fields: ['name', 'code'], }, }, { relation: 'warehouseIn', scope: { - fields: ['name'], + fields: ['name', 'code'], }, }, ], @@ -39,5 +40,17 @@ export default { fields: ['id', 'nickname'], }, }, + { + relation: 'currency', + scope: { + fields: ['id', 'code'], + }, + }, + { + relation: 'entryType', + scope: { + fields: ['code', 'description'], + }, + }, ], }; diff --git a/src/pages/Entry/Card/EntrySummary.vue b/src/pages/Entry/Card/EntrySummary.vue index 8c46fb6e6..c40e2ba46 100644 --- a/src/pages/Entry/Card/EntrySummary.vue +++ b/src/pages/Entry/Card/EntrySummary.vue @@ -2,19 +2,17 @@ import { onMounted, ref, computed } from 'vue'; import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; +import { toDate } from 'src/filters'; +import { getUrl } from 'src/composables/getUrl'; +import axios from 'axios'; import CardSummary from 'components/ui/CardSummary.vue'; import VnLv from 'src/components/ui/VnLv.vue'; import TravelDescriptorProxy from 'src/pages/Travel/Card/TravelDescriptorProxy.vue'; - -import { toDate, toCurrency, toCelsius } from 'src/filters'; -import { getUrl } from 'src/composables/getUrl'; -import axios from 'axios'; -import FetchedTags from 'src/components/ui/FetchedTags.vue'; -import VnToSummary from 'src/components/ui/VnToSummary.vue'; -import EntryDescriptorMenu from './EntryDescriptorMenu.vue'; -import VnRow from 'src/components/ui/VnRow.vue'; +import EntryBuys from './EntryBuys.vue'; import VnTitle from 'src/components/common/VnTitle.vue'; +import VnCheckbox from 'src/components/common/VnCheckbox.vue'; +import VnToSummary from 'src/components/ui/VnToSummary.vue'; const route = useRoute(); const { t } = useI18n(); @@ -33,117 +31,6 @@ const entry = ref(); const entryBuys = ref([]); const entryUrl = ref(); -onMounted(async () => { - entryUrl.value = (await getUrl('entry/')) + entityId.value; -}); - -const tableColumnComponents = { - quantity: { - component: () => 'span', - props: () => {}, - }, - stickers: { - component: () => 'span', - props: () => {}, - event: () => {}, - }, - packagingFk: { - component: () => 'span', - props: () => {}, - event: () => {}, - }, - weight: { - component: () => 'span', - props: () => {}, - event: () => {}, - }, - packing: { - component: () => 'span', - props: () => {}, - event: () => {}, - }, - grouping: { - component: () => 'span', - props: () => {}, - event: () => {}, - }, - buyingValue: { - component: () => 'span', - props: () => {}, - event: () => {}, - }, - amount: { - component: () => 'span', - props: () => {}, - event: () => {}, - }, - pvp: { - component: () => 'span', - props: () => {}, - event: () => {}, - }, -}; - -const entriesTableColumns = computed(() => { - return [ - { - label: t('globals.quantity'), - field: 'quantity', - name: 'quantity', - align: 'left', - }, - { - label: t('entry.summary.stickers'), - field: 'stickers', - name: 'stickers', - align: 'left', - }, - { - label: t('entry.summary.package'), - field: 'packagingFk', - name: 'packagingFk', - align: 'left', - }, - { - label: t('globals.weight'), - field: 'weight', - name: 'weight', - align: 'left', - }, - { - label: t('entry.summary.packing'), - field: 'packing', - name: 'packing', - align: 'left', - }, - { - label: t('entry.summary.grouping'), - field: 'grouping', - name: 'grouping', - align: 'left', - }, - { - label: t('entry.summary.buyingValue'), - field: 'buyingValue', - name: 'buyingValue', - align: 'left', - format: (value) => toCurrency(value), - }, - { - label: t('entry.summary.import'), - name: 'amount', - align: 'left', - format: (_, row) => toCurrency(row.buyingValue * row.quantity), - }, - { - label: t('entry.summary.pvp'), - name: 'pvp', - align: 'left', - format: (_, row) => toCurrency(row.price2) + ' / ' + toCurrency(row.price3), - }, - ]; -}); - async function setEntryData(data) { if (data) entry.value = data; await fetchEntryBuys(); @@ -153,14 +40,18 @@ const fetchEntryBuys = async () => { const { data } = await axios.get(`Entries/${entry.value.id}/getBuys`); if (data) entryBuys.value = data; }; -</script> +onMounted(async () => { + entryUrl.value = (await getUrl('entry/')) + entityId.value; +}); +</script> <template> <CardSummary ref="summaryRef" :url="`Entries/${entityId}/getEntry`" @on-fetch="(data) => setEntryData(data)" data-key="EntrySummary" + data-cy="entry-summary" > <template #header-left> <VnToSummary @@ -173,159 +64,154 @@ const fetchEntryBuys = async () => { <template #header> <span>{{ entry.id }} - {{ entry.supplier.nickname }}</span> </template> - <template #menu="{ entity }"> - <EntryDescriptorMenu :id="entity.id" /> - </template> <template #body> <QCard class="vn-one"> <VnTitle :url="`#/entry/${entityId}/basic-data`" :text="t('globals.summary.basicData')" /> - <VnLv :label="t('entry.summary.commission')" :value="entry.commission" /> - <VnLv - :label="t('entry.summary.currency')" - :value="entry.currency?.name" - /> - <VnLv :label="t('globals.company')" :value="entry.company.code" /> - <VnLv :label="t('globals.reference')" :value="entry.reference" /> - <VnLv - :label="t('entry.summary.invoiceNumber')" - :value="entry.invoiceNumber" - /> - <VnLv - :label="t('entry.basicData.initialTemperature')" - :value="toCelsius(entry.initialTemperature)" - /> - <VnLv - :label="t('entry.basicData.finalTemperature')" - :value="toCelsius(entry.finalTemperature)" - /> + <div class="card-group"> + <div class="card-content"> + <VnLv + :label="t('entry.summary.commission')" + :value="entry?.commission" + /> + <VnLv + :label="t('entry.summary.currency')" + :value="entry?.currency?.name" + /> + <VnLv + :label="t('globals.company')" + :value="entry?.company?.code" + /> + <VnLv :label="t('globals.reference')" :value="entry?.reference" /> + <VnLv + :label="t('entry.summary.invoiceNumber')" + :value="entry?.invoiceNumber" + /> + </div> + <div class="card-content"> + <VnCheckbox + :label="t('entry.summary.ordered')" + v-model="entry.isOrdered" + :disable="true" + size="xs" + /> + <VnCheckbox + :label="t('globals.confirmed')" + v-model="entry.isConfirmed" + :disable="true" + size="xs" + /> + <VnCheckbox + :label="t('entry.summary.booked')" + v-model="entry.isBooked" + :disable="true" + size="xs" + /> + <VnCheckbox + :label="t('entry.summary.excludedFromAvailable')" + v-model="entry.isExcludedFromAvailable" + :disable="true" + size="xs" + /> + </div> + </div> </QCard> - <QCard class="vn-one"> + <QCard class="vn-one" v-if="entry?.travelFk"> <VnTitle - :url="`#/entry/${entityId}/basic-data`" - :text="t('globals.summary.basicData')" + :url="`#/travel/${entry.travel.id}/summary`" + :text="t('Travel')" /> - <VnLv :label="t('entry.summary.travelReference')"> - <template #value> - <span class="link"> - {{ entry.travel.ref }} - <TravelDescriptorProxy :id="entry.travel.id" /> - </span> - </template> - </VnLv> - <VnLv - :label="t('entry.summary.travelAgency')" - :value="entry.travel.agency?.name" - /> - <VnLv - :label="t('globals.shipped')" - :value="toDate(entry.travel.shipped)" - /> - <VnLv - :label="t('globals.warehouseOut')" - :value="entry.travel.warehouseOut?.name" - /> - <VnLv - :label="t('entry.summary.travelDelivered')" - :value="entry.travel.isDelivered" - /> - <VnLv :label="t('globals.landed')" :value="toDate(entry.travel.landed)" /> - <VnLv - :label="t('globals.warehouseIn')" - :value="entry.travel.warehouseIn?.name" - /> - <VnLv - :label="t('entry.summary.travelReceived')" - :value="entry.travel.isReceived" - /> - </QCard> - <QCard class="vn-one"> - <VnTitle :url="`#/travel/${entityId}/summary`" :text="t('Travel data')" /> - <VnRow class="block"> - <VnLv :label="t('entry.summary.ordered')" :value="entry.isOrdered" /> - <VnLv :label="t('globals.confirmed')" :value="entry.isConfirmed" /> - <VnLv :label="t('entry.summary.booked')" :value="entry.isBooked" /> - <VnLv - :label="t('entry.summary.excludedFromAvailable')" - :value="entry.isExcludedFromAvailable" - /> - </VnRow> + <div class="card-group"> + <div class="card-content"> + <VnLv :label="t('entry.summary.travelReference')"> + <template #value> + <span class="link"> + {{ entry.travel.ref }} + <TravelDescriptorProxy :id="entry.travel.id" /> + </span> + </template> + </VnLv> + <VnLv + :label="t('entry.summary.travelAgency')" + :value="entry.travel.agency?.name" + /> + <VnLv + :label="t('entry.summary.travelShipped')" + :value="toDate(entry.travel.shipped)" + /> + <VnLv + :label="t('globals.warehouseOut')" + :value="entry.travel.warehouseOut?.name" + /> + <VnLv + :label="t('entry.summary.travelLanded')" + :value="toDate(entry.travel.landed)" + /> + <VnLv + :label="t('globals.warehouseIn')" + :value="entry.travel.warehouseIn?.name" + /> + </div> + <div class="card-content"> + <VnCheckbox + :label="t('entry.summary.travelDelivered')" + v-model="entry.travel.isDelivered" + :disable="true" + size="xs" + /> + <VnCheckbox + :label="t('entry.summary.travelReceived')" + v-model="entry.travel.isReceived" + :disable="true" + size="xs" + /> + </div> + </div> </QCard> <QCard class="vn-max"> <VnTitle :url="`#/entry/${entityId}/buys`" :text="t('entry.summary.buys')" /> - <QTable - :rows="entryBuys" - :columns="entriesTableColumns" - row-key="index" - class="full-width q-mt-md" - :no-data-label="t('globals.noResults')" - > - <template #body="{ cols, row, rowIndex }"> - <QTr no-hover> - <QTd v-for="col in cols" :key="col?.name"> - <component - :is="tableColumnComponents[col?.name].component()" - v-bind="tableColumnComponents[col?.name].props()" - @click="tableColumnComponents[col?.name].event()" - class="col-content" - > - <template - v-if=" - col?.name !== 'observation' && - col?.name !== 'isConfirmed' - " - >{{ col.value }}</template - > - <QTooltip v-if="col.toolTip">{{ - col.toolTip - }}</QTooltip> - </component> - </QTd> - </QTr> - <QTr no-hover> - <QTd> - <span>{{ row.item.itemType.code }}</span> - </QTd> - <QTd> - <span>{{ row.item.id }}</span> - </QTd> - <QTd> - <span>{{ row.item.size }}</span> - </QTd> - <QTd> - <span>{{ toCurrency(row.item.minPrice) }}</span> - </QTd> - <QTd colspan="6"> - <span>{{ row.item.concept }}</span> - <span v-if="row.item.subName" class="subName"> - {{ row.item.subName }} - </span> - <FetchedTags :item="row.item" /> - </QTd> - </QTr> - <!-- Esta última row es utilizada para agregar un espaciado y así marcar una diferencia visual entre los diferentes buys --> - <QTr v-if="rowIndex !== entryBuys.length - 1"> - <QTd colspan="10" class="vn-table-separation-row" /> - </QTr> - </template> - </QTable> + <EntryBuys + v-if="entityId" + :id="Number(entityId)" + :editable-mode="false" + table-height="49vh" + /> </QCard> </template> </CardSummary> </template> - <style lang="scss" scoped> -.separation-row { - background-color: var(--vn-section-color) !important; +.card-group { + display: flex; + flex-direction: column; +} + +.card-content { + display: flex; + flex-direction: column; + text-overflow: ellipsis; + > div { + max-height: 24px; + } +} + +@media (min-width: 1010px) { + .card-group { + flex-direction: row; + } + .card-content { + flex: 1; + margin-right: 16px; + } } </style> - <i18n> es: - Travel data: Datos envío + Travel: Envío + InvoiceIn data: Datos factura </i18n> diff --git a/src/pages/Entry/EntryFilter.vue b/src/pages/Entry/EntryFilter.vue index 0f632c0ef..8c60918a8 100644 --- a/src/pages/Entry/EntryFilter.vue +++ b/src/pages/Entry/EntryFilter.vue @@ -19,6 +19,7 @@ const props = defineProps({ const currenciesOptions = ref([]); const companiesOptions = ref([]); +const entryFilterPanel = ref(); </script> <template> @@ -38,7 +39,7 @@ const companiesOptions = ref([]); @on-fetch="(data) => (currenciesOptions = data)" auto-load /> - <VnFilterPanel :data-key="props.dataKey" :search-button="true"> + <VnFilterPanel ref="entryFilterPanel" :data-key="props.dataKey" :search-button="true"> <template #tags="{ tag, formatFn }"> <div class="q-gutter-x-xs"> <strong>{{ t(`entryFilter.params.${tag.label}`) }}: </strong> @@ -48,70 +49,65 @@ const companiesOptions = ref([]); <template #body="{ params, searchFn }"> <QItem> <QItemSection> - <VnInput - v-model="params.search" - :label="t('entryFilter.params.search')" - is-outlined - /> + <QCheckbox + :label="t('params.isExcludedFromAvailable')" + v-model="params.isExcludedFromAvailable" + toggle-indeterminate + > + <QTooltip> + {{ t('params.isExcludedFromAvailable') }} + </QTooltip> + </QCheckbox> + </QItemSection> + <QItemSection> + <QCheckbox + :label="t('params.isOrdered')" + v-model="params.isOrdered" + toggle-indeterminate + > + <QTooltip> + {{ t('entry.list.tableVisibleColumns.isOrdered') }} + </QTooltip> + </QCheckbox> </QItemSection> </QItem> <QItem> <QItemSection> - <VnInput - v-model="params.reference" - :label="t('entryFilter.params.reference')" - is-outlined - /> + <QCheckbox + :label="t('params.isReceived')" + v-model="params.isReceived" + toggle-indeterminate + > + <QTooltip> + {{ t('entry.list.tableVisibleColumns.isReceived') }} + </QTooltip> + </QCheckbox> + </QItemSection> + <QItemSection> + <QCheckbox + :label="t('entry.list.tableVisibleColumns.isConfirmed')" + v-model="params.isConfirmed" + toggle-indeterminate + > + <QTooltip> + {{ t('entry.list.tableVisibleColumns.isConfirmed') }} + </QTooltip> + </QCheckbox> </QItemSection> </QItem> <QItem> <QItemSection> - <VnInput - v-model="params.invoiceNumber" - :label="t('entryFilter.params.invoiceNumber')" - is-outlined - /> - </QItemSection> - </QItem> - <QItem> - <QItemSection> - <VnInput - v-model="params.travelFk" - :label="t('entryFilter.params.travelFk')" - is-outlined - /> - </QItemSection> - </QItem> - <QItem> - <QItemSection> - <VnSelect - :label="t('entryFilter.params.companyFk')" - v-model="params.companyFk" + <VnInputDate + :label="t('params.landed')" + v-model="params.landed" @update:model-value="searchFn()" - :options="companiesOptions" - option-value="id" - option-label="code" - hide-selected - dense - outlined - rounded + is-outlined /> </QItemSection> </QItem> <QItem> <QItemSection> - <VnSelect - :label="t('entryFilter.params.currencyFk')" - v-model="params.currencyFk" - @update:model-value="searchFn()" - :options="currenciesOptions" - option-value="id" - option-label="name" - hide-selected - dense - outlined - rounded - /> + <VnInput v-model="params.id" label="Id" is-outlined /> </QItemSection> </QItem> <QItem> @@ -125,62 +121,165 @@ const companiesOptions = ref([]); rounded /> </QItemSection> - </QItem> - <QItem> <QItemSection> - <VnInputDate - :label="t('entryFilter.params.created')" - v-model="params.created" - @update:model-value="searchFn()" + <VnInput + v-model="params.invoiceNumber" + :label="t('params.invoiceNumber')" is-outlined /> </QItemSection> </QItem> <QItem> <QItemSection> - <VnInputDate - :label="t('entryFilter.params.from')" - v-model="params.from" - @update:model-value="searchFn()" + <VnInput + v-model="params.reference" + :label="t('entry.list.tableVisibleColumns.reference')" is-outlined /> </QItemSection> </QItem> <QItem> <QItemSection> - <VnInputDate - :label="t('entryFilter.params.to')" - v-model="params.to" + <VnSelect + :label="t('params.agencyModeId')" + v-model="params.agencyModeId" @update:model-value="searchFn()" + url="AgencyModes" + :fields="['id', 'name']" + hide-selected + dense + outlined + rounded + /> + </QItemSection> + </QItem> + <QItem> + <QItemSection> + <VnInput + v-model="params.evaNotes" + :label="t('params.evaNotes')" is-outlined /> </QItemSection> </QItem> <QItem> <QItemSection> - <QCheckbox - :label="t('entryFilter.params.isBooked')" - v-model="params.isBooked" - toggle-indeterminate - /> - </QItemSection> - <QItemSection> - <QCheckbox - :label="t('entryFilter.params.isConfirmed')" - v-model="params.isConfirmed" - toggle-indeterminate + <VnSelect + :label="t('params.warehouseOutFk')" + v-model="params.warehouseOutFk" + @update:model-value="searchFn()" + url="Warehouses" + :fields="['id', 'name']" + hide-selected + dense + outlined + rounded /> </QItemSection> </QItem> <QItem> <QItemSection> - <QCheckbox - :label="t('entryFilter.params.isOrdered')" - v-model="params.isOrdered" - toggle-indeterminate + <VnSelect + :label="t('params.warehouseInFk')" + v-model="params.warehouseInFk" + @update:model-value="searchFn()" + url="Warehouses" + :fields="['id', 'name']" + hide-selected + dense + outlined + rounded + > + <template #option="scope"> + <QItem v-bind="scope.itemProps"> + <QItemSection> + <QItemLabel> + {{ scope.opt?.name }} + </QItemLabel> + <QItemLabel caption> + {{ `#${scope.opt?.id} , ${scope.opt?.nickname}` }} + </QItemLabel> + </QItemSection> + </QItem> + </template> + </VnSelect> + </QItemSection> + </QItem> + <QItem> + <QItemSection> + <VnInput + v-model="params.invoiceNumber" + :label="t('params.invoiceNumber')" + is-outlined + /> + </QItemSection> + </QItem> + + <QItem> + <QItemSection> + <VnSelect + :label="t('params.entryTypeCode')" + v-model="params.entryTypeCode" + @update:model-value="searchFn()" + url="EntryTypes" + :fields="['code', 'description']" + option-value="code" + option-label="description" + hide-selected + dense + outlined + rounded + /> + </QItemSection> + </QItem> + <QItem> + <QItemSection> + <VnInput + v-model="params.evaNotes" + :label="t('params.evaNotes')" + is-outlined /> </QItemSection> </QItem> </template> </VnFilterPanel> </template> + +<i18n> +en: + params: + isExcludedFromAvailable: Inventory + isOrdered: Ordered + isReceived: Received + isConfirmed: Confirmed + isRaid: Raid + landed: Date + id: Id + supplierFk: Supplier + invoiceNumber: Invoice number + reference: Ref/Alb/Guide + agencyModeId: Agency mode + evaNotes: Notes + warehouseOutFk: Origin + warehouseInFk: Destiny + entryTypeCode: Entry type + hasToShowDeletedEntries: Show deleted entries +es: + params: + isExcludedFromAvailable: Inventario + isOrdered: Pedida + isConfirmed: Confirmado + isReceived: Recibida + isRaid: Raid + landed: Fecha + id: Id + supplierFk: Proveedor + invoiceNumber: Núm. factura + reference: Ref/Alb/Guía + agencyModeId: Modo agencia + evaNotes: Notas + warehouseOutFk: Origen + warehouseInFk: Destino + entryTypeCode: Tipo de entrada + hasToShowDeletedEntries: Mostrar entradas eliminadas +</i18n> diff --git a/src/pages/Entry/EntryList.vue b/src/pages/Entry/EntryList.vue index 3172c6d0e..845d65604 100644 --- a/src/pages/Entry/EntryList.vue +++ b/src/pages/Entry/EntryList.vue @@ -1,21 +1,25 @@ <script setup> +import axios from 'axios'; +import VnSection from 'src/components/common/VnSection.vue'; import { ref, computed } from 'vue'; import { useI18n } from 'vue-i18n'; +import { useState } from 'src/composables/useState'; +import { onBeforeMount } from 'vue'; + import EntryFilter from './EntryFilter.vue'; import VnTable from 'components/VnTable/VnTable.vue'; -import { toCelsius, toDate } from 'src/filters'; -import { useSummaryDialog } from 'src/composables/useSummaryDialog'; -import EntrySummary from './Card/EntrySummary.vue'; import SupplierDescriptorProxy from 'src/pages/Supplier/Card/SupplierDescriptorProxy.vue'; -import TravelDescriptorProxy from 'src/pages/Travel/Card/TravelDescriptorProxy.vue'; -import VnSection from 'src/components/common/VnSection.vue'; +import VnSelectTravelExtended from 'src/components/common/VnSelectTravelExtended.vue'; +import { toDate } from 'src/filters'; const { t } = useI18n(); const tableRef = ref(); +const defaultEntry = ref({}); +const state = useState(); +const user = state.getUser(); const dataKey = 'EntryList'; -const { viewSummary } = useSummaryDialog(); -const entryFilter = { +const entryQueryFilter = { include: [ { relation: 'suppliers', @@ -40,44 +44,53 @@ const entryFilter = { const columns = computed(() => [ { - name: 'status', - columnFilter: false, + label: 'Ex', + toolTip: t('entry.list.tableVisibleColumns.isExcludedFromAvailable'), + name: 'isExcludedFromAvailable', + component: 'checkbox', + width: '35px', + }, + { + label: 'Pe', + toolTip: t('entry.list.tableVisibleColumns.isOrdered'), + name: 'isOrdered', + component: 'checkbox', + width: '35px', + }, + { + label: 'Le', + toolTip: t('entry.list.tableVisibleColumns.isConfirmed'), + name: 'isConfirmed', + component: 'checkbox', + width: '35px', + }, + { + label: 'Re', + toolTip: t('entry.list.tableVisibleColumns.isReceived'), + name: 'isReceived', + component: 'checkbox', + width: '35px', + }, + { + label: t('entry.list.tableVisibleColumns.landed'), + name: 'landed', + component: 'date', + columnField: { + component: null, + }, + format: (row, dashIfEmpty) => dashIfEmpty(toDate(row.landed)), + width: '105px', }, { - align: 'left', label: t('globals.id'), name: 'id', isId: true, + component: 'number', chip: { condition: () => true, }, }, { - align: 'left', - label: t('globals.reference'), - name: 'reference', - isTitle: true, - component: 'input', - columnField: { - component: null, - }, - create: true, - cardVisible: true, - }, - { - align: 'left', - label: t('entry.list.tableVisibleColumns.created'), - name: 'created', - create: true, - cardVisible: true, - component: 'date', - columnField: { - component: null, - }, - format: (row, dashIfEmpty) => dashIfEmpty(toDate(row.created)), - }, - { - align: 'left', label: t('entry.list.tableVisibleColumns.supplierFk'), name: 'supplierFk', create: true, @@ -87,164 +100,197 @@ const columns = computed(() => [ url: 'suppliers', fields: ['id', 'name'], }, - columnField: { - component: null, - }, format: (row, dashIfEmpty) => dashIfEmpty(row.supplierName), }, { align: 'left', - label: t('entry.list.tableVisibleColumns.isBooked'), - name: 'isBooked', - cardVisible: true, - create: true, - component: 'checkbox', + label: t('entry.list.tableVisibleColumns.invoiceNumber'), + name: 'invoiceNumber', + component: 'input', }, { align: 'left', - label: t('entry.list.tableVisibleColumns.isConfirmed'), - name: 'isConfirmed', + label: t('entry.list.tableVisibleColumns.reference'), + name: 'reference', + isTitle: true, + component: 'input', + columnField: { + component: null, + }, cardVisible: true, - create: true, - component: 'checkbox', }, { align: 'left', - label: t('entry.list.tableVisibleColumns.isOrdered'), - name: 'isOrdered', - cardVisible: true, - create: true, - component: 'checkbox', + label: 'AWB', + name: 'awbCode', + component: 'input', }, { align: 'left', - label: t('entry.list.tableVisibleColumns.companyFk'), + label: t('entry.list.tableVisibleColumns.agencyModeId'), + name: 'agencyModeId', + cardVisible: true, + component: 'select', + attrs: { + url: 'agencyModes', + fields: ['id', 'name'], + }, + columnField: { + component: null, + }, + format: (row, dashIfEmpty) => dashIfEmpty(row.agencyModeName), + }, + { + align: 'left', + label: t('entry.list.tableVisibleColumns.evaNotes'), + name: 'evaNotes', + component: 'input', + }, + { + align: 'left', + label: t('entry.list.tableVisibleColumns.warehouseOutFk'), + name: 'warehouseOutFk', + cardVisible: true, + component: 'select', + attrs: { + url: 'warehouses', + fields: ['id', 'name'], + }, + columnField: { + component: null, + }, + format: (row, dashIfEmpty) => dashIfEmpty(row.warehouseOutName), + }, + { + align: 'left', + label: t('entry.list.tableVisibleColumns.warehouseInFk'), + name: 'warehouseInFk', + cardVisible: true, + component: 'select', + attrs: { + url: 'warehouses', + fields: ['id', 'name'], + }, + columnField: { + component: null, + }, + format: (row, dashIfEmpty) => dashIfEmpty(row.warehouseInName), + }, + { + align: 'left', + label: t('entry.list.tableVisibleColumns.entryTypeDescription'), + name: 'entryTypeCode', + cardVisible: true, + }, + { name: 'companyFk', + label: t('entry.list.tableVisibleColumns.companyFk'), + cardVisible: false, + visible: false, + create: true, component: 'select', attrs: { - url: 'companies', - fields: ['id', 'code'], + optionValue: 'id', optionLabel: 'code', - optionValue: 'id', + url: 'Companies', }, - columnField: { - component: null, - }, - create: true, - - format: (row, dashIfEmpty) => dashIfEmpty(row.companyCode), }, { - align: 'left', - label: t('entry.list.tableVisibleColumns.travelFk'), name: 'travelFk', - component: 'select', - attrs: { - url: 'travels', - fields: ['id', 'ref'], - optionLabel: 'ref', - optionValue: 'id', - }, - columnField: { - component: null, - }, + label: t('entry.list.tableVisibleColumns.travelFk'), + cardVisible: false, + visible: false, create: true, - format: (row, dashIfEmpty) => dashIfEmpty(row.travelRef), - }, - { - align: 'left', - label: t('entry.list.tableVisibleColumns.invoiceAmount'), - name: 'invoiceAmount', - cardVisible: true, - }, - { - align: 'left', - name: 'initialTemperature', - label: t('entry.basicData.initialTemperature'), - field: 'initialTemperature', - format: (row) => toCelsius(row.initialTemperature), - }, - { - align: 'left', - name: 'finalTemperature', - label: t('entry.basicData.finalTemperature'), - field: 'finalTemperature', - format: (row) => toCelsius(row.finalTemperature), - }, - { - label: t('entry.list.tableVisibleColumns.isExcludedFromAvailable'), - name: 'isExcludedFromAvailable', - columnFilter: { - inWhere: true, - }, - }, - { - align: 'right', - name: 'tableActions', - actions: [ - { - title: t('components.smartCard.viewSummary'), - icon: 'preview', - action: (row) => viewSummary(row.id, EntrySummary), - isPrimary: true, - }, - ], }, ]); +function getBadgeAttrs(row) { + const date = row.landed; + let today = Date.vnNew(); + today.setHours(0, 0, 0, 0); + let timeTicket = new Date(date); + timeTicket.setHours(0, 0, 0, 0); + + let timeDiff = today - timeTicket; + + if (timeDiff > 0) return { color: 'info', 'text-color': 'black' }; + if (timeDiff < 0) return { color: 'warning', 'text-color': 'black' }; + switch (row.entryTypeCode) { + case 'regularization': + case 'life': + case 'internal': + case 'inventory': + if (!row.isOrdered || !row.isConfirmed) + return { color: 'negative', 'text-color': 'black' }; + break; + case 'product': + case 'packaging': + case 'devaluation': + case 'payment': + case 'transport': + if ( + row.invoiceAmount === null || + (row.invoiceNumber === null && row.reference === null) || + !row.isOrdered || + !row.isConfirmed + ) + return { color: 'negative', 'text-color': 'black' }; + break; + default: + break; + } + return { color: 'transparent' }; +} + +onBeforeMount(async () => { + defaultEntry.value = (await axios.get('EntryConfigs/findOne')).data; +}); </script> <template> <VnSection :data-key="dataKey" - :columns="columns" prefix="entry" url="Entries/filter" :array-data-props="{ url: 'Entries/filter', - order: 'id DESC', - userFilter: entryFilter, + order: 'landed DESC', + userFilter: EntryFilter, }" > <template #advanced-menu> - <EntryFilter data-key="EntryList" /> + <EntryFilter :data-key="dataKey" /> </template> <template #body> <VnTable + v-if="defaultEntry.defaultSupplierFk" ref="tableRef" :data-key="dataKey" + url="Entries/filter" + :filter="entryQueryFilter" + order="landed DESC" :create="{ urlCreate: 'Entries', - title: t('entry.list.newEntry'), + title: t('Create entry'), onDataSaved: ({ id }) => tableRef.redirect(id), - formInitialData: {}, + formInitialData: { + supplierFk: defaultEntry.defaultSupplierFk, + dated: Date.vnNew(), + companyFk: user?.companyFk, + }, }" :columns="columns" redirect="entry" :right-search="false" > - <template #column-status="{ row }"> - <div class="row q-gutter-xs"> - <QIcon - v-if="!!row.isExcludedFromAvailable" - name="vn:inventory" - color="primary" - > - <QTooltip>{{ - t( - 'entry.list.tableVisibleColumns.isExcludedFromAvailable', - ) - }}</QTooltip> - </QIcon> - <QIcon v-if="!!row.isRaid" name="vn:net" color="primary"> - <QTooltip> - {{ - t('globals.raid', { - daysInForward: row.daysInForward, - }) - }}</QTooltip - > - </QIcon> - </div> + <template #column-landed="{ row }"> + <QBadge + v-if="row?.travelFk" + v-bind="getBadgeAttrs(row)" + class="q-pa-sm" + style="font-size: 14px" + > + {{ toDate(row.landed) }} + </QBadge> </template> <template #column-supplierFk="{ row }"> <span class="link" @click.stop> @@ -252,13 +298,26 @@ const columns = computed(() => [ <SupplierDescriptorProxy :id="row.supplierFk" /> </span> </template> - <template #column-travelFk="{ row }"> - <span class="link" @click.stop> - {{ row.travelRef }} - <TravelDescriptorProxy :id="row.travelFk" /> - </span> + <template #column-create-travelFk="{ data }"> + <VnSelectTravelExtended + :data="data" + v-model="data.travelFk" + :onFilterTravelSelected=" + (data, result) => (data.travelFk = result) + " + data-cy="entry-travel-select" + /> </template> </VnTable> </template> </VnSection> </template> + +<i18n> +es: + Inventory entry: Es inventario + Virtual entry: Es una redada + Search entries: Buscar entradas + You can search by entry reference: Puedes buscar por referencia de la entrada + Create entry: Crear entrada +</i18n> diff --git a/src/pages/Entry/locale/en.yml b/src/pages/Entry/locale/en.yml index 80f3491a8..88b16cb03 100644 --- a/src/pages/Entry/locale/en.yml +++ b/src/pages/Entry/locale/en.yml @@ -1,21 +1,36 @@ entry: + lock: + title: Lock entry + message: This entry has been locked by {userName} for {time} minutes. Do you want to unlock it? + success: The entry has been locked successfully list: newEntry: New entry tableVisibleColumns: - created: Creation - supplierFk: Supplier - isBooked: Booked - isConfirmed: Confirmed + isExcludedFromAvailable: Exclude from inventory isOrdered: Ordered + isConfirmed: Ready to label + isReceived: Received + isRaid: Raid + landed: Date + supplierFk: Supplier + reference: Ref/Alb/Guide + invoiceNumber: Invoice + agencyModeId: Agency + isBooked: Booked companyFk: Company - travelFk: Travel - isExcludedFromAvailable: Inventory + evaNotes: Notes + warehouseOutFk: Origin + warehouseInFk: Destiny + entryTypeDescription: Entry type invoiceAmount: Import + travelFk: Travel + dated: Dated inventoryEntry: Inventory entry summary: commission: Commission currency: Currency invoiceNumber: Invoice number + invoiceAmount: Invoice amount ordered: Ordered booked: Booked excludedFromAvailable: Inventory @@ -33,6 +48,7 @@ entry: buyingValue: Buying value import: Import pvp: PVP + entryType: Entry type basicData: travel: Travel currency: Currency @@ -69,17 +85,55 @@ entry: landing: Landing isExcludedFromAvailable: Es inventory params: - toShipped: To - fromShipped: From - daysOnward: Days onward - daysAgo: Days ago - warehouseInFk: Warehouse in + isExcludedFromAvailable: Exclude from inventory + isOrdered: Ordered + isConfirmed: Ready to label + isReceived: Received + isIgnored: Ignored + isRaid: Raid + landed: Date + supplierFk: Supplier + reference: Ref/Alb/Guide + invoiceNumber: Invoice + agencyModeId: Agency + isBooked: Booked + companyFk: Company + evaNotes: Notes + warehouseOutFk: Origin + warehouseInFk: Destiny + entryTypeDescription: Entry type + invoiceAmount: Import + travelFk: Travel + dated: Dated + itemFk: Item id + hex: Color + name: Item name + size: Size + stickers: Stickers + packagingFk: Packaging + weight: Kg + groupingMode: Grouping selector + grouping: Grouping + quantity: Quantity + buyingValue: Buying value + price2: Package + price3: Box + minPrice: Minumum price + hasMinPrice: Has minimum price + packingOut: Packing out + comment: Comment + subName: Supplier name + tags: Tags + company_name: Company name + itemTypeFk: Item type + workerFk: Worker id search: Search entries searchInfo: You can search by entry reference descriptorMenu: showEntryReport: Show entry report entryFilter: params: + isExcludedFromAvailable: Exclude from inventory invoiceNumber: Invoice number travelFk: Travel companyFk: Company @@ -91,8 +145,16 @@ entryFilter: isBooked: Booked isConfirmed: Confirmed isOrdered: Ordered + isReceived: Received search: General search reference: Reference + landed: Landed + id: Id + agencyModeId: Agency + evaNotes: Notes + warehouseOutFk: Origin + warehouseInFk: Destiny + entryTypeCode: Entry type myEntries: id: ID landed: Landed diff --git a/src/pages/Entry/locale/es.yml b/src/pages/Entry/locale/es.yml index a5b968016..3025d64cb 100644 --- a/src/pages/Entry/locale/es.yml +++ b/src/pages/Entry/locale/es.yml @@ -1,21 +1,36 @@ entry: + lock: + title: Entrada bloqueada + message: Esta entrada ha sido bloqueada por {userName} hace {time} minutos. ¿Quieres desbloquearla? + success: La entrada ha sido bloqueada correctamente list: newEntry: Nueva entrada tableVisibleColumns: - created: Creación - supplierFk: Proveedor - isBooked: Asentado - isConfirmed: Confirmado + isExcludedFromAvailable: Excluir del inventario isOrdered: Pedida + isConfirmed: Lista para etiquetar + isReceived: Recibida + isRaid: Redada + landed: Fecha + supplierFk: Proveedor + invoiceNumber: Nº Factura + reference: Ref/Alb/Guía + agencyModeId: Agencia + isBooked: Asentado companyFk: Empresa travelFk: Envio - isExcludedFromAvailable: Inventario + evaNotes: Notas + warehouseOutFk: Origen + warehouseInFk: Destino + entryTypeDescription: Tipo entrada invoiceAmount: Importe + dated: Fecha inventoryEntry: Es inventario summary: commission: Comisión currency: Moneda invoiceNumber: Núm. factura + invoiceAmount: Importe ordered: Pedida booked: Contabilizada excludedFromAvailable: Inventario @@ -34,12 +49,13 @@ entry: buyingValue: Coste import: Importe pvp: PVP + entryType: Tipo entrada basicData: travel: Envío currency: Moneda observation: Observación commission: Comisión - booked: Asentado + booked: Contabilizada excludedFromAvailable: Inventario initialTemperature: Ini °C finalTemperature: Fin °C @@ -69,31 +85,70 @@ entry: packingOut: Embalaje envíos landing: Llegada isExcludedFromAvailable: Es inventario - params: - toShipped: Hasta - fromShipped: Desde - warehouseInFk: Alm. entrada - daysOnward: Días adelante - daysAgo: Días atras - descriptorMenu: - showEntryReport: Ver informe del pedido + search: Buscar entradas searchInfo: Puedes buscar por referencia de entrada + params: + isExcludedFromAvailable: Excluir del inventario + isOrdered: Pedida + isConfirmed: Lista para etiquetar + isReceived: Recibida + isRaid: Redada + isIgnored: Ignorado + landed: Fecha + supplierFk: Proveedor + invoiceNumber: Nº Factura + reference: Ref/Alb/Guía + agencyModeId: Agencia + isBooked: Asentado + companyFk: Empresa + travelFk: Envio + evaNotes: Notas + warehouseOutFk: Origen + warehouseInFk: Destino + entryTypeDescription: Tipo entrada + invoiceAmount: Importe + dated: Fecha + itemFk: Id artículo + hex: Color + name: Nombre artículo + size: Medida + stickers: Etiquetas + packagingFk: Embalaje + weight: Kg + groupinMode: Selector de grouping + grouping: Grouping + quantity: Quantity + buyingValue: Precio de compra + price2: Paquete + price3: Caja + minPrice: Precio mínimo + hasMinPrice: Tiene precio mínimo + packingOut: Packing out + comment: Referencia + subName: Nombre proveedor + tags: Etiquetas + company_name: Nombre empresa + itemTypeFk: Familia + workerFk: Comprador entryFilter: params: - invoiceNumber: Núm. factura - travelFk: Envío - companyFk: Empresa - currencyFk: Moneda - supplierFk: Proveedor - from: Desde - to: Hasta - created: Fecha creación - isBooked: Asentado - isConfirmed: Confirmado + isExcludedFromAvailable: Inventario isOrdered: Pedida - search: Búsqueda general - reference: Referencia + isConfirmed: Confirmado + isReceived: Recibida + isRaid: Raid + landed: Fecha + id: Id + supplierFk: Proveedor + invoiceNumber: Núm. factura + reference: Ref/Alb/Guía + agencyModeId: Modo agencia + evaNotes: Notas + warehouseOutFk: Origen + warehouseInFk: Destino + entryTypeCode: Tipo de entrada + hasToShowDeletedEntries: Mostrar entradas eliminadas myEntries: id: ID landed: F. llegada diff --git a/src/pages/InvoiceIn/Card/InvoiceInBasicData.vue b/src/pages/InvoiceIn/Card/InvoiceInBasicData.vue index a3beabdb6..905ddebb2 100644 --- a/src/pages/InvoiceIn/Card/InvoiceInBasicData.vue +++ b/src/pages/InvoiceIn/Card/InvoiceInBasicData.vue @@ -125,7 +125,7 @@ function deleteFile(dmsFk) { <VnInput clearable clear-icon="close" - :label="t('Supplier ref')" + :label="t('invoiceIn.supplierRef')" v-model="data.supplierRef" /> </VnRow> @@ -149,6 +149,7 @@ function deleteFile(dmsFk) { option-value="id" option-label="id" :filter-options="['id', 'name']" + data-cy="UnDeductibleVatSelect" > <template #option="scope"> <QItem v-bind="scope.itemProps"> @@ -310,7 +311,6 @@ function deleteFile(dmsFk) { supplierFk: Supplier es: supplierFk: Proveedor - Supplier ref: Ref. proveedor Expedition date: Fecha expedición Operation date: Fecha operación Undeductible VAT: Iva no deducible diff --git a/src/pages/InvoiceIn/Card/InvoiceInDescriptor.vue b/src/pages/InvoiceIn/Card/InvoiceInDescriptor.vue index acd55c0fa..3843f5bf7 100644 --- a/src/pages/InvoiceIn/Card/InvoiceInDescriptor.vue +++ b/src/pages/InvoiceIn/Card/InvoiceInDescriptor.vue @@ -90,7 +90,6 @@ async function setInvoiceCorrection(id) { <template> <CardDescriptor ref="cardDescriptorRef" - module="InvoiceIn" data-key="InvoiceIn" :url="`InvoiceIns/${entityId}`" :filter="filter" diff --git a/src/pages/InvoiceIn/Card/InvoiceInDescriptorMenu.vue b/src/pages/InvoiceIn/Card/InvoiceInDescriptorMenu.vue index c3ab635c8..8b039ec27 100644 --- a/src/pages/InvoiceIn/Card/InvoiceInDescriptorMenu.vue +++ b/src/pages/InvoiceIn/Card/InvoiceInDescriptorMenu.vue @@ -186,7 +186,7 @@ const createInvoiceInCorrection = async () => { clickable @click="book(entityId)" > - <QItemSection>{{ t('invoiceIn.descriptorMenu.toBook') }}</QItemSection> + <QItemSection>{{ t('invoiceIn.descriptorMenu.book') }}</QItemSection> </QItem> </template> </InvoiceInToBook> @@ -197,7 +197,7 @@ const createInvoiceInCorrection = async () => { @click="triggerMenu('unbook')" > <QItemSection> - {{ t('invoiceIn.descriptorMenu.toUnbook') }} + {{ t('invoiceIn.descriptorMenu.unbook') }} </QItemSection> </QItem> <QItem diff --git a/src/pages/InvoiceIn/Card/InvoiceInDueDay.vue b/src/pages/InvoiceIn/Card/InvoiceInDueDay.vue index cb3271dc1..1cad40e0b 100644 --- a/src/pages/InvoiceIn/Card/InvoiceInDueDay.vue +++ b/src/pages/InvoiceIn/Card/InvoiceInDueDay.vue @@ -1,5 +1,5 @@ <script setup> -import { ref, computed } from 'vue'; +import { ref, computed, onBeforeMount } from 'vue'; import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; import axios from 'axios'; @@ -12,6 +12,7 @@ import VnSelect from 'src/components/common/VnSelect.vue'; import useNotify from 'src/composables/useNotify.js'; import VnInputDate from 'src/components/common/VnInputDate.vue'; import VnInputNumber from 'src/components/common/VnInputNumber.vue'; +import { toCurrency } from 'filters/index'; const route = useRoute(); const { notify } = useNotify(); @@ -26,7 +27,7 @@ const invoiceInFormRef = ref(); const invoiceId = +route.params.id; const filter = { where: { invoiceInFk: invoiceId } }; const areRows = ref(false); - +const totals = ref(); const columns = computed(() => [ { name: 'duedate', @@ -66,6 +67,8 @@ const columns = computed(() => [ }, ]); +const totalAmount = computed(() => getTotal(invoiceInFormRef.value.formData, 'amount')); + const isNotEuro = (code) => code != 'EUR'; async function insert() { @@ -73,6 +76,10 @@ async function insert() { await invoiceInFormRef.value.reload(); notify(t('globals.dataSaved'), 'positive'); } + +onBeforeMount(async () => { + totals.value = (await axios.get(`InvoiceIns/${invoiceId}/getTotals`)).data; +}); </script> <template> <FetchData @@ -153,7 +160,7 @@ async function insert() { <QTd /> <QTd /> <QTd> - {{ getTotal(rows, 'amount', { currency: 'default' }) }} + {{ toCurrency(totalAmount) }} </QTd> <QTd> <template v-if="isNotEuro(invoiceIn.currency.code)"> @@ -235,7 +242,16 @@ async function insert() { v-shortcut="'+'" size="lg" round - @click="!areRows ? insert() : invoiceInFormRef.insert()" + @click=" + () => { + if (!areRows) insert(); + else + invoiceInFormRef.insert({ + amount: (totals.totalTaxableBase - totalAmount).toFixed(2), + invoiceInFk: invoiceId, + }); + } + " /> </QPageSticky> </template> diff --git a/src/pages/InvoiceIn/Card/InvoiceInSummary.vue b/src/pages/InvoiceIn/Card/InvoiceInSummary.vue index e546638f2..d358601d3 100644 --- a/src/pages/InvoiceIn/Card/InvoiceInSummary.vue +++ b/src/pages/InvoiceIn/Card/InvoiceInSummary.vue @@ -193,7 +193,7 @@ const getLink = (param) => `#/invoice-in/${entityId.value}/${param}`; <InvoiceIntoBook> <template #content="{ book }"> <QBtn - :label="t('To book')" + :label="t('Book')" color="orange-11" text-color="black" @click="book(entityId)" @@ -224,10 +224,7 @@ const getLink = (param) => `#/invoice-in/${entityId.value}/${param}`; </span> </template> </VnLv> - <VnLv - :label="t('invoiceIn.list.supplierRef')" - :value="entity.supplierRef" - /> + <VnLv :label="t('invoiceIn.supplierRef')" :value="entity.supplierRef" /> <VnLv :label="t('invoiceIn.summary.currency')" :value="entity.currency?.code" @@ -357,7 +354,7 @@ const getLink = (param) => `#/invoice-in/${entityId.value}/${param}`; entity.totals.totalTaxableBaseForeignValue && toCurrency( entity.totals.totalTaxableBaseForeignValue, - currency + currency, ) }}</QTd> </QTr> @@ -392,7 +389,7 @@ const getLink = (param) => `#/invoice-in/${entityId.value}/${param}`; entity.totals.totalDueDayForeignValue && toCurrency( entity.totals.totalDueDayForeignValue, - currency + currency, ) }} </QTd> @@ -472,5 +469,5 @@ const getLink = (param) => `#/invoice-in/${entityId.value}/${param}`; Search invoice: Buscar factura recibida You can search by invoice reference: Puedes buscar por referencia de la factura Totals: Totales - To book: Contabilizar + Book: Contabilizar </i18n> diff --git a/src/pages/InvoiceIn/Card/InvoiceInVat.vue b/src/pages/InvoiceIn/Card/InvoiceInVat.vue index edb43375f..e77453bc0 100644 --- a/src/pages/InvoiceIn/Card/InvoiceInVat.vue +++ b/src/pages/InvoiceIn/Card/InvoiceInVat.vue @@ -1,5 +1,5 @@ <script setup> -import { ref, computed } from 'vue'; +import { ref, computed, nextTick } from 'vue'; import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; import { useArrayData } from 'src/composables/useArrayData'; @@ -25,7 +25,6 @@ const sageTaxTypes = ref([]); const sageTransactionTypes = ref([]); const rowsSelected = ref([]); const invoiceInFormRef = ref(); -const expenseRef = ref(); defineProps({ actionIcon: { @@ -97,6 +96,20 @@ const columns = computed(() => [ }, ]); +const taxableBaseTotal = computed(() => { + return getTotal(invoiceInFormRef.value.formData, 'taxableBase'); +}); + +const taxRateTotal = computed(() => { + return getTotal(invoiceInFormRef.value.formData, null, { + cb: taxRate, + }); +}); + +const combinedTotal = computed(() => { + return +taxableBaseTotal.value + +taxRateTotal.value; +}); + const filter = { fields: [ 'id', @@ -125,7 +138,7 @@ function taxRate(invoiceInTax) { return ((taxTypeSage / 100) * taxableBase).toFixed(2); } -function autocompleteExpense(evt, row, col) { +function autocompleteExpense(evt, row, col, ref) { const val = evt.target.value; if (!val) return; @@ -134,22 +147,17 @@ function autocompleteExpense(evt, row, col) { ({ id }) => id == useAccountShortToStandard(param), ); - expenseRef.value.vnSelectDialogRef.vnSelectRef.toggleOption(lookup); + ref.vnSelectDialogRef.vnSelectRef.toggleOption(lookup); } -const taxableBaseTotal = computed(() => { - return getTotal(invoiceInFormRef.value.formData, 'taxableBase'); -}); - -const taxRateTotal = computed(() => { - return getTotal(invoiceInFormRef.value.formData, null, { - cb: taxRate, +function setCursor(ref) { + nextTick(() => { + const select = ref.vnSelectDialogRef + ? ref.vnSelectDialogRef.vnSelectRef + : ref.vnSelectRef; + select.$el.querySelector('input').setSelectionRange(0, 0); }); -}); - -const combinedTotal = computed(() => { - return +taxableBaseTotal.value + +taxRateTotal.value; -}); +} </script> <template> <FetchData @@ -187,14 +195,24 @@ const combinedTotal = computed(() => { <template #body-cell-expense="{ row, col }"> <QTd> <VnSelectDialog - ref="expenseRef" + :ref="`expenseRef-${row.$index}`" v-model="row[col.model]" :options="col.options" :option-value="col.optionValue" :option-label="col.optionLabel" :filter-options="['id', 'name']" :tooltip="t('Create a new expense')" - @keydown.tab="autocompleteExpense($event, row, col)" + @keydown.tab=" + autocompleteExpense( + $event, + row, + col, + $refs[`expenseRef-${row.$index}`], + ) + " + @update:model-value=" + setCursor($refs[`expenseRef-${row.$index}`]) + " > <template #option="scope"> <QItem v-bind="scope.itemProps"> @@ -210,7 +228,7 @@ const combinedTotal = computed(() => { </QTd> </template> <template #body-cell-taxablebase="{ row }"> - <QTd> + <QTd shrink> <VnInputNumber clear-icon="close" v-model="row.taxableBase" @@ -221,12 +239,16 @@ const combinedTotal = computed(() => { <template #body-cell-sageiva="{ row, col }"> <QTd> <VnSelect + :ref="`sageivaRef-${row.$index}`" v-model="row[col.model]" :options="col.options" :option-value="col.optionValue" :option-label="col.optionLabel" :filter-options="['id', 'vat']" data-cy="vat-sageiva" + @update:model-value=" + setCursor($refs[`sageivaRef-${row.$index}`]) + " > <template #option="scope"> <QItem v-bind="scope.itemProps"> @@ -244,11 +266,15 @@ const combinedTotal = computed(() => { <template #body-cell-sagetransaction="{ row, col }"> <QTd> <VnSelect + :ref="`sagetransactionRef-${row.$index}`" v-model="row[col.model]" :options="col.options" :option-value="col.optionValue" :option-label="col.optionLabel" :filter-options="['id', 'transaction']" + @update:model-value=" + setCursor($refs[`sagetransactionRef-${row.$index}`]) + " > <template #option="scope"> <QItem v-bind="scope.itemProps"> @@ -266,7 +292,7 @@ const combinedTotal = computed(() => { </QTd> </template> <template #body-cell-foreignvalue="{ row }"> - <QTd> + <QTd shrink> <VnInputNumber :class="{ 'no-pointer-events': !isNotEuro(currency), diff --git a/src/pages/InvoiceIn/InvoiceInList.vue b/src/pages/InvoiceIn/InvoiceInList.vue index e1723e3b1..0960d0d6c 100644 --- a/src/pages/InvoiceIn/InvoiceInList.vue +++ b/src/pages/InvoiceIn/InvoiceInList.vue @@ -29,6 +29,7 @@ const cols = computed(() => [ name: 'isBooked', label: t('invoiceIn.isBooked'), columnFilter: false, + component: 'checkbox', }, { align: 'left', @@ -56,7 +57,7 @@ const cols = computed(() => [ { align: 'left', name: 'supplierRef', - label: t('invoiceIn.list.supplierRef'), + label: t('invoiceIn.supplierRef'), }, { align: 'left', @@ -177,7 +178,7 @@ const cols = computed(() => [ :required="true" /> <VnInput - :label="t('invoiceIn.list.supplierRef')" + :label="t('invoiceIn.supplierRef')" v-model="data.supplierRef" /> <VnSelect diff --git a/src/pages/InvoiceIn/InvoiceInToBook.vue b/src/pages/InvoiceIn/InvoiceInToBook.vue index 95ce8155a..5bdbe197b 100644 --- a/src/pages/InvoiceIn/InvoiceInToBook.vue +++ b/src/pages/InvoiceIn/InvoiceInToBook.vue @@ -4,6 +4,7 @@ import { useQuasar } from 'quasar'; import { useI18n } from 'vue-i18n'; import VnConfirm from 'src/components/ui/VnConfirm.vue'; import { useArrayData } from 'src/composables/useArrayData'; +import qs from 'qs'; const { notify, dialog } = useQuasar(); const { t } = useI18n(); @@ -12,29 +13,51 @@ defineExpose({ checkToBook }); const { store } = useArrayData(); async function checkToBook(id) { - let directBooking = true; + let messages = []; + + const hasProblemWithTax = ( + await axios.get('InvoiceInTaxes/count', { + params: { + where: JSON.stringify({ + invoiceInFk: id, + or: [{ taxTypeSageFk: null }, { transactionTypeSageFk: null }], + }), + }, + }) + ).data?.count; + + if (hasProblemWithTax) + messages.push(t('The VAT and Transaction fields have not been informed')); const { data: totals } = await axios.get(`InvoiceIns/${id}/getTotals`); const taxableBaseNotEqualDueDay = totals.totalDueDay != totals.totalTaxableBase; const vatNotEqualDueDay = totals.totalDueDay != totals.totalVat; - if (taxableBaseNotEqualDueDay && vatNotEqualDueDay) directBooking = false; + if (taxableBaseNotEqualDueDay && vatNotEqualDueDay) + messages.push(t('The sum of the taxable bases does not match the due dates')); - const { data: dueDaysCount } = await axios.get('InvoiceInDueDays/count', { - where: { - invoiceInFk: id, - dueDated: { gte: Date.vnNew() }, - }, - }); + const dueDaysCount = ( + await axios.get('InvoiceInDueDays/count', { + params: { + where: JSON.stringify({ + invoiceInFk: id, + dueDated: { gte: Date.vnNew() }, + }), + }, + }) + ).data?.count; - if (dueDaysCount) directBooking = false; + if (dueDaysCount) messages.push(t('Some due dates are less than or equal to today')); - if (directBooking) return toBook(id); - - dialog({ - component: VnConfirm, - componentProps: { title: t('Are you sure you want to book this invoice?') }, - }).onOk(async () => await toBook(id)); + if (!messages.length) toBook(id); + else + dialog({ + component: VnConfirm, + componentProps: { + title: t('Are you sure you want to book this invoice?'), + message: messages.reduce((acc, msg) => `${acc}<p>${msg}</p>`, ''), + }, + }).onOk(() => toBook(id)); } async function toBook(id) { @@ -59,4 +82,7 @@ async function toBook(id) { es: Are you sure you want to book this invoice?: ¿Estás seguro de querer asentar esta factura? It was not able to book the invoice: No se pudo contabilizar la factura + Some due dates are less than or equal to today: Algún vencimiento tiene una fecha menor o igual que hoy + The sum of the taxable bases does not match the due dates: La suma de las bases imponibles no coincide con la de los vencimientos + The VAT and Transaction fields have not been informed: No se han informado los campos de iva y/o transacción </i18n> diff --git a/src/pages/InvoiceIn/locale/en.yml b/src/pages/InvoiceIn/locale/en.yml index 6b21b316b..548e6c201 100644 --- a/src/pages/InvoiceIn/locale/en.yml +++ b/src/pages/InvoiceIn/locale/en.yml @@ -3,10 +3,10 @@ invoiceIn: searchInfo: Search incoming invoices by ID or supplier fiscal name serial: Serial isBooked: Is booked + supplierRef: Invoice nº list: ref: Reference supplier: Supplier - supplierRef: Supplier ref. file: File issued: Issued dueDated: Due dated @@ -19,8 +19,6 @@ invoiceIn: unbook: Unbook delete: Delete clone: Clone - toBook: To book - toUnbook: To unbook deleteInvoice: Delete invoice invoiceDeleted: invoice deleted cloneInvoice: Clone invoice @@ -70,4 +68,3 @@ invoiceIn: isBooked: Is booked account: Ledger account correctingFk: Rectificative - \ No newline at end of file diff --git a/src/pages/InvoiceIn/locale/es.yml b/src/pages/InvoiceIn/locale/es.yml index 3f27c895c..142d95f92 100644 --- a/src/pages/InvoiceIn/locale/es.yml +++ b/src/pages/InvoiceIn/locale/es.yml @@ -3,10 +3,10 @@ invoiceIn: searchInfo: Buscar facturas recibidas por ID o nombre fiscal del proveedor serial: Serie isBooked: Contabilizada + supplierRef: Nº factura list: ref: Referencia supplier: Proveedor - supplierRef: Ref. proveedor issued: F. emisión dueDated: F. vencimiento file: Fichero @@ -15,12 +15,10 @@ invoiceIn: descriptor: ticketList: Listado de tickets descriptorMenu: - book: Asentar - unbook: Desasentar + book: Contabilizar + unbook: Descontabilizar delete: Eliminar clone: Clonar - toBook: Contabilizar - toUnbook: Descontabilizar deleteInvoice: Eliminar factura invoiceDeleted: Factura eliminada cloneInvoice: Clonar factura @@ -68,4 +66,3 @@ invoiceIn: isBooked: Contabilizada account: Cuenta contable correctingFk: Rectificativa - diff --git a/src/pages/InvoiceOut/Card/InvoiceOutDescriptor.vue b/src/pages/InvoiceOut/Card/InvoiceOutDescriptor.vue index de614e9fc..dfaf6c109 100644 --- a/src/pages/InvoiceOut/Card/InvoiceOutDescriptor.vue +++ b/src/pages/InvoiceOut/Card/InvoiceOutDescriptor.vue @@ -36,7 +36,6 @@ function ticketFilter(invoice) { <template> <CardDescriptor ref="descriptor" - module="InvoiceOut" :url="`InvoiceOuts/${entityId}`" :filter="filter" title="ref" diff --git a/src/pages/InvoiceOut/InvoiceOutList.vue b/src/pages/InvoiceOut/InvoiceOutList.vue index 9398ded64..c7d7ba9f4 100644 --- a/src/pages/InvoiceOut/InvoiceOutList.vue +++ b/src/pages/InvoiceOut/InvoiceOutList.vue @@ -97,12 +97,19 @@ const columns = computed(() => [ }, { align: 'left', - name: 'companyCode', + name: 'companyFk', label: t('globals.company'), cardVisible: true, component: 'select', - attrs: { url: 'Companies', optionLabel: 'code', optionValue: 'id' }, - columnField: { component: null }, + attrs: { + url: 'Companies', + optionLabel: 'code', + optionValue: 'id', + }, + columnField: { + component: null, + }, + format: (row, dashIfEmpty) => dashIfEmpty(row.companyCode), }, { align: 'left', diff --git a/src/pages/Item/Card/ItemBasicData.vue b/src/pages/Item/Card/ItemBasicData.vue index 7e5645024..df7e71684 100644 --- a/src/pages/Item/Card/ItemBasicData.vue +++ b/src/pages/Item/Card/ItemBasicData.vue @@ -11,6 +11,7 @@ import VnSelect from 'src/components/common/VnSelect.vue'; import VnSelectDialog from 'src/components/common/VnSelectDialog.vue'; import FilterItemForm from 'src/components/FilterItemForm.vue'; import CreateIntrastatForm from './CreateIntrastatForm.vue'; +import VnCheckbox from 'src/components/common/VnCheckbox.vue'; const route = useRoute(); const { t } = useI18n(); @@ -208,30 +209,20 @@ const onIntrastatCreated = (response, formData) => { /> </VnRow> <VnRow class="row q-gutter-md q-mb-md"> - <div> - <QCheckbox - v-model="data.isFragile" - :label="t('item.basicData.isFragile')" - class="q-mr-sm" - /> - <QIcon name="info" class="cursor-pointer" size="xs"> - <QTooltip max-width="300px"> - {{ t('item.basicData.isFragileTooltip') }} - </QTooltip> - </QIcon> - </div> - <div> - <QCheckbox - v-model="data.isPhotoRequested" - :label="t('item.basicData.isPhotoRequested')" - class="q-mr-sm" - /> - <QIcon name="info" class="cursor-pointer" size="xs"> - <QTooltip> - {{ t('item.basicData.isPhotoRequestedTooltip') }} - </QTooltip> - </QIcon> - </div> + <VnCheckbox + v-model="data.isFragile" + :label="t('item.basicData.isFragile')" + :info="t('item.basicData.isFragileTooltip')" + class="q-mr-sm" + size="xs" + /> + <VnCheckbox + v-model="data.isPhotoRequested" + :label="t('item.basicData.isPhotoRequested')" + :info="t('item.basicData.isPhotoRequestedTooltip')" + class="q-mr-sm" + size="xs" + /> </VnRow> <VnRow> <VnInput diff --git a/src/pages/Item/Card/ItemBotanical.vue b/src/pages/Item/Card/ItemBotanical.vue index 4894d94fc..a40d81589 100644 --- a/src/pages/Item/Card/ItemBotanical.vue +++ b/src/pages/Item/Card/ItemBotanical.vue @@ -7,8 +7,8 @@ import FetchData from 'components/FetchData.vue'; import FormModel from 'components/FormModel.vue'; import VnRow from 'components/ui/VnRow.vue'; import VnSelectDialog from 'src/components/common/VnSelectDialog.vue'; -import CreateGenusForm from './CreateGenusForm.vue'; -import CreateSpecieForm from './CreateSpecieForm.vue'; +import CreateGenusForm from '../components/CreateGenusForm.vue'; +import CreateSpecieForm from '../components/CreateSpecieForm.vue'; const route = useRoute(); const { t } = useI18n(); diff --git a/src/pages/Item/Card/ItemDescriptor.vue b/src/pages/Item/Card/ItemDescriptor.vue index 7e7057a90..a4c58ef4b 100644 --- a/src/pages/Item/Card/ItemDescriptor.vue +++ b/src/pages/Item/Card/ItemDescriptor.vue @@ -34,6 +34,10 @@ const $props = defineProps({ type: Number, default: null, }, + proxyRender: { + type: Boolean, + default: false, + }, }); const route = useRoute(); @@ -88,7 +92,6 @@ const updateStock = async () => { <template> <CardDescriptor data-key="Item" - module="Item" :summary="$props.summary" :url="`Items/${entityId}/getCard`" @on-fetch="setData" @@ -112,7 +115,7 @@ const updateStock = async () => { <template #value> <span class="link"> {{ entity.itemType?.worker?.user?.name }} - <WorkerDescriptorProxy :id="entity.itemType?.worker?.id" /> + <WorkerDescriptorProxy :id="entity.itemType?.worker?.id ?? NaN" /> </span> </template> </VnLv> @@ -147,7 +150,7 @@ const updateStock = async () => { </QCardActions> </template> <template #actions="{}"> - <QCardActions class="row justify-center"> + <QCardActions class="row justify-center" v-if="proxyRender"> <QBtn :to="{ name: 'ItemDiary', @@ -160,6 +163,16 @@ const updateStock = async () => { > <QTooltip>{{ t('item.descriptor.itemDiary') }}</QTooltip> </QBtn> + <QBtn + :to="{ + name: 'ItemLastEntries', + }" + size="md" + icon="vn:regentry" + color="primary" + > + <QTooltip>{{ t('item.descriptor.itemLastEntries') }}</QTooltip> + </QBtn> </QCardActions> </template> </CardDescriptor> diff --git a/src/pages/Item/Card/ItemDescriptorProxy.vue b/src/pages/Item/Card/ItemDescriptorProxy.vue index 2ffc9080f..f686e8221 100644 --- a/src/pages/Item/Card/ItemDescriptorProxy.vue +++ b/src/pages/Item/Card/ItemDescriptorProxy.vue @@ -4,7 +4,7 @@ import ItemSummary from './ItemSummary.vue'; const $props = defineProps({ id: { - type: Number, + type: [Number, String], required: true, }, dated: { @@ -21,9 +21,8 @@ const $props = defineProps({ }, }); </script> - <template> - <QPopupProxy> + <QPopupProxy style="max-width: 10px"> <ItemDescriptor v-if="$props.id" :id="$props.id" @@ -31,6 +30,7 @@ const $props = defineProps({ :dated="dated" :sale-fk="saleFk" :warehouse-fk="warehouseFk" + :proxy-render="true" /> </QPopupProxy> </template> diff --git a/src/pages/Item/Card/ItemShelving.vue b/src/pages/Item/Card/ItemShelving.vue index 7ad60c9e0..b29e2a2a5 100644 --- a/src/pages/Item/Card/ItemShelving.vue +++ b/src/pages/Item/Card/ItemShelving.vue @@ -110,10 +110,16 @@ const columns = computed(() => [ attrs: { inWhere: true }, align: 'left', }, + { + label: t('globals.visible'), + name: 'stock', + attrs: { inWhere: true }, + align: 'left', + }, ]); const totalLabels = computed(() => - rows.value.reduce((acc, row) => acc + row.stock / row.packing, 0).toFixed(2) + rows.value.reduce((acc, row) => acc + row.stock / row.packing, 0).toFixed(2), ); const removeLines = async () => { @@ -157,7 +163,7 @@ watchEffect(selectedRows); openConfirmationModal( t('shelvings.removeConfirmTitle'), t('shelvings.removeConfirmSubtitle'), - removeLines + removeLines, ) " > diff --git a/src/pages/Item/ItemFixedPrice.vue b/src/pages/Item/ItemFixedPrice.vue index 1c4382fbd..fdfa1d3d1 100644 --- a/src/pages/Item/ItemFixedPrice.vue +++ b/src/pages/Item/ItemFixedPrice.vue @@ -65,10 +65,19 @@ const columns = computed(() => [ name: 'name', ...defaultColumnAttrs, create: true, + columnFilter: { + component: 'select', + attrs: { + url: 'Items', + fields: ['id', 'name', 'subName'], + optionLabel: 'name', + optionValue: 'name', + uppercase: false, + }, + }, }, { label: t('item.fixedPrice.groupingPrice'), - field: 'rate2', name: 'rate2', ...defaultColumnAttrs, component: 'input', @@ -76,7 +85,6 @@ const columns = computed(() => [ }, { label: t('item.fixedPrice.packingPrice'), - field: 'rate3', name: 'rate3', ...defaultColumnAttrs, component: 'input', @@ -85,7 +93,6 @@ const columns = computed(() => [ { label: t('item.fixedPrice.minPrice'), - field: 'minPrice', name: 'minPrice', ...defaultColumnAttrs, component: 'input', @@ -108,7 +115,6 @@ const columns = computed(() => [ }, { label: t('item.fixedPrice.ended'), - field: 'ended', name: 'ended', ...defaultColumnAttrs, columnField: { @@ -124,7 +130,6 @@ const columns = computed(() => [ { label: t('globals.warehouse'), - field: 'warehouseFk', name: 'warehouseFk', ...defaultColumnAttrs, columnClass: 'shrink', @@ -415,7 +420,6 @@ function handleOnDataSave({ CrudModelRef }) { 'row-key': 'id', selection: 'multiple', }" - :use-model="true" v-model:selected="rowsSelected" :create-as-dialog="false" :create="{ diff --git a/src/pages/Item/ItemType/Card/ItemTypeDescriptor.vue b/src/pages/Item/ItemType/Card/ItemTypeDescriptor.vue index 0f71ad1f1..725fb30aa 100644 --- a/src/pages/Item/ItemType/Card/ItemTypeDescriptor.vue +++ b/src/pages/Item/ItemType/Card/ItemTypeDescriptor.vue @@ -26,7 +26,6 @@ const entityId = computed(() => { </script> <template> <CardDescriptor - module="ItemType" :url="`ItemTypes/${entityId}`" :filter="filter" title="code" diff --git a/src/pages/Item/Card/CreateGenusForm.vue b/src/pages/Item/components/CreateGenusForm.vue similarity index 100% rename from src/pages/Item/Card/CreateGenusForm.vue rename to src/pages/Item/components/CreateGenusForm.vue diff --git a/src/pages/Item/Card/CreateSpecieForm.vue b/src/pages/Item/components/CreateSpecieForm.vue similarity index 100% rename from src/pages/Item/Card/CreateSpecieForm.vue rename to src/pages/Item/components/CreateSpecieForm.vue diff --git a/src/pages/Item/components/ItemProposal.vue b/src/pages/Item/components/ItemProposal.vue new file mode 100644 index 000000000..d2dbea7b3 --- /dev/null +++ b/src/pages/Item/components/ItemProposal.vue @@ -0,0 +1,332 @@ +<script setup> +import { ref, computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import ItemDescriptorProxy from 'src/pages/Item/Card/ItemDescriptorProxy.vue'; +import { toCurrency } from 'filters/index'; +import VnStockValueDisplay from 'src/components/ui/VnStockValueDisplay.vue'; +import VnTable from 'src/components/VnTable/VnTable.vue'; +import axios from 'axios'; +import notifyResults from 'src/utils/notifyResults'; +import FetchData from 'components/FetchData.vue'; + +const MATCH = 'match'; + +const { t } = useI18n(); +const $props = defineProps({ + itemLack: { + type: Object, + required: true, + default: () => {}, + }, + replaceAction: { + type: Boolean, + required: false, + default: false, + }, + sales: { + type: Array, + required: false, + default: () => [], + }, +}); +const proposalSelected = ref([]); +const ticketConfig = ref({}); +const proposalTableRef = ref(null); + +const sale = computed(() => $props.sales[0]); +const saleFk = computed(() => sale.value.saleFk); +const filter = computed(() => ({ + itemFk: $props.itemLack.itemFk, + sales: saleFk.value, +})); + +const defaultColumnAttrs = { + align: 'center', + sortable: false, +}; +const emit = defineEmits(['onDialogClosed', 'itemReplaced']); + +const conditionalValuePrice = (price) => + price > 1 + ticketConfig.value.lackAlertPrice / 100 ? 'match' : 'not-match'; + +const columns = computed(() => [ + { + ...defaultColumnAttrs, + label: t('proposal.available'), + name: 'available', + field: 'available', + columnFilter: { + component: 'input', + type: 'number', + columnClass: 'shrink', + }, + columnClass: 'shrink', + }, + { + ...defaultColumnAttrs, + label: t('proposal.counter'), + name: 'counter', + field: 'counter', + columnClass: 'shrink', + style: 'max-width: 75px', + columnFilter: { + component: 'input', + type: 'number', + columnClass: 'shrink', + }, + }, + + { + align: 'left', + sortable: true, + label: t('proposal.longName'), + name: 'longName', + field: 'longName', + columnClass: 'expand', + }, + { + align: 'left', + sortable: true, + label: t('item.list.color'), + name: 'tag5', + field: 'value5', + columnClass: 'expand', + }, + { + align: 'left', + sortable: true, + label: t('item.list.stems'), + name: 'tag6', + field: 'value6', + columnClass: 'expand', + }, + { + align: 'left', + sortable: true, + label: t('item.list.producer'), + name: 'tag7', + field: 'value7', + columnClass: 'expand', + }, + + { + ...defaultColumnAttrs, + label: t('proposal.price2'), + name: 'price2', + style: 'max-width: 75px', + columnFilter: { + component: 'input', + type: 'number', + columnClass: 'shrink', + }, + }, + { + ...defaultColumnAttrs, + label: t('proposal.minQuantity'), + name: 'minQuantity', + field: 'minQuantity', + style: 'max-width: 75px', + columnFilter: { + component: 'input', + type: 'number', + columnClass: 'shrink', + }, + }, + { + ...defaultColumnAttrs, + label: t('proposal.located'), + name: 'located', + field: 'located', + }, + { + align: 'right', + label: '', + name: 'tableActions', + actions: [ + { + title: t('Replace'), + icon: 'change_circle', + show: (row) => isSelectionAvailable(row), + action: change, + isPrimary: true, + }, + ], + }, +]); + +function extractMatchValues(obj) { + return Object.keys(obj) + .filter((key) => key.startsWith(MATCH)) + .map((key) => parseInt(key.replace(MATCH, ''), 10)); +} +const gradientStyle = (value) => { + let color = 'white'; + const perc = parseFloat(value); + switch (true) { + case perc >= 0 && perc < 33: + color = 'primary'; + break; + case perc >= 33 && perc < 66: + color = 'warning'; + break; + + default: + color = 'secondary'; + break; + } + return color; +}; +const statusConditionalValue = (row) => { + const matches = extractMatchValues(row); + const value = matches.reduce((acc, i) => acc + row[`${MATCH}${i}`], 0); + return 100 * (value / matches.length); +}; + +const isSelectionAvailable = (itemProposal) => { + const { price2 } = itemProposal; + const salePrice = sale.value.price; + const byPrice = (100 * price2) / salePrice > ticketConfig.value.lackAlertPrice; + if (byPrice) { + return byPrice; + } + const byQuantity = + (100 * itemProposal.available) / Math.abs($props.itemLack.lack) < + ticketConfig.value.lackAlertPrice; + return byQuantity; +}; + +async function change({ itemFk: substitutionFk }) { + try { + const promises = $props.sales.map(({ saleFk, quantity }) => { + const params = { + saleFk, + substitutionFk, + quantity, + }; + return axios.post('Sales/replaceItem', params); + }); + const results = await Promise.allSettled(promises); + + notifyResults(results, 'saleFk'); + emit('itemReplaced', { + type: 'refresh', + quantity: quantity.value, + itemProposal: proposalSelected.value[0], + }); + proposalSelected.value = []; + } catch (error) { + console.error(error); + } +} + +async function handleTicketConfig(data) { + ticketConfig.value = data[0]; +} +</script> +<template> + <FetchData + url="TicketConfigs" + :filter="{ fields: ['lackAlertPrice'] }" + @on-fetch="handleTicketConfig" + auto-load + /> + + <VnTable + v-if="ticketConfig" + auto-load + data-cy="proposalTable" + ref="proposalTableRef" + data-key="ItemsGetSimilar" + url="Items/getSimilar" + :user-filter="filter" + :columns="columns" + class="full-width q-mt-md" + row-key="id" + :row-click="change" + :is-editable="false" + :right-search="false" + :without-header="true" + :disable-option="{ card: true, table: true }" + > + <template #column-longName="{ row }"> + <QTd + class="flex" + style="max-width: 100%; flex-shrink: 50px; flex-wrap: nowrap" + > + <div + class="middle full-width" + :class="[`proposal-${gradientStyle(statusConditionalValue(row))}`]" + > + <QTooltip> {{ statusConditionalValue(row) }}% </QTooltip> + </div> + <div style="flex: 2 0 100%; align-content: center"> + <div> + <span class="link">{{ row.longName }}</span> + <ItemDescriptorProxy :id="row.id" /> + </div> + </div> + </QTd> + </template> + <template #column-tag5="{ row }"> + <span :class="{ match: !row.match5 }">{{ row.value5 }}</span> + </template> + <template #column-tag6="{ row }"> + <span :class="{ match: !row.match6 }">{{ row.value6 }}</span> + </template> + <template #column-tag7="{ row }"> + <span :class="{ match: !row.match7 }">{{ row.value7 }}</span> + </template> + <template #column-counter="{ row }"> + <span + :class="{ + match: row.counter === 1, + 'not-match': row.counter !== 1, + }" + >{{ row.counter }}</span + > + </template> + <template #column-minQuantity="{ row }"> + {{ row.minQuantity }} + </template> + <template #column-price2="{ row }"> + <div class="flex column items-center content-center"> + <VnStockValueDisplay :value="(sales[0].price - row.price2) / 100" /> + <span :class="[conditionalValuePrice(row.price2)]">{{ + toCurrency(row.price2) + }}</span> + </div> + </template> + </VnTable> +</template> +<style lang="scss" scoped> +@import 'src/css/quasar.variables.scss'; +.middle { + float: left; + margin-right: 2px; + flex: 2 0 5px; +} +.match { + color: $negative; +} +.not-match { + color: inherit; +} +.proposal-warning { + background-color: $warning; +} +.proposal-secondary { + background-color: $secondary; +} +.proposal-primary { + background-color: $primary; +} +.text { + margin: 0.05rem; + padding: 1px; + border: 1px solid var(--vn-label-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: smaller; +} +</style> diff --git a/src/pages/Item/components/ItemProposalProxy.vue b/src/pages/Item/components/ItemProposalProxy.vue new file mode 100644 index 000000000..7da0ce398 --- /dev/null +++ b/src/pages/Item/components/ItemProposalProxy.vue @@ -0,0 +1,56 @@ +<script setup> +import ItemProposal from './ItemProposal.vue'; +import { useDialogPluginComponent } from 'quasar'; + +const $props = defineProps({ + itemLack: { + type: Object, + required: true, + default: () => {}, + }, + replaceAction: { + type: Boolean, + required: false, + default: false, + }, + sales: { + type: Array, + required: false, + default: () => [], + }, +}); +const { dialogRef } = useDialogPluginComponent(); +const emit = defineEmits([ + 'onDialogClosed', + 'itemReplaced', + ...useDialogPluginComponent.emits, +]); +defineExpose({ show: () => dialogRef.value.show(), hide: () => dialogRef.value.hide() }); +</script> +<template> + <QDialog ref="dialogRef" transition-show="scale" transition-hide="scale"> + <QCard class="dialog-width"> + <QCardSection class="row items-center q-pb-none"> + <span class="text-h6 text-grey">{{ $t('Item proposal') }}</span> + <QSpace /> + <QBtn icon="close" flat round dense v-close-popup /> + </QCardSection> + <QCardSection> + <ItemProposal + v-bind="$props" + @item-replaced=" + (data) => { + emit('itemReplaced', data); + dialogRef.hide(); + } + " + ></ItemProposal + ></QCardSection> + </QCard> + </QDialog> +</template> +<style lang="scss" scoped> +.dialog-width { + max-width: $width-lg; +} +</style> diff --git a/src/pages/Item/locale/en.yml b/src/pages/Item/locale/en.yml index bc73abb12..9d27fc96e 100644 --- a/src/pages/Item/locale/en.yml +++ b/src/pages/Item/locale/en.yml @@ -112,6 +112,7 @@ item: available: Available warehouseText: 'Calculated on the warehouse of { warehouseName }' itemDiary: Item diary + itemLastEntries: Last entries producer: Producer clone: title: All its properties will be copied @@ -130,6 +131,7 @@ item: origin: Orig. userName: Buyer weight: Weight + color: Color weightByPiece: Weight/stem stemMultiplier: Multiplier producer: Producer @@ -215,4 +217,24 @@ item: specie: Specie search: 'Search item' searchInfo: 'You can search by id' - regularizeStock: Regularize stock \ No newline at end of file + regularizeStock: Regularize stock +itemProposal: Items proposal +proposal: + difference: Difference + title: Items proposal + itemFk: Item + longName: Name + subName: Producer + value5: value5 + value6: value6 + value7: value7 + value8: value8 + available: Available + minQuantity: minQuantity + price2: Price + located: Located + counter: Counter + groupingPrice: Grouping Price + itemOldPrice: itemOld Price + status: State + quantityToReplace: Quanity to replace diff --git a/src/pages/Item/locale/es.yml b/src/pages/Item/locale/es.yml index dd5074f5f..935f5160b 100644 --- a/src/pages/Item/locale/es.yml +++ b/src/pages/Item/locale/es.yml @@ -118,6 +118,7 @@ item: available: Disponible warehouseText: 'Calculado sobre el almacén de { warehouseName }' itemDiary: Registro de compra-venta + itemLastEntries: Últimas entradas producer: Productor clone: title: Todas sus propiedades serán copiadas @@ -135,6 +136,7 @@ item: size: Medida origin: Orig. weight: Peso + color: Color weightByPiece: Peso/tallo userName: Comprador stemMultiplier: Multiplicador @@ -220,5 +222,30 @@ item: achieved: 'Conseguido' concept: 'Concepto' state: 'Estado' - search: 'Buscar artículo' - searchInfo: 'Puedes buscar por id' +itemProposal: Artículos similares +proposal: + substitutionAvailable: Sustitución disponible + notSubstitutionAvailableByPrice: Sustitución no disponible, 30% de diferencia por precio o cantidad + compatibility: Compatibilidad + title: Items de sustitución para los tickets seleccionados + itemFk: Item + longName: Nombre + subName: Productor + value5: value5 + value6: value6 + value7: value7 + value8: value8 + available: Disponible + minQuantity: Min. cantidad + price2: Precio + located: Ubicado + counter: Contador + difference: Diferencial + groupingPrice: Precio Grouping + itemOldPrice: Precio itemOld + status: Estado + quantityToReplace: Cantidad a reemplazar + replace: Sustituir + replaceAndConfirm: Sustituir y confirmar precio +search: 'Buscar artículo' +searchInfo: 'Puedes buscar por id' diff --git a/src/pages/Monitor/MonitorOrders.vue b/src/pages/Monitor/MonitorOrders.vue index 4efab56fb..873f8abb4 100644 --- a/src/pages/Monitor/MonitorOrders.vue +++ b/src/pages/Monitor/MonitorOrders.vue @@ -157,7 +157,7 @@ const openTab = (id) => openConfirmationModal( $t('globals.deleteConfirmTitle'), $t('salesOrdersTable.deleteConfirmMessage'), - removeOrders + removeOrders, ) " > diff --git a/src/pages/Order/Card/OrderCatalogItemDialog.vue b/src/pages/Order/Card/OrderCatalogItemDialog.vue index be35750a9..680f6e773 100644 --- a/src/pages/Order/Card/OrderCatalogItemDialog.vue +++ b/src/pages/Order/Card/OrderCatalogItemDialog.vue @@ -43,10 +43,9 @@ const addToOrder = async () => { ); state.set('orderTotal', orderTotal); - const rows = orderData.value.rows.push(...items) || []; state.set('Order', { ...orderData.value, - rows, + items, }); notify(t('globals.dataSaved'), 'positive'); emit('added', -totalQuantity(items)); diff --git a/src/pages/Order/Card/OrderDescriptor.vue b/src/pages/Order/Card/OrderDescriptor.vue index 1752efe7b..0d18864dc 100644 --- a/src/pages/Order/Card/OrderDescriptor.vue +++ b/src/pages/Order/Card/OrderDescriptor.vue @@ -57,7 +57,6 @@ const total = ref(0); ref="descriptor" :url="`Orders/${entityId}`" :filter="filter" - module="Order" title="client.name" @on-fetch="setData" data-key="Order" diff --git a/src/pages/Order/Card/OrderLines.vue b/src/pages/Order/Card/OrderLines.vue index 6153b2d3e..1b864de6f 100644 --- a/src/pages/Order/Card/OrderLines.vue +++ b/src/pages/Order/Card/OrderLines.vue @@ -238,7 +238,7 @@ watch( lineFilter.value.where.orderFk = router.currentRoute.value.params.id; tableLinesRef.value.reload(); - } + }, ); </script> diff --git a/src/pages/Order/OrderList.vue b/src/pages/Order/OrderList.vue index 21cb5ed7e..40990f329 100644 --- a/src/pages/Order/OrderList.vue +++ b/src/pages/Order/OrderList.vue @@ -71,8 +71,9 @@ const columns = computed(() => [ format: (row) => row?.name, }, { - align: 'left', + align: 'center', name: 'isConfirmed', + component: 'checkbox', label: t('module.isConfirmed'), }, { @@ -95,7 +96,9 @@ const columns = computed(() => [ columnField: { component: null, }, - style: 'color="positive"', + style: () => { + return { color: 'positive' }; + }, }, { align: 'left', diff --git a/src/pages/Route/Agency/AgencyList.vue b/src/pages/Route/Agency/AgencyList.vue index 4322b9bc8..5c2904bf3 100644 --- a/src/pages/Route/Agency/AgencyList.vue +++ b/src/pages/Route/Agency/AgencyList.vue @@ -51,7 +51,6 @@ const columns = computed(() => [ name: 'isAnyVolumeAllowed', component: 'checkbox', cardVisible: true, - disable: true, }, { align: 'right', @@ -72,7 +71,7 @@ const columns = computed(() => [ :data-key :columns="columns" prefix="agency" - :right-filter="false" + :right-filter="true" :array-data-props="{ url: 'Agencies', order: 'name', @@ -83,6 +82,7 @@ const columns = computed(() => [ <VnTable :data-key :columns="columns" + is-editable="false" :right-search="false" :use-model="true" redirect="route/agency" diff --git a/src/pages/Route/Agency/Card/AgencyDescriptor.vue b/src/pages/Route/Agency/Card/AgencyDescriptor.vue index b9772037c..a0472c6c3 100644 --- a/src/pages/Route/Agency/Card/AgencyDescriptor.vue +++ b/src/pages/Route/Agency/Card/AgencyDescriptor.vue @@ -22,7 +22,6 @@ const card = computed(() => store.data); </script> <template> <CardDescriptor - module="Agency" data-key="Agency" :url="`Agencies/${entityId}`" :title="card?.name" diff --git a/src/pages/Route/Card/RouteDescriptor.vue b/src/pages/Route/Card/RouteDescriptor.vue index a8c6cc18b..829cce444 100644 --- a/src/pages/Route/Card/RouteDescriptor.vue +++ b/src/pages/Route/Card/RouteDescriptor.vue @@ -23,7 +23,6 @@ const entityId = computed(() => { </script> <template> <CardDescriptor - module="Route" :url="`Routes/${entityId}`" :filter="filter" :title="null" diff --git a/src/pages/Route/Roadmap/RoadmapDescriptor.vue b/src/pages/Route/Roadmap/RoadmapDescriptor.vue index 1f1e6d6ff..baa864a15 100644 --- a/src/pages/Route/Roadmap/RoadmapDescriptor.vue +++ b/src/pages/Route/Roadmap/RoadmapDescriptor.vue @@ -26,12 +26,7 @@ const entityId = computed(() => { </script> <template> - <CardDescriptor - module="Roadmap" - :url="`Roadmaps/${entityId}`" - :filter="filter" - data-key="Roadmap" - > + <CardDescriptor :url="`Roadmaps/${entityId}`" :filter="filter" data-key="Roadmap"> <template #body="{ entity }"> <VnLv :label="t('Roadmap')" :value="entity?.name" /> <VnLv :label="t('ETD')" :value="toDateHourMin(entity?.etd)" /> diff --git a/src/pages/Route/RouteExtendedList.vue b/src/pages/Route/RouteExtendedList.vue index 03d081fc8..46bc1a690 100644 --- a/src/pages/Route/RouteExtendedList.vue +++ b/src/pages/Route/RouteExtendedList.vue @@ -3,7 +3,7 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useSummaryDialog } from 'src/composables/useSummaryDialog'; import { useQuasar } from 'quasar'; -import { toDate } from 'src/filters'; +import { dashIfEmpty, toDate, toHour } from 'src/filters'; import { useRouter } from 'vue-router'; import { usePrintService } from 'src/composables/usePrintService'; @@ -38,7 +38,7 @@ const routeFilter = { }; const columns = computed(() => [ { - align: 'left', + align: 'center', name: 'id', label: 'Id', chip: { @@ -48,7 +48,7 @@ const columns = computed(() => [ columnFilter: false, }, { - align: 'left', + align: 'center', name: 'workerFk', label: t('route.Worker'), create: true, @@ -68,10 +68,10 @@ const columns = computed(() => [ }, useLike: false, cardVisible: true, - format: (row, dashIfEmpty) => dashIfEmpty(row.travelRef), + format: (row, dashIfEmpty) => dashIfEmpty(row.workerUserName), }, { - align: 'left', + align: 'center', name: 'agencyModeFk', label: t('route.Agency'), isTitle: true, @@ -87,9 +87,10 @@ const columns = computed(() => [ }, }, columnClass: 'expand', + format: (row, dashIfEmpty) => dashIfEmpty(row.agencyName), }, { - align: 'left', + align: 'center', name: 'vehicleFk', label: t('route.Vehicle'), cardVisible: true, @@ -107,29 +108,31 @@ const columns = computed(() => [ columnFilter: { inWhere: true, }, + format: (row, dashIfEmpty) => dashIfEmpty(row.vehiclePlateNumber), }, { - align: 'left', + align: 'center', name: 'dated', label: t('route.Date'), columnFilter: false, cardVisible: true, create: true, component: 'date', - format: ({ date }) => toDate(date), + format: ({ dated }, dashIfEmpty) => + dated === '0000-00-00' ? dashIfEmpty(null) : toDate(dated), }, { - align: 'left', + align: 'center', name: 'from', label: t('route.From'), visible: false, cardVisible: true, create: true, component: 'date', - format: ({ date }) => toDate(date), + format: ({ from }) => toDate(from), }, { - align: 'left', + align: 'center', name: 'to', label: t('route.To'), visible: false, @@ -146,18 +149,20 @@ const columns = computed(() => [ columnClass: 'shrink', }, { - align: 'left', + align: 'center', name: 'started', label: t('route.hourStarted'), component: 'time', columnFilter: false, + format: ({ started }) => toHour(started), }, { - align: 'left', + align: 'center', name: 'finished', label: t('route.hourFinished'), component: 'time', columnFilter: false, + format: ({ finished }) => toHour(finished), }, { align: 'center', @@ -176,7 +181,7 @@ const columns = computed(() => [ visible: false, }, { - align: 'left', + align: 'center', name: 'description', label: t('route.Description'), isTitle: true, @@ -185,7 +190,7 @@ const columns = computed(() => [ field: 'description', }, { - align: 'left', + align: 'center', name: 'isOk', label: t('route.Served'), component: 'checkbox', @@ -299,60 +304,62 @@ const openTicketsDialog = (id) => { <RouteFilter data-key="RouteList" /> </template> </RightMenu> - <VnTable - class="route-list" - ref="tableRef" - data-key="RouteList" - url="Routes/filter" - :columns="columns" - :right-search="false" - :is-editable="true" - :filter="routeFilter" - redirect="route" - :row-click="false" - :create="{ - urlCreate: 'Routes', - title: t('route.createRoute'), - onDataSaved: ({ id }) => tableRef.redirect(id), - formInitialData: {}, - }" - save-url="Routes/crud" - :disable-option="{ card: true }" - table-height="85vh" - v-model:selected="selectedRows" - :table="{ - 'row-key': 'id', - selection: 'multiple', - }" - > - <template #moreBeforeActions> - <QBtn - icon="vn:clone" - color="primary" - class="q-mr-sm" - :disable="!selectedRows?.length" - @click="confirmationDialog = true" - > - <QTooltip>{{ t('route.Clone Selected Routes') }}</QTooltip> - </QBtn> - <QBtn - icon="cloud_download" - color="primary" - class="q-mr-sm" - :disable="!selectedRows?.length" - @click="showRouteReport" - > - <QTooltip>{{ t('route.Download selected routes as PDF') }}</QTooltip> - </QBtn> - <QBtn - icon="check" - color="primary" - class="q-mr-sm" - :disable="!selectedRows?.length" - @click="markAsServed()" - > - <QTooltip>{{ t('route.Mark as served') }}</QTooltip> - </QBtn> - </template> - </VnTable> + <QPage class="q-px-md"> + <VnTable + class="route-list" + ref="tableRef" + data-key="RouteList" + url="Routes/filter" + :columns="columns" + :right-search="false" + :is-editable="true" + :filter="routeFilter" + redirect="route" + :row-click="false" + :create="{ + urlCreate: 'Routes', + title: t('route.createRoute'), + onDataSaved: ({ id }) => tableRef.redirect(id), + formInitialData: {}, + }" + save-url="Routes/crud" + :disable-option="{ card: true }" + table-height="85vh" + v-model:selected="selectedRows" + :table="{ + 'row-key': 'id', + selection: 'multiple', + }" + > + <template #moreBeforeActions> + <QBtn + icon="vn:clone" + color="primary" + class="q-mr-sm" + :disable="!selectedRows?.length" + @click="confirmationDialog = true" + > + <QTooltip>{{ t('route.Clone Selected Routes') }}</QTooltip> + </QBtn> + <QBtn + icon="cloud_download" + color="primary" + class="q-mr-sm" + :disable="!selectedRows?.length" + @click="showRouteReport" + > + <QTooltip>{{ t('route.Download selected routes as PDF') }}</QTooltip> + </QBtn> + <QBtn + icon="check" + color="primary" + class="q-mr-sm" + :disable="!selectedRows?.length" + @click="markAsServed()" + > + <QTooltip>{{ t('route.Mark as served') }}</QTooltip> + </QBtn> + </template> + </VnTable> + </QPage> </template> diff --git a/src/pages/Route/RouteList.vue b/src/pages/Route/RouteList.vue index bc3227f6c..9dad8ba22 100644 --- a/src/pages/Route/RouteList.vue +++ b/src/pages/Route/RouteList.vue @@ -38,6 +38,17 @@ const columns = computed(() => [ align: 'left', name: 'workerFk', label: t('route.Worker'), + component: 'select', + attrs: { + url: 'Workers/activeWithInheritedRole', + fields: ['id', 'name'], + useLike: false, + optionFilter: 'firstName', + find: { + value: 'workerFk', + label: 'workerUserName', + }, + }, create: true, cardVisible: true, format: (row, dashIfEmpty) => dashIfEmpty(row.travelRef), @@ -48,6 +59,15 @@ const columns = computed(() => [ name: 'agencyName', label: t('route.Agency'), cardVisible: true, + component: 'select', + attrs: { + url: 'agencyModes', + fields: ['id', 'name'], + find: { + value: 'agencyModeFk', + label: 'agencyName', + }, + }, create: true, columnClass: 'expand', columnFilter: false, @@ -57,6 +77,17 @@ const columns = computed(() => [ name: 'vehiclePlateNumber', label: t('route.Vehicle'), cardVisible: true, + component: 'select', + attrs: { + url: 'vehicles', + fields: ['id', 'numberPlate'], + optionLabel: 'numberPlate', + optionFilterValue: 'numberPlate', + find: { + value: 'vehicleFk', + label: 'vehiclePlateNumber', + }, + }, create: true, columnFilter: false, }, diff --git a/src/pages/Route/Vehicle/Card/VehicleDescriptor.vue b/src/pages/Route/Vehicle/Card/VehicleDescriptor.vue index f31ffe847..d9a2434ab 100644 --- a/src/pages/Route/Vehicle/Card/VehicleDescriptor.vue +++ b/src/pages/Route/Vehicle/Card/VehicleDescriptor.vue @@ -9,7 +9,6 @@ const { notify } = useNotify(); <template> <CardDescriptor :url="`Vehicles/${$route.params.id}`" - module="Vehicle" data-key="Vehicle" title="numberPlate" :to-module="{ name: 'VehicleList' }" diff --git a/src/pages/Shelving/Card/ShelvingDescriptor.vue b/src/pages/Shelving/Card/ShelvingDescriptor.vue index 9d491e36e..5e618aa7f 100644 --- a/src/pages/Shelving/Card/ShelvingDescriptor.vue +++ b/src/pages/Shelving/Card/ShelvingDescriptor.vue @@ -25,7 +25,6 @@ const entityId = computed(() => { </script> <template> <CardDescriptor - module="Shelving" :url="`Shelvings/${entityId}`" :filter="filter" title="code" diff --git a/src/pages/Parking/Card/ParkingBasicData.vue b/src/pages/Shelving/Parking/Card/ParkingBasicData.vue similarity index 100% rename from src/pages/Parking/Card/ParkingBasicData.vue rename to src/pages/Shelving/Parking/Card/ParkingBasicData.vue diff --git a/src/pages/Parking/Card/ParkingCard.vue b/src/pages/Shelving/Parking/Card/ParkingCard.vue similarity index 77% rename from src/pages/Parking/Card/ParkingCard.vue rename to src/pages/Shelving/Parking/Card/ParkingCard.vue index 6845aeec1..b32c1b7d3 100644 --- a/src/pages/Parking/Card/ParkingCard.vue +++ b/src/pages/Shelving/Parking/Card/ParkingCard.vue @@ -1,6 +1,6 @@ <script setup> import VnCardBeta from 'components/common/VnCardBeta.vue'; -import ParkingDescriptor from 'pages/Parking/Card/ParkingDescriptor.vue'; +import ParkingDescriptor from 'pages/Shelving/Parking/Card/ParkingDescriptor.vue'; import filter from './ParkingFilter.js'; </script> diff --git a/src/pages/Parking/Card/ParkingDescriptor.vue b/src/pages/Shelving/Parking/Card/ParkingDescriptor.vue similarity index 97% rename from src/pages/Parking/Card/ParkingDescriptor.vue rename to src/pages/Shelving/Parking/Card/ParkingDescriptor.vue index 0b7642c1c..46c9f8ea0 100644 --- a/src/pages/Parking/Card/ParkingDescriptor.vue +++ b/src/pages/Shelving/Parking/Card/ParkingDescriptor.vue @@ -17,7 +17,6 @@ const entityId = computed(() => props.id || route.params.id); </script> <template> <CardDescriptor - module="Parking" data-key="Parking" :url="`Parkings/${entityId}`" title="code" diff --git a/src/pages/Parking/Card/ParkingFilter.js b/src/pages/Shelving/Parking/Card/ParkingFilter.js similarity index 100% rename from src/pages/Parking/Card/ParkingFilter.js rename to src/pages/Shelving/Parking/Card/ParkingFilter.js diff --git a/src/pages/Parking/Card/ParkingLog.vue b/src/pages/Shelving/Parking/Card/ParkingLog.vue similarity index 100% rename from src/pages/Parking/Card/ParkingLog.vue rename to src/pages/Shelving/Parking/Card/ParkingLog.vue diff --git a/src/pages/Parking/Card/ParkingSummary.vue b/src/pages/Shelving/Parking/Card/ParkingSummary.vue similarity index 100% rename from src/pages/Parking/Card/ParkingSummary.vue rename to src/pages/Shelving/Parking/Card/ParkingSummary.vue diff --git a/src/pages/Parking/ParkingExprBuilder.js b/src/pages/Shelving/Parking/ParkingExprBuilder.js similarity index 100% rename from src/pages/Parking/ParkingExprBuilder.js rename to src/pages/Shelving/Parking/ParkingExprBuilder.js diff --git a/src/pages/Parking/ParkingFilter.vue b/src/pages/Shelving/Parking/ParkingFilter.vue similarity index 100% rename from src/pages/Parking/ParkingFilter.vue rename to src/pages/Shelving/Parking/ParkingFilter.vue diff --git a/src/pages/Parking/ParkingList.vue b/src/pages/Shelving/Parking/ParkingList.vue similarity index 100% rename from src/pages/Parking/ParkingList.vue rename to src/pages/Shelving/Parking/ParkingList.vue diff --git a/src/pages/Parking/locale/en.yml b/src/pages/Shelving/Parking/locale/en.yml similarity index 100% rename from src/pages/Parking/locale/en.yml rename to src/pages/Shelving/Parking/locale/en.yml diff --git a/src/pages/Parking/locale/es.yml b/src/pages/Shelving/Parking/locale/es.yml similarity index 100% rename from src/pages/Parking/locale/es.yml rename to src/pages/Shelving/Parking/locale/es.yml diff --git a/src/pages/Supplier/Card/SupplierDescriptor.vue b/src/pages/Supplier/Card/SupplierDescriptor.vue index 6a6feb9ef..462bdf853 100644 --- a/src/pages/Supplier/Card/SupplierDescriptor.vue +++ b/src/pages/Supplier/Card/SupplierDescriptor.vue @@ -62,7 +62,6 @@ const getEntryQueryParams = (supplier) => { <template> <CardDescriptor - module="Supplier" :url="`Suppliers/${entityId}`" :filter="filter" data-key="Supplier" diff --git a/src/pages/Supplier/Card/SupplierFiscalData.vue b/src/pages/Supplier/Card/SupplierFiscalData.vue index e569eb236..ecee5b76b 100644 --- a/src/pages/Supplier/Card/SupplierFiscalData.vue +++ b/src/pages/Supplier/Card/SupplierFiscalData.vue @@ -10,6 +10,7 @@ import VnInput from 'src/components/common/VnInput.vue'; import VnSelect from 'src/components/common/VnSelect.vue'; import VnLocation from 'src/components/common/VnLocation.vue'; import VnAccountNumber from 'src/components/common/VnAccountNumber.vue'; +import VnCheckbox from 'src/components/common/VnCheckbox.vue'; const route = useRoute(); const { t } = useI18n(); @@ -182,18 +183,11 @@ function handleLocation(data, location) { v-model="data.isTrucker" :label="t('supplier.fiscalData.isTrucker')" /> - <div class="row items-center"> - <QCheckbox v-model="data.isVies" :label="t('globals.isVies')" /> - <QIcon name="info" size="xs" class="cursor-pointer q-ml-sm"> - <QTooltip> - {{ - t( - 'When activating it, do not enter the country code in the ID field.' - ) - }} - </QTooltip> - </QIcon> - </div> + <VnCheckbox + v-model="data.isVies" + :label="t('globals.isVies')" + :info="t('whenActivatingIt')" + /> </div> </VnRow> </template> @@ -201,6 +195,8 @@ function handleLocation(data, location) { </template> <i18n> +en: + whenActivatingIt: When activating it, do not enter the country code in the ID field. es: - When activating it, do not enter the country code in the ID field.: Al activarlo, no informar el código del país en el campo nif + whenActivatingIt: Al activarlo, no informar el código del país en el campo nif. </i18n> diff --git a/src/pages/Ticket/Card/BasicData/TicketBasicData.vue b/src/pages/Ticket/Card/BasicData/TicketBasicData.vue index 44f2bf7fb..055c9a0ff 100644 --- a/src/pages/Ticket/Card/BasicData/TicketBasicData.vue +++ b/src/pages/Ticket/Card/BasicData/TicketBasicData.vue @@ -9,6 +9,7 @@ import FetchData from 'components/FetchData.vue'; import { useStateStore } from 'stores/useStateStore'; import { toCurrency } from 'filters/index'; import { useRole } from 'src/composables/useRole'; +import VnCheckbox from 'src/components/common/VnCheckbox.vue'; const haveNegatives = defineModel('have-negatives', { type: Boolean, required: true }); const formData = defineModel({ type: Object, required: true }); @@ -182,22 +183,19 @@ onMounted(async () => { </QCard> <QCard v-if="haveNegatives" - class="q-pa-md q-mb-md q-ma-md color-vn-text" + class="q-pa-xs q-mb-md q-ma-md color-vn-text" bordered flat style="border-color: black" > <QCardSection horizontal class="flex row items-center"> - <QCheckbox - :label="t('basicData.withoutNegatives')" + <VnCheckbox v-model="formData.withoutNegatives" + :label="t('basicData.withoutNegatives')" + :info="t('basicData.withoutNegativesInfo')" :toggle-indeterminate="false" + size="xs" /> - <QIcon name="info" size="xs" class="q-ml-sm"> - <QTooltip max-width="350px"> - {{ t('basicData.withoutNegativesInfo') }} - </QTooltip> - </QIcon> </QCardSection> </QCard> </QDrawer> diff --git a/src/pages/Ticket/Card/BasicData/TicketBasicDataForm.vue b/src/pages/Ticket/Card/BasicData/TicketBasicDataForm.vue index cf4481537..9d70fea38 100644 --- a/src/pages/Ticket/Card/BasicData/TicketBasicDataForm.vue +++ b/src/pages/Ticket/Card/BasicData/TicketBasicDataForm.vue @@ -260,7 +260,7 @@ async function getZone(options) { auto-load /> <QForm> - <VnRow> + <VnRow class="row q-gutter-md q-mb-md no-wrap"> <VnSelect :label="t('ticketList.client')" v-model="clientId" @@ -296,7 +296,7 @@ async function getZone(options) { :rules="validate('ticketList.warehouse')" /> </VnRow> - <VnRow> + <VnRow class="row q-gutter-md q-mb-md no-wrap"> <VnSelect :label="t('basicData.address')" v-model="addressId" diff --git a/src/pages/Ticket/Card/TicketDescriptor.vue b/src/pages/Ticket/Card/TicketDescriptor.vue index 762db19bf..c5f3233b1 100644 --- a/src/pages/Ticket/Card/TicketDescriptor.vue +++ b/src/pages/Ticket/Card/TicketDescriptor.vue @@ -44,7 +44,6 @@ function ticketFilter(ticket) { @on-fetch="(data) => ([problems] = data)" /> <CardDescriptor - module="Ticket" :url="`Tickets/${entityId}`" :filter="filter" data-key="Ticket" diff --git a/src/pages/Ticket/Card/TicketSale.vue b/src/pages/Ticket/Card/TicketSale.vue index 97d87ccf8..004bcbe79 100644 --- a/src/pages/Ticket/Card/TicketSale.vue +++ b/src/pages/Ticket/Card/TicketSale.vue @@ -14,7 +14,7 @@ import VnImg from 'src/components/ui/VnImg.vue'; import VnSubToolbar from 'src/components/ui/VnSubToolbar.vue'; import TicketSaleMoreActions from './TicketSaleMoreActions.vue'; -import TicketTransfer from './TicketTransfer.vue'; +import TicketTransferProxy from './TicketTransferProxy.vue'; import { toCurrency, toPercentage } from 'src/filters'; import { useArrayData } from 'composables/useArrayData'; @@ -609,8 +609,9 @@ watch( @click="setTransferParams()" data-cy="ticketSaleTransferBtn" > - <QTooltip>{{ t('Transfer lines') }}</QTooltip> - <TicketTransfer + <QTooltip>{{ t('ticketSale.transferLines') }}</QTooltip> + <TicketTransferProxy + class="full-width" :transfer="transfer" :ticket="store.data" @refresh-data="resetChanges()" diff --git a/src/pages/Ticket/Card/TicketSplit.vue b/src/pages/Ticket/Card/TicketSplit.vue new file mode 100644 index 000000000..e79057266 --- /dev/null +++ b/src/pages/Ticket/Card/TicketSplit.vue @@ -0,0 +1,37 @@ +<script setup> +import { ref } from 'vue'; + +import VnInputDate from 'src/components/common/VnInputDate.vue'; +import split from './components/split'; +const emit = defineEmits(['ticketTransfered']); + +const $props = defineProps({ + ticket: { + type: [Array, Object], + default: () => {}, + }, +}); + +const splitDate = ref(Date.vnNew()); + +const splitSelectedRows = async () => { + const tickets = Array.isArray($props.ticket) ? $props.ticket : [$props.ticket]; + await split(tickets, splitDate.value); + emit('ticketTransfered', tickets); +}; +</script> + +<template> + <VnInputDate class="q-mr-sm" :label="$t('New date')" v-model="splitDate" clearable /> + <QBtn class="q-mr-sm" color="primary" label="Split" @click="splitSelectedRows"></QBtn> +</template> +<style lang="scss"> +.q-table__bottom.row.items-center.q-table__bottom--nodata { + border-top: none; +} +</style> +<i18n> +es: + Sales to transfer: Líneas a transferir + Destination ticket: Ticket destinatario +</i18n> diff --git a/src/pages/Ticket/Card/TicketTransfer.vue b/src/pages/Ticket/Card/TicketTransfer.vue index 005d74a0e..ffa964c92 100644 --- a/src/pages/Ticket/Card/TicketTransfer.vue +++ b/src/pages/Ticket/Card/TicketTransfer.vue @@ -1,11 +1,11 @@ <script setup> import { ref, computed, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'; - import VnInput from 'src/components/common/VnInput.vue'; import TicketTransferForm from './TicketTransferForm.vue'; import { toDateFormat } from 'src/filters/date.js'; +const emit = defineEmits(['ticketTransfered']); const $props = defineProps({ mana: { @@ -21,16 +21,15 @@ const $props = defineProps({ default: () => {}, }, ticket: { - type: Object, + type: [Array, Object], default: () => {}, }, }); +onMounted(() => (_transfer.value = $props.transfer)); const { t } = useI18n(); -const QPopupProxyRef = ref(null); const transferFormRef = ref(null); const _transfer = ref(); - const transferLinesColumns = computed(() => [ { label: t('ticketList.id'), @@ -86,76 +85,74 @@ const handleRowClick = (row) => { transferFormRef.value.transferSales(ticketId); } }; - -onMounted(() => (_transfer.value = $props.transfer)); </script> <template> - <QPopupProxy ref="QPopupProxyRef" data-cy="ticketTransferPopup"> - <QCard class="q-px-md" style="display: flex; width: 80vw"> - <QTable - :rows="transfer.sales" - :columns="transferLinesColumns" - :title="t('Sales to transfer')" - row-key="id" - :pagination="{ rowsPerPage: 0 }" - class="full-width q-mt-md" - :no-data-label="t('globals.noResults')" - > - <template #body-cell-quantity="{ row }"> - <QTd @click.stop> - <VnInput - v-model.number="row.quantity" - :clearable="false" - style="max-width: 60px" - /> - </QTd> - </template> - </QTable> - <QSeparator vertical spaced /> - <QTable - v-if="transfer.lastActiveTickets" - :rows="transfer.lastActiveTickets" - :columns="destinationTicketColumns" - :title="t('Destination ticket')" - row-key="id" - class="full-width q-mt-md" - @row-click="(_, row) => handleRowClick(row)" - > - <template #body-cell-address="{ row }"> - <QTd @click.stop> - <span> - {{ row.nickname }} - {{ row.name }} - {{ row.street }} - {{ row.postalCode }} - {{ row.city }} - </span> - <QTooltip> - {{ row.nickname }} - {{ row.name }} - {{ row.street }} - {{ row.postalCode }} - {{ row.city }} - </QTooltip> - </QTd> - </template> + <QTable + :rows="transfer.sales" + :columns="transferLinesColumns" + :title="t('Sales to transfer')" + row-key="id" + :pagination="{ rowsPerPage: 0 }" + class="full-width q-mt-md" + :no-data-label="t('globals.noResults')" + > + <template #body-cell-quantity="{ row }"> + <QTd @click.stop> + <VnInput + v-model.number="row.quantity" + :clearable="false" + style="max-width: 60px" + /> + </QTd> + </template> + </QTable> + <QSeparator vertical spaced /> + <QTable + v-if="transfer.lastActiveTickets" + :rows="transfer.lastActiveTickets" + :columns="destinationTicketColumns" + :title="t('Destination ticket')" + row-key="id" + class="full-width q-mt-md" + @row-click="(_, row) => handleRowClick(row)" + :no-data-label="t('globals.noResults')" + :pagination="{ rowsPerPage: 0 }" + > + <template #body-cell-address="{ row }"> + <QTd @click.stop> + <span> + {{ row.nickname }} + {{ row.name }} + {{ row.street }} + {{ row.postalCode }} + {{ row.city }} + </span> + <QTooltip> + {{ row.nickname }} + {{ row.name }} + {{ row.street }} + {{ row.postalCode }} + {{ row.city }} + </QTooltip> + </QTd> + </template> - <template #no-data> - <TicketTransferForm ref="transferFormRef" v-bind="$props" /> - </template> - <template #bottom> - <TicketTransferForm ref="transferFormRef" v-bind="$props" /> - </template> - </QTable> - </QCard> - </QPopupProxy> + <template #no-data> + <TicketTransferForm ref="transferFormRef" v-bind="$props" /> + </template> + <template #bottom> + <TicketTransferForm ref="transferFormRef" v-bind="$props" /> + </template> + </QTable> </template> - +<style lang="scss"> +.q-table__bottom.row.items-center.q-table__bottom--nodata { + border-top: none; +} +</style> <i18n> es: Sales to transfer: Líneas a transferir Destination ticket: Ticket destinatario - Transfer to ticket: Transferir a ticket - New ticket: Nuevo ticket </i18n> diff --git a/src/pages/Ticket/Card/TicketTransferProxy.vue b/src/pages/Ticket/Card/TicketTransferProxy.vue new file mode 100644 index 000000000..3f3f018df --- /dev/null +++ b/src/pages/Ticket/Card/TicketTransferProxy.vue @@ -0,0 +1,54 @@ +<script setup> +import { ref } from 'vue'; +import TicketTransfer from './TicketTransfer.vue'; +import Split from './TicketSplit.vue'; +const emit = defineEmits(['ticketTransfered']); + +const $props = defineProps({ + mana: { + type: Number, + default: null, + }, + newPrice: { + type: Number, + default: 0, + }, + transfer: { + type: Object, + default: () => {}, + }, + ticket: { + type: [Array, Object], + default: () => {}, + }, + split: { + type: Boolean, + default: false, + }, +}); + +const popupProxyRef = ref(null); +const splitRef = ref(null); +const transferRef = ref(null); +</script> + +<template> + <QPopupProxy ref="popupProxyRef" data-cy="ticketTransferPopup"> + <div class="flex row items-center q-ma-lg" v-if="$props.split"> + <Split + ref="splitRef" + @splitSelectedRows="splitSelectedRows" + :ticket="$props.ticket" + /> + </div> + + <div v-else> + <TicketTransfer + ref="transferRef" + :ticket="$props.ticket" + :sales="$props.sales" + :transfer="$props.transfer" + /> + </div> + </QPopupProxy> +</template> diff --git a/src/pages/Ticket/Card/components/split.js b/src/pages/Ticket/Card/components/split.js new file mode 100644 index 000000000..afa1d5cd6 --- /dev/null +++ b/src/pages/Ticket/Card/components/split.js @@ -0,0 +1,22 @@ +import axios from 'axios'; +import notifyResults from 'src/utils/notifyResults'; + +export default async function (data, date) { + const reducedData = data.reduce((acc, item) => { + const existing = acc.find(({ ticketFk }) => ticketFk === item.id); + if (existing) { + existing.sales.push(item.saleFk); + } else { + acc.push({ ticketFk: item.id, sales: [item.saleFk], date }); + } + return acc; + }, []); + + const promises = reducedData.map((params) => axios.post(`Tickets/split`, params)); + + const results = await Promise.allSettled(promises); + + notifyResults(results, 'ticketFk'); + + return results; +} diff --git a/src/pages/Ticket/Negative/TicketLackDetail.vue b/src/pages/Ticket/Negative/TicketLackDetail.vue new file mode 100644 index 000000000..dcf835d03 --- /dev/null +++ b/src/pages/Ticket/Negative/TicketLackDetail.vue @@ -0,0 +1,198 @@ +<script setup> +import { computed, onMounted, onUnmounted, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import ChangeQuantityDialog from './components/ChangeQuantityDialog.vue'; +import ChangeStateDialog from './components/ChangeStateDialog.vue'; +import ChangeItemDialog from './components/ChangeItemDialog.vue'; +import TicketTransferProxy from '../Card/TicketTransferProxy.vue'; +import FetchData from 'src/components/FetchData.vue'; +import { useStateStore } from 'stores/useStateStore'; +import { useState } from 'src/composables/useState'; + +import { useRoute } from 'vue-router'; +import TicketLackTable from './TicketLackTable.vue'; +import VnPopupProxy from 'src/components/common/VnPopupProxy.vue'; +import ItemProposalProxy from 'src/pages/Item/components/ItemProposalProxy.vue'; + +import { useQuasar } from 'quasar'; +const quasar = useQuasar(); +const { t } = useI18n(); +const editableStates = ref([]); +const stateStore = useStateStore(); +const tableRef = ref(); +const changeItemDialogRef = ref(null); +const changeStateDialogRef = ref(null); +const changeQuantityDialogRef = ref(null); +const showProposalDialog = ref(false); +const showChangeQuantityDialog = ref(false); +const selectedRows = ref([]); +const route = useRoute(); +onMounted(() => { + stateStore.rightDrawer = false; +}); +onUnmounted(() => { + stateStore.rightDrawer = true; +}); + +const entityId = computed(() => route.params.id); +const item = ref({}); + +const itemProposalSelected = ref(null); +const reload = async () => { + tableRef.value.tableRef.reload(); +}; +defineExpose({ reload }); +const filter = computed(() => ({ + scopeDays: route.query.days, + showType: true, + alertLevelCode: 'FREE', + date: Date.vnNew(), + warehouseFk: useState().getUser().value.warehouseFk, +})); +const itemProposalEvt = (data) => { + const { itemProposal } = data; + itemProposalSelected.value = itemProposal; + reload(); +}; + +function onBuysFetched(data) { + Object.assign(item.value, data[0]); +} +const showItemProposal = () => { + quasar + .dialog({ + component: ItemProposalProxy, + componentProps: { + itemLack: tableRef.value.itemLack, + replaceAction: true, + sales: selectedRows.value, + }, + }) + .onOk(itemProposalEvt); +}; +</script> + +<template> + <FetchData + url="States/editableStates" + @on-fetch="(data) => (editableStates = data)" + auto-load + /> + <FetchData + :url="`Items/${entityId}/getCard`" + :fields="['longName']" + @on-fetch="(data) => (item = data)" + auto-load + /> + <FetchData + :url="`Buys/latestBuysFilter`" + :fields="['longName']" + :filter="{ where: { 'i.id': entityId } }" + @on-fetch="onBuysFetched" + auto-load + /> + + <TicketLackTable + ref="tableRef" + :filter="filter" + @update:selection="({ value }, _) => (selectedRows = value)" + > + <template #top-right> + <QBtnGroup push class="q-mr-lg" style="column-gap: 1px"> + <QBtn + data-cy="transferLines" + color="primary" + :disable="!(selectedRows.length === 1)" + > + <template #default> + <QIcon name="vn:splitline" /> + <QIcon name="vn:ticket" /> + + <QTooltip>{{ t('ticketSale.transferLines') }} </QTooltip> + <TicketTransferProxy + ref="transferFormRef" + split="true" + :ticket="selectedRows" + :transfer="{ + sales: selectedRows, + lastActiveTickets: selectedRows.map((row) => row.id), + }" + @ticket-transfered="reload" + ></TicketTransferProxy> + </template> + </QBtn> + <QBtn + color="primary" + @click="showProposalDialog = true" + :disable="selectedRows.length < 1" + data-cy="itemProposal" + > + <QIcon + name="import_export" + class="rotate-90" + @click="showItemProposal" + ></QIcon> + <QTooltip bottom anchor="bottom right"> + {{ t('itemProposal') }} + </QTooltip> + </QBtn> + <VnPopupProxy + data-cy="changeItem" + icon="sync" + :disable="selectedRows.length < 1" + :tooltip="t('negative.detail.modal.changeItem.title')" + > + <template #extraIcon> <QIcon name="vn:item" /> </template> + <template v-slot="{ popup }"> + <ChangeItemDialog + ref="changeItemDialogRef" + @update-item="popup.hide()" + :selected-rows="selectedRows" + /></template> + </VnPopupProxy> + <VnPopupProxy + data-cy="changeState" + icon="sync" + :disable="selectedRows.length < 1" + :tooltip="t('negative.detail.modal.changeState.title')" + > + <template #extraIcon> <QIcon name="vn:eye" /> </template> + <template v-slot="{ popup }"> + <ChangeStateDialog + ref="changeStateDialogRef" + @update-state="popup.hide()" + :selected-rows="selectedRows" + /></template> + </VnPopupProxy> + <VnPopupProxy + data-cy="changeQuantity" + icon="sync" + :disable="selectedRows.length < 1" + :tooltip="t('negative.detail.modal.changeQuantity.title')" + @click="showChangeQuantityDialog = true" + > + <template #extraIcon> <QIcon name="exposure" /> </template> + <template v-slot="{ popup }"> + <ChangeQuantityDialog + ref="changeQuantityDialogRef" + @update-quantity="popup.hide()" + :selected-rows="selectedRows" + /></template> + </VnPopupProxy> </QBtnGroup + ></template> + </TicketLackTable> +</template> +<style lang="scss" scoped> +.list-enter-active, +.list-leave-active { + transition: all 1s ease; +} +.list-enter-from, +.list-leave-to { + opacity: 0; + background-color: $primary; +} +.q-table.q-table__container > div:first-child { + border-radius: unset; +} +</style> diff --git a/src/pages/Ticket/Negative/TicketLackFilter.vue b/src/pages/Ticket/Negative/TicketLackFilter.vue new file mode 100644 index 000000000..3762f453d --- /dev/null +++ b/src/pages/Ticket/Negative/TicketLackFilter.vue @@ -0,0 +1,175 @@ +<script setup> +import { ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import FetchData from 'components/FetchData.vue'; +import VnFilterPanel from 'src/components/ui/VnFilterPanel.vue'; +import VnInput from 'src/components/common/VnInput.vue'; +import VnSelect from 'src/components/common/VnSelect.vue'; +const { t } = useI18n(); +const props = defineProps({ + dataKey: { + type: String, + required: true, + }, +}); + +const to = Date.vnNew(); +to.setDate(to.getDate() + 1); + +const warehouses = ref(); +const categoriesOptions = ref([]); +const itemTypesRef = ref(null); +const itemTypesOptions = ref([]); + +const itemTypesFilter = { + fields: ['id', 'name', 'categoryFk'], + include: 'category', + order: 'name ASC', + where: {}, +}; +const onCategoryChange = async (categoryFk, search) => { + if (!categoryFk) { + itemTypesFilter.where.categoryFk = null; + delete itemTypesFilter.where.categoryFk; + } else { + itemTypesFilter.where.categoryFk = categoryFk; + } + search(); + await itemTypesRef.value.fetch(); +}; +const emit = defineEmits(['set-user-params']); + +const setUserParams = (params) => { + emit('set-user-params', params); +}; +</script> + +<template> + <FetchData url="Warehouses" @on-fetch="(data) => (warehouses = data)" auto-load /> + <FetchData + url="ItemCategories" + :filter="{ fields: ['id', 'name'], order: 'name ASC' }" + @on-fetch="(data) => (categoriesOptions = data)" + auto-load + /> + + <FetchData + ref="itemTypesRef" + url="ItemTypes" + :filter="itemTypesFilter" + @on-fetch="(data) => (itemTypesOptions = data)" + auto-load + /> + + <VnFilterPanel + :data-key="props.dataKey" + :search-button="true" + @set-user-params="setUserParams" + > + <template #tags="{ tag, formatFn }"> + <div class="q-gutter-x-xs"> + <strong>{{ t(`negative.${tag.label}`) }}</strong> + <span>{{ formatFn(tag.value) }}</span> + </div> + </template> + <template #body="{ params, searchFn }"> + <QList dense class="q-gutter-y-sm q-mt-sm"> + <QItem> + <QItemSection> + <VnInput + v-model="params.days" + :label="t('negative.days')" + dense + is-outlined + type="number" + @update:model-value=" + (value) => { + setUserParams(params); + } + " + /> + </QItemSection> + </QItem> + <QItem> + <QItemSection> + <VnInput + v-model="params.id" + :label="t('negative.id')" + dense + is-outlined + /> + </QItemSection> + </QItem> + <QItem> + <QItemSection> + <VnInput + v-model="params.producer" + :label="t('negative.producer')" + dense + is-outlined + /> + </QItemSection> + </QItem> + <QItem> + <QItemSection> + <VnInput + v-model="params.origen" + :label="t('negative.origen')" + dense + is-outlined + /> + </QItemSection> </QItem + ><QItem> + <QItemSection v-if="categoriesOptions"> + <VnSelect + :label="t('negative.categoryFk')" + v-model="params.categoryFk" + @update:model-value=" + ($event) => onCategoryChange($event, searchFn) + " + :options="categoriesOptions" + option-value="id" + option-label="name" + hide-selected + dense + outlined + rounded + /> </QItemSection + ><QItemSection v-else> + <QSkeleton class="full-width" type="QSelect" /> + </QItemSection> + </QItem> + <QItem> + <QItemSection v-if="itemTypesOptions"> + <VnSelect + :label="t('negative.type')" + v-model="params.typeFk" + @update:model-value="searchFn()" + :options="itemTypesOptions" + option-value="id" + option-label="name" + hide-selected + dense + outlined + rounded + > + <template #option="scope"> + <QItem v-bind="scope.itemProps"> + <QItemSection> + <QItemLabel>{{ scope.opt?.name }}</QItemLabel> + <QItemLabel caption>{{ + scope.opt?.category?.name + }}</QItemLabel> + </QItemSection> + </QItem> + </template> + </VnSelect> </QItemSection + ><QItemSection v-else> + <QSkeleton class="full-width" type="QSelect" /> + </QItemSection> + </QItem> + </QList> + </template> + </VnFilterPanel> +</template> diff --git a/src/pages/Ticket/Negative/TicketLackList.vue b/src/pages/Ticket/Negative/TicketLackList.vue new file mode 100644 index 000000000..d1e8b823a --- /dev/null +++ b/src/pages/Ticket/Negative/TicketLackList.vue @@ -0,0 +1,227 @@ +<script setup> +import { computed, ref, reactive } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useStateStore } from 'stores/useStateStore'; +import VnTable from 'components/VnTable/VnTable.vue'; +import { onBeforeMount } from 'vue'; +import { dashIfEmpty, toDate, toHour } from 'src/filters'; +import { useRouter } from 'vue-router'; +import { useState } from 'src/composables/useState'; +import { useRole } from 'src/composables/useRole'; +import ItemDescriptorProxy from 'src/pages/Item/Card/ItemDescriptorProxy.vue'; +import RightMenu from 'src/components/common/RightMenu.vue'; +import TicketLackFilter from './TicketLackFilter.vue'; +onBeforeMount(() => { + stateStore.$state.rightDrawer = true; +}); +const router = useRouter(); +const stateStore = useStateStore(); +const { t } = useI18n(); +const selectedRows = ref([]); +const tableRef = ref(); +const filterParams = ref({}); +const negativeParams = reactive({ + days: useRole().likeAny('buyer') ? 2 : 0, + warehouseFk: useState().getUser().value.warehouseFk, +}); +const redirectToCreateView = ({ itemFk }) => { + router.push({ + name: 'NegativeDetail', + params: { id: itemFk }, + query: { days: filterParams.value.days ?? negativeParams.days }, + }); +}; +const columns = computed(() => [ + { + name: 'date', + align: 'center', + label: t('negative.date'), + format: ({ timed }) => toDate(timed), + sortable: true, + cardVisible: true, + isId: true, + columnFilter: { + component: 'date', + }, + }, + { + columnClass: 'shrink', + name: 'timed', + align: 'center', + label: t('negative.timed'), + format: ({ timed }) => toHour(timed), + sortable: true, + cardVisible: true, + columnFilter: { + component: 'time', + }, + }, + { + name: 'itemFk', + align: 'center', + label: t('negative.id'), + format: ({ itemFk }) => itemFk, + sortable: true, + columnFilter: { + component: 'input', + type: 'number', + columnClass: 'shrink', + }, + }, + { + name: 'longName', + align: 'center', + label: t('negative.longName'), + field: ({ longName }) => longName, + + sortable: true, + headerStyle: 'width: 350px', + cardVisible: true, + columnClass: 'expand', + }, + { + name: 'producer', + align: 'center', + label: t('negative.supplier'), + field: ({ producer }) => dashIfEmpty(producer), + sortable: true, + columnClass: 'shrink', + }, + { + name: 'inkFk', + align: 'center', + label: t('negative.colour'), + field: ({ inkFk }) => inkFk, + sortable: true, + cardVisible: true, + }, + { + name: 'size', + align: 'center', + label: t('negative.size'), + field: ({ size }) => size, + sortable: true, + cardVisible: true, + columnFilter: { + component: 'input', + type: 'number', + columnClass: 'shrink', + }, + }, + { + name: 'category', + align: 'center', + label: t('negative.origen'), + field: ({ category }) => dashIfEmpty(category), + sortable: true, + cardVisible: true, + }, + { + name: 'lack', + align: 'center', + label: t('negative.lack'), + field: ({ lack }) => lack, + columnFilter: { + component: 'input', + type: 'number', + columnClass: 'shrink', + }, + sortable: true, + headerStyle: 'padding-center: 33px', + cardVisible: true, + }, + { + name: 'tableActions', + align: 'center', + actions: [ + { + title: t('Open details'), + icon: 'edit', + action: redirectToCreateView, + isPrimary: true, + }, + ], + }, +]); + +const setUserParams = (params) => { + filterParams.value = params; +}; +</script> + +<template> + <RightMenu> + <template #right-panel> + <TicketLackFilter data-key="NegativeList" @set-user-params="setUserParams" /> + </template> + </RightMenu> + {{ filterRef }} + <VnTable + ref="tableRef" + data-key="NegativeList" + :url="`Tickets/itemLack`" + :order="['itemFk DESC, date DESC, timed DESC']" + :user-params="negativeParams" + auto-load + :columns="columns" + default-mode="table" + :right-search="false" + :is-editable="false" + :use-model="true" + :map-key="false" + :row-click="redirectToCreateView" + v-model:selected="selectedRows" + :create="false" + :crud-model="{ + disableInfiniteScroll: true, + }" + :table="{ + 'row-key': 'itemFk', + selection: 'multiple', + }" + > + <template #column-itemFk="{ row }"> + <div + style="display: flex; justify-content: space-around; align-items: center" + > + <span @click.stop>{{ row.itemFk }}</span> + </div> + </template> + <template #column-longName="{ row }"> + <span class="link" @click.stop> + {{ row.longName }} + <ItemDescriptorProxy :id="row.itemFk" /> + </span> + </template> + </VnTable> +</template> + +<style lang="scss" scoped> +.list { + max-height: 100%; + padding: 15px; + width: 100%; +} + +.grid-style-transition { + transition: + transform 0.28s, + background-color 0.28s; +} + +#true { + background-color: $positive; +} + +#false { + background-color: $negative; +} + +div.q-dialog__inner > div { + max-width: fit-content !important; +} +.q-btn-group > .q-btn-item:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +</style> diff --git a/src/pages/Ticket/Negative/TicketLackTable.vue b/src/pages/Ticket/Negative/TicketLackTable.vue new file mode 100644 index 000000000..176e8f7ad --- /dev/null +++ b/src/pages/Ticket/Negative/TicketLackTable.vue @@ -0,0 +1,356 @@ +<script setup> +import FetchedTags from 'components/ui/FetchedTags.vue'; +import ItemDescriptorProxy from 'src/pages/Item/Card/ItemDescriptorProxy.vue'; +import { computed, ref, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import axios from 'axios'; +import FetchData from 'src/components/FetchData.vue'; +import { toDate, toHour } from 'src/filters'; +import useNotify from 'src/composables/useNotify.js'; +import ZoneDescriptorProxy from 'pages/Zone/Card/ZoneDescriptorProxy.vue'; +import { useRoute } from 'vue-router'; +import VnTable from 'src/components/VnTable/VnTable.vue'; +import TicketDescriptorProxy from '../Card/TicketDescriptorProxy.vue'; +import VnInputNumber from 'src/components/common/VnInputNumber.vue'; +import VnSelect from 'src/components/common/VnSelect.vue'; +import CustomerDescriptorProxy from 'src/pages/Customer/Card/CustomerDescriptorProxy.vue'; + +const $props = defineProps({ + filter: { + type: Object, + default: () => ({}), + }, +}); + +watch( + () => $props.filter, + (v) => { + filterLack.value.where = v; + tableRef.value.reload(filterLack); + }, +); + +const filterLack = ref({ + include: [ + { + relation: 'workers', + scope: { + fields: ['id', 'firstName'], + }, + }, + ], + where: { ...$props.filter }, + order: 'ts.alertLevelCode ASC', +}); + +const selectedRows = ref([]); +const { t } = useI18n(); +const { notify } = useNotify(); +const entityId = computed(() => route.params.id); +const item = ref({}); +const route = useRoute(); +const columns = computed(() => [ + { + name: 'status', + align: 'center', + sortable: false, + columnClass: 'shrink', + columnFilter: false, + }, + { + name: 'ticketFk', + label: t('negative.detail.ticketFk'), + align: 'center', + sortable: true, + columnFilter: { + component: 'input', + type: 'number', + }, + }, + { + name: 'shipped', + label: t('negative.detail.shipped'), + field: 'shipped', + align: 'center', + format: ({ shipped }) => toDate(shipped), + sortable: true, + columnFilter: { + component: 'date', + columnClass: 'shrink', + }, + }, + { + name: 'minTimed', + label: t('negative.detail.theoreticalhour'), + field: 'minTimed', + align: 'center', + sortable: true, + component: 'time', + columnFilter: {}, + }, + { + name: 'alertLevelCode', + label: t('negative.detail.state'), + columnFilter: { + name: 'alertLevelCode', + component: 'select', + attrs: { + url: 'AlertLevels', + fields: ['name', 'code'], + optionLabel: 'code', + optionValue: 'code', + }, + }, + align: 'center', + sortable: true, + }, + { + name: 'zoneName', + label: t('negative.detail.zoneName'), + field: 'zoneName', + align: 'center', + sortable: true, + }, + { + name: 'nickname', + label: t('negative.detail.nickname'), + field: 'nickname', + align: 'center', + sortable: true, + }, + { + name: 'quantity', + label: t('negative.detail.quantity'), + field: 'quantity', + sortable: true, + component: 'input', + type: 'number', + }, +]); + +const emit = defineEmits(['update:selection']); +const itemLack = ref(null); +const fetchItemLack = ref(null); +const tableRef = ref(null); +defineExpose({ tableRef, itemLack }); +watch(selectedRows, () => emit('update:selection', selectedRows)); +const getInputEvents = ({ col, ...rows }) => ({ + 'update:modelValue': () => saveChange(col.name, rows), + 'keyup.enter': () => saveChange(col.name, rows), +}); +const saveChange = async (field, { row }) => { + try { + switch (field) { + case 'alertLevelCode': + await axios.post(`Tickets/state`, { + ticketFk: row.ticketFk, + code: row[field], + }); + break; + + case 'quantity': + await axios.post(`Sales/${row.saleFk}/updateQuantity`, { + quantity: +row.quantity, + }); + break; + } + notify('globals.dataSaved', 'positive'); + fetchItemLack.value.fetch(); + } catch (err) { + console.error('Error saving changes', err); + f; + } +}; + +function onBuysFetched(data) { + Object.assign(item.value, data[0]); +} +</script> + +<template> + <FetchData + ref="fetchItemLack" + :url="`Tickets/itemLack`" + :params="{ id: entityId }" + @on-fetch="(data) => (itemLack = data[0])" + auto-load + /> + <FetchData + :url="`Items/${entityId}/getCard`" + :fields="['longName']" + @on-fetch="(data) => (item = data)" + auto-load + /> + <FetchData + :url="`Buys/latestBuysFilter`" + :fields="['longName']" + :filter="{ where: { 'i.id': entityId } }" + @on-fetch="onBuysFetched" + auto-load + /> + <VnTable + ref="tableRef" + data-key="NegativeItem" + :map-key="false" + :url="`Tickets/itemLack/${entityId}`" + :columns="columns" + auto-load + :create="false" + :create-as-dialog="false" + :use-model="true" + :filter="filterLack" + :order="['ts.alertLevelCode ASC']" + :table="{ + 'row-key': 'id', + selection: 'multiple', + }" + dense + :is-editable="true" + :row-click="false" + :right-search="false" + :right-search-icon="false" + v-model:selected="selectedRows" + :disable-option="{ card: true }" + > + <template #top-left> + <div style="display: flex; align-items: center" v-if="itemLack"> + <!-- <VnImg :id="itemLack.itemFk" class="rounded image-wrapper"></VnImg> --> + <div class="flex column" style="align-items: center"> + <QBadge + ref="badgeLackRef" + class="q-ml-xs" + text-color="white" + :color="itemLack.lack === 0 ? 'positive' : 'negative'" + :label="itemLack.lack" + /> + </div> + <div class="flex column left" style="align-items: flex-start"> + <QBtn flat class="link text-blue"> + {{ item?.longName ?? item.name }} + <ItemDescriptorProxy :id="entityId" /> + <FetchedTags class="q-ml-md" :item="item" :columns="7" /> + </QBtn> + </div> + </div> + </template> + <template #top-right> + <slot name="top-right" /> + </template> + + <template #column-status="{ row }"> + <QTd style="min-width: 150px"> + <div class="icon-container"> + <QIcon + v-if="row.isBasket" + name="vn:basket" + color="primary" + class="cursor-pointer" + size="xs" + > + <QTooltip>{{ t('negative.detail.isBasket') }}</QTooltip> + </QIcon> + <QIcon + v-if="row.hasToIgnore" + name="star" + color="primary" + class="cursor-pointer fill-icon" + size="xs" + > + <QTooltip>{{ t('negative.detail.hasToIgnore') }}</QTooltip> + </QIcon> + <QIcon + v-if="row.hasObservation" + name="change_circle" + color="primary" + class="cursor-pointer" + size="xs" + > + <QTooltip>{{ + t('negative.detail.hasObservation') + }}</QTooltip> </QIcon + ><QIcon + v-if="row.isRookie" + name="vn:Person" + size="xs" + color="primary" + class="cursor-pointer" + > + <QTooltip>{{ t('negative.detail.isRookie') }}</QTooltip> + </QIcon> + <QIcon + v-if="row.peticionCompra" + name="vn:buyrequest" + size="xs" + color="primary" + class="cursor-pointer" + > + <QTooltip>{{ t('negative.detail.peticionCompra') }}</QTooltip> + </QIcon> + <QIcon + v-if="row.turno" + name="vn:calendar" + size="xs" + color="primary" + class="cursor-pointer" + > + <QTooltip>{{ t('negative.detail.turno') }}</QTooltip> + </QIcon> + </div></QTd + > + </template> + <template #column-nickname="{ row }"> + <span class="link" @click.stop> + {{ row.nickname }} + <CustomerDescriptorProxy :id="row.customerId" /> + </span> + </template> + <template #column-ticketFk="{ row }"> + <span class="q-pa-sm link"> + {{ row.id }} + <TicketDescriptorProxy :id="row.id" /> + </span> + </template> + <template #column-alertLevelCode="props"> + <VnSelect + url="States/editableStates" + auto-load + hide-selected + option-value="id" + option-label="name" + v-model="props.row.alertLevelCode" + v-on="getInputEvents(props)" + /> + </template> + + <template #column-zoneName="{ row }"> + <span class="link">{{ row.zoneName }}</span> + <ZoneDescriptorProxy :id="row.zoneFk" /> + </template> + <template #column-quantity="props"> + <VnInputNumber + v-model.number="props.row.quantity" + v-on="getInputEvents(props)" + ></VnInputNumber> + </template> + </VnTable> +</template> +<style lang="scss" scoped> +.icon-container { + display: grid; + grid-template-columns: repeat(3, 0.2fr); + row-gap: 5px; /* Ajusta el espacio entre los iconos según sea necesario */ +} +.icon-container > * { + width: 100%; + height: auto; +} +.list-enter-active, +.list-leave-active { + transition: all 1s ease; +} +.list-enter-from, +.list-leave-to { + opacity: 0; + background-color: $primary; +} +</style> diff --git a/src/pages/Ticket/Negative/components/ChangeItemDialog.vue b/src/pages/Ticket/Negative/components/ChangeItemDialog.vue new file mode 100644 index 000000000..e419b85c0 --- /dev/null +++ b/src/pages/Ticket/Negative/components/ChangeItemDialog.vue @@ -0,0 +1,90 @@ +<script setup> +import { ref } from 'vue'; +import axios from 'axios'; +import VnSelect from 'src/components/common/VnSelect.vue'; +import notifyResults from 'src/utils/notifyResults'; +const emit = defineEmits(['update-item']); + +const showChangeItemDialog = ref(false); +const newItem = ref(null); +const $props = defineProps({ + selectedRows: { + type: Array, + default: () => [], + }, +}); + +const updateItem = async () => { + try { + showChangeItemDialog.value = true; + const rowsToUpdate = $props.selectedRows.map(({ saleFk, quantity }) => + axios.post(`Sales/replaceItem`, { + saleFk, + substitutionFk: newItem.value, + quantity, + }), + ); + const result = await Promise.allSettled(rowsToUpdate); + notifyResults(result, 'saleFk'); + emit('update-item', newItem.value); + } catch (err) { + console.error('Error updating item:', err); + return err; + } +}; +</script> + +<template> + <QCard class="q-pa-sm"> + <QCardSection class="row items-center justify-center column items-stretch"> + {{ showChangeItemDialog }} + <span>{{ $t('negative.detail.modal.changeItem.title') }}</span> + <VnSelect + url="Items/WithName" + :fields="['id', 'name']" + :sort-by="['id DESC']" + :options="items" + option-label="name" + option-value="id" + v-model="newItem" + > + </VnSelect> + </QCardSection> + <QCardActions align="right"> + <QBtn :label="$t('globals.cancel')" color="primary" flat v-close-popup /> + <QBtn + :label="$t('globals.confirm')" + color="primary" + :disable="!newItem" + @click="updateItem" + unelevated + autofocus + /> </QCardActions + ></QCard> +</template> + +<style lang="scss" scoped> +.list { + max-height: 100%; + padding: 15px; + width: 100%; +} + +.grid-style-transition { + transition: + transform 0.28s, + background-color 0.28s; +} + +#true { + background-color: $positive; +} + +#false { + background-color: $negative; +} + +div.q-dialog__inner > div { + max-width: fit-content !important; +} +</style> diff --git a/src/pages/Ticket/Negative/components/ChangeQuantityDialog.vue b/src/pages/Ticket/Negative/components/ChangeQuantityDialog.vue new file mode 100644 index 000000000..2e9aac4f0 --- /dev/null +++ b/src/pages/Ticket/Negative/components/ChangeQuantityDialog.vue @@ -0,0 +1,84 @@ +<script setup> +import { ref } from 'vue'; +import axios from 'axios'; +import VnInput from 'src/components/common/VnInput.vue'; +import notifyResults from 'src/utils/notifyResults'; + +const showChangeQuantityDialog = ref(false); +const newQuantity = ref(null); +const $props = defineProps({ + selectedRows: { + type: Array, + default: () => [], + }, +}); +const emit = defineEmits(['update-quantity']); +const updateQuantity = async () => { + try { + showChangeQuantityDialog.value = true; + const rowsToUpdate = $props.selectedRows.map(({ saleFk }) => + axios.post(`Sales/${saleFk}/updateQuantity`, { + saleFk, + quantity: +newQuantity.value, + }), + ); + + const result = await Promise.allSettled(rowsToUpdate); + notifyResults(result, 'saleFk'); + + emit('update-quantity', newQuantity.value); + } catch (err) { + return err; + } +}; +</script> + +<template> + <QCard class="q-pa-sm"> + <QCardSection class="row items-center justify-center column items-stretch"> + <span>{{ $t('negative.detail.modal.changeQuantity.title') }}</span> + <VnInput + type="number" + :min="0" + :label="$t('negative.detail.modal.changeQuantity.placeholder')" + v-model="newQuantity" + /> + </QCardSection> + <QCardActions align="right"> + <QBtn :label="$t('globals.cancel')" color="primary" flat v-close-popup /> + <QBtn + :label="$t('globals.confirm')" + color="primary" + :disable="!newQuantity || newQuantity < 0" + @click="updateQuantity" + unelevated + autofocus + /> </QCardActions + ></QCard> +</template> + +<style lang="scss" scoped> +.list { + max-height: 100%; + padding: 15px; + width: 100%; +} + +.grid-style-transition { + transition: + transform 0.28s, + background-color 0.28s; +} + +#true { + background-color: $positive; +} + +#false { + background-color: $negative; +} + +div.q-dialog__inner > div { + max-width: fit-content !important; +} +</style> diff --git a/src/pages/Ticket/Negative/components/ChangeStateDialog.vue b/src/pages/Ticket/Negative/components/ChangeStateDialog.vue new file mode 100644 index 000000000..1acc7e0ef --- /dev/null +++ b/src/pages/Ticket/Negative/components/ChangeStateDialog.vue @@ -0,0 +1,91 @@ +<script setup> +import { ref } from 'vue'; +import axios from 'axios'; +import VnSelect from 'src/components/common/VnSelect.vue'; +import FetchData from 'components/FetchData.vue'; +import notifyResults from 'src/utils/notifyResults'; + +const emit = defineEmits(['update-state']); +const editableStates = ref([]); +const showChangeStateDialog = ref(false); +const newState = ref(null); +const $props = defineProps({ + selectedRows: { + type: Array, + default: () => [], + }, +}); +const updateState = async () => { + try { + showChangeStateDialog.value = true; + const rowsToUpdate = $props.selectedRows.map(({ id }) => + axios.post(`Tickets/state`, { + ticketFk: id, + code: newState.value, + }), + ); + const result = await Promise.allSettled(rowsToUpdate); + notifyResults(result, 'ticketFk'); + + emit('update-state', newState.value); + } catch (err) { + return err; + } +}; +</script> + +<template> + <FetchData + url="States/editableStates" + @on-fetch="(data) => (editableStates = data)" + auto-load + /> + <QCard class="q-pa-sm"> + <QCardSection class="row items-center justify-center column items-stretch"> + <span>{{ $t('negative.detail.modal.changeState.title') }}</span> + <VnSelect + :label="$t('negative.detail.modal.changeState.placeholder')" + v-model="newState" + :options="editableStates" + option-label="name" + option-value="code" + /> + </QCardSection> + <QCardActions align="right"> + <QBtn :label="$t('globals.cancel')" color="primary" flat v-close-popup /> + <QBtn + :label="$t('globals.confirm')" + color="primary" + :disable="!newState" + @click="updateState" + unelevated + autofocus + /> </QCardActions + ></QCard> +</template> + +<style lang="scss" scoped> +.list { + max-height: 100%; + padding: 15px; + width: 100%; +} + +.grid-style-transition { + transition: + transform 0.28s, + background-color 0.28s; +} + +#true { + background-color: $positive; +} + +#false { + background-color: $negative; +} + +div.q-dialog__inner > div { + max-width: fit-content !important; +} +</style> diff --git a/src/pages/Ticket/TicketList.vue b/src/pages/Ticket/TicketList.vue index 8df19c0d9..88878076d 100644 --- a/src/pages/Ticket/TicketList.vue +++ b/src/pages/Ticket/TicketList.vue @@ -232,7 +232,7 @@ const columns = computed(() => [ function resetAgenciesSelector(formData) { agenciesOptions.value = []; - if(formData) formData.agencyModeId = null; + if (formData) formData.agencyModeId = null; } function redirectToLines(id) { @@ -240,7 +240,7 @@ function redirectToLines(id) { window.open(url, '_blank'); } -const onClientSelected = async (formData) => { +const onClientSelected = async (formData) => { resetAgenciesSelector(formData); await fetchClient(formData); await fetchAddresses(formData); @@ -248,14 +248,12 @@ const onClientSelected = async (formData) => { const fetchAvailableAgencies = async (formData) => { resetAgenciesSelector(formData); - const response= await getAgencies(formData, selectedClient.value); + const response = await getAgencies(formData, selectedClient.value); if (!response) return; - - const { options, agency } = response - if(options) - agenciesOptions.value = options; - if(agency) - formData.agencyModeId = agency; + + const { options, agency } = response; + if (options) agenciesOptions.value = options; + if (agency) formData.agencyModeId = agency; }; const fetchClient = async (formData) => { @@ -330,7 +328,7 @@ function openBalanceDialog(ticket) { const description = ref([]); const firstTicketClientId = checkedTickets[0].clientFk; const isSameClient = checkedTickets.every( - (ticket) => ticket.clientFk === firstTicketClientId + (ticket) => ticket.clientFk === firstTicketClientId, ); if (!isSameClient) { @@ -369,7 +367,7 @@ async function onSubmit() { description: dialogData.value.value.description, clientFk: dialogData.value.value.clientFk, email: email[0].email, - } + }, ); if (data) notify('globals.dataSaved', 'positive'); @@ -388,32 +386,32 @@ function setReference(data) { switch (data) { case 1: newDescription = `${t( - 'ticketList.creditCard' + 'ticketList.creditCard', )}, ${dialogData.value.value.description.replace( /^(Credit Card, |Cash, |Transfers, )/, - '' + '', )}`; break; case 2: newDescription = `${t( - 'ticketList.cash' + 'ticketList.cash', )}, ${dialogData.value.value.description.replace( /^(Credit Card, |Cash, |Transfers, )/, - '' + '', )}`; break; case 3: newDescription = `${newDescription.replace( /^(Credit Card, |Cash, |Transfers, )/, - '' + '', )}`; break; case 4: newDescription = `${t( - 'ticketList.transfers' + 'ticketList.transfers', )}, ${dialogData.value.value.description.replace( /^(Credit Card, |Cash, |Transfers, )/, - '' + '', )}`; break; case 3317: diff --git a/src/pages/Ticket/locale/en.yml b/src/pages/Ticket/locale/en.yml index 50f669dee..f58508df8 100644 --- a/src/pages/Ticket/locale/en.yml +++ b/src/pages/Ticket/locale/en.yml @@ -23,6 +23,8 @@ ticketSale: hasComponentLack: Component lack ok: Ok more: More + transferLines: Transfer lines(no basket)/ Split + transferBasket: Some row selected is basket advanceTickets: preparation: Preparation origin: Origin @@ -189,7 +191,6 @@ ticketList: accountPayment: Account payment sendDocuware: Set delivered and send delivery note(s) to the tablet addPayment: Add payment - date: Date company: Company amount: Amount reference: Reference @@ -203,8 +204,6 @@ ticketList: creditCard: Credit card transfers: Transfers province: Province - warehouse: Warehouse - hour: Hour closure: Closure toLines: Go to lines addressNickname: Address nickname @@ -215,3 +214,79 @@ ticketList: notVisible: Not visible clientFrozen: Client frozen componentLack: Component lack +negative: + hour: Hour + id: Id Article + longName: Article + supplier: Supplier + colour: Colour + size: Size + origen: Origin + value: Negative + itemFk: Article + producer: Producer + warehouse: Warehouse + warehouseFk: Warehouse + category: Category + categoryFk: Family + type: Type + typeFk: Type + lack: Negative + inkFk: inkFk + timed: timed + date: Date + minTimed: minTimed + negativeAction: Negative + totalNegative: Total negatives + days: Days + buttonsUpdate: + item: Item + state: State + quantity: Quantity + modalOrigin: + title: Update negatives + question: Select a state to update + modalSplit: + title: Confirm split selected + question: Select a state to update + detail: + saleFk: Sale + itemFk: Article + ticketFk: Ticket + code: Code + nickname: Alias + name: Name + zoneName: Agency name + shipped: Date + theoreticalhour: Theoretical hour + agName: Agency + quantity: Quantity + alertLevelCode: Group state + state: State + peticionCompra: Ticket request + isRookie: Is rookie + turno: Turn line + isBasket: Basket + hasObservation: Has substitution + hasToIgnore: VIP + modal: + changeItem: + title: Update item reference + placeholder: New item + changeState: + title: Update tickets state + placeholder: New state + changeQuantity: + title: Update tickets quantity + placeholder: New quantity + split: + title: Are you sure you want to split selected tickets? + subTitle: Confirm split action + handleSplited: + title: Handle splited tickets + subTitle: Confirm date and agency + split: + ticket: Old ticket + newTicket: New ticket + status: Result + message: Message diff --git a/src/pages/Ticket/locale/es.yml b/src/pages/Ticket/locale/es.yml index aee0a90b7..673b99cad 100644 --- a/src/pages/Ticket/locale/es.yml +++ b/src/pages/Ticket/locale/es.yml @@ -128,6 +128,8 @@ ticketSale: ok: Ok more: Más address: Consignatario + transferLines: Transferir líneas(no cesta)/ Separar + transferBasket: No disponible para una cesta size: Medida ticketComponents: serie: Serie @@ -214,6 +216,81 @@ ticketList: toLines: Ir a lineas addressNickname: Alias consignatario ref: Referencia +negative: + hour: Hora + id: Id Articulo + longName: Articulo + supplier: Productor + colour: Color + size: Medida + origen: Origen + value: Negativo + warehouseFk: Almacen + producer: Producer + category: Categoría + categoryFk: Familia + typeFk: Familia + warehouse: Almacen + lack: Negativo + inkFk: Color + timed: Hora + date: Fecha + minTimed: Hora + type: Tipo + negativeAction: Negativo + totalNegative: Total negativos + days: Rango de dias + buttonsUpdate: + item: artículo + state: Estado + quantity: Cantidad + modalOrigin: + title: Actualizar negativos + question: Seleccione un estado para guardar + modalSplit: + title: Confirmar acción de split + question: Selecciona un estado + detail: + saleFk: Línea + itemFk: Artículo + ticketFk: Ticket + code: code + nickname: Alias + name: Nombre + zoneName: Agencia + shipped: F. envío + theoreticalhour: Hora teórica + agName: Agencia + quantity: Cantidad + alertLevelCode: Estado agrupado + state: Estado + peticionCompra: Petición compra + isRookie: Cliente nuevo + turno: Linea turno + isBasket: Cesta + hasObservation: Tiene sustitución + hasToIgnore: VIP + modal: + changeItem: + title: Actualizar referencia artículo + placeholder: Nuevo articulo + changeState: + title: Actualizar estado + placeholder: Nuevo estado + changeQuantity: + title: Actualizar cantidad + placeholder: Nueva cantidad + split: + title: ¿Seguro de separar los tickets seleccionados? + subTitle: Confirma separar tickets seleccionados + handleSplited: + title: Gestionar tickets spliteados + subTitle: Confir fecha y agencia + split: + ticket: Ticket viejo + newTicket: Ticket nuevo + status: Estado + message: Mensaje rounding: Redondeo noVerifiedData: Sin datos comprobados purchaseRequest: Petición de compra diff --git a/src/pages/Travel/Card/TravelDescriptor.vue b/src/pages/Travel/Card/TravelDescriptor.vue index 72acf91b8..922f89f33 100644 --- a/src/pages/Travel/Card/TravelDescriptor.vue +++ b/src/pages/Travel/Card/TravelDescriptor.vue @@ -32,7 +32,6 @@ const setData = (entity) => (data.value = useCardDescription(entity.ref, entity. <template> <CardDescriptor - module="Travel" :url="`Travels/${entityId}`" :title="data.title" :subtitle="data.subtitle" diff --git a/src/pages/Worker/Card/WorkerDescriptor.vue b/src/pages/Worker/Card/WorkerDescriptor.vue index ffebaf5ea..de3f634e2 100644 --- a/src/pages/Worker/Card/WorkerDescriptor.vue +++ b/src/pages/Worker/Card/WorkerDescriptor.vue @@ -10,7 +10,7 @@ import axios from 'axios'; import VnImg from 'src/components/ui/VnImg.vue'; import EditPictureForm from 'components/EditPictureForm.vue'; import WorkerDescriptorMenu from './WorkerDescriptorMenu.vue'; -import DepartmentDescriptorProxy from 'src/pages/Department/Card/DepartmentDescriptorProxy.vue'; +import DepartmentDescriptorProxy from 'src/pages/Worker/Department/Card/DepartmentDescriptorProxy.vue'; const $props = defineProps({ id: { @@ -50,9 +50,9 @@ const handlePhotoUpdated = (evt = false) => { <template> <CardDescriptor ref="cardDescriptorRef" - module="Worker" :data-key="dataKey" url="Workers/summary" + :filter="{ where: { id: entityId } }" title="user.nickname" @on-fetch="getIsExcluded" > diff --git a/src/pages/Worker/Card/WorkerDescriptorProxy.vue b/src/pages/Worker/Card/WorkerDescriptorProxy.vue index 43deb7821..a142570f9 100644 --- a/src/pages/Worker/Card/WorkerDescriptorProxy.vue +++ b/src/pages/Worker/Card/WorkerDescriptorProxy.vue @@ -12,11 +12,6 @@ const $props = defineProps({ <template> <QPopupProxy> - <WorkerDescriptor - v-if="$props.id" - :id="$props.id" - :summary="WorkerSummary" - data-key="workerDescriptorProxy" - /> + <WorkerDescriptor v-if="$props.id" :id="$props.id" :summary="WorkerSummary" /> </QPopupProxy> </template> diff --git a/src/pages/Worker/Card/WorkerFormation.vue b/src/pages/Worker/Card/WorkerFormation.vue index 6fd5a4eae..e05eca7f8 100644 --- a/src/pages/Worker/Card/WorkerFormation.vue +++ b/src/pages/Worker/Card/WorkerFormation.vue @@ -94,6 +94,7 @@ const columns = computed(() => [ align: 'left', name: 'hasDiploma', label: t('worker.formation.tableVisibleColumns.hasDiploma'), + component: 'checkbox', create: true, }, { diff --git a/src/pages/Worker/Card/WorkerSummary.vue b/src/pages/Worker/Card/WorkerSummary.vue index 992f6ec71..78c5dfd82 100644 --- a/src/pages/Worker/Card/WorkerSummary.vue +++ b/src/pages/Worker/Card/WorkerSummary.vue @@ -9,7 +9,7 @@ import CardSummary from 'components/ui/CardSummary.vue'; import VnUserLink from 'src/components/ui/VnUserLink.vue'; import VnTitle from 'src/components/common/VnTitle.vue'; import RoleDescriptorProxy from 'src/pages/Account/Role/Card/RoleDescriptorProxy.vue'; -import DepartmentDescriptorProxy from 'src/pages/Department/Card/DepartmentDescriptorProxy.vue'; +import DepartmentDescriptorProxy from 'src/pages/Worker/Department/Card/DepartmentDescriptorProxy.vue'; import { useAdvancedSummary } from 'src/composables/useAdvancedSummary'; import WorkerDescriptorMenu from './WorkerDescriptorMenu.vue'; diff --git a/src/pages/Department/Card/DepartmentBasicData.vue b/src/pages/Worker/Department/Card/DepartmentBasicData.vue similarity index 100% rename from src/pages/Department/Card/DepartmentBasicData.vue rename to src/pages/Worker/Department/Card/DepartmentBasicData.vue diff --git a/src/pages/Department/Card/DepartmentCard.vue b/src/pages/Worker/Department/Card/DepartmentCard.vue similarity index 77% rename from src/pages/Department/Card/DepartmentCard.vue rename to src/pages/Worker/Department/Card/DepartmentCard.vue index 64ea24d42..2e3f11521 100644 --- a/src/pages/Department/Card/DepartmentCard.vue +++ b/src/pages/Worker/Department/Card/DepartmentCard.vue @@ -1,6 +1,6 @@ <script setup> import VnCardBeta from 'components/common/VnCardBeta.vue'; -import DepartmentDescriptor from 'pages/Department/Card/DepartmentDescriptor.vue'; +import DepartmentDescriptor from 'pages/Worker/Department/Card/DepartmentDescriptor.vue'; </script> <template> <VnCardBeta diff --git a/src/pages/Department/Card/DepartmentDescriptor.vue b/src/pages/Worker/Department/Card/DepartmentDescriptor.vue similarity index 99% rename from src/pages/Department/Card/DepartmentDescriptor.vue rename to src/pages/Worker/Department/Card/DepartmentDescriptor.vue index ecd7fa36c..4b7dfd9b8 100644 --- a/src/pages/Department/Card/DepartmentDescriptor.vue +++ b/src/pages/Worker/Department/Card/DepartmentDescriptor.vue @@ -42,7 +42,6 @@ const { openConfirmationModal } = useVnConfirm(); <template> <CardDescriptor ref="DepartmentDescriptorRef" - module="Department" :url="`Departments/${entityId}`" :summary="$props.summary" :to-module="{ name: 'WorkerDepartment' }" diff --git a/src/pages/Department/Card/DepartmentDescriptorProxy.vue b/src/pages/Worker/Department/Card/DepartmentDescriptorProxy.vue similarity index 100% rename from src/pages/Department/Card/DepartmentDescriptorProxy.vue rename to src/pages/Worker/Department/Card/DepartmentDescriptorProxy.vue diff --git a/src/pages/Department/Card/DepartmentSummary.vue b/src/pages/Worker/Department/Card/DepartmentSummary.vue similarity index 100% rename from src/pages/Department/Card/DepartmentSummary.vue rename to src/pages/Worker/Department/Card/DepartmentSummary.vue diff --git a/src/pages/Department/Card/DepartmentSummaryDialog.vue b/src/pages/Worker/Department/Card/DepartmentSummaryDialog.vue similarity index 100% rename from src/pages/Department/Card/DepartmentSummaryDialog.vue rename to src/pages/Worker/Department/Card/DepartmentSummaryDialog.vue diff --git a/src/pages/Worker/WorkerDepartmentTree.vue b/src/pages/Worker/WorkerDepartmentTree.vue index 14009134b..9baf5ee57 100644 --- a/src/pages/Worker/WorkerDepartmentTree.vue +++ b/src/pages/Worker/WorkerDepartmentTree.vue @@ -3,7 +3,7 @@ import { onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useState } from 'src/composables/useState'; import { useQuasar } from 'quasar'; -import DepartmentDescriptorProxy from 'src/pages/Department/Card/DepartmentDescriptorProxy.vue'; +import DepartmentDescriptorProxy from 'src/pages/Worker/Department/Card/DepartmentDescriptorProxy.vue'; import CreateDepartmentChild from './CreateDepartmentChild.vue'; import axios from 'axios'; import { useRouter } from 'vue-router'; diff --git a/src/pages/Zone/Card/ZoneBasicData.vue b/src/pages/Zone/Card/ZoneBasicData.vue index 5206f1e62..15d335ac8 100644 --- a/src/pages/Zone/Card/ZoneBasicData.vue +++ b/src/pages/Zone/Card/ZoneBasicData.vue @@ -1,5 +1,7 @@ <script setup> import { useI18n } from 'vue-i18n'; +import { computed, ref } from 'vue'; +import FetchData from 'components/FetchData.vue'; import FormModel from 'src/components/FormModel.vue'; import VnRow from 'components/ui/VnRow.vue'; import VnInput from 'src/components/common/VnInput.vue'; @@ -7,10 +9,33 @@ import VnInputTime from 'src/components/common/VnInputTime.vue'; import VnSelect from 'src/components/common/VnSelect.vue'; const { t } = useI18n(); + +const agencyFilter = { + fields: ['id', 'name'], + order: 'name ASC', + limit: 30, +}; +const agencyOptions = ref([]); +const validAddresses = ref([]); + +const filterWhere = computed(() => ({ + id: { inq: validAddresses.value.map((item) => item.addressFk) }, +})); </script> <template> - <FormModel :url="`Zones/${$route.params.id}`" auto-load model="zone"> + <FetchData + :filter="agencyFilter" + @on-fetch="(data) => (agencyOptions = data)" + auto-load + url="AgencyModes/isActive" + /> + <FetchData + url="RoadmapAddresses" + auto-load + @on-fetch="(data) => (validAddresses = data)" + /> + <FormModel :url="`Zones/${route.params.id}`" auto-load model="zone"> <template #form="{ data, validate }"> <VnRow> <VnInput @@ -109,6 +134,8 @@ const { t } = useI18n(); hide-selected map-options :rules="validate('data.addressFk')" + :filter-options="['id']" + :where="filterWhere" /> </VnRow> <VnRow> diff --git a/src/pages/Zone/Card/ZoneDescriptor.vue b/src/pages/Zone/Card/ZoneDescriptor.vue index 49237a02b..27676212e 100644 --- a/src/pages/Zone/Card/ZoneDescriptor.vue +++ b/src/pages/Zone/Card/ZoneDescriptor.vue @@ -25,12 +25,7 @@ const entityId = computed(() => { </script> <template> - <CardDescriptor - module="Zone" - :url="`Zones/${entityId}`" - :filter="filter" - data-key="Zone" - > + <CardDescriptor :url="`Zones/${entityId}`" :filter="filter" data-key="Zone"> <template #menu="{ entity }"> <ZoneDescriptorMenuItems :zone="entity" /> </template> diff --git a/src/pages/Zone/Card/ZoneSearchbar.vue b/src/pages/Zone/Card/ZoneSearchbar.vue index f7a59e97f..d1188a1e8 100644 --- a/src/pages/Zone/Card/ZoneSearchbar.vue +++ b/src/pages/Zone/Card/ZoneSearchbar.vue @@ -22,15 +22,50 @@ const exprBuilder = (param, value) => { return /^\d+$/.test(value) ? { id: value } : { name: { like: `%${value}%` } }; } }; + +const tableFilter = { + include: [ + { + relation: 'agencyMode', + scope: { + fields: ['id', 'name'], + }, + }, + { + relation: 'address', + scope: { + fields: ['id', 'nickname', 'provinceFk', 'postalCode'], + include: [ + { + relation: 'province', + scope: { + fields: ['id', 'name'], + }, + }, + { + relation: 'postcode', + scope: { + fields: ['code', 'townFk'], + include: { + relation: 'town', + scope: { + fields: ['id', 'name'], + }, + }, + }, + }, + ], + }, + }, + ], +}; </script> <template> <VnSearchbar data-key="ZonesList" url="Zones" - :filter="{ - include: { relation: 'agencyMode', scope: { fields: ['name'] } }, - }" + :filter="tableFilter" :expr-builder="exprBuilder" :label="t('list.searchZone')" :info="t('list.searchInfo')" diff --git a/src/pages/Zone/ZoneList.vue b/src/pages/Zone/ZoneList.vue index e4a1774fe..1fa539c91 100644 --- a/src/pages/Zone/ZoneList.vue +++ b/src/pages/Zone/ZoneList.vue @@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'; import { computed, ref } from 'vue'; import axios from 'axios'; -import { toCurrency } from 'src/filters'; +import { dashIfEmpty, toCurrency } from 'src/filters'; import { toTimeFormat } from 'src/filters/date'; import { useVnConfirm } from 'composables/useVnConfirm'; import useNotify from 'src/composables/useNotify.js'; @@ -17,7 +17,6 @@ import VnInputTime from 'src/components/common/VnInputTime.vue'; import RightMenu from 'src/components/common/RightMenu.vue'; import ZoneFilterPanel from './ZoneFilterPanel.vue'; import ZoneSearchbar from './Card/ZoneSearchbar.vue'; -import FetchData from 'src/components/FetchData.vue'; const { t } = useI18n(); const router = useRouter(); @@ -26,7 +25,6 @@ const { viewSummary } = useSummaryDialog(); const { openConfirmationModal } = useVnConfirm(); const tableRef = ref(); const warehouseOptions = ref([]); -const validAddresses = ref([]); const tableFilter = { include: [ @@ -161,30 +159,18 @@ const handleClone = (id) => { openConfirmationModal( t('list.confirmCloneTitle'), t('list.confirmCloneSubtitle'), - () => clone(id) + () => clone(id), ); }; -function showValidAddresses(row) { - if (row.addressFk) { - const isValid = validAddresses.value.some( - (address) => address.addressFk === row.addressFk - ); - if (isValid) - return `${row.address?.nickname}, - ${row.address?.postcode?.town?.name} (${row.address?.province?.name})`; - else return '-'; - } - return '-'; +function formatRow(row) { + if (!row?.address) return '-'; + return dashIfEmpty(`${row?.address?.nickname}, + ${row?.address?.postcode?.town?.name} (${row?.address?.province?.name})`); } </script> <template> - <FetchData - url="RoadmapAddresses" - auto-load - @on-fetch="(data) => (validAddresses = data)" - /> <ZoneSearchbar /> <RightMenu> <template #right-panel> @@ -207,7 +193,7 @@ function showValidAddresses(row) { :right-search="false" > <template #column-addressFk="{ row }"> - {{ showValidAddresses(row) }} + {{ dashIfEmpty(formatRow(row)) }} </template> <template #more-create-dialog="{ data }"> <VnSelect diff --git a/src/router/index.js b/src/router/index.js index f0d9487c6..4403901cb 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -107,7 +107,10 @@ export default defineRouter(function (/* { store, ssrContext } */) { 'Failed to fetch dynamically imported module', 'Importing a module script failed', ]; - state.set('error', errorMessages.some(message.includes)); + state.set( + 'error', + errorMessages.some((error) => message.startsWith(error)), + ); }); return Router; }); diff --git a/src/router/modules/entry.js b/src/router/modules/entry.js index f362c7653..b5656dc5f 100644 --- a/src/router/modules/entry.js +++ b/src/router/modules/entry.js @@ -6,13 +6,7 @@ const entryCard = { component: () => import('src/pages/Entry/Card/EntryCard.vue'), redirect: { name: 'EntrySummary' }, meta: { - menu: [ - 'EntryBasicData', - 'EntryBuys', - 'EntryNotes', - 'EntryDms', - 'EntryLog', - ], + menu: ['EntryBasicData', 'EntryBuys', 'EntryNotes', 'EntryDms', 'EntryLog'], }, children: [ { @@ -91,7 +85,7 @@ export default { 'EntryLatestBuys', 'EntryStockBought', 'EntryWasteRecalc', - ] + ], }, component: RouterView, redirect: { name: 'EntryMain' }, @@ -103,7 +97,7 @@ export default { redirect: { name: 'EntryIndexMain' }, children: [ { - path:'', + path: '', name: 'EntryIndexMain', redirect: { name: 'EntryList' }, component: () => import('src/pages/Entry/EntryList.vue'), @@ -115,6 +109,7 @@ export default { title: 'list', icon: 'view_list', }, + component: () => import('src/pages/Entry/EntryList.vue'), }, entryCard, ], @@ -127,7 +122,7 @@ export default { icon: 'add', }, component: () => import('src/pages/Entry/EntryCreate.vue'), - }, + }, { path: 'my', name: 'MyEntries', @@ -167,4 +162,4 @@ export default { ], }, ], -}; \ No newline at end of file +}; diff --git a/src/router/modules/shelving.js b/src/router/modules/shelving.js index 55fb04278..c085dd8dc 100644 --- a/src/router/modules/shelving.js +++ b/src/router/modules/shelving.js @@ -3,7 +3,7 @@ import { RouterView } from 'vue-router'; const parkingCard = { name: 'ParkingCard', path: ':id', - component: () => import('src/pages/Parking/Card/ParkingCard.vue'), + component: () => import('src/pages/Shelving/Parking/Card/ParkingCard.vue'), redirect: { name: 'ParkingSummary' }, meta: { menu: ['ParkingBasicData', 'ParkingLog'], @@ -16,7 +16,7 @@ const parkingCard = { title: 'summary', icon: 'launch', }, - component: () => import('src/pages/Parking/Card/ParkingSummary.vue'), + component: () => import('src/pages/Shelving/Parking/Card/ParkingSummary.vue'), }, { path: 'basic-data', @@ -25,7 +25,8 @@ const parkingCard = { title: 'basicData', icon: 'vn:settings', }, - component: () => import('src/pages/Parking/Card/ParkingBasicData.vue'), + component: () => + import('src/pages/Shelving/Parking/Card/ParkingBasicData.vue'), }, { path: 'log', @@ -34,7 +35,7 @@ const parkingCard = { title: 'log', icon: 'history', }, - component: () => import('src/pages/Parking/Card/ParkingLog.vue'), + component: () => import('src/pages/Shelving/Parking/Card/ParkingLog.vue'), }, ], }; @@ -127,7 +128,7 @@ export default { title: 'parkingList', icon: 'view_list', }, - component: () => import('src/pages/Parking/ParkingList.vue'), + component: () => import('src/pages/Shelving/Parking/ParkingList.vue'), children: [ { path: 'list', diff --git a/src/router/modules/ticket.js b/src/router/modules/ticket.js index e5b423f64..bfcb78787 100644 --- a/src/router/modules/ticket.js +++ b/src/router/modules/ticket.js @@ -192,7 +192,13 @@ export default { icon: 'vn:ticket', moduleName: 'Ticket', keyBinding: 't', - menu: ['TicketList', 'TicketAdvance', 'TicketWeekly', 'TicketFuture'], + menu: [ + 'TicketList', + 'TicketAdvance', + 'TicketWeekly', + 'TicketFuture', + 'TicketNegative', + ], }, component: RouterView, redirect: { name: 'TicketMain' }, @@ -229,6 +235,32 @@ export default { }, component: () => import('src/pages/Ticket/TicketCreate.vue'), }, + { + path: 'negative', + redirect: { name: 'TicketNegative' }, + children: [ + { + name: 'TicketNegative', + meta: { + title: 'negative', + icon: 'exposure', + }, + component: () => + import('src/pages/Ticket/Negative/TicketLackList.vue'), + path: '', + }, + { + name: 'NegativeDetail', + path: ':id', + meta: { + title: 'summary', + icon: 'launch', + }, + component: () => + import('src/pages/Ticket/Negative/TicketLackDetail.vue'), + }, + ], + }, { path: 'weekly', name: 'TicketWeekly', diff --git a/src/router/modules/worker.js b/src/router/modules/worker.js index faaa23fc8..3eb95a96e 100644 --- a/src/router/modules/worker.js +++ b/src/router/modules/worker.js @@ -201,7 +201,7 @@ const workerCard = { const departmentCard = { name: 'DepartmentCard', path: ':id', - component: () => import('src/pages/Department/Card/DepartmentCard.vue'), + component: () => import('src/pages/Worker/Department/Card/DepartmentCard.vue'), redirect: { name: 'DepartmentSummary' }, meta: { moduleName: 'Department', @@ -215,7 +215,8 @@ const departmentCard = { title: 'summary', icon: 'launch', }, - component: () => import('src/pages/Department/Card/DepartmentSummary.vue'), + component: () => + import('src/pages/Worker/Department/Card/DepartmentSummary.vue'), }, { path: 'basic-data', @@ -224,7 +225,8 @@ const departmentCard = { title: 'basicData', icon: 'vn:settings', }, - component: () => import('src/pages/Department/Card/DepartmentBasicData.vue'), + component: () => + import('src/pages/Worker/Department/Card/DepartmentBasicData.vue'), }, ], }; diff --git a/src/utils/notifyResults.js b/src/utils/notifyResults.js new file mode 100644 index 000000000..e87ad6c6f --- /dev/null +++ b/src/utils/notifyResults.js @@ -0,0 +1,19 @@ +import { Notify } from 'quasar'; + +export default function (results, key) { + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + const data = JSON.parse(result.value.config.data); + Notify.create({ + type: 'positive', + message: `Operación (${index + 1}) ${data[key]} completada con éxito.`, + }); + } else { + const data = JSON.parse(result.reason.config.data); + Notify.create({ + type: 'negative', + message: `Operación (${index + 1}) ${data[key]} fallida: ${result.reason.message}`, + }); + } + }); +} diff --git a/test/cypress/integration/entry/entryList.spec.js b/test/cypress/integration/entry/entryList.spec.js new file mode 100644 index 000000000..5e2fa0c01 --- /dev/null +++ b/test/cypress/integration/entry/entryList.spec.js @@ -0,0 +1,226 @@ +describe('Entry', () => { + beforeEach(() => { + cy.viewport(1920, 1080); + cy.login('buyer'); + cy.visit(`/#/entry/list`); + }); + + it('Filter deleted entries and other fields', () => { + createEntry(); + cy.get('.q-notification__message').eq(0).should('have.text', 'Data created'); + cy.waitForElement('[data-cy="entry-buys"]'); + deleteEntry(); + cy.typeSearchbar('{enter}'); + cy.get('span[title="Date"]').click().click(); + cy.typeSearchbar('{enter}'); + cy.url().should('include', 'order'); + cy.get('td[data-row-index="0"][data-col-field="landed"]').should( + 'have.text', + '-', + ); + }); + + it('Create entry, modify travel and add buys', () => { + createEntryAndBuy(); + cy.get('a[data-cy="EntryBasicData-menu-item"]').click(); + selectTravel('two'); + cy.saveCard(); + cy.get('.q-notification__message').eq(0).should('have.text', 'Data created'); + deleteEntry(); + }); + + it('Clone entry and recalculate rates', () => { + createEntry(); + + cy.waitForElement('[data-cy="entry-buys"]'); + + cy.url().then((previousUrl) => { + cy.get('[data-cy="descriptor-more-opts"]').click(); + cy.get('div[data-cy="clone-entry"]').should('be.visible').click(); + + cy.get('.q-notification__message').eq(1).should('have.text', 'Entry cloned'); + + cy.url() + .should('not.eq', previousUrl) + .then(() => { + cy.waitForElement('[data-cy="entry-buys"]'); + + cy.get('[data-cy="descriptor-more-opts"]').click(); + cy.get('div[data-cy="recalculate-rates"]').click(); + + cy.get('.q-notification__message') + .eq(2) + .should('have.text', 'Entry prices recalculated'); + + cy.get('[data-cy="descriptor-more-opts"]').click(); + deleteEntry(); + + cy.log(previousUrl); + + cy.visit(previousUrl); + + cy.waitForElement('[data-cy="entry-buys"]'); + deleteEntry(); + }); + }); + }); + + it('Should notify when entry is lock by another user', () => { + const checkLockMessage = () => { + cy.get('[data-cy="entry-lock-confirm"]').should('be.visible'); + cy.get('[data-cy="VnConfirm_message"] > span').should( + 'contain.text', + 'This entry has been locked by buyerNick', + ); + }; + + createEntry(); + goToEntryBuys(); + cy.get('.q-notification__message') + .eq(1) + .should('have.text', 'The entry has been locked successfully'); + + cy.login('logistic'); + cy.reload(); + checkLockMessage(); + cy.get('[data-cy="VnConfirm_cancel"]').click(); + cy.url().should('include', 'summary'); + + goToEntryBuys(); + checkLockMessage(); + cy.get('[data-cy="VnConfirm_confirm"]').click(); + cy.url().should('include', 'buys'); + + deleteEntry(); + }); + + it('Edit buys and use toolbar actions', () => { + const COLORS = { + negative: 'rgb(251, 82, 82)', + positive: 'rgb(200, 228, 132)', + enabled: 'rgb(255, 255, 255)', + disable: 'rgb(168, 168, 168)', + }; + + const selectCell = (field, row = 0) => + cy.get(`td[data-col-field="${field}"][data-row-index="${row}"]`); + const selectSpan = (field, row = 0) => selectCell(field, row).find('div > span'); + const selectButton = (cySelector) => cy.get(`button[data-cy="${cySelector}"]`); + const clickAndType = (field, value, row = 0) => + selectCell(field, row).click().type(value); + const checkText = (field, expectedText, row = 0) => + selectCell(field, row).should('have.text', expectedText); + const checkColor = (field, expectedColor, row = 0) => + selectSpan(field, row).should('have.css', 'color', expectedColor); + + createEntryAndBuy(); + + selectCell('isIgnored') + .click() + .click() + .trigger('keydown', { key: 'Tab', keyCode: 9, which: 9 }); + checkText('isIgnored', 'check'); + checkColor('quantity', COLORS.negative); + + clickAndType('stickers', '1'); + checkText('quantity', '11'); + checkText('amount', '550.00'); + clickAndType('packing', '2'); + checkText('packing', '12close'); + checkText('weight', '12.0'); + checkText('quantity', '132'); + checkText('amount', '6600.00'); + checkColor('packing', COLORS.enabled); + + selectCell('groupingMode').click().click().click(); + checkColor('packing', COLORS.disable); + checkColor('grouping', COLORS.enabled); + + selectCell('buyingValue').click().clear().type('{backspace}{backspace}1'); + checkText('amount', '132.00'); + checkColor('minPrice', COLORS.disable); + + selectCell('hasMinPrice').click().click(); + checkColor('minPrice', COLORS.enabled); + selectCell('hasMinPrice').click(); + + cy.saveCard(); + cy.get('span[data-cy="footer-stickers"]').should('have.text', '11'); + cy.get('.q-notification__message').contains('Data saved'); + + selectButton('change-quantity-sign').should('be.disabled'); + selectButton('check-buy-amount').should('be.disabled'); + cy.get('tr.cursor-pointer > .q-table--col-auto-width > .q-checkbox').click(); + selectButton('change-quantity-sign').should('be.enabled'); + selectButton('check-buy-amount').should('be.enabled'); + + selectButton('change-quantity-sign').click(); + selectButton('set-negative-quantity').click(); + checkText('quantity', '-132'); + selectButton('set-positive-quantity').click(); + checkText('quantity', '132'); + checkColor('amount', COLORS.disable); + + selectButton('check-buy-amount').click(); + selectButton('uncheck-amount').click(); + checkColor('amount', COLORS.disable); + + selectButton('check-amount').click(); + checkColor('amount', COLORS.positive); + cy.saveCard(); + + cy.get('span[data-cy="footer-amount"]').should( + 'have.css', + 'color', + COLORS.positive, + ); + + deleteEntry(); + }); + + function goToEntryBuys() { + const entryBuySelector = 'a[data-cy="EntryBuys-menu-item"]'; + cy.get(entryBuySelector).should('be.visible'); + cy.waitForElement('[data-cy="entry-buys"]'); + cy.get(entryBuySelector).click(); + } + + function deleteEntry() { + cy.get('[data-cy="descriptor-more-opts"]').click(); + cy.waitForElement('div[data-cy="delete-entry"]'); + cy.get('div[data-cy="delete-entry"]').should('be.visible').click(); + cy.url().should('include', 'list'); + } + + function createEntryAndBuy() { + createEntry(); + createBuy(); + } + + function createEntry() { + cy.get('button[data-cy="vnTableCreateBtn"]').click(); + selectTravel('one'); + cy.get('button[data-cy="FormModelPopup_save"]').click(); + cy.url().should('include', 'summary'); + cy.get('.q-notification__message').eq(0).should('have.text', 'Data created'); + } + + function selectTravel(warehouse) { + cy.get('i[data-cy="Travel_icon"]').click(); + cy.get('input[data-cy="Warehouse Out_select"]').type(warehouse); + cy.get('div[role="listbox"] > div > div[role="option"]').eq(0).click(); + cy.get('button[data-cy="save-filter-travel-form"]').click(); + cy.get('tr').eq(1).click(); + } + + function createBuy() { + cy.get('a[data-cy="EntryBuys-menu-item"]').click(); + cy.get('a[data-cy="EntryBuys-menu-item"]').click(); + cy.get('button[data-cy="vnTableCreateBtn"]').click(); + + cy.get('input[data-cy="itemFk-create-popup"]').type('1'); + cy.get('div[role="listbox"] > div > div[role="option"]').eq(0).click(); + cy.get('input[data-cy="Grouping mode_select"]').should('have.value', 'packing'); + cy.get('button[data-cy="FormModelPopup_save"]').click(); + } +}); diff --git a/test/cypress/integration/entry/stockBought.spec.js b/test/cypress/integration/entry/stockBought.spec.js index 078ad19cc..d2d2b414d 100644 --- a/test/cypress/integration/entry/stockBought.spec.js +++ b/test/cypress/integration/entry/stockBought.spec.js @@ -6,6 +6,7 @@ describe('EntryStockBought', () => { }); it('Should edit the reserved space', () => { cy.get('.q-field__native.q-placeholder').should('have.value', '01/01/2001'); + cy.get('td[data-col-field="reserve"]').click(); cy.get('input[name="reserve"]').type('10{enter}'); cy.get('button[title="Save"]').click(); cy.get('.q-notification__message').should('have.text', 'Data saved'); @@ -26,7 +27,7 @@ describe('EntryStockBought', () => { cy.get(':nth-child(2) > .sticky > .q-btn > .q-btn__content > .q-icon').click(); cy.get('.q-table__bottom.row.items-center.q-table__bottom--nodata').should( 'have.text', - 'warningNo data available' + 'warningNo data available', ); }); it('Should edit travel m3 and refresh', () => { diff --git a/test/cypress/integration/invoiceIn/invoiceInBasicData.spec.js b/test/cypress/integration/invoiceIn/invoiceInBasicData.spec.js index 2016fca6d..11ca1bb59 100644 --- a/test/cypress/integration/invoiceIn/invoiceInBasicData.spec.js +++ b/test/cypress/integration/invoiceIn/invoiceInBasicData.spec.js @@ -1,9 +1,9 @@ /// <reference types="cypress" /> describe('InvoiceInBasicData', () => { - const formInputs = '.q-form > .q-card input'; const firstFormSelect = '.q-card > .vn-row:nth-child(1) > .q-select'; - const documentBtns = '[data-cy="dms-buttons"] button'; const dialogInputs = '.q-dialog input'; + const resetBtn = '.q-btn-group--push > .q-btn--flat'; + const getDocumentBtns = (opt) => `[data-cy="dms-buttons"] > :nth-child(${opt})`; beforeEach(() => { cy.login('developer'); @@ -11,13 +11,16 @@ describe('InvoiceInBasicData', () => { }); it('should edit the provideer and supplier ref', () => { - cy.selectOption(firstFormSelect, 'Bros'); - cy.get('[title="Reset"]').click(); - cy.get(formInputs).eq(1).type('{selectall}4739'); - cy.saveCard(); + cy.dataCy('UnDeductibleVatSelect').type('4751000000'); + cy.get('.q-menu .q-item').contains('4751000000').click(); + cy.get(resetBtn).click(); - cy.get(`${firstFormSelect} input`).invoke('val').should('eq', 'Plants nick'); - cy.get(formInputs).eq(1).invoke('val').should('eq', '4739'); + cy.waitForElement('#formModel').within(() => { + cy.dataCy('vnSupplierSelect').type('Bros nick'); + }) + cy.get('.q-menu .q-item').contains('Bros nick').click(); + cy.saveCard(); + cy.get(`${firstFormSelect} input`).invoke('val').should('eq', 'Bros nick'); }); it('should edit, remove and create the dms data', () => { @@ -25,18 +28,18 @@ describe('InvoiceInBasicData', () => { const secondInput = "I don't know what posting here!"; //edit - cy.get(documentBtns).eq(1).click(); + cy.get(getDocumentBtns(2)).click(); cy.get(dialogInputs).eq(0).type(`{selectall}${firtsInput}`); cy.get('textarea').type(`{selectall}${secondInput}`); cy.get('[data-cy="FormModelPopup_save"]').click(); - cy.get(documentBtns).eq(1).click(); + cy.get(getDocumentBtns(2)).click(); cy.get(dialogInputs).eq(0).invoke('val').should('eq', firtsInput); cy.get('textarea').invoke('val').should('eq', secondInput); cy.get('[data-cy="FormModelPopup_save"]').click(); cy.checkNotification('Data saved'); //remove - cy.get(documentBtns).eq(2).click(); + cy.get(getDocumentBtns(3)).click(); cy.get('[data-cy="VnConfirm_confirm"]').click(); cy.checkNotification('Data saved'); @@ -46,7 +49,7 @@ describe('InvoiceInBasicData', () => { 'test/cypress/fixtures/image.jpg', { force: true, - } + }, ); cy.get('[data-cy="FormModelPopup_save"]').click(); cy.checkNotification('Data saved'); diff --git a/test/cypress/integration/invoiceOut/invoiceOutNegativeBases.spec.js b/test/cypress/integration/invoiceOut/invoiceOutNegativeBases.spec.js index 5f629df0b..02b7fbb43 100644 --- a/test/cypress/integration/invoiceOut/invoiceOutNegativeBases.spec.js +++ b/test/cypress/integration/invoiceOut/invoiceOutNegativeBases.spec.js @@ -7,9 +7,7 @@ describe('InvoiceOut negative bases', () => { }); it('should filter and download as CSV', () => { - cy.get( - ':nth-child(7) > .full-width > :nth-child(1) > .column > div.q-px-xs > .q-field > .q-field__inner > .q-field__control' - ).type('23{enter}'); + cy.get('input[name="ticketFk"]').type('23{enter}'); cy.get('#subToolbar > .q-btn').click(); cy.checkNotification('CSV downloaded successfully'); }); diff --git a/test/cypress/integration/item/ItemProposal.spec.js b/test/cypress/integration/item/ItemProposal.spec.js new file mode 100644 index 000000000..b3ba9f676 --- /dev/null +++ b/test/cypress/integration/item/ItemProposal.spec.js @@ -0,0 +1,11 @@ +/// <reference types="cypress" /> +describe('ItemProposal', () => { + beforeEach(() => { + const ticketId = 1; + + cy.login('developer'); + cy.visit(`/#/ticket/${ticketId}/summary`); + }); + + describe('Handle item proposal selected', () => {}); +}); diff --git a/test/cypress/integration/item/itemTag.spec.js b/test/cypress/integration/item/itemTag.spec.js index 10d68d08a..d1596f693 100644 --- a/test/cypress/integration/item/itemTag.spec.js +++ b/test/cypress/integration/item/itemTag.spec.js @@ -13,7 +13,7 @@ describe('Item tag', () => { cy.dataCy('Tag_select').eq(7).type('Tallos'); cy.get('.q-menu .q-item').contains('Tallos').click(); cy.get(':nth-child(8) > [label="Value"]').type('1'); - +cy.dataCy('crudModelDefaultSaveBtn').click(); + cy.dataCy('crudModelDefaultSaveBtn').click(); cy.checkNotification("The tag or priority can't be repeated for an item"); }); @@ -26,8 +26,11 @@ describe('Item tag', () => { cy.get(':nth-child(8) > [label="Value"]').type('50'); cy.dataCy('crudModelDefaultSaveBtn').click(); cy.checkNotification('Data saved'); - cy.dataCy('itemTags').children(':nth-child(8)').find('.justify-center > .q-icon').click(); + cy.dataCy('itemTags') + .children(':nth-child(8)') + .find('.justify-center > .q-icon') + .click(); cy.dataCy('VnConfirm_confirm').click(); cy.checkNotification('Data saved'); }); -}); \ No newline at end of file +}); diff --git a/test/cypress/integration/route/routeList.spec.js b/test/cypress/integration/route/routeList.spec.js index 4da43ce8e..976ce7352 100644 --- a/test/cypress/integration/route/routeList.spec.js +++ b/test/cypress/integration/route/routeList.spec.js @@ -4,9 +4,6 @@ describe('Route', () => { cy.login('developer'); cy.visit(`/#/route/extended-list`); }); - const getVnSelect = - '> :nth-child(1) > .column > .q-field > .q-field__inner > .q-field__control > .q-field__control-container'; - const getRowColumn = (row, column) => `:nth-child(${row}) > :nth-child(${column})`; it('Route list create route', () => { cy.addBtnClick(); @@ -17,15 +14,23 @@ describe('Route', () => { it('Route list search and edit', () => { cy.get('#searchbar input').type('{enter}'); - cy.get('input[name="description"]').type('routeTestOne{enter}'); + cy.get('[data-col-field="description"][data-row-index="0"]') + .click() + .type('routeTestOne{enter}'); cy.get('.q-table tr') .its('length') .then((rowCount) => { expect(rowCount).to.be.greaterThan(0); }); - cy.get(getRowColumn(1, 3) + getVnSelect).type('{downArrow}{enter}'); - cy.get(getRowColumn(1, 4) + getVnSelect).type('{downArrow}{enter}'); - cy.get(getRowColumn(1, 5) + getVnSelect).type('{downArrow}{enter}'); + cy.get('[data-col-field="workerFk"][data-row-index="0"]') + .click() + .type('{downArrow}{enter}'); + cy.get('[data-col-field="agencyModeFk"][data-row-index="0"]') + .click() + .type('{downArrow}{enter}'); + cy.get('[data-col-field="vehicleFk"][data-row-index="0"]') + .click() + .type('{downArrow}{enter}'); cy.get('button[title="Save"]').click(); cy.get('.q-notification__message').should('have.text', 'Data saved'); }); diff --git a/test/cypress/integration/ticket/negative/TicketLackDetail.spec.js b/test/cypress/integration/ticket/negative/TicketLackDetail.spec.js new file mode 100644 index 000000000..9ea1cff63 --- /dev/null +++ b/test/cypress/integration/ticket/negative/TicketLackDetail.spec.js @@ -0,0 +1,147 @@ +/// <reference types="cypress" /> +describe('Ticket Lack detail', () => { + beforeEach(() => { + cy.login('developer'); + cy.intercept('GET', /\/api\/Tickets\/itemLack\/5.*$/, { + statusCode: 200, + body: [ + { + saleFk: 33, + code: 'OK', + ticketFk: 142, + nickname: 'Malibu Point', + shipped: '2000-12-31T23:00:00.000Z', + hour: 0, + quantity: 50, + agName: 'Super-Man delivery', + alertLevel: 0, + stateName: 'OK', + stateId: 3, + itemFk: 5, + price: 1.79, + alertLevelCode: 'FREE', + zoneFk: 9, + zoneName: 'Zone superMan', + theoreticalhour: '2011-11-01T22:59:00.000Z', + isRookie: 1, + turno: 1, + peticionCompra: 1, + hasObservation: 1, + hasToIgnore: 1, + isBasket: 1, + minTimed: 0, + customerId: 1104, + customerName: 'Tony Stark', + observationTypeCode: 'administrative', + }, + ], + }).as('getItemLack'); + + cy.visit('/#/ticket/negative/5'); + cy.wait('@getItemLack'); + }); + describe('Table actions', () => { + it.skip('should display only one row in the lack list', () => { + cy.location('href').should('contain', '#/ticket/negative/5'); + + cy.get('[data-cy="changeItem"]').should('be.disabled'); + cy.get('[data-cy="changeState"]').should('be.disabled'); + cy.get('[data-cy="changeQuantity"]').should('be.disabled'); + cy.get('[data-cy="itemProposal"]').should('be.disabled'); + cy.get('[data-cy="transferLines"]').should('be.disabled'); + cy.get('tr.cursor-pointer > :nth-child(1)').click(); + cy.get('[data-cy="changeItem"]').should('be.enabled'); + cy.get('[data-cy="changeState"]').should('be.enabled'); + cy.get('[data-cy="changeQuantity"]').should('be.enabled'); + cy.get('[data-cy="itemProposal"]').should('be.enabled'); + cy.get('[data-cy="transferLines"]').should('be.enabled'); + }); + }); + describe('Item proposal', () => { + beforeEach(() => { + cy.get('tr.cursor-pointer > :nth-child(1)').click(); + + cy.intercept('GET', /\/api\/Items\/getSimilar\?.*$/, { + statusCode: 200, + body: [ + { + id: 1, + longName: 'Ranged weapon longbow 50cm', + subName: 'Stark Industries', + tag5: 'Color', + value5: 'Brown', + match5: 0, + match6: 0, + match7: 0, + match8: 1, + tag6: 'Categoria', + value6: '+1 precission', + tag7: 'Tallos', + value7: '1', + tag8: null, + value8: null, + available: 20, + calc_id: 6, + counter: 0, + minQuantity: 1, + visible: null, + price2: 1, + }, + { + id: 2, + longName: 'Ranged weapon longbow 100cm', + subName: 'Stark Industries', + tag5: 'Color', + value5: 'Brown', + match5: 0, + match6: 1, + match7: 0, + match8: 1, + tag6: 'Categoria', + value6: '+1 precission', + tag7: 'Tallos', + value7: '1', + tag8: null, + value8: null, + available: 50, + calc_id: 6, + counter: 1, + minQuantity: 5, + visible: null, + price2: 10, + }, + { + id: 3, + longName: 'Ranged weapon longbow 200cm', + subName: 'Stark Industries', + tag5: 'Color', + value5: 'Brown', + match5: 1, + match6: 1, + match7: 1, + match8: 1, + tag6: 'Categoria', + value6: '+1 precission', + tag7: 'Tallos', + value7: '1', + tag8: null, + value8: null, + available: 185, + calc_id: 6, + counter: 10, + minQuantity: 10, + visible: null, + price2: 100, + }, + ], + }).as('getItemGetSimilar'); + cy.get('[data-cy="itemProposal"]').click(); + cy.wait('@getItemGetSimilar'); + }); + describe('Replace item if', () => { + it.only('Quantity is less than available', () => { + cy.get(':nth-child(1) > .text-right > .q-btn').click(); + }); + }); + }); +}); diff --git a/test/cypress/integration/ticket/negative/TicketLackList.spec.js b/test/cypress/integration/ticket/negative/TicketLackList.spec.js new file mode 100644 index 000000000..01ab4f621 --- /dev/null +++ b/test/cypress/integration/ticket/negative/TicketLackList.spec.js @@ -0,0 +1,36 @@ +/// <reference types="cypress" /> +describe('Ticket Lack list', () => { + beforeEach(() => { + cy.login('developer'); + cy.intercept('GET', /Tickets\/itemLack\?.*$/, { + statusCode: 200, + body: [ + { + itemFk: 5, + longName: 'Ranged weapon pistol 9mm', + warehouseFk: 1, + producer: null, + size: 15, + category: null, + warehouse: 'Warehouse One', + lack: -50, + inkFk: 'SLV', + timed: '2025-01-25T22:59:00.000Z', + minTimed: '23:59', + originFk: 'Holand', + }, + ], + }).as('getLack'); + + cy.visit('/#/ticket/negative'); + }); + + describe('Table actions', () => { + it('should display only one row in the lack list', () => { + cy.wait('@getLack', { timeout: 10000 }); + + cy.get('.q-virtual-scroll__content > :nth-child(1) > .sticky').click(); + cy.location('href').should('contain', '#/ticket/negative/5'); + }); + }); +}); diff --git a/test/cypress/integration/wagon/wagonType/wagonTypeCreate.spec.js b/test/cypress/integration/wagon/wagonType/wagonTypeCreate.spec.js index 343c1c127..2cd43984a 100644 --- a/test/cypress/integration/wagon/wagonType/wagonTypeCreate.spec.js +++ b/test/cypress/integration/wagon/wagonType/wagonTypeCreate.spec.js @@ -9,7 +9,7 @@ describe('WagonTypeCreate', () => { it('should create a new wagon type and then delete it', () => { cy.get('.q-page-sticky > div > .q-btn').click(); cy.get('input').first().type('Example for testing'); - cy.get('button[type="submit"]').click(); + cy.get('[data-cy="FormModelPopup_save"]').click(); cy.get('[title="Remove"] > .q-btn__content > .q-icon').first().click(); }); }); diff --git a/test/cypress/support/commands.js b/test/cypress/support/commands.js index 2c93fbf84..aa4a1219e 100755 --- a/test/cypress/support/commands.js +++ b/test/cypress/support/commands.js @@ -87,36 +87,55 @@ Cypress.Commands.add('getValue', (selector) => { }); // Fill Inputs -Cypress.Commands.add('selectOption', (selector, option, timeout = 5000) => { +Cypress.Commands.add('selectOption', (selector, option, timeout = 2500) => { cy.waitForElement(selector, timeout); - cy.get(selector).click(); - cy.get(selector).invoke('data', 'url').as('dataUrl'); - cy.get(selector) - .clear() - .type(option) - .then(() => { - cy.get('.q-menu', { timeout }) - .should('be.visible') // Asegurarse de que el menú está visible - .and('exist') // Verificar que el menú existe - .then(() => { - cy.get('@dataUrl').then((url) => { - if (url) { - // Esperar a que el menú no esté visible (desaparezca) - cy.get('.q-menu').should('not.be.visible'); - // Ahora esperar a que el menú vuelva a aparecer - cy.get('.q-menu').should('be.visible').and('exist'); - } - }); - }); - }); - // Finalmente, seleccionar la opción deseada - cy.get('.q-menu:visible') // Asegurarse de que estamos dentro del menú visible - .find('.q-item') // Encontrar los elementos de las opciones - .contains(option) // Verificar que existe una opción que contenga el texto deseado - .click(); // Hacer clic en la opción + cy.get(selector, { timeout }) + .should('exist') + .should('be.visible') + .click() + .then(($el) => { + cy.wrap($el.is('input') ? $el : $el.find('input')) + .invoke('attr', 'aria-controls') + .then((ariaControl) => selectItem(selector, option, ariaControl)); + }); }); +function selectItem(selector, option, ariaControl, hasWrite = true) { + if (!hasWrite) cy.wait(100); + + getItems(ariaControl).then((items) => { + const matchingItem = items + .toArray() + .find((item) => item.innerText.includes(option)); + if (matchingItem) return cy.wrap(matchingItem).click(); + + if (hasWrite) cy.get(selector).clear().type(option, { delay: 0 }); + return selectItem(selector, option, ariaControl, false); + }); +} + +function getItems(ariaControl, startTime = Cypress._.now(), timeout = 2500) { + // Se intenta obtener la lista de opciones del desplegable de manera recursiva + return cy + .get('#' + ariaControl, { timeout }) + .should('exist') + .find('.q-item') + .should('exist') + .then(($items) => { + if (!$items?.length || $items.first().text().trim() === '') { + if (Cypress._.now() - startTime > timeout) { + throw new Error( + `getItems: Tiempo de espera (${timeout}ms) excedido.`, + ); + } + return getItems(ariaControl, startTime, timeout); + } + + return cy.wrap($items); + }); +} + Cypress.Commands.add('countSelectOptions', (selector, option) => { cy.waitForElement(selector); cy.get(selector).click({ force: true }); diff --git a/test/cypress/support/waitUntil.js b/test/cypress/support/waitUntil.js index 5fb47a2d8..359f8643f 100644 --- a/test/cypress/support/waitUntil.js +++ b/test/cypress/support/waitUntil.js @@ -1,7 +1,7 @@ const waitUntil = (subject, checkFunction, originalOptions = {}) => { if (!(checkFunction instanceof Function)) { throw new Error( - '`checkFunction` parameter should be a function. Found: ' + checkFunction + '`checkFunction` parameter should be a function. Found: ' + checkFunction, ); }