0
0
Fork 0

Merge branch 'dev' into 6303-smsDialogFix

This commit is contained in:
Pablo Natek 2023-10-16 06:51:07 +00:00
commit 23747f5029
39 changed files with 1282 additions and 376 deletions

View File

@ -64,7 +64,7 @@ module.exports = {
}, },
overrides: [ overrides: [
{ {
files: ['test/cypress/**/*.spec.{js,ts}'], files: ['test/cypress/**/*.*'],
extends: [ extends: [
// Add Cypress-specific lint rules, globals and Cypress plugin // Add Cypress-specific lint rules, globals and Cypress plugin
// See https://github.com/cypress-io/eslint-plugin-cypress#rules // See https://github.com/cypress-io/eslint-plugin-cypress#rules

View File

@ -7,7 +7,7 @@ module.exports = defineConfig({
screenshotsFolder: 'test/cypress/screenshots', screenshotsFolder: 'test/cypress/screenshots',
supportFile: 'test/cypress/support/index.js', supportFile: 'test/cypress/support/index.js',
videosFolder: 'test/cypress/videos', videosFolder: 'test/cypress/videos',
video: true, video: false,
specPattern: 'test/cypress/integration/*.spec.js', specPattern: 'test/cypress/integration/*.spec.js',
experimentalRunAllSpecs: true, experimentalRunAllSpecs: true,
component: { component: {

2
package-lock.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "salix-front", "name": "salix-front",
"version": "23.36.01", "version": "23.40.01",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

View File

@ -1,6 +1,6 @@
{ {
"name": "salix-front", "name": "salix-front",
"version": "23.36.01", "version": "23.40.01",
"description": "Salix frontend", "description": "Salix frontend",
"productName": "Salix", "productName": "Salix",
"author": "Verdnatura", "author": "Verdnatura",
@ -9,7 +9,7 @@
"lint": "eslint --ext .js,.vue ./", "lint": "eslint --ext .js,.vue ./",
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore", "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test:e2e": "cypress open", "test:e2e": "cypress open",
"test:e2e:ci": "cypress run --browser chromium", "test:e2e:ci": "cd ../salix && gulp docker && cd ../salix-front && cypress run --browser chromium",
"test": "echo \"See package.json => scripts for available tests.\" && exit 0", "test": "echo \"See package.json => scripts for available tests.\" && exit 0",
"test:unit": "vitest", "test:unit": "vitest",
"test:unit:ci": "vitest run" "test:unit:ci": "vitest run"

View File

@ -46,7 +46,7 @@ const onResponseError = (error) => {
message = responseError.message; message = responseError.message;
} }
switch (response.status) { switch (response?.status) {
case 500: case 500:
message = 'errors.statusInternalServerError'; message = 'errors.statusInternalServerError';
break; break;
@ -58,7 +58,7 @@ const onResponseError = (error) => {
break; break;
} }
if (session.isLoggedIn() && response.status === 401) { if (session.isLoggedIn() && response?.status === 401) {
session.destroy(); session.destroy();
const hash = window.location.hash; const hash = window.location.hash;
const url = hash.slice(1); const url = hash.slice(1);

View File

@ -0,0 +1,324 @@
<script setup>
import axios from 'axios';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { useValidator } from 'src/composables/useValidator';
import { useStateStore } from 'stores/useStateStore';
import VnPaginate from 'components/ui/VnPaginate.vue';
import VnConfirm from 'components/ui/VnConfirm.vue';
import SkeletonTable from 'components/ui/SkeletonTable.vue';
import { tMobile } from 'src/composables/tMobile';
const quasar = useQuasar();
const stateStore = useStateStore();
const { t } = useI18n();
const { validate } = useValidator();
const $props = defineProps({
model: {
type: String,
default: '',
},
url: {
type: String,
default: '',
},
saveUrl: {
type: String,
default: null,
},
primaryKey: {
type: String,
default: 'id',
},
dataRequired: {
type: Object,
default: () => {},
},
defaultSave: {
type: Boolean,
default: true,
},
defaultReset: {
type: Boolean,
default: true,
},
defaultRemove: {
type: Boolean,
default: true,
},
selected: {
type: Object,
default: null,
},
saveFn: {
type: Function,
default: null,
},
});
const isLoading = ref(false);
const hasChanges = ref(false);
const originalData = ref();
const vnPaginateRef = ref();
const formData = ref();
const saveButtonRef = ref(null);
const formUrl = computed(() => $props.url);
const emit = defineEmits(['onFetch', 'update:selected', 'saveChanges']);
defineExpose({
reload,
insert,
remove,
onSubmit,
reset,
hasChanges,
saveChanges,
});
async function fetch(data) {
if (data && Array.isArray(data)) {
let $index = 0;
data.map((d) => (d.$index = $index++));
}
originalData.value = data && JSON.parse(JSON.stringify(data));
formData.value = data && JSON.parse(JSON.stringify(data));
watch(formData, () => (hasChanges.value = true), { deep: true });
emit('onFetch', data);
}
async function reset() {
await fetch(originalData.value);
hasChanges.value = false;
}
// eslint-disable-next-line vue/no-dupe-keys
function filter(value, update, filterOptions) {
update(
() => {
const { options, filterFn, field } = filterOptions;
options.value = filterFn(options, value, field);
},
(ref) => {
ref.setOptionIndex(-1);
ref.moveOptionSelection(1, true);
}
);
}
async function onSubmit() {
if (!hasChanges.value) {
return quasar.notify({
type: 'negative',
message: t('globals.noChanges'),
});
}
isLoading.value = true;
await saveChanges();
}
async function saveChanges(data) {
if ($props.saveFn) return $props.saveFn(data, getChanges);
const changes = data || getChanges();
try {
await axios.post($props.saveUrl || $props.url + '/crud', changes);
} catch (e) {
return (isLoading.value = false);
}
originalData.value = JSON.parse(JSON.stringify(formData.value));
if (changes.creates?.length) await vnPaginateRef.value.fetch();
hasChanges.value = false;
isLoading.value = false;
emit('saveChanges', data);
}
async function insert() {
const $index = formData.value.length
? formData.value[formData.value.length - 1].$index + 1
: 0;
formData.value.push(Object.assign({ $index }, $props.dataRequired));
hasChanges.value = true;
}
async function remove(data) {
if (!data.length)
return quasar.notify({
type: 'warning',
message: t('globals.noChanges'),
});
const pk = $props.primaryKey;
let ids = data.map((d) => d[pk]).filter(Boolean);
let preRemove = data.map((d) => (d[pk] ? null : d.$index)).filter(Boolean);
let newData = formData.value;
if (preRemove.length) {
newData = newData.filter(
(form) => !preRemove.some((index) => index == form.$index)
);
const changes = getChanges();
if (!changes.creates?.length && !changes.updates?.length)
hasChanges.value = false;
fetch(newData);
}
if (ids.length) {
quasar
.dialog({
component: VnConfirm,
componentProps: {
title: t('confirmDeletion'),
message: t('confirmDeletionMessage'),
newData,
ids,
},
})
.onOk(async () => {
await saveChanges({ deletes: ids });
newData = newData.filter((form) => !ids.some((id) => id == form[pk]));
fetch(newData);
});
}
emit('update:selected', []);
}
function getChanges() {
const updates = [];
const creates = [];
const pk = $props.primaryKey;
for (const [i, row] of formData.value.entries()) {
if (!row[pk]) {
creates.push(row);
} else if (originalData.value) {
const data = getDifferences(originalData.value[i], row);
if (!isEmpty(data)) {
updates.push({
data,
where: { [pk]: row[pk] },
});
}
}
}
const changes = { updates, creates };
for (let prop in changes) {
if (changes[prop].length === 0) changes[prop] = undefined;
}
return changes;
}
function getDifferences(obj1, obj2) {
let diff = {};
delete obj1.$index;
delete obj2.$index;
for (let key in obj1) {
if (obj2[key] && obj1[key] !== obj2[key]) {
diff[key] = obj2[key];
}
}
for (let key in obj2) {
if (obj1[key] === undefined || obj1[key] !== obj2[key]) {
diff[key] = obj2[key];
}
}
return diff;
}
function isEmpty(obj) {
if (obj == null) return true;
if (obj === undefined) return true;
if (Object.keys(obj).length === 0) return true;
if (obj.length > 0) return false;
}
async function reload() {
vnPaginateRef.value.fetch();
}
watch(formUrl, async () => {
originalData.value = null;
reset();
});
</script>
<template>
<VnPaginate
:url="url"
v-bind="$attrs"
@on-fetch="fetch"
:skeleton="false"
ref="vnPaginateRef"
>
<template #body v-if="formData">
<slot
name="body"
:rows="formData"
:validate="validate"
:filter="filter"
></slot>
</template>
</VnPaginate>
<SkeletonTable v-if="!formData" />
<Teleport to="#st-actions" v-if="stateStore?.isSubToolbarShown()">
<QBtnGroup push class="q-gutter-x-sm">
<slot name="moreBeforeActions" />
<QBtn
:label="tMobile('globals.remove')"
color="primary"
icon="delete"
flat
@click="remove(selected)"
:disable="!selected?.length"
:title="t('globals.remove')"
v-if="$props.defaultRemove"
/>
<QBtn
:label="tMobile('globals.reset')"
color="primary"
icon="restart_alt"
flat
@click="reset"
:disable="!hasChanges"
:title="t('globals.reset')"
v-if="$props.defaultReset"
/>
<QBtn
:label="tMobile('globals.save')"
ref="saveButtonRef"
color="primary"
icon="save"
@click="onSubmit"
:disable="!hasChanges"
:title="t('globals.save')"
v-if="$props.defaultSave"
/>
<slot name="moreAfterActions" />
</QBtnGroup>
</Teleport>
<QInnerLoading
:showing="isLoading"
:label="t && t('globals.pleaseWait')"
color="primary"
/>
</template>
<i18n>
{
"en": {
"confirmDeletion": "Confirm deletion",
"confirmDeletionMessage": "Are you sure you want to delete this?"
},
"es": {
"confirmDeletion": "Confirmar eliminación",
"confirmDeletionMessage": "Seguro que quieres eliminar?"
}
}
</i18n>

View File

@ -4,12 +4,14 @@ import { onMounted, onUnmounted, computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { useState } from 'src/composables/useState'; import { useState } from 'src/composables/useState';
import { useStateStore } from 'stores/useStateStore';
import { useValidator } from 'src/composables/useValidator'; import { useValidator } from 'src/composables/useValidator';
import SkeletonForm from 'components/ui/SkeletonForm.vue'; import SkeletonForm from 'components/ui/SkeletonForm.vue';
const quasar = useQuasar(); const quasar = useQuasar();
const { t } = useI18n();
const state = useState(); const state = useState();
const stateStore = useStateStore();
const { t } = useI18n();
const { validate } = useValidator(); const { validate } = useValidator();
const $props = defineProps({ const $props = defineProps({
@ -29,6 +31,10 @@ const $props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
defaultActions: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits(['onFetch']); const emit = defineEmits(['onFetch']);
@ -45,17 +51,21 @@ onUnmounted(() => {
const isLoading = ref(false); const isLoading = ref(false);
const hasChanges = ref(false); const hasChanges = ref(false);
const formData = computed(() => state.get($props.model));
const originalData = ref(); const originalData = ref();
const formData = computed(() => state.get($props.model));
const formUrl = computed(() => $props.url); const formUrl = computed(() => $props.url);
function tMobile(...args) {
if (!quasar.platform.is.mobile) return t(...args);
}
async function fetch() { async function fetch() {
const { data } = await axios.get($props.url, { const { data } = await axios.get($props.url, {
params: { filter: $props.filter }, params: { filter: $props.filter },
}); });
state.set($props.model, data); state.set($props.model, data);
originalData.value = Object.assign({}, data); originalData.value = data && JSON.parse(JSON.stringify(data));
watch(formData.value, () => (hasChanges.value = true)); watch(formData.value, () => (hasChanges.value = true));
@ -72,13 +82,18 @@ async function save() {
isLoading.value = true; isLoading.value = true;
await axios.patch($props.urlUpdate || $props.url, formData.value); await axios.patch($props.urlUpdate || $props.url, formData.value);
originalData.value = formData.value; originalData.value = JSON.parse(JSON.stringify(formData.value));
hasChanges.value = false; hasChanges.value = false;
isLoading.value = false; isLoading.value = false;
} }
function reset() { function reset() {
state.set($props.model, originalData.value); state.set($props.model, originalData.value);
originalData.value = JSON.parse(JSON.stringify(originalData.value));
watch(formData.value, () => (hasChanges.value = true));
emit('onFetch', state.get($props.model));
hasChanges.value = false; hasChanges.value = false;
} }
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
@ -109,20 +124,31 @@ watch(formUrl, async () => {
</QBanner> </QBanner>
<QForm v-if="formData" @submit="save" @reset="reset" class="q-pa-md"> <QForm v-if="formData" @submit="save" @reset="reset" class="q-pa-md">
<slot name="form" :data="formData" :validate="validate" :filter="filter"></slot> <slot name="form" :data="formData" :validate="validate" :filter="filter"></slot>
<div class="q-mt-lg">
<slot name="actions">
<QBtn :label="t('globals.save')" type="submit" color="primary" />
<QBtn
:label="t('globals.reset')"
type="reset"
class="q-ml-sm"
color="primary"
flat
:disable="!hasChanges"
/>
</slot>
</div>
</QForm> </QForm>
<Teleport to="#st-actions" v-if="stateStore?.isSubToolbarShown()">
<div v-if="$props.defaultActions">
<QBtnGroup push class="q-gutter-x-sm">
<slot name="moreActions" />
<QBtn
:label="tMobile('globals.reset')"
color="primary"
icon="restart_alt"
flat
@click="reset"
:disable="!hasChanges"
:title="t('globals.reset')"
/>
<QBtn
:label="tMobile('globals.save')"
color="primary"
icon="save"
@click="save"
:disable="!hasChanges"
:title="t('globals.save')"
/>
</QBtnGroup>
</div>
</Teleport>
<SkeletonForm v-if="!formData" /> <SkeletonForm v-if="!formData" />
<QInnerLoading <QInnerLoading
:showing="isLoading" :showing="isLoading"

View File

@ -0,0 +1,95 @@
<script setup>
import { ref, toRefs, watch, computed } from 'vue';
const emit = defineEmits(['update:modelValue', 'update:options']);
const $props = defineProps({
modelValue: {
type: [String, Number],
default: null,
},
options: {
type: Array,
default: () => [],
},
optionLabel: {
type: String,
default: '',
},
});
const { optionLabel, options } = toRefs($props);
const myOptions = ref([]);
const myOptionsOriginal = ref([]);
const vnSelectRef = ref(null);
function setOptions(data) {
myOptions.value = JSON.parse(JSON.stringify(data));
myOptionsOriginal.value = JSON.parse(JSON.stringify(data));
}
setOptions(options.value);
const filter = (val, options) => {
const search = val.toLowerCase();
if (val === '') return options;
return options.filter((row) => {
const id = row.id;
const name = row[$props.optionLabel].toLowerCase();
const idMatches = id == search;
const nameMatches = name.indexOf(search) > -1;
return idMatches || nameMatches;
});
};
const filterHandler = (val, update) => {
update(
() => {
myOptions.value = filter(val, myOptionsOriginal.value);
},
(ref) => {
if (val !== '' && ref.options.length > 0) {
ref.setOptionIndex(-1);
ref.moveOptionSelection(1, true);
}
}
);
};
watch(options, (newValue) => {
setOptions(newValue);
});
const value = computed({
get() {
return $props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
});
</script>
<template>
<QSelect
v-model="value"
:options="myOptions"
:option-label="optionLabel"
v-bind="$attrs"
emit-value
map-options
use-input
@filter="filterHandler"
hide-selected
fill-input
ref="vnSelectRef"
>
<template #append>
<QIcon name="close" @click.stop="value = null" class="cursor-pointer" />
</template>
<template v-for="(_, slotName) in $slots" #[slotName]="slotData">
<slot :name="slotName" v-bind="slotData" />
</template>
</QSelect>
</template>

View File

@ -29,12 +29,14 @@ const $props = defineProps({
const slots = useSlots(); const slots = useSlots();
const { t } = useI18n(); const { t } = useI18n();
const entity = ref();
onMounted(() => fetch()); onMounted(async () => {
await fetch();
});
const emit = defineEmits(['onFetch']); const emit = defineEmits(['onFetch']);
const entity = ref();
async function fetch() { async function fetch() {
const params = {}; const params = {};

View File

@ -0,0 +1,50 @@
<template>
<div class="q-pa-md w">
<div class="row q-gutter-md q-mb-md">
<div class="col-1">
<QSkeleton type="rect" square />
</div>
<div class="col">
<QSkeleton type="rect" square />
</div>
<div class="col">
<QSkeleton type="rect" square />
</div>
<div class="col">
<QSkeleton type="rect" square />
</div>
<div class="col">
<QSkeleton type="rect" square />
</div>
<div class="col">
<QSkeleton type="rect" square />
</div>
</div>
<div class="row q-gutter-md q-mb-md" v-for="n in 5" :key="n">
<div class="col-1">
<QSkeleton type="QInput" square />
</div>
<div class="col">
<QSkeleton type="QInput" square />
</div>
<div class="col">
<QSkeleton type="QInput" square />
</div>
<div class="col">
<QSkeleton type="QInput" square />
</div>
<div class="col">
<QSkeleton type="QInput" square />
</div>
<div class="col">
<QSkeleton type="QInput" square />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.w {
width: 80vw;
}
</style>

View File

@ -46,6 +46,10 @@ const props = defineProps({
type: Number, type: Number,
default: 500, default: 500,
}, },
skeleton: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits(['onFetch', 'onPaginate']); const emit = defineEmits(['onFetch', 'onPaginate']);
@ -137,14 +141,9 @@ async function onLoad(...params) {
</h5> </h5>
</div> </div>
<div <div
v-if="store.data && store.data.length === 0 && !isLoading" v-if="props.skeleton && props.autoLoad && !store.data"
class="info-row q-pa-md text-center" class="card-list q-gutter-y-md"
> >
<h5>
{{ t('No results found') }}
</h5>
</div>
<div v-if="props.autoLoad && !store.data" class="card-list q-gutter-y-md">
<QCard class="card" v-for="$index in $props.limit" :key="$index"> <QCard class="card" v-for="$index in $props.limit" :key="$index">
<QItem v-ripple class="q-pa-none items-start cursor-pointer q-hoverable"> <QItem v-ripple class="q-pa-none items-start cursor-pointer q-hoverable">
<QItemSection class="q-pa-md"> <QItemSection class="q-pa-md">
@ -164,7 +163,7 @@ async function onLoad(...params) {
</QCard> </QCard>
</div> </div>
</div> </div>
<QInfiniteScroll v-if="store.data" @load="onLoad" :offset="offset"> <QInfiniteScroll v-if="store.data" @load="onLoad" :offset="offset" class="full-width">
<slot name="body" :rows="store.data"></slot> <slot name="body" :rows="store.data"></slot>
<div v-if="isLoading" class="info-row q-pa-md text-center"> <div v-if="isLoading" class="info-row q-pa-md text-center">
<QSpinner color="orange" size="md" /> <QSpinner color="orange" size="md" />

View File

@ -0,0 +1,8 @@
import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
export function tMobile(...args) {
const quasar = useQuasar();
const { t } = useI18n();
if (!quasar.platform.is.mobile) return t(...args);
}

View File

@ -38,11 +38,11 @@ export function useArrayData(key, userOptions) {
'limit', 'limit',
'skip', 'skip',
'userParams', 'userParams',
'userFilter' 'userFilter',
]; ];
if (typeof userOptions === 'object') { if (typeof userOptions === 'object') {
for (const option in userOptions) { for (const option in userOptions) {
const isEmpty = userOptions[option] == null || userOptions[option] == '' const isEmpty = userOptions[option] == null || userOptions[option] == '';
if (isEmpty || !allowedOptions.includes(option)) continue; if (isEmpty || !allowedOptions.includes(option)) continue;
if (Object.prototype.hasOwnProperty.call(store, option)) { if (Object.prototype.hasOwnProperty.call(store, option)) {
@ -73,7 +73,7 @@ export function useArrayData(key, userOptions) {
Object.assign(params, store.userParams); Object.assign(params, store.userParams);
store.isLoading = true store.isLoading = true;
const response = await axios.get(store.url, { const response = await axios.get(store.url, {
signal: canceller.signal, signal: canceller.signal,
params, params,
@ -94,7 +94,7 @@ export function useArrayData(key, userOptions) {
updateStateParams(); updateStateParams();
} }
store.isLoading = false store.isLoading = false;
canceller = null; canceller = null;
} }
@ -153,8 +153,8 @@ export function useArrayData(key, userOptions) {
}); });
} }
const totalRows = computed(() => store.data && store.data.length || 0); const totalRows = computed(() => (store.data && store.data.length) || 0);
const isLoading = computed(() => store.isLoading || false) const isLoading = computed(() => store.isLoading || false);
return { return {
fetch, fetch,
@ -167,6 +167,6 @@ export function useArrayData(key, userOptions) {
hasMoreData, hasMoreData,
totalRows, totalRows,
updateStateParams, updateStateParams,
isLoading isLoading,
}; };
} }

View File

@ -3,15 +3,13 @@ import { useI18n } from 'vue-i18n';
import axios from 'axios'; import axios from 'axios';
import validator from 'validator'; import validator from 'validator';
const models = ref(null); const models = ref(null);
export function useValidator() { export function useValidator() {
if (!models.value) fetch(); if (!models.value) fetch();
function fetch() { function fetch() {
axios.get('Schemas/ModelInfo') axios.get('Schemas/ModelInfo').then((response) => (models.value = response.data));
.then(response => models.value = response.data)
} }
function validate(propertyRule) { function validate(propertyRule) {
@ -38,19 +36,18 @@ export function useValidator() {
const { t } = useI18n(); const { t } = useI18n();
const validations = function (validation) { const validations = function (validation) {
return { return {
presence: (value) => { presence: (value) => {
let message = `Value can't be empty`; let message = `Value can't be empty`;
if (validation.message) if (validation.message)
message = t(validation.message) || validation.message message = t(validation.message) || validation.message;
return !validator.isEmpty(value ? String(value) : '') || message return !validator.isEmpty(value ? String(value) : '') || message;
}, },
length: (value) => { length: (value) => {
const options = { const options = {
min: validation.min || validation.is, min: validation.min || validation.is,
max: validation.max || validation.is max: validation.max || validation.is,
}; };
value = String(value); value = String(value);
@ -69,14 +66,14 @@ export function useValidator() {
}, },
numericality: (value) => { numericality: (value) => {
if (validation.int) if (validation.int)
return validator.isInt(value) || 'Value should be integer' return validator.isInt(value) || 'Value should be integer';
return validator.isNumeric(value) || 'Value should be a number' return validator.isNumeric(value) || 'Value should be a number';
}, },
custom: (value) => validation.bindedFunction(value) || 'Invalid value' custom: (value) => validation.bindedFunction(value) || 'Invalid value',
}; };
}; };
return { return {
validate validate,
}; };
} }

View File

@ -32,10 +32,16 @@ body.body--light {
--vn-text: #000000; --vn-text: #000000;
--vn-gray: #f5f5f5; --vn-gray: #f5f5f5;
--vn-label: #5f5f5f; --vn-label: #5f5f5f;
--vn-dark: white;
} }
body.body--dark { body.body--dark {
--vn-text: #ffffff; --vn-text: #ffffff;
--vn-gray: #313131; --vn-gray: #313131;
--vn-label: #a8a8a8; --vn-label: #a8a8a8;
--vn-dark: #292929;
}
.bg-vn-dark {
background-color: var(--vn-dark);
} }

View File

@ -266,6 +266,7 @@ export default {
lines: 'Lines', lines: 'Lines',
rma: 'RMA', rma: 'RMA',
photos: 'Photos', photos: 'Photos',
development: 'Development',
log: 'Audit logs', log: 'Audit logs',
notes: 'Notes', notes: 'Notes',
}, },

View File

@ -264,6 +264,7 @@ export default {
basicData: 'Datos básicos', basicData: 'Datos básicos',
lines: 'Líneas', lines: 'Líneas',
rma: 'RMA', rma: 'RMA',
development: 'Trazabilidad',
photos: 'Fotos', photos: 'Fotos',
log: 'Registros de auditoría', log: 'Registros de auditoría',
notes: 'Notas', notes: 'Notas',

View File

@ -44,17 +44,6 @@ onMounted(async () => {
<LeftMenu source="card" /> <LeftMenu source="card" />
<QSeparator /> <QSeparator />
<QList> <QList>
<QItem
active-class="text-primary"
clickable
v-ripple
:href="`${salixUrl}/development`"
>
<QItemSection avatar>
<QIcon name="vn:traceability"></QIcon>
</QItemSection>
<QItemSection>{{ t('Development') }}</QItemSection>
</QItem>
<QItem <QItem
active-class="text-primary" active-class="text-primary"
clickable clickable
@ -68,8 +57,13 @@ onMounted(async () => {
</QScrollArea> </QScrollArea>
</QDrawer> </QDrawer>
<QPageContainer> <QPageContainer>
<QPage class="q-pa-md"> <QPage>
<RouterView></RouterView> <QToolbar class="bg-vn-dark justify-end">
<div id="st-data"></div>
<QSpace />
<div id="st-actions"></div>
</QToolbar>
<div class="q-pa-md"><RouterView></RouterView></div>
</QPage> </QPage>
</QPageContainer> </QPageContainer>
</template> </template>
@ -80,6 +74,5 @@ es:
You can search by claim id or customer name: Puedes buscar por id de la reclamación o nombre del cliente You can search by claim id or customer name: Puedes buscar por id de la reclamación o nombre del cliente
Details: Detalles Details: Detalles
Notes: Notas Notes: Notas
Development: Trazabilidad
Action: Acción Action: Acción
</i18n> </i18n>

View File

@ -3,6 +3,8 @@ import { ref, computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { toDate } from 'src/filters'; import { toDate } from 'src/filters';
import { useState } from 'src/composables/useState';
import TicketDescriptorProxy from 'pages/Ticket/Card/TicketDescriptorProxy.vue'; import TicketDescriptorProxy from 'pages/Ticket/Card/TicketDescriptorProxy.vue';
import WorkerDescriptorProxy from 'src/pages/Worker/Card/WorkerDescriptorProxy.vue'; import WorkerDescriptorProxy from 'src/pages/Worker/Card/WorkerDescriptorProxy.vue';
import ClaimDescriptorMenu from 'pages/Claim/Card/ClaimDescriptorMenu.vue'; import ClaimDescriptorMenu from 'pages/Claim/Card/ClaimDescriptorMenu.vue';
@ -19,6 +21,7 @@ const $props = defineProps({
}); });
const route = useRoute(); const route = useRoute();
const state = useState();
const { t } = useI18n(); const { t } = useI18n();
const entityId = computed(() => { const entityId = computed(() => {
@ -67,6 +70,7 @@ function stateColor(code) {
const data = ref(useCardDescription()); const data = ref(useCardDescription());
const setData = (entity) => { const setData = (entity) => {
data.value = useCardDescription(entity.client.name, entity.id); data.value = useCardDescription(entity.client.name, entity.id);
state.set('ClaimDescriptor', entity);
}; };
</script> </script>

View File

@ -0,0 +1,266 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import CrudModel from 'components/CrudModel.vue';
import FetchData from 'components/FetchData.vue';
import VnSelectFilter from 'components/common/VnSelectFilter.vue';
import { getUrl } from 'composables/getUrl';
import { tMobile } from 'composables/tMobile';
const route = useRoute();
const { t } = useI18n();
const claimDevelopmentForm = ref();
const claimReasons = ref([]);
const claimResults = ref([]);
const claimResponsibles = ref([]);
const claimRedeliveries = ref([]);
const workers = ref([]);
const selected = ref([]);
const insertButtonRef = ref();
let salixUrl;
onMounted(async () => {
salixUrl = await getUrl(`claim/${route.params.id}`);
});
const developmentsFilter = {
fields: [
'id',
'claimFk',
'claimReasonFk',
'claimResultFk',
'claimResponsibleFk',
'workerFk',
'claimRedeliveryFk',
],
where: {
claimFk: route.params.id,
},
};
const columns = computed(() => [
{
name: 'claimReason',
label: t('Reason'),
field: (row) => row.claimReasonFk,
sortable: true,
options: claimReasons.value,
required: true,
model: 'claimReasonFk',
optionValue: 'id',
optionLabel: 'description',
tabIndex: 1,
},
{
name: 'claimResult',
label: t('Result'),
field: (row) => row.claimResultFk,
sortable: true,
options: claimResults.value,
required: true,
model: 'claimResultFk',
optionValue: 'id',
optionLabel: 'description',
tabIndex: 2,
},
{
name: 'claimResponsible',
label: t('Responsible'),
field: (row) => row.claimResponsibleFk,
sortable: true,
options: claimResponsibles.value,
required: true,
model: 'claimResponsibleFk',
optionValue: 'id',
optionLabel: 'description',
tabIndex: 3,
},
{
name: 'worker',
label: t('Worker'),
field: (row) => row.workerFk,
sortable: true,
options: workers.value,
model: 'workerFk',
optionValue: 'id',
optionLabel: 'nickname',
tabIndex: 4,
},
{
name: 'claimRedelivery',
label: t('Redelivery'),
field: (row) => row.claimRedeliveryFk,
sortable: true,
options: claimRedeliveries.value,
required: true,
model: 'claimRedeliveryFk',
optionValue: 'id',
optionLabel: 'description',
tabIndex: 5,
},
]);
function goToAction() {
location.href = `${salixUrl}/action`;
}
</script>
<template>
<FetchData
url="ClaimReasons"
order="description"
@on-fetch="(data) => (claimReasons = data)"
auto-load
/>
<FetchData
url="ClaimResults"
order="description"
@on-fetch="(data) => (claimResults = data)"
auto-load
/>
<FetchData
url="ClaimResponsibles"
order="description"
@on-fetch="(data) => (claimResponsibles = data)"
auto-load
/>
<FetchData
url="ClaimRedeliveries"
order="description"
@on-fetch="(data) => (claimRedeliveries = data)"
auto-load
/>
<FetchData
url="Workers/search"
:where="{ active: 1 }"
order="name ASC"
@on-fetch="(data) => (workers = data)"
auto-load
/>
<CrudModel
data-key="ClaimDevelopments"
url="ClaimDevelopments"
model="claimDevelopment"
:filter="developmentsFilter"
ref="claimDevelopmentForm"
:data-required="{ claimFk: route.params.id }"
v-model:selected="selected"
auto-load
@save-changes="goToAction"
:default-save="false"
>
<template #body="{ rows }">
<QTable
:columns="columns"
:rows="rows"
:pagination="{ rowsPerPage: 0 }"
row-key="$index"
selection="multiple"
hide-pagination
v-model:selected="selected"
:grid="$q.screen.lt.md"
>
<template #body-cell="{ row, col }">
<QTd
auto-width
@keyup.ctrl.enter.stop="claimDevelopmentForm.saveChanges()"
>
<VnSelectFilter
:label="col.label"
v-model="row[col.model]"
:options="col.options"
:option-value="col.optionValue"
:option-label="col.optionLabel"
:autofocus="col.tabIndex == 1"
input-debounce="0"
>
<template #option="scope" v-if="col.name == 'worker'">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{ scope.opt?.name }}</QItemLabel>
<QItemLabel caption>
{{ scope.opt?.nickname }}
{{ scope.opt?.code }}
</QItemLabel>
</QItemSection>
</QItem>
</template>
</VnSelectFilter>
</QTd>
</template>
<template #item="props">
<div class="q-pa-xs col-xs-12 col-sm-6 grid-style-transition">
<QCard
bordered
flat
@keyup.ctrl.enter.stop="claimDevelopmentForm?.saveChanges()"
>
<QCardSection>
<QCheckbox v-model="props.selected" dense />
</QCardSection>
<QSeparator />
<QList dense>
<QItem v-for="col in props.cols" :key="col.name">
<QItemSection>
<VnSelectFilter
:label="col.label"
v-model="props.row[col.model]"
:options="col.options"
:option-value="col.optionValue"
:option-label="col.optionLabel"
dense
input-debounce="0"
:autofocus="col.tabIndex == 1"
/>
</QItemSection>
</QItem>
</QList>
</QCard>
</div>
</template>
</QTable>
</template>
<template #moreAfterActions>
<QBtn
:label="tMobile('globals.save')"
ref="saveButtonRef"
color="primary"
icon="save"
:disable="!claimDevelopmentForm?.hasChanges"
@click="claimDevelopmentForm?.onSubmit"
:title="t('globals.save')"
/>
</template>
</CrudModel>
<QPageSticky position="bottom-right" :offset="[25, 25]">
<QBtn
ref="insertButtonRef"
fab
color="primary"
icon="add"
@click="claimDevelopmentForm.insert()"
@keydown.ctrl.enter.stop="claimDevelopmentForm.saveChanges()"
@keydown.enter.stop
/>
</QPageSticky>
</template>
<style lang="scss" scoped>
.grid-style-transition {
transition: transform 0.28s, background-color 0.28s;
}
.maxwidth {
width: 100%;
}
</style>
<i18n>
es:
Reason: Motivo
Result: Consecuencia
Responsible: Responsable
Worker: Trabajador
Redelivery: Devolución
</i18n>

View File

@ -6,9 +6,8 @@ import { useQuasar } from 'quasar';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useArrayData } from 'composables/useArrayData'; import { useArrayData } from 'composables/useArrayData';
import { useStateStore } from 'stores/useStateStore'; import { useStateStore } from 'stores/useStateStore';
import VnPaginate from 'components/ui/VnPaginate.vue'; import CrudModel from 'components/CrudModel.vue';
import FetchData from 'components/FetchData.vue'; import FetchData from 'components/FetchData.vue';
import VnConfirm from 'components/ui/VnConfirm.vue';
import { toDate, toCurrency, toPercentage } from 'filters/index'; import { toDate, toCurrency, toPercentage } from 'filters/index';
import VnDiscount from 'components/common/vnDiscount.vue'; import VnDiscount from 'components/common/vnDiscount.vue';
@ -17,6 +16,7 @@ import ClaimLinesImport from './ClaimLinesImport.vue';
const quasar = useQuasar(); const quasar = useQuasar();
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const stateStore = useStateStore(); const stateStore = useStateStore();
const arrayData = useArrayData('ClaimLines'); const arrayData = useArrayData('ClaimLines');
const store = arrayData.store; const store = arrayData.store;
@ -36,16 +36,17 @@ const linesFilter = {
}, },
}; };
const claimLinesForm = ref();
const claim = ref(null); const claim = ref(null);
async function onFetchClaim(data) { async function onFetchClaim(data) {
claim.value = data; claim.value = data;
fetchMana(); fetchMana();
} }
const amount = ref(0); const amount = ref(0);
const amountClaimed = ref(0); const amountClaimed = ref(0);
async function onFetch(rows) { async function onFetch(rows) {
if (!rows || rows.length) return;
amount.value = rows.reduce( amount.value = rows.reduce(
(acumulator, { sale }) => acumulator + sale.price * sale.quantity, (acumulator, { sale }) => acumulator + sale.price * sale.quantity,
0 0
@ -141,60 +142,20 @@ function onUpdateDiscount(response) {
}); });
} }
async function confirmRemove() {
const rows = selected.value;
const count = rows.length;
if (count === 0) {
return quasar.notify({
message: 'You must select at least one row',
type: 'warning',
});
}
quasar
.dialog({
component: VnConfirm,
componentProps: {
title: t('Delete claimed sales'),
message: t('You are about to remove {count} rows', count, { count }),
data: { rows },
promise: remove,
},
})
.onOk(() => {
for (const row of rows) {
const orgData = store.data;
const index = orgData.findIndex((item) => item.id === row.id);
store.data.splice(index, 1);
selected.value = [];
}
});
}
async function remove({ rows }) {
if (!rows.length) return;
const body = { deletes: rows.map((row) => row.id) };
await axios.post(`ClaimBeginnings/crud`, body);
quasar.notify({
type: 'positive',
message: t('globals.rowRemoved'),
});
}
function showImportDialog() { function showImportDialog() {
quasar quasar
.dialog({ .dialog({
component: ClaimLinesImport, component: ClaimLinesImport,
componentProps: {
ticketId: claim.value.ticketFk,
},
}) })
.onOk(() => arrayData.refresh()); .onOk(() => claimLinesForm.value.reload());
} }
</script> </script>
<template> <template>
<QPageSticky position="top" :offset="[0, 0]" expand> <Teleport to="#st-data" v-if="stateStore.isSubToolbarShown()">
<QToolbar class="bg-dark text-white"> <QToolbar class="bg-dark text-white">
<QToolbarTitle> {{ t('Claimed lines') }} </QToolbarTitle>
<QSpace />
<div class="row q-gutter-md"> <div class="row q-gutter-md">
<div> <div>
{{ t('Amount') }} {{ t('Amount') }}
@ -211,7 +172,7 @@ function showImportDialog() {
</div> </div>
</div> </div>
</QToolbar> </QToolbar>
</QPageSticky> </Teleport>
<FetchData <FetchData
:url="`Claims/${route.params.id}`" :url="`Claims/${route.params.id}`"
@ -221,11 +182,16 @@ function showImportDialog() {
/> />
<div class="column items-center"> <div class="column items-center">
<div class="list"> <div class="list">
<VnPaginate <CrudModel
data-key="ClaimLines" data-key="ClaimLines"
ref="claimLinesForm"
:url="`Claims/${route.params.id}/lines`" :url="`Claims/${route.params.id}/lines`"
save-url="ClaimBeginnings/crud"
:filter="linesFilter" :filter="linesFilter"
@on-fetch="onFetch" @on-fetch="onFetch"
v-model:selected="selected"
:default-save="false"
:default-reset="false"
auto-load auto-load
> >
<template #body="{ rows }"> <template #body="{ rows }">
@ -361,46 +327,12 @@ function showImportDialog() {
</template> </template>
</QTable> </QTable>
</template> </template>
</VnPaginate> </CrudModel>
</div> </div>
</div> </div>
<Teleport <QPageSticky position="bottom-right" :offset="[25, 25]">
v-if="stateStore.isHeaderMounted() && !$q.screen.lt.sm" <QBtn fab color="primary" icon="add" @click="showImportDialog()" />
to="#actions-prepend"
>
<div class="row q-gutter-x-sm">
<QBtn
v-if="selected.length > 0"
@click="confirmRemove"
icon="delete"
color="primary"
flat
dense
rounded
>
<QTooltip bottom> {{ t('globals.remove') }} </QTooltip>
</QBtn>
<QBtn @click="showImportDialog" icon="add" color="primary" flat dense rounded>
<QTooltip bottom> {{ t('globals.add') }} </QTooltip>
</QBtn>
<QSeparator vertical />
</div>
</Teleport>
<!-- v-if="quasar.platform.is.mobile" -->
<QPageSticky v-if="$q.screen.lt.sm" position="bottom" :offset="[0, 0]" expand>
<QToolbar class="bg-primary text-white q-pa-none">
<QTabs class="full-width" align="justify" inline-label narrow-indicator>
<QTab @click="showImportDialog" icon="add" :label="t('globals.add')" />
<QSeparator vertical inset />
<QTab
@click="confirmRemove"
icon="delete"
:label="t('globals.remove')"
:disable="selected.length === 0"
/>
</QTabs>
</QToolbar>
</QPageSticky> </QPageSticky>
</template> </template>
@ -421,7 +353,6 @@ en:
You are about to remove <strong>{count}</strong> row | You are about to remove <strong>{count}</strong> row |
You are about to remove <strong>{count}</strong> rows' You are about to remove <strong>{count}</strong> rows'
es: es:
Claimed lines: Líneas reclamadas
Delivered: Entregado Delivered: Entregado
Quantity: Cantidad Quantity: Cantidad
Claimed: Reclamada Claimed: Reclamada

View File

@ -14,6 +14,13 @@ const route = useRoute();
const quasar = useQuasar(); const quasar = useQuasar();
const { t } = useI18n(); const { t } = useI18n();
const $props = defineProps({
ticketId: {
type: Number,
required: true,
},
});
const columns = computed(() => [ const columns = computed(() => [
{ {
name: 'delivered', name: 'delivered',
@ -99,7 +106,7 @@ function cancel() {
</script> </script>
<template> <template>
<FetchData <FetchData
url="Sales/getClaimableFromTicket?ticketFk=16" :url="`Sales/getClaimableFromTicket?ticketFk=${$props.ticketId}`"
@on-fetch="(data) => (claimableSales = data)" @on-fetch="(data) => (claimableSales = data)"
auto-load auto-load
/> />

View File

@ -25,7 +25,7 @@ const body = {
}; };
</script> </script>
<template> <template>
<div class="col items-center"> <div class="column items-center">
<VnNotes <VnNotes
:add-note="true" :add-note="true"
:id="id" :id="id"

View File

@ -4,7 +4,6 @@ import { ref, computed } from 'vue';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStateStore } from 'stores/useStateStore';
import { useSession } from 'composables/useSession'; import { useSession } from 'composables/useSession';
import VnConfirm from 'components/ui/VnConfirm.vue'; import VnConfirm from 'components/ui/VnConfirm.vue';
import FetchData from 'components/FetchData.vue'; import FetchData from 'components/FetchData.vue';
@ -12,7 +11,6 @@ import FetchData from 'components/FetchData.vue';
const router = useRouter(); const router = useRouter();
const quasar = useQuasar(); const quasar = useQuasar();
const { t } = useI18n(); const { t } = useI18n();
const stateStore = useStateStore();
const session = useSession(); const session = useSession();
const token = session.getToken(); const token = session.getToken();
@ -237,19 +235,9 @@ function onDrag() {
</div> </div>
</div> </div>
<Teleport <QPageSticky position="bottom-right" :offset="[25, 25]">
v-if="stateStore.isHeaderMounted() && !quasar.platform.is.mobile"
to="#actions-prepend"
>
<div class="row q-gutter-x-sm">
<label for="fileInput"> <label for="fileInput">
<QBtn <QBtn fab @click="inputFile.nativeEl.click()" icon="add" color="primary">
@click="inputFile.nativeEl.click()"
icon="add"
color="primary"
dense
rounded
>
<QInput <QInput
ref="inputFile" ref="inputFile"
type="file" type="file"
@ -261,35 +249,6 @@ function onDrag() {
<QTooltip bottom> {{ t('globals.add') }} </QTooltip> <QTooltip bottom> {{ t('globals.add') }} </QTooltip>
</QBtn> </QBtn>
</label> </label>
<QSeparator vertical />
</div>
</Teleport>
<QPageSticky
v-if="quasar.platform.is.mobile"
position="bottom"
:offset="[0, 0]"
expand
>
<QToolbar class="bg-primary text-white q-pa-none">
<QTabs class="full-width" align="justify" inline-label narrow-indicator>
<QTab
@click="inputFile.nativeEl.click()"
icon="add_circle"
:label="t('globals.add')"
>
<QInput
ref="inputFile"
type="file"
style="display: none"
multiple
v-model="files"
@update:model-value="create()"
/>
<QTooltip bottom> {{ t('globals.add') }} </QTooltip>
</QTab>
</QTabs>
</QToolbar>
</QPageSticky> </QPageSticky>
<!-- MULTIMEDIA DIALOG START--> <!-- MULTIMEDIA DIALOG START-->

View File

@ -1,32 +1,21 @@
<script setup> <script setup>
import axios from 'axios'; import axios from 'axios';
import { ref } from 'vue'; import { watch, ref, computed, onUnmounted, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { useRoute } from 'vue-router'; import CrudModel from 'components/CrudModel.vue';
import { useArrayData } from 'src/composables/useArrayData'; import { useState } from 'src/composables/useState';
import { useStateStore } from 'stores/useStateStore';
import VnPaginate from 'src/components/ui/VnPaginate.vue';
import FetchData from 'components/FetchData.vue';
import VnConfirm from 'src/components/ui/VnConfirm.vue';
import { toDate } from 'src/filters'; import { toDate } from 'src/filters';
const quasar = useQuasar(); const quasar = useQuasar();
const route = useRoute(); const state = useState();
const { t } = useI18n(); const { t } = useI18n();
const stateStore = useStateStore(); const selected = ref([]);
const arrayData = useArrayData('ClaimRma'); const claimRmaRef = ref();
const claim = computed(() => state.get('ClaimDescriptor'));
const claim = ref(); const claimRmaFilter = {
const claimFilter = {
fields: ['rma'],
};
async function onFetch(data) {
claim.value = data;
const filter = {
include: { include: {
relation: 'worker', relation: 'worker',
scope: { scope: {
@ -37,13 +26,10 @@ async function onFetch(data) {
}, },
order: 'created DESC', order: 'created DESC',
where: { where: {
code: claim.value.rma, code: claim.value?.rma,
}, },
}; };
arrayData.applyFilter({ filter });
}
async function addRow() { async function addRow() {
if (!claim.value.rma) { if (!claim.value.rma) {
return quasar.notify({ return quasar.notify({
@ -56,7 +42,7 @@ async function addRow() {
}; };
await axios.post(`ClaimRmas`, formData); await axios.post(`ClaimRmas`, formData);
await arrayData.refresh(); await claimRmaRef.value.reload();
quasar.notify({ quasar.notify({
type: 'positive', type: 'positive',
@ -65,38 +51,33 @@ async function addRow() {
}); });
} }
function confirmRemove(id) { onMounted(() => {
quasar if (claim.value) claimRmaRef.value.reload();
.dialog({
component: VnConfirm,
componentProps: {
data: { id },
promise: remove,
},
})
.onOk(async () => await arrayData.refresh());
}
async function remove({ id }) {
await axios.delete(`ClaimRmas/${id}`);
quasar.notify({
type: 'positive',
message: t('globals.rowRemoved'),
}); });
} watch(
claim,
() => {
claimRmaRef.value.reload();
},
{ deep: true }
);
</script> </script>
<template> <template>
<FetchData
:url="`Claims/${route.params.id}`"
:filter="claimFilter"
@on-fetch="onFetch"
auto-load
/>
<div class="column items-center"> <div class="column items-center">
<div class="list"> <div class="list">
<VnPaginate data-key="ClaimRma" url="ClaimRmas"> <CrudModel
data-key="ClaimRma"
url="ClaimRmas"
model="ClaimRma"
:filter="claimRmaFilter"
v-model:selected="selected"
ref="claimRmaRef"
:default-save="false"
:default-reset="false"
:default-remove="false"
>
<template #body="{ rows }"> <template #body="{ rows }">
<QCard class="card"> <QCard>
<template v-for="(row, index) of rows" :key="row.id"> <template v-for="(row, index) of rows" :key="row.id">
<QItem class="q-pa-none items-start"> <QItem class="q-pa-none items-start">
<QItemSection class="q-pa-md"> <QItemSection class="q-pa-md">
@ -107,7 +88,7 @@ async function remove({ id }) {
{{ t('claim.rma.user') }} {{ t('claim.rma.user') }}
</QItemLabel> </QItemLabel>
<QItemLabel> <QItemLabel>
{{ row.worker.user.name }} {{ row?.worker?.user?.name }}
</QItemLabel> </QItemLabel>
</QItemSection> </QItemSection>
</QItem> </QItem>
@ -133,7 +114,7 @@ async function remove({ id }) {
round round
color="orange" color="orange"
icon="vn:bin" icon="vn:bin"
@click="confirmRemove(row.id)" @click="claimRmaRef.remove([row])"
> >
<QTooltip>{{ t('globals.remove') }}</QTooltip> <QTooltip>{{ t('globals.remove') }}</QTooltip>
</QBtn> </QBtn>
@ -143,33 +124,11 @@ async function remove({ id }) {
</template> </template>
</QCard> </QCard>
</template> </template>
</VnPaginate> </CrudModel>
</div> </div>
</div> </div>
<QPageSticky position="bottom-right" :offset="[25, 25]">
<Teleport <QBtn fab color="primary" icon="add" @click="addRow()" />
v-if="stateStore.isHeaderMounted() && !quasar.platform.is.mobile"
to="#actions-prepend"
>
<div class="row q-gutter-x-sm">
<QBtn @click="addRow()" icon="add" color="primary" dense rounded>
<QTooltip bottom> {{ t('globals.add') }} </QTooltip>
</QBtn>
<QSeparator vertical />
</div>
</Teleport>
<QPageSticky
v-if="quasar.platform.is.mobile"
position="bottom"
:offset="[0, 0]"
expand
>
<QToolbar class="bg-primary text-white q-pa-none">
<QTabs class="full-width" align="justify" inline-label narrow-indicator>
<QTab @click="addRow()" icon="add_circle" :label="t('globals.add')" />
</QTabs>
</QToolbar>
</QPageSticky> </QPageSticky>
</template> </template>
@ -178,16 +137,6 @@ async function remove({ id }) {
width: 100%; width: 100%;
max-width: 60em; max-width: 60em;
} }
.q-toolbar {
background-color: $grey-9;
}
.sticky-page {
padding-top: 66px;
}
.q-page-sticky {
z-index: 2998;
}
</style> </style>
<i18n> <i18n>

View File

@ -1,11 +1,13 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStateStore } from 'stores/useStateStore'; import { useStateStore } from 'stores/useStateStore';
import { useRoute } from 'vue-router';
import CustomerDescriptor from './CustomerDescriptor.vue'; import CustomerDescriptor from './CustomerDescriptor.vue';
import LeftMenu from 'components/LeftMenu.vue'; import LeftMenu from 'components/LeftMenu.vue';
import VnSearchbar from 'src/components/ui/VnSearchbar.vue'; import VnSearchbar from 'src/components/ui/VnSearchbar.vue';
const stateStore = useStateStore(); const stateStore = useStateStore();
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
</script> </script>
<template> <template>
@ -25,8 +27,13 @@ const { t } = useI18n();
</QScrollArea> </QScrollArea>
</QDrawer> </QDrawer>
<QPageContainer> <QPageContainer>
<QPage class="q-pa-md"> <QPage>
<RouterView></RouterView> <QToolbar class="bg-vn-dark justify-end">
<div id="st-data"></div>
<QSpace />
<div id="st-actions"></div>
</QToolbar>
<div class="q-pa-md"><RouterView></RouterView></div>
</QPage> </QPage>
</QPageContainer> </QPageContainer>
</template> </template>

View File

@ -25,8 +25,13 @@ const { t } = useI18n();
</QScrollArea> </QScrollArea>
</QDrawer> </QDrawer>
<QPageContainer> <QPageContainer>
<QPage class="q-pa-md"> <QPage>
<RouterView></RouterView> <QToolbar class="bg-vn-dark justify-end">
<div id="st-data"></div>
<QSpace />
<div id="st-actions"></div>
</QToolbar>
<div class="q-pa-md"><RouterView></RouterView></div>
</QPage> </QPage>
</QPageContainer> </QPageContainer>
</template> </template>

View File

@ -25,8 +25,13 @@ const { t } = useI18n();
</QScrollArea> </QScrollArea>
</QDrawer> </QDrawer>
<QPageContainer> <QPageContainer>
<QPage class="q-pa-md"> <QPage>
<RouterView></RouterView> <QToolbar class="bg-vn-dark justify-end">
<div id="st-data"></div>
<QSpace />
<div id="st-actions"></div>
</QToolbar>
<div class="q-pa-md"><RouterView></RouterView></div>
</QPage> </QPage>
</QPageContainer> </QPageContainer>
</template> </template>

View File

@ -20,7 +20,7 @@ const $props = defineProps({
}); });
const entityId = computed(() => $props.id || route.params.id); const entityId = computed(() => $props.id || route.params.id);
let wagonTypes; let wagonTypes = [];
let originalData = {}; let originalData = {};
const wagon = ref({}); const wagon = ref({});
const filteredWagonTypes = ref(wagonTypes); const filteredWagonTypes = ref(wagonTypes);

View File

@ -25,8 +25,13 @@ const { t } = useI18n();
</QScrollArea> </QScrollArea>
</QDrawer> </QDrawer>
<QPageContainer> <QPageContainer>
<QPage class="q-pa-md"> <QPage>
<RouterView></RouterView> <QToolbar class="bg-vn-dark justify-end">
<div id="st-data"></div>
<QSpace />
<div id="st-actions"></div>
</QToolbar>
<div class="q-pa-md"><RouterView></RouterView></div>
</QPage> </QPage>
</QPageContainer> </QPageContainer>
</template> </template>

View File

@ -1,6 +1,5 @@
<script setup> <script setup>
import axios from 'axios'; import { ref, onMounted, computed } from 'vue';
import { ref, onMounted, computed, onUpdated } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import CardSummary from 'components/ui/CardSummary.vue'; import CardSummary from 'components/ui/CardSummary.vue';

View File

@ -18,6 +18,7 @@ export default {
'ClaimPhotos', 'ClaimPhotos',
'ClaimLog', 'ClaimLog',
'ClaimNotes', 'ClaimNotes',
'ClaimDevelopment',
], ],
}, },
children: [ children: [
@ -101,6 +102,16 @@ export default {
}, },
component: () => import('src/pages/Claim/Card/ClaimPhoto.vue'), component: () => import('src/pages/Claim/Card/ClaimPhoto.vue'),
}, },
{
name: 'ClaimDevelopment',
path: 'development',
meta: {
title: 'development',
icon: 'vn:traceability',
roles: ['claimManager'],
},
component: () => import('src/pages/Claim/Card/ClaimDevelopment.vue'),
},
{ {
name: 'ClaimLog', name: 'ClaimLog',
path: 'log', path: 'log',

View File

@ -30,6 +30,13 @@ export const useStateStore = defineStore('stateStore', () => {
return rightDrawer.value; return rightDrawer.value;
} }
function isSubToolbarShown() {
return (
!!document.querySelector('#st-data') &&
!!document.querySelector('#st-actions')
);
}
return { return {
leftDrawer, leftDrawer,
rightDrawer, rightDrawer,
@ -39,5 +46,6 @@ export const useStateStore = defineStore('stateStore', () => {
toggleRightDrawer, toggleRightDrawer,
isLeftDrawerShown, isLeftDrawerShown,
isRightDrawerShown, isRightDrawerShown,
isSubToolbarShown,
}; };
}); });

View File

@ -0,0 +1,58 @@
/// <reference types="cypress" />
describe('ClaimDevelopment', () => {
const claimId = 1;
const firstLineReason = 'tbody > :nth-child(1) > :nth-child(2)';
const thirdRow = 'tbody > :nth-child(3)';
beforeEach(() => {
cy.viewport(1920, 1080);
cy.login('developer');
cy.visit(`/#/claim/${claimId}/development`);
});
it('should reset line', () => {
cy.selectOption(firstLineReason, 'Novato');
cy.resetCard();
cy.getValue(firstLineReason).should('have.value', 'Prisas');
});
it('should edit line', () => {
cy.selectOption(firstLineReason, 'Novato');
cy.saveCard();
cy.login('developer');
cy.visit(`/#/claim/${claimId}/development`);
cy.getValue(firstLineReason).should('have.value', 'Novato');
//Restart data
cy.selectOption(firstLineReason, 'Prisas');
cy.saveCard();
});
it('should add and remove new line', () => {
cy.addCard();
cy.get(thirdRow).should('exist');
const rowData = [false, 'Novato', 'Roces', 'Compradores', 'employeeNick', 'Tour'];
cy.fillRow(thirdRow, rowData);
cy.saveCard();
cy.login('developer');
cy.visit(`/#/claim/${claimId}/development`);
cy.validateRow(thirdRow, rowData);
cy.reload();
cy.validateRow(thirdRow, rowData);
//remove row
cy.fillRow(thirdRow, [true]);
cy.removeCard();
cy.clickConfirm();
cy.get(thirdRow).should('not.exist');
cy.reload();
cy.get(thirdRow).should('not.exist');
});
});

View File

@ -40,4 +40,90 @@ Cypress.Commands.add('login', (user) => {
window.localStorage.setItem('token', response.body.token); window.localStorage.setItem('token', response.body.token);
}); });
}); });
Cypress.Commands.add('waitForElement', (element) => {
cy.get(element, { timeout: 2000 }).should('be.visible');
});
Cypress.Commands.add('getValue', (selector) => {
cy.get(selector).then(($el) => {
if ($el.find('.q-checkbox__inner').length > 0) {
return cy.get(selector + '.q-checkbox__inner');
}
// Si es un QSelect
else if ($el.find('.q-select__dropdown-icon').length) {
return cy.get(
selector +
'> .q-field > .q-field__inner > .q-field__control > .q-field__control-container > .q-field__native > input'
);
} else {
// Puedes añadir un log o lanzar un error si el elemento no es reconocido
cy.log('Elemento no soportado');
}
});
});
// Fill Inputs
Cypress.Commands.add('selectOption', (selector, option) => {
cy.get(selector).find('.q-select__dropdown-icon').click();
cy.get('.q-menu .q-item').contains(option).click();
});
Cypress.Commands.add('checkOption', (selector) => {
cy.wrap(selector).find('.q-checkbox__inner').click();
});
// Global buttons
Cypress.Commands.add('saveCard', () => {
cy.get('[title="Save"]').click();
});
Cypress.Commands.add('resetCard', () => {
cy.get('[title="Reset"]').click();
});
Cypress.Commands.add('removeCard', () => {
cy.get('[title="Remove"]').click();
});
Cypress.Commands.add('addCard', () => {
cy.waitForElement('tbody');
cy.get('.q-page-sticky > div > .q-btn').click();
});
Cypress.Commands.add('clickConfirm', () => {
cy.get('.q-btn--unelevated > .q-btn__content > .block').click();
});
Cypress.Commands.add('fillRow', (rowSelector, data) => {
// Usar el selector proporcionado para obtener la fila deseada
cy.waitForElement('tbody');
cy.get(rowSelector).as('currentRow');
data.forEach((value, index) => {
if (value === null) return;
cy.get('@currentRow')
.find('td')
.eq(index)
.then((td) => {
if (td.find('.q-select__dropdown-icon').length) {
cy.selectOption(td, value);
}
if (td.find('.q-checkbox__inner').length && value) {
cy.checkOption(td);
}
});
});
});
Cypress.Commands.add('validateRow', (rowSelector, expectedValues) => {
cy.waitForElement('tbody');
cy.get(rowSelector).within(() => {
for (const [index, value] of expectedValues.entries()) {
cy.log('CHECKING ', index, value);
if (typeof value == 'boolean') {
const prefix = value ? '' : 'not.';
cy.getValue(`:nth-child(${index + 1})`).should(`${prefix}be.checked`);
continue;
}
cy.getValue(`:nth-child(${index + 1})`).should('have.value', value);
}
});
});
// registerCommands(); // registerCommands();

View File

@ -0,0 +1,120 @@
import { createWrapper } from 'app/test/vitest/helper';
import CrudModel from 'components/CrudModel.vue';
import { vi, afterEach, beforeEach, beforeAll, describe, expect, it } from 'vitest';
describe('CrudModel', () => {
let vm;
beforeAll(() => {
vm = createWrapper(CrudModel, {
global: {
stubs: [
'vnPaginate',
'useState',
'arrayData',
'useStateStore',
'vue-i18n',
],
mocks: {
validate: vi.fn(),
},
},
propsData: {
dataRequired: {
fk: 1,
},
dataKey: 'crudModelKey',
model: 'crudModel',
url: 'crudModelUrl',
},
}).vm;
});
beforeEach(() => {
vm.fetch([]);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('insert()', () => {
it('should new element in list with index 0 if formData not has data', () => {
vm.insert();
expect(vm.formData.length).toEqual(1);
expect(vm.formData[0].fk).toEqual(1);
expect(vm.formData[0].$index).toEqual(0);
});
});
describe('getChanges()', () => {
it('should return correct updates and creates', async () => {
vm.fetch([
{ id: 1, name: 'New name one' },
{ id: 2, name: 'New name two' },
{ id: 3, name: 'Bruce Wayne' },
]);
vm.originalData = [
{ id: 1, name: 'Tony Starks' },
{ id: 2, name: 'Jessica Jones' },
{ id: 3, name: 'Bruce Wayne' },
];
vm.insert();
const result = vm.getChanges();
const expected = {
creates: [
{
$index: 3,
fk: 1,
},
],
updates: [
{
data: {
name: 'New name one',
},
where: {
id: 1,
},
},
{
data: {
name: 'New name two',
},
where: {
id: 2,
},
},
],
};
expect(result).toEqual(expected);
});
});
describe('getDifferences()', () => {
it('should return the differences between two objects', async () => {
const obj1 = {
a: 1,
b: 2,
c: 3,
};
const obj2 = {
a: null,
b: 4,
d: 5,
};
const result = vm.getDifferences(obj1, obj2);
expect(result).toEqual({
a: null,
b: 4,
d: 5,
});
});
});
});

View File

@ -5,7 +5,6 @@ import ClaimLines from 'pages/Claim/Card/ClaimLines.vue';
describe('ClaimLines', () => { describe('ClaimLines', () => {
let vm; let vm;
beforeAll(() => { beforeAll(() => {
vm = createWrapper(ClaimLines, { vm = createWrapper(ClaimLines, {
global: { global: {
@ -13,25 +12,26 @@ describe('ClaimLines', () => {
mocks: { mocks: {
fetch: vi.fn(), fetch: vi.fn(),
}, },
} },
}).vm; }).vm;
}); });
beforeEach(() => { beforeEach(() => {
vm.claim = { vm.claim = {
id: 1, id: 1,
ticketFk: 1 ticketFk: 1,
} };
vm.store.data = [ vm.store.data = [
{ {
id: 1, id: 1,
quantity: 10, quantity: 10,
sale: { sale: {
id: 1, discount: 0 id: 1,
} discount: 0,
} },
] },
}) ];
});
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -42,13 +42,17 @@ describe('ClaimLines', () => {
vi.spyOn(axios, 'post').mockResolvedValue({ data: true }); vi.spyOn(axios, 'post').mockResolvedValue({ data: true });
vi.spyOn(vm.quasar, 'notify'); vi.spyOn(vm.quasar, 'notify');
const canceller = new AbortController() const canceller = new AbortController();
await vm.updateDiscount({ saleFk: 1, discount: 5, canceller }); await vm.updateDiscount({ saleFk: 1, discount: 5, canceller });
const expectedData = { salesIds: [1], newDiscount: 5 } const expectedData = { salesIds: [1], newDiscount: 5 };
expect(axios.post).toHaveBeenCalledWith('Tickets/1/updateDiscount', expectedData, { expect(axios.post).toHaveBeenCalledWith(
signal: canceller.signal 'Tickets/1/updateDiscount',
}) expectedData,
{
signal: canceller.signal,
}
);
}); });
}); });
@ -56,37 +60,14 @@ describe('ClaimLines', () => {
it('should make a POST request and then set the discount on the original row', async () => { it('should make a POST request and then set the discount on the original row', async () => {
vi.spyOn(vm.quasar, 'notify'); vi.spyOn(vm.quasar, 'notify');
vm.onUpdateDiscount({ discount: 5, rowIndex: 0 }); vm.onUpdateDiscount({ discount: 5, rowIndex: 0 });
const firstRow = vm.store.data[0] const firstRow = vm.store.data[0];
expect(firstRow.sale.discount).toEqual(5) expect(firstRow.sale.discount).toEqual(5);
expect(vm.quasar.notify).toHaveBeenCalledWith( expect(vm.quasar.notify).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
message: 'Discount updated', message: 'Discount updated',
type: 'positive' type: 'positive',
})
);
});
});
describe('remove()', () => {
it('should make a POST request and then call to the quasar notify() method', async () => {
vi.spyOn(axios, 'post').mockResolvedValue({ data: true });
vi.spyOn(vm.quasar, 'notify');
await vm.remove({
rows: [
{ id: 1 }
]
});
const expectedData = { deletes: [1] }
expect(axios.post).toHaveBeenCalledWith('ClaimBeginnings/crud', expectedData)
expect(vm.quasar.notify).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Row removed',
type: 'positive'
}) })
); );
}); });

View File

@ -7,9 +7,11 @@ describe('WagonCreate', () => {
const entityId = 1; const entityId = 1;
beforeAll(() => { beforeAll(() => {
vmEdit = createWrapper(WagonCreate, {propsData: { vmEdit = createWrapper(WagonCreate, {
propsData: {
id: entityId, id: entityId,
}}).vm; },
}).vm;
vmCreate = createWrapper(WagonCreate).vm; vmCreate = createWrapper(WagonCreate).vm;
}); });
@ -29,9 +31,7 @@ describe('WagonCreate', () => {
await vmCreate.onSubmit(); await vmCreate.onSubmit();
expect(axios.patch).toHaveBeenCalledWith( expect(axios.patch).toHaveBeenCalledWith(`Wagons`, vmCreate.wagon);
`Wagons`, vmCreate.wagon
);
}); });
it('should update a wagon', async () => { it('should update a wagon', async () => {
@ -46,9 +46,7 @@ describe('WagonCreate', () => {
await vmEdit.onSubmit(); await vmEdit.onSubmit();
expect(axios.patch).toHaveBeenCalledWith( expect(axios.patch).toHaveBeenCalledWith(`Wagons`, vmEdit.wagon);
`Wagons`, vmEdit.wagon
);
}); });
}); });
@ -88,16 +86,12 @@ describe('WagonCreate', () => {
describe('fetch()', () => { describe('fetch()', () => {
it('should fetch data', async () => { it('should fetch data', async () => {
vi.spyOn(axios, 'get').mockResolvedValue({ data: true }); vi.spyOn(axios, 'get').mockResolvedValue({ data: [] });
await vmEdit.fetch(); await vmEdit.fetch();
expect(axios.get).toHaveBeenCalledWith( expect(axios.get).toHaveBeenCalledWith(`WagonTypes`);
`WagonTypes` expect(axios.get).toHaveBeenCalledWith(`Wagons/${entityId}`);
);
expect(axios.get).toHaveBeenCalledWith(
`Wagons/${entityId}`
);
}); });
}); });
}); });

View File

@ -5,6 +5,7 @@ import { vi } from 'vitest';
import { i18n } from 'src/boot/i18n'; import { i18n } from 'src/boot/i18n';
import { Notify, Dialog } from 'quasar'; import { Notify, Dialog } from 'quasar';
import axios from 'axios'; import axios from 'axios';
import * as useValidator from 'src/composables/useValidator';
installQuasarPlugin({ installQuasarPlugin({
plugins: { plugins: {
@ -34,6 +35,10 @@ vi.mock('vue-router', () => ({
}), }),
})); }));
vi.spyOn(useValidator, 'useValidator').mockImplementation(() => {
return { validate: vi.fn(), fetch: vi.fn() };
});
class FormDataMock { class FormDataMock {
append() { append() {
vi.fn(); vi.fn();
@ -64,6 +69,10 @@ export function createWrapper(component, options) {
global: { global: {
plugins: [i18n, pinia], plugins: [i18n, pinia],
}, },
mocks: {
t: (tKey) => tKey,
$t: (tKey) => tKey,
},
}; };
const mountOptions = Object.assign({}, defaultOptions); const mountOptions = Object.assign({}, defaultOptions);