0
0
Fork 0

Merge branch 'dev' of https://gitea.verdnatura.es/verdnatura/salix-front into 6416-refactor-InvoiceIn

This commit is contained in:
Javier Segarra 2024-02-19 14:12:09 +01:00
commit 08115563d5
338 changed files with 31773 additions and 3912 deletions

View File

@ -58,7 +58,7 @@ module.exports = {
rules: {
'prefer-promise-reject-errors': 'off',
'no-unused-vars': 'warn',
"vue/no-multiple-template-root": "off" ,
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
},

View File

@ -13,5 +13,6 @@
],
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
},
"cSpell.words": ["axios"]
}

View File

@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2400.01] - 2024-01-04
### Added
### Changed
### Fixed
## [2350.01] - 2023-12-14
### Added
- (Carros) => Se añade contador de carros. #6545
- (Reclamaciones) => Se añade la sección para hacer acciones sobre una reclamación. #5654
### Changed
### Fixed
- (Reclamaciones) => Se corrige el color de la barra según el tema y el evento de actualziar cantidades #6334
## [2253.01] - 2023-01-05
### Added

2
Jenkinsfile vendored
View File

@ -96,4 +96,4 @@ pipeline {
}
}
}
}
}

18
package-lock.json generated
View File

@ -1,17 +1,18 @@
{
"name": "salix-front",
"version": "23.40.01",
"version": "24.02.01",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "salix-front",
"version": "0.0.1",
"version": "24.02.01",
"dependencies": {
"@quasar/cli": "^2.2.1",
"@quasar/cli": "^2.3.0",
"@quasar/extras": "^1.16.4",
"axios": "^1.4.0",
"chromium": "^3.0.3",
"croppie": "^2.6.5",
"pinia": "^2.1.3",
"quasar": "^2.12.0",
"validator": "^13.9.0",
@ -946,9 +947,9 @@
}
},
"node_modules/@quasar/cli": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@quasar/cli/-/cli-2.2.1.tgz",
"integrity": "sha512-PMwJ76IeeNRRBw+08hUMjhqGC6JKJ/t1zIb+IOiyR5D4rkBR26Ha/Z46OD3KfwUprq4Q8s4ieB1+d3VY8FhPKg==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@quasar/cli/-/cli-2.3.0.tgz",
"integrity": "sha512-DNFDemicj3jXe5+Ib+5w9Bwj1U3yoHQkqn0bU/qysIl/p0MmGA1yqOfUF0V4fw/5or1dfCvStIA/oZxUcC+2pQ==",
"dependencies": {
"@quasar/ssl-certificate": "^1.0.0",
"ci-info": "^3.8.0",
@ -3169,6 +3170,11 @@
"node": ">= 10"
}
},
"node_modules/croppie": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/croppie/-/croppie-2.6.5.tgz",
"integrity": "sha512-IlChnVUGG5T3w2gRZIaQgBtlvyuYnlUWs2YZIXXR3H9KrlO1PtBT3j+ykxvy9eZIWhk+V5SpBmhCQz5UXKrEKQ=="
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "salix-front",
"version": "23.48.01",
"version": "24.10.0",
"description": "Salix frontend",
"productName": "Salix",
"author": "Verdnatura",
@ -9,23 +9,23 @@
"lint": "eslint --ext .js,.vue ./",
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test:e2e": "cypress open",
"test:e2e:ci": "cd ../salix && gulp docker && cd ../salix-front && cypress run --browser chromium",
"test:e2e:ci": "cd ../salix && gulp docker && cd ../salix-front && cypress run",
"test": "echo \"See package.json => scripts for available tests.\" && exit 0",
"test:unit": "vitest",
"test:unit:ci": "vitest run"
},
"dependencies": {
"@quasar/cli": "^2.2.1",
"@quasar/cli": "^2.3.0",
"@quasar/extras": "^1.16.4",
"axios": "^1.4.0",
"chromium": "^3.0.3",
"croppie": "^2.6.5",
"pinia": "^2.1.3",
"quasar": "^2.12.0",
"validator": "^13.9.0",
"vue": "^3.3.4",
"vue-i18n": "^9.2.2",
"vue-router": "^4.2.1",
"vue-router-mock": "^0.2.0"
"vue-router": "^4.2.1"
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^0.8.1",
@ -53,4 +53,4 @@
"vite": "^4.3.5",
"vitest": "^0.31.1"
}
}
}

View File

@ -29,7 +29,7 @@ module.exports = configure(function (/* ctx */) {
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli/boot-files
boot: ['i18n', 'axios', 'vnDate'],
boot: ['i18n', 'axios', 'vnDate', 'validations'],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
css: ['app.scss'],
@ -66,7 +66,9 @@ module.exports = configure(function (/* ctx */) {
// publicPath: '/',
// analyze: true,
// env: {},
// rawDefine: {}
rawDefine: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
},
// ignorePublicFolder: true,
// minify: false,
// polyfillModulePreload: true,
@ -89,11 +91,12 @@ module.exports = configure(function (/* ctx */) {
vitePlugins: [
[
VueI18nPlugin,
VueI18nPlugin({
runtimeOnly: false
}),
{
// if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false`
// compositionOnly: false,
// you need to set i18n resource including paths !
include: path.resolve(__dirname, './src/i18n/**'),
},

View File

@ -1,10 +1,11 @@
<script setup>
import { onMounted } from 'vue';
import { useQuasar } from 'quasar';
import { useQuasar, Dark } from 'quasar';
import { useI18n } from 'vue-i18n';
const quasar = useQuasar();
const { availableLocales, locale, fallbackLocale } = useI18n();
Dark.set(true);
onMounted(() => {
let userLang = window.navigator.language;

6
src/boot/validations.js Normal file
View File

@ -0,0 +1,6 @@
import { boot } from 'quasar/wrappers';
import { useValidationsStore } from 'src/stores/useValidationsStore';
export default boot(async ({ store }) => {
await useValidationsStore(store).fetchModels();
});

View File

@ -15,4 +15,14 @@ export default boot(() => {
Date.vnNow = () => {
return new Date(Date.vnUTC()).getTime();
};
Date.vnFirstDayOfMonth = () => {
const date = new Date(Date.vnUTC());
return new Date(date.getFullYear(), date.getMonth(), 1);
};
Date.vnLastDayOfMonth = () => {
const date = new Date(Date.vnUTC());
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
};
});

View File

@ -0,0 +1,115 @@
<script setup>
import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import VnInput from 'src/components/common/VnInput.vue';
import VnSelectFilter from 'src/components/common/VnSelectFilter.vue';
import FetchData from 'components/FetchData.vue';
import VnRow from 'components/ui/VnRow.vue';
import FormModelPopup from './FormModelPopup.vue';
const props = defineProps({
showEntityField: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['onDataSaved']);
const { t } = useI18n();
const bankEntityFormData = reactive({
name: null,
bic: null,
countryFk: null,
id: null,
});
const countriesFilter = {
fields: ['id', 'country', 'code'],
};
const countriesOptions = ref([]);
const onDataSaved = (data) => {
emit('onDataSaved', data);
};
</script>
<template>
<FetchData
url="Countries"
:filter="countriesFilter"
auto-load
@on-fetch="(data) => (countriesOptions = data)"
/>
<FormModelPopup
url-create="bankEntities"
model="bankEntity"
:title="t('title')"
:subtitle="t('subtitle')"
:form-initial-data="bankEntityFormData"
@on-data-saved="onDataSaved($event)"
>
<template #form-inputs="{ data, validate }">
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnInput
:label="t('name')"
v-model="data.name"
:required="true"
:rules="validate('bankEntity.name')"
/>
</div>
<div class="col">
<VnInput
:label="t('swift')"
v-model="data.bic"
:required="true"
:rules="validate('bankEntity.bic')"
/>
</div>
</VnRow>
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnSelectFilter
:label="t('country')"
v-model="data.countryFk"
:options="countriesOptions"
option-value="id"
option-label="country"
hide-selected
:required="true"
:rules="validate('bankEntity.countryFk')"
/>
</div>
<div v-if="showEntityField" class="col">
<VnInput
:label="t('id')"
v-model="data.id"
:required="true"
:rules="validate('city.name')"
/>
</div>
</VnRow>
</template>
</FormModelPopup>
</template>
<i18n>
en:
title: New bank entity
subtitle: Please, ensure you put the correct data!
name: Name
swift: Swift
country: Country
id: Entity code
es:
title: Nueva entidad bancaria
subtitle: ¡Por favor, asegúrate de poner los datos correctos!
name: Nombre
swift: Swift
country: País
id: Código de la entidad
</i18n>

View File

@ -0,0 +1,100 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import VnRow from 'components/ui/VnRow.vue';
import VnInput from 'src/components/common/VnInput.vue';
import FormModel from 'components/FormModel.vue';
const emit = defineEmits(['onDataSaved']);
const $props = defineProps({
parentId: {
type: Number,
default: null,
},
});
const { t } = useI18n();
const departmentChildData = reactive({
name: null,
});
const closeButton = ref(null);
const isLoading = ref(false);
const onDataSaved = () => {
emit('onDataSaved');
closeForm();
};
const closeForm = () => {
if (closeButton.value) closeButton.value.click();
};
onMounted(() => {
if ($props.parentId) departmentChildData.parentId = $props.parentId;
});
</script>
<template>
<FormModel
:form-initial-data="departmentChildData"
:observe-form-changes="false"
:default-actions="false"
url-create="departments/createChild"
@on-data-saved="onDataSaved()"
>
<template #form="{ data }">
<span ref="closeButton" class="close-icon" v-close-popup>
<QIcon name="close" size="sm" />
</span>
<h1 class="title">{{ t('New department') }}</h1>
<VnRow class="row q-gutter-md q-mb-md" style="min-width: 250px">
<div class="col">
<VnInput :label="t('Name')" v-model="data.name" />
</div>
</VnRow>
<div class="q-mt-lg row justify-end">
<QBtn
:label="t('globals.cancel')"
type="reset"
color="primary"
flat
class="q-ml-sm"
:disabled="isLoading"
:loading="isLoading"
v-close-popup
/>
<QBtn
:label="t('globals.save')"
type="submit"
color="primary"
:disabled="isLoading"
:loading="isLoading"
/>
</div>
</template>
</FormModel>
</template>
<style lang="scss" scoped>
.close-icon {
position: absolute;
top: 20px;
right: 20px;
cursor: pointer;
}
.title {
font-size: 17px;
font-weight: bold;
line-height: 20px;
}
</style>
<i18n>
es:
Name: Nombre
New department: Nuevo departamento
</i18n>

View File

@ -0,0 +1,72 @@
<script setup>
import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import FetchData from 'components/FetchData.vue';
import VnRow from 'components/ui/VnRow.vue';
import VnSelectFilter from 'src/components/common/VnSelectFilter.vue';
import VnInput from 'src/components/common/VnInput.vue';
import FormModelPopup from './FormModelPopup.vue';
const emit = defineEmits(['onDataSaved']);
const { t } = useI18n();
const cityFormData = reactive({
name: null,
provinceFk: null,
});
const provincesOptions = ref([]);
const onDataSaved = (dataSaved) => {
emit('onDataSaved', dataSaved);
};
</script>
<template>
<FetchData
@on-fetch="(data) => (provincesOptions = data)"
auto-load
url="Provinces"
/>
<FormModelPopup
:title="t('New city')"
:subtitle="t('Please, ensure you put the correct data!')"
:form-initial-data="cityFormData"
url-create="towns"
model="city"
@on-data-saved="onDataSaved($event)"
>
<template #form-inputs="{ data, validate }">
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnInput
:label="t('Name')"
v-model="data.name"
:rules="validate('city.name')"
/>
</div>
<div class="col">
<VnSelectFilter
:label="t('Province')"
:options="provincesOptions"
hide-selected
option-label="name"
option-value="id"
v-model="data.provinceFk"
:rules="validate('city.provinceFk')"
/>
</div>
</VnRow>
</template>
</FormModelPopup>
</template>
<i18n>
es:
New city: Nueva ciudad
Please, ensure you put the correct data!: ¡Por favor, asegúrese de poner los datos correctos!
Name: Nombre
Province: Provincia
</i18n>

View File

@ -0,0 +1,148 @@
<script setup>
import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import FetchData from 'components/FetchData.vue';
import VnRow from 'components/ui/VnRow.vue';
import VnSelectFilter from 'src/components/common/VnSelectFilter.vue';
import VnInput from 'src/components/common/VnInput.vue';
import CreateNewCityForm from './CreateNewCityForm.vue';
import CreateNewProvinceForm from './CreateNewProvinceForm.vue';
import VnSelectDialog from 'components/common/VnSelectDialog.vue';
import FormModelPopup from './FormModelPopup.vue';
const emit = defineEmits(['onDataSaved']);
const { t } = useI18n();
const postcodeFormData = reactive({
code: null,
countryFk: null,
provinceFk: null,
townFk: null,
});
const townsFetchDataRef = ref(null);
const provincesFetchDataRef = ref(null);
const countriesOptions = ref([]);
const provincesOptions = ref([]);
const townsLocationOptions = ref([]);
const onDataSaved = (dataSaved) => {
emit('onDataSaved', dataSaved);
};
const onCityCreated = async ({ name, provinceFk }, formData) => {
await townsFetchDataRef.value.fetch();
formData.townFk = townsLocationOptions.value.find((town) => town.name === name).id;
formData.provinceFk = provinceFk;
formData.countryFk = provincesOptions.value.find(
(province) => province.id === provinceFk
).countryFk;
};
const onProvinceCreated = async ({ name }, formData) => {
await provincesFetchDataRef.value.fetch();
formData.provinceFk = provincesOptions.value.find(
(province) => province.name === name
).id;
};
</script>
<template>
<FetchData
ref="townsFetchDataRef"
@on-fetch="(data) => (townsLocationOptions = data)"
auto-load
url="Towns/location"
/>
<FetchData
ref="provincesFetchDataRef"
@on-fetch="(data) => (provincesOptions = data)"
auto-load
url="Provinces"
/>
<FetchData
@on-fetch="(data) => (countriesOptions = data)"
auto-load
url="Countries"
/>
<FormModelPopup
url-create="postcodes"
model="postcode"
:title="t('New postcode')"
:subtitle="t('Please, ensure you put the correct data!')"
:form-initial-data="postcodeFormData"
@on-data-saved="onDataSaved($event)"
>
<template #form-inputs="{ data, validate }">
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnInput
:label="t('Postcode')"
v-model="data.code"
:rules="validate('postcode.code')"
/>
</div>
<div class="col">
<VnSelectDialog
:label="t('City')"
:options="townsLocationOptions"
v-model="data.townFk"
hide-selected
option-label="name"
option-value="id"
:rules="validate('postcode.city')"
:roles-allowed-to-create="['deliveryAssistant']"
>
<template #form>
<CreateNewCityForm
@on-data-saved="onCityCreated($event, data)"
/>
</template>
</VnSelectDialog>
</div>
</VnRow>
<VnRow class="row q-gutter-md q-mb-xl">
<div class="col">
<VnSelectDialog
:label="t('Province')"
:options="provincesOptions"
hide-selected
option-label="name"
option-value="id"
v-model="data.provinceFk"
:rules="validate('postcode.provinceFk')"
:roles-allowed-to-create="['deliveryAssistant']"
>
<template #form>
<CreateNewProvinceForm
@on-data-saved="onProvinceCreated($event, data)"
/>
</template>
</VnSelectDialog>
</div>
<div class="col">
<VnSelectFilter
:label="t('Country')"
:options="countriesOptions"
hide-selected
option-label="country"
option-value="id"
v-model="data.countryFk"
:rules="validate('postcode.countryFk')"
/>
</div> </VnRow
></template>
</FormModelPopup>
</template>
<i18n>
es:
New postcode: Nuevo código postal
Please, ensure you put the correct data!: ¡Por favor, asegúrese de poner los datos correctos!
City: Población
Province: Provincia
Country: País
Postcode: Código postal
</i18n>

View File

@ -0,0 +1,72 @@
<script setup>
import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import FetchData from 'components/FetchData.vue';
import VnRow from 'components/ui/VnRow.vue';
import VnSelectFilter from 'src/components/common/VnSelectFilter.vue';
import VnInput from 'src/components/common/VnInput.vue';
import FormModelPopup from './FormModelPopup.vue';
const emit = defineEmits(['onDataSaved']);
const { t } = useI18n();
const provinceFormData = reactive({
name: null,
autonomyFk: null,
});
const autonomiesOptions = ref([]);
const onDataSaved = (dataSaved) => {
emit('onDataSaved', dataSaved);
};
</script>
<template>
<FetchData
@on-fetch="(data) => (autonomiesOptions = data)"
auto-load
url="Autonomies"
/>
<FormModelPopup
:title="t('New province')"
:subtitle="t('Please, ensure you put the correct data!')"
url-create="provinces"
model="province"
:form-initial-data="provinceFormData"
@on-data-saved="onDataSaved($event)"
>
<template #form-inputs="{ data, validate }">
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnInput
:label="t('Name')"
v-model="data.name"
:rules="validate('province.name')"
/>
</div>
<div class="col">
<VnSelectFilter
:label="t('Autonomy')"
:options="autonomiesOptions"
hide-selected
option-label="name"
option-value="id"
v-model="data.autonomyFk"
:rules="validate('province.autonomyFk')"
/>
</div>
</VnRow>
</template>
</FormModelPopup>
</template>
<i18n>
es:
New province: Nueva provincia
Please, ensure you put the correct data!: ¡Por favor, asegúrese de poner los datos correctos!
Name: Nombre
Autonomy: Autonomía
</i18n>

View File

@ -0,0 +1,114 @@
<script setup>
import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import FetchData from 'components/FetchData.vue';
import VnRow from 'components/ui/VnRow.vue';
import VnSelectFilter from 'src/components/common/VnSelectFilter.vue';
import VnInput from 'src/components/common/VnInput.vue';
import FormModelPopup from './FormModelPopup.vue';
const emit = defineEmits(['onDataSaved']);
const { t } = useI18n();
const thermographFormData = reactive({
thermographId: null,
model: 'DISPOSABLE',
warehouseId: null,
temperatureFk: 'cool',
});
const thermographsModels = ref(null);
const warehousesOptions = ref([]);
const temperaturesOptions = ref([]);
const onDataSaved = (dataSaved) => {
emit('onDataSaved', dataSaved);
};
</script>
<template>
<FetchData
@on-fetch="(data) => (thermographsModels = data)"
auto-load
url="Thermographs/getThermographModels"
/>
<FetchData
@on-fetch="(data) => (warehousesOptions = data)"
auto-load
url="Warehouses"
:filter="{ fields: ['id', 'name'], order: 'name ASC', limit: 30 }"
/>
<FetchData
@on-fetch="(data) => (temperaturesOptions = data)"
auto-load
url="Temperatures"
/>
<FormModelPopup
url-create="Thermographs/createThermograph"
model="thermograph"
:title="t('New thermograph')"
:form-initial-data="thermographFormData"
@on-data-saved="onDataSaved($event)"
>
<template #form-inputs="{ data, validate }">
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnInput
:label="t('Identifier')"
v-model="data.thermographId"
:required="true"
:rules="validate('thermograph.id')"
/>
</div>
<div class="col">
<VnSelectFilter
:label="t('Model')"
:options="thermographsModels"
hide-selected
option-label="value"
option-value="value"
v-model="data.model"
:required="true"
:rules="validate('thermograph.model')"
/>
</div>
</VnRow>
<VnRow class="row q-gutter-md q-mb-xl">
<div class="col">
<VnSelectFilter
:label="t('Warehouse')"
:options="warehousesOptions"
hide-selected
option-label="name"
option-value="id"
v-model="data.warehouseId"
:required="true"
/>
</div>
<div class="col">
<VnSelectFilter
:label="t('Temperature')"
:options="temperaturesOptions"
hide-selected
option-label="name"
option-value="code"
v-model="data.temperatureFk"
:required="true"
/>
</div>
</VnRow>
</template>
</FormModelPopup>
</template>
<i18n>
es:
Identifier: Identificador
Model: Modelo
Warehouse: Almacén
Temperature: Temperatura
New thermograph: Nuevo termógrafo
</i18n>

View File

@ -89,6 +89,7 @@ async function fetch(data) {
watch(formData, () => (hasChanges.value = true), { deep: true });
emit('onFetch', data);
return data;
}
async function reset() {
@ -195,7 +196,6 @@ function getChanges() {
const creates = [];
const pk = $props.primaryKey;
for (const [i, row] of formData.value.entries()) {
if (!row[pk]) {
creates.push(row);
@ -224,15 +224,19 @@ function getDifferences(obj1, obj2) {
delete obj2.$index;
for (let key in obj1) {
if (obj2[key] && obj1[key] !== obj2[key]) {
if (obj2[key] && JSON.stringify(obj1[key]) !== JSON.stringify(obj2[key])) {
diff[key] = obj2[key];
}
}
for (let key in obj2) {
if (obj1[key] === undefined || obj1[key] !== obj2[key]) {
if (
obj1[key] === undefined ||
JSON.stringify(obj1[key]) !== JSON.stringify(obj2[key])
) {
diff[key] = obj2[key];
}
}
return diff;
}
@ -272,7 +276,7 @@ watch(formUrl, async () => {
</VnPaginate>
<SkeletonTable v-if="!formData" />
<Teleport to="#st-actions" v-if="stateStore?.isSubToolbarShown()">
<QBtnGroup push class="q-gutter-x-sm">
<QBtnGroup push style="column-gap: 10px">
<slot name="moreBeforeActions" />
<QBtn
:label="tMobile('globals.remove')"

View File

@ -0,0 +1,359 @@
<script setup>
import { reactive, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import VnSelectFilter from 'src/components/common/VnSelectFilter.vue';
import FetchData from 'components/FetchData.vue';
import VnRow from 'components/ui/VnRow.vue';
import VnInput from 'src/components/common/VnInput.vue';
import Croppie from 'croppie/croppie';
import 'croppie/croppie.css';
import useNotify from 'src/composables/useNotify.js';
import axios from 'axios';
const emit = defineEmits(['closeForm', 'onPhotoUploaded']);
const props = defineProps({
id: {
type: String,
default: '',
},
collection: {
type: String,
default: '',
},
});
const { t } = useI18n();
const { notify } = useNotify();
const uploadMethodsOptions = [
{ label: t('Select from computer'), value: 'computer' },
{ label: t('Import from external URL'), value: 'URL' },
];
const viewportTypes = [
{
code: 'normal',
description: t('Normal'),
viewport: {
width: 400,
height: 400,
},
output: {
width: 1200,
height: 1200,
},
},
{
code: 'panoramic',
description: t('Panoramic'),
viewport: {
width: 675,
height: 450,
},
output: {
width: 1350,
height: 900,
},
},
{
code: 'vertical',
description: t('Vertical'),
viewport: {
width: 306.66,
height: 533.33,
},
output: {
width: 460,
height: 800,
},
},
];
const uploadMethodSelected = ref('computer');
const viewPortTypeSelected = ref(viewportTypes[0]);
const inputFileRef = ref(null);
const allowedContentTypes = ref('');
const photoContainerRef = ref(null);
const editor = ref(null);
const newPhoto = reactive({
id: props.id,
collection: props.collection,
file: null,
url: null,
blob: null,
});
const openInputFile = () => {
inputFileRef.value.pickFiles();
};
const displayEditor = () => {
const viewportType = viewPortTypeSelected.value;
const viewport = viewportType.viewport;
const boundaryWidth = viewport.width + 200;
const boundaryHeight = viewport.height + 200;
if (editor.value) editor.value.destroy();
editor.value = new Croppie(photoContainerRef.value, {
viewport: { width: viewport.width, height: viewport.height },
boundary: { width: boundaryWidth, height: boundaryHeight },
enableOrientation: true,
showZoomer: true,
});
};
const viewportSelection = computed({
get() {
return viewPortTypeSelected.value;
},
set(val) {
viewPortTypeSelected.value = val;
const hasFile = newPhoto.files || newPhoto.url;
if (!val || !hasFile) return;
let file;
if (uploadMethodSelected.value == 'computer') file = newPhoto.files;
else if (uploadMethodSelected.value == 'URL') file = newPhoto.url;
updatePhotoPreview(file);
},
});
const updatePhotoPreview = (value) => {
if (value) {
displayEditor();
if (uploadMethodSelected.value == 'computer') {
newPhoto.files = value;
const reader = new FileReader();
reader.onload = (e) => editor.value.bind({ url: e.target.result });
reader.readAsDataURL(value);
} else if (uploadMethodSelected.value == 'URL') {
newPhoto.url = value;
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = value;
img.onload = () => editor.value.bind({ url: value });
img.onerror = () => {
notify(
t("This photo provider doesn't allow remote downloads"),
'negative'
);
};
}
}
};
const rotateLeft = () => {
editor.value.rotate(90);
};
const rotateRight = () => {
editor.value.rotate(-90);
};
const onUploadAccept = () => {
try {
if (!newPhoto.files && !newPhoto.url) {
notify(t('Select an image'), 'negative');
return;
}
const options = {
type: 'blob',
};
editor.value
.result(options)
.then((result) => {
const file = new File([result], newPhoto.files?.name || '');
newPhoto.blob = file;
})
.then(() => makeRequest());
} catch (err) {
console.error('Error uploading image');
}
};
const makeRequest = async () => {
const formData = new FormData();
const now = Date.vnNew();
const timestamp = now.getTime();
const fileName = `${newPhoto.files?.name}_${timestamp}`;
formData.append('blob', newPhoto.blob, fileName);
await axios.post('Images/upload', formData, {
params: newPhoto,
headers: {
'Content-Type': 'multipart/form-data',
},
});
emit('closeForm');
emit('onPhotoUploaded');
notify(t('globals.dataSaved'), 'positive');
};
</script>
<template>
<FetchData
ref="allowTypesRef"
url="ImageContainers/allowedContentTypes"
@on-fetch="(data) => (allowedContentTypes = data.join(', '))"
auto-load
/>
<QForm @submit="onUploadAccept()" class="all-pointer-events">
<QCard class="q-pa-lg">
<span ref="closeButton" class="close-icon" v-close-popup>
<QIcon name="close" size="sm" />
</span>
<h1 class="title">{{ t('Edit photo') }}</h1>
<div class="row q-gutter-lg">
<div
v-show="newPhoto.files || newPhoto.url"
class="row q-gutter-lg items-center"
>
<QIcon
name="rotate_left"
size="sm"
color="primary"
class="cursor-pointer"
@click="rotateLeft()"
>
<!-- <QTooltip class="no-pointer-events">
{{ t('Rotate left') }}
</QTooltip> -->
</QIcon>
<div>
<div ref="photoContainerRef" />
</div>
<QIcon
name="rotate_right"
size="sm"
color="primary"
class="cursor-pointer"
@click="rotateRight()"
>
<!-- <QTooltip class="no-pointer-events">
{{ t('Rotate right') }}
</QTooltip> -->
</QIcon>
</div>
<div class="column">
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<QOptionGroup
:options="uploadMethodsOptions"
type="radio"
v-model="uploadMethodSelected"
/>
</div>
</VnRow>
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<QFile
v-if="uploadMethodSelected === 'computer'"
ref="inputFileRef"
:label="t('File')"
:multiple="false"
v-model="newPhoto.files"
@update:model-value="updatePhotoPreview($event)"
:accept="allowedContentTypes"
class="required cursor-pointer"
>
<template #append>
<QIcon
name="vn:attach"
class="cursor-pointer q-mr-sm"
@click="openInputFile()"
>
<!-- <QTooltip>{{ t('Select a file') }}</QTooltip> -->
</QIcon>
<QIcon name="info" class="cursor-pointer">
<QTooltip>{{
t('globals.allowedFilesText', {
allowedContentTypes: allowedContentTypes,
})
}}</QTooltip>
</QIcon>
</template>
</QFile>
<VnInput
v-if="uploadMethodSelected === 'URL'"
v-model="newPhoto.url"
@update:model-value="updatePhotoPreview($event)"
placeholder="https://"
/>
</div>
</VnRow>
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnSelectFilter
:label="t('Orientation')"
:options="viewportTypes"
hide-selected
option-label="description"
v-model="viewportSelection"
/>
</div>
</VnRow>
<div class="q-mt-lg row justify-end">
<QBtn
:label="t('globals.save')"
type="submit"
color="primary"
:disabled="isLoading"
:loading="isLoading"
/>
<QBtn
:label="t('globals.cancel')"
type="reset"
color="primary"
flat
class="q-ml-sm"
:disabled="isLoading"
:loading="isLoading"
v-close-popup
/>
</div>
</div>
</div>
</QCard>
</QForm>
</template>
<style lang="scss" scoped>
.title {
font-size: 17px;
font-weight: bold;
line-height: 20px;
}
.close-icon {
position: absolute;
top: 20px;
right: 20px;
cursor: pointer;
}
</style>
<i18n>
es:
Edit photo: Editar foto
Select from computer: Seleccionar desde ordenador
Import from external URL: Importar desde URL externa
Vertical: Vertical
Normal: Normal
Panoramic: Panorámica
Orientation: Orientación
File: Fichero
This photo provider doesn't allow remote downloads: Este proveedor de fotos no permite descargas remotas
Rotate left: Girar a la izquierda
Rotate right: Girar a la derecha
Select an image: Selecciona una imagen
</i18n>

View File

@ -0,0 +1,141 @@
<script setup>
import { ref, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import VnSelectFilter from 'src/components/common/VnSelectFilter.vue';
import VnInput from 'src/components/common/VnInput.vue';
import axios from 'axios';
import useNotify from 'src/composables/useNotify.js';
const emit = defineEmits(['onDataSaved']);
const $props = defineProps({
rows: {
type: Array,
default: () => [],
},
fieldsOptions: {
type: Array,
default: () => [],
},
editUrl: {
type: String,
default: '',
},
});
const { t } = useI18n();
const { notify } = useNotify();
const formData = reactive({
field: null,
newValue: null,
});
const closeButton = ref(null);
const isLoading = ref(false);
const onDataSaved = () => {
notify('globals.dataSaved', 'positive');
emit('onDataSaved');
closeForm();
};
const submitData = async () => {
try {
isLoading.value = true;
const rowsToEdit = $props.rows.map((row) => ({ id: row.id, itemFk: row.itemFk }));
const payload = {
field: formData.field,
newValue: formData.newValue,
lines: rowsToEdit,
};
await axios.post($props.editUrl, payload);
onDataSaved();
isLoading.value = false;
} catch (err) {
console.error('Error submitting table cell edit');
}
};
const closeForm = () => {
if (closeButton.value) closeButton.value.click();
};
</script>
<template>
<QForm @submit="submitData()" class="all-pointer-events">
<QCard class="q-pa-lg">
<span ref="closeButton" class="close-icon" v-close-popup>
<QIcon name="close" size="sm" />
</span>
<h1 class="title">
{{
t('editBuyTitle', {
buysAmount: rows.length,
})
}}
</h1>
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnSelectFilter
:label="t('Field to edit')"
:options="fieldsOptions"
hide-selected
option-label="label"
option-value="field"
v-model="formData.field"
/>
</div>
<div class="col">
<VnInput :label="t('Value')" v-model="formData.newValue" />
</div>
</VnRow>
<div class="q-mt-lg row justify-end">
<QBtn
:label="t('globals.save')"
type="submit"
color="primary"
:disabled="isLoading"
:loading="isLoading"
/>
<QBtn
:label="t('globals.cancel')"
type="reset"
color="primary"
flat
class="q-ml-sm"
:disabled="isLoading"
:loading="isLoading"
v-close-popup
/>
</div>
</QCard>
</QForm>
</template>
<style lang="scss" scoped>
.title {
font-size: 17px;
font-weight: bold;
line-height: 20px;
}
.close-icon {
position: absolute;
top: 20px;
right: 20px;
cursor: pointer;
}
</style>
<i18n>
en:
editBuyTitle: Edit {buysAmount} buy(s)
es:
editBuyTitle: Editar {buysAmount} compra(s)
Field to edit: Campo a editar
Value: Valor
</i18n>

View File

@ -27,6 +27,10 @@ const $props = defineProps({
type: String,
default: '',
},
params: {
type: Object,
default: null,
},
});
const emit = defineEmits(['onFetch']);
@ -38,18 +42,19 @@ onMounted(async () => {
}
});
async function fetch() {
async function fetch(fetchFilter = {}) {
try {
const filter = Object.assign({}, $props.filter); // eslint-disable-line vue/no-dupe-keys
if ($props.where) filter.where = $props.where;
const filter = Object.assign(fetchFilter, $props.filter); // eslint-disable-line vue/no-dupe-keys
if ($props.where && !fetchFilter.where) filter.where = $props.where;
if ($props.sortBy) filter.order = $props.sortBy;
if ($props.limit) filter.limit = $props.limit;
const { data } = await axios.get($props.url, {
params: { filter },
params: { filter: JSON.stringify(filter), ...$props.params },
});
emit('onFetch', data);
return data;
} catch (e) {
//
}

View File

@ -0,0 +1,242 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import VnRow from 'components/ui/VnRow.vue';
import FetchData from 'components/FetchData.vue';
import VnInput from 'src/components/common/VnInput.vue';
import VnSelectFilter from 'components/common/VnSelectFilter.vue';
import ItemDescriptorProxy from 'src/pages/Item/Card/ItemDescriptorProxy.vue';
import axios from 'axios';
import { dashIfEmpty } from 'src/filters';
const emit = defineEmits(['itemSelected']);
const { t } = useI18n();
const route = useRoute();
const itemFilter = {
include: [
{
relation: 'producer',
scope: {
fields: ['name'],
},
},
{
relation: 'ink',
scope: {
fields: ['name'],
},
},
],
};
const itemFilterParams = reactive({});
const closeButton = ref(null);
const isLoading = ref(false);
const producersOptions = ref([]);
const ItemTypesOptions = ref([]);
const InksOptions = ref([]);
const tableRows = ref([]);
const loading = ref(false);
const tableColumns = computed(() => [
{
label: t('entry.buys.id'),
name: 'id',
field: 'id',
align: 'left',
},
{
label: t('entry.buys.name'),
name: 'name',
field: 'name',
align: 'left',
},
{
label: t('entry.buys.size'),
name: 'size',
field: 'size',
align: 'left',
},
{
label: t('entry.buys.producer'),
name: 'producerName',
field: 'producer',
align: 'left',
format: (val) => dashIfEmpty(val),
},
{
label: t('entry.buys.color'),
name: 'ink',
field: 'inkName',
align: 'left',
},
]);
const fetchResults = async () => {
try {
let filter = itemFilter;
const params = itemFilterParams;
const where = {};
for (let key in params) {
const value = params[key];
if (!value) continue;
switch (key) {
case 'name':
where[key] = { like: `%${value}%` };
break;
case 'producerFk':
case 'typeFk':
case 'size':
case 'inkFk':
where[key] = value;
}
}
filter.where = where;
const { data } = await axios.get(`Entries/${route.params.id}/lastItemBuys`, {
params: { filter: JSON.stringify(filter) },
});
tableRows.value = data;
} catch (err) {
console.error('Error fetching entries items');
}
};
const closeForm = () => {
if (closeButton.value) closeButton.value.click();
};
const selectItem = ({ id }) => {
emit('itemSelected', id);
closeForm();
};
</script>
<template>
<FetchData
url="Producers"
@on-fetch="(data) => (producersOptions = data)"
:filter="{ fields: ['id', 'name'], order: 'name ASC', limit: 30 }"
auto-load
/>
<FetchData
url="ItemTypes"
:filter="{ fields: ['id', 'name'], order: 'name ASC', limit: 30 }"
order="name"
@on-fetch="(data) => (ItemTypesOptions = data)"
auto-load
/>
<FetchData
url="Inks"
:filter="{ fields: ['id', 'name'], order: 'name ASC', limit: 30 }"
order="name"
@on-fetch="(data) => (InksOptions = data)"
auto-load
/>
<QForm @submit="fetchResults()" class="all-pointer-events">
<QCard class="column" style="padding: 32px; z-index: 100">
<span ref="closeButton" class="close-icon" v-close-popup>
<QIcon name="close" size="sm" />
</span>
<h1 class="title">{{ t('Filter item') }}</h1>
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnInput
:label="t('entry.buys.name')"
v-model="itemFilterParams.name"
/>
</div>
<div class="col">
<VnInput
:label="t('entry.buys.size')"
v-model="itemFilterParams.size"
/>
</div>
<div class="col">
<VnSelectFilter
:label="t('entry.buys.producer')"
:options="producersOptions"
hide-selected
option-label="name"
option-value="id"
v-model="itemFilterParams.producerFk"
/>
</div>
<div class="col">
<VnSelectFilter
:label="t('entry.buys.type')"
:options="ItemTypesOptions"
hide-selected
option-label="name"
option-value="id"
v-model="itemFilterParams.typeFk"
/>
</div>
<div class="col">
<VnSelectFilter
:label="t('entry.buys.color')"
:options="InksOptions"
hide-selected
option-label="name"
option-value="id"
v-model="itemFilterParams.inkFk"
/>
</div>
</VnRow>
<div class="q-mt-lg row justify-end">
<QBtn
:label="t('globals.search')"
type="submit"
color="primary"
:disabled="isLoading"
:loading="isLoading"
/>
</div>
<QTable
:columns="tableColumns"
:rows="tableRows"
:pagination="{ rowsPerPage: 0 }"
:loading="loading"
:hide-header="!tableRows || !tableRows.length > 0"
:no-data-label="t('Enter a new search')"
class="q-mt-lg"
@row-click="(_, row) => selectItem(row)"
>
<template #body-cell-id="{ row }">
<QTd auto-width @click.stop>
<QBtn flat color="blue">{{ row.id }}</QBtn>
<ItemDescriptorProxy :id="row.id" />
</QTd>
</template>
</QTable>
</QCard>
</QForm>
</template>
<i18n>
es:
Filter item: Filtrar artículo
Enter a new search: Introduce una nueva búsqueda
</i18n>
<style lang="scss" scoped>
.title {
font-size: 17px;
font-weight: bold;
line-height: 20px;
}
.close-icon {
position: absolute;
top: 20px;
right: 20px;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,240 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import VnRow from 'components/ui/VnRow.vue';
import FetchData from 'components/FetchData.vue';
import VnInputDate from 'src/components/common/VnInputDate.vue';
import VnSelectFilter from 'components/common/VnSelectFilter.vue';
import TravelDescriptorProxy from 'src/pages/Travel/Card/TravelDescriptorProxy.vue';
import axios from 'axios';
import { toDate } from 'src/filters';
const emit = defineEmits(['travelSelected']);
const { t } = useI18n();
const travelFilter = {
include: [
{
relation: 'agency',
scope: {
fields: ['name'],
},
},
{
relation: 'warehouseIn',
scope: {
fields: ['name'],
},
},
{
relation: 'warehouseOut',
scope: {
fields: ['name'],
},
},
],
};
const travelFilterParams = reactive({});
const closeButton = ref(null);
const isLoading = ref(false);
const agenciesOptions = ref([]);
const warehousesOptions = ref([]);
const tableRows = ref([]);
const loading = ref(false);
const tableColumns = computed(() => [
{
label: t('entry.basicData.id'),
name: 'id',
field: 'id',
align: 'left',
},
{
label: t('entry.basicData.warehouseOut'),
name: 'warehouseOutFk',
field: 'warehouseOutFk',
align: 'left',
format: (val) =>
warehousesOptions.value.find((warehouse) => warehouse.id === val).name,
},
{
label: t('entry.basicData.warehouseIn'),
name: 'warehouseInFk',
field: 'warehouseInFk',
align: 'left',
format: (val) =>
warehousesOptions.value.find((warehouse) => warehouse.id === val).name,
},
{
label: t('entry.basicData.shipped'),
name: 'shipped',
field: 'shipped',
align: 'left',
format: (val) => toDate(val),
},
{
label: t('entry.basicData.landed'),
name: 'landed',
field: 'landed',
align: 'left',
format: (val) => toDate(val),
},
]);
const fetchResults = async () => {
try {
let filter = travelFilter;
const params = travelFilterParams;
const where = {};
for (let key in params) {
const value = params[key];
if (!value) continue;
switch (key) {
case 'agencyModeFk':
case 'warehouseInFk':
case 'warehouseOutFk':
case 'shipped':
case 'landed':
where[key] = value;
}
}
filter.where = where;
const { data } = await axios.get('Travels', {
params: { filter: JSON.stringify(filter) },
});
tableRows.value = data;
} catch (err) {
console.error('Error fetching travels');
}
};
const closeForm = () => {
if (closeButton.value) closeButton.value.click();
};
const selectTravel = ({ id }) => {
emit('travelSelected', id);
closeForm();
};
</script>
<template>
<FetchData
url="AgencyModes"
@on-fetch="(data) => (agenciesOptions = data)"
:filter="{ fields: ['id', 'name'], order: 'name ASC', limit: 30 }"
auto-load
/>
<FetchData
url="Warehouses"
:filter="{ fields: ['id', 'name'] }"
order="name"
@on-fetch="(data) => (warehousesOptions = data)"
auto-load
/>
<QForm @submit="fetchResults()" class="all-pointer-events">
<QCard class="column" style="padding: 32px; z-index: 100">
<span ref="closeButton" class="close-icon" v-close-popup>
<QIcon name="close" size="sm" />
</span>
<h1 class="title">{{ t('Filter travels') }}</h1>
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnSelectFilter
:label="t('entry.basicData.agency')"
:options="agenciesOptions"
hide-selected
option-label="name"
option-value="id"
v-model="travelFilterParams.agencyModeFk"
/>
</div>
<div class="col">
<VnSelectFilter
:label="t('entry.basicData.warehouseOut')"
:options="warehousesOptions"
hide-selected
option-label="name"
option-value="id"
v-model="travelFilterParams.warehouseOutFk"
/>
</div>
<div class="col">
<VnSelectFilter
:label="t('entry.basicData.warehouseIn')"
:options="warehousesOptions"
hide-selected
option-label="name"
option-value="id"
v-model="travelFilterParams.warehouseInFk"
/>
</div>
<div class="col">
<VnInputDate
:label="t('entry.basicData.shipped')"
v-model="travelFilterParams.shipped"
/>
</div>
<div class="col">
<VnInputDate
:label="t('entry.basicData.landed')"
v-model="travelFilterParams.landed"
/>
</div>
</VnRow>
<div class="q-mt-lg row justify-end">
<QBtn
:label="t('globals.search')"
type="submit"
color="primary"
:disabled="isLoading"
:loading="isLoading"
/>
</div>
<QTable
:columns="tableColumns"
:rows="tableRows"
:pagination="{ rowsPerPage: 0 }"
:loading="loading"
:hide-header="!tableRows || !tableRows.length > 0"
:no-data-label="t('Enter a new search')"
class="q-mt-lg"
@row-click="(_, row) => selectTravel(row)"
>
<template #body-cell-id="{ row }">
<QTd auto-width @click.stop>
<QBtn flat color="blue">{{ row.id }}</QBtn>
<TravelDescriptorProxy :id="row.id" />
</QTd>
</template>
</QTable>
</QCard>
</QForm>
</template>
<i18n>
es:
Filter travels: Filtro envíos
Enter a new search: Introduce una nueva búsqueda
</i18n>
<style lang="scss" scoped>
.title {
font-size: 17px;
font-weight: bold;
line-height: 20px;
}
.close-icon {
position: absolute;
top: 20px;
right: 20px;
cursor: pointer;
}
</style>

View File

@ -1,11 +1,12 @@
<script setup>
import axios from 'axios';
import { onMounted, onUnmounted, computed, ref, watch } from 'vue';
import { onMounted, onUnmounted, computed, ref, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { useState } from 'src/composables/useState';
import { useStateStore } from 'stores/useStateStore';
import { useValidator } from 'src/composables/useValidator';
import useNotify from 'src/composables/useNotify.js';
import SkeletonForm from 'components/ui/SkeletonForm.vue';
const quasar = useQuasar();
@ -13,6 +14,7 @@ const state = useState();
const stateStore = useStateStore();
const { t } = useI18n();
const { validate } = useValidator();
const { notify } = useNotify();
const $props = defineProps({
url: {
@ -31,59 +33,123 @@ const $props = defineProps({
type: String,
default: null,
},
urlCreate: {
type: String,
default: null,
},
defaultActions: {
type: Boolean,
default: true,
},
autoLoad: {
type: Boolean,
default: false,
},
formInitialData: {
type: Object,
default: () => {},
},
observeFormChanges: {
type: Boolean,
default: true,
description:
'Esto se usa principalmente para permitir guardar sin hacer cambios (Útil para la feature de clonar ya que en este caso queremos poder guardar de primeras)',
},
mapper: {
type: Function,
default: null,
},
});
const emit = defineEmits(['onFetch']);
const emit = defineEmits(['onFetch', 'onDataSaved']);
defineExpose({
save,
});
onMounted(async () => await fetch());
const componentIsRendered = ref(false);
onMounted(async () => {
nextTick(() => {
componentIsRendered.value = true;
});
// Podemos enviarle al form la estructura de data inicial sin necesidad de fetchearla
if ($props.formInitialData && !$props.autoLoad) {
state.set($props.model, $props.formInitialData);
} else {
await fetch();
}
// Si así se desea disparamos el watcher del form después de 100ms, asi darle tiempo de que se haya cargado la data inicial
// para evitar que detecte cambios cuando es data inicial default
if ($props.observeFormChanges) {
setTimeout(() => {
startFormWatcher();
}, 100);
}
});
onUnmounted(() => {
state.unset($props.model);
});
const isLoading = ref(false);
const hasChanges = ref(false);
const originalData = ref();
// Si elegimos observar los cambios del form significa que inicialmente las actions estaran deshabilitadas
const isResetting = ref(false);
const hasChanges = ref(!$props.observeFormChanges);
const originalData = ref({ ...$props.formInitialData });
const formData = computed(() => state.get($props.model));
const formUrl = computed(() => $props.url);
const startFormWatcher = () => {
watch(
() => formData.value,
(val) => {
hasChanges.value = !isResetting.value && val;
isResetting.value = false;
},
{ deep: true }
);
};
function tMobile(...args) {
if (!quasar.platform.is.mobile) return t(...args);
}
async function fetch() {
const { data } = await axios.get($props.url, {
params: { filter: $props.filter },
params: { filter: JSON.stringify($props.filter) },
});
state.set($props.model, data);
originalData.value = data && JSON.parse(JSON.stringify(data));
watch(formData.value, () => (hasChanges.value = true));
emit('onFetch', state.get($props.model));
}
async function save() {
if (!hasChanges.value) {
return quasar.notify({
type: 'negative',
message: t('globals.noChanges'),
});
if ($props.observeFormChanges && !hasChanges.value) {
notify('globals.noChanges', 'negative');
return;
}
isLoading.value = true;
await axios.patch($props.urlUpdate || $props.url, formData.value);
originalData.value = JSON.parse(JSON.stringify(formData.value));
hasChanges.value = false;
try {
const body = $props.mapper ? $props.mapper(formData.value) : formData.value;
let response;
if ($props.urlCreate) {
response = await axios.post($props.urlCreate, body);
notify('globals.dataCreated', 'positive');
} else {
response = await axios.patch($props.urlUpdate || $props.url, body);
}
emit('onDataSaved', formData.value, response?.data);
originalData.value = JSON.parse(JSON.stringify(formData.value));
hasChanges.value = false;
} catch (err) {
notify('errors.create', 'negative');
}
isLoading.value = false;
}
@ -91,11 +157,13 @@ function reset() {
state.set($props.model, originalData.value);
originalData.value = JSON.parse(JSON.stringify(originalData.value));
watch(formData.value, () => (hasChanges.value = true));
emit('onFetch', state.get($props.model));
hasChanges.value = false;
if ($props.observeFormChanges) {
hasChanges.value = false;
isResetting.value = true;
}
}
// eslint-disable-next-line vue/no-dupe-keys
function filter(value, update, filterOptions) {
update(
@ -118,11 +186,7 @@ watch(formUrl, async () => {
});
</script>
<template>
<QBanner v-if="hasChanges" class="text-white bg-warning">
<QIcon name="warning" size="md" class="q-mr-md" />
<span>{{ t('globals.changesToSave') }}</span>
</QBanner>
<div class="column items-center">
<div class="column items-center full-width">
<QForm
v-if="formData"
@submit="save"
@ -140,7 +204,10 @@ watch(formUrl, async () => {
</QCard>
</QForm>
</div>
<Teleport to="#st-actions" v-if="stateStore?.isSubToolbarShown()">
<Teleport
to="#st-actions"
v-if="stateStore?.isSubToolbarShown() && componentIsRendered"
>
<div v-if="$props.defaultActions">
<QBtnGroup push class="q-gutter-x-sm">
<slot name="moreActions" />
@ -169,6 +236,7 @@ watch(formUrl, async () => {
:showing="isLoading"
:label="t('globals.pleaseWait')"
color="primary"
style="min-width: 100%"
/>
</template>
<style lang="scss" scoped>
@ -176,6 +244,7 @@ watch(formUrl, async () => {
max-width: 800px;
width: 100%;
}
.q-card {
padding: 32px;
}

View File

@ -0,0 +1,107 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import FormModel from 'components/FormModel.vue';
const emit = defineEmits(['onDataSaved']);
const $props = defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
url: {
type: String,
default: '',
},
model: {
type: String,
default: '',
},
filter: {
type: Object,
default: null,
},
urlCreate: {
type: String,
default: null,
},
formInitialData: {
type: Object,
default: () => {},
},
});
const { t } = useI18n();
const closeButton = ref(null);
const isLoading = ref(false);
const onDataSaved = (dataSaved) => {
emit('onDataSaved', dataSaved);
closeForm();
};
const closeForm = () => {
if (closeButton.value) closeButton.value.click();
};
</script>
<template>
<FormModel
:form-initial-data="formInitialData"
:observe-form-changes="false"
:default-actions="false"
:url-create="urlCreate"
:model="model"
@on-data-saved="onDataSaved($event)"
>
<template #form="{ data, validate }">
<span ref="closeButton" class="close-icon" v-close-popup>
<QIcon name="close" size="sm" />
</span>
<h1 class="title">{{ title }}</h1>
<p>{{ subtitle }}</p>
<slot name="form-inputs" :data="data" :validate="validate" />
<div class="q-mt-lg row justify-end">
<QBtn
:label="t('globals.save')"
type="submit"
color="primary"
:disabled="isLoading"
:loading="isLoading"
/>
<QBtn
:label="t('globals.cancel')"
type="reset"
color="primary"
flat
class="q-ml-sm"
:disabled="isLoading"
:loading="isLoading"
v-close-popup
/>
</div>
</template>
</FormModel>
</template>
<style lang="scss" scoped>
.title {
font-size: 17px;
font-weight: bold;
line-height: 20px;
}
.close-icon {
position: absolute;
top: 20px;
right: 20px;
cursor: pointer;
}
</style>

View File

@ -206,6 +206,17 @@ async function togglePinned(item, event) {
<template v-if="$props.source === 'card'">
<template v-for="item in items" :key="item.name">
<LeftMenuItem v-if="!item.children" :item="item" />
<QList v-else>
<QExpansionItem
v-ripple
clickable
:icon="item.icon"
:label="t(item.title)"
:content-inset-level="0.5"
>
<LeftMenuItemGroup :item="item" />
</QExpansionItem>
</QList>
</template>
</template>
</QList>

View File

@ -24,28 +24,15 @@ const pinnedModulesRef = ref();
</script>
<template>
<QHeader class="bg-dark" color="white" elevated>
<QHeader color="white" elevated>
<QToolbar class="q-py-sm q-px-md">
<QBtn
@click="stateStore.toggleLeftDrawer()"
icon="menu"
class="q-mr-sm"
round
dense
flat
>
<QBtn @click="stateStore.toggleLeftDrawer()" icon="menu" round dense flat>
<QTooltip bottom anchor="bottom right">
{{ t('globals.collapseMenu') }}
</QTooltip>
</QBtn>
<RouterLink to="/">
<QBtn
class="q-ml-xs"
color="primary"
flat
round
v-if="!quasar.platform.is.mobile"
>
<QBtn color="primary" flat round v-if="!quasar.platform.is.mobile">
<QAvatar square size="md">
<QImg
src="~/assets/salix_icon.svg"
@ -118,6 +105,9 @@ const pinnedModulesRef = ref();
.searchbar {
width: max-content;
}
.q-header {
background-color: var(--vn-dark);
}
</style>
<i18n>
en:

View File

@ -0,0 +1,81 @@
<script setup>
import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import VnSelectFilter from 'src/components/common/VnSelectFilter.vue';
import FetchData from 'components/FetchData.vue';
import VnRow from 'components/ui/VnRow.vue';
import FormModelPopup from './FormModelPopup.vue';
const emit = defineEmits(['onDataSaved']);
const props = defineProps({
itemFk: {
type: Number,
default: null,
},
warehouseFk: {
type: Boolean,
default: null,
},
});
const { t } = useI18n();
const regularizeFormData = reactive({
itemFk: props.itemFk,
warehouseFk: props.warehouseFk,
quantity: null,
});
const warehousesOptions = ref([]);
const onDataSaved = (data) => {
emit('onDataSaved', data);
};
</script>
<template>
<FetchData
url="Warehouses"
@on-fetch="(data) => (warehousesOptions = data)"
auto-load
/>
<FormModelPopup
url-create="Items/regularize"
model="Items"
:title="t('Regularize stock')"
:form-initial-data="regularizeFormData"
@on-data-saved="onDataSaved($event)"
>
<template #form-inputs="{ data }">
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<QInput
:label="t('Type the visible quantity')"
v-model.number="data.quantity"
/>
</div>
</VnRow>
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnSelectFilter
:label="t('Warehouse')"
v-model="data.warehouseFk"
:options="warehousesOptions"
option-value="id"
option-label="name"
hide-selected
/>
</div>
</VnRow>
</template>
</FormModelPopup>
</template>
<i18n>
es:
Warehouse: Almacén
Type the visible quantity: Introduce la cantidad visible
Regularize stock: Regularizar stock
</i18n>

View File

@ -4,15 +4,16 @@ import { Dark, Quasar } from 'quasar';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { useState } from 'src/composables/useState';
import { useSession } from 'src/composables/useSession';
import { localeEquivalence } from 'src/i18n/index';
const state = useState();
const session = useSession();
const router = useRouter();
const { t, locale } = useI18n();
import { useClipboard } from 'src/composables/useClipboard';
const { copyText } = useClipboard();
const userLocale = computed({
get() {
return locale.value;
@ -20,13 +21,11 @@ const userLocale = computed({
set(value) {
locale.value = value;
if (value === 'en') value = 'en-GB';
value = localeEquivalence[value] ?? value;
// FIXME: Dynamic imports from absolute paths are not compatible with vite:
// https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations
try {
const langList = import.meta.glob('../../node_modules/quasar/lang/*.mjs');
langList[`../../node_modules/quasar/lang/${value}.mjs`]().then((lang) => {
/* @vite-ignore */
import(`../../node_modules/quasar/lang/${value}.mjs`).then((lang) => {
Quasar.lang.set(lang.default);
});
} catch (error) {
@ -81,6 +80,10 @@ function logout() {
session.destroy();
router.push('/login');
}
function copyUserToken() {
copyText(session.getToken(), { label: 'components.userPanel.copyToken' });
}
</script>
<template>
@ -122,7 +125,12 @@ function logout() {
<div class="text-subtitle1 q-mt-md">
<strong>{{ user.nickname }}</strong>
</div>
<div class="text-subtitle3 text-grey-7 q-mb-xs">@{{ user.name }}</div>
<div
class="text-subtitle3 text-grey-7 q-mb-xs copyText"
@click="copyUserToken()"
>
@{{ user.name }}
</div>
<QBtn
id="logout"
@ -143,4 +151,10 @@ function logout() {
.panel {
width: 150px;
}
.copyText {
&:hover {
cursor: alias;
}
}
</style>

View File

@ -1,7 +1,9 @@
<script setup>
import { ref } from 'vue';
import { useDialogPluginComponent } from 'quasar';
import { useI18n } from 'vue-i18n';
import { useDialogPluginComponent } from 'quasar';
import VnInput from 'src/components/common/VnInput.vue';
const props = defineProps({
data: {
@ -24,12 +26,13 @@ const address = ref(props.data.address);
const isLoading = ref(false);
async function confirm() {
const response = { address };
const response = { address: address.value };
if (props.promise) {
isLoading.value = true;
const { address: _address, ...restData } = props.data;
try {
Object.assign(response, props.data);
Object.assign(response, restData);
await props.promise(response);
} finally {
isLoading.value = false;
@ -51,7 +54,7 @@ async function confirm() {
{{ t('The notification will be sent to the following address') }}
</QCardSection>
<QCardSection class="q-pt-none">
<QInput dense v-model="address" rounded outlined autofocus />
<VnInput v-model="address" is-outlined autofocus />
</QCardSection>
<QCardActions align="right">
<QBtn :label="t('globals.cancel')" color="primary" flat v-close-popup />

View File

@ -0,0 +1,192 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ref, computed, onMounted } from 'vue';
import { useState } from 'src/composables/useState';
import axios from 'axios';
import useNotify from 'src/composables/useNotify.js';
const $props = defineProps({
allColumns: {
type: Array,
default: () => [],
},
tableCode: {
type: String,
default: '',
},
labelsTraductionsPath: {
type: String,
default: '',
},
});
const emit = defineEmits(['onConfigSaved']);
const { notify } = useNotify();
const state = useState();
const { t } = useI18n();
const popupProxyRef = ref(null);
const user = state.getUser();
const initialUserConfigViewData = ref(null);
const formattedCols = ref([]);
const areAllChecksMarked = computed(() => {
return formattedCols.value.every((col) => col.active);
});
const setUserConfigViewData = (data) => {
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
formattedCols.value = $props.allColumns.map((col) => ({
name: col,
active: data[col] == undefined ? true : data[col],
}));
emitSavedConfig();
};
const toggleMarkAll = (val) => {
formattedCols.value.forEach((col) => (col.active = val));
};
const getConfig = async (url, filter) => {
const response = await axios.get(url, {
params: { filter: filter },
});
return response.data && response.data.length > 0 ? response.data[0] : null;
};
const fetchViewConfigData = async () => {
try {
const userConfigFilter = {
where: { tableCode: $props.tableCode, userFk: user.id },
};
const userConfig = await getConfig('UserConfigViews', userConfigFilter);
if (userConfig) {
initialUserConfigViewData.value = userConfig;
setUserConfigViewData(userConfig.configuration);
return;
}
const defaultConfigFilter = { where: { tableCode: $props.tableCode } };
const defaultConfig = await getConfig('DefaultViewConfigs', defaultConfigFilter);
if (defaultConfig) {
setUserConfigViewData(defaultConfig.columns);
return;
}
} catch (err) {
console.err('Error fetching config view data', err);
}
};
const saveConfig = async () => {
try {
const params = {};
const configuration = {};
formattedCols.value.forEach((col) => {
const { name, active } = col;
configuration[name] = active;
});
// Si existe una view config del usuario hacemos un update si no la creamos
if (initialUserConfigViewData.value) {
params.updates = [
{
data: {
configuration: configuration,
},
where: {
id: initialUserConfigViewData.value.id,
},
},
];
} else {
params.creates = [
{
userFk: user.value.id,
tableCode: $props.tableCode,
tableConfig: $props.tableCode,
configuration: configuration,
},
];
}
const response = await axios.post('UserConfigViews/crud', params);
if (response.data && response.data[0]) {
initialUserConfigViewData.value = response.data[0];
}
emitSavedConfig();
notify('globals.dataSaved', 'positive');
popupProxyRef.value.hide();
} catch (err) {
console.error('Error saving user view config', err);
}
};
const emitSavedConfig = () => {
const activeColumns = formattedCols.value
.filter((col) => col.active)
.map((col) => col.name);
emit('onConfigSaved', activeColumns);
};
onMounted(async () => {
await fetchViewConfigData();
});
</script>
<template>
<QBtn color="primary" icon="view_column">
<QPopupProxy ref="popupProxyRef">
<QCard class="column q-pa-md">
<QIcon name="info" size="sm" class="info-icon">
<QTooltip>{{ t('Check the columns you want to see') }}</QTooltip>
</QIcon>
<span class="text-body1 q-mb-sm">{{ t('Visible columns') }}</span>
<QCheckbox
:label="t('Tick all')"
:model-value="areAllChecksMarked"
@update:model-value="toggleMarkAll($event)"
class="q-mb-sm"
/>
<div
v-if="allColumns.length > 0 && formattedCols.length > 0"
class="checks-layout"
>
<QCheckbox
v-for="(col, index) in allColumns"
:key="index"
:label="t(`${$props.labelsTraductionsPath + '.' + col}`)"
v-model="formattedCols[index].active"
/>
</div>
<QBtn class="full-width q-mt-md" color="primary" @click="saveConfig()">{{
t('globals.save')
}}</QBtn>
</QCard>
</QPopupProxy>
<QTooltip>{{ t('Visible columns') }}</QTooltip>
</QBtn>
</template>
<style lang="scss" scoped>
.info-icon {
position: absolute;
top: 20px;
right: 20px;
}
.checks-layout {
display: grid;
grid-template-columns: repeat(3, 200px);
}
</style>
<i18n>
es:
Check the columns you want to see: Marca las columnas que quieres ver
Visible columns: Columnas visibles
</i18n>

View File

@ -0,0 +1,41 @@
<script setup>
import { ref, watch } from 'vue';
import { QInput } from 'quasar';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue', 'accountShortToStandard']);
let internalValue = ref(props.modelValue);
watch(
() => props.modelValue,
(newVal) => {
internalValue.value = newVal;
}
);
watch(
() => internalValue.value,
(newVal) => {
emit('update:modelValue', newVal);
accountShortToStandard();
}
);
function accountShortToStandard() {
internalValue.value = internalValue.value.replace(
'.',
'0'.repeat(11 - internalValue.value.length)
);
}
</script>
<template>
<q-input v-model="internalValue" />
</template>

View File

@ -69,6 +69,10 @@ function getBreadcrumb(param) {
> div {
flex-wrap: nowrap;
}
&--last,
&__separator {
color: var(--vn-label);
}
}
@media (max-width: $breakpoint-md) {
.q-breadcrumbs {

View File

@ -0,0 +1,62 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const emit = defineEmits(['update:modelValue', 'update:options', 'keyup.enter']);
const $props = defineProps({
modelValue: {
type: [String, Number],
default: null,
},
isOutlined: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const requiredFieldRule = (val) => !!val || t('globals.fieldRequired');
const value = computed({
get() {
return $props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
});
const styleAttrs = computed(() => {
return $props.isOutlined
? {
dense: true,
outlined: true,
rounded: true,
}
: {};
});
const onEnterPress = () => {
emit('keyup.enter');
};
</script>
<template>
<QInput
ref="vnInputRef"
v-model="value"
v-bind="{ ...$attrs, ...styleAttrs }"
type="text"
:class="{ required: $attrs.required }"
@keyup.enter="onEnterPress()"
:rules="$attrs.required ? [requiredFieldRule] : null"
>
<template v-if="$slots.prepend" #prepend>
<slot name="prepend" />
</template>
<template v-if="$slots.append" #append>
<slot name="append" />
</template>
</QInput>
</template>

View File

@ -0,0 +1,90 @@
<script setup>
import { computed, ref } from 'vue';
import { toDate } from 'src/filters';
const props = defineProps({
modelValue: {
type: String,
default: null,
},
readonly: {
type: Boolean,
default: false,
},
isOutlined: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const value = computed({
get() {
return props.modelValue;
},
set(value) {
emit('update:modelValue', value ? new Date(value).toISOString() : null);
},
});
const isPopupOpen = ref(false);
const onDateUpdate = (date) => {
value.value = date;
isPopupOpen.value = false;
};
const padDate = (value) => value.toString().padStart(2, '0');
const formatDate = (dateString) => {
const date = new Date(dateString || '');
return `${date.getFullYear()}/${padDate(date.getMonth() + 1)}/${padDate(
date.getDate()
)}`;
};
const styleAttrs = computed(() => {
return props.isOutlined
? {
dense: true,
outlined: true,
rounded: true,
}
: {};
});
</script>
<template>
<QInput
class="vn-input-date"
rounded
readonly
:model-value="toDate(value)"
v-bind="{ ...$attrs, ...styleAttrs }"
>
<template #append>
<QIcon name="event" class="cursor-pointer">
<QPopupProxy
v-model="isPopupOpen"
cover
transition-show="scale"
transition-hide="scale"
:no-parent-event="props.readonly"
>
<QDate
:model-value="formatDate(value)"
@update:model-value="onDateUpdate"
/>
</QPopupProxy>
</QIcon>
</template>
</QInput>
</template>
<style lang="scss">
.vn-input-date.q-field--standard.q-field--readonly .q-field__control:before {
border-bottom-style: solid;
}
.vn-input-date.q-field--outlined.q-field--readonly .q-field__control:before {
border-style: solid;
}
</style>

View File

@ -0,0 +1,128 @@
<script setup>
import { computed, ref } from 'vue';
import { toHour } from 'src/filters';
import { useI18n } from 'vue-i18n';
import isValidDate from 'filters/isValidDate';
const props = defineProps({
modelValue: {
type: String,
default: null,
},
readonly: {
type: Boolean,
default: false,
},
isOutlined: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const emit = defineEmits(['update:modelValue']);
const value = computed({
get() {
return props.modelValue;
},
set(value) {
const [hours, minutes] = value.split(':');
const date = new Date();
date.setUTCHours(
Number.parseInt(hours) || 0,
Number.parseInt(minutes) || 0,
0,
0
);
emit('update:modelValue', value ? date.toISOString() : null);
},
});
const onDateUpdate = (date) => {
internalValue.value = date;
};
const save = () => {
value.value = internalValue.value;
};
const formatTime = (dateString) => {
if (!isValidDate(dateString)) {
return '';
}
const date = new Date(dateString || '');
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
};
const internalValue = ref(formatTime(value));
const styleAttrs = computed(() => {
return props.isOutlined
? {
dense: true,
outlined: true,
rounded: true,
}
: {};
});
</script>
<template>
<QInput
class="vn-input-time"
rounded
readonly
:model-value="toHour(value)"
v-bind="{ ...$attrs, ...styleAttrs }"
>
<template #append>
<QIcon name="event" class="cursor-pointer">
<QPopupProxy
cover
transition-show="scale"
transition-hide="scale"
:no-parent-event="props.readonly"
>
<QTime
:format24h="false"
:model-value="formatTime(value)"
@update:model-value="onDateUpdate"
>
<div class="row items-center justify-end q-gutter-sm">
<QBtn
:label="t('Cancel')"
color="primary"
flat
v-close-popup
/>
<QBtn
label="Ok"
color="primary"
flat
@click="save"
v-close-popup
/>
</div>
</QTime>
</QPopupProxy>
</QIcon>
</template>
</QInput>
</template>
<style lang="scss">
.vn-input-time.q-field--standard.q-field--readonly .q-field__control:before {
border-bottom-style: solid;
}
.vn-input-time.q-field--outlined.q-field--readonly .q-field__control:before {
border-style: solid;
}
</style>
<i18n>
es:
Cancel: Cancelar
</i18n>

View File

@ -0,0 +1,88 @@
<script setup>
import { watch } from 'vue';
import { toDateString } from 'src/filters';
const props = defineProps({
value: { type: [String, Number, Boolean, Object], default: undefined },
});
const maxStrLen = 512;
let t = '';
let cssClass = '';
let type;
const updateValue = () => {
type = typeof props.value;
if (props.value == null) {
t = '∅';
cssClass = 'json-null';
} else {
cssClass = `json-${type}`;
switch (type) {
case 'number':
if (Number.isInteger(props.value)) {
t = props.value.toString();
} else {
t = (
Math.round((props.value + Number.EPSILON) * 1000) / 1000
).toString();
}
break;
case 'boolean':
t = props.value ? '✓' : '✗';
cssClass = `json-${props.value ? 'true' : 'false'}`;
break;
case 'string':
t =
props.value.length <= maxStrLen
? props.value
: props.value.substring(0, maxStrLen) + '...';
break;
case 'object':
if (props.value instanceof Date) {
t = toDateString(props.value);
} else {
t = props.value.toString();
}
break;
default:
t = props.value.toString();
}
}
};
watch(() => props.value, updateValue);
updateValue();
</script>
<template>
<span
:title="type === 'string' && props.value.length > maxStrLen ? props.value : ''"
:class="{ [cssClass]: t !== '' }"
>
{{ t }}
</span>
</template>
<style scoped>
.json-string {
color: #d172cc;
}
.json-object {
color: #d1a572;
}
.json-number {
color: #85d0ff;
}
.json-true {
color: #7dc489;
}
.json-false {
color: #c74949;
}
.json-null {
color: #cd7c7c;
font-style: italic;
}
</style>

View File

@ -0,0 +1,140 @@
<script setup>
import { ref, toRefs, computed, watch, onMounted } from 'vue';
import CreateNewPostcode from 'src/components/CreateNewPostcodeForm.vue';
import VnSelectDialog from 'components/common/VnSelectDialog.vue';
import FetchData from 'components/FetchData.vue';
const emit = defineEmits(['update:modelValue', 'update:options']);
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const postcodesOptions = ref([]);
const postcodesRef = ref(null);
const $props = defineProps({
modelValue: {
type: [String, Number, Object],
default: null,
},
options: {
type: Array,
default: () => [],
},
optionLabel: {
type: String,
default: '',
},
optionValue: {
type: String,
default: '',
},
filterOptions: {
type: Array,
default: () => [],
},
isClearable: {
type: Boolean,
default: true,
},
defaultFilter: {
type: Boolean,
default: true,
},
});
const { options } = toRefs($props);
const myOptions = ref([]);
const myOptionsOriginal = ref([]);
const value = computed({
get() {
return $props.modelValue;
},
set(value) {
emit(
'update:modelValue',
postcodesOptions.value.find((p) => p.code === value)
);
},
});
onMounted(() => {
locationFilter($props.modelValue);
});
function setOptions(data) {
myOptions.value = JSON.parse(JSON.stringify(data));
myOptionsOriginal.value = JSON.parse(JSON.stringify(data));
}
setOptions(options.value);
watch(options, (newValue) => {
setOptions(newValue);
});
function showLabel(data) {
return `${data.code} - ${data.town}(${data.province}), ${data.country}`;
}
function locationFilter(search = '') {
if (
search &&
(search.includes('undefined') || search.startsWith(`${$props.modelValue} - `))
)
return;
let where = { search };
postcodesRef.value.fetch({ filter: { where }, limit: 30 });
}
function handleFetch(data) {
postcodesOptions.value = data;
}
</script>
<template>
<FetchData
ref="postcodesRef"
url="Postcodes/filter"
@on-fetch="(data) => handleFetch(data)"
/>
<VnSelectDialog
v-if="postcodesRef"
:option-label="(opt) => showLabel(opt) ?? 'code'"
:option-value="(opt) => opt.code"
v-model="value"
:options="postcodesOptions"
:label="t('Location')"
:placeholder="t('search_by_postalcode')"
@input-value="locationFilter"
:default-filter="false"
:input-debounce="300"
:class="{ required: $attrs.required }"
v-bind="$attrs"
clearable
>
<template #form>
<CreateNewPostcode @on-data-saved="locationFilter()" />
</template>
<template #option="{ itemProps, opt }">
<QItem v-bind="itemProps">
<QItemSection v-if="opt">
<QItemLabel>{{ opt.code }}</QItemLabel>
<QItemLabel caption>{{ showLabel(opt) }}</QItemLabel>
</QItemSection>
</QItem>
</template>
</VnSelectDialog>
</template>
<style lang="scss" scoped>
.add-icon {
cursor: pointer;
background-color: $primary;
border-radius: 50px;
}
</style>
<i18n>
en:
search_by_postalcode: Search by postalcode, town, province or country
es:
Location: Ubicación
search_by_postalcode: Buscar por código postal, ciudad o país
</i18n>

File diff suppressed because it is too large Load Diff

View File

@ -38,28 +38,26 @@ const workers = ref();
minimal
>
</QDate>
<QList dense>
<QSeparator />
<QItem>
<QItemSection v-if="!workers">
<QSkeleton type="QInput" class="full-width" />
</QItemSection>
<QItemSection v-if="workers">
<QSelect
:label="t('User')"
v-model="params.userFk"
@update:model-value="searchFn()"
:options="workers"
option-value="id"
option-label="name"
emit-value
map-options
use-input
:input-debounce="0"
/>
</QItemSection>
</QItem>
</QList>
<QSeparator />
<QItem>
<QItemSection v-if="!workers">
<QSkeleton type="QInput" class="full-width" />
</QItemSection>
<QItemSection v-if="workers">
<QSelect
:label="t('User')"
v-model="params.userFk"
@update:model-value="searchFn()"
:options="workers"
option-value="id"
option-label="name"
emit-value
map-options
use-input
:input-debounce="0"
/>
</QItemSection>
</QItem>
</template>
</VnFilterPanel>
</template>

View File

@ -0,0 +1,86 @@
<script setup>
import { ref, computed } from 'vue';
import VnSelectFilter from 'src/components/common/VnSelectFilter.vue';
import { useRole } from 'src/composables/useRole';
const emit = defineEmits(['update:modelValue']);
const $props = defineProps({
modelValue: {
type: [String, Number, Object],
default: null,
},
options: {
type: Array,
default: () => [],
},
rolesAllowedToCreate: {
type: Array,
default: () => ['developer'],
},
actionIcon: {
type: String,
default: 'add',
},
tooltip: {
type: String,
default: '',
},
});
const role = useRole();
const showForm = ref(false);
const value = computed({
get() {
return $props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
});
const isAllowedToCreate = computed(() => {
return role.hasAny($props.rolesAllowedToCreate);
});
const toggleForm = () => {
showForm.value = !showForm.value;
};
</script>
<template>
<VnSelectFilter v-model="value" :options="options" v-bind="$attrs">
<template v-if="isAllowedToCreate" #append>
<QIcon
@click.stop.prevent="toggleForm()"
:name="actionIcon"
:size="actionIcon === 'add' ? 'xs' : 'sm'"
:class="['default-icon', { '--add-icon': actionIcon === 'add' }]"
>
<QTooltip v-if="tooltip">{{ tooltip }}</QTooltip>
</QIcon>
<QDialog v-model="showForm" transition-show="scale" transition-hide="scale">
<slot name="form" />
</QDialog>
</template>
<template v-for="(_, slotName) in $slots" #[slotName]="slotData" :key="slotName">
<slot :name="slotName" v-bind="slotData" :key="slotName" />
</template>
</VnSelectFilter>
</template>
<style lang="scss" scoped>
.default-icon {
cursor: pointer;
color: $primary;
border-radius: 50px;
&.--add-icon {
color: var(--vn-text);
background-color: $primary;
}
}
</style>

View File

@ -1,10 +1,13 @@
<script setup>
import { ref, toRefs, watch, computed } from 'vue';
import { ref, toRefs, computed, watch } from 'vue';
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import FetchData from 'src/components/FetchData.vue';
const emit = defineEmits(['update:modelValue', 'update:options']);
const $props = defineProps({
modelValue: {
type: [String, Number],
type: [String, Number, Object],
default: null,
},
options: {
@ -12,26 +15,75 @@ const $props = defineProps({
default: () => [],
},
optionLabel: {
type: [String],
default: '',
},
optionValue: {
type: String,
default: '',
},
url: {
type: String,
default: '',
},
filterOptions: {
type: Array,
type: [Array],
default: () => [],
},
isClearable: {
type: Boolean,
default: true,
},
defaultFilter: {
type: Boolean,
default: true,
},
fields: {
type: Array,
default: null,
},
where: {
type: Object,
default: null,
},
sortBy: {
type: String,
default: null,
},
limit: {
type: Number,
default: 30,
},
});
const { optionLabel, options } = toRefs($props);
const { t } = useI18n();
const requiredFieldRule = (val) => !!val || t('globals.fieldRequired');
const { optionLabel, optionValue, options, modelValue } = toRefs($props);
const myOptions = ref([]);
const myOptionsOriginal = ref([]);
const vnSelectRef = ref(null);
const vnSelectRef = ref();
const dataRef = ref();
const value = computed({
get() {
return $props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
});
function setOptions(data) {
myOptions.value = JSON.parse(JSON.stringify(data));
myOptionsOriginal.value = JSON.parse(JSON.stringify(data));
}
setOptions(options.value);
onMounted(() => {
setOptions(options.value);
if ($props.url && $props.modelValue) fetchFilter($props.modelValue);
});
const filter = (val, options) => {
function filter(val, options) {
const search = val.toString().toLowerCase();
if (!search) return options;
@ -49,12 +101,29 @@ const filter = (val, options) => {
return id == search || optionLabel.includes(search);
});
};
}
const filterHandler = (val, update) => {
async function fetchFilter(val) {
if (!$props.url || !dataRef.value) return;
const { fields, sortBy, limit } = $props;
let key = optionLabel.value;
if (new RegExp(/\d/g).test(val)) key = optionValue.value;
const where = { [key]: { like: `%${val}%` } };
return dataRef.value.fetch({ fields, where, order: sortBy, limit });
}
async function filterHandler(val, update) {
if (!$props.defaultFilter) return update();
let newOptions;
if ($props.url) {
newOptions = await fetchFilter(val);
} else newOptions = filter(val, myOptionsOriginal.value);
update(
() => {
myOptions.value = filter(val, myOptionsOriginal.value);
myOptions.value = newOptions;
},
(ref) => {
if (val !== '' && ref.options.length > 0) {
@ -63,27 +132,33 @@ const filterHandler = (val, update) => {
}
}
);
};
}
watch(options, (newValue) => {
setOptions(newValue);
});
const value = computed({
get() {
return $props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
watch(modelValue, (newValue) => {
if (!myOptions.value.some((option) => option[optionValue.value] == newValue))
fetchFilter(newValue);
});
</script>
<template>
<FetchData
ref="dataRef"
:url="$props.url"
@on-fetch="(data) => setOptions(data)"
:where="where || { [optionValue]: value }"
:limit="limit"
:order-by="orderBy"
:fields="fields"
/>
<QSelect
v-model="value"
:options="myOptions"
:option-label="optionLabel"
:option-value="optionValue"
v-bind="$attrs"
emit-value
map-options
@ -92,12 +167,25 @@ const value = computed({
hide-selected
fill-input
ref="vnSelectRef"
:class="{ required: $attrs.required }"
:rules="$attrs.required ? [requiredFieldRule] : null"
>
<template #append>
<QIcon name="close" @click.stop="value = null" class="cursor-pointer" />
<template v-if="isClearable" #append>
<QIcon
name="close"
@click.stop="value = null"
class="cursor-pointer"
size="xs"
/>
</template>
<template v-for="(_, slotName) in $slots" #[slotName]="slotData">
<slot :name="slotName" v-bind="slotData ?? {}" />
<template v-for="(_, slotName) in $slots" #[slotName]="slotData" :key="slotName">
<slot :name="slotName" v-bind="slotData ?? {}" :key="slotName" />
</template>
</QSelect>
</template>
<style scoped lang="scss">
.q-field--outlined {
max-width: 100%;
}
</style>

View File

@ -1,7 +1,9 @@
<script setup>
import { ref, computed } from 'vue';
import { useDialogPluginComponent } from 'quasar';
import { useI18n } from 'vue-i18n';
import { useDialogPluginComponent } from 'quasar';
import VnInput from 'src/components/common/VnInput.vue';
const { dialogRef, onDialogOK } = useDialogPluginComponent();
const { t, availableLocales } = useI18n();
@ -117,24 +119,10 @@ async function send() {
/>
</QCardSection>
<QCardSection class="q-pb-xs">
<QInput
:label="t('Phone')"
v-model="phone"
rounded
outlined
autofocus
dense
/>
<VnInput :label="t('Phone')" v-model="phone" is-outlined />
</QCardSection>
<QCardSection class="q-pb-xs">
<QInput
:label="t('Subject')"
v-model="subject"
rounded
outlined
autofocus
dense
/>
<VnInput v-model="subject" :label="t('Subject')" is-outlined />
</QCardSection>
<QCardSection class="q-mb-md" q-input>
<QInput
@ -198,7 +186,7 @@ async function send() {
en:
CustomerDefaultLanguage: This customer uses <strong>{locale}</strong> as their default language
templates:
pendingPayment: 'Your order is pending of payment.
pendingPayment: 'Your order is pending of payment.
Please, enter the website and make the payment with a credit card. Thank you.'
minAmount: 'A minimum amount of 50 (VAT excluded) is required for your order
{ orderId } of { shipped } to receive it without additional shipping costs.'
@ -215,7 +203,7 @@ es:
Subject: Asunto
Message: Mensaje
templates:
pendingPayment: 'Su pedido está pendiente de pago.
pendingPayment: 'Su pedido está pendiente de pago.
Por favor, entre en la página web y efectue el pago con tarjeta. Muchas gracias.'
minAmount: 'Es necesario un importe mínimo de 50 (Sin IVA) en su pedido
{ orderId } del día { shipped } para recibirlo sin portes adicionales.'
@ -249,7 +237,7 @@ pt:
Subject: Assunto
Message: Mensagem
templates:
pendingPayment: 'Seu pedido está pendente de pagamento.
pendingPayment: 'Seu pedido está pendente de pagamento.
Por favor, acesse o site e faça o pagamento com cartão. Muito obrigado.'
minAmount: 'É necessário um valor mínimo de 50 (sem IVA) em seu pedido
{ orderId } do dia { shipped } para recebê-lo sem custos de envio adicionais.'

View File

@ -1,21 +1,23 @@
<script setup>
import { useDialogPluginComponent } from 'quasar';
import WorkerSummary from './WorkerSummary.vue';
const $props = defineProps({
defineProps({
id: {
type: Number,
required: true,
},
summary: {
type: Object,
required: true,
},
});
defineEmits([...useDialogPluginComponent.emits]);
const { dialogRef, onDialogHide } = useDialogPluginComponent();
</script>
<template>
<QDialog ref="dialogRef" @hide="onDialogHide">
<WorkerSummary v-if="$props.id" :id="$props.id" />
<QDialog ref="dialogRef" @hide="onDialogHide" full-width>
<component :is="summary" :id="id" />
</QDialog>
</template>

View File

@ -1,8 +1,9 @@
<script setup>
import { onMounted, useSlots, ref, watch } from 'vue';
import { onMounted, useSlots, watch, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import SkeletonDescriptor from 'components/ui/SkeletonDescriptor.vue';
import { useArrayData } from 'composables/useArrayData';
import { useSummaryDialog } from 'src/composables/useSummaryDialog';
const $props = defineProps({
url: {
@ -25,78 +26,95 @@ const $props = defineProps({
type: Number,
default: 0,
},
dataKey: {
type: String,
default: '',
},
summary: {
type: Object,
default: null,
},
});
const slots = useSlots();
const { t } = useI18n();
const entity = ref();
const { viewSummary } = useSummaryDialog();
const entity = computed(() => useArrayData($props.dataKey).store.data);
const isLoading = ref(false);
defineExpose({
getData,
});
onMounted(async () => {
await fetch();
await getData();
watch(
() => $props.url,
async (newUrl, lastUrl) => {
if (newUrl == lastUrl) return;
await getData();
}
);
});
const emit = defineEmits(['onFetch']);
async function fetch() {
const params = {};
if ($props.filter) params.filter = JSON.stringify($props.filter);
const { data } = await axios.get($props.url, { params });
entity.value = data;
emit('onFetch', data);
async function getData() {
const arrayData = useArrayData($props.dataKey, {
url: $props.url,
filter: $props.filter,
skip: 0,
});
isLoading.value = true;
try {
const { data } = await arrayData.fetch({ append: false, updateRouter: false });
emit('onFetch', data);
} finally {
isLoading.value = false;
}
}
watch($props, async () => {
entity.value = null;
await fetch();
});
const emit = defineEmits(['onFetch']);
</script>
<template>
<div class="descriptor">
<template v-if="entity">
<div class="header bg-primary q-pa-sm">
<RouterLink :to="{ name: `${module}List` }">
<QBtn
round
flat
dense
size="md"
icon="view_list"
color="white"
class="link"
>
<QTooltip>
{{ t('components.cardDescriptor.mainList') }}
</QTooltip>
</QBtn>
</RouterLink>
<template v-if="entity && !isLoading">
<div class="header bg-primary q-pa-sm justify-between">
<slot name="header-extra-action" />
<QBtn
@click.stop="viewSummary(entity.id, $props.summary)"
round
flat
dense
size="md"
icon="preview"
color="white"
class="link"
v-if="summary"
>
<QTooltip>
{{ t('components.smartCard.openSummary') }}
</QTooltip>
</QBtn>
<RouterLink :to="{ name: `${module}Summary`, params: { id: entity.id } }">
<QBtn
round
flat
dense
size="md"
icon="launch"
color="white"
class="link"
color="white"
dense
flat
icon="launch"
round
size="md"
>
<QTooltip>
{{ t('components.cardDescriptor.summary') }}
</QTooltip>
</QBtn>
</RouterLink>
<QBtn
v-if="slots.menu"
size="md"
icon="more_vert"
color="white"
round
flat
dense
flat
icon="more_vert"
round
size="md"
:class="{ invisible: !slots.menu }"
>
<QTooltip>
{{ t('components.cardDescriptor.moreOptions') }}
@ -142,8 +160,13 @@ watch($props, async () => {
<slot name="after" />
</template>
<!-- Skeleton -->
<SkeletonDescriptor v-if="!entity" />
<SkeletonDescriptor v-if="!entity || isLoading" />
</div>
<QInnerLoading
:label="t('globals.pleaseWait')"
:showing="isLoading"
color="primary"
/>
</template>
<style lang="scss">
@ -198,11 +221,8 @@ watch($props, async () => {
margin-bottom: 15px;
}
.list-box {
width: 90%;
background-color: var(--vn-gray);
margin: 10px auto;
padding: 10px 5px 10px 0px;
border-radius: 8px;
.q-item__label {
color: var(--vn-label);
}
@ -211,8 +231,6 @@ watch($props, async () => {
width: 256px;
.header {
display: flex;
justify-content: space-between;
align-items: stretch;
}
.icons {
margin: 0 10px;

View File

@ -1,66 +1,113 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const $props = defineProps({
element: { type: Object, default: null },
id: { type: Number, default: null },
isSelected: { type: Boolean, default: false },
title: { type: String, default: null },
showCheckbox: { type: Boolean, default: false },
hasInfoIcons: { type: Boolean, default: false },
});
const emit = defineEmits(['toggleCardCheck']);
const toggleCardCheck = (item) => {
emit('toggleCardCheck', item);
};
</script>
<template>
<QCard class="card q-mb-md cursor-pointer q-hoverable bg-white-7 q-pa-lg">
<div>
<slot name="title">
<div class="title text-primary text-weight-bold text-h5">
{{ $props.title ?? `#${$props.id}` }}
<div class="flex justify-between">
<div class="flex items-center">
<div class="title text-primary text-weight-bold text-h5">
{{ $props.title }}
</div>
<QChip class="q-chip-color" outline size="sm">
{{ t('ID') }}: {{ $props.id }}
</QChip>
</div>
<QCheckbox
v-if="showCheckbox"
:model-value="isSelected"
@click="toggleCardCheck($props.element)"
/>
</div>
</slot>
<div class="card-list-body row">
<div class="list-items row flex-wrap-wrap q-mt-md">
<div class="card-list-body">
<div v-if="hasInfoIcons" class="column q-mr-md q-gutter-y-xs">
<slot name="info-icons" />
</div>
<div class="list-items row flex-wrap-wrap">
<slot name="list-items" />
</div>
<div class="actions column justify-center">
<div class="actions">
<slot name="actions" />
</div>
</div>
</div>
</QCard>
</template>
<style lang="scss">
.title {
margin-right: 25px;
}
.q-chip-color {
color: var(--vn-label);
}
.card-list-body {
display: flex;
justify-content: space-between;
margin-top: 10px;
.vn-label-value {
display: flex;
justify-content: flex-start;
gap: 2%;
width: 50%;
.label {
width: 30%;
width: 35%;
color: var(--vn-label);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.value {
width: 60%;
width: 65%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.actions {
.q-btn {
width: 30px;
}
.q-icon {
color: $primary;
font-size: 25px;
}
display: flex;
flex-direction: column;
justify-content: center;
width: 25%;
}
}
@media (max-width: $breakpoint-xs) {
.card-list-body {
flex-wrap: wrap;
justify-content: center;
.vn-label-value {
width: 100%;
}
.actions {
width: 100%;
margin-top: 15px;
padding: 0 15%;
justify-content: center;
}
}
}
</style>
@ -73,11 +120,11 @@ const $props = defineProps({
background-color: var(--vn-gray);
}
.list-items {
width: 90%;
}
@media (max-width: $breakpoint-xs) {
.list-items {
width: 85%;
}
width: 75%;
}
</style>
<i18n>
es:
ID: ID
</i18n>

View File

@ -27,7 +27,7 @@ defineExpose({
async function fetch() {
const params = {};
if (props.filter) params.filter = props.filter;
if (props.filter) params.filter = JSON.stringify(props.filter);
const { data } = await axios.get(props.url, { params });
entity.value = data;
@ -73,6 +73,7 @@ watch(props, async () => {
.cardSummary {
width: 100%;
.summaryHeader {
text-align: center;
font-size: 20px;
@ -82,21 +83,23 @@ watch(props, async () => {
.summaryBody {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
gap: 15px;
padding: 15px;
background-color: var(--vn-gray);
> .q-card.vn-one {
flex: 1;
}
> .q-card.vn-two {
flex: 2;
flex: 40%;
}
> .q-card.vn-three {
flex: 3;
flex: 75%;
}
> .q-card.vn-max {
width: 100%;
flex: 100%;
}
> .q-card {
@ -112,18 +115,17 @@ watch(props, async () => {
margin-top: 5px;
.label {
color: var(--vn-label);
width: 10em;
width: 8em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 10px;
flex-grow: 0;
flex-shrink: 0;
}
.value {
color: var(--vn-text);
width: max-content;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.header {

View File

@ -1,10 +1,39 @@
<template>
<div id="descriptor-skeleton">
<div class="col q-pl-sm q-pa-sm">
<QSkeleton type="text" square height="45px" />
<QSkeleton type="text" square height="18px" />
<QSkeleton type="text" square height="18px" />
<QSkeleton type="text" square height="18px" />
<div class="row justify-between q-pa-sm">
<QSkeleton square size="40px" />
<QSkeleton square size="40px" />
<QSkeleton square height="40px" width="20px" />
</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>
<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>
</div>
<QCardActions>

View File

@ -1,18 +1,37 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSession } from 'src/composables/useSession';
import { useColor } from 'src/composables/useColor';
const $props = defineProps({
worker: { type: Number, required: true },
workerId: { type: Number, required: true },
description: { type: String, default: null },
size: { type: String, default: null },
title: { type: String, default: null },
});
const session = useSession();
const token = session.getToken();
const { t } = useI18n();
const title = computed(() => $props.title ?? t('globals.system'));
const showLetter = ref(false);
</script>
<template>
<div class="avatar-picture column items-center">
<QAvatar color="orange">
<QAvatar
:style="{
backgroundColor: useColor(title),
}"
:size="$props.size"
:title="title"
>
<template v-if="showLetter">{{ title.charAt(0) }}</template>
<QImg
:src="`/api/Images/user/160x160/${$props.worker}/download?access_token=${token}`"
v-else
:src="`/api/Images/user/160x160/${$props.workerId}/download?access_token=${token}`"
spinner-color="white"
@error="showLetter = true"
/>
</QAvatar>
<div class="description">

View File

@ -4,6 +4,8 @@ import { useI18n } from 'vue-i18n';
import { useArrayData } from 'composables/useArrayData';
import toDate from 'filters/toDate';
import VnFilterPanelChip from 'components/ui/VnFilterPanelChip.vue';
const { t } = useI18n();
const props = defineProps({
dataKey: {
@ -24,11 +26,32 @@ const props = defineProps({
type: Boolean,
default: true,
},
unremovableParams: {
type: Array,
required: false,
default: () => [],
description:
'Algunos filtros vienen con parametros de búsqueda por default y necesitan tener si o si un valor, por eso de ser necesario, esta prop nos sirve para saber que filtros podemos remover y cuales no',
},
exprBuilder: {
type: Function,
default: null,
},
hiddenTags: {
type: Array,
default: () => [],
},
customTags: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['refresh', 'clear']);
const emit = defineEmits(['refresh', 'clear', 'search', 'init', 'remove']);
const arrayData = useArrayData(props.dataKey);
const arrayData = useArrayData(props.dataKey, {
exprBuilder: props.exprBuilder,
});
const store = arrayData.store;
const userParams = ref({});
@ -37,22 +60,23 @@ onMounted(() => {
if (Object.keys(store.userParams).length > 0) {
userParams.value = JSON.parse(JSON.stringify(store.userParams));
}
emit('init', { params: userParams.value });
});
const isLoading = ref(false);
async function search() {
for (const param in userParams.value) {
if (userParams.value[param] === '' || userParams.value[param] === null) {
delete userParams.value[param];
delete store.userParams[param];
}
}
const params = { ...userParams.value };
isLoading.value = true;
await arrayData.addFilter({ params });
const params = { ...userParams.value };
store.userParamsChanged = true;
store.filter.skip = 0;
store.skip = 0;
const { params: newParams } = await arrayData.addFilter({ params });
userParams.value = newParams;
if (!props.showAll && !Object.values(params).length) store.data = [];
isLoading.value = false;
emit('search');
}
async function reload() {
@ -66,32 +90,50 @@ async function reload() {
}
async function clearFilters() {
userParams.value = {};
isLoading.value = true;
await arrayData.applyFilter({ params: {} });
if (!props.showAll) store.data = [];
isLoading.value = false;
store.userParamsChanged = true;
store.filter.skip = 0;
store.skip = 0;
// Filtrar los params no removibles
const removableFilters = Object.keys(userParams.value).filter((param) =>
props.unremovableParams.includes(param)
);
const newParams = {};
// Conservar solo los params que no son removibles
for (const key of removableFilters) {
newParams[key] = userParams.value[key];
}
userParams.value = { ...newParams }; // Actualizar los params con los removibles
await arrayData.applyFilter({ params: userParams.value });
if (!props.showAll) {
store.data = [];
}
isLoading.value = false;
emit('clear');
}
const tags = computed(() => {
const params = [];
const tagsList = computed(() =>
Object.entries(userParams.value)
.filter(([key, value]) => value && !(props.hiddenTags || []).includes(key))
.map(([key, value]) => ({
label: key,
value: value,
}))
);
for (const param in store.userParams) {
params.push({
label: param,
value: store.userParams[param],
});
}
return params;
});
const tags = computed(() =>
tagsList.value.filter((tag) => !(props.customTags || []).includes(tag.label))
);
const customTags = computed(() =>
tagsList.value.filter((tag) => (props.customTags || []).includes(tag.label))
);
async function remove(key) {
delete userParams.value[key];
delete store.userParams[key];
userParams.value[key] = null;
await search();
emit('remove', key);
}
function formatValue(value) {
@ -106,6 +148,7 @@ function formatValue(value) {
return `"${value}"`;
}
</script>
<template>
<QForm @submit="search">
<QList dense>
@ -119,48 +162,44 @@ function formatValue(value) {
<div class="q-gutter-xs">
<QBtn
@click="clearFilters"
icon="filter_list_off"
color="primary"
size="sm"
dense
flat
icon="filter_list_off"
padding="none"
round
flat
dense
size="sm"
>
<QTooltip>{{ t('Remove filters') }}</QTooltip>
</QBtn>
<QBtn
@click="reload"
icon="refresh"
color="primary"
size="sm"
dense
flat
icon="refresh"
padding="none"
round
flat
dense
size="sm"
>
<QTooltip>{{ t('Refresh') }}</QTooltip>
</QBtn>
</div>
</QItemSection>
</QItem>
<QItem>
<QItem class="q-mb-sm">
<div
v-if="tags.length === 0"
v-if="tagsList.length === 0"
class="text-grey font-xs text-center full-width"
>
{{ t(`No filters applied`) }}
</div>
<div>
<QChip
<VnFilterPanelChip
v-for="chip of tags"
:key="chip.label"
:removable="!unremovableParams.includes(chip.label)"
@remove="remove(chip.label)"
icon="label"
color="primary"
class="text-dark"
size="sm"
removable
>
<slot name="tags" :tag="chip" :format-fn="formatValue">
<div class="q-gutter-x-xs">
@ -168,39 +207,55 @@ function formatValue(value) {
<span>"{{ chip.value }}"</span>
</div>
</slot>
</QChip>
</VnFilterPanelChip>
<slot
v-if="$slots.customTags"
name="customTags"
:params="userParams"
:tags="customTags"
:format-fn="formatValue"
:search-fn="search"
/>
</div>
</QItem>
<QSeparator />
<template v-if="props.searchButton">
<QItem>
<QItemSection class="q-py-sm">
<QBtn
:label="t('Search')"
type="submit"
color="primary"
class="full-width"
icon="search"
unelevated
rounded
dense
/>
</QItemSection>
</QItem>
<QSeparator />
</template>
</QList>
<slot name="body" :params="userParams" :search-fn="search"></slot>
<QList dense class="list q-gutter-y-sm q-mt-sm">
<slot name="body" :params="userParams" :search-fn="search"></slot>
</QList>
<template v-if="props.searchButton">
<QItem>
<QItemSection class="q-py-sm">
<QBtn
:label="t('Search')"
class="full-width"
color="primary"
dense
icon="search"
rounded
type="submit"
unelevated
/>
</QItemSection>
</QItem>
<QSeparator />
</template>
</QForm>
<QInnerLoading
:showing="isLoading"
:label="t('globals.pleaseWait')"
:showing="isLoading"
color="primary"
/>
</template>
<style scoped lang="scss">
.list {
width: 256px;
}
</style>
<i18n>
es:
es:
No filters applied: No se han aplicado filtros
Applied filters: Filtros aplicados
Remove filters: Eliminar filtros

View File

@ -0,0 +1,7 @@
<script setup></script>
<template>
<QChip class="text-dark" color="primary" icon="label" size="sm" v-bind="$attrs">
<slot />
</QChip>
</template>

View File

@ -0,0 +1,22 @@
<script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({
phoneNumber: { type: [String, Number], default: null },
});
const { t } = useI18n();
</script>
<template>
<QBtn
v-if="props.phoneNumber"
flat
round
icon="phone"
size="sm"
color="primary"
padding="none"
:href="`sip:${props.phoneNumber}`"
:title="t('globals.microsip')"
@click.stop
/>
</template>
<style scoped></style>

View File

@ -2,21 +2,41 @@
import { computed } from 'vue';
import { dashIfEmpty } from 'src/filters';
import { useClipboard } from 'src/composables/useClipboard';
const $props = defineProps({
label: { type: String, default: null },
value: { type: [Number, String, Boolean], default: null },
titleLabel: { type: String, default: null },
titleValue: { type: [Number, String, Boolean], default: null },
value: {
type: [String, Boolean, Number],
default: null,
},
info: { type: String, default: null },
dash: { type: Boolean, default: true },
copy: { type: Boolean, default: false },
});
const isBooleanValue = computed(() => typeof $props.value === 'boolean');
const { copyText } = useClipboard();
function copyValueText() {
copyText($props.value, {
component: {
copyValue: $props.value,
},
});
}
</script>
<style scoped>
.label,
.value {
white-space: pre-line;
word-wrap: break-word;
}
</style>
<template>
<div class="vn-label-value">
<div v-if="$props.label || $slots.label" class="label">
<slot name="label">
<span :title="$props.titleLabel ?? $props.label">{{ $props.label }}</span>
<span>{{ $props.label }}</span>
</slot>
</div>
<div class="value">
@ -40,5 +60,16 @@ const isBooleanValue = computed(() => typeof $props.value === 'boolean');
</QTooltip>
</QIcon>
</div>
<div class="copy" v-if="$props.copy && $props.value" @click="copyValueText()">
<QIcon name="Content_Copy" color="primary" />
</div>
</div>
</template>
<style lang="scss" scoped>
.copy {
&:hover {
cursor: pointer;
}
}
</style>

View File

@ -1,11 +1,11 @@
<script setup>
import WorkerDescriptorProxy from 'src/pages/Worker/Card/WorkerDescriptorProxy.vue';
import VnAvatar from 'src/components/ui/VnAvatar.vue';
import { toDateHour } from 'src/filters';
import { ref } from 'vue';
import axios from 'axios';
import { useI18n } from 'vue-i18n';
import VnPaginate from './VnPaginate.vue';
import VnUserLink from '../ui/VnUserLink.vue';
const $props = defineProps({
id: { type: String, required: true },
@ -28,7 +28,7 @@ async function insert() {
}
</script>
<template>
<div class="column items-center">
<div class="column items-center full-height">
<VnPaginate
:data-key="$props.url"
:url="$props.url"
@ -39,22 +39,23 @@ async function insert() {
ref="vnPaginateRef"
>
<template #body="{ rows }">
<QCard class="q-pa-md q-mb-md" v-for="(note, index) in rows" :key="index">
<QCard class="q-pa-xs q-mb-md" v-for="(note, index) in rows" :key="index">
<QCardSection horizontal>
<slot name="picture">
<VnAvatar :worker="note.workerFk" />
<VnAvatar :descriptor="false" :worker-id="note.workerFk" />
</slot>
<QItem class="full-width justify-between items-start">
<span class="link">
{{ `${note.worker.firstName} ${note.worker.lastName}` }}
<WorkerDescriptorProxy :id="note.worker.id" />
</span>
<VnUserLink
:name="`${note.worker.user.nickname}`"
:worker-id="note.worker.id"
/>
<slot name="actions">
{{ toDateHour(note.created) }}
</slot>
</QItem>
</QCardSection>
<QCardSection>
<QCardSection class="q-pa-sm">
<slot name="text">
{{ note.text }}
</slot>
@ -62,15 +63,8 @@ async function insert() {
</QCard>
</template>
</VnPaginate>
<QPageSticky position="bottom-right" :offset="[25, 25]">
<QBtn
v-if="addNote"
color="primary"
icon="add"
size="lg"
round
@click="noteModal = true"
/>
<QPageSticky position="bottom-right" :offset="[25, 25]" v-if="addNote">
<QBtn color="primary" icon="add" size="lg" round @click="noteModal = true" />
</QPageSticky>
<QDialog v-model="noteModal" @hide="newNote = ''">
<QCard>
@ -115,6 +109,10 @@ async function insert() {
<style lang="scss" scoped>
.q-card {
max-width: 80em;
&__section {
word-wrap: break-word;
}
}
.q-dialog .q-card {
width: 400px;

View File

@ -31,7 +31,7 @@ const props = defineProps({
default: null,
},
order: {
type: String,
type: [String, Array],
default: '',
},
limit: {
@ -50,6 +50,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
exprBuilder: {
type: Function,
default: null,
},
});
const emit = defineEmits(['onFetch', 'onPaginate']);
@ -68,6 +72,7 @@ const arrayData = useArrayData(props.dataKey, {
limit: props.limit,
order: props.order,
userParams: props.userParams,
exprBuilder: props.exprBuilder,
});
const store = arrayData.store;
@ -99,6 +104,8 @@ async function paginate() {
await arrayData.loadMore();
if (!arrayData.hasMoreData.value) {
if (store.userParamsChanged) arrayData.hasMoreData.value = true;
store.userParamsChanged = false;
isLoading.value = false;
return;
}
@ -124,9 +131,9 @@ async function onLoad(...params) {
pagination.value.page = pagination.value.page + 1;
await paginate();
const endOfPages = !arrayData.hasMoreData.value;
done(endOfPages);
let isDone = false;
if (store.userParamsChanged) isDone = !arrayData.hasMoreData.value;
done(isDone);
}
</script>
@ -144,7 +151,7 @@ async function onLoad(...params) {
v-if="props.skeleton && props.autoLoad && !store.data"
class="card-list q-gutter-y-md"
>
<QCard class="card" v-for="$index in $props.limit" :key="$index">
<QCard class="card" v-for="$index in props.limit" :key="$index">
<QItem v-ripple class="q-pa-none items-start cursor-pointer q-hoverable">
<QItemSection class="q-pa-md">
<QSkeleton type="rect" class="q-mb-md" square />
@ -163,7 +170,12 @@ async function onLoad(...params) {
</QCard>
</div>
</div>
<QInfiniteScroll v-if="store.data" @load="onLoad" :offset="offset" class="full-width">
<QInfiniteScroll
v-if="store.data"
@load="onLoad"
:offset="offset"
class="full-width full-height"
>
<slot name="body" :rows="store.data"></slot>
<div v-if="isLoading" class="info-row q-pa-md text-center">
<QSpinner color="orange" size="md" />

View File

@ -1,8 +1,12 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useArrayData } from 'composables/useArrayData';
import { useQuasar } from 'quasar';
import VnInput from 'src/components/common/VnInput.vue';
import { useArrayData } from 'composables/useArrayData';
const quasar = useQuasar();
const props = defineProps({
@ -49,6 +53,18 @@ const props = defineProps({
type: Object,
default: null,
},
staticParams: {
type: Array,
default: () => [],
},
exprBuilder: {
type: Function,
default: null,
},
customRouteRedirectName: {
type: String,
default: '',
},
});
const router = useRouter();
@ -65,28 +81,34 @@ onMounted(() => {
});
async function search() {
const staticParams = Object.entries(store.userParams)
.filter(([key, value]) => value && (props.staticParams || []).includes(key));
await arrayData.applyFilter({
params: {
...Object.fromEntries(staticParams),
search: searchText.value,
},
});
if (!props.redirect) return;
const rows = store.data;
const module = route.matched[1];
if (rows.length === 1) {
const [firstRow] = rows;
await router.push({ path: `${module.path}/${firstRow.id}` });
} else if (route.matched.length > 3) {
await router.push({ path: `/${module.path}` });
arrayData.updateStateParams();
if (props.customRouteRedirectName) {
router.push({
name: props.customRouteRedirectName,
params: { id: searchText.value },
});
return;
}
const { matched: matches } = route;
const { path } = matches[matches.length - 1];
const newRoute = path.replace(':id', searchText.value);
await router.push(newRoute);
}
</script>
<template>
<QForm @submit="search">
<QInput
<VnInput
id="searchbar"
v-model="searchText"
:placeholder="props.label"
@ -113,7 +135,7 @@ async function search() {
<QTooltip>{{ props.info }}</QTooltip>
</QIcon>
</template>
</QInput>
</VnInput>
</QForm>
</template>

114
src/components/ui/VnSms.vue Normal file
View File

@ -0,0 +1,114 @@
<script setup>
import { onBeforeMount } from 'vue';
import { date } from 'quasar';
import VnPaginate from 'src/components/ui/VnPaginate.vue';
import VnAvatar from '../ui/VnAvatar.vue';
import VnUserLink from 'src/components/ui/VnUserLink.vue';
const $props = defineProps({
url: { type: String, default: null },
where: { type: Object, default: () => {} },
});
const filter = {
fields: ['smsFk'],
include: {
relation: 'sms',
scope: {
fields: [
'senderFk',
'sender',
'destination',
'message',
'statusCode',
'status',
'created',
],
include: {
relation: 'sender',
scope: {
fields: ['name'],
},
},
},
},
};
onBeforeMount(() => (filter.where = $props.where));
function formatNumber(number) {
if (number.length <= 10) return number;
return number.slice(0, 4) + ' ' + number.slice(4);
}
</script>
<template>
<div class="column items-center">
<div class="list">
<VnPaginate
:data-key="$props.url"
:url="$props.url"
:filter="filter"
order="smsFk DESC"
:offset="100"
:limit="5"
auto-load
>
<template #body="{ rows }">
<QCard
flat
bordered
class="card q-pa-md q-mb-sm smsCard"
v-for="row of rows"
:key="row.smsFk"
>
<QItem>
<QItemSection side top>
<VnUserLink :worker-id="row.sms?.senderFk">
<template #link>
<VnAvatar
:worker-id="row.sms?.senderFk"
class="cursor-pointer"
:title="row.sms?.sender?.name"
/>
</template>
</VnUserLink>
</QItemSection>
<QSeparator />
<QItemSection>
<QItemLabel caption>{{
formatNumber(row.sms.destination)
}}</QItemLabel>
<QItemLabel>{{ row.sms.message }}</QItemLabel>
</QItemSection>
<QItemSection side>
<QItemLabel caption>{{
date.formatDate(
row.sms.created,
'YYYY-MM-DD HH:mm:ss'
)
}}</QItemLabel>
<QItemLabel class="row center">
<QChip
:color="
row.sms.status == 'OK'
? 'positive'
: 'negative'
"
>
{{ row.sms.status }}
</QChip>
</QItemLabel>
</QItemSection>
</QItem>
</QCard>
</template>
</VnPaginate>
</div>
</div>
</template>
<style lang="scss" scoped>
.q-item__section--side {
align-items: center;
}
</style>

View File

@ -0,0 +1,38 @@
<script setup>
import { onMounted, onUnmounted } from 'vue';
import { useStateStore } from 'stores/useStateStore';
const stateStore = useStateStore();
onMounted(() => {
stateStore.toggleSubToolbar();
});
onUnmounted(() => {
stateStore.toggleSubToolbar();
});
</script>
<template>
<QToolbar class="bg-vn-dark justify-end sticky">
<slot name="st-data">
<div id="st-data"></div>
</slot>
<QSpace />
<slot name="st-actions">
<div id="st-actions"></div>
</slot>
</QToolbar>
</template>
<style lang="scss" scoped>
.sticky {
position: sticky;
top: 61px;
z-index: 1;
}
@media (max-width: $breakpoint-sm) {
.sticky {
top: 90px;
}
}
</style>

View File

@ -0,0 +1,159 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import CreateDepartmentChild from '../CreateDepartmentChild.vue';
import axios from 'axios';
import useNotify from 'src/composables/useNotify.js';
const quasar = useQuasar();
const { t } = useI18n();
const router = useRouter();
const { notify } = useNotify();
const treeRef = ref(null);
const showCreateNodeFormVal = ref(false);
const creationNodeSelectedId = ref(null);
const expanded = ref([]);
const nodes = ref([{ id: null, name: t('Departments'), sons: true, children: [{}] }]);
const fetchedChildrensSet = ref(new Set());
const onNodeExpanded = (nodeKeysArray) => {
// Verificar si el nodo ya fue expandido
if (!fetchedChildrensSet.value.has(nodeKeysArray.at(-1))) {
fetchedChildrensSet.value.add(nodeKeysArray.at(-1));
fetchNodeLeaves(nodeKeysArray.at(-1)); // Llamar a la función para obtener los nodos hijos
}
};
const fetchNodeLeaves = async (nodeKey) => {
try {
const node = treeRef.value.getNodeByKey(nodeKey);
if (!node || node.sons === 0) return;
const params = { parentId: node.id };
const response = await axios.get('/departments/getLeaves', { params });
// Si hay datos en la respuesta y tiene hijos, agregarlos al nodo actual
if (response.data) {
node.children = response.data;
node.children.forEach((node) => {
if (node.sons) node.children = [{}];
});
}
} catch (err) {
console.error('Error fetching department leaves');
throw new Error();
}
};
const removeNode = (node) => {
const { id, parentFk } = node;
quasar
.dialog({
title: t('Are you sure you want to delete it?'),
message: t('Delete department'),
ok: {
push: true,
color: 'primary',
},
cancel: true,
})
.onOk(async () => {
try {
await axios.post(`/Departments/${id}/removeChild`, id);
notify(t('department.departmentRemoved'), 'positive');
await fetchNodeLeaves(parentFk);
} catch (err) {
console.log('Error removing department');
}
});
};
const showCreateNodeForm = (nodeId) => {
showCreateNodeFormVal.value = true;
creationNodeSelectedId.value = nodeId;
};
const onNodeCreated = async () => {
await fetchNodeLeaves(creationNodeSelectedId.value);
};
const redirectToDepartmentSummary = (id) => {
if (!id) return;
router.push({ name: 'DepartmentSummary', params: { id } });
};
</script>
<template>
<QCard class="full-width" style="max-width: 800px">
<QTree
ref="treeRef"
:nodes="nodes"
node-key="id"
label-key="name"
v-model:expanded="expanded"
@update:expanded="onNodeExpanded($event)"
>
<template #default-header="{ node }">
<div
class="row justify-between full-width q-pr-md cursor-pointer"
@click.stop="redirectToDepartmentSummary(node.id)"
>
<span class="text-uppercase">
{{ node.name }}
</span>
<div class="row justify-between" style="max-width: max-content">
<QIcon
v-if="node.id"
name="delete"
color="primary"
size="sm"
class="q-pr-xs cursor-pointer"
@click.stop="removeNode(node)"
>
<QTooltip>
{{ t('Remove') }}
</QTooltip>
</QIcon>
<QIcon
name="add"
color="primary"
size="sm"
class="cursor-pointer"
@click.stop="showCreateNodeForm(node.id)"
>
<QTooltip>
{{ t('Create') }}
</QTooltip>
</QIcon>
</div>
</div>
</template>
</QTree>
<QDialog
v-model="showCreateNodeFormVal"
transition-show="scale"
transition-hide="scale"
>
<CreateDepartmentChild
:parent-id="creationNodeSelectedId"
@on-data-saved="onNodeCreated()"
/>
</QDialog>
</QCard>
</template>
<i18n>
es:
Departments: Departamentos
Remove: Quitar
Create: Crear
Are you sure you want to delete it?: ¿Seguro que quieres eliminarlo?
Delete department: Eliminar departamento
</i18n>

View File

@ -0,0 +1,21 @@
<script setup>
import WorkerDescriptorProxy from 'src/pages/Worker/Card/WorkerDescriptorProxy.vue';
import { useI18n } from 'vue-i18n';
const $props = defineProps({
name: { type: String, default: null },
workerId: { type: Number, default: null },
defaultName: { type: Boolean, default: false },
});
const { t } = useI18n();
</script>
<template>
<slot name="link">
<span :class="{ link: $props.workerId }">
{{ $props.defaultName ? $props.name ?? t('globals.system') : $props.name }}
</span>
</slot>
<WorkerDescriptorProxy v-if="$props.workerId" :id="$props.workerId" />
</template>
<style scoped></style>

View File

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

View File

@ -2,6 +2,7 @@ import { onMounted, ref, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import axios from 'axios';
import { useArrayDataStore } from 'stores/useArrayDataStore';
import { buildFilter } from 'filters/filterPanel';
const arrayDataStore = useArrayDataStore();
@ -29,6 +30,10 @@ export function useArrayData(key, userOptions) {
}
});
if (key && userOptions) {
setOptions();
}
function setOptions() {
const allowedOptions = [
'url',
@ -39,10 +44,11 @@ export function useArrayData(key, userOptions) {
'skip',
'userParams',
'userFilter',
'exprBuilder',
];
if (typeof userOptions === 'object') {
for (const option in userOptions) {
const isEmpty = userOptions[option] == null || userOptions[option] == '';
const isEmpty = userOptions[option] == null || userOptions[option] === '';
if (isEmpty || !allowedOptions.includes(option)) continue;
if (Object.prototype.hasOwnProperty.call(store, option)) {
@ -52,7 +58,7 @@ export function useArrayData(key, userOptions) {
}
}
async function fetch({ append = false }) {
async function fetch({ append = false, updateRouter = true }) {
if (!store.url) return;
cancelRequest();
@ -64,16 +70,27 @@ export function useArrayData(key, userOptions) {
skip: store.skip,
};
Object.assign(filter, store.userFilter);
Object.assign(store.filter, filter);
let exprFilter;
let userParams = { ...store.userParams };
if (store?.exprBuilder) {
const where = buildFilter(userParams, (param, value) => {
const res = store.exprBuilder(param, value);
if (res) delete userParams[param];
return res;
});
exprFilter = where ? { where } : null;
}
Object.assign(filter, store.userFilter, exprFilter);
Object.assign(store.filter, filter);
const params = {
filter: JSON.stringify(store.filter),
};
Object.assign(params, store.userParams);
Object.assign(params, userParams);
store.isLoading = true;
store.currentFilter = params;
const response = await axios.get(store.url, {
signal: canceller.signal,
params,
@ -83,20 +100,19 @@ export function useArrayData(key, userOptions) {
hasMoreData.value = response.data.length === limit;
if (append === true) {
if (append) {
if (!store.data) store.data = [];
for (const row of response.data) store.data.push(row);
}
if (append === false) {
} else {
store.data = response.data;
updateStateParams();
if (!document.querySelectorAll('[role="dialog"]'))
updateRouter && updateStateParams();
}
store.isLoading = false;
canceller = null;
return response;
}
function destroy() {
@ -114,6 +130,7 @@ export function useArrayData(key, userOptions) {
async function applyFilter({ filter, params }) {
if (filter) store.userFilter = filter;
store.filter = {};
if (params) store.userParams = Object.assign({}, params);
await fetch({ append: false });
@ -121,9 +138,33 @@ export function useArrayData(key, userOptions) {
async function addFilter({ filter, params }) {
if (filter) store.userFilter = Object.assign(store.userFilter, filter);
if (params) store.userParams = Object.assign(store.userParams, params);
let userParams = Object.assign({}, store.userParams, params);
userParams = sanitizerParams(userParams, store?.exprBuilder);
store.userParams = userParams;
store.skip = 0;
store.filter.skip = 0;
await fetch({ append: false });
return { filter, params };
}
function sanitizerParams(params, exprBuilder) {
for (const param in params) {
if (params[param] === '' || params[param] === null) {
delete store.userParams[param];
delete params[param];
if (store.filter?.where) {
delete store.filter.where[
Object.keys(exprBuilder ? exprBuilder(param) : param)[0]
];
if (Object.keys(store.filter.where).length === 0) {
delete store.filter.where;
}
}
}
}
return params;
}
async function loadMore() {
@ -147,10 +188,11 @@ export function useArrayData(key, userOptions) {
if (store.userParams && Object.keys(store.userParams).length !== 0)
query.params = JSON.stringify(store.userParams);
router.replace({
path: route.path,
query: query,
});
if (router)
router.replace({
path: route.path,
query: query,
});
}
const totalRows = computed(() => (store.data && store.data.length) || 0);

View File

@ -1,3 +1,3 @@
export function useFirstUpper(str) {
export function useCapitalize(str) {
return str && str.charAt(0).toUpperCase() + str.substr(1);
}

View File

@ -0,0 +1,17 @@
import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
export function useClipboard() {
const quasar = useQuasar();
const { t } = useI18n();
/**
*
* @param {String} value Value to send to clipboardAPI
* @param {Object} {label, component} Refer to Quasar notify configuration. Label is the text to translate
*/
function copyText(value, { label = 'components.VnLv.copyText', component = {} }) {
navigator.clipboard.writeText(value);
quasar.notify({ type: 'positive', message: t(label, component) });
}
return { copyText };
}

View File

@ -0,0 +1,35 @@
export function djb2a(string) {
let hash = 5381;
for (let i = 0; i < string.length; i++)
hash = ((hash << 5) + hash) ^ string.charCodeAt(i);
return hash >>> 0;
}
export function useColor(value) {
return '#' + colors[djb2a(value || '') % colors.length];
}
const colors = [
'b5b941', // Yellow
'ae9681', // Peach
'd78767', // Salmon
'cc7000', // Orange bright
'e2553d', // Coral
'8B0000', // Red dark
'de4362', // Red crimson
'FF1493', // Ping intense
'be39a2', // Pink light
'b754cf', // Purple middle
'a87ba8', // Pink
'8a69cd', // Blue lavender
'ab20ab', // Purple dark
'00b5b8', // Turquoise
'1fa8a1', // Green ocean
'5681cf', // Blue steel
'3399fe', // Blue sky
'6d9c3e', // Green chartreuse
'51bb51', // Green lime
'518b8b', // Gray board
'7e7e7e', // Gray
'5d5d5d', // Gray dark
];

View File

@ -0,0 +1,22 @@
import { Notify } from 'quasar';
import { i18n } from 'src/boot/i18n';
export default function useNotify() {
const notify = (message, type, icon) => {
const defaultIcons = {
warning: 'warning',
negative: 'error',
positive: 'check',
};
Notify.create({
message: i18n.global.t(message),
type: type,
icon: icon ? icon : defaultIcons[type],
});
};
return {
notify,
};
}

View File

@ -8,6 +8,8 @@ const user = ref({
nickname: '',
lang: '',
darkMode: null,
companyFk: null,
warehouseFk: null,
});
const roles = ref([]);
@ -23,6 +25,8 @@ export function useState() {
nickname: user.value.nickname,
lang: user.value.lang,
darkMode: user.value.darkMode,
companyFk: user.value.companyFk,
warehouseFk: user.value.warehouseFk,
};
});
}
@ -34,6 +38,8 @@ export function useState() {
nickname: data.nickname,
lang: data.lang,
darkMode: data.darkMode,
companyFk: data.companyFk,
warehouseFk: data.warehouseFk,
};
}
@ -59,7 +65,6 @@ export function useState() {
delete state.value[name];
}
return {
getUser,
setUser,
@ -69,6 +74,6 @@ export function useState() {
get,
unset,
drawer,
headerMounted
headerMounted,
};
}

View File

@ -0,0 +1,15 @@
import VnSummaryDialog from 'src/components/common/VnSummaryDialog.vue';
import { useQuasar } from 'quasar';
export function useSummaryDialog() {
const quasar = useQuasar();
function viewSummary(id, summary) {
quasar.dialog({
component: VnSummaryDialog,
componentProps: { id, summary },
});
}
return { viewSummary };
}

View File

@ -1,14 +1,25 @@
import axios from 'axios';
import { useState } from './useState';
import useNotify from './useNotify';
export function useUserConfig() {
const state = useState();
const { notify } = useNotify();
async function fetch() {
const { data } = await axios.get('UserConfigs/getUserConfig');
const user = state.getUser().value;
user.darkMode = data.darkMode;
state.setUser(user);
try {
const { data } = await axios.get('UserConfigs/getUserConfig');
const user = state.getUser().value;
user.darkMode = data.darkMode;
user.companyFk = data.companyFk;
user.warehouseFk = data.warehouseFk;
state.setUser(user);
return data;
} catch (error) {
notify('errors.userConfig', 'negative');
console.error('Error fetching user config:', error);
}
}
return {

View File

@ -1,19 +1,12 @@
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import validator from 'validator';
const models = ref(null);
import { useValidationsStore } from 'src/stores/useValidationsStore';
export function useValidator() {
if (!models.value) fetch();
function fetch() {
axios.get('Schemas/ModelInfo').then((response) => (models.value = response.data));
}
const models = useValidationsStore().validations;
function validate(propertyRule) {
const modelInfo = models.value;
const modelInfo = models;
if (!modelInfo || !propertyRule) return;
const rule = propertyRule.split('.');
@ -37,6 +30,15 @@ export function useValidator() {
const { t } = useI18n();
const validations = function (validation) {
return {
format: (value) => {
const { allowNull, with: format, allowBlank } = validation;
const message = t(validation.message) || validation.message;
if (!allowBlank && value === '') return message;
if (!allowNull && value === null) return message;
const isValid = new RegExp(format).test(value);
if (!isValid) return message;
},
presence: (value) => {
let message = `Value can't be empty`;
if (validation.message)
@ -75,5 +77,6 @@ export function useValidator() {
return {
validate,
models,
};
}

View File

@ -17,9 +17,9 @@ a {
// Removes chrome autofill background
input:-webkit-autofill,
select:-webkit-autofill {
color: $input-text-color !important;
color: var(--vn-text);
font-family: $typography-font-family;
-webkit-text-fill-color: $input-text-color !important;
-webkit-text-fill-color: var(--vn-text);
-webkit-background-clip: text !important;
background-clip: text !important;
}
@ -47,3 +47,45 @@ body.body--dark {
.bg-vn-dark {
background-color: var(--vn-dark);
}
.color-vn-text {
color: var(--vn-text);
}
.color-vn-white {
color: $white;
}
.vn-card {
background-color: var(--vn-gray);
color: var(--vn-text);
border-radius: 8px;
}
.vn-card-list {
width: 100%;
max-width: 60em;
}
.bg-vn-primary-row {
background-color: var(--vn-dark);
}
.bg-vn-secondary-row {
background-color: var(--vn-light-gray);
}
/* Estilo para el asterisco en campos requeridos */
.q-field.required .q-field__label:after {
content: ' *';
}
input[type='number'] {
-moz-appearance: textfield;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src/css/fonts/icon.eot Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 162 KiB

BIN
src/css/fonts/icon.ttf Normal file

Binary file not shown.

BIN
src/css/fonts/icon.woff Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -1,412 +1,387 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?g6kvgn');
src: url('fonts/icomoon.eot?g6kvgn#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?g6kvgn') format('truetype'),
url('fonts/icomoon.woff?g6kvgn') format('woff'),
url('fonts/icomoon.svg?g6kvgn#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
font-family: 'icon';
src: url('fonts/icon.eot?7zbcv0');
src: url('fonts/icon.eot?7zbcv0#iefix') format('embedded-opentype'),
url('fonts/icon.ttf?7zbcv0') format('truetype'),
url('fonts/icon.woff?7zbcv0') format('woff'),
url('fonts/icon.svg?7zbcv0#icon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
[class^='icon-'],
[class*=' icon-'] {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'icomoon' !important;
speak: never;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
[class^="icon-"], [class*=" icon-"] {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'icon' !important;
speak: never;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-frozen:before {
content: '\e900';
}
.icon-Person:before {
content: '\e901';
}
.icon-handmadeArtificial:before {
content: '\e902';
}
.icon-fruit:before {
content: '\e903';
}
.icon-funeral:before {
content: '\e904';
}
.icon-noPayMethod:before {
content: '\e905';
}
.icon-preserved:before {
content: '\e906';
}
.icon-greenery:before {
content: '\e907';
}
.icon-plant:before {
content: '\e908';
}
.icon-handmade:before {
content: '\e909';
}
.icon-accessory:before {
content: '\e90a';
}
.icon-artificial:before {
content: '\e90b';
}
.icon-flower:before {
content: '\e90c';
}
.icon-fixedPrice:before {
content: '\e90d';
}
.icon-addperson:before {
content: '\e90e';
}
.icon-supplierfalse:before {
content: '\e90f';
}
.icon-invoice-out:before {
content: '\e910';
}
.icon-invoice-in:before {
content: '\e911';
}
.icon-invoice-in-create:before {
content: '\e912';
}
.icon-basketadd:before {
content: '\e913';
}
.icon-basket:before {
content: '\e914';
}
.icon-uniE915:before {
content: '\e915';
}
.icon-uniE916:before {
content: '\e916';
}
.icon-uniE917:before {
content: '\e917';
}
.icon-uniE918:before {
content: '\e918';
}
.icon-uniE919:before {
content: '\e919';
}
.icon-uniE91A:before {
content: '\e91a';
}
.icon-isTooLittle:before {
content: '\e91b';
}
.icon-deliveryprices:before {
content: '\e91c';
}
.icon-onlinepayment:before {
content: '\e91d';
}
.icon-risk:before {
content: '\e91e';
}
.icon-noweb:before {
content: '\e91f';
}
.icon-no036:before {
content: '\e920';
}
.icon-disabled:before {
content: '\e921';
}
.icon-treatments:before {
content: '\e922';
}
.icon-invoice:before {
content: '\e923';
}
.icon-photo:before {
content: '\e924';
}
.icon-supplier:before {
content: '\e925';
}
.icon-languaje:before {
content: '\e926';
}
.icon-credit:before {
content: '\e927';
}
.icon-client:before {
content: '\e928';
}
.icon-shipment-01:before {
content: '\e929';
}
.icon-account:before {
content: '\e92a';
}
.icon-inventory:before {
content: '\e92b';
}
.icon-unavailable:before {
content: '\e92c';
}
.icon-wiki:before {
content: '\e92d';
}
.icon-attach:before {
content: '\e92e';
}
.icon-exit:before {
content: '\e92f';
}
.icon-anonymous:before {
content: '\e930';
}
.icon-net:before {
content: '\e931';
}
.icon-buyrequest:before {
content: '\e932';
}
.icon-thermometer:before {
content: '\e933';
}
.icon-entry:before {
content: '\e934';
}
.icon-deletedTicket:before {
content: '\e935';
}
.icon-logout:before {
content: '\e936';
}
.icon-catalog:before {
content: '\e937';
}
.icon-agency:before {
content: '\e938';
}
.icon-delivery:before {
content: '\e939';
}
.icon-wand:before {
content: '\e93a';
}
.icon-buscaman:before {
content: '\e93b';
}
.icon-pbx:before {
content: '\e93c';
}
.icon-calendar:before {
content: '\e93d';
}
.icon-splitline:before {
content: '\e93e';
}
.icon-consignatarios:before {
content: '\e93f';
}
.icon-tax:before {
content: '\e940';
}
.icon-notes:before {
content: '\e941';
}
.icon-lines:before {
content: '\e942';
}
.icon-zone:before {
content: '\e943';
}
.icon-greuge:before {
content: '\e944';
}
.icon-ticketAdd:before {
content: '\e945';
}
.icon-components:before {
content: '\e946';
}
.icon-pets:before {
content: '\e947';
}
.icon-linesprepaired:before {
content: '\e948';
}
.icon-control:before {
content: '\e949';
}
.icon-revision:before {
content: '\e94a';
}
.icon-deaulter:before {
content: '\e94b';
}
.icon-services:before {
content: '\e94c';
}
.icon-albaran:before {
content: '\e94d';
}
.icon-solunion:before {
content: '\e94e';
}
.icon-stowaway:before {
content: '\e94f';
}
.icon-apps:before {
content: '\e951';
}
.icon-info:before {
content: '\e952';
}
.icon-columndelete:before {
content: '\e953';
}
.icon-columnadd:before {
content: '\e954';
}
.icon-deleteline:before {
content: '\e955';
}
.icon-item:before {
content: '\e956';
}
.icon-worker:before {
content: '\e957';
}
.icon-headercol:before {
content: '\e958';
}
.icon-reserva:before {
content: '\e959';
}
.icon-100:before {
content: '\e95a';
}
.icon-sign:before {
content: '\e95d';
}
.icon-polizon:before {
content: '\e95e';
}
.icon-solclaim:before {
content: '\e95f';
}
.icon-actions:before {
content: '\e960';
}
.icon-details:before {
content: '\e961';
}
.icon-traceability:before {
content: '\e962';
}
.icon-claims:before {
content: '\e963';
}
.icon-regentry:before {
content: '\e964';
}
.icon-transaction:before {
content: '\e966';
content: "\e926";
}
.icon-History:before {
content: '\e968';
content: "\e964";
}
.icon-mana:before {
content: '\e96a';
.icon-Person:before {
content: "\e984";
}
.icon-ticket:before {
content: '\e96b';
.icon-accessory:before {
content: "\e948";
}
.icon-niche:before {
content: '\e96c';
.icon-account:before {
content: "\e927";
}
.icon-tags:before {
content: '\e96d';
.icon-actions:before {
content: "\e928";
}
.icon-volume:before {
content: '\e96e';
}
.icon-bin:before {
content: '\e96f';
}
.icon-splur:before {
content: '\e970';
}
.icon-barcode:before {
content: '\e971';
}
.icon-botanical:before {
content: '\e972';
}
.icon-clone:before {
content: '\e973';
}
.icon-sms:before {
content: '\e975';
}
.icon-eye:before {
content: '\e976';
}
.icon-doc:before {
content: '\e977';
}
.icon-package:before {
content: '\e978';
}
.icon-settings:before {
content: '\e979';
}
.icon-bucket:before {
content: '\e97a';
}
.icon-mandatory:before {
content: '\e97b';
}
.icon-recovery:before {
content: '\e97c';
}
.icon-payment:before {
content: '\e97e';
}
.icon-grid:before {
content: '\e980';
}
.icon-web:before {
content: '\e982';
}
.icon-dfiscales:before {
content: '\e984';
}
.icon-trolley:before {
content: '\e95c';
.icon-addperson:before {
content: "\e929";
}
.icon-agency-term:before {
content: '\e950';
content: "\e92b";
}
.icon-client-unpaid:before {
content: '\e95b';
.icon-anonymous:before {
content: "\e92d";
}
.icon-apps:before {
content: "\e92e";
}
.icon-artificial:before {
content: "\e92f";
}
.icon-attach:before {
content: "\e930";
}
.icon-barcode:before {
content: "\e932";
}
.icon-basket:before {
content: "\e933";
}
.icon-basketadd:before {
content: "\e934";
}
.icon-bin:before {
content: "\e935";
}
.icon-botanical:before {
content: "\e936";
}
.icon-bucket:before {
content: "\e937";
}
.icon-buscaman:before {
content: "\e938";
}
.icon-buyrequest:before {
content: "\e939";
}
.icon-calc_volum:before {
content: "\e93a";
}
.icon-calendar:before {
content: "\e940";
}
.icon-catalog:before {
content: "\e941";
}
.icon-claims:before {
content: "\e942";
}
.icon-client:before {
content: "\e943";
}
.icon-clone:before {
content: "\e945";
}
.icon-columnadd:before {
content: "\e946";
}
.icon-columndelete:before {
content: "\e947";
}
.icon-components:before {
content: "\e949";
}
.icon-consignatarios:before {
content: "\e94b";
}
.icon-control:before {
content: "\e94c";
}
.icon-credit:before {
content: "\e94d";
}
.icon-deaulter:before {
content: "\e94e";
}
.icon-deletedTicket:before {
content: "\e94f";
}
.icon-deleteline:before {
content: "\e950";
}
.icon-delivery:before {
content: "\e951";
}
.icon-deliveryprices:before {
content: "\e952";
}
.icon-details:before {
content: "\e954";
}
.icon-dfiscales:before {
content: "\e955";
}
.icon-disabled:before {
content: "\e965";
}
.icon-doc:before {
content: "\e956";
}
.icon-entry:before {
content: "\e958";
}
.icon-exit:before {
content: "\e959";
}
.icon-eye:before {
content: "\e95a";
}
.icon-fixedPrice:before {
content: "\e95b";
}
.icon-flower:before {
content: "\e95c";
}
.icon-frozen:before {
content: "\e95d";
}
.icon-fruit:before {
content: "\e95e";
}
.icon-funeral:before {
content: "\e95f";
}
.icon-greenery:before {
content: "\e91e";
}
.icon-greuge:before {
content: "\e960";
}
.icon-grid:before {
content: "\e961";
}
.icon-handmade:before {
content: "\e94a";
}
.icon-handmadeArtificial:before {
content: "\e962";
}
.icon-headercol:before {
content: "\e963";
}
.icon-info:before {
content: "\e966";
}
.icon-inventory:before {
content: "\e967";
}
.icon-invoice:before {
content: "\e969";
}
.icon-invoice-in:before {
content: "\e96a";
}
.icon-invoice-in-create:before {
content: "\e96b";
}
.icon-invoice-out:before {
content: "\e96c";
}
.icon-isTooLittle:before {
content: "\e96e";
}
.icon-item:before {
content: "\e96f";
}
.icon-languaje:before {
content: "\e912";
}
.icon-lines:before {
content: "\e971";
}
.icon-linesprepaired:before {
content: "\e972";
}
.icon-link-to-corrected:before {
content: "\e900";
}
.icon-link-to-correcting:before {
content: "\e906";
}
.icon-logout:before {
content: "\e90a";
}
.icon-mana:before {
content: "\e974";
}
.icon-mandatory:before {
content: "\e975";
}
.icon-net:before {
content: "\e976";
}
.icon-newalbaran:before {
content: "\e977";
}
.icon-niche:before {
content: "\e979";
}
.icon-no036:before {
content: "\e97a";
}
.icon-noPayMethod:before {
content: "\e97b";
}
.icon-notes:before {
content: "\e97c";
}
.icon-noweb:before {
content: "\e97e";
}
.icon-onlinepayment:before {
content: "\e97f";
}
.icon-package:before {
content: "\e980";
}
.icon-payment:before {
content: "\e982";
}
.icon-pbx:before {
content: "\e983";
}
.icon-pets:before {
content: "\e985";
}
.icon-photo:before {
content: "\e986";
}
.icon-plant:before {
content: "\e987";
}
.icon-polizon:before {
content: "\e989";
}
.icon-preserved:before {
content: "\e98a";
}
.icon-recovery:before {
content: "\e98b";
}
.icon-regentry:before {
content: "\e901";
}
.icon-reserva:before {
content: "\e902";
}
.icon-revision:before {
content: "\e903";
}
.icon-risk:before {
content: "\e904";
}
.icon-services:before {
content: "\e905";
}
.icon-settings:before {
content: "\e907";
}
.icon-shipment:before {
content: "\e908";
}
.icon-sign:before {
content: "\e909";
}
.icon-sms:before {
content: "\e90b";
}
.icon-solclaim:before {
content: "\e90c";
}
.icon-solunion:before {
content: "\e90d";
}
.icon-splitline:before {
content: "\e90e";
}
.icon-splur:before {
content: "\e90f";
}
.icon-stowaway:before {
content: "\e910";
}
.icon-supplier:before {
content: "\e911";
}
.icon-supplierfalse:before {
content: "\e913";
}
.icon-tags:before {
content: "\e914";
}
.icon-tax:before {
content: "\e915";
}
.icon-thermometer:before {
content: "\e916";
}
.icon-ticket:before {
content: "\e917";
}
.icon-ticketAdd:before {
content: "\e918";
}
.icon-traceability:before {
content: "\e919";
}
.icon-treatments:before {
content: "\e91c";
}
.icon-trolley:before {
content: '\e95c';
}
.icon-grafana:before {
content: '\e965';
content: "\e91a";
}
.icon-troncales:before {
content: '\e967';
content: "\e91b";
}
.icon-unavailable:before {
content: "\e91d";
}
.icon-volume:before {
content: "\e91f";
}
.icon-wand:before {
content: "\e920";
}
.icon-web:before {
content: "\e921";
}
.icon-wiki:before {
content: "\e922";
}
.icon-worker:before {
content: "\e923";
}
.icon-zone:before {
content: "\e924";
}

8
src/filters/dateRange.js Normal file
View File

@ -0,0 +1,8 @@
export default function dateRange(value) {
const minHour = new Date(value);
minHour.setHours(0, 0, 0, 0);
const maxHour = new Date(value);
maxHour.setHours(23, 59, 59, 59);
return [minHour, maxHour];
}

View File

@ -0,0 +1,94 @@
/**
* Passes a loopback fields filter to an object.
*
* @param {Object} fields The fields object or array
* @return {Object} The fields as object
*/
function fieldsToObject(fields) {
let fieldsObj = {};
if (Array.isArray(fields)) {
for (let field of fields) fieldsObj[field] = true;
} else if (typeof fields == 'object') {
for (let field in fields) {
if (fields[field]) fieldsObj[field] = true;
}
}
return fieldsObj;
}
/**
* Merges two loopback fields filters.
*
* @param {Object|Array} src The source fields
* @param {Object|Array} dst The destination fields
* @return {Array} The merged fields as an array
*/
function mergeFields(src, dst) {
let fields = {};
Object.assign(fields, fieldsToObject(src), fieldsToObject(dst));
return Object.keys(fields);
}
/**
* Merges two loopback where filters.
*
* @param {Object|Array} src The source where
* @param {Object|Array} dst The destination where
* @return {Array} The merged wheres
*/
function mergeWhere(src, dst) {
let and = [];
if (src) and.push(src);
if (dst) and.push(dst);
return simplifyOperation(and, 'and');
}
/**
* Merges two loopback filters returning the merged filter.
*
* @param {Object} src The source filter
* @param {Object} dst The destination filter
* @return {Object} The result filter
*/
function mergeFilters(src, dst) {
let res = Object.assign({}, dst);
if (!src) return res;
if (src.fields) res.fields = mergeFields(src.fields, res.fields);
if (src.where) res.where = mergeWhere(res.where, src.where);
if (src.include) res.include = src.include;
if (src.order) res.order = src.order;
if (src.limit) res.limit = src.limit;
if (src.offset) res.offset = src.offset;
if (src.skip) res.skip = src.skip;
return res;
}
function simplifyOperation(operation, operator) {
switch (operation.length) {
case 0:
return undefined;
case 1:
return operation[0];
default:
return { [operator]: operation };
}
}
function buildFilter(params, builderFunc) {
let and = [];
for (let param in params) {
let value = params[param];
if (value == null) continue;
let expr = builderFunc(param, value);
if (expr) and.push(expr);
}
return simplifyOperation(and, 'and');
}
export { fieldsToObject, mergeFields, mergeWhere, mergeFilters, buildFilter };

View File

@ -2,18 +2,24 @@ import toLowerCase from './toLowerCase';
import toDate from './toDate';
import toDateString from './toDateString';
import toDateHour from './toDateHour';
import toRelativeDate from './toRelativeDate';
import toCurrency from './toCurrency';
import toPercentage from './toPercentage';
import toLowerCamel from './toLowerCamel';
import dashIfEmpty from './dashIfEmpty';
import dateRange from './dateRange';
import toHour from './toHour';
export {
toLowerCase,
toLowerCamel,
toDate,
toHour,
toDateString,
toDateHour,
toRelativeDate,
toCurrency,
toPercentage,
dashIfEmpty,
dateRange,
};

View File

@ -0,0 +1,3 @@
export default function isValidDate(date) {
return !isNaN(new Date(date).getTime());
}

11
src/filters/toHour.js Normal file
View File

@ -0,0 +1,11 @@
import isValidDate from 'filters/isValidDate';
export default function toHour(date) {
if (!isValidDate(date)) {
return '--:--';
}
return (new Date(date || '')).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}

View File

@ -0,0 +1,32 @@
import { useI18n } from 'vue-i18n';
export default function formatDate(dateVal) {
const { t } = useI18n();
const today = new Date();
if (dateVal == null) return '';
const date = new Date(dateVal);
const dateZeroTime = new Date(dateVal);
dateZeroTime.setHours(0, 0, 0, 0);
const diff = Math.trunc(
(today.getTime() - dateZeroTime.getTime()) / (1000 * 3600 * 24)
);
let format;
if (diff === 0) format = t('globals.today');
else if (diff === 1) format = t('globals.yesterday');
else if (diff > 1 && diff < 7) {
const options = { weekday: 'short' };
format = date.toLocaleDateString(t('globals.dateFormat'), options);
} else if (today.getFullYear() === date.getFullYear()) {
const options = { day: 'numeric', month: 'short' };
format = date.toLocaleDateString(t('globals.dateFormat'), options);
} else {
const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
format = date.toLocaleDateString(t('globals.dateFormat'), options);
}
// Formatear la hora en HH:mm
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${format} ${hours}:${minutes}`;
}

View File

@ -5,6 +5,9 @@ export default {
en: 'English',
},
language: 'Language',
entity: 'Entity',
user: 'User',
details: 'Details',
collapseMenu: 'Collapse left menu',
backToDashboard: 'Return to dashboard',
notifications: 'Notifications',
@ -13,8 +16,12 @@ export default {
pinnedModules: 'Pinned modules',
darkMode: 'Dark mode',
logOut: 'Log out',
date: 'Date',
dataSaved: 'Data saved',
dataDeleted: 'Data deleted',
search: 'Search',
changes: 'Changes',
dataCreated: 'Data created',
add: 'Add',
create: 'Create',
save: 'Save',
@ -36,13 +43,41 @@ export default {
summary: {
basicData: 'Basic data',
},
today: 'Today',
yesterday: 'Yesterday',
dateFormat: 'en-GB',
microsip: 'Open in MicroSIP',
noSelectedRows: `You don't have any line selected`,
downloadCSVSuccess: 'CSV downloaded successfully',
reference: 'Reference',
agency: 'Agency',
wareHouseOut: 'Warehouse Out',
wareHouseIn: 'Warehouse In',
landed: 'Landed',
shipped: 'Shipped',
totalEntries: 'Total entries',
amount: 'Amount',
packages: 'Packages',
download: 'Download',
selectRows: 'Select all { numberRows } row(s)',
allRows: 'All { numberRows } row(s)',
markAll: 'Mark all',
requiredField: 'Required field',
class: 'clase',
type: 'type',
reason: 'reason',
noResults: 'No results',
system: 'System',
fieldRequired: 'Field required',
allowedFilesText: 'Allowed file types: { allowedContentTypes }',
},
errors: {
statusUnauthorized: 'Access denied',
statusInternalServerError: 'An internal server error has ocurred',
statusBadGateway: 'It seems that the server has fall down',
statusGatewayTimeout: 'Could not contact the server',
userConfig: 'Error fetching user config',
create: 'Error during creation',
},
login: {
title: 'Login',
@ -81,11 +116,30 @@ export default {
customer: {
pageTitles: {
customers: 'Customers',
create: 'Create',
list: 'List',
webPayments: 'Web Payments',
extendedList: 'Extended list',
notifications: 'Notifications',
defaulter: 'Defaulter',
createCustomer: 'Create customer',
summary: 'Summary',
basicData: 'Basic Data',
basicData: 'Basic data',
fiscalData: 'Fiscal data',
billingData: 'Billing data',
consignees: 'Consignees',
notes: 'Notes',
credits: 'Credits',
greuges: 'Greuges',
balance: 'Balance',
recoveries: 'Recoveries',
webAccess: 'Web access',
log: 'Log',
sms: 'Sms',
creditManagement: 'Credit management',
creditContracts: 'Credit contracts',
creditOpinion: 'Credit opinion',
others: 'Others',
},
list: {
phone: 'Phone',
@ -176,6 +230,172 @@ export default {
salesPerson: 'Sales person',
contactChannel: 'Contact channel',
},
extendedList: {
tableVisibleColumns: {
id: 'Identifier',
name: 'Name',
fi: 'Tax number',
salesPersonFk: 'Salesperson',
credit: 'Credit',
creditInsurance: 'Credit insurance',
phone: 'Phone',
mobile: 'Mobile',
street: 'Street',
countryFk: 'Country',
provinceFk: 'Province',
city: 'City',
postcode: 'Postcode',
email: 'Email',
created: 'Created',
businessTypeFk: 'Business type',
payMethodFk: 'Billing data',
sageTaxTypeFk: 'Sage tax type',
sageTransactionTypeFk: 'Sage tr. type',
isActive: 'Active',
isVies: 'Vies',
isTaxDataChecked: 'Verified data',
isEqualizated: 'Is equalizated',
isFreezed: 'Freezed',
hasToInvoice: 'Invoice',
hasToInvoiceByAddress: 'Invoice by address',
isToBeMailed: 'Mailing',
hasLcr: 'Received LCR',
hasCoreVnl: 'VNL core received',
hasSepaVnl: 'VNL B2B received',
},
},
},
entry: {
pageTitles: {
entries: 'Entries',
list: 'List',
summary: 'Summary',
basicData: 'Basic data',
buys: 'Buys',
notes: 'Notes',
log: 'Log',
create: 'Create',
latestBuys: 'Latest buys',
},
list: {
newEntry: 'New entry',
landed: 'Landed',
invoiceNumber: 'Invoice number',
supplier: 'Supplier',
booked: 'Booked',
confirmed: 'Confirmed',
ordered: 'Ordered',
},
summary: {
commission: 'Commission',
currency: 'Currency',
company: 'Company',
reference: 'Reference',
invoiceNumber: 'Invoice number',
ordered: 'Ordered',
confirmed: 'Confirmed',
booked: 'Booked',
raid: 'Raid',
excludedFromAvailable: 'Inventory',
travelReference: 'Reference',
travelAgency: 'Agency',
travelShipped: 'Shipped',
travelWarehouseOut: 'Warehouse Out',
travelDelivered: 'Delivered',
travelLanded: 'Landed',
travelWarehouseIn: 'Warehouse In',
travelReceived: 'Received',
buys: 'Buys',
quantity: 'Quantity',
stickers: 'Stickers',
package: 'Package',
weight: 'Weight',
packing: 'Packing',
grouping: 'Grouping',
buyingValue: 'Buying value',
import: 'Import',
pvp: 'PVP',
item: 'Item',
},
basicData: {
supplier: 'Supplier',
travel: 'Travel',
reference: 'Reference',
invoiceNumber: 'Invoice number',
company: 'Company',
currency: 'Currency',
commission: 'Commission',
observation: 'Observation',
ordered: 'Ordered',
confirmed: 'Confirmed',
booked: 'Booked',
raid: 'Raid',
excludedFromAvailable: 'Inventory',
agency: 'Agency',
warehouseOut: 'Warehouse Out',
warehouseIn: 'Warehouse In',
shipped: 'Shipped',
landed: 'Landed',
id: 'ID',
},
buys: {
groupingPrice: 'Grouping price',
packingPrice: 'Packing price',
reference: 'Reference',
observations: 'Observations',
item: 'Item',
description: 'Description',
size: 'Size',
packing: 'Packing',
grouping: 'Grouping',
buyingValue: 'Buying value',
packagingFk: 'Box',
file: 'File',
name: 'Name',
producer: 'Producer',
type: 'Type',
color: 'Color',
id: 'ID',
},
notes: {
observationType: 'Observation type',
description: 'Description',
},
descriptor: {
agency: 'Agency',
landed: 'Landed',
warehouseOut: 'Warehouse Out',
},
latestBuys: {
picture: 'Picture',
itemFk: 'Item ID',
packing: 'Packing',
grouping: 'Grouping',
quantity: 'Quantity',
description: 'Description',
size: 'Size',
tags: 'Tags',
type: 'Type',
intrastat: 'Intrastat',
origin: 'Origin',
weightByPiece: 'Weight/Piece',
isActive: 'Active',
family: 'Family',
entryFk: 'Entry',
buyingValue: 'Buying value',
freightValue: 'Freight value',
comissionValue: 'Commission value',
packageValue: 'Package value',
isIgnored: 'Is ignored',
price2: 'Grouping',
price3: 'Packing',
minPrice: 'Min',
ektFk: 'Ekt',
weight: 'Weight',
packagingFk: 'Package',
packingOut: 'Package out',
landing: 'Landing',
},
},
ticket: {
pageTitles: {
@ -225,7 +445,6 @@ export default {
invoice: 'Invoice',
shipped: 'Shipped',
landed: 'Landed',
packages: 'Packages',
consigneePhone: 'Consignee phone',
consigneeMobile: 'Consignee mobile',
clientPhone: 'Client phone',
@ -242,7 +461,6 @@ export default {
description: 'Description',
price: 'Price',
discount: 'Discount',
amount: 'Amount',
packing: 'Packing',
hasComponentLack: 'Component lack',
itemShortage: 'Not visible',
@ -274,6 +492,7 @@ export default {
development: 'Development',
log: 'Audit logs',
notes: 'Notes',
action: 'Action',
},
list: {
customer: 'Customer',
@ -328,13 +547,13 @@ export default {
responsible: 'Responsible',
worker: 'Worker',
redelivery: 'Redelivery',
returnOfMaterial: 'RMA',
},
basicData: {
customer: 'Customer',
assignedTo: 'Assigned',
created: 'Created',
state: 'State',
packages: 'Packages',
picked: 'Picked',
returnOfMaterial: 'Return of material authorization (RMA)',
},
@ -346,8 +565,10 @@ export default {
},
invoiceOut: {
pageTitles: {
invoiceOuts: 'Invoices Out',
invoiceOuts: 'Invoice out',
list: 'List',
negativeBases: 'Negative Bases',
globalInvoicing: 'Global invoicing',
createInvoiceOut: 'Create invoice out',
summary: 'Summary',
basicData: 'Basic Data',
@ -356,7 +577,6 @@ export default {
ref: 'Reference',
issued: 'Issued',
shortIssued: 'Issued',
amount: 'Amount',
client: 'Client',
created: 'Created',
shortCreated: 'Created',
@ -366,7 +586,6 @@ export default {
},
card: {
issued: 'Issued',
amount: 'Amount',
client: 'Client',
company: 'Company',
customerCard: 'Customer card',
@ -389,6 +608,73 @@ export default {
shipped: 'Shipped',
totalWithVat: 'Amount',
},
globalInvoices: {
errors: {
chooseValidClient: 'Choose a valid client',
chooseValidCompany: 'Choose a valid company',
chooseValidPrinter: 'Choose a valid printer',
fillDates: 'Invoice date and the max date should be filled',
invoiceDateLessThanMaxDate: 'Invoice date can not be less than max date',
invoiceWithFutureDate: 'Exists an invoice with a future date',
noTicketsToInvoice: 'There are not clients to invoice',
criticalInvoiceError: 'Critical invoicing error, process stopped',
},
table: {
client: 'Client',
addressId: 'Address id',
streetAddress: 'Street',
},
statusCard: {
percentageText: '{getPercentage}% {getAddressNumber} of {getNAddresses}',
pdfsNumberText: '{nPdfs} of {totalPdfs} PDFs',
},
},
negativeBases: {
from: 'From',
to: 'To',
company: 'Company',
country: 'Country',
clientId: 'Client Id',
client: 'Client',
amount: 'Amount',
base: 'Base',
ticketId: 'Ticket Id',
active: 'Active',
hasToInvoice: 'Has to Invoice',
verifiedData: 'Verified Data',
comercial: 'Comercial',
errors: {
downloadCsvFailed: 'CSV download failed',
},
},
},
shelving: {
pageTitles: {
shelving: 'Shelving',
shelvingList: 'Shelving List',
create: 'Create',
summary: 'Summary',
basicData: 'Basic Data',
log: 'Logs',
},
list: {
parking: 'Parking',
priority: 'Priority',
newShelving: 'New Shelving',
},
summary: {
code: 'Code',
parking: 'Parking',
priority: 'Priority',
worker: 'Worker',
recyclable: 'Recyclable',
},
basicData: {
code: 'Code',
parking: 'Parking',
priority: 'Priority',
recyclable: 'Recyclable',
},
},
invoiceIn: {
pageTitles: {
@ -396,10 +682,11 @@ export default {
list: 'List',
createInvoiceIn: 'Create invoice in',
summary: 'Summary',
basicData: 'Basic Data',
basicData: 'Basic data',
vat: 'VAT',
dueDay: 'Due day',
intrastat: 'Intrastat',
corrective: 'Corrective',
log: 'Logs',
},
list: {
@ -455,6 +742,82 @@ export default {
country: 'Country',
},
},
order: {
pageTitles: {
order: 'Orders',
orderList: 'List',
create: 'Create',
summary: 'Summary',
basicData: 'Basic Data',
catalog: 'Catalog',
volume: 'Volume',
lines: 'Lines',
},
field: {
salesPersonFk: 'Sales Person',
clientFk: 'Client',
isConfirmed: 'Confirmed',
created: 'Created',
landed: 'Landed',
hour: 'Hour',
agency: 'Agency',
total: 'Total',
},
form: {
clientFk: 'Client',
addressFk: 'Address',
landed: 'Landed',
agencyModeFk: 'Agency',
},
list: {
newOrder: 'New Order',
},
summary: {
basket: 'Basket',
nickname: 'Nickname',
company: 'Company',
confirmed: 'Confirmed',
notConfirmed: 'Not confirmed',
created: 'Created',
landed: 'Landed',
phone: 'Phone',
createdFrom: 'Created From',
address: 'Address',
notes: 'Notes',
subtotal: 'Subtotal',
total: 'Total',
vat: 'VAT',
state: 'State',
alias: 'Alias',
items: 'Items',
orderTicketList: 'Order Ticket List',
details: 'Details',
item: 'Item',
description: 'Description',
quantity: 'Quantity',
price: 'Price',
amount: 'Amount',
},
},
department: {
pageTitles: {
basicData: 'Basic data',
department: 'Department',
summary: 'Summary',
},
name: 'Name',
code: 'Code',
chat: 'Chat',
bossDepartment: 'Boss Department',
email: 'Email',
selfConsumptionCustomer: 'Self-consumption customer',
telework: 'Telework',
notifyOnErrors: 'Notify on errors',
worksInProduction: 'Works in production',
hasToRefill: 'Fill in days without physical check-ins',
hasToSendMail: 'Send check-ins by email',
departmentRemoved: 'Department removed',
},
worker: {
pageTitles: {
workers: 'Workers',
@ -462,6 +825,8 @@ export default {
basicData: 'Basic data',
summary: 'Summary',
notifications: 'Notifications',
workerCreate: 'New worker',
department: 'Department',
},
list: {
name: 'Name',
@ -471,6 +836,7 @@ export default {
active: 'Active',
department: 'Department',
schedule: 'Schedule',
newWorker: 'New worker',
},
card: {
workerId: 'Worker ID',
@ -502,6 +868,25 @@ export default {
subscribed: 'Subscribed to the notification',
unsubscribed: 'Unsubscribed from the notification',
},
create: {
name: 'Name',
lastName: 'Last name',
birth: 'Birth',
fi: 'Fi',
code: 'Worker code',
phone: 'Phone',
postcode: 'Postcode',
province: 'Province',
city: 'City',
street: 'Street',
webUser: 'Web user',
personalEmail: 'Personal email',
company: 'Company',
boss: 'Boss',
payMethods: 'Pay method',
iban: 'IBAN',
bankEntity: 'Swift / BIC',
},
imageNotFound: 'Image not found',
},
wagon: {
@ -513,6 +898,7 @@ export default {
typesList: 'Types List',
typeCreate: 'Create type',
typeEdit: 'Edit type',
wagonCounter: 'Trolley counter',
},
type: {
name: 'Name',
@ -551,6 +937,10 @@ export default {
pageTitles: {
routes: 'Routes',
cmrsList: 'External CMRs list',
RouteList: 'List',
create: 'Create',
basicData: 'Basic Data',
summary: 'Summary',
},
cmr: {
list: {
@ -569,17 +959,220 @@ export default {
},
},
},
supplier: {
pageTitles: {
suppliers: 'Suppliers',
supplier: 'Supplier',
list: 'List',
create: 'Create',
summary: 'Summary',
basicData: 'Basic data',
fiscalData: 'Fiscal data',
billingData: 'Billing data',
log: 'Log',
accounts: 'Accounts',
contacts: 'Contacts',
addresses: 'Addresses',
consumption: 'Consumption',
agencyTerm: 'Agency agreement',
},
list: {
payMethod: 'Pay method',
payDeadline: 'Pay deadline',
payDay: 'Pay day',
account: 'Account',
newSupplier: 'New supplier',
},
summary: {
responsible: 'Responsible',
notes: 'Notes',
verified: 'Verified',
isActive: 'Is active',
billingData: 'Billing data',
payMethod: 'Pay method',
payDeadline: 'Pay deadline',
payDay: 'Día de pago',
account: 'Account',
fiscalData: 'Fiscal data',
sageTaxType: 'Sage tax type',
sageTransactionType: 'Sage transaction type',
sageWithholding: 'Sage withholding',
supplierActivity: 'Supplier activity',
healthRegister: 'Healt register',
fiscalAddress: 'Fiscal address',
socialName: 'Social name',
taxNumber: 'Tax number',
street: 'Street',
city: 'City',
postCode: 'Postcode',
province: 'Province',
country: 'Country',
},
create: {
supplierName: 'Supplier name',
},
basicData: {
alias: 'Alias',
workerFk: 'Responsible',
isSerious: 'Verified',
isActive: 'Active',
isPayMethodChecked: 'PayMethod checked',
note: 'Notes',
},
fiscalData: {
name: 'Social name *',
nif: 'Tax number *',
account: 'Account',
sageTaxTypeFk: 'Sage tax type',
sageWithholdingFk: 'Sage withholding',
sageTransactionTypeFk: 'Sage transaction type',
supplierActivityFk: 'Supplier activity',
healthRegister: 'Health register',
street: 'Street',
postcode: 'Postcode',
city: 'City *',
provinceFk: 'Province',
country: 'Country',
isTrucker: 'Trucker',
isVies: 'Vies',
},
billingData: {
payMethodFk: 'Billing data',
payDemFk: 'Payment deadline',
payDay: 'Pay day',
},
accounts: {
iban: 'Iban',
bankEntity: 'Bank entity',
beneficiary: 'Beneficiary',
},
contacts: {
name: 'Name',
phone: 'Phone',
mobile: 'Mobile',
email: 'Email',
observation: 'Notes',
},
addresses: {
street: 'Street',
postcode: 'Postcode',
phone: 'Phone',
name: 'Name',
city: 'City',
province: 'Province',
mobile: 'Mobile',
},
agencyTerms: {
agencyFk: 'Agency',
minimumM3: 'Minimum M3',
packagePrice: 'Package Price',
kmPrice: 'Km Price',
m3Price: 'M3 Price',
routePrice: 'Route price',
minimumKm: 'Minimum Km',
addRow: 'Add row',
},
consumption: {
entry: 'Entry',
date: 'Date',
reference: 'Reference',
},
},
travel: {
pageTitles: {
travel: 'Travels',
list: 'List',
create: 'Create',
summary: 'Summary',
extraCommunity: 'Extra community',
travelCreate: 'New travel',
basicData: 'Basic data',
history: 'Log',
thermographs: 'Thermograph',
},
summary: {
confirmed: 'Confirmed',
entryId: 'Entry Id',
freight: 'Freight',
package: 'Package',
delivered: 'Delivered',
received: 'Received',
entries: 'Entries',
cloneShipping: 'Clone travel',
CloneTravelAndEntries: 'Clone travel and his entries',
deleteTravel: 'Delete travel',
AddEntry: 'Add entry',
thermographs: 'Thermographs',
hb: 'HB',
},
variables: {
search: 'Id/Reference',
agencyModeFk: 'Agency',
warehouseInFk: ' Warehouse In',
warehouseOutFk: 'Warehouse Out',
landedFrom: 'Landed from',
landedTo: 'Landed to',
continent: 'Continent out',
totalEntries: 'Total entries',
},
basicData: {
reference: 'Reference',
agency: 'Agency',
shipped: 'Shipped',
landed: 'Landed',
warehouseOut: 'Warehouse Out',
warehouseIn: 'Warehouse In',
delivered: 'Delivered',
received: 'Received',
},
thermographs: {
code: 'Code',
temperature: 'Temperature',
state: 'State',
destination: 'Destination',
created: 'Created',
thermograph: 'Thermograph',
reference: 'Reference',
type: 'Type',
company: 'Company',
warehouse: 'Warehouse',
travelFileDescription: 'Travel id { travelId }',
file: 'File',
description: 'Description',
},
},
item: {
pageTitles: {
items: 'Items',
list: 'List',
diary: 'Diary',
tags: 'Tags',
},
descriptor: {
item: 'Item',
buyer: 'Buyer',
color: 'Color',
category: 'Category',
stems: 'Stems',
visible: 'Visible',
available: 'Available',
warehouseText: 'Calculated on the warehouse of { warehouseName }',
itemDiary: 'Item diary',
},
},
components: {
topbar: {},
userPanel: {
copyToken: 'Token copied to clipboard',
settings: 'Settings',
logOut: 'Log Out',
},
smartCard: {
openCard: 'View card',
openSummary: 'Open summary',
viewDescription: 'View description',
downloadFile: 'Download file',
clone: 'Clone',
openCard: 'View',
openSummary: 'Summary',
viewDescription: 'Description',
},
cardDescriptor: {
mainList: 'Main list',
@ -590,5 +1183,9 @@ export default {
addToPinned: 'Add to pinned',
removeFromPinned: 'Remove from pinned',
},
VnLv: {
copyText: '{copyValue} has been copied to the clipboard',
},
iban_tooltip: 'IBAN: ES21 1234 5678 90 0123456789',
},
};

View File

@ -5,6 +5,9 @@ export default {
en: 'Inglés',
},
language: 'Idioma',
entity: 'Entidad',
user: 'Usuario',
details: 'Detalles',
collapseMenu: 'Contraer menú lateral',
backToDashboard: 'Volver al tablón',
notifications: 'Notificaciones',
@ -13,8 +16,12 @@ export default {
pinnedModules: 'Módulos fijados',
darkMode: 'Modo oscuro',
logOut: 'Cerrar sesión',
date: 'Fecha',
dataSaved: 'Datos guardados',
dataDeleted: 'Datos eliminados',
search: 'Buscar',
changes: 'Cambios',
dataCreated: 'Datos creados',
add: 'Añadir',
create: 'Crear',
save: 'Guardar',
@ -36,13 +43,41 @@ export default {
summary: {
basicData: 'Datos básicos',
},
today: 'Hoy',
yesterday: 'Ayer',
dateFormat: 'es-ES',
noSelectedRows: `No tienes ninguna línea seleccionada`,
microsip: 'Abrir en MicroSIP',
downloadCSVSuccess: 'Descarga de CSV exitosa',
reference: 'Referencia',
agency: 'Agencia',
wareHouseOut: 'Alm. salida',
wareHouseIn: 'Alm. entrada',
landed: 'F. entrega',
shipped: 'F. envío',
totalEntries: 'Ent. totales',
amount: 'Importe',
packages: 'Bultos',
download: 'Descargar',
selectRows: 'Seleccionar las { numberRows } filas(s)',
allRows: 'Todo { numberRows } filas(s)',
markAll: 'Marcar todo',
requiredField: 'Campo obligatorio',
class: 'clase',
type: 'tipo',
reason: 'motivo',
noResults: 'Sin resultados',
system: 'Sistema',
fieldRequired: 'Campo requerido',
allowedFilesText: 'Tipos de archivo permitidos: { allowedContentTypes }',
},
errors: {
statusUnauthorized: 'Acceso denegado',
statusInternalServerError: 'Ha ocurrido un error interno del servidor',
statusBadGateway: 'Parece ser que el servidor ha caído',
statusGatewayTimeout: 'No se ha podido contactar con el servidor',
userConfig: 'Error al obtener configuración de usuario',
create: 'Error al crear',
},
login: {
title: 'Inicio de sesión',
@ -81,11 +116,30 @@ export default {
customer: {
pageTitles: {
customers: 'Clientes',
create: 'Crear',
list: 'Listado',
webPayments: 'Pagos Web',
extendedList: 'Listado extendido',
notifications: 'Notificaciones',
defaulter: 'Morosos',
createCustomer: 'Crear cliente',
basicData: 'Datos básicos',
summary: 'Resumen',
basicData: 'Datos básicos',
fiscalData: 'Datos fiscales',
billingData: 'Forma de pago',
consignees: 'Consignatarios',
notes: 'Notas',
credits: 'Créditos',
greuges: 'Greuges',
balance: 'Balance',
recoveries: 'Recobros',
webAccess: 'Acceso web',
log: 'Historial',
sms: 'Sms',
creditManagement: 'Gestión de crédito',
creditContracts: 'Contratos de crédito',
creditOpinion: 'Opinión de crédito',
others: 'Otros',
},
list: {
phone: 'Teléfono',
@ -175,6 +229,172 @@ export default {
salesPerson: 'Comercial',
contactChannel: 'Canal de contacto',
},
extendedList: {
tableVisibleColumns: {
id: 'Identificador',
name: 'Nombre',
fi: 'NIF / CIF',
salesPersonFk: 'Comercial',
credit: 'Crédito',
creditInsurance: 'Crédito asegurado',
phone: 'Teléfono',
mobile: 'Móvil',
street: 'Dirección fiscal',
countryFk: 'País',
provinceFk: 'Provincia',
city: 'Población',
postcode: 'Código postal',
email: 'Email',
created: 'Fecha creación',
businessTypeFk: 'Tipo de negocio',
payMethodFk: 'Forma de pago',
sageTaxTypeFk: 'Tipo de impuesto Sage',
sageTransactionTypeFk: 'Tipo tr. sage',
isActive: 'Activo',
isVies: 'Vies',
isTaxDataChecked: 'Datos comprobados',
isEqualizated: 'Recargo de equivalencias',
isFreezed: 'Congelado',
hasToInvoice: 'Factura',
hasToInvoiceByAddress: 'Factura por consigna',
isToBeMailed: 'Env. emails',
hasLcr: 'Recibido LCR',
hasCoreVnl: 'Recibido core VNL',
hasSepaVnl: 'Recibido B2B VNL',
},
},
},
entry: {
pageTitles: {
entries: 'Entradas',
list: 'Listado',
summary: 'Resumen',
basicData: 'Datos básicos',
buys: 'Compras',
notes: 'Notas',
log: 'Historial',
create: 'Crear',
latestBuys: 'Últimas compras',
},
list: {
newEntry: 'Nueva entrada',
landed: 'F. entrega',
invoiceNumber: 'Núm. factura',
supplier: 'Proveedor',
booked: 'Asentado',
confirmed: 'Confirmado',
ordered: 'Pedida',
},
summary: {
commission: 'Comisión',
currency: 'Moneda',
company: 'Empresa',
reference: 'Referencia',
invoiceNumber: 'Núm. factura',
ordered: 'Pedida',
confirmed: 'Confirmado',
booked: 'Asentado',
raid: 'Redada',
excludedFromAvailable: 'Inventario',
travelReference: 'Referencia',
travelAgency: 'Agencia',
travelShipped: 'F. envio',
travelWarehouseOut: 'Alm. salida',
travelDelivered: 'Enviada',
travelLanded: 'F. entrega',
travelWarehouseIn: 'Alm. entrada',
travelReceived: 'Recibida',
buys: 'Compras',
quantity: 'Cantidad',
stickers: 'Etiquetas',
package: 'Embalaje',
weight: 'Peso',
packing: 'Packing',
grouping: 'Grouping',
buyingValue: 'Coste',
import: 'Importe',
pvp: 'PVP',
item: 'Artículo',
},
basicData: {
supplier: 'Proveedor',
travel: 'Envío',
reference: 'Referencia',
invoiceNumber: 'Núm. factura',
company: 'Empresa',
currency: 'Moneda',
observation: 'Observación',
commission: 'Comisión',
ordered: 'Pedida',
confirmed: 'Confirmado',
booked: 'Asentado',
raid: 'Redada',
excludedFromAvailable: 'Inventario',
agency: 'Agencia',
warehouseOut: 'Alm. salida',
warehouseIn: 'Alm. entrada',
shipped: 'F. envío',
landed: 'F. entrega',
id: 'ID',
},
buys: {
groupingPrice: 'Precio grouping',
packingPrice: 'Precio packing',
reference: 'Referencia',
observations: 'Observaciónes',
item: 'Artículo',
description: 'Descripción',
size: 'Medida',
packing: 'Packing',
grouping: 'Grouping',
buyingValue: 'Coste',
packagingFk: 'Embalaje',
file: 'Fichero',
name: 'Nombre',
producer: 'Productor',
type: 'Tipo',
color: 'Color',
id: 'ID',
},
notes: {
observationType: 'Tipo de observación',
description: 'Descripción',
},
descriptor: {
agency: 'Agencia',
landed: 'F. entrega',
warehouseOut: 'Alm. salida',
},
latestBuys: {
picture: 'Foto',
itemFk: 'ID Artículo',
packing: 'Packing',
grouping: 'Grouping',
quantity: 'Cantidad',
description: 'Descripción',
size: 'Medida',
tags: 'Etiquetas',
type: 'Tipo',
intrastat: 'Intrastat',
origin: 'Origen',
weightByPiece: 'Peso (gramos)/tallo',
isActive: 'Activo',
family: 'Familia',
entryFk: 'Entrada',
buyingValue: 'Coste',
freightValue: 'Porte',
comissionValue: 'Comisión',
packageValue: 'Embalaje',
isIgnored: 'Ignorado',
price2: 'Grouping',
price3: 'Packing',
minPrice: 'Min',
ektFk: 'Ekt',
weight: 'Peso',
packagingFk: 'Embalaje',
packingOut: 'Embalaje envíos',
landing: 'Llegada',
},
},
ticket: {
pageTitles: {
@ -224,7 +444,6 @@ export default {
invoice: 'Factura',
shipped: 'Enviado',
landed: 'Entregado',
packages: 'Bultos',
consigneePhone: 'Tel. consignatario',
consigneeMobile: 'Móv. consignatario',
clientPhone: 'Tel. cliente',
@ -241,7 +460,6 @@ export default {
description: 'Descripción',
price: 'Precio',
discount: 'Descuento',
amount: 'Importe',
packing: 'Encajado',
hasComponentLack: 'Faltan componentes',
itemShortage: 'No visible',
@ -273,6 +491,7 @@ export default {
photos: 'Fotos',
log: 'Registros de auditoría',
notes: 'Notas',
action: 'Acción',
},
list: {
customer: 'Cliente',
@ -327,13 +546,13 @@ export default {
responsible: 'Responsable',
worker: 'Trabajador',
redelivery: 'Devolución',
returnOfMaterial: 'RMA',
},
basicData: {
customer: 'Cliente',
assignedTo: 'Asignada a',
created: 'Creada',
state: 'Estado',
packages: 'Bultos',
picked: 'Recogida',
returnOfMaterial: 'Autorización de retorno de materiales (RMA)',
},
@ -348,6 +567,8 @@ export default {
pageTitles: {
invoiceOuts: 'Fact. emitidas',
list: 'Listado',
negativeBases: 'Bases Negativas',
globalInvoicing: 'Facturación global',
createInvoiceOut: 'Crear fact. emitida',
summary: 'Resumen',
basicData: 'Datos básicos',
@ -356,7 +577,6 @@ export default {
ref: 'Referencia',
issued: 'Fecha emisión',
shortIssued: 'F. emisión',
amount: 'Importe',
client: 'Cliente',
created: 'Fecha creación',
shortCreated: 'F. creación',
@ -366,7 +586,6 @@ export default {
},
card: {
issued: 'Fecha emisión',
amount: 'Importe',
client: 'Cliente',
company: 'Empresa',
customerCard: 'Ficha del cliente',
@ -389,17 +608,144 @@ export default {
shipped: 'F. envío',
totalWithVat: 'Importe',
},
globalInvoices: {
errors: {
chooseValidClient: 'Selecciona un cliente válido',
chooseValidCompany: 'Selecciona una empresa válida',
chooseValidPrinter: 'Selecciona una impresora 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 hay clientes para facturar',
criticalInvoiceError: 'Error crítico en la facturación, proceso detenido',
},
table: {
client: 'Cliente',
addressId: 'Id dirección',
streetAddress: 'Dirección fiscal',
},
statusCard: {
percentageText: '{getPercentage}% {getAddressNumber} de {getNAddresses}',
pdfsNumberText: '{nPdfs} de {totalPdfs} PDFs',
},
},
negativeBases: {
from: 'Desde',
to: 'Hasta',
company: 'Empresa',
country: 'País',
clientId: 'Id cliente',
client: 'Cliente',
amount: 'Importe',
base: 'Base',
ticketId: 'Id ticket',
active: 'Activo',
hasToInvoice: 'Facturar',
verifiedData: 'Datos comprobados',
comercial: 'Comercial',
errors: {
downloadCsvFailed: 'Error al descargar CSV',
},
},
},
order: {
pageTitles: {
order: 'Cesta',
orderList: 'Listado',
create: 'Crear',
summary: 'Resumen',
basicData: 'Datos básicos',
catalog: 'Catálogo',
volume: 'Volumen',
lines: 'Líneas',
},
field: {
salesPersonFk: 'Comercial',
clientFk: 'Cliente',
isConfirmed: 'Confirmada',
created: 'Creado',
landed: 'F. entrega',
hour: 'Hora',
agency: 'Agencia',
total: 'Total',
},
form: {
clientFk: 'Cliente',
addressFk: 'Dirección',
landed: 'F. entrega',
agencyModeFk: 'Agencia',
},
list: {
newOrder: 'Nuevo Pedido',
},
summary: {
basket: 'Cesta',
nickname: 'Alias',
company: 'Empresa',
confirmed: 'Confirmada',
notConfirmed: 'No confirmada',
created: 'Creado',
landed: 'F. entrega',
phone: 'Teléfono',
createdFrom: 'Creado desde',
address: 'Dirección',
notes: 'Notas',
subtotal: 'Subtotal',
total: 'Total',
vat: 'IVA',
state: 'Estado',
alias: 'Alias',
items: 'Items',
orderTicketList: 'Tickets del pedido',
details: 'Detalles',
item: 'Item',
description: 'Descripción',
quantity: 'Cantidad',
price: 'Precio',
amount: 'Monto',
},
},
shelving: {
pageTitles: {
shelving: 'Carros',
shelvingList: 'Listado de carros',
create: 'Crear',
summary: 'Resumen',
basicData: 'Datos básicos',
log: 'Registros de auditoría',
},
list: {
parking: 'Parking',
priority: 'Prioridad',
newShelving: 'Nuevo Carro',
},
summary: {
code: 'Código',
parking: 'Parking',
priority: 'Prioridad',
worker: 'Trabajador',
recyclable: 'Reciclable',
},
basicData: {
code: 'Código',
parking: 'Parking',
priority: 'Prioridad',
recyclable: 'Reciclable',
},
},
invoiceIn: {
pageTitles: {
invoiceIns: 'Fact. recibidas',
list: 'Listado',
createInvoiceOut: 'Crear fact. recibida',
createInvoiceIn: 'Crear fact. recibida',
summary: 'Resumen',
basicData: 'Datos básicos',
vat: 'IVA',
dueDay: 'Vencimiento',
intrastat: 'Intrastat',
corrective: 'Rectificativa',
log: 'Registros de auditoría',
},
list: {
@ -426,8 +772,8 @@ export default {
dueDay: 'Fecha de vencimiento',
},
summary: {
supplier: 'Supplier',
supplierRef: 'Supplier ref.',
supplier: 'Proveedor',
supplierRef: 'Ref. proveedor',
currency: 'Divisa',
docNumber: 'Número documento',
issued: 'Fecha de expedición',
@ -453,6 +799,25 @@ export default {
country: 'País',
},
},
department: {
pageTitles: {
basicData: 'Basic data',
department: 'Departamentos',
summary: 'Resumen',
},
name: 'Nombre',
code: 'Código',
chat: 'Chat',
bossDepartment: 'Jefe de departamento',
email: 'Email',
selfConsumptionCustomer: 'Cliente autoconsumo',
telework: 'Teletrabaja',
notifyOnErrors: 'Notificar errores',
worksInProduction: 'Pertenece a producción',
hasToRefill: 'Completar días sin registros físicos',
hasToSendMail: 'Enviar fichadas por mail',
departmentRemoved: 'Departamento eliminado',
},
worker: {
pageTitles: {
workers: 'Trabajadores',
@ -460,6 +825,8 @@ export default {
basicData: 'Datos básicos',
summary: 'Resumen',
notifications: 'Notificaciones',
workerCreate: 'Nuevo trabajador',
department: 'Departamentos',
},
list: {
name: 'Nombre',
@ -469,6 +836,7 @@ export default {
active: 'Activo',
department: 'Departamento',
schedule: 'Horario',
newWorker: 'Nuevo trabajador',
},
card: {
workerId: 'ID Trabajador',
@ -500,6 +868,25 @@ export default {
subscribed: 'Se ha suscrito a la notificación',
unsubscribed: 'Se ha dado de baja de la notificación',
},
create: {
name: 'Nombre',
lastName: 'Apellido',
birth: 'Fecha de nacimiento',
fi: 'DNI/NIF/NIE',
code: 'Código de trabajador',
phone: 'Teléfono',
postcode: 'Código postal',
province: 'Provincia',
city: 'Población',
street: 'Dirección',
webUser: 'Usuario Web',
personalEmail: 'Correo personal',
company: 'Empresa',
boss: 'Jefe',
payMethods: 'Método de pago',
iban: 'IBAN',
bankEntity: 'Swift / BIC',
},
imageNotFound: 'No se ha encontrado la imagen',
},
wagon: {
@ -511,6 +898,7 @@ export default {
typesList: 'Listado tipos',
typeCreate: 'Crear tipo',
typeEdit: 'Editar tipo',
wagonCounter: 'Contador de carros',
},
type: {
name: 'Nombre',
@ -549,6 +937,10 @@ export default {
pageTitles: {
routes: 'Rutas',
cmrsList: 'Listado de CMRs externos',
RouteList: 'Listado',
create: 'Crear',
basicData: 'Datos básicos',
summary: 'Summary',
},
cmr: {
list: {
@ -567,17 +959,220 @@ export default {
},
},
},
supplier: {
pageTitles: {
suppliers: 'Proveedores',
supplier: 'Proveedor',
list: 'Listado',
create: 'Crear',
summary: 'Resumen',
basicData: 'Datos básicos',
fiscalData: 'Datos fiscales',
billingData: 'Forma de pago',
log: 'Historial',
accounts: 'Cuentas',
contacts: 'Contactos',
addresses: 'Direcciones',
consumption: 'Consumo',
agencyTerm: 'Acuerdo agencia',
},
list: {
payMethod: 'Método de pago',
payDeadline: 'Plazo de pago',
payDay: 'Día de pago',
account: 'Cuenta',
newSupplier: 'Nuevo proveedor',
},
summary: {
responsible: 'Responsable',
notes: 'Notas',
verified: 'Verificado',
isActive: 'Está activo',
billingData: 'Forma de pago',
payMethod: 'Método de pago',
payDeadline: 'Plazo de pago',
payDay: 'Día de pago',
account: 'Cuenta',
fiscalData: 'Datos fiscales',
sageTaxType: 'Tipo de impuesto Sage',
sageTransactionType: 'Tipo de transacción Sage',
sageWithholding: 'Retención sage',
supplierActivity: 'Actividad proveedor',
healthRegister: 'Pasaporte sanitario',
fiscalAddress: 'Dirección fiscal',
socialName: 'Razón social',
taxNumber: 'NIF/CIF',
street: 'Dirección',
city: 'Población',
postCode: 'Código postal',
province: 'Provincia',
country: 'País',
},
create: {
supplierName: 'Nombre del proveedor',
},
basicData: {
alias: 'Alias',
workerFk: 'Responsable',
isSerious: 'Verificado',
isActive: 'Activo',
isPayMethodChecked: 'Método de pago validado',
note: 'Notas',
},
fiscalData: {
name: 'Razón social *',
nif: 'NIF/CIF *',
account: 'Cuenta',
sageTaxTypeFk: 'Tipo de impuesto sage',
sageWithholdingFk: 'Retención sage',
sageTransactionTypeFk: 'Tipo de transacción sage',
supplierActivityFk: 'Actividad proveedor',
healthRegister: 'Pasaporte sanitario',
street: 'Calle',
postcode: 'Código postal',
city: 'Población *',
provinceFk: 'Provincia',
country: 'País',
isTrucker: 'Transportista',
isVies: 'Vies',
},
billingData: {
payMethodFk: 'Forma de pago',
payDemFk: 'Plazo de pago',
payDay: 'Día de pago',
},
accounts: {
iban: 'Iban',
bankEntity: 'Entidad bancaria',
beneficiary: 'Beneficiario',
},
contacts: {
name: 'Nombre',
phone: 'Teléfono',
mobile: 'Móvil',
email: 'Email',
observation: 'Notas',
},
addresses: {
street: 'Dirección',
postcode: 'Código postal',
phone: 'Teléfono',
name: 'Nombre',
city: 'Población',
province: 'Provincia',
mobile: 'Móvil',
},
agencyTerms: {
agencyFk: 'Agencia',
minimumM3: 'M3 mínimos',
packagePrice: 'Precio bulto',
kmPrice: 'Precio Km',
m3Price: 'Precio M3',
routePrice: 'Precio ruta',
minimumKm: 'Km mínimos',
addRow: 'Añadir fila',
},
consumption: {
entry: 'Entrada',
date: 'Fecha',
reference: 'Referencia',
},
},
travel: {
pageTitles: {
travel: 'Envíos',
list: 'Listado',
create: 'Crear',
summary: 'Resumen',
extraCommunity: 'Extra comunitarios',
travelCreate: 'Nuevo envío',
basicData: 'Datos básicos',
history: 'Historial',
thermographs: 'Termógrafos',
},
summary: {
confirmed: 'Confirmado',
entryId: 'Id entrada',
freight: 'Porte',
package: 'Embalaje',
delivered: 'Enviada',
received: 'Recibida',
entries: 'Entradas',
cloneShipping: 'Clonar envío',
CloneTravelAndEntries: 'Clonar travel y sus entradas',
deleteTravel: 'Eliminar envío',
AddEntry: 'Añadir entrada',
thermographs: 'Termógrafos',
hb: 'HB',
},
variables: {
search: 'Id/Referencia',
agencyModeFk: 'Agencia',
warehouseInFk: 'Alm. entrada',
warehouseOutFk: ' Alm. salida',
landedFrom: 'Llegada desde',
landedTo: 'Llegada hasta',
continent: 'Cont. Salida',
totalEntries: 'Ent. totales',
},
basicData: {
reference: 'Referencia',
agency: 'Agencia',
shipped: 'F. Envío',
landed: 'F. entrega',
warehouseOut: 'Alm. salida',
warehouseIn: 'Alm. entrada',
delivered: 'Enviada',
received: 'Recibida',
},
thermographs: {
code: 'Código',
temperature: 'Temperatura',
state: 'Estado',
destination: 'Destino',
created: 'Fecha creación',
thermograph: 'Termógrafo',
reference: 'Referencia',
type: 'Tipo',
company: 'Empresa',
warehouse: 'Almacén',
travelFileDescription: 'Id envío { travelId }',
file: 'Fichero',
description: 'Descripción',
},
},
item: {
pageTitles: {
items: 'Artículos',
list: 'Listado',
diary: 'Histórico',
tags: 'Etiquetas',
},
descriptor: {
item: 'Artículo',
buyer: 'Comprador',
color: 'Color',
category: 'Categoría',
stems: 'Tallos',
visible: 'Visible',
available: 'Disponible',
warehouseText: 'Calculado sobre el almacén de { warehouseName }',
itemDiary: 'Registro de compra-venta',
},
},
components: {
topbar: {},
userPanel: {
copyToken: 'Token copiado al portapapeles',
settings: 'Configuración',
logOut: 'Cerrar sesión',
},
smartCard: {
openCard: 'Ver ficha',
openSummary: 'Abrir detalles',
viewDescription: 'Ver descripción',
downloadFile: 'Descargar archivo',
clone: 'Clonar',
openCard: 'Ficha',
openSummary: 'Detalles',
viewDescription: 'Descripción',
},
cardDescriptor: {
mainList: 'Listado principal',
@ -588,5 +1183,9 @@ export default {
addToPinned: 'Añadir a fijados',
removeFromPinned: 'Eliminar de fijados',
},
VnLv: {
copyText: '{copyValue} se ha copiado al portapepeles',
},
iban_tooltip: 'IBAN: ES21 1234 5678 90 0123456789',
},
};

View File

@ -1,6 +1,8 @@
import en from './en';
import es from './es';
export const localeEquivalence = {
'en':'en-GB'
}
export default {
en: en,
es: es,

View File

@ -1,7 +1,6 @@
<script setup>
import { useQuasar } from 'quasar';
import Navbar from 'src/components/NavBar.vue';
const quasar = useQuasar();
</script>

View File

@ -26,6 +26,7 @@ const userLocale = computed({
}
},
});
const darkMode = computed({
get() {
return Dark.isActive;

View File

@ -0,0 +1,507 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { useStateStore } from 'src/stores/useStateStore';
import { toDate, toPercentage, toCurrency } from 'filters/index';
import { tMobile } from 'src/composables/tMobile';
import CrudModel from 'src/components/CrudModel.vue';
import FetchData from 'src/components/FetchData.vue';
import VnSelectFilter from 'src/components/common/VnSelectFilter.vue';
import VnConfirm from 'src/components/ui/VnConfirm.vue';
import TicketDescriptorProxy from 'src/pages/Ticket/Card/TicketDescriptorProxy.vue';
import { useArrayData } from 'composables/useArrayData';
const { t } = useI18n();
const quasar = useQuasar();
const route = useRoute();
const router = useRouter();
const stateStore = computed(() => useStateStore());
const claim = ref(null);
const claimRef = ref();
const claimId = route.params.id;
const dialogDestination = ref(false);
const claimDestinationFk = ref(null);
const resolvedStateId = ref(null);
const claimActionsForm = ref();
const rows = ref([]);
const selectedRows = ref([]);
const destinationTypes = ref([]);
const totalClaimed = ref(null);
const DEFAULT_MAX_RESPONSABILITY = 5;
const DEFAULT_MIN_RESPONSABILITY = 1;
const arrayData = useArrayData('claimData');
const marker_labels = [
{ value: DEFAULT_MIN_RESPONSABILITY, label: t('claim.summary.company') },
{ value: DEFAULT_MAX_RESPONSABILITY, label: t('claim.summary.person') },
];
const multiplicatorValue = ref();
const columns = computed(() => [
{
name: 'Id',
label: t('Id item'),
field: (row) => row.itemFk,
},
{
name: 'ticket',
label: t('Ticket'),
field: (row) => row.ticketFk,
align: 'center',
},
{
name: 'destination',
label: t('Destination'),
field: (row) => row.claimDestinationFk,
align: 'left',
},
{
name: 'Landed',
label: t('Landed'),
field: (row) => toDate(row.landed),
},
{
name: 'quantity',
label: t('Quantity'),
field: (row) => row.quantity,
},
{
name: 'concept',
label: t('Description'),
field: (row) => row.concept,
align: 'left',
},
{
name: 'price',
label: t('Price'),
field: (row) => row.price,
format: (value) => value,
align: 'center',
},
{
name: 'discount',
label: t('Discount'),
field: (row) => row.discount,
format: (value) => toPercentage(value / 100),
align: 'left',
},
{
name: 'total',
label: t('Total'),
field: (row) => row.total,
format: (value) => value,
align: 'center',
},
{
name: 'delete',
},
]);
onMounted(() => {
getTotal();
});
function setData(data) {
rows.value = data;
getTotal();
}
function getTotal() {
if (rows.value.length) {
totalClaimed.value = rows.value.reduce((total, row) => total + row.total, 0);
}
}
async function updateDestinations(claimDestinationFk) {
await updateDestination(claimDestinationFk, selectedRows.value, { reload: true });
}
async function updateDestination(claimDestinationFk, row, options = {}) {
if (claimDestinationFk) {
await axios.post('Claims/updateClaimDestination', {
claimDestinationFk,
rows: Array.isArray(row) ? row : [row],
});
options.reload && claimActionsForm.value.reload();
}
}
async function regularizeClaim() {
await axios.post(`Claims/${claimId}/regularizeClaim`);
await claimRef.value.fetch();
await arrayData.fetch({ append: false });
quasar.notify({
message: t('globals.dataSaved'),
type: 'positive',
});
if (multiplicatorValue.value) await onUpdateGreugeAccept();
}
async function onUpdateGreugeAccept() {
const greugeTypeFreightId = (
await axios.get(`GreugeTypes/findOne`, {
filter: { where: { code: 'freightPickUp' } },
})
).data.id;
const freightPickUpPrice =
(await axios.get(`GreugeConfigs/findOne`)).data.freightPickUpPrice *
multiplicatorValue.value;
await axios.post(`Greuges`, {
clientFk: claim.value.clientFk,
description: `${t('ClaimGreugeDescription')} ${claimId}`.toUpperCase(),
amount: freightPickUpPrice,
greugeTypeFk: greugeTypeFreightId,
ticketFk: claim.value.ticketFk,
});
quasar.notify({
message: t('globals.dataSaved'),
type: 'positive',
});
}
async function save(data) {
const query = `Claims/${claimId}/updateClaimAction`;
await axios.patch(query, data);
}
async function importToNewRefundTicket() {
const query = `ClaimBeginnings/${claimId}/importToNewRefundTicket`;
await axios.post(query);
claimActionsForm.value.reload();
quasar.notify({
message: t('globals.dataSaved'),
type: 'positive',
});
}
</script>
<template>
<FetchData
ref="claimRef"
:url="`Claims/${claimId}`"
@on-fetch="(data) => (claim = data)"
auto-load
/>
<FetchData
url="ClaimStates/findOne"
@on-fetch="(data) => (resolvedStateId = data.id)"
auto-load
:where="{ code: 'resolved' }"
/>
<FetchData
url="ClaimDestinations"
auto-load
@on-fetch="(data) => (destinationTypes = data)"
/>
<template v-if="stateStore.isHeaderMounted()">
<Teleport to="#actions-append">
<div class="row q-gutter-x-sm">
<QBtn
flat
@click="stateStore.toggleRightDrawer()"
round
dense
icon="menu"
>
<QTooltip bottom anchor="bottom right">
{{ t('globals.collapseMenu') }}
</QTooltip>
</QBtn>
</div>
</Teleport>
</template>
<QDrawer
v-model="stateStore.rightDrawer"
side="right"
:width="300"
show-if-above
v-if="claim"
>
<QCard class="totalClaim q-my-md q-pa-sm no-box-shadow">
{{ `${t('Total claimed')}: ${toCurrency(totalClaimed)}` }}
</QCard>
<QCard class="q-mb-md q-pa-sm no-box-shadow">
<QItem class="justify-between">
<QItemLabel class="slider-container">
<p class="text-primary">
{{ t('claim.summary.actions') }}
</p>
<QSlider
class="responsibility { 'background-color:primary': quasar.platform.is.mobile }"
v-model="claim.responsibility"
:label-value="t('claim.summary.responsibility')"
@change="(value) => save({ responsibility: value })"
label-always
color="primary"
markers
:marker-labels="marker_labels"
:min="DEFAULT_MIN_RESPONSABILITY"
:max="DEFAULT_MAX_RESPONSABILITY"
/>
</QItemLabel>
</QItem>
</QCard>
<QCard class="q-mb-md q-pa-sm no-box-shadow" style="margin-bottom: 1em">
<QItemLabel class="mana q-mb-md">
<QCheckbox
v-model="claim.isChargedToMana"
@update:model-value="(value) => save({ isChargedToMana: value })"
/>
<span>{{ t('mana') }}</span>
</QItemLabel>
</QCard>
<QCard class="q-mb-md q-pa-sm no-box-shadow" style="position: static">
<QInput
:disable="
!(claim.responsibility >= Math.ceil(DEFAULT_MAX_RESPONSABILITY) / 2)
"
:label="t('confirmGreuges')"
class="q-field__native text-grey-2"
type="number"
placeholder="0"
id="multiplicatorValue"
name="multiplicatorValue"
min="0"
max="50"
v-model="multiplicatorValue"
/>
</QCard>
</QDrawer>
<Teleport to="#st-data" v-if="stateStore.isSubToolbarShown()"> </Teleport>
<CrudModel
v-if="claim"
data-key="ClaimEnds"
url="ClaimEnds/filter"
save-url="ClaimEnds/crud"
ref="claimActionsForm"
v-model:selected="selectedRows"
:filter="{ where: { claimFk: claimId } }"
:default-remove="true"
:default-save="false"
:default-reset="false"
@on-fetch="setData"
auto-load
>
<template #body>
<QTable
:columns="columns"
:rows="rows"
:dense="$q.screen.lt.md"
row-key="id"
selection="multiple"
v-model:selected="selectedRows"
:grid="$q.screen.lt.md"
:pagination="{ rowsPerPage: 0 }"
:hide-bottom="true"
>
<template #body-cell-ticket="{ value }">
<QTd align="center">
<span class="link">
{{ value }}
<TicketDescriptorProxy :id="value" />
</span>
</QTd>
</template>
<template #body-cell-destination="{ row }">
<QTd>
<VnSelectFilter
v-model="row.claimDestinationFk"
:options="destinationTypes"
option-label="description"
option-value="id"
:autofocus="true"
dense
input-debounce="0"
hide-selected
@update:model-value="(value) => updateDestination(value, row)"
/>
</QTd>
</template>
<template #body-cell-price="{ value }">
<QTd align="center">
{{ toCurrency(value) }}
</QTd>
</template>
<template #body-cell-total="{ value }">
<QTd align="center">
{{ toCurrency(value) }}
</QTd>
</template>
<!-- View for grid mode -->
<template #item="props">
<div class="q-mb-md col-12 grid-style-transition">
<QCard>
<QCardSection class="row justify-between">
<QCheckbox v-model="props.selected" />
<QBtn color="primary" icon="delete" flat round />
</QCardSection>
<QSeparator inset />
<QList dense>
<QItem v-for="column of props.cols" :key="column.name">
<QItemSection>
<QItemLabel caption>
{{ column.label }}
</QItemLabel>
</QItemSection>
<QItemSection side>
<QItemLabel v-if="column.name === 'destination'">
<VnSelectFilter
v-model="props.row.claimDestinationFk"
:options="destinationTypes"
option-label="description"
option-value="id"
:autofocus="true"
dense
input-debounce="0"
hide-selected
@update:model-value="
(value) =>
updateDestination(
value,
props.row
)
"
/>
</QItemLabel>
<QItemLabel v-else>
{{ column.value }}
</QItemLabel>
</QItemSection>
</QItem>
</QList>
</QCard>
</div>
</template>
</QTable>
</template>
<template #moreBeforeActions>
<QBtn
color="primary"
text-color="white"
:unelevated="true"
:label="tMobile('Regularize')"
:title="t('Regularize')"
icon="check"
@click="regularizeClaim"
:disable="claim.claimStateFk == resolvedStateId"
/>
<QBtn
color="primary"
text-color="white"
:unelevated="true"
:disable="!selectedRows.length"
:label="tMobile('Change destination')"
:title="t('Change destination')"
icon="swap_horiz"
@click="dialogDestination = !dialogDestination"
/>
<QBtn
color="primary"
text-color="white"
:unelevated="true"
:label="tMobile('Import claim')"
:title="t('Import claim')"
icon="Upload"
@click="importToNewRefundTicket"
:disable="claim.claimStateFk == resolvedStateId"
/>
</template>
</CrudModel>
<QDialog v-model="dialogDestination">
<QCard>
<QCardSection>
<QItem class="q-pa-sm">
<span class="q-dialog__title text-white">
{{ t('dialog title') }}
</span>
<QBtn icon="close" flat round dense v-close-popup />
</QItem>
</QCardSection>
<QItemSection>
<VnSelectFilter
class="q-pa-sm"
v-model="claimDestinationFk"
:options="destinationTypes"
option-label="description"
option-value="id"
:autofocus="true"
dense
input-debounce="0"
hide-selected
/>
</QItemSection>
<QCardActions class="justify-end q-mr-sm">
<QBtn flat :label="t('globals.close')" color="primary" v-close-popup />
<QBtn
:disable="!claimDestinationFk"
:label="t('globals.save')"
color="primary"
v-close-popup
@click="updateDestinations(claimDestinationFk)"
/>
</QCardActions>
</QCard>
</QDialog>
</template>
<style lang="scss" scoped>
.slider-container {
width: 50%;
}
@media (max-width: $breakpoint-xs) {
.slider-container {
width: 90%;
}
}
.q-table {
.q-item {
min-height: min-content;
height: 0;
}
}
.q-dialog {
.q-btn {
height: min-content;
}
}
.responsibility {
max-width: 100%;
margin-left: 40px;
}
.mana {
float: inline-start;
}
</style>
<i18n>
en:
mana: Is paid with mana
dialog title: Change destination to all selected rows
confirmGreuges: Do you want to insert complaints?
confirmGreugesMessage: Insert complaints into the client's record
es:
mana: Cargado al maná
Delivered: Descripción
Quantity: Cantidad
Claimed: Rec
Description: Descripción
Price: Precio
Discount: Dto.
Destination: Destino
Landed: F.entrega
Remove line: Eliminar línea
Total claimed: Total reclamado
Regularize: Regularizar
Change destination: Cambiar destino
Import claim: Importar reclamación
dialog title: Cambiar destino en todas las filas seleccionadas
Remove: Eliminar
dialogGreuge title: Insertar greuges en la ficha del cliente
ClaimGreugeDescription: Id reclamación
Id item: Id artículo
confirmGreuges: ¿Desea insertar greuges?
confirmGreugesMessage: Insertar greuges en la ficha del cliente
Apply Greuges: Aplicar Greuges
</i18n>

View File

@ -3,10 +3,13 @@ import { ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useSession } from 'src/composables/useSession';
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 VnInputDate from 'components/common/VnInputDate.vue';
import { useSession } from 'src/composables/useSession';
const route = useRoute();
const { t } = useI18n();
@ -96,44 +99,22 @@ const statesFilter = {
:url-update="`Claims/updateClaim/${route.params.id}`"
:filter="claimFilter"
model="claim"
auto-load
>
<template #form="{ data, validate, filter }">
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<QInput
<VnInput
v-model="data.client.name"
:label="t('claim.basicData.customer')"
disable
/>
</div>
<div class="col">
<QInput
<VnInputDate
v-model="data.created"
mask="####-##-##"
fill-mask="_"
autofocus
>
<template #append>
<QIcon name="event" class="cursor-pointer">
<QPopupProxy
cover
transition-show="scale"
transition-hide="scale"
>
<QDate v-model="data.created" mask="YYYY-MM-DD">
<div class="row items-center justify-end">
<QBtn
v-close-popup
label="Close"
color="primary"
flat
/>
</div>
</QDate>
</QPopupProxy>
</QIcon>
</template>
</QInput>
:label="t('claim.basicData.created')"
/>
</div>
</VnRow>
<VnRow class="row q-gutter-md q-mb-md">
@ -183,13 +164,13 @@ const statesFilter = {
<div class="col">
<QInput
v-model.number="data.packages"
:label="t('claim.basicData.packages')"
:label="t('globals.packages')"
:rules="validate('claim.packages')"
type="number"
/>
</div>
<div class="col">
<QInput
<VnInput
v-model="data.rma"
:label="t('claim.basicData.returnOfMaterial')"
:rules="validate('claim.rma')"

View File

@ -1,32 +1,13 @@
<script setup>
import LeftMenu from 'components/LeftMenu.vue';
import { getUrl } from 'composables/getUrl';
import VnSearchbar from 'src/components/ui/VnSearchbar.vue';
import { useStateStore } from 'stores/useStateStore';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import ClaimDescriptor from './ClaimDescriptor.vue';
import VnSubToolbar from 'src/components/ui/VnSubToolbar.vue';
import { onMounted } from 'vue';
const stateStore = useStateStore();
const { t } = useI18n();
const route = useRoute();
const $props = defineProps({
id: {
type: Number,
required: false,
default: null,
},
});
const entityId = computed(() => {
return $props.id || route.params.id;
});
let salixUrl;
onMounted(async () => {
salixUrl = await getUrl(`claim/${entityId.value}`);
});
</script>
<template>
<Teleport to="#searchbar" v-if="stateStore.isHeaderMounted()">
@ -42,27 +23,11 @@ onMounted(async () => {
<ClaimDescriptor />
<QSeparator />
<LeftMenu source="card" />
<QSeparator />
<QList>
<QItem
active-class="text-primary"
clickable
v-ripple
:href="`${salixUrl}/action`"
>
<QItemSection avatar><QIcon name="vn:actions"></QIcon></QItemSection>
<QItemSection>{{ t('Action') }}</QItemSection>
</QItem>
</QList>
</QScrollArea>
</QDrawer>
<QPageContainer>
<QPage>
<QToolbar class="bg-vn-dark justify-end">
<div id="st-data"></div>
<QSpace />
<div id="st-actions"></div>
</QToolbar>
<VnSubToolbar />
<div class="q-pa-md"><RouterView></RouterView></div>
</QPage>
</QPageContainer>

View File

@ -1,16 +1,16 @@
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { toDate } from 'src/filters';
import { toDate, toPercentage } from 'src/filters';
import { useState } from 'src/composables/useState';
import TicketDescriptorProxy from 'pages/Ticket/Card/TicketDescriptorProxy.vue';
import WorkerDescriptorProxy from 'src/pages/Worker/Card/WorkerDescriptorProxy.vue';
import ClaimDescriptorMenu from 'pages/Claim/Card/ClaimDescriptorMenu.vue';
import CardDescriptor from 'components/ui/CardDescriptor.vue';
import VnLv from 'src/components/ui/VnLv.vue';
import useCardDescription from 'src/composables/useCardDescription';
import VnUserLink from 'src/components/ui/VnUserLink.vue';
import { getUrl } from 'src/composables/getUrl';
const $props = defineProps({
id: {
@ -23,7 +23,7 @@ const $props = defineProps({
const route = useRoute();
const state = useState();
const { t } = useI18n();
const salixUrl = ref();
const entityId = computed(() => {
return $props.id || route.params.id;
});
@ -33,7 +33,16 @@ const filter = {
{
relation: 'client',
scope: {
include: { relation: 'salesPersonUser' },
include: [
{ relation: 'salesPersonUser' },
{
relation: 'claimsRatio',
scope: {
fields: ['claimingRate'],
limit: 1,
},
},
],
},
},
{
@ -62,16 +71,23 @@ const filter = {
],
};
const STATE_COLOR = {
pending: 'warning',
managed: 'info',
resolved: 'positive',
};
function stateColor(code) {
if (code === 'pending') return 'positive';
if (code === 'managed') return 'warning';
if (code === 'resolved') return 'negative';
return STATE_COLOR[code];
}
const data = ref(useCardDescription());
const setData = (entity) => {
if (!entity) return;
data.value = useCardDescription(entity.client.name, entity.id);
state.set('ClaimDescriptor', entity);
};
onMounted(async () => {
salixUrl.value = await getUrl('');
});
</script>
<template>
@ -83,6 +99,7 @@ const setData = (entity) => {
:title="data.title"
:subtitle="data.subtitle"
@on-fetch="setData"
data-key="claimData"
>
<template #menu="{ entity }">
<ClaimDescriptorMenu :claim="entity" />
@ -111,25 +128,29 @@ const setData = (entity) => {
:value="entity.worker.user.name"
>
<template #value>
<span class="link">
{{ entity.worker.user.name }}
<WorkerDescriptorProxy :id="entity.worker.userFk" />
</span>
<VnUserLink
:name="entity.worker.user.name"
:worker-id="entity.worker.id"
/>
</template>
</VnLv>
<VnLv :label="t('claim.card.commercial')">
<template #value>
<span class="link">
{{ entity.client.salesPersonUser.name }}
<WorkerDescriptorProxy :id="entity.client.salesPersonFk" />
</span>
<VnUserLink
:name="entity.client?.salesPersonUser?.name"
:worker-id="entity.client?.salesPersonFk"
/>
</template>
</VnLv>
<VnLv
:label="t('claim.card.province')"
:value="entity.ticket.address.province.name"
:value="entity.ticket?.address?.province?.name"
/>
<VnLv :label="t('claim.card.zone')" :value="entity.ticket?.zone?.name" />
<VnLv
:label="t('claimRate')"
:value="toPercentage(entity.client?.claimsRatio?.claimingRate)"
/>
<VnLv :label="t('claim.card.zone')" :value="entity.ticket.zone.name" />
</template>
<template #actions="{ entity }">
<QCardActions>
@ -149,6 +170,20 @@ const setData = (entity) => {
>
<QTooltip>{{ t('claim.card.claimedTicket') }}</QTooltip>
</QBtn>
<QBtn
size="md"
icon="assignment"
color="primary"
:href="salixUrl + 'ticket/' + entity.ticketFk + '/sale-tracking'"
>
</QBtn>
<QBtn
size="md"
icon="visibility"
color="primary"
:href="salixUrl + 'ticket/' + entity.ticketFk + '/tracking/index'"
>
</QBtn>
</QCardActions>
</template>
</CardDescriptor>
@ -158,3 +193,9 @@ const setData = (entity) => {
margin-top: 0;
}
</style>
<i18n>
en:
claimRate: Claming rate
es:
claimRate: Ratio de reclamación
</i18n>

View File

@ -37,7 +37,7 @@ function confirmPickupOrder() {
data: {
address: customer.email,
},
send: sendPickupOrder,
promise: sendPickupOrder,
},
});
}

View File

@ -1,11 +1,10 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import CrudModel from 'components/CrudModel.vue';
import FetchData from 'components/FetchData.vue';
import VnSelectFilter from 'components/common/VnSelectFilter.vue';
import { getUrl } from 'composables/getUrl';
import { tMobile } from 'composables/tMobile';
const route = useRoute();
@ -20,11 +19,6 @@ const claimRedeliveries = ref([]);
const workers = ref([]);
const selected = ref([]);
const saveButtonRef = ref();
let salixUrl;
onMounted(async () => {
salixUrl = await getUrl(`claim/${route.params.id}`);
});
const developmentsFilter = {
fields: [
@ -53,6 +47,7 @@ const columns = computed(() => [
optionValue: 'id',
optionLabel: 'description',
tabIndex: 1,
align: 'left',
},
{
name: 'claimResult',
@ -65,6 +60,7 @@ const columns = computed(() => [
optionValue: 'id',
optionLabel: 'description',
tabIndex: 2,
align: 'left',
},
{
name: 'claimResponsible',
@ -77,6 +73,7 @@ const columns = computed(() => [
optionValue: 'id',
optionLabel: 'description',
tabIndex: 3,
align: 'left',
},
{
name: 'worker',
@ -88,6 +85,7 @@ const columns = computed(() => [
optionValue: 'id',
optionLabel: 'nickname',
tabIndex: 4,
align: 'left',
},
{
name: 'claimRedelivery',
@ -100,12 +98,9 @@ const columns = computed(() => [
optionValue: 'id',
optionLabel: 'description',
tabIndex: 5,
align: 'left',
},
]);
function goToAction() {
location.href = `${salixUrl}/action`;
}
</script>
<template>
<FetchData
@ -148,7 +143,7 @@ function goToAction() {
:data-required="{ claimFk: route.params.id }"
v-model:selected="selected"
auto-load
@save-changes="goToAction"
@save-changes="$router.push(`/claim/${route.params.id}/action`)"
:default-save="false"
>
<template #body="{ rows }">
@ -161,6 +156,7 @@ function goToAction() {
hide-pagination
v-model:selected="selected"
:grid="$q.screen.lt.md"
table-header-class="text-left"
>
<template #body-cell="{ row, col }">
<QTd
@ -168,13 +164,13 @@ function goToAction() {
@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"
hide-selected
>
<template #option="scope" v-if="col.name == 'worker'">
<QItem v-bind="scope.itemProps">
@ -213,6 +209,7 @@ function goToAction() {
dense
input-debounce="0"
:autofocus="col.tabIndex == 1"
hide-selected
/>
</QItemSection>
</QItem>
@ -249,9 +246,6 @@ function goToAction() {
.grid-style-transition {
transition: transform 0.28s, background-color 0.28s;
}
.maxwidth {
width: 100%;
}
</style>
<i18n>

View File

@ -4,14 +4,14 @@ import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { useRoute } from 'vue-router';
import { useArrayData } from 'composables/useArrayData';
import { useStateStore } from 'stores/useStateStore';
import { useArrayData } from 'composables/useArrayData';
import { toDate, toCurrency, toPercentage } from 'filters/index';
import CrudModel from 'components/CrudModel.vue';
import FetchData from 'components/FetchData.vue';
import { toDate, toCurrency, toPercentage } from 'filters/index';
import VnDiscount from 'components/common/vnDiscount.vue';
import ClaimLinesImport from './ClaimLinesImport.vue';
import ItemDescriptorProxy from 'src/pages/Item/Card/ItemDescriptorProxy.vue';
const quasar = useQuasar();
const route = useRoute();
@ -43,17 +43,20 @@ async function onFetchClaim(data) {
fetchMana();
}
const amount = ref(0);
const amountClaimed = ref(0);
const amount = ref();
const amountClaimed = ref();
async function onFetch(rows) {
if (!rows || rows.length) return;
amount.value = 0;
amountClaimed.value = 0;
if (!rows || !rows.length) return;
amount.value = rows.reduce(
(acumulator, { sale }) => acumulator + sale.price * sale.quantity,
(accumulator, { sale }) => accumulator + sale.price * sale.quantity,
0
);
amountClaimed.value = rows.reduce(
(acumulator, line) => acumulator + line.sale.price * line.quantity,
(accumulator, line) => accumulator + line.sale.price * line.quantity,
0
);
}
@ -155,23 +158,21 @@ function showImportDialog() {
</script>
<template>
<Teleport to="#st-data" v-if="stateStore.isSubToolbarShown()">
<QToolbar class="bg-dark text-white">
<div class="row q-gutter-md">
<div>
{{ t('Amount') }}
<QChip :dense="$q.screen.lt.sm">
{{ toCurrency(amount) }}
</QChip>
</div>
<QSeparator dark vertical />
<div>
{{ t('Amount Claimed') }}
<QChip color="positive" :dense="$q.screen.lt.sm">
{{ toCurrency(amountClaimed) }}
</QChip>
</div>
<div class="row q-gutter-md">
<div>
{{ t('Amount') }}
<QChip :dense="$q.screen.lt.sm">
{{ toCurrency(amount) }}
</QChip>
</div>
</QToolbar>
<QSeparator dark vertical />
<div>
{{ t('Amount Claimed') }}
<QChip color="positive" :dense="$q.screen.lt.sm">
{{ toCurrency(amountClaimed) }}
</QChip>
</div>
</div>
</Teleport>
<FetchData
@ -189,6 +190,7 @@ function showImportDialog() {
save-url="ClaimBeginnings/crud"
:filter="linesFilter"
@on-fetch="onFetch"
@save-changes="onFetch"
v-model:selected="selected"
:default-save="false"
:default-reset="false"
@ -228,7 +230,14 @@ function showImportDialog() {
</QPopupEdit>
</QTd>
</template>
<template #body-cell-description="{ row, value }">
<QTd auto-width align="right" class="text-primary">
{{ value }}
<ItemDescriptorProxy
:id="row.sale.itemFk"
></ItemDescriptorProxy>
</QTd>
</template>
<template #body-cell-discount="{ row, value, rowIndex }">
<QTd auto-width align="right" class="text-primary">
{{ value }}

View File

@ -8,6 +8,10 @@ const state = useState();
const user = state.getUser();
const id = route.params.id;
const $props = defineProps({
addNote: { type: Boolean, default: true },
});
const claimFilter = {
where: { claimFk: id },
fields: ['created', 'workerFk', 'text'],
@ -15,6 +19,12 @@ const claimFilter = {
relation: 'worker',
scope: {
fields: ['id', 'firstName', 'lastName'],
include: {
relation: 'user',
scope: {
fields: ['id', 'nickname'],
},
},
},
},
};
@ -27,7 +37,8 @@ const body = {
<template>
<div class="column items-center">
<VnNotes
:add-note="true"
style="overflow-y: scroll"
:add-note="$props.addNote"
:id="id"
url="claimObservations"
:filter="claimFilter"

View File

@ -26,6 +26,7 @@ const client = ref({});
const inputFile = ref();
const files = ref({});
const spinnerRef = ref();
const claimDmsRef = ref();
const dmsType = ref({});
const config = ref({});
@ -118,11 +119,11 @@ async function create() {
clientId: client.value.id,
}).toUpperCase(),
};
spinnerRef.value.show();
await axios.post(query, formData, {
params: dms,
});
spinnerRef.value.hide();
quasar.notify({
message: t('globals.dataSaved'),
type: 'positive',
@ -234,7 +235,9 @@ function onDrag() {
</div>
</div>
</div>
<QDialog ref="spinnerRef">
<QSpinner color="primary" size="xl" />
</QDialog>
<QPageSticky position="bottom-right" :offset="[25, 25]">
<label for="fileInput">
<QBtn fab @click="inputFile.nativeEl.click()" icon="add" color="primary">

View File

@ -1,5 +1,5 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { onMounted, ref, computed, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { toDate, toCurrency } from 'src/filters';
@ -7,8 +7,10 @@ import CardSummary from 'components/ui/CardSummary.vue';
import FetchData from 'components/FetchData.vue';
import { getUrl } from 'src/composables/getUrl';
import { useSession } from 'src/composables/useSession';
import WorkerDescriptorProxy from 'pages/Worker/Card/WorkerDescriptorProxy.vue';
import VnLv from 'src/components/ui/VnLv.vue';
import ClaimNotes from 'src/pages/Claim/Card/ClaimNotes.vue';
import VnUserLink from 'src/components/ui/VnUserLink.vue';
import ItemDescriptorProxy from 'src/pages/Item/Card/ItemDescriptorProxy.vue';
const route = useRoute();
const { t } = useI18n();
@ -26,6 +28,15 @@ const entityId = computed(() => $props.id || route.params.id);
const claimUrl = ref();
const salixUrl = ref();
const claimDmsRef = ref();
const claimDmsFilter = ref({
include: [
{
relation: 'dms',
},
],
where: { claimFk: entityId.value },
});
onMounted(async () => {
salixUrl.value = await getUrl('');
@ -85,10 +96,13 @@ const detailsColumns = ref([
},
]);
const STATE_COLOR = {
pending: 'warning',
managed: 'info',
resolved: 'positive',
};
function stateColor(code) {
if (code === 'pending') return 'green';
if (code === 'managed') return 'orange';
if (code === 'resolved') return 'red';
return STATE_COLOR[code];
}
const developmentColumns = ref([
@ -113,7 +127,7 @@ const developmentColumns = ref([
{
name: 'worker',
label: 'claim.summary.worker',
field: (row) => row.worker.user.nickname,
field: (row) => row.worker?.user.nickname,
sortable: true,
},
{
@ -126,18 +140,9 @@ const developmentColumns = ref([
const claimDms = ref([]);
const multimediaDialog = ref();
const multimediaSlide = ref();
const claimDmsFilter = ref({
include: [
{
relation: 'dms',
},
],
where: { claimFk: entityId.value },
});
function setClaimDms(data) {
if (!data) return;
claimDms.value = [];
data.forEach((media) => {
claimDms.value.push({
isVideo: media.dms.contentType == 'video/mp4',
@ -159,7 +164,7 @@ function openDialog(dmsId) {
:filter="claimDmsFilter"
@on-fetch="(data) => setClaimDms(data)"
limit="20"
auto-load
ref="claimDmsRef"
/>
<CardSummary ref="summary" :url="`Claims/${entityId}/getSummary`">
<template #header="{ entity: { claim } }">
@ -184,30 +189,44 @@ function openDialog(dmsId) {
</VnLv>
<VnLv :label="t('claim.summary.assignedTo')">
<template #value>
<span class="link">
{{ claim.worker.user.nickname }}
<WorkerDescriptorProxy :id="claim.workerFk" />
</span>
<VnUserLink
:name="claim.worker?.user?.nickname"
:worker-id="claim.workerFk"
/>
</template>
</VnLv>
<VnLv :label="t('claim.summary.attendedBy')">
<template #value>
<span class="link">
{{ claim.client.salesPersonUser.name }}
<WorkerDescriptorProxy :id="claim.client.salesPersonFk" />
</span>
<VnUserLink
:name="claim.client?.salesPersonUser?.name"
:worker-id="claim.client?.salesPersonFk"
/>
</template>
</VnLv>
<VnLv :label="t('claim.summary.customer')">
<template #value>
<VnUserLink
:name="claim.client?.name"
:worker-id="claim.client?.id"
/>
</template>
</VnLv>
<VnLv :label="t('claim.summary.returnOfMaterial')" :value="claim.rma" />
<QCheckbox
:align-items="right"
:label="t('claim.basicData.picked')"
v-model="claim.hasToPickUp"
/>
</QCard>
<QCard class="vn-one">
<a class="header" :href="claimUrl + 'note/index'">
<QCard class="vn-three claimVnNotes full-height">
<a class="header" :href="`#/claim/${entityId}/notes`">
{{ t('claim.summary.notes') }}
<QIcon name="open_in_new" color="primary" />
</a>
<!-- Use VnNotes and maybe VirtualScroll-->
<ClaimNotes :add-note="false" style="height: 350px" order="created ASC" />
</QCard>
<QCard class="vn-max" v-if="salesClaimed.length > 0">
<a class="header" :href="claimUrl + 'note/index'">
<QCard class="vn-two" v-if="salesClaimed.length > 0">
<a class="header" :href="`#/claim/${entityId}/lines`">
{{ t('claim.summary.details') }}
<QIcon name="open_in_new" color="primary" />
</a>
@ -219,8 +238,43 @@ function openDialog(dmsId) {
</QTh>
</QTr>
</template>
<template #body="props">
<QTr :props="props">
<QTh v-for="col in props.cols" :key="col.name" :props="props">
<span v-if="col.name != 'description'">{{
t(col.value)
}}</span>
<QBtn
v-if="col.name == 'description'"
flat
color="blue"
>{{ col.value }}</QBtn
>
<ItemDescriptorProxy
v-if="col.name == 'description'"
:id="2"
></ItemDescriptorProxy>
</QTh>
</QTr>
</template>
</QTable>
</QCard>
<QCard class="vn-two" v-if="developments.length > 0">
<a class="header" :href="claimUrl + 'development'">
{{ t('claim.summary.development') }}
<QIcon name="open_in_new" color="primary" />
</a>
<QTable :columns="developmentColumns" :rows="developments" flat>
<template #header="props">
<QTr :props="props">
<QTh v-for="col in props.cols" :key="col.name" :props="props">
{{ t(col.label) }}
</QTh>
</QTr>
</template>
</QTable>
</QCard>
<QCard class="vn-max" v-if="claimDms.length > 0">
<a class="header" :href="`#/claim/${entityId}/photos`">
{{ t('claim.summary.photos') }}
@ -263,22 +317,8 @@ function openDialog(dmsId) {
</div>
</div>
</QCard>
<QCard class="vn-two" v-if="developments.length > 0">
<a class="header" :href="claimUrl + 'development'">
{{ t('claim.summary.development') }}
<QIcon name="open_in_new" color="primary" />
</a>
<QTable :columns="developmentColumns" :rows="developments" flat>
<template #header="props">
<QTr :props="props">
<QTh v-for="col in props.cols" :key="col.name" :props="props">
{{ t(col.label) }}
</QTh>
</QTr>
</template>
</QTable>
</QCard>
<QCard class="vn-max" v-if="developments.length > 0">
<QCard class="vn-max">
<a class="header" :href="claimUrl + 'action'">
{{ t('claim.summary.actions') }}
<QIcon name="open_in_new" color="primary" />
@ -301,20 +341,6 @@ function openDialog(dmsId) {
/>
</div>
</QCard>
<!-- <QCardSection class="q-pa-md" v-if="observations.length > 0">
<h6>{{ t('claim.summary.notes') }}</h6>
<div class="note-list" v-for="note in observations" :key="note.id">
<div class="note-caption">
<span
>{{ note.worker.firstName }} {{ note.worker.lastName }}
</span>
<span>{{ toDate(note.created) }}</span>
</div>
<div class="note-text">
<span>{{ note.text }}</span>
</div>
</div>
</QCardSection> -->
<QDialog
v-model="multimediaDialog"
transition-show="slide-up"
@ -357,6 +383,13 @@ function openDialog(dmsId) {
</template>
</CardSummary>
</template>
<style lang="scss">
.claimVnNotes {
.q-card {
max-width: 100%;
}
}
</style>
<style lang="scss" scoped>
.q-dialog__inner--minimized > div {
max-width: 80%;

Some files were not shown because too many files have changed in this diff Show More