Compare commits

..

No commits in common. "dev" and "7030-FixTranslations" have entirely different histories.

858 changed files with 39115 additions and 53756 deletions

View File

@ -1,4 +1,4 @@
export default { module.exports = {
// https://eslint.org/docs/user-guide/configuring#configuration-cascading-and-hierarchy // https://eslint.org/docs/user-guide/configuring#configuration-cascading-and-hierarchy
// This option interrupts the configuration hierarchy at this file // This option interrupts the configuration hierarchy at this file
// Remove this if you have an higher level ESLint config file (it usually happens into a monorepos) // Remove this if you have an higher level ESLint config file (it usually happens into a monorepos)
@ -58,7 +58,7 @@ export default {
rules: { rules: {
'prefer-promise-reject-errors': 'off', 'prefer-promise-reject-errors': 'off',
'no-unused-vars': 'warn', 'no-unused-vars': 'warn',
'vue/no-multiple-template-root': 'off', "vue/no-multiple-template-root": "off" ,
// allow debugger during development only // allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
}, },

8
.gitignore vendored
View File

@ -29,9 +29,5 @@ yarn-error.log*
*.sln *.sln
# Cypress directories and files # Cypress directories and files
/test/cypress/videos /tests/cypress/videos
/test/cypress/screenshots /tests/cypress/screenshots
# VitePress directories and files
/docs/.vitepress/cache
/docs/.vuepress

View File

@ -1,33 +0,0 @@
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join, resolve } from 'path';
function getCurrentBranchName(p = process.cwd()) {
if (!existsSync(p)) return false;
const gitHeadPath = join(p, '.git', 'HEAD');
if (!existsSync(gitHeadPath)) {
return getCurrentBranchName(resolve(p, '..'));
}
const headContent = readFileSync(gitHeadPath, 'utf-8');
return headContent.trim().split('/')[2];
}
const branchName = getCurrentBranchName();
if (branchName) {
const msgPath = '.git/COMMIT_EDITMSG';
const msg = readFileSync(msgPath, 'utf-8');
const reference = branchName.match(/^\d+/);
const referenceTag = `refs #${reference}`;
if (!msg.includes(referenceTag) && reference) {
const splitedMsg = msg.split(':');
if (splitedMsg.length > 1) {
const finalMsg = splitedMsg[0] + ': ' + referenceTag + splitedMsg.slice(1).join(':');
writeFileSync(msgPath, finalMsg);
}
}
}

View File

@ -1,8 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
echo "Running husky commit-msg hook"
npx --no-install commitlint --edit
echo "Adding reference tag to commit message"
node .husky/addReferenceTag.js

View File

@ -1,4 +1,4 @@
export default { module.exports = {
singleQuote: true, singleQuote: true,
printWidth: 90, printWidth: 90,
tabWidth: 4, tabWidth: 4,

View File

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

File diff suppressed because it is too large Load Diff

6
Jenkinsfile vendored
View File

@ -4,8 +4,7 @@ def PROTECTED_BRANCH
def BRANCH_ENV = [ def BRANCH_ENV = [
test: 'test', test: 'test',
master: 'production', master: 'production'
beta: 'production'
] ]
node { node {
@ -16,8 +15,7 @@ node {
PROTECTED_BRANCH = [ PROTECTED_BRANCH = [
'dev', 'dev',
'test', 'test',
'master', 'master'
'beta'
].contains(env.BRANCH_NAME) ].contains(env.BRANCH_NAME)
// https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables // https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables

View File

@ -1 +0,0 @@
export default { extends: ['@commitlint/config-conventional'] };

View File

@ -1,9 +1,6 @@
import { defineConfig } from 'cypress'; const { defineConfig } = require('cypress');
// https://docs.cypress.io/app/tooling/reporters
// https://docs.cypress.io/app/references/configuration
// https://www.npmjs.com/package/cypress-mochawesome-reporter
export default defineConfig({ module.exports = defineConfig({
e2e: { e2e: {
baseUrl: 'http://localhost:9000/', baseUrl: 'http://localhost:9000/',
experimentalStudio: true, experimentalStudio: true,
@ -11,32 +8,16 @@ export default defineConfig({
screenshotsFolder: 'test/cypress/screenshots', screenshotsFolder: 'test/cypress/screenshots',
supportFile: 'test/cypress/support/index.js', supportFile: 'test/cypress/support/index.js',
videosFolder: 'test/cypress/videos', videosFolder: 'test/cypress/videos',
downloadsFolder: 'test/cypress/downloads',
video: false, video: false,
specPattern: 'test/cypress/integration/**/*.spec.js', specPattern: 'test/cypress/integration/**/*.spec.js',
experimentalRunAllSpecs: true, experimentalRunAllSpecs: true,
watchForFileChanges: true,
reporter: 'cypress-mochawesome-reporter',
reporterOptions: {
charts: true,
reportPageTitle: 'Cypress Inline Reporter',
reportFilename: '[status]_[datetime]-report',
embeddedScreenshots: true,
reportDir: 'test/cypress/reports',
inlineAssets: true,
},
component: { component: {
componentFolder: 'src', componentFolder: 'src',
testFiles: '**/*.spec.js', testFiles: '**/*.spec.js',
supportFile: 'test/cypress/support/unit.js', supportFile: 'test/cypress/support/unit.js',
}, },
setupNodeEvents: async (on, config) => { setupNodeEvents(on, config) {
const plugin = await import('cypress-mochawesome-reporter/plugin'); // implement node event listeners here
plugin.default(on);
return config;
}, },
viewportWidth: 1280,
viewportHeight: 720,
}, },
}); });

View File

@ -1,38 +0,0 @@
import { defineConfig } from 'vitepress';
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: 'Lilium',
description: 'Lilium docs',
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
nav: [
{ text: 'Home', link: '/' },
{ text: 'Components', link: '/components/vnInput' },
{ text: 'Composables', link: '/composables/useArrayData' },
],
sidebar: [
{
items: [
{
text: 'Components',
collapsible: true,
collapsed: true,
items: [{ text: 'VnInput', link: '/components/vnInput' }],
},
{
text: 'Composables',
collapsible: true,
collapsed: true,
items: [
{ text: 'useArrayData', link: '/composables/useArrayData' },
],
},
],
},
],
socialLinks: [{ icon: 'github', link: 'https://github.com/vuejs/vitepress' }],
},
});

View File

@ -1,136 +0,0 @@
# VnInput
`VnInput` is a custom input component that provides various useful features such as validation, input clearing, and more.
## Props
### `modelValue`
- **Type:** `String | Number`
- **Default:** `null`
- **Description:** The value of the model bound to the component.
### `isOutlined`
- **Type:** `Boolean`
- **Default:** `false`
- **Description:** If `true`, the component is rendered with an outlined style.
### `info`
- **Type:** `String`
- **Default:** `''`
- **Description:** Additional information displayed alongside the component.
### `clearable`
- **Type:** `Boolean`
- **Default:** `true`
- **Description:** If `true`, the component shows a button to clear the input.
### `emptyToNull`
- **Type:** `Boolean`
- **Default:** `true`
- **Description:** If `true`, converts empty inputs to `null`.
### `insertable`
- **Type:** `Boolean`
- **Default:** `false`
- **Description:** If `true`, allows the insertion of new values.
### `maxlength`
- **Type:** `Number`
- **Default:** `null`
- **Description:** The maximum number of characters allowed in the input.
### `uppercase`
- **Type:** `Boolean`
- **Default:** `false`
- **Description:** If `true`, converts the input text to uppercase.
## Emits
### `update:modelValue`
- **Description:** Emits the updated model value.
- **Behavior:** This event is emitted whenever the input value changes. It is used to update the model value bound to the component.
### `update:options`
- **Description:** Emits the updated options.
- **Behavior:** This event is emitted when the component's options change. It is useful for components with dynamic options.
### `keyup.enter`
- **Description:** Emits an event when the Enter key is pressed.
- **Behavior:** This event is emitted whenever the Enter key is pressed while the input is focused. It can be used to handle specific actions when the input is confirmed.
### `remove`
- **Description:** Emits an event to remove the current value.
- **Behavior:** This event is emitted when the clear button (close icon) is clicked. It is used to handle the removal of the current input value.
## Functions
### `focus`
- **Description:** Focuses the input.
- **Behavior:** This function is exposed so it can be called from outside the component. It uses `vnInputRef.value.focus()` to focus the input.
### `handleKeydown`
- **Description:** Handles the `keydown` event of the input.
- **Behavior:** This function is called whenever a key is pressed while the input is focused. If the pressed key is `Backspace`, it does nothing. If `insertable` is `true` and the pressed key is a number, it calls `handleInsertMode`.
### `handleInsertMode`
- **Description:** Handles the insertion mode of values.
- **Behavior:** This function is called when `insertable` is `true` and a numeric key is pressed. It inserts the value at the cursor position and updates the input value. Then, it moves the cursor to the correct position.
### `handleUppercase`
- **Description:** Converts the input value to uppercase.
- **Behavior:** This function is called when the uppercase icon is clicked. It converts the current input value to uppercase.
## Usage
```vue
<template>
<VnInput
v-model="inputValue"
:isOutlined="true"
info="Additional information"
:clearable="true"
:emptyToNull="true"
:insertable="false"
:maxlength="50"
:uppercase="true"
@update:modelValue="handleUpdate"
@keyup.enter="handleEnter"
@remove="handleRemove"
/>
</template>
<script setup>
import { ref } from 'vue';
import VnInput from 'src/components/common/VnInput.vue';
const inputValue = ref('');
const handleUpdate = (value) => {
console.log('Updated value:', value);
};
const handleEnter = () => {
console.log('Enter pressed');
};
const handleRemove = () => {
console.log('Value removed');
};
</script>
```

View File

@ -1,215 +0,0 @@
# useArrayData
`useArrayData` is a composable function that provides a set of utilities for managing array data in a Vue component. It leverages Pinia for state management and provides various methods for fetching, filtering, and manipulating data.
## Usage
```javascript
import { useArrayData } from 'src/composables/useArrayData';
const {
fetch,
applyFilter,
addFilter,
getCurrentFilter,
setCurrentFilter,
addFilterWhere,
addOrder,
deleteOrder,
refresh,
destroy,
loadMore,
store,
totalRows,
updateStateParams,
isLoading,
deleteOption,
reset,
resetPagination,
} = useArrayData('myKey', userOptions);
```
## Parameters
### `key`
- **Type:** `String`
- **Description:** A unique key to identify the data store.
### `userOptions`
- **Type:** `Object`
- **Description:** An object containing user-defined options for configuring the data store.
## Methods
### `fetch`
Fetches data from the server.
#### Parameters
- **`options`** : An object with the following properties:
- `append` (Boolean): Whether to append the fetched data to the existing data.
- `updateRouter` (Boolean): Whether to update the router with the current filter.
#### Returns
- **`Promise`** : A promise that resolves with the fetched data.
### `applyFilter`
Applies a filter to the data.
#### Parameters
- **`filter`** : An object containing the filter criteria.
- **`params`** : Additional parameters for the filter.
- **`fetchOptions`** : Options for the fetch method.
#### Returns
- **`Promise`** : A promise that resolves with the filtered data.
### `addFilter`
Adds a filter to the existing filters.
#### Parameters
- **`filter`** : An object containing the filter criteria.
- **`params`** : Additional parameters for the filter.
#### Returns
- **`Promise`** : A promise that resolves with the updated filter and parameters.
### `getCurrentFilter`
Gets the current filter applied to the data.
#### Returns
- **`Object`** : The current filter and parameters.
### `setCurrentFilter`
Sets the current filter for the data.
#### Returns
- **`Object`** : The current filter and parameters.
### `addFilterWhere`
Adds a `where` clause to the existing filters.
#### Parameters
- **`where`** : An object containing the `where` clause.
#### Returns
- **`Promise`** : A promise that resolves when the filter is applied.
### `addOrder`
Adds an order to the existing orders.
#### Parameters
- **`field`** : The field to order by.
- **`direction`** : The direction of the order (`ASC` or `DESC`).
#### Returns
- **`Promise`** : A promise that resolves with the updated order.
### `deleteOrder`
Deletes an order from the existing orders.
#### Parameters
- **`field`** : The field to delete the order for.
#### Returns
- **`Promise`** : A promise that resolves when the order is deleted.
### `refresh`
Refreshes the data by re-fetching it from the server.
#### Returns
- **`Promise`** : A promise that resolves with the refreshed data.
### `destroy`
Destroys the data store for the given key.
### `loadMore`
Loads more data by incrementing the pagination.
#### Returns
- **`Promise`** : A promise that resolves with the additional data.
### `updateStateParams`
Updates the state parameters with the given data.
#### Parameters
- **`data`** : The data to update the state parameters with.
### `deleteOption`
Deletes an option from the store.
#### Parameters
- **`option`** : The option to delete.
### `reset`
Resets the store to its default state.
#### Parameters
- **`opts`** : An array of options to reset.
### `resetPagination`
Resets the pagination for the store.
## Computed Properties
### `totalRows`
- **Description:** The total number of rows in the data.
- **Type:** `Number`
### `isLoading`
- **Description:** Whether the data is currently being loaded.
- **Type:** `Boolean`
```vue
<script setup>
import { useArrayData } from 'src/composables/useArrayData';
const userOptions = {
url: '/api/data',
limit: 10,
};
const arrayData = useArrayData('myKey', userOptions);
</script>
```
```
```

View File

@ -1,13 +0,0 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: 'Lilium'
text: 'Lilium docs'
tagline: Powered by Verdnatura
actions:
- theme: brand
text: Docs
link: /components/vnInput
---

View File

@ -1,64 +1,49 @@
{ {
"name": "salix-front", "name": "salix-front",
"version": "25.08.0", "version": "24.30.1",
"description": "Salix frontend", "description": "Salix frontend",
"productName": "Salix", "productName": "Salix",
"author": "Verdnatura", "author": "Verdnatura",
"private": true, "private": true,
"packageManager": "pnpm@8.15.1", "packageManager": "pnpm@8.15.1",
"type": "module",
"scripts": { "scripts": {
"resetDatabase": "cd ../salix && gulp docker",
"lint": "eslint --ext .js,.vue ./", "lint": "eslint --ext .js,.vue ./",
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore", "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test:e2e": "cypress open", "test:e2e": "cypress open",
"test:e2e:ci": "npm run resetDatabase && cd ../salix-front && cypress run", "test:e2e:ci": "cd ../salix && gulp docker && cd ../salix-front && cypress run",
"test": "echo \"See package.json => scripts for available tests.\" && exit 0", "test": "echo \"See package.json => scripts for available tests.\" && exit 0",
"test:unit": "vitest", "test:unit": "vitest",
"test:unit:ci": "vitest run", "test:unit:ci": "vitest run"
"commitlint": "commitlint --edit",
"prepare": "npx husky install",
"addReferenceTag": "node .husky/addReferenceTag.js",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
}, },
"dependencies": { "dependencies": {
"@quasar/cli": "^2.4.1", "@quasar/cli": "^2.3.0",
"@quasar/extras": "^1.16.16", "@quasar/extras": "^1.16.9",
"axios": "^1.4.0", "axios": "^1.4.0",
"chromium": "^3.0.3", "chromium": "^3.0.3",
"croppie": "^2.6.5", "croppie": "^2.6.5",
"moment": "^2.30.1",
"pinia": "^2.1.3", "pinia": "^2.1.3",
"quasar": "^2.17.7", "quasar": "^2.14.5",
"validator": "^13.9.0", "validator": "^13.9.0",
"vue": "^3.5.13", "vue": "^3.3.4",
"vue-i18n": "^9.3.0", "vue-i18n": "^9.2.2",
"vue-router": "^4.2.5" "vue-router": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^19.2.1", "@intlify/unplugin-vue-i18n": "^0.8.1",
"@commitlint/config-conventional": "^19.1.0",
"@intlify/unplugin-vue-i18n": "^0.8.2",
"@pinia/testing": "^0.1.2", "@pinia/testing": "^0.1.2",
"@quasar/app-vite": "^2.0.8", "@quasar/app-vite": "^1.7.3",
"@quasar/quasar-app-extension-qcalendar": "^4.0.2", "@quasar/quasar-app-extension-qcalendar": "4.0.0-beta.15",
"@quasar/quasar-app-extension-testing-unit-vitest": "^0.4.0", "@quasar/quasar-app-extension-testing-unit-vitest": "^0.4.0",
"@vue/test-utils": "^2.4.4", "@vue/test-utils": "^2.4.4",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"cypress": "^13.6.6", "cypress": "^13.6.6",
"cypress-mochawesome-reporter": "^3.8.2", "eslint": "^8.41.0",
"eslint": "^9.18.0", "eslint-config-prettier": "^8.8.0",
"eslint-config-prettier": "^10.0.1", "eslint-plugin-cypress": "^2.13.3",
"eslint-plugin-cypress": "^4.1.0", "eslint-plugin-vue": "^9.14.1",
"eslint-plugin-vue": "^9.32.0",
"husky": "^8.0.0",
"postcss": "^8.4.23", "postcss": "^8.4.23",
"prettier": "^3.4.2", "prettier": "^2.8.8",
"sass": "^1.83.4", "vitest": "^0.31.1"
"vitepress": "^1.6.3",
"vitest": "^0.34.0"
}, },
"engines": { "engines": {
"node": "^20 || ^18 || ^16", "node": "^20 || ^18 || ^16",
@ -67,8 +52,8 @@
"bun": ">= 1.0.25" "bun": ">= 1.0.25"
}, },
"overrides": { "overrides": {
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.0.4",
"vite": "^6.0.11", "vite": "^5.1.4",
"vitest": "^0.31.1" "vitest": "^0.31.1"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,10 @@
/* eslint-disable */ /* eslint-disable */
// https://github.com/michael-ciniawsky/postcss-load-config // https://github.com/michael-ciniawsky/postcss-load-config
import autoprefixer from 'autoprefixer'; module.exports = {
// Uncomment the following line if you want to support RTL CSS
// import rtlcss from 'postcss-rtlcss';
export default {
plugins: [ plugins: [
// https://github.com/postcss/autoprefixer // https://github.com/postcss/autoprefixer
autoprefixer({ require('autoprefixer')({
overrideBrowserslist: [ overrideBrowserslist: [
'last 4 Chrome versions', 'last 4 Chrome versions',
'last 4 Firefox versions', 'last 4 Firefox versions',
@ -22,7 +18,10 @@ export default {
}), }),
// https://github.com/elchininet/postcss-rtlcss // https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL CSS, uncomment the following line: // If you want to support RTL css, then
// rtlcss(), // 1. yarn/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line:
// require('postcss-rtlcss')
], ],
}; };

View File

@ -1,6 +0,0 @@
export default [
{
path: '/api',
rule: { target: 'http://0.0.0.0:3000' },
},
];

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

View File

@ -8,11 +8,11 @@
// Configuration for your app // Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
import { configure } from 'quasar/wrappers'; const { configure } = require('quasar/wrappers');
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'; const VueI18nPlugin = require('@intlify/unplugin-vue-i18n/vite');
import path from 'path'; const path = require('path');
export default configure(function (/* ctx */) { module.exports = configure(function (/* ctx */) {
return { return {
eslint: { eslint: {
// fix: true, // fix: true,
@ -179,6 +179,7 @@ export default configure(function (/* ctx */) {
'render', // keep this as last one 'render', // keep this as last one
], ],
}, },
// https://v2.quasar.dev/quasar-cli/developing-pwa/configuring-pwa // https://v2.quasar.dev/quasar-cli/developing-pwa/configuring-pwa
pwa: { pwa: {
workboxMode: 'generateSW', // or 'injectManifest' workboxMode: 'generateSW', // or 'injectManifest'

View File

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

View File

@ -2,23 +2,18 @@ import axios from 'axios';
import { useSession } from 'src/composables/useSession'; import { useSession } from 'src/composables/useSession';
import { Router } from 'src/router'; import { Router } from 'src/router';
import useNotify from 'src/composables/useNotify.js'; import useNotify from 'src/composables/useNotify.js';
import { useStateQueryStore } from 'src/stores/useStateQueryStore';
import { getToken, isLoggedIn } from 'src/utils/session';
import { i18n } from 'src/boot/i18n';
const session = useSession();
const { notify } = useNotify(); const { notify } = useNotify();
const stateQuery = useStateQueryStore();
const baseUrl = '/api/'; axios.defaults.baseURL = '/api/';
axios.defaults.baseURL = baseUrl;
const axiosNoError = axios.create({ baseURL: baseUrl });
const onRequest = (config) => { const onRequest = (config) => {
const token = getToken(); const token = session.getToken();
if (token.length && !config.headers.Authorization) { if (token.length && !config.headers.Authorization) {
config.headers.Authorization = token; config.headers.Authorization = token;
config.headers['Accept-Language'] = i18n.global.locale.value;
} }
stateQuery.add(config);
return config; return config;
}; };
@ -27,34 +22,53 @@ const onRequestError = (error) => {
}; };
const onResponse = (response) => { const onResponse = (response) => {
const config = response.config; const { method } = response.config;
stateQuery.remove(config);
if (config.method === 'patch') { const isSaveRequest = method === 'patch';
if (isSaveRequest) {
notify('globals.dataSaved', 'positive'); notify('globals.dataSaved', 'positive');
} }
return response; return response;
}; };
const onResponseError = async (error) => { const onResponseError = (error) => {
stateQuery.remove(error.config); let message = '';
if (isLoggedIn() && error.response?.status === 401) { const response = error.response;
await useSession().destroy(false); const responseData = response && response.data;
const responseError = responseData && response.data.error;
if (responseError) {
message = responseError.message;
}
switch (response?.status) {
case 500:
message = 'errors.statusInternalServerError';
break;
case 502:
message = 'errors.statusBadGateway';
break;
case 504:
message = 'errors.statusGatewayTimeout';
break;
}
if (session.isLoggedIn() && response?.status === 401) {
session.destroy();
const hash = window.location.hash; const hash = window.location.hash;
const url = hash.slice(1); const url = hash.slice(1);
Router.push(`/login?redirect=${url}`); Router.push({ path: url });
} else if (!isLoggedIn()) { } else if (!session.isLoggedIn()) {
return Promise.reject(error); return Promise.reject(error);
} }
notify(message, 'negative');
return Promise.reject(error); return Promise.reject(error);
}; };
axios.interceptors.request.use(onRequest, onRequestError); axios.interceptors.request.use(onRequest, onRequestError);
axios.interceptors.response.use(onResponse, onResponseError); axios.interceptors.response.use(onResponse, onResponseError);
axiosNoError.interceptors.request.use(onRequest);
axiosNoError.interceptors.response.use(onResponse);
export { onRequest, onResponseError, axiosNoError }; export { onRequest, onResponseError };

View File

@ -1,4 +0,0 @@
import { QInput } from 'quasar';
import setDefault from './setDefault';
setDefault(QInput, 'dense', true);

View File

@ -1,4 +0,0 @@
import { QSelect } from 'quasar';
import setDefault from './setDefault';
setDefault(QSelect, 'dense', true);

View File

@ -1,11 +1,9 @@
import { boot } from 'quasar/wrappers'; import { boot } from 'quasar/wrappers';
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import messages from 'src/i18n'; import messages from 'src/i18n';
import { useState } from 'src/composables/useState';
const user = useState().getUser();
const i18n = createI18n({ const i18n = createI18n({
locale: user.value.lang || navigator.language || navigator.userLanguage, locale: navigator.language || navigator.userLanguage,
fallbackLocale: 'en', fallbackLocale: 'en',
globalInjection: true, globalInjection: true,
messages, messages,

View File

@ -1,33 +0,0 @@
export default {
mounted(el, binding) {
const shortcut = binding.value || '+';
const { key, ctrl, alt, callback } =
typeof shortcut === 'string'
? {
key: shortcut,
ctrl: true,
alt: true,
callback: () => el?.click(),
}
: binding.value;
if (!el.hasAttribute('shortcut')) {
el.setAttribute('shortcut', key);
}
const handleKeydown = (event) => {
if (event.key === key && (!ctrl || event.ctrlKey) && (!alt || event.altKey)) {
callback();
}
};
window.addEventListener('keydown', handleKeydown);
el._handleKeydown = handleKeydown;
},
unmounted(el) {
if (el._handleKeydown) {
window.removeEventListener('keydown', el._handleKeydown);
}
},
};

View File

@ -1,36 +0,0 @@
import routes from 'src/router/modules';
import { useRouter } from 'vue-router';
let isNotified = false;
export default {
created: function () {
const router = useRouter();
const keyBindingMap = routes
.filter((route) => route.meta.keyBinding)
.reduce((map, route) => {
map['Key' + route.meta.keyBinding.toUpperCase()] = route.path;
return map;
}, {});
const handleKeyDown = (event) => {
const { ctrlKey, altKey, code } = event;
if (ctrlKey && altKey && keyBindingMap[code] && !isNotified) {
event.preventDefault();
router.push(keyBindingMap[code]);
isNotified = true;
}
};
const handleKeyUp = (event) => {
const { ctrlKey, altKey } = event;
if (!ctrlKey || !altKey) {
isNotified = false;
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
},
};

View File

@ -1,37 +1,31 @@
function focusFirstInput(input) { import { getCurrentInstance } from 'vue';
input.focus();
} const filterAvailableInput = (element) => {
return element.classList.contains('q-field__native') && !element.disabled;
};
const filterAvailableText = (element) => {
return (
element.__vueParentComponent.type.name === 'QInput' &&
element.__vueParentComponent?.attrs?.class !== 'vn-input-date'
);
};
export default { export default {
mounted: function () { 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 availableInputs = elementsArray.filter(filterAvailableInput);
const firstInputElement = availableInputs.find(filterAvailableText);
if (firstInputElement) {
firstInputElement.focus();
}
const that = this; const that = this;
this.$el.addEventListener('keyup', function (evt) {
const form = document.querySelector('.q-form#formModel'); if (evt.key === 'Enter') {
if (!form) return;
try {
const inputsFormCard = form.querySelectorAll(
`input:not([disabled]):not([type="checkbox"])`
);
if (inputsFormCard.length) {
focusFirstInput(inputsFormCard[0]);
}
const textareas = document.querySelectorAll(
'textarea:not([disabled]), [contenteditable]:not([disabled])'
);
if (textareas.length) {
focusFirstInput(textareas[textareas.length - 1]);
}
const inputs = document.querySelectorAll(
'form#formModel input:not([disabled]):not([type="checkbox"])'
);
const input = inputs[0];
if (!input) return;
focusFirstInput(input);
} catch (error) {
console.error(error);
}
form.addEventListener('keyup', function (evt) {
if (evt.key === 'Enter' && !that.$attrs['prevent-submit']) {
const input = evt.target; const input = evt.target;
if (input.type == 'textarea' && evt.shiftKey) { if (input.type == 'textarea' && evt.shiftKey) {
evt.preventDefault(); evt.preventDefault();
@ -47,5 +41,7 @@ export default {
that.onSubmit(); that.onSubmit();
} }
}); });
}
}
}, },
}; };

View File

@ -1,3 +1 @@
export * from './defaults/qTable'; export * from './defaults/qTable';
export * from './defaults/qInput';
export * from './defaults/qSelect';

View File

@ -1,54 +1,6 @@
import axios from 'axios';
import { boot } from 'quasar/wrappers'; import { boot } from 'quasar/wrappers';
import qFormMixin from './qformMixin'; import qFormMixin from './qformMixin';
import keyShortcut from './keyShortcut';
import { QForm } from 'quasar';
import { QLayout } from 'quasar';
import mainShortcutMixin from './mainShortcutMixin';
import { useCau } from 'src/composables/useCau';
export default boot(({ app }) => { export default boot(({ app }) => {
QForm.mixins = [qFormMixin]; app.mixin(qFormMixin);
QLayout.mixins = [mainShortcutMixin];
app.directive('shortcut', keyShortcut);
app.config.errorHandler = async (error) => {
let message;
const response = error.response;
const responseData = response?.data;
const responseError = responseData && response.data.error;
if (responseError) {
message = responseError.message;
}
switch (response?.status) {
case 422:
if (error.name == 'ValidationError')
message +=
' "' +
responseError.details.context +
'.' +
Object.keys(responseError.details.codes).join(',') +
'"';
break;
case 500:
message = 'errors.statusInternalServerError';
break;
case 502:
message = 'errors.statusBadGateway';
break;
case 504:
message = 'errors.statusGatewayTimeout';
break;
}
console.error(error);
if (error instanceof axios.CanceledError) {
const env = process.env.NODE_ENV;
if (env && env !== 'development') return;
message = 'Duplicate request';
}
await useCau(response, message);
};
}); });

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { reactive, ref, onMounted, nextTick, computed } from 'vue'; import { reactive, ref, onMounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import VnInput from 'src/components/common/VnInput.vue'; import VnInput from 'src/components/common/VnInput.vue';
@ -7,29 +7,27 @@ import VnSelect from 'src/components/common/VnSelect.vue';
import FetchData from 'components/FetchData.vue'; import FetchData from 'components/FetchData.vue';
import VnRow from 'components/ui/VnRow.vue'; import VnRow from 'components/ui/VnRow.vue';
import FormModelPopup from './FormModelPopup.vue'; import FormModelPopup from './FormModelPopup.vue';
import { useState } from 'src/composables/useState';
defineProps({ showEntityField: { type: Boolean, default: true } });
const emit = defineEmits(['onDataSaved']); const emit = defineEmits(['onDataSaved']);
const { t } = useI18n(); const { t } = useI18n();
const bicInputRef = ref(null); const bicInputRef = ref(null);
const state = useState(); const bankEntityFormData = reactive({
name: null,
const customer = computed(() => state.get('Customer')); bic: null,
countryFk: null,
id: null,
});
const countriesFilter = { const countriesFilter = {
fields: ['id', 'name', 'code'], fields: ['id', 'name', 'code'],
}; };
const bankEntityFormData = reactive({
name: null,
bic: null,
countryFk: customer.value?.countryFk,
});
const countriesOptions = ref([]); const countriesOptions = ref([]);
const onDataSaved = (...args) => { const onDataSaved = (formData, requestResponse) => {
emit('onDataSaved', ...args); emit('onDataSaved', formData, requestResponse);
}; };
onMounted(async () => { onMounted(async () => {
@ -41,6 +39,7 @@ onMounted(async () => {
<template> <template>
<FetchData <FetchData
url="Countries" url="Countries"
:filter="countriesFilter"
auto-load auto-load
@on-fetch="(data) => (countriesOptions = data)" @on-fetch="(data) => (countriesOptions = data)"
/> />
@ -50,11 +49,10 @@ onMounted(async () => {
:title="t('title')" :title="t('title')"
:subtitle="t('subtitle')" :subtitle="t('subtitle')"
:form-initial-data="bankEntityFormData" :form-initial-data="bankEntityFormData"
:filter="countriesFilter"
@on-data-saved="onDataSaved" @on-data-saved="onDataSaved"
> >
<template #form-inputs="{ data, validate }"> <template #form-inputs="{ data, validate }">
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<VnInput <VnInput
:label="t('name')" :label="t('name')"
v-model="data.name" v-model="data.name"
@ -69,7 +67,7 @@ onMounted(async () => {
:rules="validate('bankEntity.bic')" :rules="validate('bankEntity.bic')"
/> />
</VnRow> </VnRow>
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<div class="col"> <div class="col">
<VnSelect <VnSelect
:label="t('country')" :label="t('country')"
@ -82,13 +80,7 @@ onMounted(async () => {
:rules="validate('bankEntity.countryFk')" :rules="validate('bankEntity.countryFk')"
/> />
</div> </div>
<div <div v-if="showEntityField" class="col">
v-if="
countriesOptions.find((c) => c.id === data.countryFk)?.code ==
'ES'
"
class="col"
>
<VnInput <VnInput
:label="t('id')" :label="t('id')"
v-model="data.id" v-model="data.id"

View File

@ -0,0 +1,166 @@
<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 VnSelect from 'src/components/common/VnSelect.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'],
where: { refFk: null },
order: 'shipped DESC',
}"
@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">
<VnSelect
: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>
</VnSelect>
<span class="row items-center" style="max-width: max-content">{{
t('Or')
}}</span>
<VnSelect
:label="t('Client')"
:options="clientsOptions"
hide-selected
option-label="name"
option-value="id"
v-model="data.clientFk"
@update:model-value="data.ticketFk = null"
/>
<VnInputDate :label="t('Max date')" v-model="data.maxShipped" />
</VnRow>
<VnRow class="row q-gutter-md q-mb-md">
<VnSelect
:label="t('Serial')"
:options="invoiceOutSerialsOptions"
hide-selected
option-label="description"
option-value="code"
v-model="data.serial"
:required="true"
/>
<VnSelect
:label="t('Area')"
:options="taxAreasOptions"
hide-selected
option-label="code"
option-value="code"
v-model="data.taxArea"
:required="true"
/>
</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

@ -1,62 +1,58 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue'; import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import FetchData from 'components/FetchData.vue';
import VnRow from 'components/ui/VnRow.vue'; import VnRow from 'components/ui/VnRow.vue';
import VnSelectProvince from 'components/VnSelectProvince.vue'; import VnSelect from 'src/components/common/VnSelect.vue';
import VnInput from 'components/common/VnInput.vue'; import VnInput from 'src/components/common/VnInput.vue';
import FormModelPopup from './FormModelPopup.vue'; import FormModelPopup from './FormModelPopup.vue';
const emit = defineEmits(['onDataSaved']); const emit = defineEmits(['onDataSaved']);
const $props = defineProps({
countryFk: {
type: Number,
default: null,
},
provinceSelected: {
type: Number,
default: null,
},
});
const { t } = useI18n(); const { t } = useI18n();
const cityFormData = ref({ const cityFormData = reactive({
name: null, name: null,
provinceFk: null, provinceFk: null,
}); });
onMounted(() => {
cityFormData.value.provinceFk = $props.provinceSelected; const provincesOptions = ref([]);
});
const onDataSaved = (...args) => { const onDataSaved = (dataSaved) => {
emit('onDataSaved', ...args); emit('onDataSaved', dataSaved);
}; };
</script> </script>
<template> <template>
<FetchData
@on-fetch="(data) => (provincesOptions = data)"
auto-load
url="Provinces"
/>
<FormModelPopup <FormModelPopup
:title="t('New city')" :title="t('New city')"
:subtitle="t('Please, ensure you put the correct data!')" :subtitle="t('Please, ensure you put the correct data!')"
:form-initial-data="cityFormData" :form-initial-data="cityFormData"
url-create="towns" url-create="towns"
model="city" model="city"
@on-data-saved="onDataSaved" @on-data-saved="onDataSaved($event)"
data-cy="newCityForm"
> >
<template #form-inputs="{ data, validate }"> <template #form-inputs="{ data, validate }">
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<VnInput <VnInput
:label="t('Name')" :label="t('Name')"
v-model="data.name" v-model="data.name"
:rules="validate('city.name')" :rules="validate('city.name')"
required
data-cy="cityName"
/> />
<VnSelectProvince <VnSelect
:province-selected="$props.provinceSelected" :label="t('Province')"
:country-fk="$props.countryFk" :options="provincesOptions"
hide-selected
option-label="name"
option-value="id"
v-model="data.provinceFk" v-model="data.provinceFk"
required :rules="validate('city.provinceFk')"
data-cy="provinceCity"
/> />
</VnRow> </VnRow>
</template> </template>

View File

@ -1,50 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import VnRow from 'components/ui/VnRow.vue';
import VnInput from 'src/components/common/VnInput.vue';
import FormModelPopup from './FormModelPopup.vue';
const emit = defineEmits(['onDataSaved']);
const { t } = useI18n();
</script>
<template>
<FormModelPopup
url-create="Expenses"
model="Expense"
:title="t('New expense')"
:form-initial-data="{ id: null, isWithheld: false, name: null }"
@on-data-saved="emit('onDataSaved', $event)"
>
<template #form-inputs="{ data, validate }">
<VnRow>
<VnInput
:label="`${t('globals.code')}`"
v-model="data.id"
:required="true"
:rules="validate('expense.code')"
/>
<QCheckbox
dense
size="sm"
:label="`${t('It\'s a withholding')}`"
v-model="data.isWithheld"
:rules="validate('expense.isWithheld')"
/>
</VnRow>
<VnRow>
<VnInput
:label="`${t('globals.description')}`"
v-model="data.name"
:required="true"
:rules="validate('expense.description')"
/>
</VnRow>
</template>
</FormModelPopup>
</template>
<i18n>
es:
New expense: Nuevo gasto
It's a withholding: Es una retención
</i18n>

View File

@ -5,9 +5,9 @@ import { useI18n } from 'vue-i18n';
import FetchData from 'components/FetchData.vue'; import FetchData from 'components/FetchData.vue';
import VnRow from 'components/ui/VnRow.vue'; import VnRow from 'components/ui/VnRow.vue';
import VnSelect from 'src/components/common/VnSelect.vue'; import VnSelect from 'src/components/common/VnSelect.vue';
import VnSelectProvince from 'src/components/VnSelectProvince.vue';
import VnInput from 'src/components/common/VnInput.vue'; import VnInput from 'src/components/common/VnInput.vue';
import CreateNewCityForm from './CreateNewCityForm.vue'; import CreateNewCityForm from './CreateNewCityForm.vue';
import CreateNewProvinceForm from './CreateNewProvinceForm.vue';
import VnSelectDialog from 'components/common/VnSelectDialog.vue'; import VnSelectDialog from 'components/common/VnSelectDialog.vue';
import FormModelPopup from './FormModelPopup.vue'; import FormModelPopup from './FormModelPopup.vue';
@ -21,196 +21,123 @@ const postcodeFormData = reactive({
provinceFk: null, provinceFk: null,
townFk: null, townFk: null,
}); });
const townsFetchDataRef = ref(false);
const townFilter = ref({});
const countriesRef = ref(false); const townsFetchDataRef = ref(null);
const provincesFetchDataRef = ref(null);
const countriesOptions = ref([]);
const provincesOptions = ref([]); const provincesOptions = ref([]);
const townsOptions = ref([]); const townsLocationOptions = ref([]);
const town = ref({});
const countryFilter = ref({});
function onDataSaved(formData) { const onDataSaved = (formData) => {
const newPostcode = { const newPostcode = {
...formData, ...formData,
}; };
newPostcode.town = town.value.name; const townObject = townsLocationOptions.value.find(
newPostcode.townFk = town.value.id; ({ id }) => id === formData.townFk
);
newPostcode.town = townObject?.name;
const provinceObject = provincesOptions.value.find( const provinceObject = provincesOptions.value.find(
({ id }) => id === formData.provinceFk ({ id }) => id === formData.provinceFk
); );
newPostcode.province = provinceObject?.name; newPostcode.province = provinceObject?.name;
const countryObject = countriesRef.value.opts.find( const countryObject = countriesOptions.value.find(
({ id }) => id === formData.countryFk ({ id }) => id === formData.countryFk
); );
newPostcode.country = countryObject?.name; newPostcode.country = countryObject?.country;
emit('onDataSaved', newPostcode); emit('onDataSaved', newPostcode);
}
async function setCountry(countryFk, data) {
data.townFk = null;
data.provinceFk = null;
data.countryFk = countryFk;
await fetchTowns();
}
// Province
async function setProvince(id, data) {
if (data.provinceFk === id) return;
const newProvince = provincesOptions.value.find((province) => province.id == id);
if (newProvince) data.countryFk = newProvince.countryFk;
postcodeFormData.provinceFk = id;
await fetchTowns();
}
async function onProvinceCreated(data) {
postcodeFormData.provinceFk = data.id;
}
function provinceByCountry(countryFk = postcodeFormData.countryFk) {
return provincesOptions.value
.filter((province) => province.countryFk === countryFk)
.map(({ id }) => id);
}
// Town
async function handleTowns(data) {
townsOptions.value = data;
}
function setTown(newTown, data) {
town.value = newTown;
data.provinceFk = newTown?.provinceFk ?? newTown;
data.countryFk = newTown?.province?.countryFk ?? newTown;
}
async function onCityCreated(newTown, formData) {
newTown.province = provincesOptions.value.find(
(province) => province.id === newTown.provinceFk
);
formData.townFk = newTown;
setTown(newTown, formData);
}
async function fetchTowns(countryFk = postcodeFormData.countryFk) {
if (!countryFk) return;
const provinces = postcodeFormData.provinceFk
? [postcodeFormData.provinceFk]
: provinceByCountry();
townFilter.value.where = {
provinceFk: {
inq: provinces,
},
}; };
await townsFetchDataRef.value?.fetch();
}
async function filterTowns(name) { const onCityCreated = async ({ name, provinceFk }, formData) => {
if (name !== '') { await townsFetchDataRef.value.fetch();
townFilter.value.where = { formData.townFk = townsLocationOptions.value.find((town) => town.name === name).id;
name: { formData.provinceFk = provinceFk;
like: `%${name}%`, formData.countryFk = provincesOptions.value.find(
}, (province) => province.id === provinceFk
).countryFk;
};
const onProvinceCreated = async ({ name }, formData) => {
await provincesFetchDataRef.value.fetch();
formData.provinceFk = provincesOptions.value.find(
(province) => province.name === name
).id;
}; };
await townsFetchDataRef.value?.fetch();
}
}
</script> </script>
<template> <template>
<FetchData <FetchData
ref="townsFetchDataRef" ref="townsFetchDataRef"
:sort-by="['name ASC']" @on-fetch="(data) => (townsLocationOptions = data)"
:limit="30"
:filter="townFilter"
@on-fetch="handleTowns"
auto-load auto-load
url="Towns/location" url="Towns/location"
/> />
<FetchData
ref="provincesFetchDataRef"
@on-fetch="(data) => (provincesOptions = data)"
auto-load
url="Provinces"
/>
<FetchData
@on-fetch="(data) => (countriesOptions = data)"
auto-load
url="Countries"
/>
<FormModelPopup <FormModelPopup
url-create="postcodes" url-create="postcodes"
model="postcode" model="postcode"
:title="t('New postcode')" :title="t('New postcode')"
:subtitle="t('Please, ensure you put the correct data!')" :subtitle="t('Please, ensure you put the correct data!')"
:form-initial-data="postcodeFormData" :form-initial-data="postcodeFormData"
:mapper="(data) => (data.townFk = data.townFk.id) && data"
@on-data-saved="onDataSaved" @on-data-saved="onDataSaved"
> >
<template #form-inputs="{ data, validate }"> <template #form-inputs="{ data, validate }">
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<VnInput <VnInput
:label="t('Postcode')" :label="t('Postcode')"
v-model="data.code" v-model="data.code"
:rules="validate('postcode.code')" :rules="validate('postcode.code')"
clearable
required
data-cy="locationPostcode"
/> />
<VnSelectDialog <VnSelectDialog
:label="t('City')" :label="t('City')"
@update:model-value="(value) => setTown(value, data)" :options="townsLocationOptions"
@filter="filterTowns"
:tooltip="t('Create city')"
v-model="data.townFk" v-model="data.townFk"
:options="townsOptions" hide-selected
option-label="name" option-label="name"
option-value="id" option-value="id"
:rules="validate('postcode.city')" :rules="validate('postcode.city')"
:acls="[{ model: 'Town', props: '*', accessType: 'WRITE' }]" :roles-allowed-to-create="['deliveryAssistant']"
:emit-value="false"
required
data-cy="locationTown"
> >
<template #option="{ itemProps, opt }">
<QItem v-bind="itemProps">
<QItemSection>
<QItemLabel>{{ opt.name }}</QItemLabel>
<QItemLabel caption>
{{ opt.province.name }},
{{ opt.province.country.name }}
</QItemLabel>
</QItemSection>
</QItem>
</template>
<template #form> <template #form>
<CreateNewCityForm <CreateNewCityForm @on-data-saved="onCityCreated($event, data)" />
:country-fk="data.countryFk"
:province-selected="data.provinceFk"
@on-data-saved="
(_, requestResponse) =>
onCityCreated(requestResponse, data)
"
/>
</template> </template>
</VnSelectDialog> </VnSelectDialog>
</VnRow> </VnRow>
<VnRow> <VnRow class="row q-gutter-md q-mb-xl">
<VnSelectProvince <VnSelectDialog
:country-fk="data.countryFk" :label="t('Province')"
:province-selected="data.provinceFk" :options="provincesOptions"
@update:model-value="(value) => setProvince(value, data)" hide-selected
@update:options=" option-label="name"
(data) => { option-value="id"
provincesOptions = data;
}
"
v-model="data.provinceFk" v-model="data.provinceFk"
@on-province-created="onProvinceCreated" :rules="validate('postcode.provinceFk')"
required :roles-allowed-to-create="['deliveryAssistant']"
>
<template #form>
<CreateNewProvinceForm
@on-data-saved="onProvinceCreated($event, data)"
/> />
<VnSelect </template> </VnSelectDialog
ref="countriesRef" ></VnRow>
:limit="30" <VnRow class="row q-gutter-md q-mb-xl"
:filter="countryFilter" ><VnSelect
:sort-by="['name ASC']"
auto-load
url="Countries"
required
:label="t('Country')" :label="t('Country')"
:options="countriesOptions"
hide-selected hide-selected
option-label="name" option-label="name"
option-value="id" option-value="id"
v-model="data.countryFk" v-model="data.countryFk"
:rules="validate('postcode.countryFk')" :rules="validate('postcode.countryFk')"
@update:model-value="(value) => setCountry(value, data)"
data-cy="locationCountry"
/> />
</VnRow> </VnRow>
</template> </template>
@ -220,7 +147,6 @@ async function filterTowns(name) {
<i18n> <i18n>
es: es:
New postcode: Nuevo código postal New postcode: Nuevo código postal
Create city: Crear población
Please, ensure you put the correct data!: ¡Por favor, asegúrese de poner los datos correctos! Please, ensure you put the correct data!: ¡Por favor, asegúrese de poner los datos correctos!
City: Población City: Población
Province: Provincia Province: Provincia

View File

@ -1,7 +1,8 @@
<script setup> <script setup>
import { computed, reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import FetchData from 'components/FetchData.vue';
import VnRow from 'components/ui/VnRow.vue'; import VnRow from 'components/ui/VnRow.vue';
import VnSelect from 'src/components/common/VnSelect.vue'; import VnSelect from 'src/components/common/VnSelect.vue';
import VnInput from 'src/components/common/VnInput.vue'; import VnInput from 'src/components/common/VnInput.vue';
@ -15,71 +16,44 @@ const provinceFormData = reactive({
name: null, name: null,
autonomyFk: null, autonomyFk: null,
}); });
const $props = defineProps({
countryFk: {
type: Number,
default: null,
},
});
const autonomiesRef = ref([]);
const onDataSaved = (dataSaved, requestResponse) => { const autonomiesOptions = ref([]);
requestResponse.autonomy = autonomiesRef.value.opts.find(
(autonomy) => autonomy.id == requestResponse.autonomyFk const onDataSaved = (dataSaved) => {
); emit('onDataSaved', dataSaved);
emit('onDataSaved', dataSaved, requestResponse);
}; };
const where = computed(() => {
if (!$props.countryFk) {
return {};
}
return { countryFk: $props.countryFk };
});
</script> </script>
<template> <template>
<FetchData
@on-fetch="(data) => (autonomiesOptions = data)"
auto-load
url="Autonomies"
/>
<FormModelPopup <FormModelPopup
:title="t('New province')" :title="t('New province')"
:subtitle="t('Please, ensure you put the correct data!')" :subtitle="t('Please, ensure you put the correct data!')"
url-create="provinces" url-create="provinces"
model="province" model="province"
:form-initial-data="provinceFormData" :form-initial-data="provinceFormData"
@on-data-saved="onDataSaved" @on-data-saved="onDataSaved($event)"
> >
<template #form-inputs="{ data, validate }"> <template #form-inputs="{ data, validate }">
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<VnInput <VnInput
:label="t('Name')" :label="t('Name')"
v-model="data.name" v-model="data.name"
:rules="validate('province.name')" :rules="validate('province.name')"
required
data-cy="provinceName"
/> />
<VnSelect <VnSelect
data-cy="autonomyProvince"
required
ref="autonomiesRef"
auto-load
:where="where"
url="Autonomies/location"
:sort-by="['name ASC']"
:limit="30"
:label="t('Autonomy')" :label="t('Autonomy')"
:options="autonomiesOptions"
hide-selected hide-selected
option-label="name" option-label="name"
option-value="id" option-value="id"
v-model="data.autonomyFk" v-model="data.autonomyFk"
:rules="validate('province.autonomyFk')" :rules="validate('province.autonomyFk')"
> />
<template #option="{ itemProps, opt }">
<QItem v-bind="itemProps">
<QItemSection>
<QItemLabel>{{ opt.name }}</QItemLabel>
<QItemLabel caption> {{ opt.country.name }} </QItemLabel>
</QItemSection>
</QItem>
</template>
</VnSelect>
</VnRow> </VnRow>
</template> </template>
</FormModelPopup> </FormModelPopup>

View File

@ -38,7 +38,7 @@ const onDataSaved = (dataSaved) => {
@on-fetch="(data) => (warehousesOptions = data)" @on-fetch="(data) => (warehousesOptions = data)"
auto-load auto-load
url="Warehouses" url="Warehouses"
:filter="{ fields: ['id', 'name'], order: 'name ASC' }" :filter="{ fields: ['id', 'name'], order: 'name ASC', limit: 30 }"
/> />
<FetchData <FetchData
@on-fetch="(data) => (temperaturesOptions = data)" @on-fetch="(data) => (temperaturesOptions = data)"
@ -50,10 +50,10 @@ const onDataSaved = (dataSaved) => {
model="thermograph" model="thermograph"
:title="t('New thermograph')" :title="t('New thermograph')"
:form-initial-data="thermographFormData" :form-initial-data="thermographFormData"
@on-data-saved="(_, response) => onDataSaved(response)" @on-data-saved="onDataSaved($event)"
> >
<template #form-inputs="{ data, validate }"> <template #form-inputs="{ data, validate }">
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<VnInput <VnInput
:label="t('Identifier')" :label="t('Identifier')"
v-model="data.thermographId" v-model="data.thermographId"

View File

@ -1,7 +1,7 @@
<script setup> <script setup>
import axios from 'axios'; import axios from 'axios';
import { computed, ref, useAttrs, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter, onBeforeRouteLeave } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { useValidator } from 'src/composables/useValidator'; import { useValidator } from 'src/composables/useValidator';
@ -10,14 +10,12 @@ import VnPaginate from 'components/ui/VnPaginate.vue';
import VnConfirm from 'components/ui/VnConfirm.vue'; import VnConfirm from 'components/ui/VnConfirm.vue';
import SkeletonTable from 'components/ui/SkeletonTable.vue'; import SkeletonTable from 'components/ui/SkeletonTable.vue';
import { tMobile } from 'src/composables/tMobile'; import { tMobile } from 'src/composables/tMobile';
import getDifferences from 'src/filters/getDifferences';
const { push } = useRouter(); const { push } = useRouter();
const quasar = useQuasar(); const quasar = useQuasar();
const stateStore = useStateStore(); const stateStore = useStateStore();
const { t } = useI18n(); const { t } = useI18n();
const { validate } = useValidator(); const { validate } = useValidator();
const $attrs = useAttrs();
const $props = defineProps({ const $props = defineProps({
model: { model: {
@ -69,7 +67,7 @@ const $props = defineProps({
default: '', default: '',
description: 'It is used for redirect on click "save and continue"', description: 'It is used for redirect on click "save and continue"',
}, },
hasSubToolbar: { hasSubtoolbar: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
@ -79,7 +77,7 @@ const isLoading = ref(false);
const hasChanges = ref(false); const hasChanges = ref(false);
const originalData = ref(); const originalData = ref();
const vnPaginateRef = ref(); const vnPaginateRef = ref();
const formData = ref([]); const formData = ref();
const saveButtonRef = ref(null); const saveButtonRef = ref(null);
const watchChanges = ref(); const watchChanges = ref();
const formUrl = computed(() => $props.url); const formUrl = computed(() => $props.url);
@ -96,41 +94,27 @@ defineExpose({
saveChanges, saveChanges,
getChanges, getChanges,
formData, formData,
originalData,
vnPaginateRef, vnPaginateRef,
}); });
onBeforeRouteLeave((to, from, next) => {
if (hasChanges.value)
quasar.dialog({
component: VnConfirm,
componentProps: {
title: t('globals.unsavedPopup.title'),
message: t('globals.unsavedPopup.subtitle'),
promise: () => next(),
},
});
else next();
});
async function fetch(data) { async function fetch(data) {
const keyData = $attrs['key-data'];
const rows = keyData ? data[keyData] : data;
resetData(rows);
emit('onFetch', rows);
return rows;
}
function resetData(data) {
if (!data) return;
if (data && Array.isArray(data)) { if (data && Array.isArray(data)) {
let $index = 0; let $index = 0;
data.map((d) => (d.$index = $index++)); data.map((d) => (d.$index = $index++));
} }
resetData(data);
emit('onFetch', data);
return data;
}
function resetData(data) {
if (!data) return;
originalData.value = JSON.parse(JSON.stringify(data)); originalData.value = JSON.parse(JSON.stringify(data));
formData.value = JSON.parse(JSON.stringify(data)); formData.value = JSON.parse(JSON.stringify(data));
if (watchChanges.value) watchChanges.value(); //destroy watcher if (watchChanges.value) watchChanges.value(); //destoy watcher
watchChanges.value = watch(formData, () => (hasChanges.value = true), { deep: true }); watchChanges.value = watch(formData, () => (hasChanges.value = true), { deep: true });
} }
@ -149,7 +133,7 @@ function filter(value, update, filterOptions) {
(ref) => { (ref) => {
ref.setOptionIndex(-1); ref.setOptionIndex(-1);
ref.moveOptionSelection(1, true); ref.moveOptionSelection(1, true);
}, }
); );
} }
@ -179,13 +163,14 @@ async function saveChanges(data) {
const changes = data || getChanges(); const changes = data || getChanges();
try { try {
await axios.post($props.saveUrl || $props.url + '/crud', changes); await axios.post($props.saveUrl || $props.url + '/crud', changes);
} finally { } catch (e) {
isLoading.value = false; return (isLoading.value = false);
} }
originalData.value = JSON.parse(JSON.stringify(formData.value)); originalData.value = JSON.parse(JSON.stringify(formData.value));
if (changes.creates?.length) await vnPaginateRef.value.fetch(); if (changes.creates?.length) await vnPaginateRef.value.fetch();
hasChanges.value = false; hasChanges.value = false;
isLoading.value = false;
emit('saveChanges', data); emit('saveChanges', data);
quasar.notify({ quasar.notify({
type: 'positive', type: 'positive',
@ -193,11 +178,11 @@ async function saveChanges(data) {
}); });
} }
async function insert(pushData = $props.dataRequired) { async function insert() {
const $index = formData.value.length const $index = formData.value.length
? formData.value[formData.value.length - 1].$index + 1 ? formData.value[formData.value.length - 1].$index + 1
: 0; : 0;
formData.value.push(Object.assign({ $index }, pushData)); formData.value.push(Object.assign({ $index }, $props.dataRequired));
hasChanges.value = true; hasChanges.value = true;
} }
@ -215,7 +200,7 @@ async function remove(data) {
if (preRemove.length) { if (preRemove.length) {
newData = newData.filter( newData = newData.filter(
(form) => !preRemove.some((index) => index == form.$index), (form) => !preRemove.some((index) => index == form.$index)
); );
const changes = getChanges(); const changes = getChanges();
if (!changes.creates?.length && !changes.updates?.length) if (!changes.creates?.length && !changes.updates?.length)
@ -238,8 +223,6 @@ async function remove(data) {
newData = newData.filter((form) => !ids.some((id) => id == form[pk])); newData = newData.filter((form) => !ids.some((id) => id == form[pk]));
fetch(newData); fetch(newData);
}); });
} else {
reset();
} }
emit('update:selected', []); emit('update:selected', []);
} }
@ -252,7 +235,7 @@ function getChanges() {
for (const [i, row] of formData.value.entries()) { for (const [i, row] of formData.value.entries()) {
if (!row[pk]) { if (!row[pk]) {
creates.push(row); creates.push(row);
} else if (originalData.value[i]) { } else if (originalData.value) {
const data = getDifferences(originalData.value[i], row); const data = getDifferences(originalData.value[i], row);
if (!isEmpty(data)) { if (!isEmpty(data)) {
updates.push({ updates.push({
@ -271,10 +254,34 @@ function getChanges() {
return changes; return changes;
} }
function getDifferences(obj1, obj2) {
let diff = {};
delete obj1.$index;
delete obj2.$index;
for (let key in obj1) {
if (obj2[key] && JSON.stringify(obj1[key]) !== JSON.stringify(obj2[key])) {
diff[key] = obj2[key];
}
}
for (let key in obj2) {
if (
obj1[key] === undefined ||
JSON.stringify(obj1[key]) !== JSON.stringify(obj2[key])
) {
diff[key] = obj2[key];
}
}
return diff;
}
function isEmpty(obj) { function isEmpty(obj) {
if (obj == null) return true; if (obj == null) return true;
if (Array.isArray(obj)) return !obj.length; if (obj === undefined) return true;
return !Object.keys(obj).length; if (Object.keys(obj).length === 0) return true;
if (obj.length > 0) return false;
} }
async function reload(params) { async function reload(params) {
@ -292,7 +299,7 @@ watch(formUrl, async () => {
:url="url" :url="url"
:limit="limit" :limit="limit"
@on-fetch="fetch" @on-fetch="fetch"
@on-change="fetch" @on-change="resetData"
:skeleton="false" :skeleton="false"
ref="vnPaginateRef" ref="vnPaginateRef"
v-bind="$attrs" v-bind="$attrs"
@ -306,11 +313,8 @@ watch(formUrl, async () => {
></slot> ></slot>
</template> </template>
</VnPaginate> </VnPaginate>
<SkeletonTable <SkeletonTable v-if="!formData" :columns="$attrs.columns?.length" />
v-if="!formData && $attrs.autoLoad" <Teleport to="#st-actions" v-if="stateStore?.isSubToolbarShown() && hasSubtoolbar">
:columns="$attrs.columns?.length"
/>
<Teleport to="#st-actions" v-if="stateStore?.isSubToolbarShown() && hasSubToolbar">
<QBtnGroup push style="column-gap: 10px"> <QBtnGroup push style="column-gap: 10px">
<slot name="moreBeforeActions" /> <slot name="moreBeforeActions" />
<QBtn <QBtn
@ -374,7 +378,6 @@ watch(formUrl, async () => {
@click="onSubmit" @click="onSubmit"
:disable="!hasChanges" :disable="!hasChanges"
:title="t('globals.save')" :title="t('globals.save')"
data-cy="crudModelDefaultSaveBtn"
/> />
<slot name="moreAfterActions" /> <slot name="moreAfterActions" />
</QBtnGroup> </QBtnGroup>

View File

@ -156,6 +156,7 @@ const rotateRight = () => {
}; };
const onSubmit = () => { const onSubmit = () => {
try {
if (!newPhoto.files && !newPhoto.url) { if (!newPhoto.files && !newPhoto.url) {
notify(t('Select an image'), 'negative'); notify(t('Select an image'), 'negative');
return; return;
@ -172,6 +173,9 @@ const onSubmit = () => {
newPhoto.blob = file; newPhoto.blob = file;
}) })
.then(() => makeRequest()); .then(() => makeRequest());
} catch (err) {
console.error('Error uploading image');
}
}; };
const makeRequest = async () => { const makeRequest = async () => {
@ -241,14 +245,14 @@ const makeRequest = async () => {
</div> </div>
<div class="column"> <div class="column">
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<QOptionGroup <QOptionGroup
:options="uploadMethodsOptions" :options="uploadMethodsOptions"
type="radio" type="radio"
v-model="uploadMethodSelected" v-model="uploadMethodSelected"
/> />
</VnRow> </VnRow>
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<QFile <QFile
v-if="uploadMethodSelected === 'computer'" v-if="uploadMethodSelected === 'computer'"
ref="inputFileRef" ref="inputFileRef"
@ -283,7 +287,7 @@ const makeRequest = async () => {
placeholder="https://" placeholder="https://"
/> />
</VnRow> </VnRow>
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<VnSelect <VnSelect
:label="t('Orientation')" :label="t('Orientation')"
:options="viewportTypes" :options="viewportTypes"

View File

@ -51,6 +51,7 @@ const onDataSaved = () => {
}; };
const onSubmit = async () => { const onSubmit = async () => {
try {
isLoading.value = true; isLoading.value = true;
const rowsToEdit = $props.rows.map((row) => ({ id: row.id, itemFk: row.itemFk })); const rowsToEdit = $props.rows.map((row) => ({ id: row.id, itemFk: row.itemFk }));
const payload = { const payload = {
@ -62,6 +63,9 @@ const onSubmit = async () => {
await axios.post($props.editUrl, payload); await axios.post($props.editUrl, payload);
onDataSaved(); onDataSaved();
isLoading.value = false; isLoading.value = false;
} catch (err) {
console.error('Error submitting table cell edit');
}
}; };
const closeForm = () => { const closeForm = () => {
@ -78,21 +82,19 @@ const closeForm = () => {
<span class="title">{{ t('Edit') }}</span> <span class="title">{{ t('Edit') }}</span>
<span class="countLines">{{ ` ${rows.length} ` }}</span> <span class="countLines">{{ ` ${rows.length} ` }}</span>
<span class="title">{{ t('buy(s)') }}</span> <span class="title">{{ t('buy(s)') }}</span>
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<VnSelect <VnSelect
:label="t('Field to edit')" :label="t('Field to edit')"
:options="fieldsOptions" :options="fieldsOptions"
hide-selected hide-selected
option-label="label" option-label="label"
v-model="selectedField" v-model="selectedField"
data-cy="field-to-edit"
/> />
<component <component
:is="inputs[selectedField?.component || 'input']" :is="inputs[selectedField?.component || 'input']"
v-bind="selectedField?.attrs || {}" v-bind="selectedField?.attrs || {}"
v-model="newValue" v-model="newValue"
:label="t('Value')" :label="t('Value')"
data-cy="value-to-edit"
style="width: 200px" style="width: 200px"
/> />
</VnRow> </VnRow>

View File

@ -44,7 +44,7 @@ onMounted(async () => {
async function fetch(fetchFilter = {}) { async function fetch(fetchFilter = {}) {
try { try {
const filter = { ...fetchFilter, ...$props.filter }; // eslint-disable-line vue/no-dupe-keys const filter = Object.assign(fetchFilter, $props.filter); // eslint-disable-line vue/no-dupe-keys
if ($props.where && !fetchFilter.where) filter.where = $props.where; if ($props.where && !fetchFilter.where) filter.where = $props.where;
if ($props.sortBy) filter.order = $props.sortBy; if ($props.sortBy) filter.order = $props.sortBy;
if ($props.limit) filter.limit = $props.limit; if ($props.limit) filter.limit = $props.limit;

View File

@ -50,25 +50,25 @@ const loading = ref(false);
const tableColumns = computed(() => [ const tableColumns = computed(() => [
{ {
label: t('globals.id'), label: t('entry.buys.id'),
name: 'id', name: 'id',
field: 'id', field: 'id',
align: 'left', align: 'left',
}, },
{ {
label: t('globals.name'), label: t('entry.buys.name'),
name: 'name', name: 'name',
field: 'name', field: 'name',
align: 'left', align: 'left',
}, },
{ {
label: t('globals.size'), label: t('entry.buys.size'),
name: 'size', name: 'size',
field: 'size', field: 'size',
align: 'left', align: 'left',
}, },
{ {
label: t('globals.producer'), label: t('entry.buys.producer'),
name: 'producerName', name: 'producerName',
field: 'producer', field: 'producer',
align: 'left', align: 'left',
@ -84,6 +84,7 @@ const tableColumns = computed(() => [
]); ]);
const onSubmit = async () => { const onSubmit = async () => {
try {
let filter = itemFilter; let filter = itemFilter;
const params = itemFilterParams; const params = itemFilterParams;
const where = {}; const where = {};
@ -108,6 +109,9 @@ const onSubmit = async () => {
params: { filter: JSON.stringify(filter) }, params: { filter: JSON.stringify(filter) },
}); });
tableRows.value = data; tableRows.value = data;
} catch (err) {
console.error('Error fetching entries items');
}
}; };
const closeForm = () => { const closeForm = () => {
@ -147,11 +151,11 @@ const selectItem = ({ id }) => {
<QIcon name="close" size="sm" /> <QIcon name="close" size="sm" />
</span> </span>
<h1 class="title">{{ t('Filter item') }}</h1> <h1 class="title">{{ t('Filter item') }}</h1>
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<VnInput :label="t('globals.name')" v-model="itemFilterParams.name" /> <VnInput :label="t('entry.buys.name')" v-model="itemFilterParams.name" />
<VnInput :label="t('entry.buys.size')" v-model="itemFilterParams.size" /> <VnInput :label="t('entry.buys.size')" v-model="itemFilterParams.size" />
<VnSelect <VnSelect
:label="t('globals.producer')" :label="t('entry.buys.producer')"
:options="producersOptions" :options="producersOptions"
hide-selected hide-selected
option-label="name" option-label="name"
@ -159,7 +163,7 @@ const selectItem = ({ id }) => {
v-model="itemFilterParams.producerFk" v-model="itemFilterParams.producerFk"
/> />
<VnSelect <VnSelect
:label="t('globals.type')" :label="t('entry.buys.type')"
:options="ItemTypesOptions" :options="ItemTypesOptions"
hide-selected hide-selected
option-label="name" option-label="name"

View File

@ -48,13 +48,13 @@ const loading = ref(false);
const tableColumns = computed(() => [ const tableColumns = computed(() => [
{ {
label: t('globals.id'), label: t('entry.basicData.id'),
name: 'id', name: 'id',
field: 'id', field: 'id',
align: 'left', align: 'left',
}, },
{ {
label: t('globals.warehouseOut'), label: t('entry.basicData.warehouseOut'),
name: 'warehouseOutFk', name: 'warehouseOutFk',
field: 'warehouseOutFk', field: 'warehouseOutFk',
align: 'left', align: 'left',
@ -62,7 +62,7 @@ const tableColumns = computed(() => [
warehousesOptions.value.find((warehouse) => warehouse.id === val).name, warehousesOptions.value.find((warehouse) => warehouse.id === val).name,
}, },
{ {
label: t('globals.warehouseIn'), label: t('entry.basicData.warehouseIn'),
name: 'warehouseInFk', name: 'warehouseInFk',
field: 'warehouseInFk', field: 'warehouseInFk',
align: 'left', align: 'left',
@ -70,14 +70,14 @@ const tableColumns = computed(() => [
warehousesOptions.value.find((warehouse) => warehouse.id === val).name, warehousesOptions.value.find((warehouse) => warehouse.id === val).name,
}, },
{ {
label: t('globals.shipped'), label: t('entry.basicData.shipped'),
name: 'shipped', name: 'shipped',
field: 'shipped', field: 'shipped',
align: 'left', align: 'left',
format: (val) => toDate(val), format: (val) => toDate(val),
}, },
{ {
label: t('globals.landed'), label: t('entry.basicData.landed'),
name: 'landed', name: 'landed',
field: 'landed', field: 'landed',
align: 'left', align: 'left',
@ -86,6 +86,7 @@ const tableColumns = computed(() => [
]); ]);
const onSubmit = async () => { const onSubmit = async () => {
try {
let filter = travelFilter; let filter = travelFilter;
const params = travelFilterParams; const params = travelFilterParams;
const where = {}; const where = {};
@ -108,6 +109,9 @@ const onSubmit = async () => {
params: { filter: JSON.stringify(filter) }, params: { filter: JSON.stringify(filter) },
}); });
tableRows.value = data; tableRows.value = data;
} catch (err) {
console.error('Error fetching travels');
}
}; };
const closeForm = () => { const closeForm = () => {
@ -140,9 +144,9 @@ const selectTravel = ({ id }) => {
<QIcon name="close" size="sm" /> <QIcon name="close" size="sm" />
</span> </span>
<h1 class="title">{{ t('Filter travels') }}</h1> <h1 class="title">{{ t('Filter travels') }}</h1>
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<VnSelect <VnSelect
:label="t('globals.agency')" :label="t('entry.basicData.agency')"
:options="agenciesOptions" :options="agenciesOptions"
hide-selected hide-selected
option-label="name" option-label="name"
@ -150,7 +154,7 @@ const selectTravel = ({ id }) => {
v-model="travelFilterParams.agencyModeFk" v-model="travelFilterParams.agencyModeFk"
/> />
<VnSelect <VnSelect
:label="t('globals.warehouseOut')" :label="t('entry.basicData.warehouseOut')"
:options="warehousesOptions" :options="warehousesOptions"
hide-selected hide-selected
option-label="name" option-label="name"
@ -158,7 +162,7 @@ const selectTravel = ({ id }) => {
v-model="travelFilterParams.warehouseOutFk" v-model="travelFilterParams.warehouseOutFk"
/> />
<VnSelect <VnSelect
:label="t('globals.warehouseIn')" :label="t('entry.basicData.warehouseIn')"
:options="warehousesOptions" :options="warehousesOptions"
hide-selected hide-selected
option-label="name" option-label="name"
@ -166,11 +170,11 @@ const selectTravel = ({ id }) => {
v-model="travelFilterParams.warehouseInFk" v-model="travelFilterParams.warehouseInFk"
/> />
<VnInputDate <VnInputDate
:label="t('globals.shipped')" :label="t('entry.basicData.shipped')"
v-model="travelFilterParams.shipped" v-model="travelFilterParams.shipped"
/> />
<VnInputDate <VnInputDate
:label="t('globals.landed')" :label="t('entry.basicData.landed')"
v-model="travelFilterParams.landed" v-model="travelFilterParams.landed"
/> />
</VnRow> </VnRow>

View File

@ -1,7 +1,7 @@
<script setup> <script setup>
import axios from 'axios'; import axios from 'axios';
import { onMounted, onUnmounted, computed, ref, watch, nextTick } from 'vue'; import { onMounted, onUnmounted, computed, ref, watch, nextTick } from 'vue';
import { onBeforeRouteLeave, useRouter, useRoute } from 'vue-router'; import { onBeforeRouteLeave, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { useState } from 'src/composables/useState'; import { useState } from 'src/composables/useState';
@ -12,6 +12,7 @@ import SkeletonForm from 'components/ui/SkeletonForm.vue';
import VnConfirm from './ui/VnConfirm.vue'; import VnConfirm from './ui/VnConfirm.vue';
import { tMobile } from 'src/composables/tMobile'; import { tMobile } from 'src/composables/tMobile';
import { useArrayData } from 'src/composables/useArrayData'; import { useArrayData } from 'src/composables/useArrayData';
import { useRoute } from 'vue-router';
const { push } = useRouter(); const { push } = useRouter();
const quasar = useQuasar(); const quasar = useQuasar();
@ -21,7 +22,7 @@ const { t } = useI18n();
const { validate } = useValidator(); const { validate } = useValidator();
const { notify } = useNotify(); const { notify } = useNotify();
const route = useRoute(); const route = useRoute();
const myForm = ref(null);
const $props = defineProps({ const $props = defineProps({
url: { url: {
type: String, type: String,
@ -84,20 +85,12 @@ const $props = defineProps({
}, },
reload: { reload: {
type: Boolean, type: Boolean,
default: true, default: false,
},
defaultTrim: {
type: Boolean,
default: true,
},
maxWidth: {
type: [String, Boolean],
default: '800px',
}, },
}); });
const emit = defineEmits(['onFetch', 'onDataSaved']); const emit = defineEmits(['onFetch', 'onDataSaved']);
const modelValue = computed( const modelValue = computed(
() => $props.model ?? `formModel_${route?.meta?.title ?? route.name}`, () => $props.model ?? `formModel_${route?.meta?.title ?? route.name}`
).value; ).value;
const componentIsRendered = ref(false); const componentIsRendered = ref(false);
const arrayData = useArrayData(modelValue); const arrayData = useArrayData(modelValue);
@ -105,28 +98,26 @@ const isLoading = ref(false);
// Si elegimos observar los cambios del form significa que inicialmente las actions estaran deshabilitadas // Si elegimos observar los cambios del form significa que inicialmente las actions estaran deshabilitadas
const isResetting = ref(false); const isResetting = ref(false);
const hasChanges = ref(!$props.observeFormChanges); const hasChanges = ref(!$props.observeFormChanges);
const originalData = computed(() => state.get(modelValue)); const originalData = ref({});
const formData = ref({}); const formData = computed(() => state.get(modelValue));
const formUrl = computed(() => $props.url);
const defaultButtons = computed(() => ({ const defaultButtons = computed(() => ({
save: { save: {
dataCy: 'saveDefaultBtn',
color: 'primary', color: 'primary',
icon: 'save', icon: 'save',
label: 'globals.save', label: 'globals.save',
click: () => myForm.value.submit(),
type: 'submit',
}, },
reset: { reset: {
dataCy: 'resetDefaultBtn',
color: 'primary', color: 'primary',
icon: 'restart_alt', icon: 'restart_alt',
label: 'globals.reset', label: 'globals.reset',
click: () => reset(),
}, },
...$props.defaultButtons, ...$props.defaultButtons,
})); }));
onMounted(async () => { onMounted(async () => {
originalData.value = JSON.parse(JSON.stringify($props.formInitialData ?? {}));
nextTick(() => (componentIsRendered.value = true)); nextTick(() => (componentIsRendered.value = true));
// Podemos enviarle al form la estructura de data inicial sin necesidad de fetchearla // Podemos enviarle al form la estructura de data inicial sin necesidad de fetchearla
@ -146,7 +137,7 @@ onMounted(async () => {
JSON.stringify(newVal) !== JSON.stringify(originalData.value); JSON.stringify(newVal) !== JSON.stringify(originalData.value);
isResetting.value = false; isResetting.value = false;
}, },
{ deep: true }, { deep: true }
); );
} }
}); });
@ -154,33 +145,22 @@ onMounted(async () => {
if (!$props.url) if (!$props.url)
watch( watch(
() => arrayData.store.data, () => arrayData.store.data,
(val) => updateAndEmit('onFetch', val), (val) => updateAndEmit('onFetch', val)
); );
watch( watch(formUrl, async () => {
originalData, originalData.value = null;
(val) => {
if (val) formData.value = JSON.parse(JSON.stringify(val));
},
{ immediate: true },
);
watch(
() => [$props.url, $props.filter],
async () => {
state.set(modelValue, null);
reset(); reset();
await fetch(); await fetch();
}, });
);
onBeforeRouteLeave((to, from, next) => { onBeforeRouteLeave((to, from, next) => {
if (hasChanges.value && $props.observeFormChanges) if (hasChanges.value && $props.observeFormChanges)
quasar.dialog({ quasar.dialog({
component: VnConfirm, component: VnConfirm,
componentProps: { componentProps: {
title: t('globals.unsavedPopup.title'), title: t('Unsaved changes will be lost'),
message: t('globals.unsavedPopup.subtitle'), message: t('Are you sure exit without saving?'),
promise: () => next(), promise: () => next(),
}, },
}); });
@ -203,7 +183,7 @@ async function fetch() {
updateAndEmit('onFetch', data); updateAndEmit('onFetch', data);
} catch (e) { } catch (e) {
state.set(modelValue, {}); state.set(modelValue, {});
throw e; originalData.value = {};
} }
} }
@ -213,10 +193,7 @@ async function save() {
isLoading.value = true; isLoading.value = true;
try { try {
formData.value = trimData(formData.value); const body = $props.mapper ? $props.mapper(formData.value) : formData.value;
const body = $props.mapper
? $props.mapper(formData.value, originalData.value)
: formData.value;
const method = $props.urlCreate ? 'post' : 'patch'; const method = $props.urlCreate ? 'post' : 'patch';
const url = const url =
$props.urlCreate || $props.urlUpdate || $props.url || arrayData.store.url; $props.urlCreate || $props.urlUpdate || $props.url || arrayData.store.url;
@ -229,8 +206,11 @@ async function save() {
updateAndEmit('onDataSaved', formData.value, response?.data); updateAndEmit('onDataSaved', formData.value, response?.data);
if ($props.reload) await arrayData.fetch({}); if ($props.reload) await arrayData.fetch({});
hasChanges.value = false; } catch (err) {
console.error(err);
notify('errors.writeRequest', 'negative');
} finally { } finally {
hasChanges.value = false;
isLoading.value = false; isLoading.value = false;
} }
} }
@ -241,7 +221,6 @@ async function saveAndGo() {
} }
function reset() { function reset() {
formData.value = JSON.parse(JSON.stringify(originalData.value));
updateAndEmit('onFetch', originalData.value); updateAndEmit('onFetch', originalData.value);
if ($props.observeFormChanges) { if ($props.observeFormChanges) {
hasChanges.value = false; hasChanges.value = false;
@ -260,45 +239,34 @@ function filter(value, update, filterOptions) {
(ref) => { (ref) => {
ref.setOptionIndex(-1); ref.setOptionIndex(-1);
ref.moveOptionSelection(1, true); ref.moveOptionSelection(1, true);
}, }
); );
} }
function updateAndEmit(evt, val, res) { function updateAndEmit(evt, val, res) {
state.set(modelValue, val); state.set(modelValue, val);
originalData.value = val && JSON.parse(JSON.stringify(val));
if (!$props.url) arrayData.store.data = val; if (!$props.url) arrayData.store.data = val;
emit(evt, state.get(modelValue), res); emit(evt, state.get(modelValue), res);
} }
function trimData(data) {
if (!$props.defaultTrim) return data;
for (const key in data) {
if (typeof data[key] == 'string') data[key] = data[key].trim();
}
return data;
}
defineExpose({ defineExpose({
save, save,
isLoading, isLoading,
hasChanges, hasChanges,
reset, reset,
fetch, fetch,
formData,
}); });
</script> </script>
<template> <template>
<div class="column items-center full-width"> <div class="column items-center full-width">
<QForm <QForm
ref="myForm"
v-if="formData"
@submit="save" @submit="save"
@reset="reset" @reset="reset"
class="q-pa-md" class="q-pa-md"
:style="maxWidth ? 'max-width: ' + maxWidth : ''"
id="formModel" id="formModel"
:prevent-submit="$attrs['prevent-submit']"
> >
<QCard> <QCard>
<slot <slot
@ -314,12 +282,9 @@ defineExpose({
</div> </div>
<Teleport <Teleport
to="#st-actions" to="#st-actions"
v-if=" v-if="stateStore?.isSubToolbarShown() && componentIsRendered"
$props.defaultActions &&
stateStore?.isSubToolbarShown() &&
componentIsRendered
"
> >
<div v-if="$props.defaultActions">
<QBtnGroup push class="q-gutter-x-sm"> <QBtnGroup push class="q-gutter-x-sm">
<slot name="moreActions" /> <slot name="moreActions" />
<QBtn <QBtn
@ -327,12 +292,11 @@ defineExpose({
:color="defaultButtons.reset.color" :color="defaultButtons.reset.color"
:icon="defaultButtons.reset.icon" :icon="defaultButtons.reset.icon"
flat flat
@click="defaultButtons.reset.click" @click="reset"
:disable="!hasChanges" :disable="!hasChanges"
:title="t(defaultButtons.reset.label)" :title="t(defaultButtons.reset.label)"
/> />
<QBtnDropdown <QBtnDropdown
data-cy="saveAndContinueDefaultBtn"
v-if="$props.goTo" v-if="$props.goTo"
@click="saveAndGo" @click="saveAndGo"
:label="tMobile('globals.saveAndContinue')" :label="tMobile('globals.saveAndContinue')"
@ -368,11 +332,12 @@ defineExpose({
:label="tMobile('globals.save')" :label="tMobile('globals.save')"
color="primary" color="primary"
icon="save" icon="save"
@click="defaultButtons.save.click" @click="save"
:disable="!hasChanges" :disable="!hasChanges"
:title="t(defaultButtons.save.label)" :title="t(defaultButtons.save.label)"
/> />
</QBtnGroup> </QBtnGroup>
</div>
</Teleport> </Teleport>
<QInnerLoading <QInnerLoading
@ -387,6 +352,7 @@ defineExpose({
color: black; color: black;
} }
#formModel { #formModel {
max-width: 800px;
width: 100%; width: 100%;
} }
@ -394,3 +360,8 @@ defineExpose({
padding: 32px; padding: 32px;
} }
</style> </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

@ -23,15 +23,18 @@ const formModelRef = ref(null);
const closeButton = ref(null); const closeButton = ref(null);
const onDataSaved = (formData, requestResponse) => { const onDataSaved = (formData, requestResponse) => {
if (closeButton.value) closeButton.value.click(); closeForm();
emit('onDataSaved', formData, requestResponse); emit('onDataSaved', formData, requestResponse);
}; };
const isLoading = computed(() => formModelRef.value?.isLoading); const isLoading = computed(() => formModelRef.value?.isLoading);
const closeForm = async () => {
if (closeButton.value) closeButton.value.click();
};
defineExpose({ defineExpose({
isLoading, isLoading,
onDataSaved,
}); });
</script> </script>
@ -61,8 +64,6 @@ defineExpose({
:loading="isLoading" :loading="isLoading"
@click="emit('onDataCanceled')" @click="emit('onDataCanceled')"
v-close-popup v-close-popup
data-cy="FormModelPopup_cancel"
z-max
/> />
<QBtn <QBtn
:label="t('globals.save')" :label="t('globals.save')"
@ -72,8 +73,6 @@ defineExpose({
class="q-ml-sm" class="q-ml-sm"
:disabled="isLoading" :disabled="isLoading"
:loading="isLoading" :loading="isLoading"
data-cy="FormModelPopup_save"
z-max
/> />
</div> </div>
</template> </template>

View File

@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
const emit = defineEmits(['onSubmit']); const emit = defineEmits(['onSubmit']);
const $props = defineProps({ defineProps({
title: { title: {
type: String, type: String,
default: '', default: '',
@ -25,21 +25,16 @@ const $props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
submitOnEnter: {
type: Boolean,
default: true,
},
}); });
const { t } = useI18n(); const { t } = useI18n();
const closeButton = ref(null); const closeButton = ref(null);
const isLoading = ref(false); const isLoading = ref(false);
const onSubmit = () => { const onSubmit = () => {
if ($props.submitOnEnter) {
emit('onSubmit'); emit('onSubmit');
closeForm(); closeForm();
}
}; };
const closeForm = () => { const closeForm = () => {

View File

@ -9,8 +9,6 @@ import VnSelect from 'components/common/VnSelect.vue';
import VnFilterPanelChip from 'components/ui/VnFilterPanelChip.vue'; import VnFilterPanelChip from 'components/ui/VnFilterPanelChip.vue';
import axios from 'axios'; import axios from 'axios';
import { getParamWhere } from 'src/filters';
import { useRoute } from 'vue-router';
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps({ const props = defineProps({
@ -28,24 +26,32 @@ const props = defineProps({
}, },
}); });
const route = useRoute(); const itemCategories = ref([]);
const selectedCategoryFk = ref(null);
const selectedTypeFk = ref(null);
const itemTypesOptions = ref([]); const itemTypesOptions = ref([]);
const suppliersOptions = ref([]);
const tagOptions = ref([]); const tagOptions = ref([]);
const tagValues = ref([]); const tagValues = ref([]);
const categoryList = ref(null);
const selectedCategoryFk = ref(getParamWhere(route.query.table, 'categoryFk', false));
const selectedTypeFk = ref(getParamWhere(route.query.table, 'typeFk', false));
const selectedCategory = computed(() => { const categoryList = computed(() => {
return (categoryList.value || []).find( return (itemCategories.value || [])
(category) => category?.id === selectedCategoryFk.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(() => { const selectedType = computed(() => {
return (itemTypesOptions.value || []).find( return (itemTypesOptions.value || []).find(
(type) => type?.id === selectedTypeFk.value, (type) => type?.id === selectedTypeFk.value
); );
}); });
@ -81,7 +87,8 @@ const applyTags = (params, search) => {
search(); search();
}; };
const fetchItemTypes = async (id = selectedCategoryFk.value) => { const fetchItemTypes = async (id) => {
try {
const filter = { const filter = {
fields: ['id', 'name', 'categoryFk'], fields: ['id', 'name', 'categoryFk'],
where: { categoryFk: id }, where: { categoryFk: id },
@ -92,6 +99,9 @@ const fetchItemTypes = async (id = selectedCategoryFk.value) => {
params: { filter: JSON.stringify(filter) }, params: { filter: JSON.stringify(filter) },
}); });
itemTypesOptions.value = data; itemTypesOptions.value = data;
} catch (err) {
console.error('Error fetching item types', err);
}
}; };
const getCategoryClass = (category, params) => { const getCategoryClass = (category, params) => {
@ -101,7 +111,7 @@ const getCategoryClass = (category, params) => {
}; };
const getSelectedTagValues = async (tag) => { const getSelectedTagValues = async (tag) => {
if (!tag?.selectedTag?.id) return; try {
tag.value = null; tag.value = null;
const filter = { const filter = {
fields: ['value'], fields: ['value'],
@ -114,25 +124,31 @@ const getSelectedTagValues = async (tag) => {
params, params,
}); });
tag.valueOptions = data; tag.valueOptions = data;
} catch (err) {
console.error('Error getting selected tag values');
}
}; };
const removeTag = (index, params, search) => { const removeTag = (index, params, search) => {
(tagValues.value || []).splice(index, 1); (tagValues.value || []).splice(index, 1);
applyTags(params, search); applyTags(params, search);
}; };
const setCategoryList = (data) => {
categoryList.value = (data || [])
.filter((category) => category.display)
.map((category) => ({
...category,
icon: `vn:${(category.icon || '').split('-')[1]}`,
}));
fetchItemTypes();
};
</script> </script>
<template> <template>
<FetchData url="ItemCategories" limit="30" auto-load @on-fetch="setCategoryList" /> <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 <FetchData
url="Tags" url="Tags"
:filter="{ fields: ['id', 'name', 'isFree'] }" :filter="{ fields: ['id', 'name', 'isFree'] }"
@ -142,8 +158,8 @@ const setCategoryList = (data) => {
/> />
<VnFilterPanel <VnFilterPanel
:data-key="props.dataKey" :data-key="props.dataKey"
:expr-builder="props.exprBuilder" :expr-builder="exprBuilder"
:custom-tags="props.customTags" :custom-tags="customTags"
> >
<template #tags="{ tag, formatFn }"> <template #tags="{ tag, formatFn }">
<strong v-if="tag.label === 'categoryFk'"> <strong v-if="tag.label === 'categoryFk'">
@ -231,7 +247,7 @@ const setCategoryList = (data) => {
> >
<QItemSection class="col"> <QItemSection class="col">
<VnSelect <VnSelect
:label="t('globals.tag')" :label="t('components.itemsFilterPanel.tag')"
v-model="value.selectedTag" v-model="value.selectedTag"
:options="tagOptions" :options="tagOptions"
option-label="name" option-label="name"
@ -280,12 +296,11 @@ const setCategoryList = (data) => {
/> />
</QItem> </QItem>
<QItem class="q-mt-lg"> <QItem class="q-mt-lg">
<QBtn <QIcon
icon="add_circle" name="add_circle"
v-shortcut="'+'"
flat
class="fill-icon-on-hover q-px-xs" class="fill-icon-on-hover q-px-xs"
color="primary" color="primary"
size="sm"
@click="tagValues.push({})" @click="tagValues.push({})"
/> />
</QItem> </QItem>
@ -341,11 +356,4 @@ es:
floramondo: Floramondo floramondo: Floramondo
salesPersonFk: Comprador salesPersonFk: Comprador
categoryFk: Categoría categoryFk: Categoría
Plant: Planta natural
Flower: Flor fresca
Handmade: Hecho a mano
Artificial: Artificial
Green: Verdes frescos
Accessories: Complementos florales
Fruit: Fruta
</i18n> </i18n>

View File

@ -1,6 +1,6 @@
<script setup> <script setup>
import axios from 'axios'; import axios from 'axios';
import { onMounted, watch, ref, reactive, computed } from 'vue'; import { onMounted, ref, reactive } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { QSeparator, useQuasar } from 'quasar'; import { QSeparator, useQuasar } from 'quasar';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
@ -9,71 +9,26 @@ import { toLowerCamel } from 'src/filters';
import routes from 'src/router/modules'; import routes from 'src/router/modules';
import LeftMenuItem from './LeftMenuItem.vue'; import LeftMenuItem from './LeftMenuItem.vue';
import LeftMenuItemGroup from './LeftMenuItemGroup.vue'; import LeftMenuItemGroup from './LeftMenuItemGroup.vue';
import VnInput from './common/VnInput.vue';
import { useRouter } from 'vue-router';
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const quasar = useQuasar(); const quasar = useQuasar();
const navigation = useNavigationStore(); const navigation = useNavigationStore();
const router = useRouter();
const props = defineProps({ const props = defineProps({
source: { source: {
type: String, type: String,
default: 'main', default: 'main',
}, },
}); });
const initialized = ref(false);
const items = ref([]);
const expansionItemElements = reactive({});
const pinnedModules = computed(() => {
const map = new Map();
items.value.forEach((item) => item.isPinned && map.set(item.name, item));
return map;
});
const search = ref(null);
const filteredItems = computed(() => { const expansionItemElements = reactive({});
if (!search.value) return items.value;
const normalizedSearch = normalize(search.value);
return items.value.filter((item) => {
const locale = normalize(t(item.title));
return locale.includes(normalizedSearch);
});
});
const filteredPinnedModules = computed(() => {
if (!search.value) return pinnedModules.value;
const normalizedSearch = search.value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
const map = new Map();
for (const [key, pinnedModule] of pinnedModules.value) {
const locale = t(pinnedModule.title)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
if (locale.includes(normalizedSearch)) map.set(key, pinnedModule);
}
return map;
});
onMounted(async () => { onMounted(async () => {
await navigation.fetchPinned(); await navigation.fetchPinned();
getRoutes(); getRoutes();
initialized.value = true;
}); });
watch(
() => route.matched,
() => {
if (!initialized.value) return;
items.value = [];
getRoutes();
},
{ deep: true },
);
function findMatches(search, item) { function findMatches(search, item) {
const matches = []; const matches = [];
function findRoute(search, item) { function findRoute(search, item) {
@ -92,61 +47,46 @@ function findMatches(search, item) {
} }
function addChildren(module, route, parent) { function addChildren(module, route, parent) {
const menus = route?.meta?.menu ?? route?.menus?.[props.source]; //backwards compatible if (route.menus) {
if (!menus) return; const mainMenus = route.menus[props.source];
const matches = findMatches(mainMenus, route);
const matches = findMatches(menus, route);
for (const child of matches) { for (const child of matches) {
navigation.addMenuItem(module, child, parent); navigation.addMenuItem(module, child, parent);
} }
} }
}
const items = ref([]);
function getRoutes() { function getRoutes() {
const handleRoutes = { if (props.source === 'main') {
main: getMainRoutes,
card: getCardRoutes,
};
try {
handleRoutes[props.source]();
} catch (error) {
throw new Error(`Method is not defined`);
}
}
function getMainRoutes() {
const modules = Object.assign([], navigation.getModules().value); const modules = Object.assign([], navigation.getModules().value);
for (const item of modules) { for (const item of modules) {
const moduleDef = routes.find( const moduleDef = routes.find(
(route) => toLowerCamel(route.name) === item.module, (route) => toLowerCamel(route.name) === item.module
); );
if (!moduleDef) continue;
item.children = []; item.children = [];
if (!moduleDef) continue;
addChildren(item.module, moduleDef, item.children); addChildren(item.module, moduleDef, item.children);
} }
items.value = modules; items.value = modules;
} }
function getCardRoutes() { if (props.source === 'card') {
const currentRoute = route.matched[1]; const currentRoute = route.matched[1];
const currentModule = toLowerCamel(currentRoute.name); const currentModule = toLowerCamel(currentRoute.name);
let moduleDef = routes.find((route) => toLowerCamel(route.name) === currentModule); const moduleDef = routes.find(
(route) => toLowerCamel(route.name) === currentModule
);
if (!moduleDef) return; if (!moduleDef) return;
if (!moduleDef?.menus) moduleDef = betaGetRoutes();
addChildren(currentModule, moduleDef, items.value); addChildren(currentModule, moduleDef, items.value);
} }
function betaGetRoutes() {
let menuRoute;
let index = route.matched.length - 1;
while (!menuRoute && index > 0) {
if (route.matched[index]?.meta?.menu) menuRoute = route.matched[index];
index--;
}
return menuRoute;
} }
async function togglePinned(item, event) { async function togglePinned(item, event) {
@ -174,71 +114,22 @@ async function togglePinned(item, event) {
const handleItemExpansion = (itemName) => { const handleItemExpansion = (itemName) => {
expansionItemElements[itemName].scrollToLastElement(); expansionItemElements[itemName].scrollToLastElement();
}; };
function normalize(text) {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
const searchModule = () => {
const [item] = filteredItems.value;
if (item) router.push({ name: item.name });
};
</script> </script>
<template> <template>
<QList padding class="column-max-width"> <QList padding class="column-max-width">
<template v-if="$props.source === 'main'"> <template v-if="$props.source === 'main'">
<template v-if="$route?.matched[1]?.name === 'Dashboard'"> <template v-if="$route?.matched[1]?.name === 'Dashboard'">
<QItem class="q-pb-md"> <QItem class="header">
<VnInput <QItemSection avatar>
v-model="search" <QIcon name="view_module" />
:label="t('Search modules')" </QItemSection>
class="full-width" <QItemSection> {{ t('globals.modules') }}</QItemSection>
filled
dense
autofocus
@keyup.enter.stop="searchModule()"
/>
</QItem> </QItem>
<QSeparator /> <QSeparator />
<template v-if="filteredPinnedModules.size && !search"> <template v-for="item in items" :key="item.name">
<LeftMenuItem <template v-if="item.children">
v-for="[key, pinnedModule] of filteredPinnedModules" <LeftMenuItem :item="item" group="modules">
:key="key"
:item="pinnedModule"
group="modules"
>
<template #side>
<QBtn
v-if="pinnedModule.isPinned === true"
@click="togglePinned(pinnedModule, $event)"
icon="remove_circle"
size="xs"
flat
round
>
<QTooltip>
{{ t('components.leftMenu.removeFromPinned') }}
</QTooltip>
</QBtn>
</template>
</LeftMenuItem>
<QSeparator />
</template>
<template v-for="(item, index) in filteredItems" :key="item.name">
<template
v-if="
search ||
(item.children && !filteredPinnedModules.has(item.name))
"
>
<LeftMenuItem
:item="item"
group="modules"
:class="search && index === 0 ? 'searched' : ''"
>
<template #side> <template #side>
<QBtn <QBtn
v-if="item.isPinned === true" v-if="item.isPinned === true"
@ -355,11 +246,4 @@ const searchModule = () => {
.header { .header {
color: var(--vn-label-color); color: var(--vn-label-color);
} }
.searched {
background-color: var(--vn-section-hover-color);
}
</style> </style>
<i18n>
es:
Search modules: Buscar módulos
</i18n>

View File

@ -21,7 +21,7 @@ const itemComputed = computed(() => {
</script> </script>
<template> <template>
<QItem <QItem
active-class="bg-vn-hover" active-class="bg-hover"
class="min-height" class="min-height"
:to="{ name: itemComputed.name }" :to="{ name: itemComputed.name }"
clickable clickable
@ -33,17 +33,13 @@ const itemComputed = computed(() => {
<QItemSection avatar v-if="!itemComputed.icon"> <QItemSection avatar v-if="!itemComputed.icon">
<QIcon name="disabled_by_default" /> <QIcon name="disabled_by_default" />
</QItemSection> </QItemSection>
<QItemSection> <QItemSection>{{ t(itemComputed.title) }}</QItemSection>
{{ t(itemComputed.title) }}
<QTooltip v-if="item.keyBinding">
{{ 'Ctrl + Alt + ' + item?.keyBinding?.toUpperCase() }}
</QTooltip>
</QItemSection>
<QItemSection side> <QItemSection side>
<slot name="side" :item="itemComputed" /> <slot name="side" :item="itemComputed" />
</QItemSection> </QItemSection>
</QItem> </QItem>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.q-item { .q-item {
min-height: 5vh; min-height: 5vh;

View File

@ -1,37 +1,32 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useSession } from 'src/composables/useSession';
import { useState } from 'src/composables/useState'; import { useState } from 'src/composables/useState';
import { useStateStore } from 'stores/useStateStore'; import { useStateStore } from 'stores/useStateStore';
import { useStateQueryStore } from 'src/stores/useStateQueryStore';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import PinnedModules from './PinnedModules.vue'; import PinnedModules from './PinnedModules.vue';
import UserPanel from 'components/UserPanel.vue'; import UserPanel from 'components/UserPanel.vue';
import VnBreadcrumbs from './common/VnBreadcrumbs.vue'; import VnBreadcrumbs from './common/VnBreadcrumbs.vue';
import VnAvatar from './ui/VnAvatar.vue';
const { t } = useI18n(); const { t } = useI18n();
const stateStore = useStateStore(); const stateStore = useStateStore();
const quasar = useQuasar(); const quasar = useQuasar();
const stateQuery = useStateQueryStore();
const state = useState(); const state = useState();
const user = state.getUser(); const user = state.getUser();
const { getTokenMultimedia } = useSession();
const token = getTokenMultimedia();
const appName = 'Lilium'; const appName = 'Lilium';
const pinnedModulesRef = ref();
onMounted(() => stateStore.setMounted()); onMounted(() => stateStore.setMounted());
const refresh = () => window.location.reload();
const pinnedModulesRef = ref();
</script> </script>
<template> <template>
<QHeader color="white" elevated> <QHeader color="white" elevated>
<QToolbar class="q-py-sm q-px-md"> <QToolbar class="q-py-sm q-px-md">
<QBtn <QBtn @click="stateStore.toggleLeftDrawer()" icon="menu" round dense flat>
@click="stateStore.toggleLeftDrawer()"
icon="dock_to_right"
round
dense
flat
>
<QTooltip bottom anchor="bottom right"> <QTooltip bottom anchor="bottom right">
{{ t('globals.collapseMenu') }} {{ t('globals.collapseMenu') }}
</QTooltip> </QTooltip>
@ -51,27 +46,21 @@ const refresh = () => window.location.reload();
</QBtn> </QBtn>
</RouterLink> </RouterLink>
<VnBreadcrumbs v-if="$q.screen.gt.sm" /> <VnBreadcrumbs v-if="$q.screen.gt.sm" />
<QSpinner
color="primary"
class="q-ml-md"
:class="{
'no-visible': !stateQuery.isLoading().value,
}"
size="xs"
data-cy="loading-spinner"
/>
<QSpace /> <QSpace />
<div id="searchbar" class="searchbar"></div> <div id="searchbar" class="searchbar"></div>
<QSpace /> <QSpace />
<div class="q-pl-sm q-gutter-sm row items-center no-wrap"> <div class="q-pl-sm q-gutter-sm row items-center no-wrap">
<div id="actions-prepend"></div> <div id="actions-prepend"></div>
<QIcon <QBtn
name="refresh" flat
size="md" v-if="!quasar.platform.is.mobile"
color="red" @click="pinnedModulesRef.redirect($route.params.id)"
v-if="state.get('error')" icon="more_up"
@click="refresh" >
/> <QTooltip>
{{ t('Go to Salix') }}
</QTooltip>
</QBtn>
<QBtn <QBtn
:class="{ 'q-pa-none': quasar.platform.is.mobile }" :class="{ 'q-pa-none': quasar.platform.is.mobile }"
id="pinnedModules" id="pinnedModules"
@ -85,13 +74,21 @@ const refresh = () => window.location.reload();
</QTooltip> </QTooltip>
<PinnedModules ref="pinnedModulesRef" /> <PinnedModules ref="pinnedModulesRef" />
</QBtn> </QBtn>
<QBtn class="q-pa-none" rounded dense flat no-wrap id="user"> <QBtn
<VnAvatar :class="{ 'q-pa-none': quasar.platform.is.mobile }"
:worker-id="user.id" rounded
:title="user.name" dense
size="lg" flat
color="transparent" no-wrap
/> id="user"
>
<QAvatar size="lg">
<QImg
:src="`/api/Images/user/160x160/${user.id}/download?access_token=${token}`"
spinner-color="primary"
>
</QImg>
</QAvatar>
<QTooltip bottom> <QTooltip bottom>
{{ t('globals.userPanel') }} {{ t('globals.userPanel') }}
</QTooltip> </QTooltip>
@ -103,6 +100,7 @@ const refresh = () => window.location.reload();
<VnBreadcrumbs v-if="$q.screen.lt.md" class="q-ml-md" /> <VnBreadcrumbs v-if="$q.screen.lt.md" class="q-ml-md" />
</QHeader> </QHeader>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.searchbar { .searchbar {
width: max-content; width: max-content;
@ -111,3 +109,9 @@ const refresh = () => window.location.reload();
background-color: var(--vn-section-color); background-color: var(--vn-section-color);
} }
</style> </style>
<i18n>
en:
Go to Salix: Go to Salix
es:
Go to Salix: Ir a Salix
</i18n>

View File

@ -1,170 +0,0 @@
<script setup>
import { ref, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useDialogPluginComponent } from 'quasar';
import VnRow from 'components/ui/VnRow.vue';
import FetchData from 'components/FetchData.vue';
import VnSelect from 'components/common/VnSelect.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 { dialogRef } = useDialogPluginComponent();
const { t } = useI18n();
const router = useRouter();
const { notify } = useNotify();
const rectificativeTypeOptions = ref([]);
const siiTypeInvoiceOutsOptions = ref([]);
const invoiceParams = reactive({
id: $props.invoiceOutData?.id,
inheritWarehouse: true,
});
const invoiceCorrectionTypesOptions = ref([]);
const refund = async () => {
const params = {
id: invoiceParams.id,
withWarehouse: invoiceParams.inheritWarehouse,
cplusRectificationTypeFk: invoiceParams.cplusRectificationTypeFk,
siiTypeInvoiceOutFk: invoiceParams.siiTypeInvoiceOutFk,
invoiceCorrectionTypeFk: invoiceParams.invoiceCorrectionTypeFk,
};
const { data } = await axios.post('InvoiceOuts/refundAndInvoice', params);
notify(t('Refunded invoice'), 'positive');
const [id] = data?.refundId || [];
if (id) router.push({ name: 'InvoiceOutSummary', params: { id } });
};
</script>
<template>
<FetchData
url="CplusRectificationTypes"
:filter="{ order: 'description' }"
@on-fetch="
(data) => (
(rectificativeTypeOptions = data),
(invoiceParams.cplusRectificationTypeFk = data.filter(
(type) => type.description == 'I Por diferencias'
)[0].id)
)
"
auto-load
/>
<FetchData
url="SiiTypeInvoiceOuts"
:filter="{ where: { code: { like: 'R%' } } }"
@on-fetch="
(data) => (
(siiTypeInvoiceOutsOptions = data),
(invoiceParams.siiTypeInvoiceOutFk = data.filter(
(type) => type.code == 'R4'
)[0].id)
)
"
auto-load
/>
<FetchData
url="InvoiceCorrectionTypes"
@on-fetch="(data) => (invoiceCorrectionTypesOptions = data)"
auto-load
/>
<QDialog ref="dialogRef">
<FormPopup
@on-submit="refund()"
:custom-submit-button-label="t('Accept')"
:default-cancel-button="false"
>
<template #form-inputs>
<VnRow>
<VnSelect
:label="t('Rectificative type')"
:options="rectificativeTypeOptions"
hide-selected
option-label="description"
option-value="id"
v-model="invoiceParams.cplusRectificationTypeFk"
:required="true"
/>
</VnRow>
<VnRow>
<VnSelect
:label="t('Class')"
:options="siiTypeInvoiceOutsOptions"
hide-selected
option-label="description"
option-value="id"
v-model="invoiceParams.siiTypeInvoiceOutFk"
:required="true"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>
{{ scope.opt?.code }} -
{{ scope.opt?.description }}
</QItemLabel>
</QItemSection>
</QItem>
</template>
</VnSelect>
</VnRow>
<VnRow>
<VnSelect
:label="t('Type')"
:options="invoiceCorrectionTypesOptions"
hide-selected
option-label="description"
option-value="id"
v-model="invoiceParams.invoiceCorrectionTypeFk"
:required="true"
/> </VnRow
><VnRow>
<div>
<QCheckbox
:label="t('Inherit warehouse')"
v-model="invoiceParams.inheritWarehouse"
/>
<QIcon name="info" class="cursor-info q-ml-sm" size="sm">
<QTooltip>{{ t('Inherit warehouse tooltip') }}</QTooltip>
</QIcon>
</div>
</VnRow>
</template>
</FormPopup>
</QDialog>
</template>
<i18n>
en:
Refund invoice: Refund invoice
Rectificative type: Rectificative type
Class: Class
Type: Type
Refunded invoice: Refunded invoice
Inherit warehouse: Inherit the warehouse
Inherit warehouse tooltip: Select this option to inherit the warehouse when refunding the invoice
Accept: Accept
Error refunding invoice: Error refunding invoice
es:
Refund invoice: Abonar factura
Rectificative type: Tipo rectificativa
Class: Clase
Type: Tipo
Refunded invoice: Factura abonada
Inherit warehouse: Heredar el almacén
Inherit warehouse tooltip: Seleccione esta opción para heredar el almacén al abonar la factura.
Accept: Aceptar
Error refunding invoice: Error abonando factura
</i18n>

View File

@ -15,7 +15,7 @@ const props = defineProps({
default: null, default: null,
}, },
warehouseFk: { warehouseFk: {
type: Number, type: Boolean,
default: null, default: null,
}, },
}); });
@ -23,7 +23,7 @@ const props = defineProps({
const { t } = useI18n(); const { t } = useI18n();
const regularizeFormData = reactive({ const regularizeFormData = reactive({
itemFk: Number(props.itemFk), itemFk: props.itemFk,
warehouseFk: props.warehouseFk, warehouseFk: props.warehouseFk,
quantity: null, quantity: null,
}); });
@ -44,25 +44,23 @@ const onDataSaved = (data) => {
<FormModelPopup <FormModelPopup
url-create="Items/regularize" url-create="Items/regularize"
model="Items" model="Items"
:title="t('item.regularizeStock')" :title="t('Regularize stock')"
:form-initial-data="regularizeFormData" :form-initial-data="regularizeFormData"
@on-data-saved="onDataSaved($event)" @on-data-saved="onDataSaved($event)"
> >
<template #form-inputs="{ data }"> <template #form-inputs="{ data }">
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<QInput <QInput
:label="t('Type the visible quantity')" :label="t('Type the visible quantity')"
v-model.number="data.quantity" v-model.number="data.quantity"
type="number"
autofocus autofocus
data-cy="regularizeStockInput"
/> />
</VnRow> </VnRow>
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<div class="col"> <div class="col">
<VnSelect <VnSelect
:label="t('Warehouse')" :label="t('Warehouse')"
v-model.number="data.warehouseFk" v-model="data.warehouseFk"
:options="warehousesOptions" :options="warehousesOptions"
option-value="id" option-value="id"
option-label="name" option-label="name"

View File

@ -1,70 +0,0 @@
<script setup>
defineProps({ row: { type: Object, required: true } });
</script>
<template>
<span class="q-gutter-x-xs">
<QIcon
v-if="row?.risk"
name="vn:risk"
:color="row.hasHighRisk ? 'negative' : 'primary'"
size="xs"
>
<QTooltip>
{{ $t('salesTicketsTable.risk') }}: {{ row.risk - row.credit }}
</QTooltip>
</QIcon>
<QIcon
v-if="row?.hasComponentLack"
name="vn:components"
color="primary"
size="xs"
>
<QTooltip>{{ $t('salesTicketsTable.componentLack') }}</QTooltip>
</QIcon>
<QIcon v-if="row?.hasItemDelay" color="primary" size="xs" name="vn:hasItemDelay">
<QTooltip>
{{ $t('ticket.summary.hasItemDelay') }}
</QTooltip>
</QIcon>
<QIcon v-if="row?.hasItemLost" color="primary" size="xs" name="vn:hasItemLost">
<QTooltip>
{{ $t('salesTicketsTable.hasItemLost') }}
</QTooltip>
</QIcon>
<QIcon
v-if="row?.hasItemShortage"
name="vn:unavailable"
color="primary"
size="xs"
>
<QTooltip>{{ $t('salesTicketsTable.notVisible') }}</QTooltip>
</QIcon>
<QIcon v-if="row?.hasRounding" color="primary" name="sync_problem" size="xs">
<QTooltip>
{{ $t('ticketList.rounding') }}
</QTooltip>
</QIcon>
<QIcon
v-if="row?.hasTicketRequest"
name="vn:buyrequest"
color="primary"
size="xs"
>
<QTooltip>{{ $t('salesTicketsTable.purchaseRequest') }}</QTooltip>
</QIcon>
<QIcon
v-if="!row?.isTaxDataChecked === 0"
name="vn:no036"
color="primary"
size="xs"
>
<QTooltip>{{ $t('salesTicketsTable.noVerifiedData') }}</QTooltip>
</QIcon>
<QIcon v-if="row?.isFreezed" name="vn:frozen" color="primary" size="xs">
<QTooltip>{{ $t('salesTicketsTable.clientFrozen') }}</QTooltip>
</QIcon>
<QIcon v-if="row?.isTooLittle" name="vn:isTooLittle" color="primary" size="xs">
<QTooltip>{{ $t('salesTicketsTable.tooLittle') }}</QTooltip>
</QIcon>
</span>
</template>

View File

@ -2,12 +2,13 @@
import { ref, reactive } from 'vue'; import { ref, reactive } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useQuasar, useDialogPluginComponent } from 'quasar'; import { useQuasar } from 'quasar';
import VnConfirm from 'components/ui/VnConfirm.vue'; import VnConfirm from 'components/ui/VnConfirm.vue';
import VnRow from 'components/ui/VnRow.vue'; import VnRow from 'components/ui/VnRow.vue';
import FetchData from 'components/FetchData.vue'; import FetchData from 'components/FetchData.vue';
import VnSelect from 'components/common/VnSelect.vue'; import VnSelect from 'components/common/VnSelect.vue';
import FormPopup from './FormPopup.vue'; import FormPopup from './FormPopup.vue';
import axios from 'axios'; import axios from 'axios';
import useNotify from 'src/composables/useNotify.js'; import useNotify from 'src/composables/useNotify.js';
@ -18,18 +19,18 @@ const $props = defineProps({
}, },
}); });
const { dialogRef } = useDialogPluginComponent();
const quasar = useQuasar(); const quasar = useQuasar();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const { notify } = useNotify(); const { notify } = useNotify();
const rectificativeTypeOptions = ref([]);
const siiTypeInvoiceOutsOptions = ref([]);
const checked = ref(true); const checked = ref(true);
const transferInvoiceParams = reactive({ const transferInvoiceParams = reactive({
id: $props.invoiceOutData?.id, id: $props.invoiceOutData?.id,
refFk: $props.invoiceOutData?.ref,
}); });
const rectificativeTypeOptions = ref([]);
const siiTypeInvoiceOutsOptions = ref([]);
const invoiceCorrectionTypesOptions = ref([]); const invoiceCorrectionTypesOptions = ref([]);
const selectedClient = (client) => { const selectedClient = (client) => {
@ -43,12 +44,14 @@ const makeInvoice = async () => {
const params = { const params = {
id: transferInvoiceParams.id, id: transferInvoiceParams.id,
cplusRectificationTypeFk: transferInvoiceParams.cplusRectificationTypeFk, cplusRectificationTypeFk: transferInvoiceParams.cplusRectificationTypeFk,
siiTypeInvoiceOutFk: transferInvoiceParams.siiTypeInvoiceOutFk,
invoiceCorrectionTypeFk: transferInvoiceParams.invoiceCorrectionTypeFk, invoiceCorrectionTypeFk: transferInvoiceParams.invoiceCorrectionTypeFk,
newClientFk: transferInvoiceParams.newClientFk, newClientFk: transferInvoiceParams.newClientFk,
refFk: transferInvoiceParams.refFk,
siiTypeInvoiceOutFk: transferInvoiceParams.siiTypeInvoiceOutFk,
makeInvoice: checked.value, makeInvoice: checked.value,
}; };
try {
if (checked.value && hasToInvoiceByAddress) { if (checked.value && hasToInvoiceByAddress) {
const response = await new Promise((resolve) => { const response = await new Promise((resolve) => {
quasar quasar
@ -71,10 +74,13 @@ const makeInvoice = async () => {
} }
} }
const { data } = await axios.post('InvoiceOuts/transfer', params); const { data } = await axios.post('InvoiceOuts/transferInvoice', params);
notify(t('Transferred invoice'), 'positive'); notify(t('Transferred invoice'), 'positive');
const id = data?.[0]; const id = data?.[0];
if (id) router.push({ name: 'InvoiceOutSummary', params: { id } }); if (id) router.push({ name: 'InvoiceOutSummary', params: { id } });
} catch (err) {
console.error('Error transfering invoice', err);
}
}; };
</script> </script>
@ -110,7 +116,6 @@ const makeInvoice = async () => {
@on-fetch="(data) => (invoiceCorrectionTypesOptions = data)" @on-fetch="(data) => (invoiceCorrectionTypesOptions = data)"
auto-load auto-load
/> />
<QDialog ref="dialogRef">
<FormPopup <FormPopup
@on-submit="makeInvoice()" @on-submit="makeInvoice()"
:title="t('Transfer invoice')" :title="t('Transfer invoice')"
@ -118,7 +123,7 @@ const makeInvoice = async () => {
:default-cancel-button="false" :default-cancel-button="false"
> >
<template #form-inputs> <template #form-inputs>
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<VnSelect <VnSelect
:label="t('Client')" :label="t('Client')"
:options="clientsOptions" :options="clientsOptions"
@ -154,7 +159,7 @@ const makeInvoice = async () => {
:required="true" :required="true"
/> />
</VnRow> </VnRow>
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<VnSelect <VnSelect
:label="t('Class')" :label="t('Class')"
:options="siiTypeInvoiceOutsOptions" :options="siiTypeInvoiceOutsOptions"
@ -185,12 +190,9 @@ const makeInvoice = async () => {
:required="true" :required="true"
/> />
</VnRow> </VnRow>
<VnRow> <VnRow class="row q-gutter-md q-mb-md">
<div> <div>
<QCheckbox <QCheckbox :label="t('Bill destination client')" v-model="checked" />
:label="t('Bill destination client')"
v-model="checked"
/>
<QIcon name="info" class="cursor-info q-ml-sm" size="sm"> <QIcon name="info" class="cursor-info q-ml-sm" size="sm">
<QTooltip>{{ t('transferInvoiceInfo') }}</QTooltip> <QTooltip>{{ t('transferInvoiceInfo') }}</QTooltip>
</QIcon> </QIcon>
@ -198,7 +200,6 @@ const makeInvoice = async () => {
</VnRow> </VnRow>
</template> </template>
</FormPopup> </FormPopup>
</QDialog>
</template> </template>
<i18n> <i18n>

View File

@ -1,9 +1,6 @@
<script setup> <script setup>
import quasarLang from 'src/utils/quasarLang';
import { onMounted, computed, ref } from 'vue'; import { onMounted, computed, ref } from 'vue';
import { Dark, Quasar } from 'quasar';
import { Dark } from 'quasar';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
@ -14,17 +11,13 @@ import VnSelect from 'src/components/common/VnSelect.vue';
import VnRow from 'components/ui/VnRow.vue'; import VnRow from 'components/ui/VnRow.vue';
import FetchData from 'components/FetchData.vue'; import FetchData from 'components/FetchData.vue';
import { useClipboard } from 'src/composables/useClipboard'; import { useClipboard } from 'src/composables/useClipboard';
import { useRole } from 'src/composables/useRole'; import VnImg from 'src/components/ui/VnImg.vue';
import VnAvatar from './ui/VnAvatar.vue';
import useNotify from 'src/composables/useNotify';
const state = useState(); const state = useState();
const session = useSession(); const session = useSession();
const router = useRouter(); const router = useRouter();
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { copyText } = useClipboard(); const { copyText } = useClipboard();
const { notify } = useNotify();
const userLocale = computed({ const userLocale = computed({
get() { get() {
return locale.value; return locale.value;
@ -34,7 +27,14 @@ const userLocale = computed({
value = localeEquivalence[value] ?? value; value = localeEquivalence[value] ?? value;
quasarLang(value); try {
/* @vite-ignore */
import(`../../node_modules/quasar/lang/${value}.mjs`).then((lang) => {
Quasar.lang.set(lang.default);
});
} catch (error) {
//
}
}, },
}); });
@ -51,7 +51,6 @@ const user = state.getUser();
const warehousesData = ref(); const warehousesData = ref();
const companiesData = ref(); const companiesData = ref();
const accountBankData = ref(); const accountBankData = ref();
const isEmployee = computed(() => useRole().isEmployee());
onMounted(async () => { onMounted(async () => {
updatePreferences(); updatePreferences();
@ -69,28 +68,18 @@ function updatePreferences() {
async function saveDarkMode(value) { async function saveDarkMode(value) {
const query = `/UserConfigs/${user.value.id}`; const query = `/UserConfigs/${user.value.id}`;
try {
await axios.patch(query, { await axios.patch(query, {
darkMode: value, darkMode: value,
}); });
user.value.darkMode = value; user.value.darkMode = value;
onDataSaved();
} catch (error) {
onDataError();
}
} }
async function saveLanguage(value) { async function saveLanguage(value) {
const query = `/VnUsers/${user.value.id}`; const query = `/VnUsers/${user.value.id}`;
try { await axios.patch(query, {
await axios.patch(query, { lang: value }); lang: value,
});
user.value.lang = value; user.value.lang = value;
useState().setUser(user.value);
onDataSaved();
} catch (error) {
onDataError();
}
} }
function logout() { function logout() {
@ -106,23 +95,10 @@ function localUserData() {
state.setUser(user.value); state.setUser(user.value);
} }
async function saveUserData(param, value) { function saveUserData(param, value) {
try { axios.post('UserConfigs/setUserConfig', { [param]: value });
await axios.post('UserConfigs/setUserConfig', { [param]: value });
localUserData(); localUserData();
onDataSaved();
} catch (error) {
onDataError();
} }
}
const onDataSaved = () => {
notify('globals.dataSaved', 'positive');
};
const onDataError = () => {
notify('errors.updateUserConfig', 'negative');
};
</script> </script>
<template> <template>
@ -133,14 +109,12 @@ const onDataError = () => {
auto-load auto-load
/> />
<FetchData <FetchData
v-if="isEmployee"
url="Companies" url="Companies"
order="name" order="name"
@on-fetch="(data) => (companiesData = data)" @on-fetch="(data) => (companiesData = data)"
auto-load auto-load
/> />
<FetchData <FetchData
v-if="isEmployee"
url="Accountings" url="Accountings"
order="name" order="name"
@on-fetch="(data) => (accountBankData = data)" @on-fetch="(data) => (accountBankData = data)"
@ -157,7 +131,7 @@ const onDataError = () => {
@update:model-value="saveLanguage" @update:model-value="saveLanguage"
:label="t(`globals.lang['${userLocale}']`)" :label="t(`globals.lang['${userLocale}']`)"
icon="public" icon="public"
color="primary" color="orange"
false-value="es" false-value="es"
true-value="en" true-value="en"
/> />
@ -166,7 +140,7 @@ const onDataError = () => {
@update:model-value="saveDarkMode" @update:model-value="saveDarkMode"
:label="t(`globals.darkMode`)" :label="t(`globals.darkMode`)"
checked-icon="dark_mode" checked-icon="dark_mode"
color="primary" color="orange"
unchecked-icon="light_mode" unchecked-icon="light_mode"
/> />
</div> </div>
@ -174,20 +148,10 @@ const onDataError = () => {
<QSeparator vertical inset class="q-mx-lg" /> <QSeparator vertical inset class="q-mx-lg" />
<div class="col column items-center q-mb-sm"> <div class="col column items-center q-mb-sm">
<VnAvatar <QAvatar size="80px">
:worker-id="user.id" <VnImg :id="user.id" collection="user" size="160x160" />
:title="user.name" </QAvatar>
size="xxl"
color="transparent"
/>
<QBtn
v-if="isEmployee"
class="q-mt-sm q-px-md"
:to="`/worker/${user.id}`"
color="primary"
:label="t('globals.myAccount')"
dense
/>
<div class="text-subtitle1 q-mt-md"> <div class="text-subtitle1 q-mt-md">
<strong>{{ user.nickname }}</strong> <strong>{{ user.nickname }}</strong>
</div> </div>
@ -199,7 +163,7 @@ const onDataError = () => {
</div> </div>
<QBtn <QBtn
id="logout" id="logout"
color="primary" color="orange"
flat flat
:label="t('globals.logOut')" :label="t('globals.logOut')"
size="sm" size="sm"

View File

@ -1,97 +0,0 @@
<script setup>
import { ref, watch } from 'vue';
import { useValidator } from 'src/composables/useValidator';
import { useI18n } from 'vue-i18n';
import VnSelectDialog from 'components/common/VnSelectDialog.vue';
import FetchData from 'components/FetchData.vue';
import CreateNewProvinceForm from './CreateNewProvinceForm.vue';
const emit = defineEmits(['onProvinceCreated', 'onProvinceFetched', 'update:options']);
const $props = defineProps({
countryFk: {
type: Number,
default: null,
},
provinceSelected: {
type: Number,
default: null,
},
});
const provinceFk = defineModel({ type: Number, default: null });
const { validate } = useValidator();
const { t } = useI18n();
const filter = ref({
include: { relation: 'country' },
where: {
countryFk: $props.countryFk,
},
});
const provincesOptions = ref($props.provinces);
const provincesFetchDataRef = ref();
provinceFk.value = $props.provinceSelected;
if (!$props.countryFk) {
filter.value.where = {};
}
async function onProvinceCreated(_, data) {
await provincesFetchDataRef.value.fetch({ where: { countryFk: $props.countryFk } });
provinceFk.value = data.id;
emit('onProvinceCreated', data);
}
async function handleProvinces(data) {
provincesOptions.value = data;
emit('update:options', data);
}
watch(
() => $props.countryFk,
async () => {
if ($props.countryFk) {
filter.value.where.countryFk = $props.countryFk;
} else filter.value.where = {};
await provincesFetchDataRef.value.fetch({});
emit('onProvinceFetched', provincesOptions.value);
}
);
</script>
<template>
<FetchData
ref="provincesFetchDataRef"
:filter="filter"
@on-fetch="handleProvinces"
url="Provinces"
auto-load
/>
<VnSelectDialog
data-cy="locationProvince"
:label="t('Province')"
:options="provincesOptions"
:tooltip="t('Create province')"
hide-selected
v-model="provinceFk"
:rules="validate && validate('postcode.provinceFk')"
:acls="[{ model: 'Province', props: '*', accessType: 'WRITE' }]"
>
<template #option="{ itemProps, opt }">
<QItem v-bind="itemProps">
<QItemSection>
<QItemLabel>{{ opt.name }}</QItemLabel>
<QItemLabel caption> {{ opt.country.name }} </QItemLabel>
</QItemSection>
</QItem>
</template>
<template #form>
<CreateNewProvinceForm
:country-fk="$props.countryFk"
@on-data-saved="onProvinceCreated"
/>
</template>
</VnSelectDialog>
</template>
<i18n>
es:
Province: Provincia
Create province: Crear provincia
</i18n>

View File

@ -35,9 +35,7 @@ function stopEventPropagation(event) {
dense dense
square square
> >
<span v-if="!col.chip.icon"> <span v-if="!col.chip.icon">{{ row[col.name] }}</span>
{{ col.format ? col.format(row) : row[col.name] }}
</span>
<QIcon v-else :name="col.chip.icon" color="primary-light" /> <QIcon v-else :name="col.chip.icon" color="primary-light" />
</QChip> </QChip>
</span> </span>

View File

@ -1,17 +1,13 @@
<script setup> <script setup>
import { markRaw, computed } from 'vue'; import { markRaw, computed, defineModel } from 'vue';
import { QIcon, QCheckbox } from 'quasar'; import { QIcon, QCheckbox } from 'quasar';
import { dashIfEmpty } from 'src/filters'; import { dashIfEmpty } from 'src/filters';
/* basic input */ /* basic input */
import VnSelect from 'components/common/VnSelect.vue'; import VnSelect from 'components/common/VnSelect.vue';
import VnSelectCache from 'components/common/VnSelectCache.vue';
import VnInput from 'components/common/VnInput.vue'; import VnInput from 'components/common/VnInput.vue';
import VnInputNumber from 'components/common/VnInputNumber.vue';
import VnInputDate from 'components/common/VnInputDate.vue'; import VnInputDate from 'components/common/VnInputDate.vue';
import VnInputTime from 'components/common/VnInputTime.vue';
import VnComponent from 'components/common/VnComponent.vue'; import VnComponent from 'components/common/VnComponent.vue';
import VnUserLink from 'components/ui/VnUserLink.vue';
const model = defineModel(undefined, { required: true }); const model = defineModel(undefined, { required: true });
const $props = defineProps({ const $props = defineProps({
@ -45,33 +41,20 @@ const $props = defineProps({
}, },
}); });
const defaultSelect = {
attrs: {
row: $props.row,
disable: !$props.isEditable,
class: 'fit',
},
forceAttrs: {
label: $props.showLabel && $props.column.label,
},
};
const defaultComponents = { const defaultComponents = {
input: { input: {
component: markRaw(VnInput), component: markRaw(VnInput),
attrs: { attrs: {
disable: !$props.isEditable, disable: !$props.isEditable,
class: 'fit',
}, },
forceAttrs: { forceAttrs: {
label: $props.showLabel && $props.column.label, label: $props.showLabel && $props.column.label,
}, },
}, },
number: { number: {
component: markRaw(VnInputNumber), component: markRaw(VnInput),
attrs: { attrs: {
disable: !$props.isEditable, disable: !$props.isEditable,
class: 'fit',
}, },
forceAttrs: { forceAttrs: {
label: $props.showLabel && $props.column.label, label: $props.showLabel && $props.column.label,
@ -80,19 +63,9 @@ const defaultComponents = {
date: { date: {
component: markRaw(VnInputDate), component: markRaw(VnInputDate),
attrs: { attrs: {
readonly: !$props.isEditable, readonly: true,
disable: !$props.isEditable, disable: !$props.isEditable,
style: 'min-width: 125px', style: 'min-width: 125px',
class: 'fit',
},
forceAttrs: {
label: $props.showLabel && $props.column.label,
},
},
time: {
component: markRaw(VnInputTime),
attrs: {
disable: !$props.isEditable,
}, },
forceAttrs: { forceAttrs: {
label: $props.showLabel && $props.column.label, label: $props.showLabel && $props.column.label,
@ -100,14 +73,14 @@ const defaultComponents = {
}, },
checkbox: { checkbox: {
component: markRaw(QCheckbox), component: markRaw(QCheckbox),
attrs: ({ model }) => { attrs: (prop) => {
const defaultAttrs = { const defaultAttrs = {
disable: !$props.isEditable, disable: !$props.isEditable,
'model-value': Boolean(model), 'model-value': Boolean(prop),
class: 'no-padding fit', class: 'no-padding',
}; };
if (typeof model == 'number') { if (typeof prop == 'number') {
defaultAttrs['true-value'] = 1; defaultAttrs['true-value'] = 1;
defaultAttrs['false-value'] = 0; defaultAttrs['false-value'] = 0;
} }
@ -118,19 +91,17 @@ const defaultComponents = {
}, },
}, },
select: { select: {
component: markRaw(VnSelectCache),
...defaultSelect,
},
rawSelect: {
component: markRaw(VnSelect), component: markRaw(VnSelect),
...defaultSelect, attrs: {
disable: !$props.isEditable,
},
forceAttrs: {
label: $props.showLabel && $props.column.label,
},
}, },
icon: { icon: {
component: markRaw(QIcon), component: markRaw(QIcon),
}, },
userLink: {
component: markRaw(VnUserLink),
},
}; };
const value = computed(() => { const value = computed(() => {
@ -151,8 +122,8 @@ const col = computed(() => {
}; };
} }
if ( if (
(/^is[A-Z]/.test(newColumn.name) || /^has[A-Z]/.test(newColumn.name)) && (newColumn.name.startsWith('is') || newColumn.name.startsWith('has')) &&
newColumn.component == null !newColumn.component
) )
newColumn.component = 'checkbox'; newColumn.component = 'checkbox';
if ($props.default && !newColumn.component) newColumn.component = $props.default; if ($props.default && !newColumn.component) newColumn.component = $props.default;
@ -163,19 +134,19 @@ const col = computed(() => {
const components = computed(() => $props.components ?? defaultComponents); const components = computed(() => $props.components ?? defaultComponents);
</script> </script>
<template> <template>
<div class="row no-wrap"> <div class="row no-wrap fit">
<VnComponent <VnComponent
v-if="col.before" v-if="col.before"
:prop="col.before" :prop="col.before"
:components="components" :components="components"
:value="{ row, model }" :value="model"
v-model="model" v-model="model"
/> />
<VnComponent <VnComponent
v-if="col.component" v-if="col.component"
:prop="col" :prop="col"
:components="components" :components="components"
:value="{ row, model }" :value="model"
v-model="model" v-model="model"
/> />
<span :title="value" v-else>{{ value }}</span> <span :title="value" v-else>{{ value }}</span>
@ -183,7 +154,7 @@ const components = computed(() => $props.components ?? defaultComponents);
v-if="col.after" v-if="col.after"
:prop="col.after" :prop="col.after"
:components="components" :components="components"
:value="{ row, model }" :value="model"
v-model="model" v-model="model"
/> />
</div> </div>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { markRaw, computed } from 'vue'; import { markRaw, computed, defineModel } from 'vue';
import { QCheckbox } from 'quasar'; import { QCheckbox } from 'quasar';
import { useArrayData } from 'composables/useArrayData'; import { useArrayData } from 'composables/useArrayData';
@ -7,7 +7,6 @@ import { useArrayData } from 'composables/useArrayData';
import VnSelect from 'components/common/VnSelect.vue'; import VnSelect from 'components/common/VnSelect.vue';
import VnInput from 'components/common/VnInput.vue'; import VnInput from 'components/common/VnInput.vue';
import VnInputDate from 'components/common/VnInputDate.vue'; import VnInputDate from 'components/common/VnInputDate.vue';
import VnInputTime from 'components/common/VnInputTime.vue';
import VnTableColumn from 'components/VnTable/VnColumn.vue'; import VnTableColumn from 'components/VnTable/VnColumn.vue';
const $props = defineProps({ const $props = defineProps({
@ -25,17 +24,11 @@ const $props = defineProps({
}, },
searchUrl: { searchUrl: {
type: String, type: String,
default: 'table', default: 'params',
}, },
}); });
defineExpose({ addFilter, props: $props });
const model = defineModel(undefined, { required: true }); const model = defineModel(undefined, { required: true });
const arrayData = useArrayData( const arrayData = useArrayData($props.dataKey, { searchUrl: $props.searchUrl });
$props.dataKey,
$props.searchUrl ? { searchUrl: $props.searchUrl } : null
);
const columnFilter = computed(() => $props.column?.columnFilter); const columnFilter = computed(() => $props.column?.columnFilter);
const updateEvent = { 'update:modelValue': addFilter }; const updateEvent = { 'update:modelValue': addFilter };
@ -46,23 +39,12 @@ const enterEvent = {
const defaultAttrs = { const defaultAttrs = {
filled: !$props.showTitle, filled: !$props.showTitle,
class: 'q-px-xs q-pb-xs q-pt-none fit', class: 'q-px-sm q-pb-xs q-pt-none',
dense: true, dense: true,
}; };
const forceAttrs = { const forceAttrs = {
label: $props.showTitle ? '' : columnFilter.value?.label ?? $props.column.label, label: $props.showTitle ? '' : $props.column.label,
};
const selectComponent = {
component: markRaw(VnSelect),
event: updateEvent,
attrs: {
class: 'q-px-sm q-pb-xs q-pt-none fit',
dense: true,
filled: !$props.showTitle,
},
forceAttrs,
}; };
const components = { const components = {
@ -81,7 +63,6 @@ const components = {
attrs: { attrs: {
...defaultAttrs, ...defaultAttrs,
clearable: true, clearable: true,
type: 'number',
}, },
forceAttrs, forceAttrs,
}, },
@ -94,36 +75,33 @@ const components = {
}, },
forceAttrs, forceAttrs,
}, },
time: {
component: markRaw(VnInputTime),
event: updateEvent,
attrs: {
...defaultAttrs,
disable: !$props.isEditable,
},
forceAttrs: {
label: $props.showLabel && $props.column.label,
},
},
checkbox: { checkbox: {
component: markRaw(QCheckbox), component: markRaw(QCheckbox),
event: updateEvent, event: updateEvent,
attrs: { attrs: {
dense: true, dense: true,
class: $props.showTitle ? 'q-py-sm q-mt-md' : 'q-px-md q-py-xs fit', class: $props.showTitle ? 'q-py-sm q-mt-md' : 'q-px-md q-py-xs',
'toggle-indeterminate': true, 'toggle-indeterminate': true,
}, },
forceAttrs, forceAttrs,
}, },
select: selectComponent, select: {
rawSelect: selectComponent, component: markRaw(VnSelect),
event: updateEvent,
attrs: {
class: 'q-px-md q-pb-xs q-pt-none',
dense: true,
filled: !$props.showTitle,
},
forceAttrs,
},
}; };
async function addFilter(value, name) { async function addFilter(value) {
value ??= undefined; value ??= undefined;
if (value && typeof value === 'object') value = model.value; if (value && typeof value === 'object') value = model.value;
value = value === '' ? undefined : value; value = value === '' ? undefined : value;
let field = columnFilter.value?.name ?? $props.column.name ?? name; let field = columnFilter.value?.name ?? $props.column.name;
if (columnFilter.value?.inWhere) { if (columnFilter.value?.inWhere) {
if (columnFilter.value.alias) field = columnFilter.value.alias + '.' + field; if (columnFilter.value.alias) field = columnFilter.value.alias + '.' + field;
@ -146,25 +124,23 @@ function alignRow() {
const showFilter = computed( const showFilter = computed(
() => $props.column?.columnFilter !== false && $props.column.name != 'tableActions' () => $props.column?.columnFilter !== false && $props.column.name != 'tableActions'
); );
const onTabPressed = async () => {
if (model.value) enterEvent['keyup.enter']();
};
</script> </script>
<template> <template>
<div <div
v-if="showFilter" v-if="showTitle"
class="full-width" class="q-pt-sm q-px-sm ellipsis"
:class="alignRow()" :class="`text-${column?.align ?? 'left'}`"
style="max-height: 45px; overflow: hidden" :style="!showFilter ? { 'min-height': 72 + 'px' } : ''"
> >
{{ column?.label }}
</div>
<div v-if="showFilter" class="full-width" :class="alignRow()">
<VnTableColumn <VnTableColumn
:column="$props.column" :column="$props.column"
default="input" default="input"
v-model="model" v-model="model"
:components="components" :components="components"
component-prop="columnFilter" component-prop="columnFilter"
@keydown.tab="onTabPressed"
/> />
</div> </div>
</template> </template>

View File

@ -1,95 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useArrayData } from 'composables/useArrayData';
const model = defineModel({ type: Object });
const $props = defineProps({
name: {
type: [String, Boolean],
default: '',
},
label: {
type: String,
default: undefined,
},
dataKey: {
type: String,
required: true,
},
searchUrl: {
type: String,
default: 'table',
},
vertical: {
type: Boolean,
default: false,
},
});
const hover = ref();
const arrayData = useArrayData($props.dataKey, { searchUrl: $props.searchUrl });
async function orderBy(name, direction) {
if (!name) return;
switch (direction) {
case 'DESC':
direction = undefined;
break;
case undefined:
direction = 'ASC';
break;
case 'ASC':
direction = 'DESC';
break;
}
if (!direction) return await arrayData.deleteOrder(name);
await arrayData.addOrder(name, direction);
}
defineExpose({ orderBy });
</script>
<template>
<div
@mouseenter="hover = true"
@mouseleave="hover = false"
@click="orderBy(name, model?.direction)"
class="row items-center no-wrap cursor-pointer"
>
<span :title="label">{{ label }}</span>
<QChip
v-if="name"
:label="!vertical ? model?.index : ''"
:icon="
(model?.index || hover) && !vertical
? model?.direction == 'DESC'
? 'arrow_downward'
: 'arrow_upward'
: undefined
"
:size="vertical ? '' : 'sm'"
:class="[
model?.index ? 'color-vn-text' : 'bg-transparent',
vertical ? 'q-px-none' : '',
]"
class="no-box-shadow"
:clickable="true"
style="min-width: 40px"
>
<div
class="column flex-center"
v-if="vertical"
:style="!model?.index && 'color: #5d5d5d'"
>
{{ model?.index }}
<QIcon
:name="
model?.index
? model?.direction == 'DESC'
? 'arrow_downward'
: 'arrow_upward'
: 'swap_vert'
"
size="xs"
/>
</div>
</QChip>
</div>
</template>

View File

@ -1,21 +1,18 @@
<script setup> <script setup>
import { ref, onBeforeMount, onMounted, computed, watch, useAttrs } from 'vue'; import { ref, onMounted, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { useStateStore } from 'stores/useStateStore';
import { useFilterParams } from 'src/composables/useFilterParams';
import CrudModel from 'src/components/CrudModel.vue'; import { useStateStore } from 'stores/useStateStore';
import FormModelPopup from 'components/FormModelPopup.vue'; import FormModelPopup from 'components/FormModelPopup.vue';
import CrudModel from 'src/components/CrudModel.vue';
import VnFilterPanel from 'components/ui/VnFilterPanel.vue';
import VnLv from 'components/ui/VnLv.vue';
import VnTableColumn from 'components/VnTable/VnColumn.vue'; import VnTableColumn from 'components/VnTable/VnColumn.vue';
import VnFilter from 'components/VnTable/VnFilter.vue'; import VnTableFilter from 'components/VnTable/VnFilter.vue';
import VnTableChip from 'components/VnTable/VnChip.vue'; import VnTableChip from 'components/VnTable/VnChip.vue';
import VnVisibleColumn from 'src/components/VnTable/VnVisibleColumn.vue';
import VnLv from 'components/ui/VnLv.vue';
import VnTableOrder from 'src/components/VnTable/VnOrder.vue';
import VnTableFilter from './VnTableFilter.vue';
const $props = defineProps({ const $props = defineProps({
columns: { columns: {
@ -24,7 +21,7 @@ const $props = defineProps({
}, },
defaultMode: { defaultMode: {
type: String, type: String,
default: 'table', // 'table', 'card' default: 'card', // 'table', 'card'
}, },
columnSearch: { columnSearch: {
type: Boolean, type: Boolean,
@ -34,16 +31,8 @@ const $props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
rightSearchIcon: {
type: Boolean,
default: true,
},
rowClick: { rowClick: {
type: [Function, Boolean], type: Function,
default: null,
},
rowCtrlClick: {
type: [Function, Boolean],
default: null, default: null,
}, },
redirect: { redirect: {
@ -54,20 +43,12 @@ const $props = defineProps({
type: Object, type: Object,
default: null, default: null,
}, },
createAsDialog: {
type: Boolean,
default: true,
},
bottom: {
type: Boolean,
default: false,
},
cardClass: { cardClass: {
type: String, type: String,
default: 'flex-one', default: 'flex-one',
}, },
searchUrl: { searchUrl: {
type: [String, Boolean], type: String,
default: 'table', default: 'table',
}, },
isEditable: { isEditable: {
@ -78,41 +59,9 @@ const $props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
hasSubToolbar: { hasSubtoolbar: {
type: Boolean, type: Boolean,
default: null, default: true,
},
disableOption: {
type: Object,
default: () => ({ card: false, table: false }),
},
withoutHeader: {
type: Boolean,
default: false,
},
tableCode: {
type: String,
default: null,
},
table: {
type: Object,
default: () => ({}),
},
crudModel: {
type: Object,
default: () => ({}),
},
tableHeight: {
type: String,
default: '90vh',
},
footer: {
type: Boolean,
default: false,
},
disabledAttr: {
type: Boolean,
default: false,
}, },
}); });
const { t } = useI18n(); const { t } = useI18n();
@ -120,99 +69,78 @@ const stateStore = useStateStore();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const quasar = useQuasar(); const quasar = useQuasar();
const $attrs = useAttrs();
const CARD_MODE = 'card'; const DEFAULT_MODE = 'card';
const TABLE_MODE = 'table'; const TABLE_MODE = 'table';
const mode = ref(CARD_MODE); const mode = ref(DEFAULT_MODE);
const selected = ref([]); const selected = ref([]);
const hasParams = ref(false); const routeQuery = JSON.parse(route?.query[$props.searchUrl] ?? '{}');
const params = ref({ ...routeQuery, ...routeQuery.filter?.where });
const CrudModelRef = ref({}); const CrudModelRef = ref({});
const showForm = ref(false); const showForm = ref(false);
const splittedColumns = ref({ columns: [] }); const splittedColumns = ref({ columns: [] });
const columnsVisibilitySkipped = ref();
const createForm = ref();
const tableRef = ref();
const params = ref(useFilterParams($attrs['data-key']).params);
const orders = ref(useFilterParams($attrs['data-key']).orders);
const tableModes = [ const tableModes = [
{ {
icon: 'view_column', icon: 'view_column',
title: t('table view'), title: t('table view'),
value: TABLE_MODE, value: TABLE_MODE,
disable: $props.disableOption?.table,
}, },
{ {
icon: 'grid_view', icon: 'grid_view',
title: t('grid view'), title: t('grid view'),
value: CARD_MODE, value: DEFAULT_MODE,
disable: $props.disableOption?.card,
}, },
]; ];
onBeforeMount(() => {
const urlParams = route.query[$props.searchUrl];
hasParams.value = urlParams && Object.keys(urlParams).length !== 0;
});
onMounted(() => { onMounted(() => {
mode.value = mode.value = quasar.platform.is.mobile ? DEFAULT_MODE : $props.defaultMode;
quasar.platform.is.mobile && !$props.disableOption?.card stateStore.rightDrawer = true;
? CARD_MODE setUserParams(route.query[$props.searchUrl]);
: $props.defaultMode;
stateStore.rightDrawer = quasar.screen.gt.xs;
columnsVisibilitySkipped.value = [
...splittedColumns.value.columns
.filter((c) => c.visible === false)
.map((c) => c.name),
...['tableActions'],
];
createForm.value = $props.create;
if ($props.create && route?.query?.createForm) {
showForm.value = true;
createForm.value = {
...createForm.value,
...{ formInitialData: JSON.parse(route?.query?.createForm) },
};
}
}); });
watch( watch(
() => $props.columns, () => $props.columns,
(value) => splitColumns(value), (value) => splitColumns(value),
{ immediate: true }, { immediate: true }
); );
const isTableMode = computed(() => mode.value == TABLE_MODE); watch(
const showRightIcon = computed(() => $props.rightSearch || $props.rightSearchIcon); () => route.query[$props.searchUrl],
(val) => setUserParams(val)
);
function setUserParams(watchedParams) {
if (!watchedParams) return;
if (typeof watchedParams == 'string') watchedParams = JSON.parse(watchedParams);
const where = JSON.parse(watchedParams?.filter)?.where;
watchedParams = { ...watchedParams, ...where };
delete watchedParams.filter;
params.value = { ...params.value, ...watchedParams };
}
function splitColumns(columns) { function splitColumns(columns) {
splittedColumns.value = { splittedColumns.value = {
columns: [], columns: [],
chips: [], chips: [],
create: [], create: [],
cardVisible: [], visible: [],
}; };
for (const col of columns) { for (const col of columns) {
if (col.name == 'tableActions') { if (col.name == 'tableActions') splittedColumns.value.actions = col;
col.orderBy = false;
splittedColumns.value.actions = col;
}
if (col.chip) splittedColumns.value.chips.push(col); if (col.chip) splittedColumns.value.chips.push(col);
if (col.isTitle) splittedColumns.value.title = col; if (col.isTitle) splittedColumns.value.title = col;
if (col.create) splittedColumns.value.create.push(col); if (col.create) splittedColumns.value.create.push(col);
if (col.cardVisible) splittedColumns.value.cardVisible.push(col); if (col.cardVisible) splittedColumns.value.visible.push(col);
if ($props.isEditable && col.disable == null) col.disable = false; if ($props.isEditable && col.disable == null) col.disable = false;
if ($props.useModel && col.columnFilter !== false) if ($props.useModel) col.columnFilter = { ...col.columnFilter, inWhere: true };
col.columnFilter = { inWhere: true, ...col.columnFilter };
splittedColumns.value.columns.push(col); splittedColumns.value.columns.push(col);
} }
// Status column // Status column
if (splittedColumns.value.chips.length) { if (splittedColumns.value.chips.length) {
splittedColumns.value.columnChips = splittedColumns.value.chips.filter( splittedColumns.value.columnChips = splittedColumns.value.chips.filter(
(c) => !c.isId, (c) => !c.isId
); );
if (splittedColumns.value.columnChips.length) if (splittedColumns.value.columnChips.length)
splittedColumns.value.columns.unshift({ splittedColumns.value.columns.unshift({
@ -220,27 +148,16 @@ function splitColumns(columns) {
label: t('status'), label: t('status'),
name: 'tableStatus', name: 'tableStatus',
columnFilter: false, columnFilter: false,
orderBy: false,
}); });
} }
} }
const rowClickFunction = computed(() => { const rowClickFunction = computed(() => {
if ($props.rowClick != undefined) return $props.rowClick; if ($props.rowClick) return $props.rowClick;
if ($props.redirect) return ({ id }) => redirectFn(id); if ($props.redirect) return ({ id }) => redirectFn(id);
return () => {}; return () => {};
}); });
const rowCtrlClickFunction = computed(() => {
if ($props.rowCtrlClick != undefined) return $props.rowCtrlClick;
if ($props.redirect)
return (evt, { id }) => {
stopEventPropagation(evt);
window.open(`/#/${$props.redirect}/${id}`, '_blank');
};
return () => {};
});
function redirectFn(id) { function redirectFn(id) {
router.push({ path: `/${$props.redirect}/${id}` }); router.push({ path: `/${$props.redirect}/${id}` });
} }
@ -251,7 +168,6 @@ function stopEventPropagation(event) {
} }
function reload(params) { function reload(params) {
selected.value = [];
CrudModelRef.value.reload(params); CrudModelRef.value.reload(params);
} }
@ -268,42 +184,10 @@ function getColAlign(col) {
const emit = defineEmits(['onFetch', 'update:selected', 'saveChanges']); const emit = defineEmits(['onFetch', 'update:selected', 'saveChanges']);
defineExpose({ defineExpose({
create: createForm,
reload, reload,
redirect: redirectFn, redirect: redirectFn,
selected, selected,
CrudModelRef,
params,
tableRef,
}); });
function handleOnDataSaved(_) {
if (_.onDataSaved) _.onDataSaved({ CrudModelRef: CrudModelRef.value });
else $props.create.onDataSaved(_);
}
function handleScroll() {
if ($props.crudModel.disableInfiniteScroll) return;
const tMiddle = tableRef.value.$el.querySelector('.q-table__middle');
const { scrollHeight, scrollTop, clientHeight } = tMiddle;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) <= 40;
if (isAtBottom) CrudModelRef.value.vnPaginateRef.paginate();
}
function handleSelection({ evt, added, rows: selectedRows }, rows) {
if (evt?.shiftKey && added) {
const rowIndex = selectedRows[0].$index;
const selectedIndexes = new Set(selected.value.map((row) => row.$index));
for (const row of rows) {
if (row.$index == rowIndex) break;
if (!selectedIndexes.has(row.$index)) {
selected.value.push(row);
selectedIndexes.add(row.$index);
}
}
}
}
</script> </script>
<template> <template>
<QDrawer <QDrawer
@ -314,10 +198,41 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) {
show-if-above show-if-above
> >
<QScrollArea class="fit"> <QScrollArea class="fit">
<VnTableFilter <VnFilterPanel
:data-key="$attrs['data-key']" :data-key="$attrs['data-key']"
:columns="columns" :search-button="true"
:redirect="redirect" v-model="params"
:disable-submit-event="true"
:search-url="searchUrl"
>
<template #body>
<VnTableFilter
:column="col"
:data-key="$attrs['data-key']"
v-for="col of splittedColumns.columns"
:key="col.id"
v-model="params[columnName(col)]"
:search-url="searchUrl"
/>
</template>
<slot
name="moreFilterPanel"
:params="params"
:columns="splittedColumns.columns"
/>
</VnFilterPanel>
</QScrollArea>
</QDrawer>
<!-- class in div to fix warn-->
<div class="q-px-md">
<CrudModel
v-bind="$attrs"
:limit="20"
ref="CrudModelRef"
:search-url="searchUrl"
:disable-infinite-scroll="mode == TABLE_MODE"
@save-changes="reload"
:has-subtoolbar="$attrs['hasSubtoolbar'] ?? isEditable"
> >
<template <template
v-for="(_, slotName) in $slots" v-for="(_, slotName) in $slots"
@ -326,100 +241,66 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) {
> >
<slot :name="slotName" v-bind="slotData ?? {}" :key="slotName" /> <slot :name="slotName" v-bind="slotData ?? {}" :key="slotName" />
</template> </template>
</VnTableFilter>
</QScrollArea>
</QDrawer>
<CrudModel
v-bind="$attrs"
:class="$attrs['class'] ?? 'q-px-md'"
:limit="$attrs['limit'] ?? 20"
ref="CrudModelRef"
@on-fetch="(...args) => emit('onFetch', ...args)"
:search-url="searchUrl"
:disable-infinite-scroll="isTableMode"
@save-changes="reload"
:has-sub-toolbar="$props.hasSubToolbar ?? isEditable"
:auto-load="hasParams || $attrs['auto-load']"
>
<template v-for="(_, slotName) in $slots" #[slotName]="slotData" :key="slotName">
<slot :name="slotName" v-bind="slotData ?? {}" :key="slotName" />
</template>
<template #body="{ rows }"> <template #body="{ rows }">
<QTable <QTable
ref="tableRef" v-bind="$attrs['q-table']"
v-bind="table"
class="vnTable" class="vnTable"
:class="{ 'last-row-sticky': $props.footer }"
:columns="splittedColumns.columns" :columns="splittedColumns.columns"
:rows="rows" :rows="rows"
v-model:selected="selected" v-model:selected="selected"
:grid="!isTableMode" :grid="mode != TABLE_MODE"
table-header-class="bg-header" table-header-class="bg-header"
card-container-class="grid-three" card-container-class="grid-three"
flat flat
:style="isTableMode && `max-height: ${tableHeight}`" :style="mode == TABLE_MODE && 'max-height: 90vh'"
:virtual-scroll="isTableMode" virtual-scroll
@virtual-scroll="handleScroll" @virtual-scroll="
@row-click="(_, row) => rowClickFunction && rowClickFunction(row)" (event) =>
event.index > rows.length - 2 &&
CrudModelRef.vnPaginateRef.paginate()
"
@row-click="(_, row) => rowClickFunction(row)"
@update:selected="emit('update:selected', $event)" @update:selected="emit('update:selected', $event)"
@selection="(details) => handleSelection(details, rows)"
> >
<template #top-left v-if="!$props.withoutHeader"> <template #top-left>
<slot name="top-left"></slot> <slot name="top-left"></slot>
</template> </template>
<template #top-right v-if="!$props.withoutHeader"> <template #top-right>
<VnVisibleColumn <!-- <QBtn
v-if="isTableMode" icon="visibility"
v-model="splittedColumns.columns" title="asd"
:table-code="tableCode ?? route.name" class="bg-vn-section-color q-mr-md"
:skip="columnsVisibilitySkipped" dense
/> v-if="mode == 'table'"
/> -->
<QBtnToggle <QBtnToggle
v-model="mode" v-model="mode"
toggle-color="primary" toggle-color="primary"
class="bg-vn-section-color" class="bg-vn-section-color"
dense dense
:options="tableModes.filter((mode) => !mode.disable)" :options="tableModes"
/> />
<QBtn <QBtn
v-if="showRightIcon"
icon="filter_alt" icon="filter_alt"
class="bg-vn-section-color q-ml-sm" title="asd"
class="bg-vn-section-color q-ml-md"
dense dense
@click="stateStore.toggleRightDrawer()" @click="stateStore.toggleRightDrawer()"
/> />
</template> </template>
<template #header-cell="{ col }"> <template #header-cell="{ col }">
<QTh <QTh
v-if="col.visible ?? true" auto-width
:style="col.headerStyle" style="min-width: 100px"
:class="col.headerClass"
>
<div
class="column ellipsis"
:class="`text-${col?.align ?? 'left'}`"
:style="$props.columnSearch ? 'height: 75px' : ''"
>
<div class="row items-center no-wrap" style="height: 30px">
<QTooltip v-if="col.toolTip">{{ col.toolTip }}</QTooltip>
<VnTableOrder
v-model="orders[col.orderBy ?? col.name]"
:name="col.orderBy ?? col.name"
:label="col?.label"
:data-key="$attrs['data-key']"
:search-url="searchUrl"
/>
</div>
<VnFilter
v-if="$props.columnSearch" v-if="$props.columnSearch"
>
<VnTableFilter
:column="col" :column="col"
:show-title="true" :show-title="true"
:data-key="$attrs['data-key']" :data-key="$attrs['data-key']"
v-model="params[columnName(col)]" v-model="params[columnName(col)]"
:search-url="searchUrl" :search-url="searchUrl"
class="full-width"
/> />
</div>
</QTh> </QTh>
</template> </template>
<template #header-cell-tableActions> <template #header-cell-tableActions>
@ -427,36 +308,28 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) {
</template> </template>
<template #body-cell-tableStatus="{ col, row }"> <template #body-cell-tableStatus="{ col, row }">
<QTd auto-width :class="getColAlign(col)"> <QTd auto-width :class="getColAlign(col)">
<VnTableChip :columns="splittedColumns.columnChips" :row="row"> <VnTableChip
:columns="splittedColumns.columnChips"
:row="row"
>
<template #afterChip> <template #afterChip>
<slot name="afterChip" :row="row"></slot> <slot name="afterChip" :row="row"></slot>
</template> </template>
</VnTableChip> </VnTableChip>
</QTd> </QTd>
</template> </template>
<template #body-cell="{ col, row, rowIndex }"> <template #body-cell="{ col, row }">
<!-- Columns --> <!-- Columns -->
<QTd <QTd
auto-width auto-width
class="no-margin" class="no-margin q-px-xs"
:class="[getColAlign(col), col.columnClass]" :class="getColAlign(col)"
:style="col.style"
v-if="col.visible ?? true"
@click.ctrl="
($event) =>
rowCtrlClickFunction && rowCtrlClickFunction($event, row)
"
>
<slot
:name="`column-${col.name}`"
:col="col"
:row="row"
:row-index="rowIndex"
> >
<slot :name="`column-${col.name}`" :col="col" :row="row">
<VnTableColumn <VnTableColumn
:column="col" :column="col"
:row="row" :row="row"
:is-editable="col.isEditable ?? isEditable" :is-editable="false"
v-model="row[col.name]" v-model="row[col.name]"
component-prop="columnField" component-prop="columnField"
/> />
@ -469,25 +342,19 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) {
:class="getColAlign(col)" :class="getColAlign(col)"
class="sticky no-padding" class="sticky no-padding"
@click="stopEventPropagation($event)" @click="stopEventPropagation($event)"
:style="col.style"
> >
<QBtn <QBtn
v-for="(btn, index) of col.actions" v-for="(btn, index) of col.actions"
v-show="btn.show ? btn.show(row) : true"
:key="index" :key="index"
:title="btn.title" :title="btn.title"
:icon="btn.icon" :icon="btn.icon"
class="q-pa-xs" class="q-px-sm"
flat flat
dense
:class=" :class="
btn.isPrimary ? 'text-primary-light' : 'color-vn-text ' btn.isPrimary
? 'text-primary-light'
: 'color-vn-text '
" "
:style="`visibility: ${
((btn.show && btn.show(row)) ?? true)
? 'visible'
: 'hidden'
}`"
@click="btn.action(row)" @click="btn.action(row)"
/> />
</QTd> </QTd>
@ -500,13 +367,12 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) {
<QCard <QCard
bordered bordered
flat flat
class="row no-wrap justify-between cursor-pointer q-pa-sm" class="row no-wrap justify-between cursor-pointer"
@click=" @click="
(_, row) => { (_, row) => {
$props.rowClick && $props.rowClick(row); $props.rowClick && $props.rowClick(row);
} }
" "
style="height: 100%"
> >
<QCardSection <QCardSection
vertical vertical
@ -546,22 +412,27 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) {
:class="$props.cardClass" :class="$props.cardClass"
> >
<div <div
v-for="( v-for="col of splittedColumns.visible"
col, index
) of splittedColumns.cardVisible"
:key="col.name" :key="col.name"
class="fields" class="fields"
> >
<VnLv :label="col.label + ':'"> <VnLv
:label="
!col.component &&
col.label &&
`${col.label}:`
"
>
<template #value> <template #value>
<span <span
@click="stopEventPropagation($event)" @click="
stopEventPropagation($event)
"
> >
<slot <slot
:name="`column-${col.name}`" :name="`column-${col.name}`"
:col="col" :col="col"
:row="row" :row="row"
:row-index="index"
> >
<VnTableColumn <VnTableColumn
:column="col" :column="col"
@ -581,6 +452,7 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) {
<!-- Actions --> <!-- Actions -->
<QCardSection <QCardSection
v-if="colsMap.tableActions" v-if="colsMap.tableActions"
class="column flex-center w-10 no-margin q-pa-xs q-gutter-y-xs"
@click="stopEventPropagation($event)" @click="stopEventPropagation($event)"
> >
<QBtn <QBtn
@ -602,75 +474,27 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) {
</QCard> </QCard>
</component> </component>
</template> </template>
<template #bottom-row="{ cols }" v-if="$props.footer">
<QTr v-if="rows.length" style="height: 30px">
<QTh
v-for="col of cols.filter((cols) => cols.visible ?? true)"
:key="col?.id"
class="text-center"
:class="getColAlign(col)"
>
<slot :name="`column-footer-${col.name}`" />
</QTh>
</QTr>
</template>
</QTable> </QTable>
<div class="full-width bottomButton" v-if="bottom">
<QBtn
@click="
() =>
createAsDialog
? (showForm = !showForm)
: handleOnDataSaved(create)
"
class="cursor-pointer fill-icon"
color="primary"
icon="add_circle"
size="md"
round
flat
v-shortcut="'+'"
:disabled="!disabledAttr"
/>
<QTooltip>
{{ createForm.title }}
</QTooltip>
</div>
</template> </template>
</CrudModel> </CrudModel>
<QPageSticky v-if="$props.create" :offset="[20, 20]" style="z-index: 2"> </div>
<QBtn <QPageSticky v-if="create" :offset="[20, 20]" style="z-index: 2">
@click=" <QBtn @click="showForm = !showForm" color="primary" fab icon="add" />
() => <QTooltip>
createAsDialog ? (showForm = !showForm) : handleOnDataSaved(create) {{ create.title }}
"
color="primary"
fab
icon="add"
v-shortcut="'+'"
data-cy="vnTableCreateBtn"
/>
<QTooltip self="top right">
{{ createForm?.title }}
</QTooltip> </QTooltip>
</QPageSticky> </QPageSticky>
<QDialog v-model="showForm" transition-show="scale" transition-hide="scale"> <QDialog v-model="showForm" transition-show="scale" transition-hide="scale">
<FormModelPopup <FormModelPopup
v-bind="createForm" v-bind="create"
:model="$attrs['data-key'] + 'Create'" :model="$attrs['data-key'] + 'Create'"
@on-data-saved="(_, res) => createForm.onDataSaved(res)" @on-data-saved="(_, res) => create.onDataSaved(res)"
> >
<template #form-inputs="{ data }"> <template #form-inputs="{ data }">
<div class="grid-create"> <div class="grid-create">
<slot <VnTableColumn
v-for="column of splittedColumns.create" v-for="column of splittedColumns.create"
:key="column.name" :key="column.name"
:name="`column-create-${column.name}`"
:data="data"
:column-name="column.name"
:label="column.label"
>
<VnTableColumn
:column="column" :column="column"
:row="{}" :row="{}"
default="input" default="input"
@ -678,7 +502,6 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) {
:show-label="true" :show-label="true"
component-prop="columnCreate" component-prop="columnCreate"
/> />
</slot>
<slot name="more-create-dialog" :data="data" /> <slot name="more-create-dialog" :data="data" />
</div> </div>
</template> </template>
@ -688,12 +511,8 @@ function handleSelection({ evt, added, rows: selectedRows }, rows) {
<i18n> <i18n>
en: en:
status: Status status: Status
table view: Table view
grid view: Grid view
es: es:
status: Estados status: Estados
table view: Vista en tabla
grid view: Vista en cuadrícula
</i18n> </i18n>
<style lang="scss"> <style lang="scss">
@ -703,17 +522,25 @@ es:
} }
.bg-header { .bg-header {
background-color: var(--vn-accent-color); background-color: #5d5d5d;
color: var(--vn-text-color); color: var(--vn-text-color);
} }
.color-vn-text { .q-table--dark .q-table__bottom,
color: var(--vn-text-color); .q-table--dark thead,
.q-table--dark tr,
.q-table--dark th,
.q-table--dark td {
border-color: #222222;
}
.q-table__container > div:first-child {
background-color: var(--vn-page-color);
} }
.grid-three { .grid-three {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, max-content)); grid-template-columns: repeat(auto-fit, minmax(400px, max-content));
max-width: 100%; max-width: 100%;
grid-gap: 20px; grid-gap: 20px;
margin: 0 auto; margin: 0 auto;
@ -739,17 +566,10 @@ es:
} }
} }
.q-table { .q-table th {
th {
padding: 0; padding: 0;
} }
&__top {
padding: 12px 0px;
top: 0;
}
}
.vnTable { .vnTable {
thead tr th { thead tr th {
position: sticky; position: sticky;
@ -760,28 +580,30 @@ es:
} }
.q-table__top { .q-table__top {
top: 0; top: 0;
padding: 12px 0; }
tbody {
.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);
}
}
} }
.sticky { .sticky {
position: sticky; position: sticky;
right: 0; right: 0;
} }
td.sticky { td.sticky {
background-color: var(--vn-section-color); background-color: var(--q-dark);
z-index: 1; z-index: 1;
} }
table tbody th {
position: relative;
}
}
.last-row-sticky {
tbody:nth-last-child(1) {
@extend .bg-header;
position: sticky;
z-index: 2;
bottom: 0;
}
} }
.vn-label-value { .vn-label-value {
@ -806,15 +628,12 @@ es:
.grid-two { .grid-two {
display: grid; display: grid;
grid-template-columns: 2fr 2fr; grid-template-columns: repeat(auto-fit, minmax(150px, max-content));
.vn-label-value { max-width: 100%;
flex-direction: column; margin: 0 auto;
white-space: nowrap; overflow: scroll;
.fields { white-space: wrap;
display: flex; width: 100%;
}
}
white-space: nowrap;
} }
.w-80 { .w-80 {
@ -830,12 +649,4 @@ es:
cursor: text; cursor: text;
user-select: all; user-select: all;
} }
.q-table__container {
background-color: transparent;
}
.q-table__middle.q-virtual-scroll.q-virtual-scroll--vertical.scroll {
background-color: var(--vn-section-color);
}
</style> </style>

View File

@ -1,69 +0,0 @@
<script setup>
import { ref } from 'vue';
import VnFilterPanel from 'components/ui/VnFilterPanel.vue';
import VnFilter from 'components/VnTable/VnFilter.vue';
import VnTableOrder from 'src/components/VnTable/VnOrder.vue';
defineProps({
columns: {
type: Array,
required: true,
},
searchUrl: {
type: [String, Boolean],
default: 'table',
},
});
const tableFilterRef = ref([]);
function columnName(col) {
const column = { ...col, ...col.columnFilter };
let name = column.name;
if (column.alias) name = column.alias + '.' + name;
return name;
}
</script>
<template>
<VnFilterPanel v-bind="$attrs" :search-button="true" :disable-submit-event="true">
<template #body="{ params, orders }">
<div
class="row no-wrap flex-center"
v-for="col of columns.filter((c) => c.columnFilter ?? true)"
:key="col.id"
>
<VnFilter
ref="tableFilterRef"
:column="col"
:data-key="$attrs['data-key']"
v-model="params[columnName(col)]"
:search-url="searchUrl"
/>
<VnTableOrder
v-if="col?.columnFilter !== false && col?.name !== 'tableActions'"
v-model="orders[col.orderBy ?? col.name]"
:name="col.orderBy ?? col.name"
:data-key="$attrs['data-key']"
:search-url="searchUrl"
:vertical="true"
/>
</div>
<slot
name="moreFilterPanel"
:params="params"
:orders="orders"
:columns="columns"
/>
</template>
<template #tags="{ tag, formatFn, getLocale }">
<div class="q-gutter-x-xs">
<strong>{{ getLocale(`${tag.label}`) }}: </strong>
<span>{{ formatFn(tag.value) }}</span>
</div>
</template>
<template v-for="(_, slotName) in $slots" #[slotName]="slotData" :key="slotName">
<slot :name="slotName" v-bind="slotData ?? {}" :key="slotName" />
</template>
</VnFilterPanel>
</template>

View File

@ -1,189 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ref, computed, onMounted } from 'vue';
import { useState } from 'src/composables/useState';
import axios from 'axios';
import useNotify from 'src/composables/useNotify.js';
const columns = defineModel({ type: Object, default: [] });
const $props = defineProps({
tableCode: {
type: String,
default: '',
},
skip: {
type: Array,
default: () => [],
},
});
const { notify } = useNotify();
const { t } = useI18n();
const state = useState();
const user = state.getUser();
const popupProxyRef = ref();
const initialUserConfigViewData = ref();
const localColumns = ref([]);
const areAllChecksMarked = computed(() => {
return localColumns.value.every((col) => col.visible);
});
function setUserConfigViewData(data, isLocal) {
if (!data) return;
// Importante: El name de las columnas de la tabla debe conincidir con el name de las variables que devuelve la view config
if (!isLocal) localColumns.value = [];
// Array to Object
const skippeds = $props.skip.reduce((a, v) => ({ ...a, [v]: v }), {});
for (let column of columns.value) {
const { label, name } = column;
if (skippeds[name]) continue;
column.visible = data[name] ?? true;
if (!isLocal) localColumns.value.push({ name, label, visible: column.visible });
}
}
function toggleMarkAll(val) {
localColumns.value.forEach((col) => (col.visible = val));
}
async function getConfig(url, filter) {
const response = await axios.get(url, {
params: { filter: filter },
});
return response.data && response.data.length > 0 ? response.data[0] : null;
}
async function fetchViewConfigData() {
try {
const defaultFilter = {
where: { tableCode: $props.tableCode },
};
const userConfig = await getConfig('UserConfigViews', {
where: {
...defaultFilter.where,
...{ userFk: user.value.id },
},
});
if (userConfig) {
initialUserConfigViewData.value = userConfig;
setUserConfigViewData(userConfig.configuration);
return;
}
const defaultConfig = await getConfig('DefaultViewConfigs', defaultFilter);
if (defaultConfig) {
setUserConfigViewData(defaultConfig.columns);
return;
}
} catch (err) {
console.error('Error fetching config view data', err);
}
}
async function saveConfig() {
const configuration = {};
for (const { name, visible } of localColumns.value)
configuration[name] = visible ?? true;
setUserConfigViewData(configuration, true);
if (!$props.tableCode) return popupProxyRef.value.hide();
try {
const params = {};
// Si existe una view config del usuario hacemos un update si no la creamos
if (initialUserConfigViewData.value) {
params.updates = [
{
data: {
configuration,
},
where: {
id: initialUserConfigViewData.value.id,
},
},
];
} else {
params.creates = [
{
userFk: user.value.id,
tableCode: $props.tableCode,
tableConfig: $props.tableCode,
configuration,
},
];
}
const response = await axios.post('UserConfigViews/crud', params);
if (response.data && response.data[0]) {
initialUserConfigViewData.value = response.data[0];
}
notify('globals.dataSaved', 'positive');
popupProxyRef.value.hide();
} catch (err) {
console.error('Error saving user view config', err);
notify('errors.writeRequest', 'negative');
}
}
onMounted(async () => {
setUserConfigViewData({});
await fetchViewConfigData();
});
</script>
<template>
<QBtn icon="vn:visible_columns" class="bg-vn-section-color q-mr-sm q-px-sm" dense>
<QPopupProxy ref="popupProxyRef">
<QCard class="column q-pa-md">
<QIcon name="info" size="sm" class="info-icon">
<QTooltip>{{ t('Check the columns you want to see') }}</QTooltip>
</QIcon>
<span class="text-body1 q-mb-sm">{{ t('Visible columns') }}</span>
<QCheckbox
:label="t('Tick all')"
:model-value="areAllChecksMarked"
@update:model-value="toggleMarkAll($event)"
class="q-mb-sm"
/>
<div v-if="columns.length > 0" class="checks-layout">
<QCheckbox
v-for="col in localColumns"
:key="col.name"
:label="col.label ?? col.name"
v-model="col.visible"
/>
</div>
<QBtn
class="full-width q-mt-md"
color="primary"
@click="saveConfig()"
:label="t('globals.save')"
/>
</QCard>
</QPopupProxy>
<QTooltip>{{ t('Visible columns') }}</QTooltip>
</QBtn>
</template>
<style lang="scss" scoped>
.info-icon {
position: absolute;
top: 20px;
right: 20px;
}
.checks-layout {
display: grid;
grid-template-columns: repeat(3, 200px);
}
</style>
<i18n>
es:
Check the columns you want to see: Marca las columnas que quieres ver
Visible columns: Columnas visibles
Tick all: Marcar todas
</i18n>

View File

@ -1,56 +0,0 @@
import { describe, expect, it, beforeAll, beforeEach, vi } from 'vitest';
import { createWrapper } from 'app/test/vitest/helper';
import VnTable from 'src/components/VnTable/VnTable.vue';
describe('VnTable', () => {
let wrapper;
let vm;
beforeAll(() => {
wrapper = createWrapper(VnTable, {
propsData: {
columns: [],
},
});
vm = wrapper.vm;
vi.mock('src/composables/useFilterParams', () => {
return {
useFilterParams: vi.fn(() => ({
params: {},
orders: {},
})),
};
});
});
beforeEach(() => (vm.selected = []));
describe('handleSelection()', () => {
const rows = [{ $index: 0 }, { $index: 1 }, { $index: 2 }];
const selectedRows = [{ $index: 1 }];
it('should add rows to selected when shift key is pressed and rows are added except last one', () => {
vm.handleSelection(
{ evt: { shiftKey: true }, added: true, rows: selectedRows },
rows
);
expect(vm.selected).toEqual([{ $index: 0 }]);
});
it('should not add rows to selected when shift key is not pressed', () => {
vm.handleSelection(
{ evt: { shiftKey: false }, added: true, rows: selectedRows },
rows
);
expect(vm.selected).toEqual([]);
});
it('should not add rows to selected when rows are not added', () => {
vm.handleSelection(
{ evt: { shiftKey: true }, added: false, rows: selectedRows },
rows
);
expect(vm.selected).toEqual([]);
});
});
});

View File

@ -1,121 +0,0 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { createWrapper } from 'app/test/vitest/helper';
import VnVisibleColumn from '../VnVisibleColumn.vue';
import { axios } from 'app/test/vitest/helper';
describe('VnVisibleColumns', () => {
let wrapper;
let vm;
beforeEach(() => {
wrapper = createWrapper(VnVisibleColumn, {
propsData: {
tableCode: 'testTable',
skip: ['skippedColumn'],
},
});
vm = wrapper.vm;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('setUserConfigViewData()', () => {
it('should initialize localColumns with visible configuration', () => {
vm.columns = [
{ name: 'columnMockName', label: undefined },
{ name: 'columnMockAddress', label: undefined },
{ name: 'columnMockId', label: undefined },
];
const configuration = {
columnMockName: true,
columnMockAddress: false,
columnMockId: true,
};
const expectedColumns = [
{ name: 'columnMockName', label: undefined, visible: true },
{ name: 'columnMockAddress', label: undefined, visible: false },
{ name: 'columnMockId', label: undefined, visible: true },
];
vm.setUserConfigViewData(configuration, false);
expect(vm.localColumns).toEqual(expectedColumns);
});
it('should skip columns based on props', () => {
vm.columns = [
{ name: 'columnMockName', label: undefined },
{ name: 'columnMockId', label: undefined },
{ name: 'skippedColumn', label: 'Skipped Column' },
];
const configuration = {
columnMockName: true,
skippedColumn: false,
columnMockId: true,
};
const expectedColumns = [
{ name: 'columnMockName', label: undefined, visible: true },
{ name: 'columnMockId', label: undefined, visible: true },
];
vm.setUserConfigViewData(configuration, false);
expect(vm.localColumns).toEqual(expectedColumns);
});
});
describe('toggleMarkAll()', () => {
it('should set all localColumns to visible=true', () => {
vm.localColumns = [
{ name: 'columnMockName', visible: false },
{ name: 'columnMockId', visible: false },
];
vm.toggleMarkAll(true);
expect(vm.localColumns.every((col) => col.visible)).toBe(true);
});
it('should set all localColumns to visible=false', () => {
vm.localColumns = [
{ name: 'columnMockName', visible: true },
{ name: 'columnMockId', visible: true },
];
vm.toggleMarkAll(false);
expect(vm.localColumns.every((col) => col.visible)).toBe(false);
});
});
describe('saveConfig()', () => {
it('should call setUserConfigViewData and axios.post with correct params', async () => {
const mockAxiosPost = vi.spyOn(axios, 'post').mockResolvedValue({
data: [{ id: 1 }],
});
vm.localColumns = [
{ name: 'columnMockName', visible: true },
{ name: 'columnMockId', visible: false },
];
await vm.saveConfig();
expect(mockAxiosPost).toHaveBeenCalledWith('UserConfigViews/crud', {
creates: [
{
userFk: vm.user.id,
tableCode: vm.tableCode,
tableConfig: vm.tableCode,
configuration: {
columnMockName: true,
columnMockId: false,
},
},
],
});
});
});
});

View File

@ -1,12 +0,0 @@
export default function (initialFooter, data) {
const footer = data.reduce(
(acc, row) => {
Object.entries(initialFooter).forEach(([key, initialValue]) => {
acc[key] += row?.[key] !== undefined ? row[key] : initialValue;
});
return acc;
},
{ ...initialFooter }
);
return footer;
}

View File

@ -1,248 +0,0 @@
import { createWrapper, axios } from 'app/test/vitest/helper';
import CrudModel from 'components/CrudModel.vue';
import { vi, afterEach, beforeEach, beforeAll, describe, expect, it } from 'vitest';
describe('CrudModel', () => {
let wrapper;
let vm;
let data;
beforeAll(() => {
wrapper = createWrapper(CrudModel, {
global: {
stubs: [
'vnPaginate',
'useState',
'arrayData',
'useStateStore',
'vue-i18n',
],
mocks: {
validate: vi.fn(),
},
},
propsData: {
dataRequired: {
fk: 1,
},
dataKey: 'crudModelKey',
model: 'crudModel',
url: 'crudModelUrl',
saveFn: '',
},
});
wrapper=wrapper.wrapper;
vm=wrapper.vm;
});
beforeEach(() => {
vm.fetch([]);
vm.watchChanges = null;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('insert()', () => {
it('should new element in list with index 0 if formData not has data', () => {
vm.insert();
expect(vm.formData.length).toEqual(1);
expect(vm.formData[0].fk).toEqual(1);
expect(vm.formData[0].$index).toEqual(0);
});
});
describe('getChanges()', () => {
it('should return correct updates and creates', async () => {
vm.fetch([
{ id: 1, name: 'New name one' },
{ id: 2, name: 'New name two' },
{ id: 3, name: 'Bruce Wayne' },
]);
vm.originalData = [
{ id: 1, name: 'Tony Starks' },
{ id: 2, name: 'Jessica Jones' },
{ id: 3, name: 'Bruce Wayne' },
];
vm.insert();
const result = vm.getChanges();
const expected = {
creates: [
{
$index: 3,
fk: 1,
},
],
updates: [
{
data: {
name: 'New name one',
},
where: {
id: 1,
},
},
{
data: {
name: 'New name two',
},
where: {
id: 2,
},
},
],
};
expect(result).toEqual(expected);
});
});
describe('getDifferences()', () => {
it('should return the differences between two objects', async () => {
const obj1 = {
a: 1,
b: 2,
c: 3,
};
const obj2 = {
a: null,
b: 4,
d: 5,
};
const result = vm.getDifferences(obj1, obj2);
expect(result).toEqual({
a: null,
b: 4,
d: 5,
});
});
});
describe('isEmpty()', () => {
let dummyObj;
let dummyArray;
let result;
it('should return true if object si null', async () => {
dummyObj = null;
result = vm.isEmpty(dummyObj);
expect(result).toBe(true);
});
it('should return true if object si undefined', async () => {
dummyObj = undefined;
result = vm.isEmpty(dummyObj);
expect(result).toBe(true);
});
it('should return true if object is empty', async () => {
dummyObj ={};
result = vm.isEmpty(dummyObj);
expect(result).toBe(true);
});
it('should return false if object is not empty', async () => {
dummyObj = {a:1, b:2, c:3};
result = vm.isEmpty(dummyObj);
expect(result).toBe(false);
});
it('should return true if array is empty', async () => {
dummyArray = [];
result = vm.isEmpty(dummyArray);
expect(result).toBe(true);
});
it('should return false if array is not empty', async () => {
dummyArray = [1,2,3];
result = vm.isEmpty(dummyArray);
expect(result).toBe(false);
})
});
describe('resetData()', () => {
it('should add $index to elements in data[] and sets originalData and formData with data', async () => {
data = [{
name: 'Tony',
lastName: 'Stark',
age: 42,
}];
vm.resetData(data);
expect(vm.originalData).toEqual(data);
expect(vm.originalData[0].$index).toEqual(0);
expect(vm.formData).toEqual(data);
expect(vm.formData[0].$index).toEqual(0);
expect(vm.watchChanges).not.toBeNull();
});
it('should dont do nothing if data is null', async () => {
vm.resetData(null);
expect(vm.watchChanges).toBeNull();
});
it('should set originalData and formatData with data and generate watchChanges', async () => {
data = {
name: 'Tony',
lastName: 'Stark',
age: 42,
};
vm.resetData(data);
expect(vm.originalData).toEqual(data);
expect(vm.formData).toEqual(data);
expect(vm.watchChanges).not.toBeNull();
});
});
describe('saveChanges()', () => {
data = [{
name: 'Tony',
lastName: 'Stark',
age: 42,
}];
it('should call saveFn if exists', async () => {
await wrapper.setProps({ saveFn: vi.fn() });
vm.saveChanges(data);
expect(vm.saveFn).toHaveBeenCalledOnce();
expect(vm.isLoading).toBe(false);
expect(vm.hasChanges).toBe(false);
await wrapper.setProps({ saveFn: '' });
});
it("should use default url if there's not saveFn", async () => {
const postMock =vi.spyOn(axios, 'post');
vm.formData = [{
name: 'Bruce',
lastName: 'Wayne',
age: 45,
}]
await vm.saveChanges(data);
expect(postMock).toHaveBeenCalledWith(vm.url + '/crud', data);
expect(vm.isLoading).toBe(false);
expect(vm.hasChanges).toBe(false);
expect(vm.originalData).toEqual(JSON.parse(JSON.stringify(vm.formData)));
});
});
});

View File

@ -1,56 +0,0 @@
import { createWrapper, axios } from 'app/test/vitest/helper';
import EditForm from 'components/EditTableCellValueForm.vue';
import { vi, afterEach, beforeAll, describe, expect, it } from 'vitest';
const fieldA = 'fieldA';
const fieldB = 'fieldB';
describe('EditForm', () => {
let vm;
const mockRows = [
{ id: 1, itemFk: 101 },
{ id: 2, itemFk: 102 },
];
const mockFieldsOptions = [
{ label: 'Field A', field: fieldA, component: 'input', attrs: {} },
{ label: 'Field B', field: fieldB, component: 'date', attrs: {} },
];
const editUrl = '/api/edit';
beforeAll(() => {
vi.spyOn(axios, 'post').mockResolvedValue({ status: 200 });
vm = createWrapper(EditForm, {
props: {
rows: mockRows,
fieldsOptions: mockFieldsOptions,
editUrl,
},
}).vm;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('onSubmit()', () => {
it('should call axios.post with the correct parameters in the payload', async () => {
const selectedField = { field: fieldA, component: 'input', attrs: {} };
const newValue = 'Test Value';
vm.selectedField = selectedField;
vm.newValue = newValue;
await vm.onSubmit();
const payload = axios.post.mock.calls[0][1];
expect(axios.post).toHaveBeenCalledWith(editUrl, expect.any(Object));
expect(payload.field).toEqual(fieldA);
expect(payload.newValue).toEqual(newValue);
expect(payload.lines).toEqual(expect.arrayContaining(mockRows));
expect(vm.isLoading).toEqual(false);
});
});
});

View File

@ -1,82 +0,0 @@
import { createWrapper, axios } from 'app/test/vitest/helper';
import FilterItemForm from 'src/components/FilterItemForm.vue';
import { vi, beforeAll, describe, expect, it } from 'vitest';
describe('FilterItemForm', () => {
let vm;
let wrapper;
beforeAll(() => {
wrapper = createWrapper(FilterItemForm, {
props: {
url: 'Items/withName',
},
});
vm = wrapper.vm;
wrapper = wrapper.wrapper;
vi.spyOn(axios, 'get').mockResolvedValue({
data: [
{
id: 999996,
name: 'bolas de madera',
size: 2,
inkFk: null,
producerFk: null,
},
],
});
});
it('should filter data and populate tableRows for table display', async () => {
vm.itemFilterParams.name = 'bolas de madera';
await vm.onSubmit();
const expectedFilter = {
include: [
{ relation: 'producer', scope: { fields: ['name'] } },
{ relation: 'ink', scope: { fields: ['name'] } },
],
where: {"name":{"like":"%bolas de madera%"}},
};
expect(axios.get).toHaveBeenCalledWith('Items/withName', {
params: { filter: JSON.stringify(expectedFilter) },
});
expect(vm.tableRows).toEqual([
{
id: 999996,
name: 'bolas de madera',
size: 2,
inkFk: null,
producerFk: null,
},
]);
});
it('should handle an empty itemFilterParams correctly', async () => {
vm.itemFilterParams.name = null;
vm.itemFilterParams = {};
await vm.onSubmit();
const expectedFilter = {
include: [
{ relation: 'producer', scope: { fields: ['name'] } },
{ relation: 'ink', scope: { fields: ['name'] } },
],
where: {},
};
expect(axios.get).toHaveBeenCalledWith('Items/withName', {
params: { filter: JSON.stringify(expectedFilter) },
});
});
it('should emit "itemSelected" with the correct id and close the form', () => {
vm.selectItem({ id: 12345 });
expect(wrapper.emitted('itemSelected')[0]).toEqual([12345]);
});
});

View File

@ -1,150 +0,0 @@
import { describe, expect, it, beforeAll, vi, afterAll } from 'vitest';
import { createWrapper, axios } from 'app/test/vitest/helper';
import FormModel from 'src/components/FormModel.vue';
describe('FormModel', () => {
const model = 'mockModel';
const url = 'mockUrl';
const formInitialData = { mockKey: 'mockVal' };
describe('modelValue', () => {
it('should use the provided model', () => {
const { vm } = mount({ propsData: { model } });
expect(vm.modelValue).toBe(model);
});
it('should use the route meta title when model is not provided', () => {
const { vm } = mount({});
expect(vm.modelValue).toBe('formModel_mockTitle');
});
});
describe('onMounted()', () => {
let mockGet;
beforeAll(() => {
mockGet = vi.spyOn(axios, 'get').mockResolvedValue({ data: {} });
});
afterAll(() => {
mockGet.mockRestore();
});
it('should not fetch when has formInitialData', () => {
mount({ propsData: { url, model, autoLoad: true, formInitialData } });
expect(mockGet).not.toHaveBeenCalled();
});
it('should fetch when there is url and auto-load', () => {
mount({ propsData: { url, model, autoLoad: true } });
expect(mockGet).toHaveBeenCalled();
});
it('should not observe changes', () => {
const { vm } = mount({
propsData: { url, model, observeFormChanges: false, formInitialData },
});
expect(vm.hasChanges).toBe(true);
vm.reset();
expect(vm.hasChanges).toBe(true);
});
it('should observe changes', async () => {
const { vm } = mount({
propsData: { url, model, formInitialData },
});
vm.state.set(model, formInitialData);
expect(vm.hasChanges).toBe(false);
vm.formData.mockKey = 'newVal';
await vm.$nextTick();
expect(vm.hasChanges).toBe(true);
vm.formData.mockKey = 'mockVal';
});
});
describe('trimData()', () => {
let vm;
beforeAll(() => {
vm = mount({}).vm;
});
it('should trim whitespace from string values', () => {
const data = { key1: ' value1 ', key2: ' value2 ' };
const trimmedData = vm.trimData(data);
expect(trimmedData).toEqual({ key1: 'value1', key2: 'value2' });
});
it('should not modify non-string values', () => {
const data = { key1: 123, key2: true, key3: null, key4: undefined };
const trimmedData = vm.trimData(data);
expect(trimmedData).toEqual(data);
});
});
describe('save()', async () => {
it('should not call if there are not changes', async () => {
const { vm } = mount({ propsData: { url, model } });
await vm.save();
expect(vm.hasChanges).toBe(false);
});
it('should call axios.patch with the right data', async () => {
const spy = vi.spyOn(axios, 'patch').mockResolvedValue({ data: {} });
const { vm } = mount({ propsData: { url, model } });
vm.formData.mockKey = 'newVal';
await vm.$nextTick();
await vm.save();
expect(spy).toHaveBeenCalled();
vm.formData.mockKey = 'mockVal';
});
it('should call axios.post with the right data', async () => {
const spy = vi.spyOn(axios, 'post').mockResolvedValue({ data: {} });
const { vm } = mount({
propsData: { url, model, formInitialData, urlCreate: 'mockUrlCreate' },
});
await vm.$nextTick();
vm.formData.mockKey = 'newVal';
await vm.$nextTick();
await vm.save();
expect(spy).toHaveBeenCalled();
vm.formData.mockKey = 'mockVal';
});
it('should use the saveFn', async () => {
const { vm } = mount({
propsData: { url, model, formInitialData, saveFn: () => {} },
});
const spyPatch = vi.spyOn(axios, 'patch').mockResolvedValue({ data: {} });
const spySaveFn = vi.spyOn(vm.$props, 'saveFn');
await vm.$nextTick();
vm.formData.mockKey = 'newVal';
await vm.$nextTick();
await vm.save();
expect(spyPatch).not.toHaveBeenCalled();
expect(spySaveFn).toHaveBeenCalled();
vm.formData.mockKey = 'mockVal';
});
it('should reload the data after save', async () => {
const { vm } = mount({
propsData: { url, model, formInitialData, reload: true },
});
vi.spyOn(axios, 'patch').mockResolvedValue({ data: {} });
vm.formData.mockKey = 'newVal';
await vm.$nextTick();
await vm.save();
vm.formData.mockKey = 'mockVal';
});
});
});
function mount({ propsData = {} }) {
return createWrapper(FormModel, {
propsData,
});
}

View File

@ -1,388 +0,0 @@
import { vi, describe, expect, it, beforeAll, beforeEach, afterEach } from 'vitest';
import { createWrapper, axios } from 'app/test/vitest/helper';
import Leftmenu from 'components/LeftMenu.vue';
import * as vueRouter from 'vue-router';
import { useNavigationStore } from 'src/stores/useNavigationStore';
let vm;
let navigation;
vi.mock('src/router/modules', () => ({
default: [
{
path: '/customer',
name: 'Customer',
meta: {
title: 'customers',
icon: 'vn:client',
},
menus: {
main: ['CustomerList', 'CustomerCreate'],
card: ['CustomerBasicData'],
},
children: [
{
path: '',
name: 'CustomerMain',
meta: {
menu: 'Customer',
menuChildren: [
{
name: 'CustomerCreditContracts',
title: 'creditContracts',
icon: 'vn:solunion',
},
],
},
children: [
{
path: 'list',
name: 'CustomerList',
meta: {
title: 'list',
icon: 'view_list',
menuChildren: [
{
name: 'CustomerCreditContracts',
title: 'creditContracts',
icon: 'vn:solunion',
},
],
},
},
{
path: 'create',
name: 'CustomerCreate',
meta: {
title: 'createCustomer',
icon: 'vn:addperson',
},
},
],
},
],
},
],
}));
vi.spyOn(vueRouter, 'useRoute').mockReturnValue({
matched: [
{
path: '/',
redirect: {
name: 'Dashboard',
},
name: 'Main',
meta: {},
props: {
default: false,
},
children: [
{
path: '/dashboard',
name: 'Dashboard',
meta: {
title: 'dashboard',
icon: 'dashboard',
},
},
],
},
{
path: '/customer',
redirect: {
name: 'CustomerMain',
},
name: 'Customer',
meta: {
title: 'customers',
icon: 'vn:client',
moduleName: 'Customer',
keyBinding: 'c',
menu: 'customer',
},
},
],
query: {},
params: {},
meta: { moduleName: 'mockName' },
path: 'mockName/1',
name: 'Customer',
});
function mount(source = 'main') {
vi.spyOn(axios, 'get').mockResolvedValue({
data: [],
});
const wrapper = createWrapper(Leftmenu, {
propsData: {
source,
},
});
navigation = useNavigationStore();
navigation.fetchPinned = vi.fn().mockReturnValue(Promise.resolve(true));
navigation.getModules = vi.fn().mockReturnValue({
value: [
{
name: 'customer',
title: 'customer.pageTitles.customers',
icon: 'vn:customer',
module: 'customer',
},
],
});
return wrapper;
}
describe('getRoutes', () => {
afterEach(() => vi.clearAllMocks());
const getRoutes = vi.fn().mockImplementation((props, getMethodA, getMethodB) => {
const handleRoutes = {
methodA: getMethodA,
methodB: getMethodB,
};
try {
handleRoutes[props.source]();
} catch (error) {
throw Error('Method not defined');
}
});
const getMethodA = vi.fn();
const getMethodB = vi.fn();
const fn = (props) => getRoutes(props, getMethodA, getMethodB);
it('should call getMethodB when source is card', () => {
let props = { source: 'methodB' };
fn(props);
expect(getMethodB).toHaveBeenCalled();
expect(getMethodA).not.toHaveBeenCalled();
});
it('should call getMethodA when source is main', () => {
let props = { source: 'methodA' };
fn(props);
expect(getMethodA).toHaveBeenCalled();
expect(getMethodB).not.toHaveBeenCalled();
});
it('should call getMethodA when source is not exists or undefined', () => {
let props = { source: 'methodC' };
expect(() => fn(props)).toThrowError('Method not defined');
expect(getMethodA).not.toHaveBeenCalled();
expect(getMethodB).not.toHaveBeenCalled();
});
});
describe('Leftmenu as card', () => {
beforeAll(() => {
vm = mount('card').vm;
});
it('should get routes for card source', async () => {
vm.getRoutes();
});
});
describe('Leftmenu as main', () => {
beforeEach(() => {
vm = mount().vm;
});
it('should initialize with default props', () => {
expect(vm.source).toBe('main');
});
it('should filter items based on search input', async () => {
vm.search = 'cust';
await vm.$nextTick();
expect(vm.filteredItems[0].name).toEqual('customer');
expect(vm.filteredItems[0].module).toEqual('customer');
});
it('should filter items based on search input', async () => {
vm.search = 'Rou';
await vm.$nextTick();
expect(vm.filteredItems).toEqual([]);
});
it('should return pinned items', () => {
vm.items = [
{ name: 'Item 1', isPinned: false },
{ name: 'Item 2', isPinned: true },
];
expect(vm.pinnedModules).toEqual(
new Map([['Item 2', { name: 'Item 2', isPinned: true }]]),
);
});
it('should find matches in routes', () => {
const search = 'child1';
const item = {
children: [
{ name: 'child1', children: [] },
{ name: 'child2', children: [] },
],
};
const matches = vm.findMatches(search, item);
expect(matches).toEqual([{ name: 'child1', children: [] }]);
});
it('should not proceed if event is already prevented', async () => {
const item = { module: 'testModule', isPinned: false };
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
defaultPrevented: true,
};
await vm.togglePinned(item, event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.stopPropagation).not.toHaveBeenCalled();
});
it('should call quasar.notify with success message', async () => {
const item = { module: 'testModule', isPinned: false };
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
defaultPrevented: false,
};
const response = { data: { id: 1 } };
vi.spyOn(axios, 'post').mockResolvedValue(response);
vi.spyOn(vm.quasar, 'notify');
await vm.togglePinned(item, event);
expect(vm.quasar.notify).toHaveBeenCalledWith({
message: 'Data saved',
type: 'positive',
});
});
it('should handle a single matched route with a menu', () => {
const route = {
matched: [{ meta: { menu: 'customer' } }],
};
const result = vm.betaGetRoutes();
expect(result.meta.menu).toEqual(route.matched[0].meta.menu);
});
it('should get routes for main source', () => {
vm.props.source = 'main';
vm.getRoutes();
expect(navigation.getModules).toHaveBeenCalled();
});
it('should find direct child matches', () => {
const search = 'child1';
const item = {
children: [{ name: 'child1' }, { name: 'child2' }],
};
const result = vm.findMatches(search, item);
expect(result).toEqual([{ name: 'child1' }]);
});
it('should find nested child matches', () => {
const search = 'child3';
const item = {
children: [
{ name: 'child1' },
{
name: 'child2',
children: [{ name: 'child3' }],
},
],
};
const result = vm.findMatches(search, item);
expect(result).toEqual([{ name: 'child3' }]);
});
});
describe('normalize', () => {
beforeAll(() => {
vm = mount('card').vm;
});
it('should normalize and lowercase text', () => {
const input = 'ÁÉÍÓÚáéíóú';
const expected = 'aeiouaeiou';
expect(vm.normalize(input)).toBe(expected);
});
it('should handle empty string', () => {
const input = '';
const expected = '';
expect(vm.normalize(input)).toBe(expected);
});
it('should handle text without diacritics', () => {
const input = 'hello';
const expected = 'hello';
expect(vm.normalize(input)).toBe(expected);
});
it('should handle mixed text', () => {
const input = 'Héllo Wórld!';
const expected = 'hello world!';
expect(vm.normalize(input)).toBe(expected);
});
});
describe('addChildren', () => {
const module = 'testModule';
beforeEach(() => {
vm = mount().vm;
vi.clearAllMocks();
});
it('should add menu items to parent if matches are found', () => {
const parent = 'testParent';
const route = {
meta: {
menu: 'testMenu',
},
children: [{ name: 'child1' }, { name: 'child2' }],
};
vm.addChildren(module, route, parent);
expect(navigation.addMenuItem).toHaveBeenCalled();
});
it('should handle routes with no meta menu', () => {
const route = {
meta: {},
menus: {},
};
const parent = [];
vm.addChildren(module, route, parent);
expect(navigation.addMenuItem).toHaveBeenCalled();
});
it('should handle empty parent array', () => {
const parent = [];
const route = {
meta: {
menu: 'child11',
},
children: [
{
name: 'child1',
meta: {
menuChildren: [
{
name: 'CustomerCreditContracts',
title: 'creditContracts',
icon: 'vn:solunion',
},
],
},
},
],
};
vm.addChildren(module, route, parent);
expect(navigation.addMenuItem).toHaveBeenCalled();
});
});

View File

@ -1,61 +0,0 @@
import { vi, describe, expect, it, beforeEach, beforeAll, afterEach } from 'vitest';
import { createWrapper } from 'app/test/vitest/helper';
import UserPanel from 'src/components/UserPanel.vue';
import axios from 'axios';
import { useState } from 'src/composables/useState';
describe('UserPanel', () => {
let wrapper;
let vm;
let state;
beforeEach(() => {
wrapper = createWrapper(UserPanel, {});
state = useState();
state.setUser({
id: 115,
name: 'itmanagement',
nickname: 'itManagementNick',
lang: 'en',
darkMode: false,
companyFk: 442,
warehouseFk: 1,
});
wrapper = wrapper.wrapper;
vm = wrapper.vm;
});
afterEach(() => {
vi.clearAllMocks();
});
it('should fetch warehouses data on mounted', async () => {
const fetchData = wrapper.findComponent({ name: 'FetchData' });
expect(fetchData.props('url')).toBe('Warehouses');
expect(fetchData.props('autoLoad')).toBe(true);
});
it('should toggle dark mode correctly and update preferences', async () => {
await vm.saveDarkMode(true);
expect(axios.patch).toHaveBeenCalledWith('/UserConfigs/115', { darkMode: true });
expect(vm.user.darkMode).toBe(true);
vm.updatePreferences();
expect(vm.darkMode).toBe(true);
});
it('should change user language and update preferences', async () => {
const userLanguage = 'es';
await vm.saveLanguage(userLanguage);
expect(axios.patch).toHaveBeenCalledWith('/VnUsers/115', { lang: userLanguage });
expect(vm.user.lang).toBe(userLanguage);
vm.updatePreferences();
expect(vm.locale).toBe(userLanguage);
});
it('should update user data', async () => {
const key = 'name';
const value = 'itboss';
await vm.saveUserData(key, value);
expect(axios.post).toHaveBeenCalledWith('UserConfigs/setUserConfig', { [key]: value });
});
});

View File

@ -1,53 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStateStore } from 'stores/useStateStore';
import { useHasContent } from 'src/composables/useHasContent';
import { watch } from 'vue';
const { t } = useI18n();
const stateStore = useStateStore();
const hasContent = useHasContent('#advanced-menu');
const $props = defineProps({
isMainSection: {
type: Boolean,
default: false,
},
});
watch(
() => $props.isMainSection,
(val) => {
if (stateStore) stateStore.rightAdvancedDrawer = val;
},
{ immediate: true },
);
</script>
<template>
<Teleport to="#searchbar-after" v-if="stateStore.isHeaderMounted()">
<QBtn
v-if="hasContent || $slots['advanced-menu']"
flat
@click="stateStore.toggleRightAdvancedDrawer()"
round
icon="tune"
color="white"
>
<QTooltip bottom anchor="bottom right">
{{ t('globals.advancedMenu') }}
</QTooltip>
</QBtn>
</Teleport>
<QDrawer
v-model="stateStore.rightAdvancedDrawer"
side="right"
:width="256"
:overlay="!isMainSection"
v-bind="$attrs"
>
<QScrollArea class="fit">
<div id="advanced-menu"></div>
<slot v-if="!hasContent" name="advanced-menu" />
</QScrollArea>
</QDrawer>
</template>

View File

@ -1,23 +1,35 @@
<script setup> <script setup>
import { onMounted, useSlots } from 'vue'; import { ref, onMounted, useSlots } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStateStore } from 'stores/useStateStore'; import { useStateStore } from 'stores/useStateStore';
import { useQuasar } from 'quasar';
import { useHasContent } from 'src/composables/useHasContent';
const { t } = useI18n();
const quasar = useQuasar();
const stateStore = useStateStore();
const slots = useSlots(); const slots = useSlots();
const hasContent = useHasContent('#right-panel'); const hasContent = ref(false);
const rightPanel = ref(null);
onMounted(() => { onMounted(() => {
if ((!slots['right-panel'] && !hasContent.value) || quasar.platform.is.mobile) rightPanel.value = document.querySelector('#right-panel');
stateStore.rightDrawer = false; if (!rightPanel.value) return;
// Check if there's content to display
const observer = new MutationObserver(() => {
hasContent.value = rightPanel.value.childNodes.length;
}); });
observer.observe(rightPanel.value, {
subtree: true,
childList: true,
attributes: true,
});
if (!slots['right-panel'] && !hasContent.value) stateStore.rightDrawer = false;
});
const { t } = useI18n();
const stateStore = useStateStore();
</script> </script>
<template> <template>
<Teleport to="#actions-prepend" v-if="stateStore.isHeaderMounted()"> <Teleport to="#actions-append" v-if="stateStore.isHeaderMounted()">
<div class="row q-gutter-x-sm"> <div class="row q-gutter-x-sm">
<QBtn <QBtn
v-if="hasContent || $slots['right-panel']" v-if="hasContent || $slots['right-panel']"
@ -25,8 +37,7 @@ onMounted(() => {
@click="stateStore.toggleRightDrawer()" @click="stateStore.toggleRightDrawer()"
round round
dense dense
icon="dock_to_left" icon="menu"
data-cy="toggle-right-drawer"
> >
<QTooltip bottom anchor="bottom right"> <QTooltip bottom anchor="bottom right">
{{ t('globals.collapseMenu') }} {{ t('globals.collapseMenu') }}
@ -34,8 +45,8 @@ onMounted(() => {
</QBtn> </QBtn>
</div> </div>
</Teleport> </Teleport>
<QDrawer v-model="stateStore.rightDrawer" side="right" :width="256"> <QDrawer v-model="stateStore.rightDrawer" side="right" :width="256" show-if-above>
<QScrollArea class="fit"> <QScrollArea class="fit text-grey-8">
<div id="right-panel"></div> <div id="right-panel"></div>
<slot v-if="!hasContent" name="right-panel" /> <slot v-if="!hasContent" name="right-panel" />
</QScrollArea> </QScrollArea>

View File

@ -52,14 +52,15 @@ const toggleMarkAll = (val) => {
const getConfig = async (url, filter) => { const getConfig = async (url, filter) => {
const response = await axios.get(url, { const response = await axios.get(url, {
params: { filter: JSON.stringify(filter) }, params: { filter: filter },
}); });
return response.data && response.data.length > 0 ? response.data[0] : null; return response.data && response.data.length > 0 ? response.data[0] : null;
}; };
const fetchViewConfigData = async () => { const fetchViewConfigData = async () => {
try {
const userConfigFilter = { const userConfigFilter = {
where: { tableCode: $props.tableCode, userFk: user.value.id }, where: { tableCode: $props.tableCode, userFk: user.id },
}; };
const userConfig = await getConfig('UserConfigViews', userConfigFilter); const userConfig = await getConfig('UserConfigViews', userConfigFilter);
@ -73,18 +74,16 @@ const fetchViewConfigData = async () => {
const defaultConfig = await getConfig('DefaultViewConfigs', defaultConfigFilter); const defaultConfig = await getConfig('DefaultViewConfigs', defaultConfigFilter);
if (defaultConfig) { if (defaultConfig) {
// Si el backend devuelve una configuración por defecto la usamos
setUserConfigViewData(defaultConfig.columns); setUserConfigViewData(defaultConfig.columns);
return; return;
} else { }
// Si no hay configuración por defecto mostramos todas las columnas } catch (err) {
const defaultColumns = {}; console.err('Error fetching config view data', err);
$props.allColumns.forEach((col) => (defaultColumns[col] = true));
setUserConfigViewData(defaultColumns);
} }
}; };
const saveConfig = async () => { const saveConfig = async () => {
try {
const params = {}; const params = {};
const configuration = {}; const configuration = {};
@ -123,6 +122,9 @@ const saveConfig = async () => {
emitSavedConfig(); emitSavedConfig();
notify('globals.dataSaved', 'positive'); notify('globals.dataSaved', 'positive');
popupProxyRef.value.hide(); popupProxyRef.value.hide();
} catch (err) {
console.error('Error saving user view config', err);
}
}; };
const emitSavedConfig = () => { const emitSavedConfig = () => {

View File

@ -1,24 +1,20 @@
<script setup> <script setup>
import { nextTick, ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { QInput } from 'quasar'; import { QInput } from 'quasar';
const $props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: String, type: String,
default: '', default: '',
}, },
insertable: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['update:modelValue', 'accountShortToStandard']); const emit = defineEmits(['update:modelValue', 'accountShortToStandard']);
let internalValue = ref($props.modelValue); let internalValue = ref(props.modelValue);
watch( watch(
() => $props.modelValue, () => props.modelValue,
(newVal) => { (newVal) => {
internalValue.value = newVal; internalValue.value = newVal;
} }
@ -32,46 +28,8 @@ watch(
} }
); );
const handleKeydown = (e) => {
if (e.key === 'Backspace') return;
if (e.key === '.') {
accountShortToStandard();
// TODO: Fix this setTimeout, with nextTick doesn't work
setTimeout(() => {
setCursorPosition(0, e.target);
}, 1);
return;
}
if ($props.insertable && e.key.match(/[0-9]/)) {
handleInsertMode(e);
}
};
function setCursorPosition(pos, el = vnInputRef.value) {
el.focus();
el.setSelectionRange(pos, pos);
}
const vnInputRef = ref(false);
const handleInsertMode = (e) => {
e.preventDefault();
const input = e.target;
const cursorPos = input.selectionStart;
const { maxlength } = vnInputRef.value;
let currentValue = internalValue.value;
if (!currentValue) currentValue = e.key;
const newValue = e.key;
if (newValue && !isNaN(newValue) && cursorPos < maxlength) {
internalValue.value =
currentValue.substring(0, cursorPos) +
newValue +
currentValue.substring(cursorPos + 1);
}
nextTick(() => {
input.setSelectionRange(cursorPos + 1, cursorPos + 1);
});
};
function accountShortToStandard() { function accountShortToStandard() {
internalValue.value = internalValue.value?.replace( internalValue.value = internalValue.value.replace(
'.', '.',
'0'.repeat(11 - internalValue.value.length) '0'.repeat(11 - internalValue.value.length)
); );
@ -79,5 +37,5 @@ function accountShortToStandard() {
</script> </script>
<template> <template>
<QInput @keydown="handleKeydown" ref="vnInputRef" v-model="internalValue" /> <q-input v-model="internalValue" />
</template> </template>

View File

@ -15,10 +15,10 @@ let root = ref(null);
watchEffect(() => { watchEffect(() => {
matched.value = currentRoute.value.matched.filter( matched.value = currentRoute.value.matched.filter(
(matched) => !!matched?.meta?.title || !!matched?.meta?.icon (matched) => Object.keys(matched.meta).length
); );
breadcrumbs.value.length = 0; breadcrumbs.value.length = 0;
if (!matched.value[0]) return;
if (matched.value[0].name != 'Dashboard') { if (matched.value[0].name != 'Dashboard') {
root.value = useCamelCase(matched.value[0].path.substring(1).toLowerCase()); root.value = useCamelCase(matched.value[0].path.substring(1).toLowerCase());

View File

@ -1,20 +0,0 @@
<script setup>
import VnSelect from './VnSelect.vue';
defineProps({
selectProps: { type: Object, required: true },
promise: { type: Function, default: () => {} },
});
</script>
<template>
<QBtnDropdown v-bind="$attrs" color="primary">
<VnSelect
v-bind="selectProps"
hide-selected
hide-dropdown-icon
focus-on-mount
@update:model-value="promise"
data-cy="vnBtnSelect_select"
/>
</QBtnDropdown>
</template>

View File

@ -1,6 +1,6 @@
<script setup> <script setup>
import { onBeforeMount, computed } from 'vue'; import { onBeforeMount, computed } from 'vue';
import { useRoute, useRouter, onBeforeRouteUpdate } from 'vue-router'; import { useRoute } from 'vue-router';
import { useArrayData } from 'src/composables/useArrayData'; import { useArrayData } from 'src/composables/useArrayData';
import { useStateStore } from 'stores/useStateStore'; import { useStateStore } from 'stores/useStateStore';
import useCardSize from 'src/composables/useCardSize'; import useCardSize from 'src/composables/useCardSize';
@ -8,54 +8,39 @@ import VnSubToolbar from '../ui/VnSubToolbar.vue';
import VnSearchbar from 'components/ui/VnSearchbar.vue'; import VnSearchbar from 'components/ui/VnSearchbar.vue';
import LeftMenu from 'components/LeftMenu.vue'; import LeftMenu from 'components/LeftMenu.vue';
import RightMenu from 'components/common/RightMenu.vue'; import RightMenu from 'components/common/RightMenu.vue';
const props = defineProps({ const props = defineProps({
dataKey: { type: String, required: true }, dataKey: { type: String, required: true },
url: { type: String, default: undefined }, baseUrl: { type: String, default: undefined },
customUrl: { type: String, default: undefined },
filter: { type: Object, default: () => {} }, filter: { type: Object, default: () => {} },
descriptor: { type: Object, required: true }, descriptor: { type: Object, required: true },
filterPanel: { type: Object, default: undefined }, filterPanel: { type: Object, default: undefined },
idInWhere: { type: Boolean, default: false },
searchDataKey: { type: String, default: undefined }, searchDataKey: { type: String, default: undefined },
searchbarProps: { type: Object, default: undefined }, searchUrl: { type: String, default: undefined },
redirectOnError: { type: Boolean, default: false }, searchbarLabel: { type: String, default: '' },
searchbarInfo: { type: String, default: '' },
searchCustomRouteRedirect: { type: String, default: undefined },
searchRedirect: { type: Boolean, default: true },
searchMakeFetch: { type: Boolean, default: true },
}); });
const stateStore = useStateStore(); const stateStore = useStateStore();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const url = computed(() => {
const searchRightDataKey = computed(() => { if (props.baseUrl) return `${props.baseUrl}/${route.params.id}`;
if (!props.searchDataKey) return route.name; return props.customUrl;
return props.searchDataKey;
}); });
const arrayData = useArrayData(props.dataKey, { const arrayData = useArrayData(props.dataKey, {
url: props.url, url: url.value,
userFilter: props.filter, filter: props.filter,
oneRecord: true,
}); });
onBeforeMount(async () => { onBeforeMount(async () => {
try { if (!props.baseUrl) arrayData.store.filter.where = { id: route.params.id };
await fetch(route.params.id); await arrayData.fetch({ append: false });
} catch {
const { matched: matches } = router.currentRoute.value;
const { path } = matches.at(-1);
router.push({ path: path.replace(/:id.*/, '') });
}
}); });
onBeforeRouteUpdate(async (to, from) => {
const id = to.params.id;
if (id !== from.params.id) await fetch(id, true);
});
async function fetch(id, append = false) {
const regex = /\/(\d+)/;
if (props.idInWhere) arrayData.store.filter.where = { id };
else if (!regex.test(props.url)) arrayData.store.url = `${props.url}/${id}`;
else arrayData.store.url = props.url.replace(regex, `/${id}`);
await arrayData.fetch({ append, updateRouter: false });
}
</script> </script>
<template> <template>
<QDrawer <QDrawer
@ -71,18 +56,26 @@ async function fetch(id, append = false) {
</QScrollArea> </QScrollArea>
</QDrawer> </QDrawer>
<slot name="searchbar" v-if="props.searchDataKey"> <slot name="searchbar" v-if="props.searchDataKey">
<VnSearchbar :data-key="props.searchDataKey" v-bind="props.searchbarProps" /> <VnSearchbar
:data-key="props.searchDataKey"
:url="props.searchUrl"
:label="props.searchbarLabel"
:info="props.searchbarInfo"
:custom-route-redirect-name="searchCustomRouteRedirect"
:redirect="searchRedirect"
/>
</slot> </slot>
<slot v-else name="searchbar" />
<RightMenu> <RightMenu>
<template #right-panel v-if="props.filterPanel"> <template #right-panel v-if="props.filterPanel">
<component :is="props.filterPanel" :data-key="searchRightDataKey" /> <component :is="props.filterPanel" :data-key="props.searchDataKey" />
</template> </template>
</RightMenu> </RightMenu>
<QPageContainer> <QPageContainer>
<QPage> <QPage>
<VnSubToolbar /> <VnSubToolbar />
<div :class="[useCardSize(), $attrs.class]"> <div :class="[useCardSize(), $attrs.class]">
<RouterView :key="$route.path" /> <RouterView />
</div> </div>
</QPage> </QPage>
</QPageContainer> </QPageContainer>

View File

@ -1,64 +0,0 @@
<script setup>
import { onBeforeMount } from 'vue';
import { useRouter, onBeforeRouteUpdate } from 'vue-router';
import { useArrayData } from 'src/composables/useArrayData';
import { useStateStore } from 'stores/useStateStore';
import useCardSize from 'src/composables/useCardSize';
import LeftMenu from 'components/LeftMenu.vue';
import VnSubToolbar from '../ui/VnSubToolbar.vue';
const props = defineProps({
dataKey: { type: String, required: true },
url: { type: String, default: undefined },
idInWhere: { type: Boolean, default: false },
filter: { type: Object, default: () => {} },
descriptor: { type: Object, required: true },
filterPanel: { type: Object, default: undefined },
searchDataKey: { type: String, default: undefined },
searchbarProps: { type: Object, default: undefined },
redirectOnError: { type: Boolean, default: false },
});
const stateStore = useStateStore();
const router = useRouter();
const arrayData = useArrayData(props.dataKey, {
url: props.url,
userFilter: props.filter,
oneRecord: true,
});
onBeforeMount(async () => {
const route = router.currentRoute.value;
try {
await fetch(route.params.id);
} catch {
const { matched: matches } = route;
const { path } = matches.at(-1);
router.push({ path: path.replace(/:id.*/, '') });
}
});
onBeforeRouteUpdate(async (to, from) => {
const id = to.params.id;
if (id !== from.params.id) await fetch(id, true);
});
async function fetch(id, append = false) {
const regex = /\/(\d+)/;
if (props.idInWhere) arrayData.store.filter.where = { id };
else if (!regex.test(props.url)) arrayData.store.url = `${props.url}/${id}`;
else arrayData.store.url = props.url.replace(regex, `/${id}`);
await arrayData.fetch({ append, updateRouter: false });
}
</script>
<template>
<Teleport to="#left-panel" v-if="stateStore.isHeaderMounted()">
<component :is="descriptor" />
<QSeparator />
<LeftMenu source="card" />
</Teleport>
<VnSubToolbar />
<div :class="[useCardSize(), $attrs.class]">
<RouterView :key="$route.path" />
</div>
</template>

View File

@ -1,136 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import VnRow from '../ui/VnRow.vue';
import FetchData from '../FetchData.vue';
import useNotify from 'src/composables/useNotify';
import VnInputPassword from 'src/components/common/VnInputPassword.vue';
const props = defineProps({
submitFn: { type: Function, default: () => {} },
askOldPass: { type: Boolean, default: false },
});
const emit = defineEmits(['onSubmit']);
const { t } = useI18n();
const { notify } = useNotify();
const form = ref();
const changePassDialog = ref();
const passwords = ref({ newPassword: null, repeatPassword: null });
const requirements = ref([]);
const isLoading = ref(false);
const validate = async () => {
const { newPassword, repeatPassword, oldPassword } = passwords.value;
if (!newPassword) {
notify(t('You must enter a new password'), 'negative');
return;
}
if (newPassword !== repeatPassword) {
notify(t("Passwords don't match"), 'negative');
return;
}
try {
isLoading.value = true;
await props.submitFn(newPassword, oldPassword);
emit('onSubmit');
} catch (e) {
notify('errors.writeRequest', 'negative');
} finally {
changePassDialog.value.hide();
isLoading.value = false;
}
};
defineExpose({ show: () => changePassDialog.value.show() });
</script>
<template>
<FetchData
url="UserPasswords/findOne"
auto-load
@on-fetch="(data) => (requirements = data)"
/>
<QDialog ref="changePassDialog">
<QCard style="width: 350px">
<QCardSection>
<slot name="header">
<VnRow class="items-center" style="flex-direction: row">
<span class="text-h6" v-text="t('globals.changePass')" />
<QIcon
class="cursor-pointer"
name="close"
size="xs"
style="flex: 0"
v-close-popup
/>
</VnRow>
</slot>
</QCardSection>
<QForm ref="form">
<QCardSection>
<VnInputPassword
v-if="props.askOldPass"
:label="t('Old password')"
v-model="passwords.oldPassword"
:required="true"
:toggle-visibility="true"
autofocus
/>
<VnInputPassword
:label="t('New password')"
v-model="passwords.newPassword"
:required="true"
:toggle-visibility="true"
:info="
t('passwordRequirements', {
length: requirements.length,
nAlpha: requirements.nAlpha,
nUpper: requirements.nUpper,
nDigits: requirements.nDigits,
nPunct: requirements.nPunct,
})
"
autofocus
/>
<VnInputPassword
:label="t('Repeat password')"
v-model="passwords.repeatPassword"
:toggle-visibility="true"
/>
</QCardSection>
</QForm>
<QCardActions>
<slot name="actions">
<QBtn
:disabled="isLoading"
:loading="isLoading"
:label="t('globals.cancel')"
class="q-ml-sm"
color="primary"
flat
type="reset"
v-close-popup
/>
<QBtn
:disabled="isLoading"
:loading="isLoading"
:label="t('globals.confirm')"
color="primary"
@click="validate"
/>
</slot>
</QCardActions>
</QCard>
</QDialog>
</template>
<i18n>
es:
New password: Nueva contraseña
Repeat password: Repetir contraseña
You must enter a new password: Debes introducir la nueva contraseña
Passwords don't match: Las contraseñas no coinciden
</i18n>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, defineModel } from 'vue';
const model = defineModel(undefined, { required: true }); const model = defineModel(undefined, { required: true });
const $props = defineProps({ const $props = defineProps({
@ -12,7 +12,7 @@ const $props = defineProps({
default: () => {}, default: () => {},
}, },
value: { value: {
type: [Object, Number, String, Boolean], type: [Object, Number, String],
default: () => {}, default: () => {},
}, },
}); });
@ -33,7 +33,7 @@ function mix(toComponent) {
...toComponent, ...toComponent,
...toValueAttrs(customComponent?.forceAttrs), ...toValueAttrs(customComponent?.forceAttrs),
}, },
event: { ...customComponent?.event, ...event }, event: event ?? customComponent?.event,
}; };
} }
@ -54,6 +54,7 @@ function toValueAttrs(attrs) {
v-bind="mix(toComponent).attrs" v-bind="mix(toComponent).attrs"
v-on="mix(toComponent).event ?? {}" v-on="mix(toComponent).event ?? {}"
v-model="model" v-model="model"
class="fit"
/> />
</span> </span>
</template> </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

@ -1,29 +0,0 @@
<script setup>
const model = defineModel({ type: [String, Number], required: true });
</script>
<template>
<QDate v-model="model" :today-btn="true" :options="$attrs.options" />
</template>
<style lang="scss" scoped>
.q-date {
width: 245px;
min-width: unset;
:deep(.q-date__calendar) {
padding-bottom: 0;
}
:deep(.q-date__view) {
min-height: 245px;
padding: 8px;
}
:deep(.q-date__calendar-days-container) {
min-height: 160px;
height: unset;
}
:deep(.q-date__header) {
padding: 2px 2px 5px 12px;
height: 60px;
}
}
</style>

View File

@ -1,39 +0,0 @@
<script setup>
import { toDateFormat } from 'src/filters/date.js';
defineProps({ date: { type: [Date, String], required: true } });
function getBadgeAttrs(date) {
let today = Date.vnNew();
today.setHours(0, 0, 0, 0);
let timeTicket = new Date(date);
timeTicket.setHours(0, 0, 0, 0);
let timeDiff = today - timeTicket;
if (timeDiff == 0) return { color: 'warning', class: 'black-text-color' };
if (timeDiff < 0) return { color: 'success', class: 'black-text-color' };
return { color: 'transparent', class: 'normal-text-color' };
}
function formatShippedDate(date) {
if (!date) return '-';
const dateSplit = date.split('T');
const [year, month, day] = dateSplit[0].split('-');
const newDate = new Date(year, month - 1, day);
return toDateFormat(newDate);
}
</script>
<template>
<QBadge v-bind="getBadgeAttrs(date)" class="q-pa-sm" style="font-size: 14px">
{{ formatShippedDate(date) }}
</QBadge>
</template>
<style lang="scss">
.black-text-color {
color: var(--vn-black-text-color);
}
.normal-text-color {
color: var(--vn-text-color);
}
</style>

View File

@ -31,10 +31,6 @@ const $props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
description: {
type: String,
default: null,
},
}); });
const warehouses = ref(); const warehouses = ref();
@ -47,8 +43,7 @@ const dms = ref({});
onMounted(() => { onMounted(() => {
defaultData(); defaultData();
if (!$props.formInitialData) if (!$props.formInitialData)
dms.value.description = dms.value.description = t($props.model + 'Description', dms.value);
$props.description ?? t($props.model + 'Description', dms.value);
}); });
function onFileChange(files) { function onFileChange(files) {
dms.value.hasFileAttached = !!files; dms.value.hasFileAttached = !!files;
@ -59,6 +54,7 @@ function mapperDms(data) {
const formData = new FormData(); const formData = new FormData();
const { files } = data; const { files } = data;
if (files) formData.append(files?.name, files); if (files) formData.append(files?.name, files);
delete data.files;
const dms = { const dms = {
hasFile: !!data.hasFile, hasFile: !!data.hasFile,
@ -82,7 +78,6 @@ async function save() {
const body = mapperDms(dms.value); const body = mapperDms(dms.value);
const response = await axios.post(getUrl(), body[0], body[1]); const response = await axios.post(getUrl(), body[0], body[1]);
emit('onDataSaved', body[1].params, response); emit('onDataSaved', body[1].params, response);
delete dms.value.files;
return response; return response;
} }
@ -163,14 +158,13 @@ function addDefaultData(data) {
/> />
<QFile <QFile
ref="inputFileRef" ref="inputFileRef"
:label="t('globals.file')" :label="t('entry.buys.file')"
v-model="dms.files" v-model="dms.files"
:multiple="false" :multiple="false"
:accept="allowedContentTypes" :accept="allowedContentTypes"
@update:model-value="onFileChange(dms.files)" @update:model-value="onFileChange(dms.files)"
class="required" class="required"
:display-value="dms.file" :display-value="dms.file"
data-cy="VnDms_inputFile"
> >
<template #append> <template #append>
<QIcon <QIcon

View File

@ -5,22 +5,19 @@ import { useRoute } from 'vue-router';
import { useQuasar, QCheckbox, QBtn, QInput } from 'quasar'; import { useQuasar, QCheckbox, QBtn, QInput } from 'quasar';
import axios from 'axios'; import axios from 'axios';
import VnUserLink from '../ui/VnUserLink.vue';
import { downloadFile } from 'src/composables/downloadFile';
import VnImg from 'components/ui/VnImg.vue';
import VnPaginate from 'components/ui/VnPaginate.vue'; import VnPaginate from 'components/ui/VnPaginate.vue';
import VnDms from 'src/components/common/VnDms.vue'; import VnDms from 'src/components/common/VnDms.vue';
import VnConfirm from 'components/ui/VnConfirm.vue'; import VnConfirm from 'components/ui/VnConfirm.vue';
import VnInputDate from 'components/common/VnInputDate.vue'; import VnInputDate from 'components/common/VnInputDate.vue';
import { useSession } from 'src/composables/useSession'; import VnUserLink from '../ui/VnUserLink.vue';
import { downloadFile } from 'src/composables/downloadFile';
const route = useRoute(); const route = useRoute();
const quasar = useQuasar(); const quasar = useQuasar();
const { t } = useI18n(); const { t } = useI18n();
const rows = ref([]); const rows = ref();
const dmsRef = ref(); const dmsRef = ref();
const formDialog = ref({}); const formDialog = ref({});
const token = useSession().getTokenMultimedia();
const $props = defineProps({ const $props = defineProps({
model: { model: {
@ -92,23 +89,6 @@ const dmsFilter = {
}; };
const columns = computed(() => [ const columns = computed(() => [
{
label: '',
name: 'file',
align: 'left',
component: VnImg,
props: (prop) => {
return {
storage: 'dms',
collection: null,
resolution: null,
id: Number(prop.row.file.split('.')[0]),
token: token,
class: 'rounded',
ratio: 1,
};
},
},
{ {
align: 'left', align: 'left',
field: 'id', field: 'id',
@ -155,13 +135,19 @@ const columns = computed(() => [
field: 'hasFile', field: 'hasFile',
label: t('globals.original'), label: t('globals.original'),
name: 'hasFile', name: 'hasFile',
toolTip: t('The documentation is available in paper form'),
component: QCheckbox, component: QCheckbox,
props: (prop) => ({ props: (prop) => ({
disable: true, disable: true,
'model-value': Boolean(prop.value), 'model-value': Boolean(prop.value),
}), }),
}, },
{
align: 'left',
field: 'file',
label: t('globals.file'),
name: 'file',
component: 'span',
},
{ {
align: 'left', align: 'left',
field: 'worker', field: 'worker',
@ -202,7 +188,7 @@ const columns = computed(() => [
prop.row.id, prop.row.id,
$props.downloadModel, $props.downloadModel,
undefined, undefined,
prop.row.download, prop.row.download
), ),
}, },
{ {
@ -287,24 +273,19 @@ function shouldRenderButton(button, isExternal = false) {
if (button.name == 'download') return true; if (button.name == 'download') return true;
return button.external === isExternal; return button.external === isExternal;
} }
defineExpose({
dmsRef,
});
</script> </script>
<template> <template>
<VnPaginate <VnPaginate
ref="dmsRef" ref="dmsRef"
:data-key="$props.model" :data-key="$props.model"
:url="$props.model" :url="$props.model"
:user-filter="dmsFilter" :filter="dmsFilter"
:order="['dmsFk DESC']" :order="['dmsFk DESC']"
auto-load :auto-load="true"
@on-fetch="setData" @on-fetch="setData"
> >
<template #body> <template #body>
<QTable <QTable
v-if="rows"
:columns="columns" :columns="columns"
:rows="rows" :rows="rows"
class="full-width q-mt-md" class="full-width q-mt-md"
@ -312,14 +293,6 @@ defineExpose({
row-key="clientFk" row-key="clientFk"
:grid="$q.screen.lt.sm" :grid="$q.screen.lt.sm"
> >
<template #header="props">
<QTr :props="props" class="bg">
<QTh v-for="col in props.cols" :key="col.name" :props="props">
<QTooltip v-if="col.toolTip">{{ col.toolTip }}</QTooltip
>{{ col.label }}
</QTh>
</QTr>
</template>
<template #body-cell="props"> <template #body-cell="props">
<QTd :props="props"> <QTd :props="props">
<QTr :props="props"> <QTr :props="props">
@ -374,7 +347,7 @@ defineExpose({
v-if=" v-if="
shouldRenderButton( shouldRenderButton(
button.name, button.name,
props.row.isDocuware, props.row.isDocuware
) )
" "
:is="button.component" :is="button.component"
@ -389,14 +362,6 @@ defineExpose({
</div> </div>
</template> </template>
</QTable> </QTable>
<div
v-else
class="info-row q-pa-md text-center"
>
<h5>
{{ t('No data to display') }}
</h5>
</div>
</template> </template>
</VnPaginate> </VnPaginate>
<QDialog v-model="formDialog.show"> <QDialog v-model="formDialog.show">
@ -409,21 +374,14 @@ defineExpose({
/> />
</QDialog> </QDialog>
<QPageSticky position="bottom-right" :offset="[25, 25]"> <QPageSticky position="bottom-right" :offset="[25, 25]">
<QBtn <QBtn fab color="primary" icon="add" @click="showFormDialog()" />
fab
color="primary"
icon="add"
v-shortcut
@click="showFormDialog()"
class="fill-icon"
>
<QTooltip>
{{ t('Upload file') }}
</QTooltip>
</QBtn>
</QPageSticky> </QPageSticky>
</template> </template>
<style scoped> <style scoped>
.q-gutter-y-ms {
display: grid;
row-gap: 20px;
}
.labelColor { .labelColor {
color: var(--vn-label-color); color: var(--vn-label-color);
} }
@ -431,10 +389,7 @@ defineExpose({
<i18n> <i18n>
en: en:
contentTypesInfo: Allowed file types {allowedContentTypes} contentTypesInfo: Allowed file types {allowedContentTypes}
The documentation is available in paper form: The documentation is available in paper form
es: es:
contentTypesInfo: Tipos de archivo permitidos {allowedContentTypes} contentTypesInfo: Tipos de archivo permitidos {allowedContentTypes}
Generate identifier for original file: Generar identificador para archivo original Generate identifier for original file: Generar identificador para archivo original
Upload file: Subir fichero
the documentation is available in paper form: Se tiene la documentación en papel
</i18n> </i18n>

View File

@ -1,11 +1,7 @@
<script setup> <script setup>
import { computed, ref, useAttrs, nextTick } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRequired } from 'src/composables/useRequired';
const $attrs = useAttrs();
const { isRequired, requiredFieldRule } = useRequired($attrs);
const { t } = useI18n();
const emit = defineEmits([ const emit = defineEmits([
'update:modelValue', 'update:modelValue',
'update:options', 'update:options',
@ -30,31 +26,16 @@ const $props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
emptyToNull: {
type: Boolean,
default: true,
},
insertable: {
type: Boolean,
default: false,
},
maxlength: {
type: Number,
default: null,
},
uppercase: {
type: Boolean,
default: false,
},
}); });
const { t } = useI18n();
const requiredFieldRule = (val) => !!val || t('globals.fieldRequired');
const vnInputRef = ref(null); const vnInputRef = ref(null);
const value = computed({ const value = computed({
get() { get() {
return $props.modelValue; return $props.modelValue;
}, },
set(value) { set(value) {
if ($props.emptyToNull && value === '') value = null;
emit('update:modelValue', value); emit('update:modelValue', value);
}, },
}); });
@ -75,113 +56,50 @@ const focus = () => {
defineExpose({ defineExpose({
focus, focus,
vnInputRef,
}); });
const mixinRules = [ const inputRules = [
requiredFieldRule,
...($attrs.rules ?? []),
(val) => { (val) => {
const { maxlength } = vnInputRef.value; const { min } = vnInputRef.value.$attrs;
if (maxlength && +val.length > maxlength)
return t(`maxLength`, { value: maxlength });
const { min, max } = vnInputRef.value.$attrs;
if (!min) return null;
if (min >= 0) if (Math.floor(val) < min) return t('inputMin', { value: min }); if (min >= 0) if (Math.floor(val) < min) return t('inputMin', { value: min });
if (!max) return null;
if (max > 0) {
if (Math.floor(val) > max) return t('inputMax', { value: max });
}
}, },
]; ];
const handleKeydown = (e) => {
if (e.key === 'Backspace') return;
if ($props.insertable && e.key.match(/[0-9]/)) {
handleInsertMode(e);
}
};
const handleInsertMode = (e) => {
e.preventDefault();
const input = e.target;
const cursorPos = input.selectionStart;
const { maxlength } = vnInputRef.value;
let currentValue = value.value;
if (!currentValue) currentValue = e.key;
const newValue = e.key;
if (newValue && !isNaN(newValue) && cursorPos < maxlength) {
value.value =
currentValue.substring(0, cursorPos) +
newValue +
currentValue.substring(cursorPos + 1);
}
nextTick(() => {
input.setSelectionRange(cursorPos + 1, cursorPos + 1);
});
};
const handleUppercase = () => {
value.value = value.value?.toUpperCase() || '';
};
</script> </script>
<template> <template>
<div @mouseover="hover = true" @mouseleave="hover = false"> <div
@mouseover="hover = true"
@mouseleave="hover = false"
:rules="$attrs.required ? [requiredFieldRule] : null"
>
<QInput <QInput
ref="vnInputRef" ref="vnInputRef"
v-model="value" v-model="value"
v-bind="{ ...$attrs, ...styleAttrs }" v-bind="{ ...$attrs, ...styleAttrs }"
:type="$attrs.type" :type="$attrs.type"
:class="{ required: isRequired }" :class="{ required: $attrs.required }"
@keyup.enter="emit('keyup.enter')" @keyup.enter="emit('keyup.enter')"
@keydown="handleKeydown"
:clearable="false" :clearable="false"
:rules="mixinRules" :rules="inputRules"
:lazy-rules="true" :lazy-rules="true"
hide-bottom-space hide-bottom-space
:data-cy="$attrs.dataCy ?? $attrs.label + '_input'"
> >
<template #prepend> <template v-if="$slots.prepend" #prepend>
<slot name="prepend" /> <slot name="prepend" />
</template> </template>
<template #append> <template #append>
<slot name="append" v-if="$slots.append && !$attrs.disabled" />
<QIcon <QIcon
name="close" name="close"
size="xs" size="xs"
:style="{ v-if="hover && value && !$attrs.disabled && $props.clearable"
visibility:
hover &&
value &&
!$attrs.disabled &&
!$attrs.readonly &&
$props.clearable
? 'visible'
: 'hidden',
}"
@click=" @click="
() => { () => {
value = null; value = null;
vnInputRef.focus();
emit('remove'); emit('remove');
} }
" "
></QIcon> ></QIcon>
<QIcon
name="match_case"
size="xs"
v-if="!$attrs.disabled && !($attrs.readonly) && $props.uppercase"
@click="handleUppercase"
class="uppercase-icon"
>
<QTooltip>
{{ t('Convert to uppercase') }}
</QTooltip>
</QIcon>
<slot name="append" v-if="$slots.append && !$attrs.disabled" />
<QIcon v-if="info" name="info"> <QIcon v-if="info" name="info">
<QTooltip max-width="350px"> <QTooltip max-width="350px">
{{ info }} {{ info }}
@ -191,27 +109,9 @@ const handleUppercase = () => {
</QInput> </QInput>
</div> </div>
</template> </template>
<style>
.uppercase-icon {
transition: color 0.3s, transform 0.2s;
cursor: pointer;
}
.uppercase-icon:hover {
color: #ed9937;
transform: scale(1.2);
}
</style>
<i18n> <i18n>
en: en:
inputMin: Must be more than {value} inputMin: Must be more than {value}
maxLength: The value exceeds {value} characters
inputMax: Must be less than {value}
es: es:
inputMin: Debe ser mayor a {value} inputMin: Debe ser mayor a {value}
maxLength: El valor excede los {value} carácteres
inputMax: Debe ser menor a {value}
Convert to uppercase: Convertir a mayúsculas
</i18n> </i18n>

View File

@ -1,83 +1,84 @@
<script setup> <script setup>
import { onMounted, watch, computed, ref, useAttrs } from 'vue'; import { computed, ref } from 'vue';
import { date } from 'quasar'; import { useI18n } from 'vue-i18n';
import VnDate from './VnDate.vue'; import isValidDate from 'filters/isValidDate';
import { useRequired } from 'src/composables/useRequired';
const $attrs = useAttrs(); const props = defineProps({
const { isRequired, requiredFieldRule } = useRequired($attrs); modelValue: {
const model = defineModel({ type: [String, Date] }); type: String,
default: null,
const $props = defineProps({ },
readonly: {
type: Boolean,
default: false,
},
isOutlined: { isOutlined: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showEvent: { emitDateFormat: {
type: Boolean, type: Boolean,
default: true, default: false,
}, },
}); });
const hover = ref(false);
const vnInputDateRef = ref(null); const emit = defineEmits(['update:modelValue']);
const dateFormat = 'DD/MM/YYYY'; const { t } = useI18n();
const isPopupOpen = ref(); const requiredFieldRule = (val) => !!val || t('globals.fieldRequired');
const hover = ref();
const mask = ref();
const mixinRules = [requiredFieldRule, ...($attrs.rules ?? [])]; 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 formattedDate = computed({ const time = computed(() => (props.modelValue ? props.modelValue.split('T')?.[1] : null));
const value = computed({
get() { get() {
if (!model.value) return model.value; return props.modelValue;
return date.formatDate(new Date(model.value), dateFormat);
}, },
set(value) { set(value) {
if (value == model.value) return; emit(
let newDate; 'update:modelValue',
if (value) { props.emitDateFormat ? new Date(value) : joinDateAndTime(value, time.value)
// parse input
if (value.includes('/') && value.length >= 10) {
if (value.at(2) == '/') value = value.split('/').reverse().join('/');
value = date.formatDate(
new Date(value).toISOString(),
'YYYY-MM-DDTHH:mm:ss.SSSZ'
); );
}
const [year, month, day] = value.split('-').map((e) => parseInt(e));
newDate = new Date(year, month - 1, day);
if (model.value) {
const orgDate =
model.value instanceof Date ? model.value : new Date(model.value);
newDate.setHours(
orgDate.getHours(),
orgDate.getMinutes(),
orgDate.getSeconds(),
orgDate.getMilliseconds()
);
}
}
if (!isNaN(newDate)) model.value = newDate.toISOString();
}, },
}); });
const popupDate = computed(() => const isPopupOpen = ref(false);
model.value ? date.formatDate(new Date(model.value), 'YYYY/MM/DD') : model.value
); const onDateUpdate = (date) => {
onMounted(() => { value.value = date;
// fix quasar bug isPopupOpen.value = false;
mask.value = '##/##/####'; };
const padDate = (value) => value.toString().padStart(2, '0');
const formatDate = (dateString) => {
const date = new Date(dateString || '');
return `${date.getFullYear()}/${padDate(date.getMonth() + 1)}/${padDate(
date.getDate()
)}`;
};
const displayDate = (dateString) => {
if (!dateString || !isValidDate(dateString)) {
return '';
}
return new Date(dateString).toLocaleDateString([], {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}); });
watch( };
() => model.value,
(val) => (formattedDate.value = val),
{ immediate: true }
);
const styleAttrs = computed(() => { const styleAttrs = computed(() => {
return $props.isOutlined return props.isOutlined
? { ? {
dense: true, dense: true,
outlined: true, outlined: true,
@ -85,65 +86,52 @@ const styleAttrs = computed(() => {
} }
: {}; : {};
}); });
const manageDate = (date) => {
formattedDate.value = date;
isPopupOpen.value = false;
};
</script> </script>
<template> <template>
<div @mouseover="hover = true" @mouseleave="hover = false"> <div @mouseover="hover = true" @mouseleave="hover = false">
<QInput <QInput
ref="vnInputDateRef"
v-model="formattedDate"
class="vn-input-date" class="vn-input-date"
:mask="mask" readonly
placeholder="dd/mm/aaaa" :model-value="displayDate(value)"
v-bind="{ ...$attrs, ...styleAttrs }" v-bind="{ ...$attrs, ...styleAttrs }"
:class="{ required: isRequired }" :class="{ required: $attrs.required }"
:rules="mixinRules" :rules="$attrs.required ? [requiredFieldRule] : null"
:clearable="false" @click="isPopupOpen = true"
@click="isPopupOpen = !isPopupOpen"
@keydown="isPopupOpen = false"
hide-bottom-space
> >
<template #append> <template #append>
<QIcon <QIcon
name="close" name="close"
size="xs" size="xs"
v-if=" v-if="hover && value && !readonly"
($attrs.clearable == undefined || $attrs.clearable) && @click="onDateUpdate(null)"
hover && ></QIcon>
model && <QIcon name="event" class="cursor-pointer">
!$attrs.disable <QPopupProxy
" v-model="isPopupOpen"
@click=" cover
vnInputDateRef.focus();
model = null;
isPopupOpen = false;
"
/>
</template>
<QMenu
v-if="$q.screen.gt.xs"
transition-show="scale" transition-show="scale"
transition-hide="scale" transition-hide="scale"
v-model="isPopupOpen" :no-parent-event="props.readonly"
anchor="bottom left"
self="top start"
:no-focus="true"
:no-parent-event="true"
> >
<VnDate v-model="popupDate" @update:model-value="manageDate" /> <QDate
</QMenu> :today-btn="true"
<QDialog v-else v-model="isPopupOpen"> :model-value="formatDate(value)"
<VnDate v-model="popupDate" @update:model-value="manageDate" /> @update:model-value="onDateUpdate"
</QDialog> />
</QPopupProxy>
</QIcon>
</template>
</QInput> </QInput>
</div> </div>
</template> </template>
<i18n>
es: <style lang="scss">
Open date: Abrir fecha .vn-input-date.q-field--standard.q-field--readonly .q-field__control:before {
</i18n> border-bottom-style: solid;
}
.vn-input-date.q-field--outlined.q-field--readonly .q-field__control:before {
border-style: solid;
}
</style>

View File

@ -1,28 +0,0 @@
<script setup>
import VnInput from 'src/components/common/VnInput.vue';
defineProps({
step: { type: Number, default: 0.01 },
decimalPlaces: { type: Number, default: 2 },
positive: { type: Boolean, default: true },
});
const model = defineModel({ type: [Number, String] });
</script>
<template>
<VnInput
v-bind="$attrs"
v-model.number="model"
type="number"
:step="step"
@input="
(evt) => {
const val = evt.target.value;
if (positive && val < 0) return (model = 0);
const [, decimal] = val.split('.');
if (val && decimal?.length > decimalPlaces)
model = parseFloat(val).toFixed(decimalPlaces);
}
"
/>
</template>

View File

@ -1,31 +0,0 @@
<script setup>
import VnInput from 'src/components/common/VnInput.vue';
import { ref } from 'vue';
const model = defineModel({ type: [Number, String] });
const $props = defineProps({
toggleVisibility: {
type: Boolean,
default: false,
},
});
const showPassword = ref(false);
</script>
<template>
<VnInput
v-bind="{ ...$attrs }"
v-model="model"
:type="
$props.toggleVisibility ? (showPassword ? 'text' : 'password') : $attrs.type
"
>
<template #append v-if="toggleVisibility">
<QIcon
:name="showPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showPassword = !showPassword"
/>
</template>
</VnInput>
</template>

View File

@ -1,14 +1,14 @@
<script setup> <script setup>
import { computed, ref, useAttrs } from 'vue'; import { computed, ref } from 'vue';
import { date } from 'quasar'; import { useI18n } from 'vue-i18n';
import VnTime from './VnTime.vue'; import isValidDate from 'filters/isValidDate';
import { useRequired } from 'src/composables/useRequired';
const $attrs = useAttrs();
const { isRequired, requiredFieldRule } = useRequired($attrs);
const model = defineModel({ type: String });
const props = defineProps({ const props = defineProps({
timeOnly: { modelValue: {
type: String,
default: null,
},
readonly: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@ -17,12 +17,43 @@ const props = defineProps({
default: false, default: false,
}, },
}); });
const vnInputTimeRef = ref(null); const emit = defineEmits(['update:modelValue']);
const initialDate = ref(model.value ?? Date.vnNew()); const { t } = useI18n();
const mixinRules = [requiredFieldRule, ...($attrs.rules ?? [])]; const requiredFieldRule = (val) => !!val || t('globals.fieldRequired');
const dateFormat = 'HH:mm';
const isPopupOpen = ref(); const value = computed({
const hover = ref(); get() {
return props.modelValue;
},
set(value) {
const [hours, minutes] = value.split(':');
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;
};
const save = () => {
value.value = internalValue.value;
};
const formatTime = (dateString) => {
if (!dateString || !isValidDate(dateString)) {
return '';
}
const date = new Date(dateString || '');
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
};
const internalValue = ref(formatTime(value));
const styleAttrs = computed(() => { const styleAttrs = computed(() => {
return props.isOutlined return props.isOutlined
@ -33,91 +64,54 @@ const styleAttrs = computed(() => {
} }
: {}; : {};
}); });
const formattedTime = computed({
get() {
if (!model.value || model.value?.length <= 5) return model.value;
return dateToTime(model.value);
},
set(value) {
if (value == model.value) return;
let time = value;
if (time) {
if (time?.length > 5) time = dateToTime(time);
else {
if (time.length == 1 && parseInt(time) > 2) time = time.padStart(2, '0');
time = time.padEnd(5, '0');
if (!time.includes(':'))
time = time.substring(0, 2) + ':' + time.substring(3, 5);
}
if (!props.timeOnly) {
const [hh, mm] = time.split(':');
const date = new Date(model.value ? model.value : initialDate.value);
date.setHours(hh, mm, 0);
time = date?.toISOString();
}
}
model.value = time;
},
});
function dateToTime(newDate) {
return date.formatDate(new Date(newDate), dateFormat);
}
</script> </script>
<template> <template>
<div @mouseover="hover = true" @mouseleave="hover = false">
<QInput <QInput
ref="vnInputTimeRef"
class="vn-input-time" class="vn-input-time"
mask="##:##" readonly
placeholder="--:--" :model-value="formatTime(value)"
v-model="formattedTime"
v-bind="{ ...$attrs, ...styleAttrs }" v-bind="{ ...$attrs, ...styleAttrs }"
:class="{ required: isRequired }" :class="{ required: $attrs.required }"
style="min-width: 100px" :rules="$attrs.required ? [requiredFieldRule] : null"
:rules="mixinRules" @click="isPopupOpen = true"
@click="isPopupOpen = !isPopupOpen"
@keydown="isPopupOpen = false"
type="time"
hide-bottom-space
> >
<template #append> <template #append>
<QIcon <QIcon name="Schedule" class="cursor-pointer">
name="close" <QPopupProxy
size="xs" v-model="isPopupOpen"
v-if=" cover
($attrs.clearable == undefined || $attrs.clearable) &&
hover &&
model &&
!$attrs.disable
"
@click="
vnInputTimeRef.focus();
model = null;
isPopupOpen = false;
"
/>
</template>
<QMenu
v-if="$q.screen.gt.xs"
transition-show="scale" transition-show="scale"
transition-hide="scale" transition-hide="scale"
v-model="isPopupOpen" :no-parent-event="props.readonly"
anchor="bottom left"
self="top start"
:no-focus="true"
:no-parent-event="true"
> >
<VnTime v-model="formattedTime" /> <QTime
</QMenu> :format24h="false"
<QDialog v-else v-model="isPopupOpen"> :model-value="formatTime(value)"
<VnTime v-model="formattedTime" /> @update:model-value="onDateUpdate"
</QDialog> >
</QInput> <div class="row items-center justify-end q-gutter-sm">
<QBtn
:label="t('Cancel')"
color="primary"
flat
v-close-popup
/>
<QBtn
label="Ok"
color="primary"
flat
@click="save"
v-close-popup
/>
</div> </div>
</QTime>
</QPopupProxy>
</QIcon>
</template> </template>
</QInput>
</template>
<style lang="scss"> <style lang="scss">
.vn-input-time.q-field--standard.q-field--readonly .q-field__control:before { .vn-input-time.q-field--standard.q-field--readonly .q-field__control:before {
border-bottom-style: solid; border-bottom-style: solid;
@ -127,12 +121,8 @@ function dateToTime(newDate) {
border-style: solid; border-style: solid;
} }
</style> </style>
<style lang="scss" scoped>
:deep(input[type='time']::-webkit-calendar-picker-indicator) {
display: none;
}
</style>
<i18n> <i18n>
es: es:
Open time: Abrir tiempo Cancel: Cancelar
</i18n> </i18n>

View File

@ -1,99 +1,122 @@
<script setup> <script setup>
import { ref, toRefs, computed, watch, onMounted } from 'vue';
import CreateNewPostcode from 'src/components/CreateNewPostcodeForm.vue'; import CreateNewPostcode from 'src/components/CreateNewPostcodeForm.vue';
import VnSelectDialog from 'components/common/VnSelectDialog.vue'; import VnSelectDialog from 'components/common/VnSelectDialog.vue';
import FetchData from 'components/FetchData.vue';
const emit = defineEmits(['update:modelValue', 'update:options']);
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { ref, watch } from 'vue';
import { useAttrs } from 'vue';
import { useRequired } from 'src/composables/useRequired';
const { t } = useI18n(); const { t } = useI18n();
const emit = defineEmits(['update:model-value', 'update:options']); const postcodesOptions = ref([]);
const $attrs = useAttrs(); const postcodesRef = ref(null);
const { isRequired, requiredFieldRule } = useRequired($attrs);
const props = defineProps({ const $props = defineProps({
location: { modelValue: {
type: Object, type: [String, Number, Object],
default: null, default: null,
}, },
options: {
type: Array,
default: () => [],
},
optionLabel: {
type: String,
default: '',
},
optionValue: {
type: String,
default: '',
},
filterOptions: {
type: Array,
default: () => [],
},
isClearable: {
type: Boolean,
default: true,
},
defaultFilter: {
type: Boolean,
default: true,
},
}); });
watch( const { options } = toRefs($props);
() => props.location, const myOptions = ref([]);
(newValue) => { const myOptionsOriginal = ref([]);
if (!modelValue.value) return;
modelValue.value = formatLocation(newValue) ?? null; const value = computed({
} get() {
return $props.modelValue;
},
set(value) {
emit(
'update:modelValue',
postcodesOptions.value.find((p) => p.code === value)
); );
},
const mixinRules = [requiredFieldRule];
const locationProperties = [
'postcode',
(obj) =>
obj.city
? `${obj.city}${obj.province?.name ? `(${obj.province.name})` : ''}`
: null,
(obj) => obj.country?.name,
];
const formatLocation = (obj, properties = locationProperties) => {
const parts = properties.map((prop) => {
if (typeof prop === 'string') {
return obj[prop];
} else if (typeof prop === 'function') {
return prop(obj);
}
return null;
}); });
const filteredParts = parts.filter( onMounted(() => {
(part) => part !== null && part !== undefined && part !== '' locationFilter($props.modelValue);
); });
return filteredParts.join(', '); function setOptions(data) {
}; myOptions.value = JSON.parse(JSON.stringify(data));
myOptionsOriginal.value = JSON.parse(JSON.stringify(data));
}
setOptions(options.value);
const modelValue = ref(props.location ? formatLocation(props.location) : null); watch(options, (newValue) => {
setOptions(newValue);
});
function showLabel(data) { function showLabel(data) {
const dataProperties = [ return `${data.code} - ${data.town}(${data.province}), ${data.country}`;
'code',
(obj) => (obj.town ? `${obj.town}(${obj.province})` : null),
'country',
];
return formatLocation(data, dataProperties);
} }
const handleModelValue = (data) => { function locationFilter(search = '') {
emit('update:model-value', data); if (
}; search &&
(search.includes('undefined') || search.startsWith(`${$props.modelValue} - `))
)
return;
let where = { search };
postcodesRef.value.fetch({ filter: { where }, limit: 30 });
}
function handleFetch(data) {
postcodesOptions.value = data;
}
function onDataSaved(newPostcode) {
postcodesOptions.value.push(newPostcode);
value.value = newPostcode.code;
}
</script> </script>
<template> <template>
<VnSelectDialog <FetchData
v-model="modelValue" ref="postcodesRef"
option-filter-value="search"
:option-label="
(opt) => (typeof modelValue === 'string' ? modelValue : showLabel(opt))
"
url="Postcodes/filter" url="Postcodes/filter"
@update:model-value="handleModelValue" @on-fetch="(data) => handleFetch(data)"
:use-like="false" />
<VnSelectDialog
v-if="postcodesRef"
:option-label="(opt) => showLabel(opt) ?? 'code'"
:option-value="(opt) => opt.code"
v-model="value"
:options="postcodesOptions"
:label="t('Location')" :label="t('Location')"
:placeholder="t('search_by_postalcode')" :placeholder="t('search_by_postalcode')"
@input-value="locationFilter"
:default-filter="false"
:input-debounce="300" :input-debounce="300"
:class="{ required: isRequired }" :class="{ required: $attrs.required }"
v-bind="$attrs" v-bind="$attrs"
:emit-value="false" clearable
:tooltip="t('Create new location')"
:rules="mixinRules"
:lazy-rules="true"
> >
<template #form> <template #form>
<CreateNewPostcode <CreateNewPostcode
@on-data-saved=" @on-data-saved="onDataSaved"
(newValue) => {
modelValue = newValue;
emit('update:model-value', newValue);
}
"
/> />
</template> </template>
<template #option="{ itemProps, opt }"> <template #option="{ itemProps, opt }">
@ -117,9 +140,7 @@ const handleModelValue = (data) => {
<i18n> <i18n>
en: en:
search_by_postalcode: Search by postalcode, town, province or country search_by_postalcode: Search by postalcode, town, province or country
Create new location: Create new location
es: es:
Location: Ubicación Location: Ubicación
Create new location: Crear nueva ubicación
search_by_postalcode: Buscar por código postal, ciudad o país search_by_postalcode: Buscar por código postal, ciudad o país
</i18n> </i18n>

View File

@ -1,7 +1,7 @@
<script setup> <script setup>
import { ref, onUnmounted, watch } from 'vue'; import { ref, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { date } from 'quasar'; import { date } from 'quasar';
import { useStateStore } from 'stores/useStateStore'; import { useStateStore } from 'stores/useStateStore';
@ -14,14 +14,11 @@ import VnJsonValue from '../common/VnJsonValue.vue';
import FetchData from '../FetchData.vue'; import FetchData from '../FetchData.vue';
import VnSelect from './VnSelect.vue'; import VnSelect from './VnSelect.vue';
import VnUserLink from '../ui/VnUserLink.vue'; import VnUserLink from '../ui/VnUserLink.vue';
import VnPaginate from '../ui/VnPaginate.vue';
import RightMenu from './RightMenu.vue';
const stateStore = useStateStore(); const stateStore = useStateStore();
const validationsStore = useValidator(); const validationsStore = useValidator();
const { models } = validationsStore; const { models } = validationsStore;
const route = useRoute(); const route = useRoute();
const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps({ const props = defineProps({
model: { model: {
@ -52,7 +49,6 @@ const filter = {
'changedModelId', 'changedModelId',
'changedModelValue', 'changedModelValue',
'description', 'description',
'summaryId',
], ],
include: [ include: [
{ {
@ -68,10 +64,9 @@ const filter = {
}, },
}, },
], ],
where: { and: [{ originFk: route.params.id }] },
}; };
const paginate = ref(); const workers = ref();
const actions = ref(); const actions = ref();
const changeInput = ref(); const changeInput = ref();
const searchInput = ref(); const searchInput = ref();
@ -131,7 +126,7 @@ const actionsIcon = {
}; };
const validDate = new RegExp( const validDate = new RegExp(
/^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])/.source + /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])/.source +
/T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(.[0-9]+)?(Z)?$/.source, /T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(.[0-9]+)?(Z)?$/.source
); );
function castJsonValue(value) { function castJsonValue(value) {
@ -193,7 +188,7 @@ function getLogTree(data) {
user: log.user, user: log.user,
userFk: log.userFk, userFk: log.userFk,
logs: [], logs: [],
}), })
); );
} }
// Model // Model
@ -211,13 +206,13 @@ function getLogTree(data) {
id: log.changedModelId, id: log.changedModelId,
showValue: log.changedModelValue, showValue: log.changedModelValue,
logs: [], logs: [],
}), })
); );
nLogs = 0; nLogs = 0;
} }
nLogs++; nLogs++;
modelLog.logs.push(log); modelLog.logs.push(log);
modelLog.summaryId = modelLog.logs[0].summaryId;
// Changes // Changes
const notDelete = log.action != 'delete'; const notDelete = log.action != 'delete';
const olds = (notDelete ? log.oldInstance : null) || {}; const olds = (notDelete ? log.oldInstance : null) || {};
@ -238,8 +233,9 @@ async function openPointRecord(id, modelLog) {
const locale = validations[modelLog.model]?.locale || {}; const locale = validations[modelLog.model]?.locale || {};
pointRecord.value = parseProps(propNames, locale, data); pointRecord.value = parseProps(propNames, locale, data);
} }
async function setLogTree(data) { async function setLogTree() {
if (!data) return; filter.where = { and: [{ originFk: route.params.id }] };
const { data } = await getLogs(filter);
logTree.value = getLogTree(data); logTree.value = getLogTree(data);
} }
@ -268,7 +264,15 @@ async function applyFilter() {
filter.where.and.push(selectedFilters.value); filter.where.and.push(selectedFilters.value);
} }
paginate.value.fetch({ filter }); const { data } = await getLogs(filter);
logTree.value = getLogTree(data);
}
async function getLogs(filter) {
return axios.get(props.url ?? `${props.model}Logs`, {
params: { filter: JSON.stringify(filter) },
});
} }
function setDate(type) { function setDate(type) {
@ -283,7 +287,7 @@ function setDate(type) {
to = date.adjustDate( to = date.adjustDate(
to, to,
{ hour: 21, minute: 59, second: 59, millisecond: 999 }, { hour: 21, minute: 59, second: 59, millisecond: 999 },
true, true
); );
switch (type) { switch (type) {
@ -366,51 +370,42 @@ async function clearFilter() {
dateTo.value = undefined; dateTo.value = undefined;
userRadio.value = undefined; userRadio.value = undefined;
Object.keys(checkboxOptions.value).forEach( Object.keys(checkboxOptions.value).forEach(
(opt) => (checkboxOptions.value[opt].selected = false), (opt) => (checkboxOptions.value[opt].selected = false)
); );
await applyFilter(); await applyFilter();
} }
setLogTree();
onUnmounted(() => { onUnmounted(() => {
stateStore.rightDrawer = false; stateStore.rightDrawer = false;
}); });
watch(
() => router.currentRoute.value.params.id,
() => {
applyFilter();
},
);
</script> </script>
<template> <template>
<FetchData
:url="`${props.model}Logs/${route.params.id}/editors`"
:filter="{
fields: ['id', 'nickname', 'name', 'image'],
order: 'nickname',
limit: 30,
}"
@on-fetch="(data) => (workers = data)"
auto-load
/>
<FetchData <FetchData
:url="`${props.model}Logs/${route.params.id}/models`" :url="`${props.model}Logs/${route.params.id}/models`"
:filter="{ order: ['changedModel'] }" :filter="{ order: ['changedModel'] }"
@on-fetch=" @on-fetch="
(data) => (data) =>
(actions = data.map((item) => { (actions = data.map((item) => {
const changedModel = item.changedModel;
return { return {
locale: useCapitalize( locale: useCapitalize(validations[item.changedModel].locale.name),
validations[changedModel]?.locale?.name ?? changedModel, value: item.changedModel,
),
value: changedModel,
}; };
})) }))
" "
auto-load auto-load
/> />
<VnPaginate
ref="paginate"
:data-key="`${model}Log`"
:url="`${model}Logs`"
:user-filter="filter"
:skeleton="false"
auto-load
@on-fetch="setLogTree"
search-url="logs"
>
<template #body>
<div <div
class="column items-center logs origin-log q-mt-md" class="column items-center logs origin-log q-mt-md"
v-for="(originLog, originLogIndex) in logTree" v-for="(originLog, originLogIndex) in logTree"
@ -458,29 +453,22 @@ watch(
size="md" size="md"
class="model-name q-mr-xs text-white" class="model-name q-mr-xs text-white"
v-if=" v-if="
!( !(modelLog.changedModel && modelLog.changedModelId) &&
modelLog.changedModel && modelLog.model
modelLog.changedModelId
) && modelLog.model
" "
:style="{ :style="{
backgroundColor: useColor(modelLog.model), backgroundColor: useColor(modelLog.model),
}" }"
:title="`${modelLog.model} #${modelLog.id}`" :title="modelLog.model"
> >
{{ t(modelLog.modelI18n) }} {{ t(modelLog.modelI18n) }}
</QChip> </QChip>
<span class="model-id" v-if="modelLog.id"
<span >#{{ modelLog.id }}</span
class="model-id q-mr-xs" >
v-if="modelLog.summaryId" <span class="model-value" :title="modelLog.showValue">
v-text="`#${modelLog.summaryId}`" {{ modelLog.showValue }}
/> </span>
<span
class="model-value"
:title="modelLog.showValue"
v-text="modelLog.showValue"
/>
<QBtn <QBtn
flat flat
round round
@ -508,7 +496,7 @@ watch(
:title=" :title="
date.formatDate( date.formatDate(
log.creationDate, log.creationDate,
'DD/MM/YYYY hh:mm:ss', 'DD/MM/YYYY hh:mm:ss'
) ?? `date:'dd/MM/yyyy HH:mm:ss'` ) ?? `date:'dd/MM/yyyy HH:mm:ss'`
" "
> >
@ -535,9 +523,7 @@ watch(
> >
{{ modelLog.modelI18n }} {{ modelLog.modelI18n }}
<span v-if="modelLog.id" <span v-if="modelLog.id"
>#{{ >#{{ modelLog.id }}</span
modelLog.id
}}</span
> >
</div> </div>
<QCardSection <QCardSection
@ -552,18 +538,12 @@ watch(
> >
<span <span
class="json-field q-mr-xs text-grey" class="json-field q-mr-xs text-grey"
:title=" :title="value.name"
value.name
"
> >
{{ {{ value.nameI18n }}:
value.nameI18n
}}:
</span> </span>
<VnJsonValue <VnJsonValue
:value=" :value="value.val.val"
value.val.val
"
/> />
</QItem> </QItem>
</QCardSection> </QCardSection>
@ -575,11 +555,7 @@ watch(
:class="actionsClass[log.action]" :class="actionsClass[log.action]"
:name="actionsIcon[log.action]" :name="actionsIcon[log.action]"
:title=" :title="
t( t(`actions.${actionsText[log.action]}`)
`actions.${
actionsText[log.action]
}`,
)
" "
/> />
</div> </div>
@ -599,27 +575,17 @@ watch(
@click="log.expand = !log.expand" @click="log.expand = !log.expand"
/> />
<span v-if="log.props.length" class="attributes"> <span v-if="log.props.length" class="attributes">
<span <span v-if="!log.expand" class="q-pa-none text-grey">
v-if="!log.expand"
class="q-pa-none text-grey"
>
<span <span
v-for="(prop, propIndex) in log.props" v-for="(prop, propIndex) in log.props"
:key="propIndex" :key="propIndex"
class="basic-json" class="basic-json"
> >
<span <span class="json-field" :title="prop.name">
class="json-field"
:title="prop.name"
>
{{ prop.nameI18n }}: {{ prop.nameI18n }}:
</span> </span>
<VnJsonValue :value="prop.val.val" /> <VnJsonValue :value="prop.val.val" />
<span <span v-if="propIndex < log.props.length - 1"
v-if="
propIndex <
log.props.length - 1
"
>,&nbsp; >,&nbsp;
</span> </span>
</span> </span>
@ -629,44 +595,28 @@ watch(
class="expanded-json column q-pa-none" class="expanded-json column q-pa-none"
> >
<div <div
v-for="( v-for="(prop, prop2Index) in log.props"
prop, prop2Index
) in log.props"
:key="prop2Index" :key="prop2Index"
class="q-pa-none text-grey" class="q-pa-none text-grey"
> >
<span <span class="json-field" :title="prop.name">
class="json-field"
:title="prop.name"
>
{{ prop.nameI18n }}: {{ prop.nameI18n }}:
</span> </span>
<VnJsonValue :value="prop.val.val" /> <VnJsonValue :value="prop.val.val" />
<span <span v-if="prop.val.id" class="id-value">
v-if="prop.val.id"
class="id-value"
>
#{{ prop.val.id }} #{{ prop.val.id }}
</span> </span>
<span v-if="log.action == 'update'"> <span v-if="log.action == 'update'">
<VnJsonValue <VnJsonValue :value="prop.old.val" />
:value="prop.old.val" <span v-if="prop.old.id" class="id-value">
/>
<span
v-if="prop.old.id"
class="id-value"
>
#{{ prop.old.id }} #{{ prop.old.id }}
</span> </span>
</span> </span>
</div> </div>
</span> </span>
</span> </span>
<span <span v-if="!log.props.length" class="description">
v-if="!log.props.length"
class="description"
>
{{ log.description }} {{ log.description }}
</span> </span>
</QCardSection> </QCardSection>
@ -676,10 +626,7 @@ watch(
</QList> </QList>
</div> </div>
</div> </div>
</template> <Teleport to="#right-panel" v-if="stateStore.isHeaderMounted()">
</VnPaginate>
<RightMenu>
<template #right-panel>
<QList dense> <QList dense>
<QSeparator /> <QSeparator />
<QItem class="q-mt-sm"> <QItem class="q-mt-sm">
@ -727,24 +674,22 @@ watch(
</QOptionGroup> </QOptionGroup>
</QItem> </QItem>
<QItem class="q-mt-sm"> <QItem class="q-mt-sm">
<QItemSection v-if="userRadio !== null"> <QItemSection v-if="!workers">
<QSkeleton type="QInput" class="full-width" />
</QItemSection>
<QItemSection v-if="workers && userRadio !== null">
<VnSelect <VnSelect
class="full-width" class="full-width"
:label="t('globals.user')" :label="t('globals.user')"
v-model="userSelect" v-model="userSelect"
option-label="name" option-label="name"
option-value="id" option-value="id"
:url="`${model}Logs/${route.params.id}/editors`" :options="workers"
:fields="['id', 'nickname', 'name', 'image']"
sort-by="nickname"
@update:model-value="selectFilter('userSelect')" @update:model-value="selectFilter('userSelect')"
hide-selected hide-selected
> >
<template #option="{ opt, itemProps }"> <template #option="{ opt, itemProps }">
<QItem <QItem v-bind="itemProps" class="q-pa-xs row items-center">
v-bind="itemProps"
class="q-pa-xs row items-center"
>
<QItemSection class="col-3 items-center"> <QItemSection class="col-3 items-center">
<VnAvatar :worker-id="opt.id" /> <VnAvatar :worker-id="opt.id" />
</QItemSection> </QItemSection>
@ -804,7 +749,7 @@ watch(
<QItem class="q-mt-sm"> <QItem class="q-mt-sm">
<QInput <QInput
class="full-width" class="full-width"
:label="t('globals.to')" :label="t('to')"
@click="dateToDialog = true" @click="dateToDialog = true"
@focus="(evt) => evt.target.blur()" @focus="(evt) => evt.target.blur()"
@clear="selectFilter('date', 'from')" @clear="selectFilter('date', 'from')"
@ -814,8 +759,7 @@ watch(
/> />
</QItem> </QItem>
</QList> </QList>
</template> </Teleport>
</RightMenu>
<QDialog v-model="dateFromDialog"> <QDialog v-model="dateFromDialog">
<QDate <QDate
:years-in-month-view="false" :years-in-month-view="false"
@ -1059,9 +1003,9 @@ en:
Deletes: Deletes Deletes: Deletes
Accesses: Accesses Accesses: Accesses
Users: Users:
User: User User: Usuario
All: All All: Todo
System: System System: Sistema
properties: properties:
id: ID id: ID
claimFk: Claim ID claimFk: Claim ID

View File

@ -1,47 +0,0 @@
<script setup>
import { useStateStore } from 'stores/useStateStore';
import { onMounted, ref } from 'vue';
import { useQuasar } from 'quasar';
import LeftMenu from '../LeftMenu.vue';
const stateStore = useStateStore();
const $props = defineProps({
leftDrawer: {
type: Boolean,
default: true,
},
});
onMounted(
() => (stateStore.leftDrawer = useQuasar().screen.gt.xs ? $props.leftDrawer : false)
);
const teleportRef = ref({});
const hasContent = ref();
let observer;
onMounted(() => {
if (!teleportRef.value) return;
const checkContent = () => {
hasContent.value = teleportRef.value?.innerHTML?.trim() !== '';
};
observer = new MutationObserver(checkContent);
observer.observe(teleportRef.value, { childList: true, subtree: true });
checkContent();
});
</script>
<template>
<QDrawer v-model="stateStore.leftDrawer" show-if-above :width="256">
<QScrollArea class="fit text-grey-8">
<div id="left-panel" ref="teleportRef"></div>
<LeftMenu v-if="!hasContent" />
</QScrollArea>
</QDrawer>
<QPageContainer>
<QPage>
<RouterView />
</QPage>
</QPageContainer>
</template>

View File

@ -9,6 +9,10 @@ const $props = defineProps({
type: Number, //Progress value (1.0 > x > 0.0) type: Number, //Progress value (1.0 > x > 0.0)
required: true, required: true,
}, },
showDialog: {
type: Boolean,
required: true,
},
cancelled: { cancelled: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -20,22 +24,30 @@ const emit = defineEmits(['cancel', 'close']);
const dialogRef = ref(null); const dialogRef = ref(null);
const showDialog = defineModel('showDialog', { const _showDialog = computed({
type: Boolean, get: () => $props.showDialog,
default: false, set: (value) => {
if (value) dialogRef.value.show();
},
}); });
const _progress = computed(() => $props.progress); const _progress = computed(() => $props.progress);
const progressLabel = computed(() => `${Math.round($props.progress * 100)}%`); const progressLabel = computed(() => `${Math.round($props.progress * 100)}%`);
const cancel = () => {
dialogRef.value.hide();
emit('cancel');
};
</script> </script>
<template> <template>
<QDialog ref="dialogRef" v-model="showDialog" @hide="emit('close')"> <QDialog ref="dialogRef" v-model="_showDialog" @hide="onDialogHide">
<QCard class="full-width dialog"> <QCard class="full-width dialog">
<QCardSection class="row"> <QCardSection class="row">
<span class="text-h6">{{ t('Progress') }}</span> <span class="text-h6">{{ t('Progress') }}</span>
<QSpace /> <QSpace />
<QBtn icon="close" flat round dense v-close-popup /> <QBtn icon="close" flat round dense @click="emit('close')" />
</QCardSection> </QCardSection>
<QCardSection> <QCardSection>
<div class="column"> <div class="column">
@ -68,7 +80,7 @@ const progressLabel = computed(() => `${Math.round($props.progress * 100)}%`);
type="button" type="button"
flat flat
class="text-primary" class="text-primary"
v-close-popup @click="cancel()"
> >
{{ t('globals.cancel') }} {{ t('globals.cancel') }}
</QBtn> </QBtn>

View File

@ -2,12 +2,5 @@
const model = defineModel({ type: Boolean, required: true }); const model = defineModel({ type: Boolean, required: true });
</script> </script>
<template> <template>
<QRadio <QRadio v-model="model" v-bind="$attrs" dense :dark="true" class="q-mr-sm" />
v-model="model"
v-bind="$attrs"
dense
:dark="true"
class="q-mr-sm"
size="xs"
/>
</template> </template>

View File

@ -1,115 +0,0 @@
<script setup>
import RightAdvancedMenu from './RightAdvancedMenu.vue';
import VnSearchbar from 'components/ui/VnSearchbar.vue';
import VnTableFilter from '../VnTable/VnTableFilter.vue';
import { onBeforeMount, onMounted, onUnmounted, computed, ref } from 'vue';
import { useArrayData } from 'src/composables/useArrayData';
import { useRoute, useRouter } from 'vue-router';
import { useHasContent } from 'src/composables/useHasContent';
const $props = defineProps({
section: {
type: String,
default: null,
},
dataKey: {
type: String,
default: null,
},
searchBar: {
type: Boolean,
default: true,
},
prefix: {
type: String,
default: null,
},
rightFilter: {
type: Boolean,
default: true,
},
columns: {
type: Array,
default: null,
},
arrayDataProps: {
type: Object,
default: null,
},
redirect: {
type: Boolean,
default: true,
},
keepData: {
type: Boolean,
default: true,
},
});
const route = useRoute();
const router = useRouter();
let arrayData;
const sectionValue = computed(() => $props.section ?? $props.dataKey);
const isMainSection = ref(false);
const searchbarId = 'section-searchbar';
const advancedMenuSlot = 'advanced-menu';
const hasContent = useHasContent(`#${searchbarId}`);
onBeforeMount(() => {
if ($props.dataKey)
arrayData = useArrayData($props.dataKey, {
searchUrl: 'table',
keepData: $props.keepData,
...$props.arrayDataProps,
navigate: $props.redirect,
});
checkIsMain();
});
onMounted(() => {
const unsubscribe = router.afterEach(() => {
checkIsMain();
});
onUnmounted(unsubscribe);
});
onUnmounted(() => {
if (arrayData) arrayData.destroy();
});
function checkIsMain() {
isMainSection.value = sectionValue.value == route.name;
if (!isMainSection.value && arrayData) {
arrayData.reset(['userParams', 'filter']);
arrayData.setCurrentFilter();
}
}
</script>
<template>
<slot name="searchbar">
<VnSearchbar
v-if="searchBar && !hasContent"
v-bind="arrayDataProps"
:data-key="dataKey"
:label="$t(`${prefix}.search`)"
:info="$t(`${prefix}.searchInfo`)"
/>
<div :id="searchbarId"></div>
</slot>
<RightAdvancedMenu :is-main-section="isMainSection">
<template #advanced-menu v-if="$slots[advancedMenuSlot] || rightFilter">
<slot :name="advancedMenuSlot">
<VnTableFilter
v-if="rightFilter && columns"
:data-key="dataKey"
:array-data="arrayData"
:columns="columns"
/>
</slot>
</template>
</RightAdvancedMenu>
<slot name="body" v-if="isMainSection" />
<RouterView v-else />
</template>

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