0
0
Fork 0

Merge branch 'dev' of https://gitea.verdnatura.es/verdnatura/salix-front into 4774-traducciones

This commit is contained in:
Carlos Satorres 2024-04-24 14:40:34 +02:00
commit f041e0837f
372 changed files with 33728 additions and 14276 deletions

View File

@ -5,6 +5,33 @@ 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).
## [2420.01]
## [2418.01]
## [2416.01] - 2024-04-18
### Added
### Fixed
- (General) => Se vuelven a mostrar los parámetros en la url al aplicar un filtro
## [2414.01] - 2024-04-04
### Added
- (Tickets) => Se añade la opción de clonar ticket. #6951
- (Parking) => Se añade la sección Parking. #5186
- (Rutas) => Se añade el campo "servida" a la tabla y se añade también a los filtros. #7130
### Changed
### Fixed
- (General) => Se corrige la redirección cuando hay 1 solo registro y cuando se aplica un filtro diferente al id al hacer una búsqueda general. #6893
## [2400.01] - 2024-01-04
### Added

View File

@ -1,5 +1,6 @@
FROM node:stretch-slim
RUN npm install -g @quasar/cli
RUN corepack enable pnpm
RUN pnpm install -g @quasar/cli
WORKDIR /app
COPY dist/spa ./
CMD ["quasar", "serve", "./", "--history", "--hostname", "0.0.0.0"]
CMD ["quasar", "serve", "./", "--history", "--hostname", "0.0.0.0"]

130
Jenkinsfile vendored
View File

@ -1,99 +1,119 @@
#!/usr/bin/env groovy
def PROTECTED_BRANCH
def BRANCH_ENV = [
test: 'test',
master: 'production'
]
node {
stage('Setup') {
env.FRONT_REPLICAS = 1
env.NODE_ENV = BRANCH_ENV[env.BRANCH_NAME] ?: 'dev'
PROTECTED_BRANCH = [
'dev',
'test',
'master'
].contains(env.BRANCH_NAME)
// https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
echo "NODE_NAME: ${env.NODE_NAME}"
echo "WORKSPACE: ${env.WORKSPACE}"
configFileProvider([
configFile(fileId: 'salix-front.properties',
variable: 'PROPS_FILE')
]) {
def props = readProperties file: PROPS_FILE
props.each {key, value -> env."${key}" = value }
props.each {key, value -> echo "${key}: ${value}" }
}
if (PROTECTED_BRANCH) {
configFileProvider([
configFile(fileId: "salix-front.branch.${env.BRANCH_NAME}",
variable: 'BRANCH_PROPS_FILE')
]) {
def props = readProperties file: BRANCH_PROPS_FILE
props.each {key, value -> env."${key}" = value }
props.each {key, value -> echo "${key}: ${value}" }
}
}
}
}
pipeline {
agent any
options {
disableConcurrentBuilds()
}
tools {
nodejs 'node-v18'
}
environment {
PROJECT_NAME = 'lilium'
STACK_NAME = "${env.PROJECT_NAME}-${env.BRANCH_NAME}"
}
stages {
stage('Checkout') {
steps {
script {
switch (env.BRANCH_NAME) {
case 'master':
env.NODE_ENV = 'production'
env.FRONT_REPLICAS = 2
break
case 'test':
env.NODE_ENV = 'test'
env.FRONT_REPLICAS = 1
break
}
}
setEnv()
}
}
stage('Install') {
environment {
NODE_ENV = ""
}
steps {
nodejs('node-v18') {
sh 'npm install --no-audit --prefer-offline'
}
sh 'pnpm install --prefer-offline'
}
}
stage('Test') {
when { not { anyOf {
branch 'test'
branch 'master'
}}}
when {
expression { !PROTECTED_BRANCH }
}
environment {
NODE_ENV = ""
}
parallel {
stage('Frontend') {
steps {
nodejs('node-v18') {
sh 'npm run test:unit:ci'
}
}
steps {
sh 'pnpm run test:unit:ci'
}
post {
always {
junit(
testResults: 'junitresults.xml',
allowEmptyResults: true
)
}
}
}
stage('Build') {
when { anyOf {
branch 'test'
branch 'master'
}}
when {
expression { PROTECTED_BRANCH }
}
environment {
CREDENTIALS = credentials('docker-registry')
}
steps {
nodejs('node-v18') {
sh 'quasar build'
sh 'quasar build'
script {
def packageJson = readJSON file: 'package.json'
env.VERSION = packageJson.version
}
dockerBuild()
}
}
stage('Deploy') {
when { anyOf {
branch 'test'
branch 'master'
}}
when {
expression { PROTECTED_BRANCH }
}
environment {
DOCKER_HOST = "${env.SWARM_HOST}"
}
steps {
script {
def packageJson = readJSON file: 'package.json'
env.VERSION = packageJson.version
}
sh "docker stack deploy --with-registry-auth --compose-file docker-compose.yml ${env.STACK_NAME}"
}
}
}
post {
always {
script {
if (!['master', 'test'].contains(env.BRANCH_NAME)) {
try {
junit 'junitresults.xml'
junit 'junit.xml'
} catch (e) {
echo e.toString()
}
}
}
}
}
}

View File

@ -5,7 +5,7 @@ Lilium frontend
## Install the dependencies
```bash
npm install
pnpm install
```
### Install quasar cli
@ -23,13 +23,13 @@ quasar dev
### Run unit tests
```bash
npm run test:unit
pnpm run test:unit
```
### Run e2e tests
```bash
npm run test:e2e
pnpm run test:e2e
```
### Build the app for production

BIN
bun.lockb Executable file

Binary file not shown.

View File

@ -3,6 +3,7 @@ const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:9000/',
experimentalStudio: true,
fixturesFolder: 'test/cypress/fixtures',
screenshotsFolder: 'test/cypress/screenshots',
supportFile: 'test/cypress/support/index.js',

9264
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,11 @@
{
"name": "salix-front",
"version": "24.8.0",
"version": "24.20.0",
"description": "Salix frontend",
"productName": "Salix",
"author": "Verdnatura",
"private": true,
"packageManager": "pnpm@8.15.1",
"scripts": {
"lint": "eslint --ext .js,.vue ./",
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
@ -16,12 +17,12 @@
},
"dependencies": {
"@quasar/cli": "^2.3.0",
"@quasar/extras": "^1.16.4",
"@quasar/extras": "^1.16.9",
"axios": "^1.4.0",
"chromium": "^3.0.3",
"croppie": "^2.6.5",
"pinia": "^2.1.3",
"quasar": "^2.12.0",
"quasar": "^2.14.5",
"validator": "^13.9.0",
"vue": "^3.3.4",
"vue-i18n": "^9.2.2",
@ -30,11 +31,12 @@
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^0.8.1",
"@pinia/testing": "^0.1.2",
"@quasar/app-vite": "^1.4.3",
"@quasar/quasar-app-extension-testing-unit-vitest": "^0.3.0",
"@vue/test-utils": "^2.3.2",
"@quasar/app-vite": "^1.7.3",
"@quasar/quasar-app-extension-qcalendar": "4.0.0-beta.15",
"@quasar/quasar-app-extension-testing-unit-vitest": "^0.4.0",
"@vue/test-utils": "^2.4.4",
"autoprefixer": "^10.4.14",
"cypress": "^12.13.0",
"cypress": "^13.6.6",
"eslint": "^8.41.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-cypress": "^2.13.3",
@ -46,11 +48,12 @@
"engines": {
"node": "^20 || ^18 || ^16",
"npm": ">= 8.1.2",
"yarn": ">= 1.21.1"
"yarn": ">= 1.21.1",
"bun": ">= 1.0.25"
},
"overrides": {
"@vitejs/plugin-vue": "^4.0.0",
"vite": "^4.3.5",
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.1.4",
"vitest": "^0.31.1"
}
}

8612
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

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', 'validations'],
boot: ['i18n', 'axios', 'vnDate', 'validations', 'quasar.defaults'],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
css: ['app.scss'],
@ -67,7 +67,7 @@ module.exports = configure(function (/* ctx */) {
// analyze: true,
// env: {},
rawDefine: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
},
// ignorePublicFolder: true,
// minify: false,
@ -92,14 +92,12 @@ module.exports = configure(function (/* ctx */) {
vitePlugins: [
[
VueI18nPlugin({
runtimeOnly: false
runtimeOnly: false,
include: [
path.resolve(__dirname, './src/i18n/locale/**'),
path.resolve(__dirname, './src/pages/**/locale/**'),
],
}),
{
// 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/**'),
},
],
],
},
@ -117,15 +115,13 @@ module.exports = configure(function (/* ctx */) {
secure: false,
},
},
open: false,
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
framework: {
config: {
config: {
brand: {
primary: 'orange',
},
dark: 'auto',
},
},

View File

@ -1,7 +1,6 @@
{
"@quasar/testing-unit-vitest": {
"options": [
"scripts"
]
}
}
"@quasar/testing-unit-vitest": {
"options": ["scripts"]
},
"@quasar/qcalendar": {}
}

View File

@ -16,7 +16,7 @@ onMounted(() => {
if (availableLocales.includes(userLang)) {
locale.value = userLang;
} else {
locale.value = fallbackLocale;
locale.value = fallbackLocale.value;
}
});

View File

@ -11,7 +11,7 @@ axios.defaults.baseURL = '/api/';
const onRequest = (config) => {
const token = session.getToken();
if (token.length && config.headers) {
if (token.length && !config.headers.Authorization) {
config.headers.Authorization = token;
}

View File

@ -0,0 +1,5 @@
import { QTable } from 'quasar';
import setDefault from './setDefault';
setDefault(QTable, 'pagination', { rowsPerPage: 0 });
setDefault(QTable, 'hidePagination', true);

View File

@ -0,0 +1,18 @@
export default function (component, key, value) {
const prop = component.props[key];
switch (typeof prop) {
case 'object':
prop.default = value;
break;
case 'function':
component.props[key] = {
type: prop,
default: value,
};
break;
case 'undefined':
throw new Error('unknown prop: ' + key);
default:
throw new Error('unhandled type: ' + typeof prop);
}
}

21
src/boot/qformMixin.js Normal file
View File

@ -0,0 +1,21 @@
import { getCurrentInstance } from 'vue';
const filterAvailableInput = element => element.classList.contains('q-field__native') && !element.disabled
const filterAvailableText = element => element.__vueParentComponent.type.name === 'QInput' && element.__vueParentComponent?.attrs?.class !== 'vn-input-date';
export default {
mounted: function () {
const vm = getCurrentInstance();
if (vm.type.name === 'QForm')
if (!['searchbarForm','filterPanelForm'].includes(this.$el?.id)) {
// AUTOFOCUS
const elementsArray = Array.from(this.$el.elements);
const firstInputElement = elementsArray.filter(filterAvailableInput).find(filterAvailableText);
if (firstInputElement) {
firstInputElement.focus();
}
}
},
};

View File

@ -0,0 +1 @@
export * from './defaults/qTable';

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

@ -0,0 +1,6 @@
import { boot } from 'quasar/wrappers';
import qFormMixin from './qformMixin';
export default boot(({ app }) => {
app.mixin(qFormMixin);
});

View File

@ -1,5 +1,5 @@
<script setup>
import { reactive, ref } from 'vue';
import { reactive, ref, onMounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import VnInput from 'src/components/common/VnInput.vue';
@ -16,9 +16,8 @@ const props = defineProps({
});
const emit = defineEmits(['onDataSaved']);
const { t } = useI18n();
const bicInputRef = ref(null);
const bankEntityFormData = reactive({
name: null,
bic: null,
@ -32,9 +31,14 @@ const countriesFilter = {
const countriesOptions = ref([]);
const onDataSaved = (data) => {
emit('onDataSaved', data);
const onDataSaved = (formData, requestResponse) => {
emit('onDataSaved', formData, requestResponse);
};
onMounted(async () => {
await nextTick();
bicInputRef.value.focus();
});
</script>
<template>
@ -50,7 +54,7 @@ const onDataSaved = (data) => {
:title="t('title')"
:subtitle="t('subtitle')"
:form-initial-data="bankEntityFormData"
@on-data-saved="onDataSaved($event)"
@on-data-saved="onDataSaved"
>
<template #form-inputs="{ data, validate }">
<VnRow class="row q-gutter-md q-mb-md">
@ -64,6 +68,7 @@ const onDataSaved = (data) => {
</div>
<div class="col">
<VnInput
ref="bicInputRef"
:label="t('swift')"
v-model="data.bic"
:required="true"

View File

@ -0,0 +1,174 @@
<script setup>
import { reactive, ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
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';
import VnInputDate from './common/VnInputDate.vue';
const emit = defineEmits(['onDataSaved']);
const { t } = useI18n();
const router = useRouter();
const manualInvoiceFormData = reactive({
maxShipped: Date.vnNew(),
});
const formModelPopupRef = ref();
const invoiceOutSerialsOptions = ref([]);
const taxAreasOptions = ref([]);
const ticketsOptions = ref([]);
const clientsOptions = ref([]);
const isLoading = computed(() => formModelPopupRef.value?.isLoading);
const onDataSaved = async (formData, requestResponse) => {
emit('onDataSaved', formData, requestResponse);
if (requestResponse && requestResponse.id)
router.push({ name: 'InvoiceOutSummary', params: { id: requestResponse.id } });
};
</script>
<template>
<FetchData
url="InvoiceOutSerials"
:filter="{ where: { code: { neq: 'R' } }, order: ['code'] }"
@on-fetch="(data) => (invoiceOutSerialsOptions = data)"
auto-load
/>
<FetchData
url="TaxAreas"
:filter="{ order: ['code'] }"
@on-fetch="(data) => (taxAreasOptions = data)"
auto-load
/>
<FetchData
url="Tickets"
:filter="{ fields: ['id', 'nickname'], order: 'shipped DESC', limit: 30 }"
@on-fetch="(data) => (ticketsOptions = data)"
auto-load
/>
<FetchData
url="Clients"
:filter="{ fields: ['id', 'name'], order: 'name ASC', limit: 30 }"
@on-fetch="(data) => (clientsOptions = data)"
auto-load
/>
<FormModelPopup
ref="formModelPopupRef"
:title="t('Create manual invoice')"
url-create="InvoiceOuts/createManualInvoice"
model="invoiceOut"
:form-initial-data="manualInvoiceFormData"
@on-data-saved="onDataSaved"
>
<template #form-inputs="{ data }">
<span v-if="isLoading" class="text-primary invoicing-text">
<QIcon name="warning" class="fill-icon q-mr-sm" size="md" />
{{ t('Invoicing in progress...') }}
</span>
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnSelectFilter
:label="t('Ticket')"
:options="ticketsOptions"
hide-selected
option-label="id"
option-value="id"
v-model="data.ticketFk"
@update:model-value="data.clientFk = null"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel> #{{ scope.opt?.id }} </QItemLabel>
<QItemLabel caption>{{
scope.opt?.nickname
}}</QItemLabel>
</QItemSection>
</QItem>
</template>
</VnSelectFilter>
</div>
<span class="row items-center" style="max-width: max-content">{{
t('Or')
}}</span>
<div class="col">
<VnSelectFilter
:label="t('Client')"
:options="clientsOptions"
hide-selected
option-label="name"
option-value="id"
v-model="data.clientFk"
@update:model-value="data.ticketFk = null"
/>
</div>
<div class="col">
<VnInputDate :label="t('Max date')" v-model="data.maxShipped" />
</div>
</VnRow>
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnSelectFilter
:label="t('Serial')"
:options="invoiceOutSerialsOptions"
hide-selected
option-label="description"
option-value="code"
v-model="data.serial"
:required="true"
/>
</div>
<div class="col">
<VnSelectFilter
:label="t('Area')"
:options="taxAreasOptions"
hide-selected
option-label="code"
option-value="code"
v-model="data.taxArea"
:required="true"
/>
</div>
</VnRow>
<VnRow class="row q-gutter-md q-mb-md">
<VnInput
:label="t('Reference')"
type="textarea"
v-model="data.reference"
fill-input
autogrow
/>
</VnRow>
</template>
</FormModelPopup>
</template>
<style lang="scss" scoped>
.invoicing-text {
display: flex;
justify-content: center;
align-items: center;
color: $primary;
font-size: 24px;
margin-bottom: 8px;
}
</style>
<i18n>
es:
Create manual invoice: Crear factura manual
Ticket: Ticket
Client: Cliente
Max date: Fecha límite
Serial: Serie
Area: Area
Reference: Referencia
Or: O
Invoicing in progress...: Facturación en progreso...
</i18n>

View File

@ -28,8 +28,23 @@ const countriesOptions = ref([]);
const provincesOptions = ref([]);
const townsLocationOptions = ref([]);
const onDataSaved = (dataSaved) => {
emit('onDataSaved', dataSaved);
const onDataSaved = (formData) => {
const newPostcode = {
...formData
};
const townObject = townsLocationOptions.value.find(
({id}) => id === formData.townFk
);
newPostcode.town = townObject?.name;
const provinceObject = provincesOptions.value.find(
({id}) => id === formData.provinceFk
);
newPostcode.province = provinceObject?.name;
const countryObject = countriesOptions.value.find(
({id}) => id === formData.countryFk
);
newPostcode.country = countryObject?.country;
emit('onDataSaved', newPostcode);
};
const onCityCreated = async ({ name, provinceFk }, formData) => {
@ -73,7 +88,7 @@ const onProvinceCreated = async ({ name }, formData) => {
:title="t('New postcode')"
:subtitle="t('Please, ensure you put the correct data!')"
:form-initial-data="postcodeFormData"
@on-data-saved="onDataSaved($event)"
@on-data-saved="onDataSaved"
>
<template #form-inputs="{ data, validate }">
<VnRow class="row q-gutter-md q-mb-md">

View File

@ -24,6 +24,10 @@ const $props = defineProps({
type: String,
default: '',
},
limit: {
type: Number,
default: 20,
},
saveUrl: {
type: String,
default: null,
@ -76,6 +80,7 @@ defineExpose({
reset,
hasChanges,
saveChanges,
getChanges,
});
async function fetch(data) {
@ -176,8 +181,8 @@ async function remove(data) {
.dialog({
component: VnConfirm,
componentProps: {
title: t('confirmDeletion'),
message: t('confirmDeletionMessage'),
title: t('globals.confirmDeletion'),
message: t('globals.confirmDeletionMessage'),
newData,
ids,
},
@ -260,6 +265,7 @@ watch(formUrl, async () => {
<template>
<VnPaginate
:url="url"
:limit="limit"
v-bind="$attrs"
@on-fetch="fetch"
:skeleton="false"
@ -317,16 +323,3 @@ watch(formUrl, async () => {
color="primary"
/>
</template>
<i18n>
{
"en": {
"confirmDeletion": "Confirm deletion",
"confirmDeletionMessage": "Are you sure you want to delete this?"
},
"es": {
"confirmDeletion": "Confirmar eliminación",
"confirmDeletionMessage": "Seguro que quieres eliminar?"
}
}
</i18n>

View File

@ -272,7 +272,7 @@ const makeRequest = async () => {
class="cursor-pointer q-mr-sm"
@click="openInputFile()"
>
<!-- <QTooltip>{{ t('Select a file') }}</QTooltip> -->
<!-- <QTooltip>{{ t('globals.selectFile') }}</QTooltip> -->
</QIcon>
<QIcon name="info" class="cursor-pointer">
<QTooltip>{{

View File

@ -1,9 +1,12 @@
<script setup>
import { ref, reactive } from 'vue';
import { ref, markRaw } from 'vue';
import { useI18n } from 'vue-i18n';
import VnSelectFilter from 'src/components/common/VnSelectFilter.vue';
import VnInput from 'src/components/common/VnInput.vue';
import VnInputDate from 'src/components/common/VnInputDate.vue';
import VnRow from 'components/ui/VnRow.vue';
import { QCheckbox } from 'quasar';
import axios from 'axios';
import useNotify from 'src/composables/useNotify.js';
@ -28,11 +31,16 @@ const $props = defineProps({
const { t } = useI18n();
const { notify } = useNotify();
const formData = reactive({
field: null,
newValue: null,
});
const inputs = {
input: markRaw(VnInput),
number: markRaw(VnInput),
date: markRaw(VnInputDate),
checkbox: markRaw(QCheckbox),
select: markRaw(VnSelectFilter),
};
const newValue = ref(null);
const selectedField = ref(null);
const closeButton = ref(null);
const isLoading = ref(false);
@ -47,8 +55,8 @@ const submitData = async () => {
isLoading.value = true;
const rowsToEdit = $props.rows.map((row) => ({ id: row.id, itemFk: row.itemFk }));
const payload = {
field: formData.field,
newValue: formData.newValue,
field: selectedField.value.field,
newValue: newValue.value,
lines: rowsToEdit,
};
@ -71,46 +79,43 @@ const closeForm = () => {
<span ref="closeButton" class="close-icon" v-close-popup>
<QIcon name="close" size="sm" />
</span>
<h1 class="title">
{{
t('editBuyTitle', {
buysAmount: rows.length,
})
}}
</h1>
<span class="title">{{ t('Edit') }}</span>
<span class="countLines">{{ ` ${rows.length} ` }}</span>
<span class="title">{{ t('buy(s)') }}</span>
<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>
<VnSelectFilter
:label="t('Field to edit')"
:options="fieldsOptions"
hide-selected
option-label="label"
v-model="selectedField"
/>
<component
:is="inputs[selectedField?.component || 'input']"
v-bind="selectedField?.attrs || {}"
v-model="newValue"
:label="t('Value')"
style="width: 200px"
/>
</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
/>
<QBtn
:label="t('globals.save')"
type="submit"
color="primary"
class="q-ml-sm"
:disabled="isLoading"
:loading="isLoading"
/>
</div>
</QCard>
</QForm>
@ -129,13 +134,18 @@ const closeForm = () => {
right: 20px;
cursor: pointer;
}
.countLines {
font-size: 24px;
color: $primary;
font-weight: bold;
}
</style>
<i18n>
en:
editBuyTitle: Edit {buysAmount} buy(s)
es:
editBuyTitle: Editar {buysAmount} compra(s)
Field to edit: Campo a editar
Value: Valor
</i18n>
es:
Edit: Editar
buy(s): compra(s)
Field to edit: Campo a editar
Value: Valor
</i18n>

View File

@ -1,5 +1,5 @@
<script setup>
import { h, onMounted } from 'vue';
import { onMounted } from 'vue';
import axios from 'axios';
const $props = defineProps({
@ -59,11 +59,7 @@ async function fetch(fetchFilter = {}) {
//
}
}
const render = () => {
return h('div', []);
};
</script>
<template>
<render />
<template></template>
</template>

View File

@ -202,7 +202,6 @@ const selectItem = ({ id }) => {
<QTable
:columns="tableColumns"
:rows="tableRows"
:pagination="{ rowsPerPage: 0 }"
:loading="loading"
:hide-header="!tableRows || !tableRows.length > 0"
:no-data-label="t('Enter a new search')"

View File

@ -200,7 +200,6 @@ const selectTravel = ({ id }) => {
<QTable
:columns="tableColumns"
:rows="tableRows"
:pagination="{ rowsPerPage: 0 }"
:loading="loading"
:hide-header="!tableRows || !tableRows.length > 0"
:no-data-label="t('Enter a new search')"

View File

@ -1,6 +1,7 @@
<script setup>
import axios from 'axios';
import { onMounted, onUnmounted, computed, ref, watch, nextTick } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { useState } from 'src/composables/useState';
@ -8,6 +9,8 @@ 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';
import VnConfirm from './ui/VnConfirm.vue';
import { tMobile } from 'src/composables/tMobile';
const quasar = useQuasar();
const state = useState();
@ -41,6 +44,10 @@ const $props = defineProps({
type: Boolean,
default: true,
},
defaultButtons: {
type: Object,
default: () => {},
},
autoLoad: {
type: Boolean,
default: false,
@ -59,25 +66,29 @@ const $props = defineProps({
type: Function,
default: null,
},
clearStoreOnUnmount: {
type: Boolean,
default: true,
},
saveFn: {
type: Function,
default: null,
},
});
const emit = defineEmits(['onFetch', 'onDataSaved']);
defineExpose({
save,
});
const componentIsRendered = ref(false);
onMounted(async () => {
originalData.value = $props.formInitialData;
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 {
state.set($props.model, $props.formInitialData);
if ($props.autoLoad && !$props.formInitialData) {
await fetch();
}
@ -90,18 +101,48 @@ onMounted(async () => {
}
});
onBeforeRouteLeave((to, from, next) => {
if (hasChanges.value && $props.observeFormChanges)
quasar.dialog({
component: VnConfirm,
componentProps: {
title: t('Unsaved changes will be lost'),
message: t('Are you sure exit without saving?'),
promise: () => next(),
},
});
else next();
});
onUnmounted(() => {
state.unset($props.model);
// Restauramos los datos originales en el store si se realizaron cambios en el formulario pero no se guardaron, evitando modificaciones erróneas.
if (hasChanges.value) {
state.set($props.model, originalData.value);
return;
}
if ($props.clearStoreOnUnmount) state.unset($props.model);
});
const isLoading = ref(false);
// 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 originalData = ref({});
const formData = computed(() => state.get($props.model));
const formUrl = computed(() => $props.url);
const defaultButtons = computed(() => ({
save: {
color: 'primary',
icon: 'save',
label: 'globals.save',
},
reset: {
color: 'primary',
icon: 'restart_alt',
label: 'globals.reset',
},
...$props.defaultButtons,
}));
const startFormWatcher = () => {
watch(
() => formData.value,
@ -113,19 +154,19 @@ const startFormWatcher = () => {
);
};
function tMobile(...args) {
if (!quasar.platform.is.mobile) return t(...args);
}
async function fetch() {
const { data } = await axios.get($props.url, {
params: { filter: JSON.stringify($props.filter) },
});
try {
const { data } = await axios.get($props.url, {
params: { filter: JSON.stringify($props.filter) },
});
state.set($props.model, data);
originalData.value = data && JSON.parse(JSON.stringify(data));
state.set($props.model, data);
originalData.value = data && JSON.parse(JSON.stringify(data));
emit('onFetch', state.get($props.model));
emit('onFetch', state.get($props.model));
} catch (error) {
state.set($props.model, {});
originalData.value = {};
}
}
async function save() {
@ -138,17 +179,20 @@ async function save() {
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);
}
if ($props.saveFn) response = await $props.saveFn(body);
else
response = await axios[$props.urlCreate ? 'post' : 'patch'](
$props.urlCreate || $props.urlUpdate || $props.url,
body
);
if ($props.urlCreate) notify('globals.dataCreated', 'positive');
emit('onDataSaved', formData.value, response?.data);
originalData.value = JSON.parse(JSON.stringify(formData.value));
hasChanges.value = false;
} catch (err) {
notify('errors.create', 'negative');
console.error(err);
notify('errors.writeRequest', 'negative');
}
isLoading.value = false;
}
@ -184,6 +228,12 @@ watch(formUrl, async () => {
reset();
fetch();
});
defineExpose({
save,
isLoading,
hasChanges,
});
</script>
<template>
<div class="column items-center full-width">
@ -212,21 +262,21 @@ watch(formUrl, async () => {
<QBtnGroup push class="q-gutter-x-sm">
<slot name="moreActions" />
<QBtn
:label="tMobile('globals.reset')"
color="primary"
icon="restart_alt"
:label="tMobile(defaultButtons.reset.label)"
:color="defaultButtons.reset.color"
:icon="defaultButtons.reset.icon"
flat
@click="reset"
:disable="!hasChanges"
:title="t('globals.reset')"
:title="t(defaultButtons.reset.label)"
/>
<QBtn
:label="tMobile('globals.save')"
color="primary"
icon="save"
:label="tMobile(defaultButtons.save.label)"
:color="defaultButtons.save.color"
:icon="defaultButtons.save.icon"
@click="save"
:disable="!hasChanges"
:title="t('globals.save')"
:title="t(defaultButtons.save.label)"
/>
</QBtnGroup>
</div>
@ -240,6 +290,9 @@ watch(formUrl, async () => {
/>
</template>
<style lang="scss" scoped>
.q-notifications {
color: black;
}
#formModel {
max-width: 800px;
width: 100%;
@ -249,3 +302,8 @@ watch(formUrl, async () => {
padding: 32px;
}
</style>
<i18n>
es:
Unsaved changes will be lost: Los cambios que no haya guardado se perderán
Are you sure exit without saving?: ¿Seguro que quiere salir sin guardar?
</i18n>

View File

@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import FormModel from 'components/FormModel.vue';
@ -39,27 +39,34 @@ const $props = defineProps({
const { t } = useI18n();
const formModelRef = ref(null);
const closeButton = ref(null);
const isLoading = ref(false);
const onDataSaved = (dataSaved) => {
emit('onDataSaved', dataSaved);
const onDataSaved = (formData, requestResponse) => {
emit('onDataSaved', formData, requestResponse);
closeForm();
};
const closeForm = () => {
const isLoading = computed(() => formModelRef.value?.isLoading);
const closeForm = async () => {
if (closeButton.value) closeButton.value.click();
};
defineExpose({
isLoading,
});
</script>
<template>
<FormModel
ref="formModelRef"
:form-initial-data="formInitialData"
:observe-form-changes="false"
:default-actions="false"
:url-create="urlCreate"
:model="model"
@on-data-saved="onDataSaved($event)"
@on-data-saved="onDataSaved"
>
<template #form="{ data, validate }">
<span ref="closeButton" class="close-icon" v-close-popup>
@ -69,23 +76,24 @@ const closeForm = () => {
<p>{{ subtitle }}</p>
<slot name="form-inputs" :data="data" :validate="validate" />
<div class="q-mt-lg row justify-end">
<QBtn
:label="t('globals.cancel')"
:title="t('globals.cancel')"
type="reset"
color="primary"
flat
:disabled="isLoading"
:loading="isLoading"
v-close-popup
/>
<QBtn
:label="t('globals.save')"
:title="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>

View File

@ -0,0 +1,96 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const emit = defineEmits(['onSubmit']);
const $props = defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
defaultSubmitButton: {
type: Boolean,
default: true,
},
defaultCancelButton: {
type: Boolean,
default: true,
},
customSubmitButtonLabel: {
type: String,
default: '',
},
});
const { t } = useI18n();
const closeButton = ref(null);
const isLoading = ref(false);
const onSubmit = () => {
emit('onSubmit');
closeForm();
};
const closeForm = () => {
if (closeButton.value) closeButton.value.click();
};
</script>
<template>
<QForm
@submit="onSubmit($event)"
class="all-pointer-events full-width"
style="max-width: 800px"
>
<QCard class="q-pa-lg">
<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" />
<div class="q-mt-lg row justify-end">
<QBtn
v-if="defaultCancelButton"
:label="t('globals.cancel')"
color="primary"
flat
class="q-ml-sm"
:disabled="isLoading"
:loading="isLoading"
v-close-popup
/>
<QBtn
v-if="defaultSubmitButton"
:label="customSubmitButtonLabel || t('globals.save')"
type="submit"
color="primary"
:disabled="isLoading"
:loading="isLoading"
/>
<slot name="customButtons" />
</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>

View File

@ -0,0 +1,359 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import VnInput from 'components/common/VnInput.vue';
import FetchData from 'components/FetchData.vue';
import VnFilterPanel from 'src/components/ui/VnFilterPanel.vue';
import VnSelectFilter from 'components/common/VnSelectFilter.vue';
import VnFilterPanelChip from 'components/ui/VnFilterPanelChip.vue';
import axios from 'axios';
const { t } = useI18n();
const props = defineProps({
dataKey: {
type: String,
required: true,
},
customTags: {
type: Array,
default: () => [],
},
exprBuilder: {
type: Function,
default: null,
},
});
const itemCategories = ref([]);
const selectedCategoryFk = ref(null);
const selectedTypeFk = ref(null);
const itemTypesOptions = ref([]);
const suppliersOptions = ref([]);
const tagOptions = ref([]);
const tagValues = ref([]);
const categoryList = computed(() => {
return (itemCategories.value || [])
.filter((category) => category.display)
.map((category) => ({
...category,
icon: `vn:${(category.icon || '').split('-')[1]}`,
}));
});
const selectedCategory = computed(() =>
(itemCategories.value || []).find(
(category) => category?.id === selectedCategoryFk.value
)
);
const selectedType = computed(() => {
return (itemTypesOptions.value || []).find(
(type) => type?.id === selectedTypeFk.value
);
});
const selectCategory = async (params, categoryId, search) => {
if (params.categoryFk === categoryId) {
resetCategory(params);
search();
return;
}
selectedCategoryFk.value = categoryId;
params.categoryFk = categoryId;
await fetchItemTypes(categoryId);
search();
};
const resetCategory = (params) => {
selectedCategoryFk.value = null;
itemTypesOptions.value = null;
if (params) {
params.categoryFk = null;
params.typeFk = null;
}
};
const applyTags = (params, search) => {
params.tags = tagValues.value
.filter((tag) => tag.selectedTag && tag.value)
.map((tag) => ({
tagFk: tag.selectedTag.id,
tagName: tag.selectedTag.name,
value: tag.value,
}));
search();
};
const fetchItemTypes = async (id) => {
try {
const filter = {
fields: ['id', 'name', 'categoryFk'],
where: { categoryFk: id },
include: 'category',
order: 'name ASC',
};
const { data } = await axios.get('ItemTypes', {
params: { filter: JSON.stringify(filter) },
});
itemTypesOptions.value = data;
} catch (err) {
console.error('Error fetching item types', err);
}
};
const getCategoryClass = (category, params) => {
if (category.id === params?.categoryFk) {
return 'active';
}
};
const getSelectedTagValues = async (tag) => {
try {
tag.value = null;
const filter = {
fields: ['value'],
order: 'value ASC',
limit: 30,
};
const params = { filter: JSON.stringify(filter) };
const { data } = await axios.get(`Tags/${tag.selectedTag.id}/filterValue`, {
params,
});
tag.valueOptions = data;
} catch (err) {
console.error('Error getting selected tag values');
}
};
const removeTag = (index, params, search) => {
(tagValues.value || []).splice(index, 1);
applyTags(params, search);
};
</script>
<template>
<FetchData
url="ItemCategories"
limit="30"
auto-load
@on-fetch="(data) => (itemCategories = data)"
/>
<FetchData
url="Suppliers"
limit="30"
auto-load
:filter="{ fields: ['id', 'name', 'nickname'], order: 'name ASC', limit: 30 }"
@on-fetch="(data) => (suppliersOptions = data)"
/>
<FetchData
url="Tags"
:filter="{ fields: ['id', 'name', 'isFree'] }"
auto-load
limit="30"
@on-fetch="(data) => (tagOptions = data)"
/>
<VnFilterPanel
:data-key="props.dataKey"
:expr-builder="exprBuilder"
:custom-tags="customTags"
>
<template #tags="{ tag, formatFn }">
<strong v-if="tag.label === 'categoryFk'">
{{ t(selectedCategory?.name || '') }}
</strong>
<strong v-else-if="tag.label === 'typeFk'">
{{ t(selectedType?.name || '') }}
</strong>
<div v-else class="q-gutter-x-xs">
<strong>{{ t(`components.itemsFilterPanel.${tag.label}`) }}: </strong>
<span>{{ formatFn(tag.value) }}</span>
</div>
</template>
<template #customTags="{ tags, params }">
<template v-for="tag in tags" :key="tag.label">
<VnFilterPanelChip
v-for="chip in tag.value"
:key="chip"
removable
@remove="removeTagChip(chip, params, searchFn)"
>
<div class="q-gutter-x-xs">
<strong>{{ chip.tagName }}: </strong>
<span>"{{ chip.value }}"</span>
</div>
</VnFilterPanelChip>
</template>
</template>
<template #body="{ params, searchFn }">
<QItem class="category-filter q-mt-md">
<QBtn
dense
flat
round
v-for="category in categoryList"
:key="category.name"
:class="['category', getCategoryClass(category, params)]"
:icon="category.icon"
@click="selectCategory(params, category.id, searchFn)"
>
<QTooltip>
{{ t(category.name) }}
</QTooltip>
</QBtn>
</QItem>
<QItem class="q-my-md">
<QItemSection>
<VnSelectFilter
:label="t('components.itemsFilterPanel.typeFk')"
v-model="params.typeFk"
:options="itemTypesOptions"
option-value="id"
option-label="name"
dense
outlined
rounded
use-input
:disable="!selectedCategoryFk"
@update:model-value="
(value) => {
selectedTypeFk = value;
searchFn();
}
"
>
<template #option="{ itemProps, opt }">
<QItem v-bind="itemProps">
<QItemSection>
<QItemLabel>{{ opt.name }}</QItemLabel>
<QItemLabel caption>
{{ opt.categoryName }}
</QItemLabel>
</QItemSection>
</QItem>
</template>
</VnSelectFilter>
</QItemSection>
</QItem>
<QSeparator />
<slot name="body" :params="params" :search-fn="searchFn" />
<QItem
v-for="(value, index) in tagValues"
:key="value"
class="q-mt-md filter-value"
>
<QItemSection class="col">
<VnSelectFilter
:label="t('components.itemsFilterPanel.tag')"
v-model="value.selectedTag"
:options="tagOptions"
option-label="name"
dense
outlined
rounded
:emit-value="false"
use-input
:is-clearable="false"
@update:model-value="getSelectedTagValues(value)"
/>
</QItemSection>
<QItemSection class="col">
<VnSelectFilter
v-if="!value?.selectedTag?.isFree && value.valueOptions"
:label="t('components.itemsFilterPanel.value')"
v-model="value.value"
:options="value.valueOptions || []"
option-value="value"
option-label="value"
dense
outlined
rounded
emit-value
use-input
:disable="!value"
:is-clearable="false"
@update:model-value="applyTags(params, searchFn)"
/>
<VnInput
v-else
v-model="value.value"
:label="t('components.itemsFilterPanel.value')"
:disable="!value"
is-outlined
:is-clearable="false"
@keyup.enter="applyTags(params, searchFn)"
/>
</QItemSection>
<QIcon
name="delete"
class="fill-icon-on-hover q-px-xs"
color="primary"
size="sm"
@click="removeTag(index, params, searchFn)"
/>
</QItem>
<QItem class="q-mt-lg">
<QIcon
name="add_circle"
class="fill-icon-on-hover q-px-xs"
color="primary"
size="sm"
@click="tagValues.push({})"
/>
</QItem>
</template>
</VnFilterPanel>
</template>
<style lang="scss" scoped>
.category-filter {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
.category {
padding: 8px;
width: 60px;
height: 60px;
font-size: 1.4rem;
background-color: var(--vn-accent-color);
&.active {
background-color: $primary;
}
}
}
.filter-value {
display: flex;
align-items: center;
}
</style>
<i18n>
en:
params:
supplier: Supplier
from: From
to: To
active: Is active
visible: Is visible
floramondo: Is floramondo
salesPersonFk: Buyer
categoryFk: Category
es:
params:
supplier: Proveedor
from: Desde
to: Hasta
active: Activo
visible: Visible
floramondo: Floramondo
salesPersonFk: Comprador
categoryFk: Categoría
</i18n>

View File

@ -1,6 +1,6 @@
<script setup>
import axios from 'axios';
import { onMounted, ref } from 'vue';
import { onMounted, ref, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { QSeparator, useQuasar } from 'quasar';
import { useRoute } from 'vue-router';
@ -22,6 +22,8 @@ const props = defineProps({
},
});
const expansionItemElements = reactive({});
onMounted(async () => {
await navigation.fetchPinned();
getRoutes();
@ -108,6 +110,10 @@ async function togglePinned(item, event) {
type: 'positive',
});
}
const handleItemExpansion = (itemName) => {
expansionItemElements[itemName].scrollToLastElement();
};
</script>
<template>
@ -213,8 +219,12 @@ async function togglePinned(item, event) {
:icon="item.icon"
:label="t(item.title)"
:content-inset-level="0.5"
@after-show="handleItemExpansion(item.name)"
>
<LeftMenuItemGroup :item="item" />
<LeftMenuItemGroup
:ref="(el) => (expansionItemElements[item.name] = el)"
:item="item"
/>
</QExpansionItem>
</QList>
</template>
@ -234,6 +244,6 @@ async function togglePinned(item, event) {
max-width: 256px;
}
.header {
color: #999999;
color: var(--vn-label-color);
}
</style>

View File

@ -2,7 +2,7 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { t, te } = useI18n();
const props = defineProps({
item: {
@ -11,19 +11,25 @@ const props = defineProps({
},
});
const item = computed(() => props.item); // eslint-disable-line vue/no-dupe-keys
const itemComputed = computed(() => {
const item = JSON.parse(JSON.stringify(props.item));
const [, , section] = item.title.split('.');
if (!te(item.title)) item.title = t(`globals.pageTitles.${section}`);
return item;
});
</script>
<template>
<QItem active-class="text-primary" :to="{ name: item.name }" clickable v-ripple>
<QItemSection avatar v-if="item.icon">
<QIcon :name="item.icon" />
<QItem active-class="bg-hover" :to="{ name: itemComputed.name }" clickable v-ripple>
<QItemSection avatar v-if="itemComputed.icon">
<QIcon :name="itemComputed.icon" />
</QItemSection>
<QItemSection avatar v-if="!item.icon">
<QItemSection avatar v-if="!itemComputed.icon">
<QIcon name="disabled_by_default" />
</QItemSection>
<QItemSection>{{ t(item.title) }}</QItemSection>
<QItemSection>{{ t(itemComputed.title) }}</QItemSection>
<QItemSection side>
<slot name="side" :item="item" />
<slot name="side" :item="itemComputed" />
</QItemSection>
</QItem>
</template>

View File

@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import LeftMenuItem from './LeftMenuItem.vue';
import { elementIsVisibleInViewport } from 'src/composables/elementIsVisibleInViewport';
const props = defineProps({
item: {
@ -13,10 +14,27 @@ const props = defineProps({
},
});
const groupEnd = ref(null);
const scrollToLastElement = () => {
if (groupEnd.value && !elementIsVisibleInViewport(groupEnd.value)) {
groupEnd.value.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
}
};
const item = computed(() => props.item); // eslint-disable-line vue/no-dupe-keys
defineExpose({
scrollToLastElement,
});
</script>
<template>
<template v-for="section in item.children" :key="section.name">
<LeftMenuItem :item="section" />
</template>
<div ref="groupEnd" />
</template>

View File

@ -10,12 +10,12 @@ import UserPanel from 'components/UserPanel.vue';
import VnBreadcrumbs from './common/VnBreadcrumbs.vue';
const { t } = useI18n();
const session = useSession();
const stateStore = useStateStore();
const quasar = useQuasar();
const state = useState();
const user = state.getUser();
const token = session.getToken();
const { getTokenMultimedia } = useSession();
const token = getTokenMultimedia();
const appName = 'Lilium';
onMounted(() => stateStore.setMounted());
@ -106,7 +106,7 @@ const pinnedModulesRef = ref();
width: max-content;
}
.q-header {
background-color: var(--vn-dark);
background-color: var(--vn-section-color);
}
</style>
<i18n>

View File

@ -0,0 +1,168 @@
<script setup>
import { ref, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import VnRow from 'components/ui/VnRow.vue';
import FetchData from 'components/FetchData.vue';
import VnSelectFilter from 'components/common/VnSelectFilter.vue';
import FormPopup from './FormPopup.vue';
import axios from 'axios';
import useNotify from 'src/composables/useNotify.js';
const $props = defineProps({
invoiceOutData: {
type: Object,
default: () => {},
},
});
const { t } = useI18n();
const router = useRouter();
const { notify } = useNotify();
const transferInvoiceParams = reactive({
id: $props.invoiceOutData?.id,
refFk: $props.invoiceOutData?.ref,
});
const closeButton = ref(null);
const clientsOptions = ref([]);
const rectificativeTypeOptions = ref([]);
const siiTypeInvoiceOutsOptions = ref([]);
const invoiceCorrectionTypesOptions = ref([]);
const closeForm = () => {
if (closeButton.value) closeButton.value.click();
};
const transferInvoice = async () => {
try {
const { data } = await axios.post(
'InvoiceOuts/transferInvoice',
transferInvoiceParams
);
notify(t('Transferred invoice'), 'positive');
closeForm();
router.push('InvoiceOutSummary', { id: data.id });
} catch (err) {
console.error('Error transfering invoice', err);
}
};
</script>
<template>
<FetchData
url="Clients"
@on-fetch="(data) => (clientsOptions = data)"
:filter="{ fields: ['id', 'name'], order: 'id', limit: 30 }"
auto-load
/>
<FetchData
url="CplusRectificationTypes"
:filter="{ order: 'description' }"
@on-fetch="(data) => (rectificativeTypeOptions = data)"
auto-load
/>
<FetchData
url="SiiTypeInvoiceOuts"
:filter="{ where: { code: { like: 'R%' } } }"
@on-fetch="(data) => (siiTypeInvoiceOutsOptions = data)"
auto-load
/>
<FetchData
url="InvoiceCorrectionTypes"
@on-fetch="(data) => (invoiceCorrectionTypesOptions = data)"
auto-load
/>
<FormPopup
@on-submit="transferInvoice()"
:title="t('Transfer invoice')"
:custom-submit-button-label="t('Transfer client')"
:default-cancel-button="false"
>
<template #form-inputs>
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnSelectFilter
:label="t('Client')"
:options="clientsOptions"
hide-selected
option-label="name"
option-value="id"
v-model="transferInvoiceParams.newClientFk"
:required="true"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>
#{{ scope.opt?.id }} -
{{ scope.opt?.name }}
</QItemLabel>
</QItemSection>
</QItem>
</template>
</VnSelectFilter>
</div>
<div class="col">
<VnSelectFilter
:label="t('Rectificative type')"
:options="rectificativeTypeOptions"
hide-selected
option-label="description"
option-value="id"
v-model="transferInvoiceParams.cplusRectificationTypeFk"
:required="true"
/>
</div>
</VnRow>
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<VnSelectFilter
:label="t('Class')"
:options="siiTypeInvoiceOutsOptions"
hide-selected
option-label="description"
option-value="id"
v-model="transferInvoiceParams.siiTypeInvoiceOutFk"
:required="true"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>
{{ scope.opt?.code }} -
{{ scope.opt?.description }}
</QItemLabel>
</QItemSection>
</QItem>
</template>
</VnSelectFilter>
</div>
<div class="col">
<VnSelectFilter
:label="t('Type')"
:options="invoiceCorrectionTypesOptions"
hide-selected
option-label="description"
option-value="id"
v-model="transferInvoiceParams.invoiceCorrectionTypeFk"
:required="true"
/>
</div>
</VnRow>
</template>
</FormPopup>
</template>
<i18n>
es:
Transfer invoice: Transferir factura
Transfer client: Transferir cliente
Client: Cliente
Rectificative type: Tipo rectificativa
Class: Clase
Type: Tipo
Transferred invoice: Factura transferida
</i18n>

View File

@ -7,12 +7,16 @@ import axios from 'axios';
import { useState } from 'src/composables/useState';
import { useSession } from 'src/composables/useSession';
import { localeEquivalence } from 'src/i18n/index';
import VnSelectFilter from 'src/components/common/VnSelectFilter.vue';
import VnRow from 'components/ui/VnRow.vue';
import FetchData from 'components/FetchData.vue';
const state = useState();
const session = useSession();
const router = useRouter();
const { t, locale } = useI18n();
import { useClipboard } from 'src/composables/useClipboard';
import { ref } from 'vue';
const { copyText } = useClipboard();
const userLocale = computed({
get() {
@ -44,7 +48,10 @@ const darkMode = computed({
});
const user = state.getUser();
const token = session.getToken();
const token = session.getTokenMultimedia();
const warehousesData = ref();
const companiesData = ref();
const accountBankData = ref();
onMounted(async () => {
updatePreferences();
@ -87,10 +94,28 @@ function copyUserToken() {
</script>
<template>
<QMenu anchor="bottom left">
<FetchData
url="Warehouses"
order="name"
@on-fetch="(data) => (warehousesData = data)"
auto-load
/>
<FetchData
url="Companies"
order="name"
@on-fetch="(data) => (companiesData = data)"
auto-load
/>
<FetchData
url="Accountings"
order="name"
@on-fetch="(data) => (accountBankData = data)"
auto-load
/>
<QMenu anchor="bottom left" class="bg-vn-section-color">
<div class="row no-wrap q-pa-md">
<div class="column panel">
<div class="text-h6 q-mb-md">
<div class="col column">
<div class="text-h6 q-ma-sm q-mb-none">
{{ t('components.userPanel.settings') }}
</div>
<QToggle
@ -114,7 +139,7 @@ function copyUserToken() {
<QSeparator vertical inset class="q-mx-lg" />
<div class="column items-center panel">
<div class="col column items-center q-mb-sm">
<QAvatar size="80px">
<QImg
:src="`/api/Images/user/160x160/${user.id}/download?access_token=${token}`"
@ -131,7 +156,6 @@ function copyUserToken() {
>
@{{ user.name }}
</div>
<QBtn
id="logout"
color="orange"
@ -141,17 +165,63 @@ function copyUserToken() {
icon="logout"
@click="logout()"
v-close-popup
dense
/>
</div>
</div>
<QSeparator inset class="q-mx-lg" />
<div class="col q-gutter-xs q-pa-md">
<VnRow>
<VnSelectFilter
:label="t('components.userPanel.localWarehouse')"
v-model="user.localWarehouseFk"
:options="warehousesData"
option-label="name"
option-value="id"
/>
<VnSelectFilter
:label="t('components.userPanel.localBank')"
hide-selected
v-model="user.localBankFk"
:options="accountBankData"
option-label="bank"
option-value="id"
></VnSelectFilter>
</VnRow>
<VnRow>
<VnSelectFilter
:label="t('components.userPanel.localCompany')"
hide-selected
v-model="user.companyFk"
:options="companiesData"
option-label="code"
option-value="id"
/>
<VnSelectFilter
:label="t('components.userPanel.userWarehouse')"
hide-selected
v-model="user.warehouseFk"
:options="warehousesData"
option-label="name"
option-value="id"
/>
</VnRow>
<VnRow>
<VnSelectFilter
:label="t('components.userPanel.userCompany')"
hide-selected
v-model="user.companyFk"
:options="companiesData"
option-label="code"
option-value="id"
style="flex: 0"
/>
</VnRow>
</div>
</QMenu>
</template>
<style lang="scss" scoped>
.panel {
width: 150px;
}
.copyText {
&:hover {
cursor: alias;

View File

@ -0,0 +1,156 @@
<script setup>
import {useDialogPluginComponent} from 'quasar';
import {useI18n} from 'vue-i18n';
import {computed, ref} from 'vue';
import VnInput from 'components/common/VnInput.vue';
import axios from 'axios';
import useNotify from "composables/useNotify";
const MESSAGE_MAX_LENGTH = 160;
const {t} = useI18n();
const {notify} = useNotify();
const props = defineProps({
title: {
type: String,
required: true,
},
url: {
type: String,
required: true,
},
destination: {
type: String,
required: true,
},
destinationFk: {
type: String,
required: true,
},
data: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits([...useDialogPluginComponent.emits, 'sent']);
const {dialogRef, onDialogHide} = useDialogPluginComponent();
const smsRules = [
(val) => (val && val.length > 0) || t("The message can't be empty"),
(val) =>
(val && new Blob([val]).size <= MESSAGE_MAX_LENGTH) ||
t("The message it's too long"),
];
const message = ref('');
const charactersRemaining = computed(
() => MESSAGE_MAX_LENGTH - new Blob([message.value]).size
);
const charactersChipColor = computed(() => {
if (charactersRemaining.value < 0) {
return 'negative';
}
if (charactersRemaining.value <= 25) {
return 'warning';
}
return 'primary';
});
const onSubmit = async () => {
if (!props.destination) {
throw new Error(`The destination can't be empty`);
}
if (!message.value) {
throw new Error(`The message can't be empty`);
}
if (charactersRemaining.value < 0) {
throw new Error(`The message it's too long`);
}
const response = await axios.post(props.url, {
destination: props.destination,
destinationFk: props.destinationFk,
message: message.value,
...props.data,
});
if (response.data) {
emit('sent', response.data);
notify('globals.smsSent', 'positive');
}
emit('ok', response.data);
emit('hide', response.data);
};
</script>
<template>
<QDialog ref="dialogRef" @hide="onDialogHide">
<QCard class="full-width dialog">
<QCardSection class="row">
<span v-if="title" class="text-h6">{{ title }}</span>
<QSpace />
<QBtn icon="close" flat round dense v-close-popup />
</QCardSection>
<QForm @submit="onSubmit">
<QCardSection>
<VnInput
v-model="message"
type="textarea"
:rules="smsRules"
:label="t('Message')"
:placeholder="t('Message')"
:rows="5"
required
clearable
no-error-icon
>
<template #append>
<QIcon name="info">
<QTooltip>
{{
t(
'Special characters like accents counts as a multiple'
)
}}
</QTooltip>
</QIcon>
</template>
</VnInput>
<p class="q-mb-none q-mt-md">
{{ t('Characters remaining') }}:
<QChip :color="charactersChipColor">
{{ charactersRemaining }}
</QChip>
</p>
</QCardSection>
<QCardActions align="right">
<QBtn type="button" flat v-close-popup class="text-primary">
{{ t('globals.cancel') }}
</QBtn>
<QBtn type="submit" color="primary">{{ t('Send') }}</QBtn>
</QCardActions>
</QForm>
</QCard>
</QDialog>
</template>
<style lang="scss" scoped>
.dialog {
max-width: 450px;
}
</style>
<i18n>
es:
Message: Mensaje
Send: Enviar
Characters remaining: Carácteres restantes
Special characters like accents counts as a multiple: Carácteres especiales como los acentos cuentan como varios
The destination can't be empty: El destinatario no puede estar vacio
The message can't be empty: El mensaje no puede estar vacio
The message it's too long: El mensaje es demasiado largo
</i18n>

View File

@ -5,16 +5,16 @@ import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
import { useCamelCase } from 'src/composables/useCamelCase';
const router = useRouter();
const quasar = useQuasar();
const { t } = useI18n();
const { currentRoute } = useRouter();
const { screen } = useQuasar();
const { t, te } = useI18n();
let matched = ref([]);
let breadcrumbs = ref([]);
let root = ref(null);
watchEffect(() => {
matched.value = router.currentRoute.value.matched.filter(
matched.value = currentRoute.value.matched.filter(
(matched) => Object.keys(matched.meta).length
);
breadcrumbs.value.length = 0;
@ -34,13 +34,17 @@ function getBreadcrumb(param) {
icon: param.meta.icon,
path: param.path,
root: root.value,
locale: t(`globals.pageTitles.${param.meta.title}`),
};
if (quasar.screen.gt.sm) {
if (screen.gt.sm) {
breadcrumb.name = param.name;
breadcrumb.title = useCamelCase(param.meta.title);
}
const moduleLocale = `${breadcrumb.root}.pageTitles.${breadcrumb.title}`;
if (te(moduleLocale)) breadcrumb.locale = t(moduleLocale);
return breadcrumb;
}
</script>
@ -50,7 +54,7 @@ function getBreadcrumb(param) {
v-for="(breadcrumb, index) of breadcrumbs"
:key="index"
:icon="breadcrumb.icon"
:label="t(`${breadcrumb.root}.pageTitles.${breadcrumb.title}`)"
:label="breadcrumb.locale"
:to="breadcrumb.path"
/>
</QBreadcrumbs>
@ -71,7 +75,7 @@ function getBreadcrumb(param) {
}
&--last,
&__separator {
color: var(--vn-label);
color: var(--vn-label-color);
}
}
@media (max-width: $breakpoint-md) {

View File

@ -0,0 +1,78 @@
<script setup>
import { onBeforeMount, computed } from 'vue';
import { useRoute, onBeforeRouteUpdate } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useArrayData } from 'src/composables/useArrayData';
import { useStateStore } from 'stores/useStateStore';
import useCardSize from 'src/composables/useCardSize';
import VnSubToolbar from '../ui/VnSubToolbar.vue';
import VnSearchbar from 'components/ui/VnSearchbar.vue';
import LeftMenu from 'components/LeftMenu.vue';
const props = defineProps({
dataKey: { type: String, required: true },
baseUrl: { type: String, default: undefined },
customUrl: { type: String, default: undefined },
filter: { type: Object, default: () => {} },
descriptor: { type: Object, required: true },
searchbarDataKey: { type: String, default: undefined },
searchbarUrl: { type: String, default: undefined },
searchbarLabel: { type: String, default: '' },
searchbarInfo: { type: String, default: '' },
});
const { t } = useI18n();
const stateStore = useStateStore();
const route = useRoute();
const url = computed(() => {
if (props.baseUrl) return `${props.baseUrl}/${route.params.id}`;
return props.customUrl;
});
const arrayData = useArrayData(props.dataKey, {
url: url.value,
filter: props.filter,
});
onBeforeMount(async () => {
if (!props.baseUrl) arrayData.store.filter.where = { id: route.params.id };
await arrayData.fetch({ append: false });
});
if (props.baseUrl) {
onBeforeRouteUpdate(async (to, from) => {
if (to.params.id !== from.params.id) {
arrayData.store.url = `${props.baseUrl}/${route.params.id}`;
await arrayData.fetch({ append: false });
}
});
}
</script>
<template>
<Teleport
to="#searchbar"
v-if="stateStore.isHeaderMounted() && props.searchbarDataKey"
>
<VnSearchbar
:data-key="props.searchbarDataKey"
:url="props.searchbarUrl"
:label="t(props.searchbarLabel)"
:info="t(props.searchbarInfo)"
/>
</Teleport>
<QDrawer v-model="stateStore.leftDrawer" show-if-above :width="256">
<QScrollArea class="fit">
<component :is="descriptor" />
<QSeparator />
<LeftMenu source="card" />
</QScrollArea>
</QDrawer>
<QPageContainer>
<QPage>
<VnSubToolbar />
<div :class="[useCardSize(), $attrs.class]">
<RouterView />
</div>
</QPage>
</QPageContainer>
</template>

View File

@ -0,0 +1,34 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useCapitalize } from 'src/composables/useCapitalize';
import VnInput from 'src/components/common/VnInput.vue';
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
});
const { t } = useI18n();
const emit = defineEmits(['update:modelValue']);
const amount = computed({
get() {
return props.modelValue;
},
set(val) {
emit('update:modelValue', val);
},
});
</script>
<template>
<VnInput
v-model="amount"
type="number"
step="any"
:label="useCapitalize(t('amount'))"
/>
</template>
<i18n>
es:
amount: importe
</i18n>

View File

@ -0,0 +1,210 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
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 'components/FormModelPopup.vue';
const route = useRoute();
const { t } = useI18n();
const emit = defineEmits(['onDataSaved']);
const $props = defineProps({
model: {
type: String,
required: true,
},
defaultDmsCode: {
type: String,
default: null,
},
formInitialData: {
type: Object,
default: null,
},
url: {
type: String,
default: null,
},
});
const warehouses = ref();
const companies = ref();
const dmsTypes = ref();
const allowedContentTypes = ref();
const inputFileRef = ref();
const dms = ref({});
onMounted(() => {
defaultData();
if (!$props.formInitialData)
dms.value.description = t($props.model + 'Description', dms.value);
});
function onFileChange(files) {
dms.value.hasFileAttached = !!files;
dms.value.file = files?.name;
}
function mapperDms(data) {
const formData = new FormData();
const { files } = data;
if (files) formData.append(files?.name, files);
delete data.files;
const dms = {
hasFile: !!data.hasFile,
hasFileAttached: data.hasFileAttached,
reference: data.reference,
warehouseId: data.warehouseFk,
companyId: data.companyFk,
dmsTypeId: data.dmsTypeFk,
description: data.description,
};
return [formData, { params: dms }];
}
function getUrl() {
if ($props.url) return $props.url;
if ($props.formInitialData) return 'dms/' + $props.formInitialData.id + '/updateFile';
return `${$props.model}/${route.params.id}/uploadFile`;
}
async function save() {
const body = mapperDms(dms.value);
const response = await axios.post(getUrl(), body[0], body[1]);
emit('onDataSaved', body[1].params, response);
}
function defaultData() {
if ($props.formInitialData) return (dms.value = $props.formInitialData);
return addDefaultData({
reference: route.params.id,
});
}
function setDmsTypes(data) {
dmsTypes.value = data;
if (!$props.formInitialData && $props.defaultDmsCode) {
const { id } = data.find((dmsType) => dmsType.code == $props.defaultDmsCode);
addDefaultData({ dmsTypeFk: id });
}
}
function addDefaultData(data) {
Object.assign(dms.value, data);
}
</script>
<template>
<FetchData url="Warehouses" @on-fetch="(data) => (warehouses = data)" auto-load />
<FetchData url="Companies" @on-fetch="(data) => (companies = data)" auto-load />
<FetchData url="DmsTypes" @on-fetch="setDmsTypes" auto-load />
<FetchData
url="DmsContainers/allowedContentTypes"
@on-fetch="(data) => (allowedContentTypes = data.join(','))"
auto-load
/>
<FetchData
url="UserConfigs/getUserConfig"
@on-fetch="addDefaultData"
:auto-load="!$props.formInitialData"
/>
<FormModelPopup
:title="formInitialData ? t('globals.edit') : t('globals.create')"
model="dms"
:form-initial-data="formInitialData ?? {}"
:save-fn="save"
>
<template #form-inputs>
<div class="q-gutter-y-ms">
<VnRow>
<VnInput :label="t('globals.reference')" v-model="dms.reference" />
<VnSelectFilter
:label="t('globals.company')"
v-model="dms.companyFk"
:options="companies"
option-value="id"
option-label="code"
input-debounce="0"
/>
</VnRow>
<VnRow>
<VnSelectFilter
:label="t('globals.warehouse')"
v-model="dms.warehouseFk"
:options="warehouses"
option-value="id"
option-label="name"
input-debounce="0"
/>
<VnSelectFilter
:label="t('globals.type')"
v-model="dms.dmsTypeFk"
:options="dmsTypes"
option-value="id"
option-label="name"
input-debounce="0"
/>
</VnRow>
<QInput
:label="t('globals.description')"
v-model="dms.description"
type="textarea"
/>
<QFile
ref="inputFileRef"
:label="t('entry.buys.file')"
v-model="dms.files"
:multiple="false"
:accept="allowedContentTypes"
@update:model-value="onFileChange(dms.files)"
class="required"
:display-value="dms.file"
>
<template #append>
<QIcon
name="vn:attach"
class="cursor-pointer"
@click="inputFileRef.pickFiles()"
>
<QTooltip>{{ t('globals.selectFile') }}</QTooltip>
</QIcon>
<QIcon name="info" class="cursor-pointer">
<QTooltip>{{
t('contentTypesInfo', { allowedContentTypes })
}}</QTooltip>
</QIcon>
</template>
</QFile>
<QCheckbox
v-model="dms.hasFile"
:label="t('Generate identifier for original file')"
/>
</div>
</template>
</FormModelPopup>
</template>
<style scoped>
.q-gutter-y-ms {
display: grid;
row-gap: 20px;
}
</style>
<i18n>
en:
contentTypesInfo: Allowed file types {allowedContentTypes}
EntryDmsDescription: Reference {reference}
WorkersDescription: Working of employee id {reference}
SupplierDmsDescription: Reference {reference}
es:
Generate identifier for original file: Generar identificador para archivo original
contentTypesInfo: Tipos de archivo permitidos {allowedContentTypes}
EntryDmsDescription: Referencia {reference}
WorkersDescription: Laboral del empleado {reference}
SupplierDmsDescription: Referencia {reference}
</i18n>

View File

@ -0,0 +1,395 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useQuasar, QCheckbox, QBtn, QInput } from 'quasar';
import axios from 'axios';
import VnPaginate from 'components/ui/VnPaginate.vue';
import VnDms from 'src/components/common/VnDms.vue';
import VnConfirm from 'components/ui/VnConfirm.vue';
import VnInputDate from 'components/common/VnInputDate.vue';
import VnUserLink from '../ui/VnUserLink.vue';
import { downloadFile } from 'src/composables/downloadFile';
const route = useRoute();
const quasar = useQuasar();
const { t } = useI18n();
const rows = ref();
const dmsRef = ref();
const formDialog = ref({});
const $props = defineProps({
model: {
type: String,
required: true,
},
updateModel: {
type: String,
default: null,
},
deleteModel: {
type: String,
default: null,
},
downloadModel: {
type: String,
required: false,
default: null,
},
defaultDmsCode: {
type: String,
required: true,
},
filter: {
type: String,
required: true,
},
});
const dmsFilter = {
include: {
relation: 'dms',
scope: {
fields: [
'dmsTypeFk',
'reference',
'hardCopyNumber',
'workerFk',
'description',
'hasFile',
'file',
'created',
'companyFk',
'warehouseFk',
],
include: [
{
relation: 'dmsType',
scope: {
fields: ['name'],
},
},
{
relation: 'worker',
scope: {
fields: ['id'],
include: {
relation: 'user',
scope: {
fields: ['name'],
},
},
},
},
],
},
},
where: { [$props.filter]: route.params.id },
};
const columns = computed(() => [
{
align: 'left',
field: 'id',
label: t('globals.id'),
name: 'id',
component: 'span',
},
{
align: 'left',
field: 'type',
label: t('globals.type'),
name: 'type',
component: QInput,
props: (prop) => ({
readonly: true,
borderless: true,
'model-value': prop.row.dmsType?.name,
}),
},
{
align: 'left',
field: 'hardCopyNumber',
label: t('globals.order'),
name: 'order',
component: 'span',
},
{
align: 'left',
field: 'reference',
label: t('globals.reference'),
name: 'reference',
component: 'span',
},
{
align: 'left',
field: 'description',
label: t('globals.description'),
name: 'description',
component: 'span',
props: (prop) => ({ value: prop.value?.toUpperCase() }),
},
{
align: 'left',
field: 'hasFile',
label: t('globals.original'),
name: 'hasFile',
component: QCheckbox,
props: (prop) => ({
disable: true,
'model-value': Boolean(prop.value),
}),
},
{
align: 'left',
field: 'file',
label: t('globals.file'),
name: 'file',
component: 'span',
},
{
align: 'left',
field: 'worker',
label: t('globals.worker'),
name: 'worker',
component: VnUserLink,
props: (prop) => ({
name: prop.row.worker?.user?.name.toLowerCase(),
workerId: prop.row.worker?.id,
}),
},
{
align: 'left',
field: 'created',
label: t('globals.created'),
name: 'created',
component: VnInputDate,
props: (prop) => ({
disable: true,
'model-value': prop.row.created,
}),
},
{
field: 'options',
name: 'options',
components: [
{
component: QBtn,
name: 'download',
isDocuware: true,
props: () => ({
icon: 'cloud_download',
flat: true,
color: 'primary',
}),
click: (prop) =>
downloadFile(
prop.row.id,
$props.downloadModel,
undefined,
prop.row.download
),
},
{
component: QBtn,
name: 'edit',
external: false,
props: () => ({
icon: 'edit',
flat: true,
color: 'primary',
}),
click: (prop) => showFormDialog(prop.row),
},
{
component: QBtn,
name: 'delete',
external: false,
props: () => ({
icon: 'delete',
flat: true,
color: 'primary',
}),
click: (prop) => deleteDms(prop.row.id),
},
{
component: QBtn,
name: 'open',
external: true,
props: () => ({
icon: 'open_in_new',
flat: true,
color: 'primary',
}),
click: (prop) => open(prop.row.url),
},
],
},
]);
function setData(data) {
const newData = data.map((value) => value.dms || value);
newData.sort((a, b) => new Date(b.created) - new Date(a.created));
rows.value = newData;
}
function deleteDms(dmsFk) {
quasar
.dialog({
component: VnConfirm,
componentProps: {
title: t('globals.confirmDeletion'),
message: t('globals.confirmDeletionMessage'),
},
})
.onOk(async () => {
await axios.post(`${$props.deleteModel ?? $props.model}/${dmsFk}/removeFile`);
const index = rows.value.findIndex((row) => row.id == dmsFk);
rows.value.splice(index, 1);
});
}
function showFormDialog(dms) {
if (dms) dms = parseDms(dms);
formDialog.value = {
show: true,
dms,
};
}
function parseDms(data) {
for (let prop in data) {
if (prop.endsWith('Fk')) data[prop.replace('Fk', 'Id')] = data[prop];
}
return data;
}
async function open(url) {
window.open(url).focus();
}
function shouldRenderButton(button, isExternal = false) {
if (button.name == 'download') return true;
return button.external === isExternal;
}
</script>
<template>
<VnPaginate
ref="dmsRef"
:data-key="$props.model"
:url="$props.model"
:filter="dmsFilter"
:order="['dmsFk DESC']"
:auto-load="true"
@on-fetch="setData"
>
<template #body>
<QTable
:columns="columns"
:rows="rows"
class="full-width q-mt-md"
hide-bottom
row-key="clientFk"
:grid="$q.screen.lt.sm"
>
<template #body-cell="props">
<QTd :props="props">
<QTr :props="props">
<component
v-if="props.col.component"
:is="props.col.component"
v-bind="props.col.props && props.col.props(props)"
>
<span
v-if="props.col.component == 'span'"
style="white-space: wrap"
>{{ props.value }}</span
>
</component>
</QTr>
<div class="row no-wrap" v-if="props.col.name == 'options'">
<div v-for="button of props.col.components" :key="button.id">
<component
v-if="
shouldRenderButton(button, props.row.isDocuware)
"
:is="button.component"
v-bind="button.props(props)"
@click="button.click(props)"
/>
</div>
</div>
</QTd>
</template>
<template #item="props">
<div class="q-pa-xs col-xs-12 col-sm-6 grid-style-transition">
<QCard
bordered
flat
@keyup.ctrl.enter.stop="claimDevelopmentForm?.saveChanges()"
>
<QSeparator />
<QList dense>
<QItem v-for="col in props.cols" :key="col.name">
<div v-if="col.name != 'options'" class="row">
<span class="labelColor">{{ col.label }}:</span>
<span>{{ col.value }}</span>
</div>
<div v-if="col.name == 'options'" class="row">
<div
v-for="button of col.components"
:key="button.id"
class="row"
>
<component
v-if="
shouldRenderButton(
button.name,
props.row.isDocuware
)
"
:is="button.component"
v-bind="button.props(col)"
@click="button.click(col)"
/>
</div>
</div>
</QItem>
</QList>
</QCard>
</div>
</template>
</QTable>
</template>
</VnPaginate>
<QDialog v-model="formDialog.show">
<VnDms
:model="updateModel ?? model"
:default-dms-code="defaultDmsCode"
:form-initial-data="formDialog.dms"
@on-data-saved="dmsRef.fetch()"
:description="$props.description"
/>
</QDialog>
<QPageSticky position="bottom-right" :offset="[25, 25]">
<QBtn fab color="primary" icon="add" @click="showFormDialog()" />
</QPageSticky>
</template>
<style scoped>
.q-gutter-y-ms {
display: grid;
row-gap: 20px;
}
.labelColor {
color: var(--vn-label-color);
}
</style>
<i18n>
en:
contentTypesInfo: Allowed file types {allowedContentTypes}
es:
contentTypesInfo: Tipos de archivo permitidos {allowedContentTypes}
Generate identifier for original file: Generar identificador para archivo original
</i18n>

View File

@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const emit = defineEmits(['update:modelValue', 'update:options', 'keyup.enter']);
@ -17,7 +17,7 @@ const $props = defineProps({
const { t } = useI18n();
const requiredFieldRule = (val) => !!val || t('globals.fieldRequired');
const vnInputRef = ref(null);
const value = computed({
get() {
return $props.modelValue;
@ -26,7 +26,7 @@ const value = computed({
emit('update:modelValue', value);
},
});
const hover = ref(false);
const styleAttrs = computed(() => {
return $props.isOutlined
? {
@ -40,23 +40,48 @@ const styleAttrs = computed(() => {
const onEnterPress = () => {
emit('keyup.enter');
};
const handleValue = (val = null) => {
value.value = val;
};
const focus = () => {
vnInputRef.value.focus();
};
defineExpose({
focus,
});
</script>
<template>
<QInput
ref="vnInputRef"
v-model="value"
v-bind="{ ...$attrs, ...styleAttrs }"
type="text"
:class="{ required: $attrs.required }"
@keyup.enter="onEnterPress()"
<div
@mouseover="hover = true"
@mouseleave="hover = false"
: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>
<QInput
ref="vnInputRef"
v-model="value"
v-bind="{ ...$attrs, ...styleAttrs }"
:type="$attrs.type"
:class="{ required: $attrs.required }"
@keyup.enter="onEnterPress()"
:clearable="false"
>
<template v-if="$slots.prepend" #prepend>
<slot name="prepend" />
</template>
<template #append>
<slot name="append" v-if="$slots.append" />
<QIcon
name="close"
size="xs"
v-if="hover && value"
@click="handleValue(null)"
></QIcon>
</template>
</QInput>
</div>
</template>

View File

@ -1,6 +1,6 @@
<script setup>
import { computed, ref } from 'vue';
import { toDate } from 'src/filters';
import isValidDate from 'filters/isValidDate';
const props = defineProps({
modelValue: {
@ -16,13 +16,28 @@ const props = defineProps({
default: false,
},
});
const hover = ref(false);
const emit = defineEmits(['update:modelValue']);
const joinDateAndTime = (date, time) => {
if (!date) {
return null;
}
if (!time) {
return new Date(date).toISOString();
}
const [year, month, day] = date.split('/');
return new Date(`${year}-${month}-${day}T${time}`).toISOString();
};
const time = computed(() => (props.modelValue ? props.modelValue.split('T')?.[1] : null));
const value = computed({
get() {
return props.modelValue;
},
set(value) {
emit('update:modelValue', value ? new Date(value).toISOString() : null);
emit('update:modelValue', joinDateAndTime(value, time.value));
},
});
@ -40,6 +55,16 @@ const formatDate = (dateString) => {
date.getDate()
)}`;
};
const displayDate = (dateString) => {
if (!dateString || !isValidDate(dateString)) {
return '';
}
return new Date(dateString).toLocaleDateString([], {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
};
const styleAttrs = computed(() => {
return props.isOutlined
@ -53,30 +78,39 @@ const styleAttrs = computed(() => {
</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>
<div @mouseover="hover = true" @mouseleave="hover = false">
<QInput
class="vn-input-date"
readonly
:model-value="displayDate(value)"
v-bind="{ ...$attrs, ...styleAttrs }"
@click="isPopupOpen = true"
>
<template #append>
<QIcon
name="close"
size="xs"
v-if="hover && value"
@click="onDateUpdate(null)"
></QIcon>
<QIcon name="event" class="cursor-pointer">
<QPopupProxy
v-model="isPopupOpen"
cover
transition-show="scale"
transition-hide="scale"
:no-parent-event="props.readonly"
>
<QDate
:today-btn="true"
:model-value="formatDate(value)"
@update:model-value="onDateUpdate"
/>
</QPopupProxy>
</QIcon>
</template>
</QInput>
</div>
</template>
<style lang="scss">

View File

@ -1,6 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { toHour } from 'src/filters';
import { useI18n } from 'vue-i18n';
import isValidDate from 'filters/isValidDate';
@ -20,23 +19,20 @@ const props = defineProps({
});
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
);
const date = new Date(props.modelValue);
date.setHours(Number.parseInt(hours) || 0, Number.parseInt(minutes) || 0, 0, 0);
emit('update:modelValue', value ? date.toISOString() : null);
},
});
const isPopupOpen = ref(false);
const onDateUpdate = (date) => {
internalValue.value = date;
};
@ -45,7 +41,7 @@ const save = () => {
value.value = internalValue.value;
};
const formatTime = (dateString) => {
if (!isValidDate(dateString)) {
if (!dateString || !isValidDate(dateString)) {
return '';
}
@ -72,14 +68,15 @@ const styleAttrs = computed(() => {
<template>
<QInput
class="vn-input-time"
rounded
readonly
:model-value="toHour(value)"
:model-value="formatTime(value)"
v-bind="{ ...$attrs, ...styleAttrs }"
@click="isPopupOpen = true"
>
<template #append>
<QIcon name="event" class="cursor-pointer">
<QIcon name="schedule" class="cursor-pointer">
<QPopupProxy
v-model="isPopupOpen"
cover
transition-show="scale"
transition-hide="scale"

View File

@ -50,7 +50,10 @@ const value = computed({
return $props.modelValue;
},
set(value) {
emit('update:modelValue', value);
emit(
'update:modelValue',
postcodesOptions.value.find((p) => p.code === value)
);
},
});
@ -85,6 +88,10 @@ function locationFilter(search = '') {
function handleFetch(data) {
postcodesOptions.value = data;
}
function onDataSaved(newPostcode) {
postcodesOptions.value.push(newPostcode);
value.value = newPostcode.code;
}
</script>
<template>
<FetchData
@ -101,23 +108,20 @@ function handleFetch(data) {
:label="t('Location')"
:placeholder="t('search_by_postalcode')"
@input-value="locationFilter"
:default-filter="true"
:default-filter="false"
:input-debounce="300"
:class="{ required: $attrs.required }"
v-bind="$attrs"
emit-value
map-options
use-input
clearable
hide-selected
fill-input
>
<template #form>
<CreateNewPostcode @on-data-saved="locationFilter()" />
<CreateNewPostcode
@on-data-saved="onDataSaved"
/>
</template>
<template #option="{ itemProps, opt }">
<QItem v-bind="itemProps">
<QItemSection v-if="opt">
<QItemSection v-if="opt.code">
<QItemLabel>{{ opt.code }}</QItemLabel>
<QItemLabel caption>{{ showLabel(opt) }}</QItemLabel>
</QItemSection>

View File

@ -403,7 +403,7 @@ setLogTree();
auto-load
/>
<div
class="column items-center logs origin-log"
class="column items-center logs origin-log q-mt-md"
v-for="(originLog, originLogIndex) in logTree"
:key="originLogIndex"
>
@ -819,7 +819,7 @@ setLogTree();
</template>
<style lang="scss" scoped>
.q-card {
background-color: var(--vn-gray);
background-color: var(--vn-section-color);
}
.q-item {
min-height: 0px;
@ -836,7 +836,7 @@ setLogTree();
max-width: 400px;
& > .header {
color: $dark;
color: var(--vn-section-color);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@ -916,7 +916,7 @@ setLogTree();
font-style: italic;
}
.model-id {
color: var(--vn-label);
color: var(--vn-label-color);
font-size: 0.9rem;
}
.q-btn {
@ -942,7 +942,7 @@ setLogTree();
}
.change-info {
overflow: hidden;
background-color: var(--vn-dark);
background-color: var(--vn-section-color);
& > .date {
overflow: hidden;
white-space: nowrap;
@ -981,7 +981,7 @@ setLogTree();
position: relative;
overflow: hidden;
text-overflow: ellipsis;
background-color: var(--vn-gray);
background-color: var(--vn-section-color);
white-space: nowrap;
box-sizing: border-box;
& > .q-icon {
@ -1030,13 +1030,12 @@ en:
ticketCreated: Created
created: Created
isChargedToMana: Charged to mana
hasToPickUp: Has to pick Up
pickup: Type of pickup
dmsFk: Document ID
text: Description
claimStateFk: Claim State
workerFk: Worker
clientFk: Customer
rma: RMA
responsibility: Responsibility
packages: Packages
es:
@ -1046,7 +1045,7 @@ es:
tooltips:
search: Buscar por identificador o concepto
changes: Buscar por cambios. Los atributos deben buscarse por su nombre interno, para obtenerlo situar el cursor sobre el atributo.
Audit logs: Registros de auditoría
Audit logs: Historial
Property: Propiedad
Before: Antes
After: Después
@ -1070,13 +1069,12 @@ es:
ticketCreated: Creado
created: Creado
isChargedToMana: Cargado a maná
hasToPickUp: Se debe recoger
pickup: Se debe recoger
dmsFk: ID documento
text: Descripción
claimStateFk: Estado de la reclamación
workerFk: Trabajador
clientFk: Cliente
rma: RMA
responsibility: Responsabilidad
packages: Bultos
</i18n>

View File

@ -59,6 +59,9 @@ const toggleForm = () => {
:name="actionIcon"
:size="actionIcon === 'add' ? 'xs' : 'sm'"
:class="['default-icon', { '--add-icon': actionIcon === 'add' }]"
:style="{
'font-variation-settings': `'FILL' ${1}`,
}"
>
<QTooltip v-if="tooltip">{{ tooltip }}</QTooltip>
</QIcon>
@ -79,7 +82,7 @@ const toggleForm = () => {
border-radius: 50px;
&.--add-icon {
color: var(--vn-text);
color: var(--vn-text-color);
background-color: $primary;
}
}

View File

@ -51,8 +51,8 @@ const $props = defineProps({
default: null,
},
limit: {
type: Number,
default: 30,
type: [Number, String],
default: '30',
},
});
@ -116,14 +116,14 @@ async function fetchFilter(val) {
}
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(
async () => {
if (!$props.defaultFilter) return;
if ($props.url) {
myOptions.value = await fetchFilter(val);
return;
}
myOptions.value = filter(val, myOptionsOriginal.value);
() => {
myOptions.value = newOptions;
},
(ref) => {
if (val !== '' && ref.options.length > 0) {
@ -151,7 +151,7 @@ watch(modelValue, (newValue) => {
@on-fetch="(data) => setOptions(data)"
:where="where || { [optionValue]: value }"
:limit="limit"
:order-by="orderBy"
:sort-by="sortBy"
:fields="fields"
/>
<QSelect

View File

@ -21,7 +21,8 @@ const props = defineProps({
},
template: {
type: String,
required: true,
required: false,
default: '',
},
locale: {
type: String,
@ -49,7 +50,7 @@ updateMessage();
function updateMessage() {
const params = props.data;
const key = `templates['${props.template}']`;
const key = props.template ? `templates['${props.template}']` : '';
message.value = t(key, params, { locale: locale.value });
}
@ -94,16 +95,6 @@ async function send() {
<QSpace />
<QBtn icon="close" :disable="isLoading" flat round dense v-close-popup />
</QCardSection>
<QCardSection v-if="props.locale">
<QBanner class="bg-amber text-white" rounded dense>
<template #avatar>
<QIcon name="warning" />
</template>
<span
v-html="t('CustomerDefaultLanguage', { locale: t(props.locale) })"
></span>
</QBanner>
</QCardSection>
<QCardSection class="q-pb-xs">
<QSelect
:label="t('Language')"
@ -114,15 +105,14 @@ async function send() {
map-options
:input-debounce="0"
rounded
outlined
dense
/>
</QCardSection>
<QCardSection class="q-pb-xs">
<VnInput :label="t('Phone')" v-model="phone" is-outlined />
<VnInput :label="t('Phone')" v-model="phone" />
</QCardSection>
<QCardSection class="q-pb-xs">
<VnInput v-model="subject" :label="t('Subject')" is-outlined />
<VnInput v-model="subject" :label="t('Subject')" />
</QCardSection>
<QCardSection class="q-mb-md" q-input>
<QInput
@ -135,7 +125,6 @@ async function send() {
:bottom-slots="true"
:rules="[(value) => value.length < maxLength || 'Error!']"
stack-label
outlined
autofocus
>
<template #append>
@ -145,6 +134,11 @@ async function send() {
@click="message = ''"
class="cursor-pointer"
/>
<QIcon name="info" class="cursor-pointer">
<QTooltip>
{{ t('messageTooltip') }}
</QTooltip>
</QIcon>
</template>
<template #counter>
<QChip :color="color" dense>
@ -184,30 +178,30 @@ async function send() {
<i18n>
en:
CustomerDefaultLanguage: This customer uses <strong>{locale}</strong> as their default language
templates:
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
minAmount: 'A minimum amount of 50 (VAT excluded) is required for your order
{ orderId } of { shipped } to receive it without additional shipping costs.'
orderChanges: 'Order {orderId} of { shipped }: { changes }'
en: English
es: Spanish
fr: French
pt: Portuguese
messageTooltip: Special characters like accents counts as multiple
es:
Send SMS: Enviar SMS
CustomerDefaultLanguage: Este cliente utiliza <strong>{locale}</strong> como idioma por defecto
Language: Idioma
Phone: Móvil
Subject: Asunto
Message: Mensaje
messageTooltip: Carácteres especiales como acentos cuentan como varios
templates:
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.'
orderChanges: 'Pedido {orderId} día { shipped }: { changes }'
minAmount: 'Es necesario un importe mínimo de 50 (Sin IVA) en su pedido
{ orderId } con llegada { landing } para recibirlo sin portes adicionales.'
orderChanges: 'Pedido {orderId} con llegada estimada día { landing }: { changes }'
en: Inglés
es: Español
fr: Francés
@ -219,12 +213,14 @@ fr:
Phone: Mobile
Subject: Affaire
Message: Message
messageTooltip: Les caractères spéciaux comme les accents comptent comme plusieurs
templates:
pendingPayment: 'Votre commande est en attente de paiement.
Veuillez vous connecter sur le site web et effectuer le paiement par carte. Merci beaucoup.'
minAmount: 'Un montant minimum de 50 (TVA non incluse) est requis pour votre commande
{ orderId } du { shipped } afin de la recevoir sans frais de port supplémentaires.'
orderChanges: 'Commande { orderId } du { shipped }: { changes }'
pendingPayment: 'Verdnatura : Commande en attente de règlement. Veuillez régler votre commande avant 9h.
Sinon elle sera décalée en fonction de vos jours de livraison . Merci'
minAmount: 'Verdnatura vous rappelle :
Montant minimum nécessaire de 50 euros pour recevoir la commande { orderId } livraison { landing }.
Merci.'
orderChanges: 'Commande {orderId} livraison {landing} indisponible/s. Désolés pour le dérangement.'
en: Anglais
es: Espagnol
fr: Français
@ -236,12 +232,13 @@ pt:
Phone: Móvel
Subject: Assunto
Message: Mensagem
messageTooltip: Caracteres especiais como acentos contam como vários
templates:
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.'
orderChanges: 'Pedido { orderId } dia { shipped }: { changes }'
minAmount: 'É necessário um valor mínimo de 50 (sem IVA) em seu pedido
{ orderId } do dia { landing } para recebê-lo sem custos de envio adicionais.'
orderChanges: 'Pedido { orderId } com chegada dia { landing }: { changes }'
en: Inglês
es: Espanhol
fr: Francês

View File

@ -0,0 +1,25 @@
<script setup>
const $props = defineProps({
url: { type: String, default: null },
text: { type: String, default: null },
icon: { type: String, default: 'open_in_new' },
});
</script>
<template>
<div class="titleBox">
<div class="header-link">
<a :href="$props.url" :class="$props.url ? 'link' : 'color-vn-text'">
{{ $props.text }}
<QIcon v-if="url" :name="$props.icon" />
</a>
</div>
</div>
</template>
<style scoped lang="scss">
a {
font-size: large;
}
.titleBox {
padding-bottom: 2%;
}
</style>

View File

@ -1,9 +1,10 @@
<script setup>
import { onMounted, useSlots, watch, computed } from 'vue';
import { onBeforeMount, watch, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import SkeletonDescriptor from 'components/ui/SkeletonDescriptor.vue';
import { useArrayData } from 'composables/useArrayData';
import { useSummaryDialog } from 'src/composables/useSummaryDialog';
import { useState } from 'src/composables/useState';
const $props = defineProps({
url: {
@ -35,40 +36,45 @@ const $props = defineProps({
default: null,
},
});
const slots = useSlots();
const state = useState();
const { t } = useI18n();
const { viewSummary } = useSummaryDialog();
const entity = computed(() => useArrayData($props.dataKey).store.data);
const arrayData = useArrayData($props.dataKey || $props.module, {
url: $props.url,
filter: $props.filter,
skip: 0,
});
const { store } = arrayData;
const entity = computed(() => (Array.isArray(store.data) ? store.data[0] : store.data));
const isLoading = ref(false);
defineExpose({
getData,
});
onMounted(async () => {
onBeforeMount(async () => {
await getData();
watch(
() => $props.url,
async (newUrl, lastUrl) => {
if (newUrl == lastUrl) return;
await getData();
}
);
watch($props, async () => await getData());
});
async function getData() {
const arrayData = useArrayData($props.dataKey, {
url: $props.url,
filter: $props.filter,
skip: 0,
});
const { data } = await arrayData.fetch({ append: false, updateRouter: false });
emit('onFetch', data);
store.url = $props.url;
store.filter = $props.filter ?? {};
isLoading.value = true;
try {
const { data } = await arrayData.fetch({ append: false, updateRouter: false });
state.set($props.dataKey, data);
emit('onFetch', Array.isArray(data) ? data[0] : data);
} finally {
isLoading.value = false;
}
}
const emit = defineEmits(['onFetch']);
</script>
<template>
<div class="descriptor">
<template v-if="entity">
<template v-if="entity && !isLoading">
<div class="header bg-primary q-pa-sm justify-between">
<slot name="header-extra-action" />
<QBtn
@ -108,7 +114,7 @@ const emit = defineEmits(['onFetch']);
icon="more_vert"
round
size="md"
:class="{ invisible: !slots.menu }"
:class="{ invisible: !$slots.menu }"
>
<QTooltip>
{{ t('components.cardDescriptor.moreOptions') }}
@ -148,45 +154,55 @@ const emit = defineEmits(['onFetch']);
<div class="icons">
<slot name="icons" :entity="entity" />
</div>
<div class="actions">
<div class="actions justify-center">
<slot name="actions" :entity="entity" />
</div>
<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">
.body {
background-color: var(--vn-section-color);
.text-h5 {
font-size: 20px;
padding-top: 5px;
padding-bottom: 5px;
padding-bottom: 0px;
}
.q-item {
min-height: 20px;
.link {
margin-left: 5px;
margin-left: 10px;
}
}
.vn-label-value {
display: flex;
padding: 2px 16px;
padding: 0px 16px;
.label {
color: var(--vn-label);
font-size: 12px;
width: 47%;
color: var(--vn-label-color);
font-size: 14px;
&:not(:has(a))::after {
content: ':';
}
}
.value {
color: var(--vn-text);
color: var(--vn-text-color);
font-size: 14px;
margin-left: 12px;
width: 47%;
margin-left: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.info {
margin-left: 5px;
@ -200,23 +216,19 @@ const emit = defineEmits(['onFetch']);
overflow: hidden;
text-overflow: ellipsis;
span {
color: $primary;
color: var(--vn-text-color);
font-weight: bold;
}
}
.subtitle {
color: var(--vn-text);
color: var(--vn-text-color);
font-size: 16px;
margin-bottom: 15px;
margin-bottom: 2px;
}
.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);
color: var(--vn-label-color);
padding-bottom: 0%;
}
}
.descriptor {
@ -234,6 +246,7 @@ const emit = defineEmits(['onFetch']);
}
.actions {
margin: 0 5px;
justify-content: center !important;
}
}
</style>

View File

@ -61,7 +61,7 @@ const toggleCardCheck = (item) => {
}
.q-chip-color {
color: var(--vn-label);
color: var(--vn-label-color) !important;
}
.card-list-body {
@ -75,7 +75,7 @@ const toggleCardCheck = (item) => {
width: 50%;
.label {
width: 35%;
color: var(--vn-label);
color: var(--vn-label-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -117,7 +117,7 @@ const toggleCardCheck = (item) => {
transition: background-color 0.2s;
}
.card:hover {
background-color: var(--vn-gray);
background-color: var(--vn-section-color);
}
.list-items {
width: 75%;

View File

@ -1,12 +1,10 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { ref, computed, watch, onBeforeMount } from 'vue';
import { useRoute } from 'vue-router';
import SkeletonSummary from 'components/ui/SkeletonSummary.vue';
import VnLv from 'src/components/ui/VnLv.vue';
import { useArrayData } from 'src/composables/useArrayData';
onMounted(() => fetch());
const entity = ref();
const props = defineProps({
url: {
type: String,
@ -16,41 +14,68 @@ const props = defineProps({
type: Object,
default: null,
},
entityId: {
type: [Number, String],
default: null,
},
dataKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['onFetch']);
const route = useRoute();
const isSummary = ref();
const arrayData = useArrayData(props.dataKey || route.meta.moduleName, {
url: props.url,
filter: props.filter,
skip: 0,
});
const { store } = arrayData;
const entity = computed(() => (Array.isArray(store.data) ? store.data[0] : store.data));
const isLoading = ref(false);
defineExpose({
entity,
fetch,
});
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);
}
watch(props, async () => {
entity.value = null;
fetch();
onBeforeMount(async () => {
isSummary.value = String(route.path).endsWith('/summary');
await fetch();
watch(props, async () => await fetch());
});
async function fetch() {
store.url = props.url;
store.filter = props.filter ?? {};
isLoading.value = true;
const { data } = await arrayData.fetch({ append: false, updateRouter: false });
emit('onFetch', Array.isArray(data) ? data[0] : data);
isLoading.value = false;
}
</script>
<template>
<div class="summary container">
<QCard class="cardSummary">
<SkeletonSummary v-if="!entity" />
<template v-if="entity">
<div class="summaryHeader bg-primary q-pa-md text-weight-bolder">
<SkeletonSummary v-if="!entity || isLoading" />
<template v-if="entity && !isLoading">
<div class="summaryHeader bg-primary q-pa-sm text-weight-bolder">
<slot name="header-left">
<span></span>
<router-link
v-if="!isSummary && route.meta.moduleName"
class="header link"
:to="{
name: `${route.meta.moduleName}Summary`,
params: { id: entityId || entity.id },
}"
>
<QIcon name="open_in_new" color="white" size="sm" />
</router-link>
<span v-else></span>
</slot>
<slot name="header" :entity="entity">
<slot name="header" :entity="entity" dense>
<VnLv :label="`${entity.id} -`" :value="entity.name" />
</slot>
<slot name="header-right">
@ -73,7 +98,6 @@ watch(props, async () => {
.cardSummary {
width: 100%;
.summaryHeader {
text-align: center;
font-size: 20px;
@ -85,37 +109,37 @@ watch(props, async () => {
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
gap: 15px;
padding: 15px;
background-color: var(--vn-gray);
gap: 10px;
padding: 10px;
background-color: var(--vn-section-color);
> .q-card.vn-one {
width: 350px;
flex: 1;
}
> .q-card.vn-two {
flex: 2;
flex: 40%;
}
> .q-card.vn-three {
flex: 4;
flex: 75%;
}
> .q-card.vn-max {
width: 100%;
flex: 100%;
}
> .q-card {
width: 100%;
background-color: var(--vn-gray);
padding: 15px;
background-color: var(--vn-section-color);
padding: 7px;
font-size: 16px;
min-width: 275px;
box-shadow: none;
.vn-label-value {
display: flex;
flex-direction: row;
margin-top: 5px;
margin-top: 2px;
.label {
color: var(--vn-label);
color: var(--vn-label-color);
width: 8em;
overflow: hidden;
white-space: nowrap;
@ -125,20 +149,33 @@ watch(props, async () => {
flex-shrink: 0;
}
.value {
color: var(--vn-text);
color: var(--vn-text-color);
overflow: hidden;
}
}
.header {
color: $primary;
font-weight: bold;
margin-bottom: 25px;
margin-bottom: 10px;
font-size: 20px;
display: inline-block;
}
.header.link:hover {
color: lighten($primary, 20%);
}
.q-checkbox {
display: flex;
margin-bottom: 9px;
& .q-checkbox__label {
margin-left: 31px;
color: var(--vn-text-color);
}
& .q-checkbox__inner {
position: absolute;
left: 0;
color: var(--vn-label-color);
}
}
}
}

View File

@ -13,12 +13,49 @@ defineProps({
<template>
<div class="fetchedTags">
<div class="wrap">
<div class="inline-tag" :class="{ empty: !$props.item.value5 }">{{ $props.item.value5 }}</div>
<div class="inline-tag" :class="{ empty: !$props.item.value6 }">{{ $props.item.value6 }}</div>
<div class="inline-tag" :class="{ empty: !$props.item.value7 }">{{ $props.item.value7 }}</div>
<div class="inline-tag" :class="{ empty: !$props.item.value8 }">{{ $props.item.value8 }}</div>
<div class="inline-tag" :class="{ empty: !$props.item.value9 }">{{ $props.item.value9 }}</div>
<div class="inline-tag" :class="{ empty: !$props.item.value10 }">{{ $props.item.value10 }}</div>
<div
class="inline-tag"
:class="{ empty: !$props.item.value5 }"
:title="$props.item.tag5 + ': ' + $props.item.value5"
>
{{ $props.item.value5 }}
</div>
<div
class="inline-tag"
:class="{ empty: !$props.item.tag6 }"
:title="$props.item.tag6 + ': ' + $props.item.value6"
>
{{ $props.item.value6 }}
</div>
<div
class="inline-tag"
:class="{ empty: !$props.item.value7 }"
:title="$props.item.tag7 + ': ' + $props.item.value7"
>
{{ $props.item.value7 }}
</div>
<div
class="inline-tag"
:class="{ empty: !$props.item.value8 }"
:title="$props.item.tag8 + ': ' + $props.item.value8"
>
{{ $props.item.value8 }}
</div>
<div
class="inline-tag"
:class="{ empty: !$props.item.value9 }"
:title="$props.item.tag9 + ': ' + $props.item.value9"
>
{{ $props.item.value9 }}
</div>
<div
class="inline-tag"
:class="{ empty: !$props.item.value10 }"
:title="$props.item.tag10 + ': ' + $props.item.value10"
>
{{ $props.item.value10 }}
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,168 @@
<script setup>
import { computed } from 'vue';
import { useQuasar } from 'quasar';
import '@quasar/quasar-ui-qcalendar/src/QCalendarVariables.sass';
const $props = defineProps({
bordered: {
type: Boolean,
default: false,
},
transparentBackground: {
type: Boolean,
default: false,
},
viewCustomization: {
type: String,
default: '',
},
});
const $q = useQuasar();
// El objetivo de asignar las clases de personalización desde el wrapper es no tener conflictos entre vistas que usen el mismo componente
const viewCustomizationClasses = {
workerCalendar: 'worker-calendar-customizations',
};
const containerClasses = computed(() => {
const classes = ['main-container-background'];
if (viewCustomizationClasses[$props.viewCustomization])
classes.push(viewCustomizationClasses[$props.viewCustomization]);
if ($props.bordered) classes.push('--bordered');
if ($props.transparentBackground) classes.push('transparent-background');
else classes.push($q.dark.isActive ? '--dark' : '--light');
return classes;
});
</script>
<template>
<div :class="containerClasses">
<div class="nav-container row"><slot name="header" /></div>
<slot name="calendar" />
</div>
</template>
<style lang="scss">
@import '../../css/quasar.variables.scss';
:root {
// Cambia los colores del día actual del calendario por los de salix
--calendar-border-current-dark: #84d0e2 2px solid;
--calendar-border-current: #84d0e2 2px solid;
--calendar-current-color-dark: #84d0e2;
// Colores de fondo del calendario en dark mode
--calendar-outside-background-dark: #222;
--calendar-background-dark: #222;
}
// Clases para modificar el color de fecha seleccionada en componente QCalendarMonth
.q-dark div .q-calendar-mini .q-calendar-month__day.q-selected .q-calendar__button {
background-color: $primary !important;
color: white !important;
}
.q-calendar-mini .q-calendar-month__day.q-selected .q-calendar__button {
background-color: $primary !important;
color: white !important;
}
.q-calendar-month__head--weekday {
// Transforma los nombres de los días de la semana a mayúsculas
text-transform: capitalize;
}
.transparent-background {
--calendar-background-dark: transparent;
--calendar-background: transparent;
--calendar-outside-background-dark: transparent;
}
.q-calendar__button {
&:hover {
background-color: var(--vn-accent-color);
cursor: pointer;
}
}
.main-container-background {
--calendar-current-background-dark: transparent;
&.--dark {
background-color: var(--calendar-background-dark);
}
&.--light {
background-color: var(--calendar-background);
}
&.--bordered {
border: 1px solid #222;
}
}
.worker-calendar-customizations {
.q-calendar__button {
width: 32px;
height: 32px;
font-size: 13px;
&:hover {
background-color: var(--vn-accent-color);
cursor: pointer;
}
}
.q-calendar-month__week--days > div:nth-child(6),
.q-calendar-month__week--days > div:nth-child(7) {
// Cambia el color de los días sábado y domingo
color: #777777;
}
.q-calendar-month__week--wrapper {
margin-bottom: 4px;
}
.q-calendar-month__workweek {
height: 32px;
display: flex;
justify-content: center;
}
.q-calendar__button--bordered {
color: $info !important;
}
.q-calendar-month__day--content {
position: absolute;
top: 1;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}
.q-outside .calendar-event {
display: none;
}
.q-calendar-month__workweek,
.q-calendar-month__head--workweek,
.q-calendar-month__head--weekday.q-calendar__center.q-calendar__ellipsis {
text-transform: capitalize;
color: #777;
font-weight: bold;
font-size: 0.8rem;
text-align: center;
}
}
.nav-container {
display: flex;
align-items: center;
&.--bordered {
border: 1px solid black;
}
}
</style>

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

@ -10,8 +10,8 @@ const $props = defineProps({
size: { type: String, default: null },
title: { type: String, default: null },
});
const session = useSession();
const token = session.getToken();
const { getTokenMultimedia } = useSession();
const token = getTokenMultimedia();
const { t } = useI18n();
const title = computed(() => $props.title ?? t('globals.system'));

View File

@ -1,7 +1,8 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { onMounted, ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useArrayData } from 'composables/useArrayData';
import { useRoute } from 'vue-router';
import toDate from 'filters/toDate';
import VnFilterPanelChip from 'components/ui/VnFilterPanelChip.vue';
@ -52,6 +53,7 @@ const emit = defineEmits(['refresh', 'clear', 'search', 'init', 'remove']);
const arrayData = useArrayData(props.dataKey, {
exprBuilder: props.exprBuilder,
});
const route = useRoute();
const store = arrayData.store;
const userParams = ref({});
@ -63,6 +65,18 @@ onMounted(() => {
emit('init', { params: userParams.value });
});
watch(
() => route.query.params,
(val) => {
if (!val) {
userParams.value = {};
} else {
const parsedParams = JSON.parse(val);
userParams.value = { ...parsedParams };
}
}
);
const isLoading = ref(false);
async function search() {
isLoading.value = true;
@ -150,7 +164,7 @@ function formatValue(value) {
</script>
<template>
<QForm @submit="search">
<QForm @submit="search" id="filterPanelForm">
<QList dense>
<QItem class="q-mt-xs">
<QItemSection top>

View File

@ -15,7 +15,6 @@ const { t } = useI18n();
color="primary"
padding="none"
:href="`sip:${props.phoneNumber}`"
:title="t('globals.microsip')"
@click.stop
/>
</template>

View File

@ -1,8 +1,8 @@
<script setup>
import { computed } from 'vue';
import { dashIfEmpty } from 'src/filters';
import { useI18n } from 'vue-i18n';
import { useClipboard } from 'src/composables/useClipboard';
const $props = defineProps({
label: { type: String, default: null },
value: {
@ -13,8 +13,8 @@ const $props = defineProps({
dash: { type: Boolean, default: true },
copy: { type: Boolean, default: false },
});
const isBooleanValue = computed(() => typeof $props.value === 'boolean');
const { t } = useI18n();
const { copyText } = useClipboard();
function copyValueText() {
@ -40,36 +40,36 @@ function copyValueText() {
</slot>
</div>
<div class="value">
<span v-if="isBooleanValue">
<QIcon
:name="$props.value ? `check` : `close`"
:color="$props.value ? `positive` : `negative`"
size="sm"
/>
</span>
<slot v-else name="value">
<slot name="value">
<span :title="$props.value">
{{ $props.dash ? dashIfEmpty($props.value) : $props.value }}
</span>
</slot>
</div>
<div class="info" v-if="$props.info">
<QIcon name="info">
<QIcon name="info" class="cursor-pointer" size="xs" color="grey">
<QTooltip class="bg-dark text-white shadow-4" :offset="[10, 10]">
{{ $props.info }}
</QTooltip>
</QIcon>
</div>
<div class="copy" v-if="$props.copy && $props.value" @click="copyValueText()">
<QIcon name="Content_Copy" color="primary" />
<QIcon name="Content_Copy" color="primary">
<QTooltip>{{ t('globals.copyClipboard') }}</QTooltip>
</QIcon>
</div>
</div>
</template>
<style lang="scss" scoped>
.vn-label-value:hover .copy {
visibility: visible;
cursor: pointer;
}
.copy {
&:hover {
cursor: pointer;
}
visibility: hidden;
}
.info {
margin-left: 5px;
}
</style>

View File

@ -1,21 +1,23 @@
<script setup>
import VnAvatar from 'src/components/ui/VnAvatar.vue';
import { toDateHour } from 'src/filters';
import { toDateHourMin } 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';
import { useState } from 'src/composables/useState';
const $props = defineProps({
id: { type: String, required: true },
url: { type: String, default: null },
filter: { type: Object, default: () => {} },
body: { type: Object, default: () => {} },
addNote: { type: Boolean, default: false },
});
const { t } = useI18n();
const noteModal = ref(false);
const state = useState();
const currentUser = ref(state.getUser());
const newNote = ref('');
const vnPaginateRef = ref();
@ -23,100 +25,90 @@ async function insert() {
const body = $props.body;
Object.assign(body, { text: newNote.value });
await axios.post($props.url, body);
vnPaginateRef.value.fetch();
await vnPaginateRef.value.fetch();
newNote.value = '';
}
</script>
<template>
<div class="column items-center full-height">
<VnPaginate
:data-key="$props.url"
:url="$props.url"
order="created DESC"
:limit="20"
:filter="$props.filter"
auto-load
ref="vnPaginateRef"
>
<template #body="{ rows }">
<QCard class="q-pa-md q-mb-md" v-for="(note, index) in rows" :key="index">
<QCardSection horizontal>
<slot name="picture">
<VnAvatar :descriptor="false" :worker-id="note.workerFk" />
</slot>
<QItem class="full-width justify-between items-start">
<VnUserLink
:name="`${note.worker.firstName} ${note.worker.lastName}`"
:worker-id="note.worker.id"
/>
<slot name="actions">
{{ toDateHour(note.created) }}
</slot>
</QItem>
</QCardSection>
<QCardSection>
<slot name="text">
{{ note.text }}
</slot>
</QCardSection>
</QCard>
</template>
</VnPaginate>
<QPageSticky position="bottom-right" :offset="[25, 25]">
<QBtn
v-if="addNote"
color="primary"
icon="add"
<QCard class="q-pa-xs q-mb-xl full-width" v-if="$props.addNote">
<QCardSection horizontal>
<VnAvatar :worker-id="currentUser.id" size="md" />
<div class="full-width row justify-between q-pa-xs">
<VnUserLink :name="t('New note')" :worker-id="currentUser.id" />
{{ t('globals.now') }}
</div>
</QCardSection>
<QCardSection class="q-pa-xs q-my-none q-py-none" horizontal>
<QInput
v-model="newNote"
class="full-width"
type="textarea"
:label="t('Add note here...')"
filled
size="lg"
round
@click="noteModal = true"
/>
</QPageSticky>
<QDialog v-model="noteModal" @hide="newNote = ''">
<QCard>
<QCardSection>
<QItem class="q-px-none">
<span class="text-primary text-h6 full-width">
<QIcon name="draft" class="q-mr-xs" />
{{ t('Add note') }}
</span>
<QBtn icon="close" flat round dense v-close-popup />
</QItem>
</QCardSection>
<QCardSection>
<QInput
autofocus
type="textarea"
:label="t('Add note here...')"
filled
size="lg"
autogrow
v-model="newNote"
></QInput>
</QCardSection>
<QCardActions class="justify-end q-mr-sm">
<QBtn
autogrow
autofocus
@keyup.ctrl.enter.stop="insert"
clearable
>
<template #append
><QBtn
:title="t('Save (ctrl + Enter)')"
icon="save"
color="primary"
flat
:label="t('globals.close')"
color="primary"
v-close-popup
/>
<QBtn
:label="t('globals.save')"
color="primary"
v-close-popup
@click="insert"
/>
</QCardActions>
</QCard>
</QDialog>
</div>
</template>
</QInput>
</QCardSection>
</QCard>
<VnPaginate
:data-key="$props.url"
:url="$props.url"
order="created DESC"
:limit="0"
:filter="$props.filter"
auto-load
ref="vnPaginateRef"
class="show"
v-bind="$attrs"
>
<template #body="{ rows }">
<TransitionGroup name="list" tag="div" class="column items-center full-width">
<QCard
class="q-pa-xs q-mb-sm full-width"
v-for="(note, index) in rows"
:key="note.id ?? index"
>
<QCardSection horizontal>
<VnAvatar
:descriptor="false"
:worker-id="note.workerFk"
size="md"
/>
<div class="full-width row justify-between q-pa-xs">
<VnUserLink
:name="`${note.worker.user.nickname}`"
:worker-id="note.worker.id"
/>
{{ toDateHourMin(note.created) }}
</div>
</QCardSection>
<QCardSection class="q-pa-xs q-my-none q-py-none">
{{ note.text }}
</QCardSection>
</QCard>
</TransitionGroup>
</template>
</VnPaginate>
</template>
<style lang="scss" scoped>
.q-card {
max-width: 80em;
width: 90%;
@media (max-width: $breakpoint-sm) {
width: 100%;
}
&__section {
word-wrap: break-word;
}
@ -124,9 +116,20 @@ async function insert() {
.q-dialog .q-card {
width: 400px;
}
.list-enter-active,
.list-leave-active {
transition: all 1s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
background-color: $primary;
}
</style>
<i18n>
es:
Add note here...: Añadir nota aquí...
Add note: Añadir nota
New note: Nueva nota
Save (ctrl + Enter): Guardar (Ctrl + Intro)
</i18n>

View File

@ -44,7 +44,7 @@ const props = defineProps({
},
offset: {
type: Number,
default: 500,
default: 0,
},
skeleton: {
type: Boolean,
@ -54,10 +54,13 @@ const props = defineProps({
type: Function,
default: null,
},
disableInfiniteScroll: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['onFetch', 'onPaginate']);
defineExpose({ fetch });
const isLoading = ref(false);
const pagination = ref({
sortBy: props.order,
@ -87,9 +90,13 @@ watch(
}
);
const addFilter = async (filter, params) => {
await arrayData.addFilter({ filter, params });
};
async function fetch() {
await arrayData.fetch({ append: false });
if (!arrayData.hasMoreData.value) {
if (!store.hasMoreData) {
isLoading.value = false;
}
emit('onFetch', store.data);
@ -102,11 +109,10 @@ async function paginate() {
isLoading.value = true;
await arrayData.loadMore();
if (!arrayData.hasMoreData.value) {
if (store.userParamsChanged) arrayData.hasMoreData.value = true;
if (!store.hasMoreData) {
if (store.userParamsChanged) store.hasMoreData = true;
store.userParamsChanged = false;
isLoading.value = false;
endPagination();
return;
}
@ -116,29 +122,32 @@ async function paginate() {
pagination.value.sortBy = sortBy;
pagination.value.descending = descending;
isLoading.value = false;
endPagination();
}
function endPagination() {
isLoading.value = false;
emit('onFetch', store.data);
emit('onPaginate');
}
async function onLoad(index, done) {
if (!store.data) return done();
async function onLoad(...params) {
if (!store.data) return;
const done = params[1];
if (store.data.length === 0 || !props.url) return done(false);
pagination.value.page = pagination.value.page + 1;
await paginate();
let isDone = false;
if (store.userParamsChanged) isDone = !arrayData.hasMoreData.value;
if (store.userParamsChanged) isDone = !store.hasMoreData;
done(isDone);
}
defineExpose({ fetch, addFilter });
</script>
<template>
<div>
<div class="full-width">
<div
v-if="!props.autoLoad && !store.data && !isLoading"
class="info-row q-pa-md text-center"
@ -174,13 +183,21 @@ async function onLoad(...params) {
v-if="store.data"
@load="onLoad"
:offset="offset"
class="full-width full-height"
class="full-width"
:disable="disableInfiniteScroll || !store.hasMoreData"
v-bind="$attrs"
>
<slot name="body" :rows="store.data"></slot>
<div v-if="isLoading" class="info-row q-pa-md text-center">
<QSpinner color="orange" size="md" />
</div>
</QInfiniteScroll>
<div
v-if="!isLoading && store.hasMoreData"
class="w-full flex justify-center q-mt-md"
>
<QBtn color="primary" :label="t('Load more data')" @click="paginate()" />
</div>
</template>
<style lang="scss" scoped>
@ -197,4 +214,5 @@ async function onLoad(...params) {
es:
No data to display: Sin datos que mostrar
No results found: No se han encontrado resultados
Load more data: Cargar más resultados
</i18n>

View File

@ -1,11 +1,17 @@
<template>
<div id="row">
<div class="vn-row q-gutter-md q-mb-md">
<slot></slot>
</div>
</template>
<style lang="scss" scopped>
.vn-row {
display: flex;
> * {
flex: 1;
}
}
@media screen and (max-width: 800px) {
#row {
.vn-row {
flex-direction: column;
}
}

View File

@ -1,11 +1,9 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import VnInput from 'src/components/common/VnInput.vue';
import { useArrayData } from 'composables/useArrayData';
import VnInput from 'src/components/common/VnInput.vue';
const quasar = useQuasar();
@ -68,9 +66,8 @@ const props = defineProps({
});
const router = useRouter();
const route = useRoute();
const arrayData = useArrayData(props.dataKey, { ...props });
const store = arrayData.store;
const { store } = arrayData;
const searchText = ref('');
onMounted(() => {
@ -81,8 +78,9 @@ onMounted(() => {
});
async function search() {
const staticParams = Object.entries(store.userParams)
.filter(([key, value]) => value && (props.staticParams || []).includes(key));
const staticParams = Object.entries(store.userParams).filter(
([key, value]) => value && (props.staticParams || []).includes(key)
);
await arrayData.applyFilter({
params: {
...Object.fromEntries(staticParams),
@ -91,23 +89,31 @@ async function search() {
});
if (!props.redirect) return;
if (props.customRouteRedirectName) {
router.push({
if (props.customRouteRedirectName)
return 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);
const { matched: matches } = router.currentRoute.value;
const { path } = matches.at(-1);
const [, moduleName] = path.split('/');
if (!store.data.length || store.data.length > 1)
return router.push({ path: `/${moduleName}/list` });
const targetId = store.data[0].id;
let targetUrl;
if (path.endsWith('/list')) targetUrl = path.replace('/list', `/${targetId}/summary`);
else if (path.includes(':id')) targetUrl = path.replace(':id', targetId);
await router.push({ path: targetUrl });
}
</script>
<template>
<QForm @submit="search">
<QForm @submit="search" id="searchbarForm">
<VnInput
id="searchbar"
v-model="searchText"
@ -117,16 +123,14 @@ async function search() {
autofocus
>
<template #prepend>
<QIcon name="search" v-if="!quasar.platform.is.mobile" />
<QIcon
v-if="!quasar.platform.is.mobile"
class="cursor-pointer"
name="search"
@click="search"
/>
</template>
<template #append>
<QIcon
v-if="searchText !== ''"
name="close"
@click="searchText = ''"
class="cursor-pointer"
/>
<QIcon
v-if="props.info && $q.screen.gt.xs"
name="info"
@ -155,11 +159,14 @@ async function search() {
.cursor-info {
cursor: help;
}
.body--light #searchbar {
#searchbar {
.q-field--standout.q-field--highlighted .q-field__control {
background-color: $grey-7;
color: #333;
background-color: white;
color: black;
.q-field__native,
.q-icon {
color: black !important;
}
}
}
</style>

View File

@ -1,11 +1,27 @@
<script setup>
import { onMounted, onUnmounted } from 'vue';
import { onMounted, onUnmounted, ref } from 'vue';
import { useStateStore } from 'stores/useStateStore';
const stateStore = useStateStore();
const actions = ref(null);
const data = ref(null);
const opts = { subtree: true, childList: true, attributes: true };
const hasContent = ref(false);
onMounted(() => {
stateStore.toggleSubToolbar();
actions.value = document.querySelector('#st-actions');
data.value = document.querySelector('#st-data');
if (!actions.value && !data.value) return;
// Check if there's content to display
const observer = new MutationObserver(
() =>
(hasContent.value =
actions.value.childNodes.length + data.value.childNodes.length)
);
if (actions.value) observer.observe(actions.value, opts);
if (data.value) observer.observe(data.value, opts);
});
onUnmounted(() => {
@ -14,7 +30,10 @@ onUnmounted(() => {
</script>
<template>
<QToolbar class="bg-vn-dark justify-end sticky">
<QToolbar
class="justify-end sticky"
v-show="hasContent || $slots['st-actions'] || $slots['st-data']"
>
<slot name="st-data">
<div id="st-data"></div>
</slot>
@ -24,6 +43,11 @@ onUnmounted(() => {
</slot>
</QToolbar>
</template>
<style lang="scss">
.q-toolbar {
background: var(--vn-section-color);
}
</style>
<style lang="scss" scoped>
.sticky {
position: sticky;

View File

@ -1,20 +1,21 @@
<script setup>
import { ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useState } from 'src/composables/useState';
import { useQuasar } from 'quasar';
import DepartmentDescriptorProxy from 'src/pages/Department/Card/DepartmentDescriptorProxy.vue';
import CreateDepartmentChild from '../CreateDepartmentChild.vue';
import axios from 'axios';
import useNotify from 'src/composables/useNotify.js';
import { useRouter } from 'vue-router';
const quasar = useQuasar();
const { t } = useI18n();
const router = useRouter();
const { notify } = useNotify();
const state = useState();
const router = useRouter();
const treeRef = ref(null);
const treeRef = ref();
const showCreateNodeFormVal = ref(false);
const creationNodeSelectedId = ref(null);
const expanded = ref([]);
@ -24,30 +25,35 @@ 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
fetchNodeLeaves(nodeKeysArray.at(-1));
}
state.set('Tree', nodeKeysArray);
};
const fetchNodeLeaves = async (nodeKey) => {
try {
const node = treeRef.value.getNodeByKey(nodeKey);
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 = [{}];
node.children = response.data.map((n) => {
const hasChildrens = n.sons > 0;
n.children = hasChildrens ? [{}] : null;
n.clickable = true;
return n;
});
}
state.set('Tree', node);
} catch (err) {
console.error('Error fetching department leaves');
console.error('Error fetching department leaves', err);
throw new Error();
}
};
@ -70,7 +76,7 @@ const removeNode = (node) => {
notify(t('department.departmentRemoved'), 'positive');
await fetchNodeLeaves(parentFk);
} catch (err) {
console.log('Error removing department');
console.error('Error removing department');
}
});
};
@ -84,10 +90,49 @@ const onNodeCreated = async () => {
await fetchNodeLeaves(creationNodeSelectedId.value);
};
const redirectToDepartmentSummary = (id) => {
if (!id) return;
router.push({ name: 'DepartmentSummary', params: { id } });
};
onMounted(async (n) => {
const tree = [...state.get('Tree'), 1];
const lastStateTree = state.get('TreeState');
if (tree) {
for (let n of tree) {
await fetchNodeLeaves(n);
}
expanded.value = tree;
if (lastStateTree) {
tree.push(lastStateTree);
await fetchNodeLeaves(lastStateTree);
}
}
setTimeout(() => {
if (lastStateTree) {
document.getElementById(lastStateTree).scrollIntoView();
}
}, 1000);
});
function handleEvent(type, event, node) {
const isParent = node.sons > 0;
const lastId = isParent ? node.id : node.parentFk;
switch (type) {
case 'path':
state.set('TreeState', lastId);
node.id && router.push({ path: `/department/department/${node.id}/summary` });
break;
case 'tab':
state.set('TreeState', lastId);
node.id &&
window.open(`#/department/department/${node.id}/summary`, '_blank');
break;
default:
node.id &&
router.push({ path: `#/department/department/${node.id}/summary` });
break;
}
}
</script>
<template>
@ -99,15 +144,27 @@ const redirectToDepartmentSummary = (id) => {
label-key="name"
v-model:expanded="expanded"
@update:expanded="onNodeExpanded($event)"
:default-expand-all="true"
>
<template #default-header="{ node }">
<div
class="row justify-between full-width q-pr-md cursor-pointer"
@click.stop="redirectToDepartmentSummary(node.id)"
:id="node.id"
class="qtr row justify-between full-width q-pr-md cursor-pointer"
>
<span class="text-uppercase">
{{ node.name }}
</span>
<div>
<span
@click="handleEvent('row', $event, node)"
class="cursor-pointer"
>
{{ node.name }}
<DepartmentDescriptorProxy :id="node.id" />
</span>
</div>
<div
@click.stop.exact="handleEvent('path', $event, node)"
@click.ctrl.stop="handleEvent('tab', $event, node)"
style="flex-grow: 1; width: 10px"
></div>
<div class="row justify-between" style="max-width: max-content">
<QIcon
v-if="node.id"
@ -149,6 +206,11 @@ const redirectToDepartmentSummary = (id) => {
</QCard>
</template>
<style lang="scss" scoped>
span {
color: $primary;
}
</style>
<i18n>
es:
Departments: Departamentos

View File

@ -1,5 +1,6 @@
<script setup>
import WorkerDescriptorProxy from 'src/pages/Worker/Card/WorkerDescriptorProxy.vue';
import CustomerDescriptorProxy from 'src/pages/Customer/Card/CustomerDescriptorProxy.vue';
import { useI18n } from 'vue-i18n';
const $props = defineProps({

View File

@ -1,11 +1,11 @@
import { useSession } from 'src/composables/useSession';
import { getUrl } from './getUrl';
const session = useSession();
const token = session.getToken();
const { getTokenMultimedia } = useSession();
const token = getTokenMultimedia();
export async function downloadFile(dmsId) {
export async function downloadFile(id, model = 'dms', urlPath = '/downloadFile', url) {
let appUrl = await getUrl('', 'lilium');
appUrl = appUrl.replace('/#/', '');
window.open(`${appUrl}/api/dms/${dmsId}/downloadFile?access_token=${token}`);
window.open(url ?? `${appUrl}/api/${model}/${id}${urlPath}?access_token=${token}`);
}

View File

@ -0,0 +1,6 @@
export const elementIsVisibleInViewport = (el) => {
const { top, left, bottom, right } = el.getBoundingClientRect();
const { innerHeight, innerWidth } = window;
return top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth;
};

View File

@ -1,5 +1,5 @@
import { onMounted, ref, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useRoute } from 'vue-router';
import axios from 'axios';
import { useArrayDataStore } from 'stores/useArrayDataStore';
import { buildFilter } from 'filters/filterPanel';
@ -9,13 +9,9 @@ const arrayDataStore = useArrayDataStore();
export function useArrayData(key, userOptions) {
if (!key) throw new Error('ArrayData: A key is required to use this composable');
if (!arrayDataStore.get(key)) {
arrayDataStore.set(key);
}
if (!arrayDataStore.get(key)) arrayDataStore.set(key);
const store = arrayDataStore.get(key);
const hasMoreData = ref(false);
const router = useRouter();
const route = useRoute();
let canceller = null;
@ -23,6 +19,7 @@ export function useArrayData(key, userOptions) {
onMounted(() => {
setOptions();
store.skip = 0;
const query = route.query;
if (query.params) {
@ -30,9 +27,7 @@ export function useArrayData(key, userOptions) {
}
});
if (key && userOptions) {
setOptions();
}
if (key && userOptions) setOptions();
function setOptions() {
const allowedOptions = [
@ -97,15 +92,15 @@ export function useArrayData(key, userOptions) {
});
const { limit } = filter;
hasMoreData.value = response.data.length === limit;
store.hasMoreData = limit && response.data.length >= limit;
if (append) {
if (!store.data) store.data = [];
for (const row of response.data) store.data.push(row);
} else {
store.data = response.data;
updateRouter && updateStateParams();
if (!document.querySelectorAll('[role="dialog"]').length)
updateRouter && updateStateParams();
}
store.isLoading = false;
@ -144,6 +139,8 @@ export function useArrayData(key, userOptions) {
store.userParams = userParams;
store.skip = 0;
store.filter.skip = 0;
page.value = 1;
await fetch({ append: false });
return { filter, params };
}
@ -154,9 +151,10 @@ export function useArrayData(key, userOptions) {
delete store.userParams[param];
delete params[param];
if (store.filter?.where) {
delete store.filter.where[
Object.keys(exprBuilder ? exprBuilder(param) : param)[0]
];
const key = Object.keys(
exprBuilder && exprBuilder(param) ? exprBuilder(param) : param
);
if (key[0]) delete store.filter.where[key[0]];
if (Object.keys(store.filter.where).length === 0) {
delete store.filter.where;
}
@ -167,7 +165,7 @@ export function useArrayData(key, userOptions) {
}
async function loadMore() {
if (!hasMoreData.value) return;
if (!store.hasMoreData) return;
store.skip = store.limit * page.value;
page.value += 1;
@ -187,11 +185,15 @@ export function useArrayData(key, userOptions) {
if (store.userParams && Object.keys(store.userParams).length !== 0)
query.params = JSON.stringify(store.userParams);
if (router)
router.replace({
path: route.path,
query: query,
});
const url = new URL(window.location.href);
const { hash: currentHash } = url;
const [currentRoute] = currentHash.split('?');
const params = new URLSearchParams();
for (const param in query) params.append(param, query[param]);
url.hash = currentRoute + '?' + params.toString();
window.history.pushState({}, '', url.hash);
}
const totalRows = computed(() => (store.data && store.data.length) || 0);
@ -205,7 +207,6 @@ export function useArrayData(key, userOptions) {
destroy,
loadMore,
store,
hasMoreData,
totalRows,
updateStateParams,
isLoading,

View File

@ -0,0 +1,7 @@
import { useQuasar } from 'quasar';
export default function() {
const quasar = useQuasar();
return quasar.screen.gt.xs ? 'q-pa-md': 'q-pa-xs';
}

View File

@ -4,7 +4,7 @@ import { useQuasar } from 'quasar';
export function usePrintService() {
const quasar = useQuasar();
const { getToken } = useSession();
const { getTokenMultimedia } = useSession();
function sendEmail(path, params) {
return axios.post(path, params).then(() =>
@ -19,7 +19,7 @@ export function usePrintService() {
function openReport(path, params) {
params = Object.assign(
{
access_token: getToken(),
access_token: getTokenMultimedia(),
},
params
);

View File

@ -1,30 +1,54 @@
import { useState } from './useState';
import { useRole } from './useRole';
import { useUserConfig } from './useUserConfig';
import axios from 'axios';
import useNotify from './useNotify';
const TOKEN_MULTIMEDIA = 'tokenMultimedia';
const TOKEN = 'token';
export function useSession() {
const { notify } = useNotify();
function getToken() {
const localToken = localStorage.getItem('token');
const sessionToken = sessionStorage.getItem('token');
const localToken = localStorage.getItem(TOKEN);
const sessionToken = sessionStorage.getItem(TOKEN);
return localToken || sessionToken || '';
}
function getTokenMultimedia() {
const localTokenMultimedia = localStorage.getItem(TOKEN_MULTIMEDIA);
const sessionTokenMultimedia = sessionStorage.getItem(TOKEN_MULTIMEDIA);
function setToken(data) {
if (data.keepLogin) {
localStorage.setItem('token', data.token);
} else {
sessionStorage.setItem('token', data.token);
}
return localTokenMultimedia || sessionTokenMultimedia || '';
}
function destroy() {
if (localStorage.getItem('token'))
localStorage.removeItem('token')
if (sessionStorage.getItem('token'))
sessionStorage.removeItem('token');
function setToken(data) {
const storage = data.keepLogin ? localStorage : sessionStorage;
storage.setItem(TOKEN, data.token);
storage.setItem(TOKEN_MULTIMEDIA, data.tokenMultimedia);
}
async function destroyToken(url, storage, key) {
if (storage.getItem(key)) {
try {
await axios.post(url, null, {
headers: { Authorization: storage.getItem(key) },
});
} catch (error) {
notify('errors.statusUnauthorized', 'negative');
} finally {
storage.removeItem(key);
}
}
}
async function destroy() {
const tokens = {
tokenMultimedia: 'Accounts/logout',
token: 'VnUsers/logout',
};
for (const [key, url] of Object.entries(tokens)) {
await destroyToken(url, localStorage, key);
await destroyToken(url, sessionStorage, key);
}
const { setUser } = useState();
@ -37,22 +61,23 @@ export function useSession() {
});
}
async function login(token, keepLogin) {
setToken({ token, keepLogin });
async function login(token, tokenMultimedia, keepLogin) {
setToken({ token, tokenMultimedia, keepLogin });
await useRole().fetch();
await useUserConfig().fetch();
}
function isLoggedIn() {
const localToken = localStorage.getItem('token');
const sessionToken = sessionStorage.getItem('token');
const localToken = localStorage.getItem(TOKEN);
const sessionToken = sessionStorage.getItem(TOKEN);
return !!(localToken || sessionToken);
}
return {
getToken,
getTokenMultimedia,
setToken,
destroy,
login,

View File

@ -0,0 +1,23 @@
import VnConfirm from 'components/ui/VnConfirm.vue';
import { useQuasar } from 'quasar';
export function useVnConfirm() {
const quasar = useQuasar();
const openConfirmationModal = (title, message, promise, successFn) => {
quasar
.dialog({
component: VnConfirm,
componentProps: {
title: title,
message: message,
promise: promise,
},
})
.onOk(async () => {
if (successFn) successFn();
});
};
return { openConfirmationModal };
}

View File

@ -1,78 +1,132 @@
// app global css in SCSS form
@import './icons.scss';
@import '@quasar/quasar-ui-qcalendar/src/QCalendarMonth.sass';
body.body--light {
--font-color: black;
--vn-section-color: #e0e0e0;
--vn-page-color: #ffffff;
--vn-text-color: var(--font-color);
--vn-label-color: #5f5f5f;
--vn-accent-color: #e7e3e3;
background-color: var(--vn-page-color);
.q-header .q-toolbar {
color: var(--font-color);
}
}
body.body--dark {
--vn-page-color: #222;
--vn-section-color: #3d3d3d;
--vn-text-color: white;
--vn-label-color: #a8a8a8;
--vn-accent-color: #424242;
background-color: var(--vn-page-color);
}
a {
text-decoration: none;
}
.link {
color: $primary;
color: $color-link;
cursor: pointer;
}
.tx-color-link {
color: $color-link !important;
}
.tx-color-font {
color: $color-link !important;
}
.header-link {
color: $color-link !important;
cursor: pointer;
border-bottom: solid $primary;
border-width: 2px;
width: 100%;
.q-icon {
float: right;
}
text-transform: uppercase;
}
.link:hover {
color: $orange-4;
text-decoration: underline;
}
// Removes chrome autofill background
input:-webkit-autofill,
select:-webkit-autofill {
color: var(--vn-text);
color: var(--vn-text-color);
font-family: $typography-font-family;
-webkit-text-fill-color: var(--vn-text);
-webkit-text-fill-color: var(--vn-text-color);
-webkit-background-clip: text !important;
background-clip: text !important;
}
body.body--light {
.q-header .q-toolbar {
background-color: $white;
color: #555;
}
--vn-text: #000000;
--vn-gray: #f5f5f5;
--vn-label: #5f5f5f;
--vn-dark: white;
--vn-light-gray: #e7e3e3;
.bg-vn-section-color {
background-color: var(--vn-section-color);
}
.bg-hover {
background-color: #666666;
}
body.body--dark {
--vn-text: #ffffff;
--vn-gray: #313131;
--vn-label: #a8a8a8;
--vn-dark: #292929;
--vn-light-gray: #424242;
}
.bg-vn-dark {
background-color: var(--vn-dark);
.color-vn-label {
color: var(--vn-label);
}
.color-vn-text {
color: var(--vn-text);
color: var(--vn-text-color);
}
.color-vn-white {
color: $white;
}
.card-width {
max-width: 800px;
width: 100%;
}
.vn-card {
background-color: var(--vn-gray);
color: var(--vn-text);
background-color: var(--vn-section-color);
color: var(--vn-text-color);
border-radius: 8px;
}
.card-width {
width: 770px;
}
.vn-card-list {
width: 100%;
max-width: 60em;
}
.bg-vn-primary-row {
background-color: var(--vn-dark);
background-color: var(--vn-section-color);
}
.bg-vn-secondary-row {
background-color: var(--vn-light-gray);
background-color: var(--vn-accent-color);
}
.fill-icon {
font-variation-settings: 'FILL' 1;
}
.fill-icon-on-hover:hover {
font-variation-settings: 'FILL' 1;
cursor: pointer;
}
.vn-table-separation-row {
height: 16px !important;
background-color: var(--vn-section-color) !important;
}
/* Estilo para el asterisco en campos requeridos */
@ -80,6 +134,50 @@ body.body--dark {
content: ' *';
}
.q-card,
.q-table,
.q-table__bottom,
.q-drawer {
background-color: var(--vn-section-color);
}
.q-checkbox {
& .q-checkbox__label {
color: var(--vn-text-color);
}
& .q-checkbox__inner {
color: var(--vn-label-color);
}
}
.tr-header {
color: var(--vn-label-color);
}
.q-chip,
.q-notification__message,
.q-notification__icon {
color: black;
}
.q-notification--standard.bg-negative {
background-color: #fa3939 !important;
}
.q-notification--standard.bg-positive {
background-color: #a3d131 !important;
}
.q-tooltip {
background-color: var(--vn-page-color);
color: var(--font-color);
font-size: medium;
}
.q-card__actions {
justify-content: center;
}
/* q-notification row items-stretch q-notification--standard bg-negative text-white */
input[type='number'] {
-moz-appearance: textfield;
}
@ -89,3 +187,7 @@ input::-webkit-inner-spin-button {
-webkit-appearance: none;
-moz-appearance: none;
}
.q-scrollarea__content {
max-width: 100%;
}

Binary file not shown.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

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

View File

@ -11,25 +11,32 @@
// It's highly recommended to change the default colors
// to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
// Tip: to add new colors https://quasar.dev/style/color-palette/#adding-your-own-colors
$primary: #ec8916;
$secondary: #26a69a;
$accent: #9c27b0;
$white: #fff;
$positive: #21ba45;
$negative: #c10015;
$info: #31ccec;
$warning: #f2c037;
$vnColor: #8ebb27;
$secondary: $primary;
$positive: #c8e484;
$negative: #fb5252;
$info: #84d0e2;
$warning: #f4b974;
// Pendiente de cuadrar con la base de datos
$success: $positive;
$alert: $negative;
$white: #fff;
$dark: #3d3d3d;
// custom
$color-link: #66bfff;
$color-spacer-light: #a3a3a31f;
$color-spacer: #7979794d;
$border-thin-light: 1px solid $color-spacer-light;
$primary-light: lighten($primary, 35%);
$dark-shadow-color: black;
$layout-shadow-dark: 0 0 10px 2px #00000033, 0 0px 10px #0000003d;
$spacing-md: 16px;
.bg-success {
background-color: $positive;
}
.bg-notice {
background-color: $info;
}
@ -39,12 +46,3 @@ $alert: $negative;
.bg-alert {
background-color: $negative;
}
$color-spacer-light: rgba(255, 255, 255, 0.12);
$color-spacer: rgba(255, 255, 255, 0.3);
$border-thin-light: 1px solid $color-spacer-light;
$dark-shadow-color: #000;
$dark: #292929;
$layout-shadow-dark: 0 0 10px 2px rgba(0, 0, 0, 0.2), 0 0px 10px rgba(0, 0, 0, 0.24);
$spacing-md: 16px;

132
src/filters/date.js Normal file
View File

@ -0,0 +1,132 @@
/**
* Checks if a given date is valid.
*
* @param {number|string|Date} date - The date to be checked.
* @returns {boolean} True if the date is valid, false otherwise.
*
* @example
* // returns true
* isValidDate(new Date());
*
* @example
* // returns false
* isValidDate('invalid date');
*/
export function isValidDate(date) {
return date && !isNaN(new Date(date).getTime());
}
/**
* Converts a given date to a specific format.
*
* @param {number|string|Date} date - The date to be formatted.
* @returns {string} The formatted date as a string in 'dd/mm/yyyy' format. If the provided date is not valid, an empty string is returned.
*
* @example
* // returns "02/12/2022"
* toDateFormat(new Date(2022, 11, 2));
*/
export function toDateFormat(date, locale = 'es-ES') {
if (!isValidDate(date)) {
return '';
}
return new Date(date).toLocaleDateString(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
/**
* Converts a given date to a specific time format.
*
* @param {number|string|Date} date - The date to be formatted.
* @param {boolean} [showSeconds=false] - Whether to include seconds in the output format.
* @returns {string} The formatted time as a string in 'hh:mm:ss' format. If the provided date is not valid, an empty string is returned. If showSeconds is false, seconds are not included in the output format.
*
* @example
* // returns "00:00"
* toTimeFormat(new Date(2022, 11, 2));
*
* @example
* // returns "00:00:00"
* toTimeFormat(new Date(2022, 11, 2), true);
*/
export function toTimeFormat(date, showSeconds = false) {
if (!isValidDate(date)) {
return '';
}
return new Date(date).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit',
second: showSeconds ? '2-digit' : undefined,
});
}
/**
* Converts a given date to a specific date and time format.
*
* @param {number|string|Date} date - The date to be formatted.
* @param {boolean} [showSeconds=false] - Whether to include seconds in the output format.
* @returns {string} The formatted date as a string in 'dd/mm/yyyy, hh:mm:ss' format. If the provided date is not valid, an empty string is returned. If showSeconds is false, seconds are not included in the output format.
*
* @example
* // returns "02/12/2022, 00:00"
* toDateTimeFormat(new Date(2022, 11, 2));
*
* @example
* // returns "02/12/2022, 00:00:00"
* toDateTimeFormat(new Date(2022, 11, 2), true);
*/
export function toDateTimeFormat(date, showSeconds = false) {
if (!isValidDate(date)) {
return '';
}
return new Date(date).toLocaleDateString('es-ES', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: showSeconds ? '2-digit' : undefined,
});
}
/**
* Converts seconds to a formatted string representing hours and minutes (hh:mm).
* @param {number} seconds - The number of seconds to convert.
* @param {boolean} includeHSuffix - Optional parameter indicating whether to include "h." after the hour.
* @returns {string} A string representing the time in the format "hh:mm" with optional "h." suffix.
*/
export function secondsToHoursMinutes(seconds, includeHSuffix = true) {
if (!seconds) return includeHSuffix ? '00:00 h.' : '00:00';
const hours = Math.floor(seconds / 3600);
const remainingMinutes = seconds % 3600;
const minutes = Math.floor(remainingMinutes / 60);
const formattedHours = hours < 10 ? '0' + hours : hours;
const formattedMinutes = minutes < 10 ? '0' + minutes : minutes;
// Append "h." if includeHSuffix is true
const suffix = includeHSuffix ? ' h.' : '';
// Return formatted string
return formattedHours + ':' + formattedMinutes + suffix;
}
export function getTimeDifferenceWithToday(date) {
let today = Date.vnNew();
today.setHours(0, 0, 0, 0);
date = new Date(date);
date.setHours(0, 0, 0, 0);
return today - date;
}
export function isLower(date) {
return getTimeDifferenceWithToday(date) > 0;
}
export function isBigger(date) {
return getTimeDifferenceWithToday(date) < 0;
}

View File

@ -1,7 +1,8 @@
import toLowerCase from './toLowerCase';
import toDate from './toDate';
import toDateString from './toDateString';
import toDateHour from './toDateHour';
import toDateHourMin from './toDateHourMin';
import toDateHourMinSec from './toDateHourMinSec';
import toRelativeDate from './toRelativeDate';
import toCurrency from './toCurrency';
import toPercentage from './toPercentage';
@ -16,7 +17,8 @@ export {
toDate,
toHour,
toDateString,
toDateHour,
toDateHourMin,
toDateHourMinSec,
toRelativeDate,
toCurrency,
toPercentage,

View File

@ -0,0 +1,11 @@
export default function toDateHourMin(date) {
const dateHour = new Date(date).toLocaleDateString('es-ES', {
timeZone: 'Europe/Madrid',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
return dateHour;
}

View File

@ -1,4 +1,4 @@
export default function toDateHour(date) {
export default function toDateHourMinSec(date) {
const dateHour = new Date(date).toLocaleDateString('es-ES', {
timeZone: 'Europe/Madrid',
year: 'numeric',

View File

@ -1,9 +1,28 @@
import en from './en';
import es from './es';
export const localeEquivalence = {
'en':'en-GB'
const files = import.meta.glob(`./locale/*.yml`);
const modules = import.meta.glob(`../pages/**/locale/*.yml`);
const translations = {};
for (const file in files) {
const lang = file.split('/').at(2).split('.')[0];
files[file]()
.then((g) => {
translations[lang] = g.default;
})
.finally(() => {
const actualLang = lang + '.yml';
for (const module in modules) {
if (!module.endsWith(actualLang)) continue;
modules[module]().then((t) => {
Object.assign(translations[lang], t.default);
})
}
});
}
export default {
en: en,
es: es,
export const localeEquivalence = {
en: 'en-GB',
};
export default translations;

1233
src/i18n/locale/en.yml Normal file

File diff suppressed because it is too large Load Diff

1232
src/i18n/locale/es.yml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,5 +11,3 @@ const quasar = useQuasar();
<QFooter v-if="quasar.platform.is.mobile"></QFooter>
</QLayout>
</template>
<style lang="scss" scoped></style>

View File

@ -40,7 +40,7 @@ const langs = ['en', 'es'];
<template>
<QLayout view="hHh LpR fFf">
<QHeader reveal class="bg-dark">
<QHeader reveal class="bg-vn-section-color">
<QToolbar class="justify-end">
<QBtn
id="switchLanguage"

View File

@ -135,7 +135,7 @@ async function regularizeClaim() {
message: t('globals.dataSaved'),
type: 'positive',
});
await onUpdateGreugeAccept();
if (multiplicatorValue.value) await onUpdateGreugeAccept();
}
async function onUpdateGreugeAccept() {
@ -291,8 +291,6 @@ async function importToNewRefundTicket() {
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">

View File

@ -9,12 +9,13 @@ import VnRow from 'components/ui/VnRow.vue';
import VnInput from 'src/components/common/VnInput.vue';
import VnInputDate from 'components/common/VnInputDate.vue';
import axios from 'axios';
import { useSession } from 'src/composables/useSession';
const route = useRoute();
const { t } = useI18n();
const session = useSession();
const token = session.getToken();
const { getTokenMultimedia } = useSession();
const token = getTokenMultimedia();
const claimFilter = {
fields: [
@ -24,8 +25,7 @@ const claimFilter = {
'workerFk',
'claimStateFk',
'packages',
'rma',
'hasToPickUp',
'pickup',
],
include: [
{
@ -51,6 +51,20 @@ function setClaimStates(data) {
claimStates.value = data;
claimStatesCopy.value = data;
}
let optionsList;
async function getEnumValues() {
optionsList = [{ id: null, description: t('claim.basicData.null') }];
const { data } = await axios.get(`Applications/get-enum-values`, {
params: {
schema: 'vn',
table: 'claim',
column: 'pickup',
},
});
for (let value of data)
optionsList.push({ id: value, description: t(`claim.basicData.${value}`) });
}
getEnumValues();
const workerFilter = {
options: workers,
@ -170,19 +184,18 @@ const statesFilter = {
/>
</div>
<div class="col">
<VnInput
v-model="data.rma"
:label="t('claim.basicData.returnOfMaterial')"
:rules="validate('claim.rma')"
/>
</div>
</VnRow>
<VnRow class="row q-gutter-md q-mb-md">
<div class="col">
<QCheckbox
v-model="data.hasToPickUp"
:label="t('claim.basicData.picked')"
/>
<QSelect
v-model="data.pickup"
:options="optionsList"
option-value="id"
option-label="description"
emit-value
:label="t('claim.basicData.pickup')"
map-options
use-input
:input-debounce="0"
>
</QSelect>
</div>
</VnRow>
</template>

View File

@ -1,43 +1,15 @@
<script setup>
import LeftMenu from 'components/LeftMenu.vue';
import VnSearchbar from 'src/components/ui/VnSearchbar.vue';
import { useStateStore } from 'stores/useStateStore';
import { useI18n } from 'vue-i18n';
import VnCard from 'components/common/VnCard.vue';
import ClaimDescriptor from './ClaimDescriptor.vue';
import VnSubToolbar from 'src/components/ui/VnSubToolbar.vue';
const stateStore = useStateStore();
const { t } = useI18n();
</script>
<template>
<Teleport to="#searchbar" v-if="stateStore.isHeaderMounted()">
<VnSearchbar
data-key="ClaimList"
url="Claims/filter"
:label="t('Search claim')"
:info="t('You can search by claim id or customer name')"
/>
</Teleport>
<QDrawer v-model="stateStore.leftDrawer" show-if-above :width="256">
<QScrollArea class="fit">
<ClaimDescriptor />
<QSeparator />
<LeftMenu source="card" />
</QScrollArea>
</QDrawer>
<QPageContainer>
<QPage>
<VnSubToolbar />
<div class="q-pa-md"><RouterView></RouterView></div>
</QPage>
</QPageContainer>
<VnCard
data-key="Claim"
base-url="Claims"
:descriptor="ClaimDescriptor"
searchbar-data-key="ClaimList"
searchbar-url="Claims/filter"
searchbar-label="Search claim"
searchbar-info="You can search by claim id or customer name"
/>
</template>
<i18n>
es:
Search claim: Buscar reclamación
You can search by claim id or customer name: Puedes buscar por id de la reclamación o nombre del cliente
Details: Detalles
Notes: Notas
Action: Acción
</i18n>

View File

@ -1,5 +1,5 @@
<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, toPercentage } from 'src/filters';
@ -10,6 +10,7 @@ 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: {
@ -22,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;
});
@ -71,11 +72,10 @@ const filter = {
};
const STATE_COLOR = {
pending: 'positive',
managed: 'warning',
resolved: 'negative',
pending: 'warning',
managed: 'info',
resolved: 'positive',
};
function stateColor(code) {
return STATE_COLOR[code];
}
@ -85,6 +85,9 @@ const setData = (entity) => {
data.value = useCardDescription(entity.client.name, entity.id);
state.set('ClaimDescriptor', entity);
};
onMounted(async () => {
salixUrl.value = await getUrl('');
});
</script>
<template>
@ -102,21 +105,24 @@ const setData = (entity) => {
<ClaimDescriptorMenu :claim="entity" />
</template>
<template #body="{ entity }">
<VnLv :label="t('claim.card.created')" :value="toDate(entity.created)" />
<VnLv v-if="entity.claimState" :label="t('claim.card.state')">
<template #value>
<QBadge :color="stateColor(entity.claimState.code)" dense>
<QBadge
:color="stateColor(entity.claimState.code)"
text-color="black"
dense
>
{{ entity.claimState.description }}
</QBadge>
</template>
</VnLv>
<VnLv :label="t('claim.card.ticketId')">
<VnLv :label="t('claim.card.created')" :value="toDate(entity.created)" />
<VnLv :label="t('claim.card.commercial')">
<template #value>
<span class="link">
{{ entity.ticketFk }}
<TicketDescriptorProxy :id="entity.ticketFk" />
</span>
<VnUserLink
:name="entity.client?.salesPersonUser?.name"
:worker-id="entity.client?.salesPersonFk"
/>
</template>
</VnLv>
<VnLv
@ -131,19 +137,20 @@ const setData = (entity) => {
/>
</template>
</VnLv>
<VnLv :label="t('claim.card.commercial')">
<template #value>
<VnUserLink
:name="entity.client?.salesPersonUser?.name"
:worker-id="entity.client?.salesPersonFk"
/>
</template>
</VnLv>
<VnLv :label="t('claim.card.zone')" :value="entity.ticket?.zone?.name" />
<VnLv
:label="t('claim.card.province')"
:value="entity.ticket?.address?.province?.name"
/>
<VnLv :label="t('claim.card.zone')" :value="entity.ticket?.zone?.name" />
<VnLv :label="t('claim.card.ticketId')">
<template #value>
<span class="link">
{{ entity.ticketFk }}
<TicketDescriptorProxy :id="entity.ticketFk" />
</span>
</template>
</VnLv>
<VnLv
:label="t('claimRate')"
:value="toPercentage(entity.client?.claimsRatio?.claimingRate)"
@ -167,6 +174,22 @@ const setData = (entity) => {
>
<QTooltip>{{ t('claim.card.claimedTicket') }}</QTooltip>
</QBtn>
<QBtn
size="md"
icon="assignment"
color="primary"
:href="salixUrl + 'ticket/' + entity.ticketFk + '/sale-tracking'"
>
<QTooltip>{{ t('claim.card.saleTracking') }}</QTooltip>
</QBtn>
<QBtn
size="md"
icon="visibility"
color="primary"
:href="salixUrl + 'ticket/' + entity.ticketFk + '/tracking/index'"
>
<QTooltip>{{ t('claim.card.ticketTracking') }}</QTooltip>
</QBtn>
</QCardActions>
</template>
</CardDescriptor>

View File

@ -150,10 +150,8 @@ const columns = computed(() => [
<QTable
:columns="columns"
:rows="rows"
:pagination="{ rowsPerPage: 0 }"
row-key="$index"
selection="multiple"
hide-pagination
v-model:selected="selected"
:grid="$q.screen.lt.md"
table-header-class="text-left"

View File

@ -11,6 +11,7 @@ import CrudModel from 'components/CrudModel.vue';
import FetchData from 'components/FetchData.vue';
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();
@ -120,11 +121,6 @@ async function fetchMana() {
mana.value = response.data;
}
async function updateQuantity({ id, quantity }) {
if (!id) return;
await axios.patch(`ClaimBeginnings/${id}`, { quantity });
}
async function updateDiscount({ saleFk, discount, canceller }) {
const body = { salesIds: [saleFk], newDiscount: discount };
const claimId = claim.value.ticketFk;
@ -154,13 +150,17 @@ function showImportDialog() {
})
.onOk(() => claimLinesForm.value.reload());
}
function saveWhenHasChanges() {
claimLinesForm.value.getChanges().updates && claimLinesForm.value.onSubmit();
}
</script>
<template>
<Teleport to="#st-data" v-if="stateStore.isSubToolbarShown()">
<div class="row q-gutter-md">
<div>
{{ t('Amount') }}
<QChip :dense="$q.screen.lt.sm">
<QChip :dense="$q.screen.lt.sm" text-color="white">
{{ toCurrency(amount) }}
</QChip>
</div>
@ -180,156 +180,135 @@ function showImportDialog() {
@on-fetch="onFetchClaim"
auto-load
/>
<div class="column items-center">
<div class="list">
<CrudModel
data-key="ClaimLines"
ref="claimLinesForm"
:url="`Claims/${route.params.id}/lines`"
save-url="ClaimBeginnings/crud"
:filter="linesFilter"
@on-fetch="onFetch"
@save-changes="onFetch"
v-model:selected="selected"
:default-save="false"
:default-reset="false"
auto-load
>
<template #body="{ rows }">
<QTable
:columns="columns"
:rows="rows"
:dense="$q.screen.lt.md"
:pagination="{ rowsPerPage: 0 }"
row-key="id"
selection="multiple"
v-model:selected="selected"
hide-pagination
:grid="$q.screen.lt.md"
>
<template #body-cell-claimed="{ row, value }">
<QTd auto-width align="right" class="text-primary">
<span>{{ value }}</span>
<QPopupEdit
v-model="row.quantity"
v-slot="scope"
:title="t('Claimed quantity')"
@update:model-value="updateQuantity(row)"
buttons
>
<QInput
v-model="scope.value"
type="number"
dense
autofocus
@keyup.enter="scope.set"
@focus="($event) => $event.target.select()"
/>
</QPopupEdit>
</QTd>
</template>
<template #body-cell-discount="{ row, value, rowIndex }">
<QTd auto-width align="right" class="text-primary">
{{ value }}
<VnDiscount
:quantity="row.quantity"
:price="row.sale.price"
:discount="row.sale.discount"
:mana="mana"
:promise="updateDiscount"
:data="{ saleFk: row.sale.id, rowIndex: rowIndex }"
@on-update="onUpdateDiscount"
/>
</QTd>
</template>
<!-- View for grid mode -->
<template #item="props">
<div
class="q-mb-md col-12 grid-style-transition"
:style="props.selected ? 'transform: scale(0.95);' : ''"
>
<QCard>
<QCardSection>
<QCheckbox v-model="props.selected" />
</QCardSection>
<QSeparator inset />
<QList dense>
<QItem
v-for="column of props.cols"
:key="column.name"
>
<QItemSection>
<QItemLabel caption>
{{ column.label }}
<div class="q-pa-md">
<CrudModel
data-key="ClaimLines"
ref="claimLinesForm"
:url="`Claims/${route.params.id}/lines`"
save-url="ClaimBeginnings/crud"
:filter="linesFilter"
@on-fetch="onFetch"
@save-changes="onFetch"
v-model:selected="selected"
:default-save="false"
:default-reset="false"
auto-load
:limit="0"
>
<template #body="{ rows }">
<QTable
:columns="columns"
:rows="rows"
:dense="$q.screen.lt.md"
row-key="id"
selection="multiple"
v-model:selected="selected"
:grid="$q.screen.lt.md"
>
<template #body-cell-claimed="{ row }">
<QTd auto-width align="right" class="text-primary">
<QInput
v-model="row.quantity"
type="number"
dense
@keyup.enter="saveWhenHasChanges()"
@blur="saveWhenHasChanges()"
/>
</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 }}
<VnDiscount
:quantity="row.quantity"
:price="row.sale.price"
:discount="row.sale.discount"
:mana="mana"
:promise="updateDiscount"
:data="{ saleFk: row.sale.id, rowIndex: rowIndex }"
@on-update="onUpdateDiscount"
/>
</QTd>
</template>
<!-- View for grid mode -->
<template #item="props">
<div
class="q-mb-md col-12 grid-style-transition"
:style="props.selected ? 'transform: scale(0.95);' : ''"
>
<QCard>
<QCardSection>
<QCheckbox v-model="props.selected" />
</QCardSection>
<QSeparator inset />
<QList dense>
<QItem
v-for="column of props.cols"
:key="column.name"
>
<QItemSection>
<QItemLabel caption>
{{ column.label }}
</QItemLabel>
</QItemSection>
<QItemSection side>
<template v-if="column.name === 'claimed'">
<QItemLabel class="text-primary">
<QInput
v-model="props.row.quantity"
type="number"
dense
autofocus
@keyup.enter="
saveWhenHasChanges()
"
@blur="saveWhenHasChanges()"
/>
</QItemLabel>
</QItemSection>
<QItemSection side>
<template
v-if="column.name === 'claimed'"
>
<QItemLabel class="text-primary">
{{ column.value }}
<QPopupEdit
v-model="props.row.quantity"
v-slot="scope"
:title="t('Claimed quantity')"
@update:model-value="
updateQuantity(props.row)
"
buttons
>
<QInput
v-model="scope.value"
type="number"
dense
autofocus
@keyup.enter="scope.set"
@focus="
($event) =>
$event.target.select()
"
/>
</QPopupEdit>
</QItemLabel>
</template>
<template
v-else-if="column.name === 'discount'"
>
<QItemLabel class="text-primary">
{{ column.value }}
<VnDiscount
:quantity="props.row.quantity"
:price="props.row.sale.price"
:discount="
props.row.sale.discount
"
:mana="mana"
:promise="updateDiscount"
:data="{
saleFk: props.row.sale.id,
rowIndex: props.rowIndex,
}"
@on-update="onUpdateDiscount"
/>
</QItemLabel>
</template>
<template v-else>
<QItemLabel>
{{ column.value }}
</QItemLabel>
</template>
</QItemSection>
</QItem>
</QList>
</QCard>
</div>
</template>
</QTable>
</template>
</CrudModel>
</div>
</template>
<template
v-else-if="column.name === 'discount'"
>
<QItemLabel class="text-primary">
{{ column.value }}
<VnDiscount
:quantity="props.row.quantity"
:price="props.row.sale.price"
:discount="
props.row.sale.discount
"
:mana="mana"
:promise="updateDiscount"
:data="{
saleFk: props.row.sale.id,
rowIndex: props.rowIndex,
}"
@on-update="onUpdateDiscount"
/>
</QItemLabel>
</template>
<template v-else>
<QItemLabel>
{{ column.value }}
</QItemLabel>
</template>
</QItemSection>
</QItem>
</QList>
</QCard>
</div>
</template>
</QTable>
</template>
</CrudModel>
</div>
<QPageSticky position="bottom-right" :offset="[25, 25]">

View File

@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import FetchData from 'components/FetchData.vue';
import { toDate, toCurrency, toPercentage } from 'filters/index';
import ItemDescriptorProxy from 'src/pages/Item/Card/ItemDescriptorProxy.vue';
import axios from 'axios';
defineEmits([...useDialogPluginComponent.emits]);
@ -118,16 +119,21 @@ function cancel() {
<QBtn icon="close" flat round dense v-close-popup />
</QCardSection>
<QTable
class="my-sticky-header-table"
:columns="columns"
:rows="claimableSales"
:pagination="{ rowsPerPage: 10 }"
row-key="saleFk"
selection="multiple"
v-model:selected="selected"
square
flat
/>
>
<template #body-cell-description="{ row, value }">
<QTd auto-width align="right" class="link">
{{ value }}
<ItemDescriptorProxy :id="row.itemFk"></ItemDescriptorProxy>
</QTd>
</template>
</QTable>
<QSeparator />
<QCardActions align="right">
<QBtn :label="t('globals.cancel')" color="primary" flat @click="cancel" />
@ -149,33 +155,6 @@ function cancel() {
}
</style>
<style lang="scss">
.my-sticky-header-table {
height: 400px;
thead tr th {
position: sticky;
z-index: 1;
}
thead tr:first-child th {
/* this is when the loading indicator appears */
top: 0;
}
&.q-table--loading thead tr:last-child th {
/* height of all previous header rows */
top: 48px;
}
// /* prevent scrolling behind sticky top row on focus */
tbody {
/* height of all previous header rows */
scroll-margin-top: 48px;
}
}
</style>
<i18n>
es:
Available sales lines: Líneas de venta disponibles

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