0
0
Fork 0

Merge branch 'dev' of https://gitea.verdnatura.es/verdnatura/salix-front into 4560-gastos-reparto

This commit is contained in:
Vicent Llopis 2023-05-24 08:11:01 +02:00
commit 3bd8e3c454
203 changed files with 17557 additions and 31479 deletions

View File

@ -1,19 +0,0 @@
{
"plugins": ["@babel/plugin-syntax-dynamic-import"],
"env": {
"test": {
"plugins": ["dynamic-import-node"],
"presets": [
[
"@babel/preset-env",
{
"modules": "commonjs",
"targets": {
"node": "current"
}
}
]
]
}
}
}

View File

@ -1,8 +1,6 @@
/dist
/src-bex/www
/src-capacitor
/src-cordova
/.quasar
/node_modules
.eslintrc.js
babel.config.js

View File

@ -5,13 +5,13 @@ module.exports = {
root: true,
parserOptions: {
parser: '@babel/eslint-parser',
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
sourceType: 'module', // Allows for the use of imports
ecmaVersion: '2021', // Allows for the parsing of modern ECMAScript features
},
env: {
node: true,
browser: true,
'vue/setup-compiler-macros': true,
},
// Rules order is important, please avoid shuffling them
@ -22,7 +22,7 @@ module.exports = {
// Uncomment any of the lines below to choose desired strictness,
// but leave only one uncommented!
// See https://eslint.vuejs.org/rules/#available-rules
//'plugin:vue/vue3-essential', // Priority A: Essential (Error Prevention)
// 'plugin:vue/vue3-essential', // Priority A: Essential (Error Prevention)
'plugin:vue/vue3-strongly-recommended', // Priority B: Strongly Recommended (Improving Readability)
// 'plugin:vue/vue3-recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
@ -52,22 +52,19 @@ module.exports = {
process: 'readonly',
Capacitor: 'readonly',
chrome: 'readonly',
defineProps: 'readonly', // Vue SFC setup compiler macro
defineEmits: 'readonly', // Vue SFC setup compiler macro
defineExpose: 'readonly', // Vue SFC setup compiler macro
},
// add your custom rules here
rules: {
'prefer-promise-reject-errors': 'off',
'no-unused-vars': 'warn',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
},
overrides: [
{
files: ['**/*.spec.{js,ts}'],
files: ['test/cypress/**/*.spec.{js,ts}'],
extends: [
// Add Cypress-specific lint rules, globals and Cypress plugin
// See https://github.com/cypress-io/eslint-plugin-cypress#rules

8
.gitignore vendored
View File

@ -16,10 +16,6 @@ node_modules
/src-capacitor/www
/src-capacitor/node_modules
# BEX related directories and files
/src-bex/www
/src-bex/js/core
# Log files
npm-debug.log*
yarn-debug.log*
@ -31,3 +27,7 @@ yarn-error.log*
*.ntvs*
*.njsproj
*.sln
# Cypress directories and files
/tests/cypress/videos
/tests/cypress/screenshots

3
.npmrc Normal file
View File

@ -0,0 +1,3 @@
# pnpm-related options
shamefully-hoist=true
strict-peer-dependencies=false

View File

@ -1,9 +0,0 @@
/* eslint-disable */
// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = {
plugins: [
// to edit target browsers: use "browserslist" field in package.json
require('autoprefixer'),
],
};

View File

@ -1,6 +1,6 @@
module.exports = {
singleQuote: true,
printWidth: 120,
printWidth: 90,
tabWidth: 4,
semi: true,
endOfLine: 'auto',

View File

@ -3,7 +3,7 @@
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"editorconfig.editorconfig",
"johnsoncodehk.volar",
"vue.volar",
"wayou.vscode-todo-highlight"
],
"unwantedRecommendations": [

View File

@ -2,7 +2,7 @@
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "johnsoncodehk.volar",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": ["source.fixAll.eslint"],
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
"json.schemas": [
@ -11,9 +11,6 @@
"url": "https://on.cypress.io/cypress.schema.json"
}
],
"[javascript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}

21
CHANGELOG.md Normal file
View File

@ -0,0 +1,21 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2253.01] - 2023-01-05
### Added
- (Clientes) => Añadida nueva sección "Pagos Web" para gestionar los pagos de todos los clientes
- (Tickets) => Añadida opción en el menú desplegable del ticket para enviar SMS al cliente
- (Reclamaciones) => Añadida nueva sección "Registros de auditoría"
- (Trabajadores) => Añadido módulo de trabajadores
- (General) => Añadida barra de búsqueda general en los listados principales
- (Vagones) => Añadido módulo de vagones
### Changed
- Changed...

7
Jenkinsfile vendored
View File

@ -31,8 +31,7 @@ pipeline {
NODE_ENV = ""
}
steps {
nodejs('node-v14') {
sh 'npm install -g @quasar/cli'
nodejs('node-v18') {
sh 'npm install --no-audit --prefer-offline'
}
}
@ -48,7 +47,7 @@ pipeline {
parallel {
stage('Frontend') {
steps {
nodejs('node-v14') {
nodejs('node-v18') {
sh 'npm run test:unit:ci'
}
}
@ -64,7 +63,7 @@ pipeline {
CREDENTIALS = credentials('docker-registry')
}
steps {
nodejs('node-v14') {
nodejs('node-v18') {
sh 'quasar build'
}
dockerBuild()

View File

@ -1,12 +1,10 @@
# Salix (salix-front)
# Lilium (lilium-front)
Salix front-end
Lilium frontend
## Install the dependencies
```bash
yarn
# or
npm install
```
@ -22,20 +20,16 @@ sudo npm install -g @quasar/cli
quasar dev
```
### Lint the files
### Run unit tests
```bash
yarn lint
# or
npm run lint
npm run test:unit
```
### Format the files
### Run e2e tests
```bash
yarn format
# or
npm run format
npm run test:e2e
```
### Build the app for production
@ -43,7 +37,3 @@ npm run format
```bash
quasar build
```
### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js).

View File

@ -1,18 +0,0 @@
/* eslint-env node */
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs-extra');
let extend = undefined;
/**
* The .babelrc file has been created to assist Jest for transpiling.
* You should keep your application's babel rules in this file.
*/
if (fs.existsSync('./.babelrc')) {
extend = './.babelrc';
}
module.exports = {
presets: ['@quasar/babel-preset-app'],
extends: extend,
};

22
cypress.config.js Normal file
View File

@ -0,0 +1,22 @@
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:9000/',
fixturesFolder: 'test/cypress/fixtures',
screenshotsFolder: 'test/cypress/screenshots',
supportFile: 'test/cypress/support/index.js',
videosFolder: 'test/cypress/videos',
video: true,
specPattern: 'test/cypress/integration/*.spec.js',
experimentalRunAllSpecs: true,
component: {
componentFolder: 'src',
testFiles: '**/*.spec.js',
supportFile: 'test/cypress/support/unit.js',
},
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
});

View File

@ -1,15 +0,0 @@
{
"baseUrl": "http://localhost:8080/",
"fixturesFolder": "tests/cypress/fixtures",
"integrationFolder": "tests/cypress/integration",
"pluginsFile": "tests/cypress/plugins/index.js",
"screenshotsFolder": "tests/cypress/screenshots",
"supportFile": "tests/cypress/support/index.js",
"videosFolder": "tests/cypress/videos",
"video": true,
"component": {
"componentFolder": "src",
"testFiles": "**/*.spec.js",
"supportFile": "tests/cypress/support/unit.js"
}
}

View File

@ -12,3 +12,6 @@ services:
placement:
constraints:
- node.role == worker
resources:
limits:
memory: 1G

View File

@ -19,7 +19,6 @@
<link rel="icon" type="image/ico" href="favicon.ico" />
</head>
<body>
<!-- DO NOT touch the following DIV -->
<div id="q-app"></div>
<!-- quasar:entry-point -->
</body>
</html>

View File

@ -1,59 +0,0 @@
const esModules = ['quasar', 'quasar/lang', 'lodash-es'].join('|');
module.exports = {
globals: {
__DEV__: true,
// TODO: Remove if resolved natively
// See https://github.com/vuejs/vue-jest/issues/175
'vue-jest': {
pug: { doctype: 'html' },
},
},
// Jest assumes we are testing in node environment, specify jsdom environment instead
testEnvironment: 'jsdom',
// noStackTrace: true,
// bail: true,
// cache: false,
// verbose: true,
// watch: true,
reporters: ['default', 'jest-junit'],
collectCoverage: false,
coverageDirectory: '<rootDir>/tests/jest/coverage',
collectCoverageFrom: ['<rootDir>/src/**/*.vue', '<rootDir>/src/**/*.js', '<rootDir>/src/**/*.jsx'],
// Needed in JS codebases too because of feature flags
coveragePathIgnorePatterns: ['/node_modules/', '.d.ts$'],
coverageThreshold: {
global: {
// branches: 50,
// functions: 50,
// lines: 50,
// statements: 50
},
},
testMatch: ['<rootDir>/src/**/__tests__/*.(spec|test).+(ts|js)?(x)'],
moduleFileExtensions: ['vue', 'js', 'jsx', 'json'],
moduleNameMapper: {
'^quasar$': 'quasar/dist/quasar.esm.prod.js',
'^~/(.*)$': '<rootDir>/$1',
'^src/(.*)$': '<rootDir>/src/$1',
'^app/(.*)$': '<rootDir>/$1',
'^components/(.*)$': '<rootDir>/src/components/$1',
'^composables/(.*)$': '<rootDir>/src/composables/$1',
'^filters/(.*)$': '<rootDir>/src/filters/$1',
'^layouts/(.*)$': '<rootDir>/src/layouts/$1',
'^pages/(.*)$': '<rootDir>/src/pages/$1',
'^assets/(.*)$': '<rootDir>/src/assets/$1',
'^boot/(.*)$': '<rootDir>/src/boot/$1',
'.*css$': '@quasar/quasar-app-extension-testing-unit-jest/stub.css',
},
transform: {
'.*\\.vue$': 'vue-jest',
'.*\\.js$': 'babel-jest',
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
// use these if NPM is being flaky, care as hosting could interfere with these
// '.*\\.vue$': '@quasar/quasar-app-extension-testing-unit-jest/node_modules/vue-jest',
// '.*\\.js$': '@quasar/quasar-app-extension-testing-unit-jest/node_modules/babel-jest'
},
transformIgnorePatterns: [`node_modules/(?!(${esModules}))`],
snapshotSerializers: ['jest-serializer-vue'],
};

View File

@ -11,11 +11,9 @@
"assets/*": ["src/assets/*"],
"boot/*": ["src/boot/*"],
"stores/*": ["src/stores/*"],
"filters/*": ["src/filters/*"],
"vue$": ["node_modules/vue/dist/vue.runtime.esm-bundler.js"]
}
},
"exclude": ["dist", ".quasar", "node_modules"],
"vueCompilerOptions": {
"experimentalDisableTemplateSupport": true
}
"exclude": ["dist", ".quasar", "node_modules"]
}

30865
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +1,54 @@
{
"name": "salix-front",
"version": "0.0.1",
"description": "Salix front-end",
"description": "Salix frontend",
"productName": "Salix",
"author": "Verdnatura",
"private": true,
"scripts": {
"lint": "eslint --ext .js,.vue ./",
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test:e2e": "cypress open",
"test:e2e:ci": "cypress run --browser chromium",
"test": "echo \"See package.json => scripts for available tests.\" && exit 0",
"test:unit": "jest --reporters=default --watchAll",
"test:unit:ci": "jest --ci --reporters=default --reporters=jest-junit --maxWorkers=2",
"test:unit:coverage": "jest --coverage",
"serve:test:coverage": "quasar serve test/jest/coverage/lcov-report/ --port 8788",
"concurrently:dev:jest": "concurrently \"quasar dev\" \"jest --watch\"",
"test:e2e": "cross-env E2E_TEST=true start-test \"quasar dev\" http-get://localhost:8080 \"cypress open\"",
"test:e2e:ci": "cross-env E2E_TEST=true start-test \"quasar dev\" http-get://localhost:8080 \"cypress run\""
"test:unit": "vitest",
"test:unit:ci": "vitest run"
},
"dependencies": {
"@quasar/extras": "^1.15.5",
"axios": "^0.21.1",
"core-js": "^3.6.5",
"quasar": "^2.10.0",
"@quasar/extras": "^1.15.11",
"axios": "^1.2.1",
"pinia": "^2.0.28",
"quasar": "^2.11.7",
"validator": "^13.7.0",
"vue": "^3.0.0",
"vue-i18n": "^9.0.0",
"vue-router": "^4.0.0"
"vue": "^3.2.45",
"vue-i18n": "^9.2.2",
"vue-router": "^4.1.6",
"vue-router-mock": "^0.1.9"
},
"devDependencies": {
"@babel/eslint-parser": "^7.13.14",
"@intlify/vue-i18n-loader": "^4.1.0",
"@quasar/app-webpack": "^3.6.2",
"@quasar/quasar-app-extension-testing-e2e-cypress": "^4.2.2",
"@quasar/quasar-app-extension-testing-unit-jest": "^3.0.0-alpha.10",
"eslint": "^8.10.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-cypress": "^2.11.3",
"eslint-plugin-jest": "^25.2.2",
"eslint-plugin-vue": "^8.5.0",
"eslint-webpack-plugin": "^3.1.1",
"jest-junit": "^13.0.0",
"prettier": "^2.5.1"
"@intlify/unplugin-vue-i18n": "^0.8.1",
"@pinia/testing": "^0.0.14",
"@quasar/app-vite": "^1.2.1",
"@quasar/quasar-app-extension-testing-unit-vitest": "^0.2.1",
"@vue/test-utils": "^2.0.0",
"autoprefixer": "^10.4.13",
"cypress": "^12.2.0",
"eslint": "^8.30.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-vue": "^9.8.0",
"postcss": "^8.4.20",
"prettier": "^2.8.1",
"vitest": "^0.26.3"
},
"browserslist": [
"last 10 Chrome versions",
"last 10 Firefox versions",
"last 4 Edge versions",
"last 7 Safari versions",
"last 8 Android versions",
"last 8 ChromeAndroid versions",
"last 8 FirefoxAndroid versions",
"last 10 iOS versions",
"last 5 Opera versions"
],
"engines": {
"node": ">= 12.22.1",
"node": "^18 || ^16 || ^14.19",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1"
},
"overrides": {
"@vitejs/plugin-vue": "^4.0.0",
"vite": "^4.0.3",
"vitest": "^0.26.3"
}
}

27
postcss.config.js Normal file
View File

@ -0,0 +1,27 @@
/* eslint-disable */
// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = {
plugins: [
// https://github.com/postcss/autoprefixer
require('autoprefixer')({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions',
],
}),
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 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

@ -6,26 +6,32 @@
*/
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
const ESLintPlugin = require('eslint-webpack-plugin');
const { configure } = require('quasar/wrappers');
const VueI18nPlugin = require('@intlify/unplugin-vue-i18n/vite');
const path = require('path');
module.exports = configure(function (ctx) {
module.exports = configure(function (/* ctx */) {
return {
// https://v2.quasar.dev/quasar-cli-webpack/supporting-ts
supportTS: false,
eslint: {
// fix: true,
// include = [],
// exclude = [],
// rawOptions = {},
warnings: true,
errors: true,
},
// https://v2.quasar.dev/quasar-cli-webpack/prefetch-feature
// https://v2.quasar.dev/quasar-cli/prefetch-feature
// preFetch: true,
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-webpack/boot-files
boot: ['i18n', 'axios'],
// https://v2.quasar.dev/quasar-cli/boot-files
boot: ['i18n', 'axios', 'vnDate'],
// https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-css
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
css: ['app.scss'],
// https://github.com/quasarframework/quasar/tree/dev/extras
@ -38,62 +44,68 @@ module.exports = configure(function (ctx) {
// 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it
'roboto-font',
'material-icons-outlined',
'material-symbols-outlined',
],
// Full list of options: https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-build
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
build: {
vueRouterMode: 'hash', // available values: 'hash', 'history'
// transpile: false,
// publicPath: '/',
// Add dependencies for transpiling with Babel (Array of string/regex)
// (from node_modules, which are by default not transpiled).
// Applies only if "transpile" is set to true.
// transpileDependencies: [],
// rtl: true, // https://quasar.dev/options/rtl-support
// preloadChunks: true,
// showProgress: false,
// gzip: true,
// analyze: true,
// Options below are automatically set depending on the env, set them if you want to override
// extractCSS: false,
// https://v2.quasar.dev/quasar-cli-webpack/handling-webpack
// "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain
chainWebpack(chain) {
chain.module
.rule('i18n')
.resourceQuery(/blockType=i18n/)
.type('javascript/auto')
.use('i18n')
.loader('@intlify/vue-i18n-loader')
.end();
chain.plugin('eslint-webpack-plugin').use(ESLintPlugin, [{ extensions: ['js', 'vue'] }]);
target: {
browser: ['es2022', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node18',
},
extendWebpack(cfg) {
cfg.resolve.alias = {
...cfg.resolve.alias, // This adds the existing alias
// Add your own alias like this
composables: path.resolve(__dirname, './src/composables'),
filters: path.resolve(__dirname, './src/filters'),
vueRouterMode: 'hash', // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// vueOptionsAPI: false,
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
// publicPath: '/',
// analyze: true,
// env: {},
// rawDefine: {}
// ignorePublicFolder: true,
// minify: false,
// polyfillModulePreload: true,
// distDir
extendViteConf(viteConf) {
// FIXME: Delete deprecated property polyfillModulePreload
// that is set by Quasar by default
delete viteConf.build.polyfillModulePreload;
viteConf.build.modulePreload = {
polyfill: false,
};
},
// viteVuePluginOptions: {},
alias: {
composables: path.join(__dirname, './src/composables'),
filters: path.join(__dirname, './src/filters'),
},
vitePlugins: [
[
VueI18nPlugin,
{
// if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false`
// compositionOnly: false,
// you need to set i18n resource including paths !
include: path.resolve(__dirname, './src/i18n/**'),
},
],
],
},
// Full list of options: https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-devServer
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
devServer: {
server: {
type: 'http',
},
port: 8080,
proxy: {
'/api': {
target: 'http://0.0.0.0:3000',
@ -104,15 +116,17 @@ module.exports = configure(function (ctx) {
},
},
// https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-framework
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
framework: {
config: {
brand: {
primary: 'orange',
config: {
brand: {
primary: 'orange',
},
dark: 'auto',
},
dark: 'auto',
},
lang: 'es',
lang: 'en-GB',
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack
@ -126,14 +140,34 @@ module.exports = configure(function (ctx) {
// Quasar plugins
plugins: ['Notify', 'Dialog'],
all: 'auto',
autoImportComponentCase: 'pascal',
},
// animations: 'all', // --- includes all animations
// https://quasar.dev/options/animations
// https://v2.quasar.dev/options/animations
animations: [],
// https://v2.quasar.dev/quasar-cli-webpack/developing-ssr/configuring-ssr
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#property-sourcefiles
// sourceFiles: {
// rootComponent: 'src/App.vue',
// router: 'src/router/index',
// store: 'src/store/index',
// registerServiceWorker: 'src-pwa/register-service-worker',
// serviceWorker: 'src-pwa/custom-service-worker',
// pwaManifestFile: 'src-pwa/manifest.json',
// electronMain: 'src-electron/electron-main',
// electronPreload: 'src-electron/electron-preload'
// },
// https://v2.quasar.dev/quasar-cli/developing-ssr/configuring-ssr
ssr: {
// ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
// will mess up SSR
// extendSSRWebserverConf (esbuildConf) {},
// extendPackageJson (json) {},
pwa: false,
// manualStoreHydration: true,
@ -142,81 +176,42 @@ module.exports = configure(function (ctx) {
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
maxAge: 1000 * 60 * 60 * 24 * 30,
// Tell browser when a file from the server should expire from cache (in ms)
chainWebpackWebserver(chain) {
chain.plugin('eslint-webpack-plugin').use(ESLintPlugin, [{ extensions: ['js'] }]);
},
middlewares: [
ctx.prod ? 'compression' : '',
'render', // keep this as last one
],
},
// https://v2.quasar.dev/quasar-cli-webpack/developing-pwa/configuring-pwa
// https://v2.quasar.dev/quasar-cli/developing-pwa/configuring-pwa
pwa: {
workboxPluginMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
workboxOptions: {}, // only for GenerateSW
// for the custom service worker ONLY (/src-pwa/custom-service-worker.[js|ts])
// if using workbox in InjectManifest mode
chainWebpackCustomSW(chain) {
chain.plugin('eslint-webpack-plugin').use(ESLintPlugin, [{ extensions: ['js'] }]);
},
manifest: {
name: `Salix`,
short_name: `Salix`,
description: `Salix front-end`,
display: 'standalone',
orientation: 'portrait',
background_color: '#ffffff',
theme_color: '#027be3',
icons: [
{
src: 'icons/icon-128x128.png',
sizes: '128x128',
type: 'image/png',
},
{
src: 'icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'icons/icon-256x256.png',
sizes: '256x256',
type: 'image/png',
},
{
src: 'icons/icon-384x384.png',
sizes: '384x384',
type: 'image/png',
},
{
src: 'icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
},
workboxMode: 'generateSW', // or 'injectManifest'
injectPwaMetaTags: true,
swFilename: 'sw.js',
manifestFilename: 'manifest.json',
useCredentialsForManifestTag: false,
// useFilenameHashes: true,
// extendGenerateSWOptions (cfg) {}
// extendInjectManifestOptions (cfg) {},
// extendManifestJson (json) {}
// extendPWACustomSWConf (esbuildConf) {}
},
// Full list of options: https://v2.quasar.dev/quasar-cli-webpack/developing-cordova-apps/configuring-cordova
// Full list of options: https://v2.quasar.dev/quasar-cli/developing-cordova-apps/configuring-cordova
cordova: {
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
},
// Full list of options: https://v2.quasar.dev/quasar-cli-webpack/developing-capacitor-apps/configuring-capacitor
// Full list of options: https://v2.quasar.dev/quasar-cli/developing-capacitor-apps/configuring-capacitor
capacitor: {
hideSplashscreen: true,
},
// Full list of options: https://v2.quasar.dev/quasar-cli-webpack/developing-electron-apps/configuring-electron
// Full list of options: https://v2.quasar.dev/quasar-cli/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (esbuildConf)
// extendElectronPreloadConf (esbuildConf)
inspectPort: 5858,
bundler: 'packager', // 'packager' or 'builder'
packager: {
@ -233,18 +228,16 @@ module.exports = configure(function (ctx) {
builder: {
// https://www.electron.build/configuration/configuration
appId: 'salix-front',
appId: 'salix-frontend',
},
},
// "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: {
contentScripts: ['my-content-script'],
chainWebpackMain(chain) {
chain.plugin('eslint-webpack-plugin').use(ESLintPlugin, [{ extensions: ['js'] }]);
},
chainWebpackPreload(chain) {
chain.plugin('eslint-webpack-plugin').use(ESLintPlugin, [{ extensions: ['js'] }]);
},
// extendBexScriptsConf (esbuildConf) {}
// extendBexManifestJson (json) {}
},
};
});

View File

@ -1,9 +1,7 @@
{
"@quasar/testing-unit-jest": {
"babel": "babelrc",
"options": ["scripts"]
},
"@quasar/testing-e2e-cypress": {
"options": ["scripts"]
}
"@quasar/testing-unit-vitest": {
"options": [
"scripts"
]
}
}

View File

@ -1,11 +1,5 @@
{
"unit-jest": {
"runnerCommand": "jest --ci"
},
"e2e-cypress": {
"runnerCommand": "cross-env E2E_TEST=true start-test \"quasar dev\" http-get://localhost:8080 \"cypress run\""
},
"unit-cypress": {
"runnerCommand": "cypress run-ct"
}
"unit-vitest": {
"runnerCommand": "vitest run"
}
}

View File

@ -1,86 +1,42 @@
<script setup>
import axios from 'axios';
import { onMounted } from 'vue';
import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useSession } from 'src/composables/useSession';
const quasar = useQuasar();
const router = useRouter();
const session = useSession();
const { t } = useI18n();
const { isLoggedIn } = session;
const { availableLocales, locale, fallbackLocale } = useI18n();
onMounted(() => {
let userLang = window.navigator.language;
if (userLang.includes('-')) {
userLang = userLang.split('-')[0];
}
if (availableLocales.includes(userLang)) {
locale.value = userLang;
} else {
locale.value = fallbackLocale;
}
});
quasar.iconMapFn = (iconName) => {
if (iconName.startsWith('vn:')) {
const name = iconName.substring(3);
return {
cls: `icon-${name}`,
cls: `icon-${name} notranslate`,
};
}
return {
cls: 'material-symbols-outlined notranslate',
content: iconName,
};
};
function responseError(error) {
let message = error.message;
let logOut = false;
switch (error.response?.status) {
case 401:
message = 'login.loginError';
if (isLoggedIn()) {
message = 'errors.statusUnauthorized';
logOut = true;
}
break;
case 403:
message = 'errors.statusUnauthorized';
break;
case 500:
message = 'errors.statusInternalServerError';
break;
case 502:
message = 'errors.statusBadGateway';
break;
case 504:
message = 'errors.statusGatewayTimeout';
break;
}
let translatedMessage = t(message);
if (!translatedMessage) translatedMessage = message;
quasar.notify({
message: translatedMessage,
type: 'negative',
});
if (logOut) {
session.destroy();
router.push({ path: '/login' });
}
return Promise.reject(error);
}
axios.interceptors.response.use((response) => {
const { method } = response.config;
const isSaveRequest = method === 'patch';
if (isSaveRequest) {
quasar.notify({
message: t('globals.dataSaved'),
type: 'positive',
icon: 'check',
});
}
return response;
}, responseError);
</script>
<template>
<router-view />
<RouterView />
</template>
<style lang="scss">

View File

@ -1,85 +0,0 @@
import { jest, describe, expect, it, beforeAll } from '@jest/globals';
import { createWrapper } from 'app/tests/jest/jestHelpers';
import App from '../App.vue';
import { useSession } from 'src/composables/useSession';
const mockPush = jest.fn();
const mockLoggedIn = jest.fn();
const mockDestroy = jest.fn();
const session = useSession();
jest.mock('vue-router', () => ({
useRouter: () => ({
push: mockPush,
currentRoute: { value: 'myCurrentRoute' }
}),
}));
jest.mock('src/composables/useSession', () => ({
useSession: () => ({
isLoggedIn: mockLoggedIn,
destroy: mockDestroy
}),
}));
jest.mock('vue-i18n', () => ({
createI18n: () => { },
useI18n: () => ({
t: () => { }
}),
}));
describe('App', () => {
let vm;
beforeAll(() => {
const options = {
global: {
stubs: ['router-view']
}
};
vm = createWrapper(App, options).vm;
});
it('should return a login error message', async () => {
jest.spyOn(vm.quasar, 'notify');
session.isLoggedIn.mockReturnValue(false);
const response = {
response: {
status: 401
}
};
expect(vm.responseError(response)).rejects.toEqual(expect.objectContaining(response));
expect(vm.quasar.notify).toHaveBeenCalledWith(expect.objectContaining(
{
type: 'negative',
message: 'login.loginError'
}
));
});
it('should return an unauthorized error message', async () => {
jest.spyOn(vm.quasar, 'notify');
session.isLoggedIn.mockReturnValue(true);
const response = {
response: {
status: 401
}
};
expect(vm.responseError(response)).rejects.toEqual(expect.objectContaining(response));
expect(vm.quasar.notify).toHaveBeenCalledWith(expect.objectContaining(
{
type: 'negative',
message: 'errors.statusUnauthorized'
}
));
expect(session.destroy).toHaveBeenCalled();
});
});

View File

@ -1,21 +1,83 @@
import axios from 'axios';
import { Notify } from 'quasar';
import { useSession } from 'src/composables/useSession';
import { Router } from 'src/router';
import { i18n } from './i18n';
const { getToken } = useSession();
const session = useSession();
const { t } = i18n.global;
axios.defaults.baseURL = '/api/';
axios.interceptors.request.use(
function (context) {
const token = getToken();
if (token.length && context.headers) {
context.headers.Authorization = token;
}
return context;
},
function (error) {
return Promise.reject(error);
const onRequest = (config) => {
const token = session.getToken();
if (token.length && config.headers) {
config.headers.Authorization = token;
}
);
return config;
};
const onRequestError = (error) => {
return Promise.reject(error);
};
const onResponse = (response) => {
const { method } = response.config;
const isSaveRequest = method === 'patch';
if (isSaveRequest) {
Notify.create({
message: t('globals.dataSaved'),
type: 'positive',
});
}
return response;
};
const onResponseError = (error) => {
let message = '';
const response = error.response;
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();
Router.push({ path: '/login' });
} else if(!session.isLoggedIn())
{
message = 'login.loginError';
}
Notify.create({
message: t(message),
type: 'negative',
});
return Promise.reject(error);
};
axios.interceptors.request.use(onRequest, onRequestError);
axios.interceptors.response.use(onResponse, onResponseError);
export {
onRequest,
onResponseError
}

View File

@ -3,9 +3,13 @@ import { createI18n } from 'vue-i18n';
import messages from 'src/i18n';
const i18n = createI18n({
locale: 'es',
locale: navigator.language || navigator.userLanguage,
fallbackLocale: 'en',
globalInjection: true,
messages,
legacy: false
missingWarn: false,
fallbackWarn: false,
legacy: false,
});
export default boot(({ app }) => {

18
src/boot/vnDate.js Normal file
View File

@ -0,0 +1,18 @@
import { boot } from 'quasar/wrappers';
export default boot(() => {
Date.vnUTC = () => {
const env = process.env.NODE_ENV;
if (!env || env === 'development') return new Date(Date.UTC(2001, 0, 1, 11));
return new Date();
};
Date.vnNew = () => {
return new Date(Date.vnUTC());
};
Date.vnNow = () => {
return new Date(Date.vnUTC()).getTime();
};
});

View File

@ -1,55 +0,0 @@
<script setup>
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useNavigation } from 'src/composables/useNavigation';
const { t } = useI18n();
const navigation = useNavigation();
onMounted(() => {
navigation.fetchFavorites();
});
</script>
<template>
<q-menu
anchor="bottom left"
class="row q-pa-md q-col-gutter-lg"
max-width="350px"
max-height="400px"
v-if="navigation.favorites.value.length"
>
<div v-for="module of navigation.favorites.value" :key="module.title" class="row no-wrap q-pa-xs flex-item">
<q-btn
align="evenly"
padding="16px"
flat
stack
size="lg"
:icon="module.icon"
color="primary"
class="col-4 button"
:to="{ name: module.stateName }"
>
<div class="text-center text-primary button-text">
{{ t(`${module.name}.pageTitles.${module.title}`) }}
</div>
</q-btn>
</div>
</q-menu>
</template>
<style lang="scss" scoped>
.flex-item {
width: 100px;
}
.button {
width: 100%;
line-height: normal;
align-items: center;
}
.button-text {
font-size: 10px;
margin-top: 5px;
}
</style>

View File

@ -39,16 +39,20 @@ onMounted(async () => {
});
async function fetch() {
const filter = Object.assign({}, $props.filter);
if ($props.where) filter.where = $props.where;
if ($props.sortBy) filter.order = $props.sortBy;
if ($props.limit) filter.limit = $props.limit;
try {
const filter = Object.assign({}, $props.filter);
if ($props.where) filter.where = $props.where;
if ($props.sortBy) filter.order = $props.sortBy;
if ($props.limit) filter.limit = $props.limit;
const { data } = await axios.get($props.url, {
params: { filter },
});
const { data } = await axios.get($props.url, {
params: { filter },
});
emit('onFetch', data);
emit('onFetch', data);
} catch (e) {
//
}
}
const render = () => {

View File

@ -1,9 +1,8 @@
<script setup>
import axios from 'axios';
import { onMounted, onUnmounted, computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import axios from 'axios';
import { useState } from 'src/composables/useState';
import { useValidator } from 'src/composables/useValidator';
import SkeletonForm from 'components/ui/SkeletonForm.vue';
@ -44,6 +43,7 @@ const isLoading = ref(false);
const hasChanges = ref(false);
const formData = computed(() => state.get($props.model));
const originalData = ref();
const formUrl = computed(() => $props.url);
async function fetch() {
const { data } = await axios.get($props.url, {
@ -91,18 +91,24 @@ function filter(value, update, filterOptions) {
}
);
}
watch(formUrl, async () => {
originalData.value = null;
reset();
fetch();
});
</script>
<template>
<q-banner v-if="hasChanges" class="text-white bg-warning">
<q-icon name="warning" size="md" class="q-mr-md" />
<QBanner v-if="hasChanges" class="text-white bg-warning">
<QIcon name="warning" size="md" class="q-mr-md" />
<span>{{ t('globals.changesToSave') }}</span>
</q-banner>
<q-form v-if="formData" @submit="save" @reset="reset" class="q-pa-md">
</QBanner>
<QForm v-if="formData" @submit="save" @reset="reset" class="q-pa-md">
<slot name="form" :data="formData" :validate="validate" :filter="filter"></slot>
<div class="q-mt-lg">
<slot name="actions">
<q-btn :label="t('globals.save')" type="submit" color="primary" />
<q-btn
<QBtn :label="t('globals.save')" type="submit" color="primary" />
<QBtn
:label="t('globals.reset')"
type="reset"
class="q-ml-sm"
@ -112,7 +118,11 @@ function filter(value, update, filterOptions) {
/>
</slot>
</div>
</q-form>
<skeleton-form v-if="!formData" />
<q-inner-loading :showing="isLoading" :label="t('globals.pleaseWait')" color="primary" />
</QForm>
<SkeletonForm v-if="!formData" />
<QInnerLoading
:showing="isLoading"
:label="t('globals.pleaseWait')"
color="primary"
/>
</template>

View File

@ -1,17 +1,111 @@
<script setup>
import axios from 'axios';
import { onMounted, ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRole } from 'src/composables/useRole';
import { useQuasar } from 'quasar';
import { useNavigation } from 'src/composables/useNavigation';
import { QSeparator, useQuasar } from 'quasar';
import { useRoute } from 'vue-router';
import { useNavigationStore } from 'src/stores/useNavigationStore';
import { toLowerCamel } from 'src/filters';
import routes from 'src/router/modules';
import LeftMenuItem from './LeftMenuItem.vue';
import LeftMenuItemGroup from './LeftMenuItemGroup.vue';
const { t } = useI18n();
const { hasAny } = useRole();
const navigation = useNavigation();
const route = useRoute();
const quasar = useQuasar();
const navigation = useNavigationStore();
async function onToggleFavoriteModule(moduleName, event) {
await navigation.toggleFavorite(moduleName, event);
const props = defineProps({
source: {
type: String,
default: 'main',
},
});
onMounted(async () => {
await navigation.fetchPinned();
getRoutes();
});
function findMatches(search, item) {
const matches = [];
function findRoute(search, item) {
for (const child of item.children) {
if (search.indexOf(child.name) > -1) {
matches.push(child);
} else if (child.children) {
findRoute(search, child);
}
}
}
findRoute(search, item);
return matches;
}
function addChildren(module, route, parent) {
if (route.menus) {
const mainMenus = route.menus[props.source];
const matches = findMatches(mainMenus, route);
for (const child of matches) {
navigation.addMenuItem(module, child, parent);
}
}
}
const pinnedItems = computed(() => {
return items.value.filter((item) => item.isPinned);
});
const items = ref([]);
function getRoutes() {
if (props.source === 'main') {
const modules = Object.assign([], navigation.getModules().value);
for (const item of modules) {
const moduleDef = routes.find(
(route) => toLowerCamel(route.name) === item.module
);
item.children = [];
if (!moduleDef) continue;
addChildren(item.module, moduleDef, item.children);
}
items.value = modules;
}
if (props.source === 'card') {
const currentRoute = route.matched[1];
const currentModule = toLowerCamel(currentRoute.name);
const moduleDef = routes.find(
(route) => toLowerCamel(route.name) === currentModule
);
if (!moduleDef) return;
addChildren(currentModule, moduleDef, items.value);
}
}
async function togglePinned(item, event) {
if (event.defaultPrevented) return;
event.preventDefault();
event.stopPropagation();
const data = { moduleName: item.module };
const response = await axios.post('StarredModules/toggleStarredModule', data);
item.isPinned = false;
if (response.data && response.data.id) {
item.isPinned = true;
}
navigation.togglePinned(item.module);
quasar.notify({
message: t('globals.dataSaved'),
@ -21,150 +115,96 @@ async function onToggleFavoriteModule(moduleName, event) {
</script>
<template>
<q-list padding>
<q-item-label header>{{ t('globals.favoriteModules') }}</q-item-label>
<template v-for="module in navigation.favorites.value" :key="module.title">
<div class="module" v-if="!module.children">
<q-item
clickable
v-ripple
active-class="text-primary"
:key="module.title"
:to="{ name: module.stateName }"
v-if="!module.roles || !module.roles.length || hasAny(module.roles)"
>
<q-item-section avatar :if="module.icon">
<q-icon :name="module.icon" />
</q-item-section>
<q-item-section>{{ t(`${module.name}.pageTitles.${module.title}`) }}</q-item-section>
<q-item-section side>
<div @click="onToggleFavoriteModule(module.name, $event)" class="row items-center">
<q-icon name="vn:pin_off"></q-icon>
</div>
</q-item-section>
</q-item>
</div>
<QList padding>
<template v-if="$props.source === 'main'">
<QItemLabel header>
{{ t('globals.pinnedModules') }}
</QItemLabel>
<template v-for="item in pinnedItems" :key="item.name">
<template v-if="item.children">
<LeftMenuItemGroup :item="item" group="pinnedModules" class="pinned">
<template #side>
<QBtn
v-if="item.isPinned === true"
@click="togglePinned(item, $event)"
icon="remove_circle"
size="xs"
flat
round
>
<QTooltip>{{
t('components.leftMenu.removeFromPinned')
}}</QTooltip>
</QBtn>
<QBtn
v-if="item.isPinned === false"
@click="togglePinned(item, $event)"
icon="push_pin"
size="xs"
flat
round
>
<QTooltip>{{
t('components.leftMenu.addToPinned')
}}</QTooltip>
</QBtn>
</template>
</LeftMenuItemGroup>
</template>
<template v-if="module.children">
<q-expansion-item
class="module"
active-class="text-primary"
:label="t(`${module.name}.pageTitles.${module.title}`)"
v-if="!module.roles || !module.roles.length || hasAny(module.roles)"
:to="{ name: module.stateName }"
>
<template #header>
<q-item-section avatar>
<q-icon :name="module.icon"></q-icon>
</q-item-section>
<q-item-section>{{ t(`${module.name}.pageTitles.${module.title}`) }}</q-item-section>
<q-item-section side>
<div @click="onToggleFavoriteModule(module.name, $event)" class="row items-center">
<q-icon name="vn:pin_off"></q-icon>
</div>
</q-item-section>
<LeftMenuItem v-if="!item.children" :item="item" />
</template>
<QSeparator />
<QExpansionItem :label="t('moduleIndex.allModules')">
<template v-for="item in items" :key="item.name">
<template v-if="item.children">
<LeftMenuItemGroup :item="item" group="modules">
<template #side>
<QBtn
v-if="item.isPinned === true"
@click="togglePinned(item, $event)"
icon="remove_circle"
size="xs"
flat
round
>
<QTooltip>
{{ t('components.leftMenu.removeFromPinned') }}
</QTooltip>
</QBtn>
<QBtn
v-if="item.isPinned === false"
@click="togglePinned(item, $event)"
icon="push_pin"
size="xs"
flat
round
>
<QTooltip>
{{ t('components.leftMenu.addToPinned') }}
</QTooltip>
</QBtn>
</template>
</LeftMenuItemGroup>
</template>
<template v-for="section in module.children" :key="section.title">
<q-item
clickable
v-ripple
active-class="text-primary"
:to="{ name: section.stateName }"
v-if="!section.roles || !section.roles.length || hasAny(section.roles)"
>
<q-item-section avatar :if="section.icon">
<q-icon :name="section.icon" />
</q-item-section>
<q-item-section>{{ t(`${module.name}.pageTitles.${section.title}`) }}</q-item-section>
</q-item>
</template>
</q-expansion-item>
</template>
</QExpansionItem>
<QSeparator />
</template>
<template v-if="$props.source === 'card'">
<template v-for="item in items" :key="item.name">
<LeftMenuItem v-if="!item.children" :item="item" />
</template>
</template>
</q-list>
<q-separator />
<q-expansion-item :label="t('moduleIndex.allModules')">
<q-list padding>
<template v-for="module in navigation.modules.value" :key="module.title">
<div class="module" v-if="!module.children">
<q-item
class="module"
clickable
v-ripple
active-class="text-primary"
:key="module.title"
:to="{ name: module.stateName }"
v-if="!module.roles || !module.roles.length || hasAny(module.roles)"
>
<q-item-section avatar :if="module.icon">
<q-icon :name="module.icon" />
</q-item-section>
<q-item-section>{{ t(`${module.name}.pageTitles.${module.title}`) }}</q-item-section>
<q-item-section side>
<div
@click="onToggleFavoriteModule(module.name, $event)"
class="row items-center"
v-if="module.name != 'dashboard'"
>
<q-icon name="vn:pin"></q-icon>
</div>
</q-item-section>
</q-item>
</div>
<template v-if="module.children">
<q-expansion-item
class="module"
active-class="text-primary"
:label="t(`${module.name}.pageTitles.${module.title}`)"
v-if="!module.roles || !module.roles.length || hasAny(module.roles)"
:to="{ name: module.stateName }"
>
<template #header>
<q-item-section avatar>
<q-icon :name="module.icon"></q-icon>
</q-item-section>
<q-item-section>{{ t(`${module.name}.pageTitles.${module.title}`) }}</q-item-section>
<q-item-section side>
<div
@click="onToggleFavoriteModule(module.name, $event)"
class="row items-center"
v-if="module.name != 'dashboard'"
>
<q-icon name="vn:pin"></q-icon>
</div>
</q-item-section>
</template>
<template v-for="section in module.children" :key="section.title">
<q-item
clickable
v-ripple
active-class="text-primary"
:to="{ name: section.stateName }"
v-if="!section.roles || !section.roles.length || hasAny(section.roles)"
>
<q-item-section avatar :if="section.icon">
<q-icon :name="section.icon" />
</q-item-section>
<q-item-section>{{ t(`${module.name}.pageTitles.${section.title}`) }}</q-item-section>
</q-item>
</template>
</q-expansion-item>
</template>
</template>
</q-list>
</q-expansion-item>
</QList>
</template>
<style>
.module .icon-pin,
.module .icon-pin_off {
.pinned .q-btn {
visibility: hidden;
}
.module:hover .icon-pin,
.module:hover .icon-pin_off {
.pinned:hover .q-btn {
visibility: visible;
}
</style>

View File

@ -0,0 +1,26 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
item: {
type: Object,
required: true,
},
});
const item = computed(() => props.item);
</script>
<template>
<QItem active-class="text-primary" :to="{ name: item.name }" clickable v-ripple>
<QItemSection avatar v-if="item.icon">
<QIcon :name="item.icon" />
</QItemSection>
<QItemSection avatar v-if="!item.icon">
<QIcon name="disabled_by_default" />
</QItemSection>
<QItemSection>{{ t(item.title) }}</QItemSection>
</QItem>
</template>

View File

@ -0,0 +1,51 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import LeftMenuItem from './LeftMenuItem.vue';
const route = useRoute();
const { t } = useI18n();
const props = defineProps({
item: {
type: Object,
required: true,
},
group: {
type: String,
default: '',
},
});
const item = computed(() => props.item);
const isOpened = computed(() => {
const { matched } = route;
const { name } = item.value;
return matched.some((item) => item.name === name);
});
</script>
<template>
<QExpansionItem
:group="props.group"
active-class="text-primary"
:label="item.title"
:to="{ name: item.name }"
expand-separator
:default-opened="isOpened"
>
<template #header>
<QItemSection avatar>
<QIcon :name="item.icon"></QIcon>
</QItemSection>
<QItemSection>{{ t(item.title) }}</QItemSection>
<QItemSection side>
<slot name="side" :item="item" />
</QItemSection>
</template>
<template v-for="section in item.children" :key="section.name">
<LeftMenuItem :item="section" />
</template>
</QExpansionItem>
</template>

82
src/components/NavBar.vue Normal file
View File

@ -0,0 +1,82 @@
<script setup>
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSession } from 'src/composables/useSession';
import UserPanel from 'components/UserPanel.vue';
import { useState } from 'src/composables/useState';
import { useStateStore } from 'stores/useStateStore';
import PinnedModules from './PinnedModules.vue';
const { t } = useI18n();
const session = useSession();
const stateStore = useStateStore();
const state = useState();
const user = state.getUser();
const token = session.getToken();
const appName = 'Lilium';
onMounted(() => stateStore.setMounted());
</script>
<template>
<QHeader class="bg-dark" color="white" elevated>
<QToolbar class="q-py-sm q-px-md">
<QBtn
@click="stateStore.toggleLeftDrawer()"
icon="menu"
class="q-mr-sm"
round
dense
flat
>
<QTooltip bottom anchor="bottom right">
{{ t('globals.collapseMenu') }}
</QTooltip>
</QBtn>
<RouterLink to="/">
<QBtn class="q-ml-xs" color="primary" flat round>
<QAvatar square size="md">
<QImg
src="~/assets/logo_icon.svg"
:alt="appName"
spinner-color="primary"
/>
</QAvatar>
<QTooltip bottom>
{{ t('globals.backToDashboard') }}
</QTooltip>
</QBtn>
</RouterLink>
<QToolbarTitle shrink class="text-weight-bold" v-if="$q.screen.gt.sm">
{{ appName }}
<QBadge label="Beta" align="top" />
</QToolbarTitle>
<QSpace />
<div id="searchbar"></div>
<QSpace />
<div class="q-pl-sm q-gutter-sm row items-center no-wrap">
<div id="actions-prepend"></div>
<QBtn id="pinnedModules" icon="apps" flat dense rounded>
<QTooltip bottom>
{{ t('globals.pinnedModules') }}
</QTooltip>
<PinnedModules />
</QBtn>
<QBtn rounded dense flat 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>
{{ t('globals.userPanel') }}
</QTooltip>
<UserPanel />
</QBtn>
<div id="actions-append"></div>
</div>
</QToolbar>
</QHeader>
</template>

View File

@ -1,68 +0,0 @@
<script setup>
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useState } from 'src/composables/useState';
import { useSession } from 'src/composables/useSession';
import UserPanel from 'components/UserPanel.vue';
import FavoriteModules from './FavoriteModules.vue';
const { t } = useI18n();
const session = useSession();
const state = useState();
const user = state.getUser();
const token = session.getToken();
onMounted(() => (state.headerMounted.value = true));
function onToggleDrawer() {
state.drawer.value = !state.drawer.value;
}
</script>
<template>
<q-header class="bg-dark" color="white" elevated>
<q-toolbar class="q-py-sm q-px-md">
<q-btn flat @click="onToggleDrawer()" round dense icon="menu">
<q-tooltip bottom anchor="bottom right">
{{ t('globals.collapseMenu') }}
</q-tooltip>
</q-btn>
<router-link to="/">
<q-btn flat round class="q-ml-xs" v-if="$q.screen.gt.xs">
<q-avatar square size="md">
<q-img src="~/assets/logo_icon.svg" alt="Logo" />
</q-avatar>
<q-tooltip bottom>
{{ t('globals.backToDashboard') }}
</q-tooltip>
</q-btn>
</router-link>
<q-toolbar-title shrink class="text-weight-bold">Salix</q-toolbar-title>
<q-space></q-space>
<div id="searchbar"></div>
<q-space></q-space>
<div class="q-pl-sm q-gutter-sm row items-center no-wrap">
<div id="header-actions"></div>
<q-btn id="favoriteModules" icon="apps" flat dense rounded>
<q-tooltip bottom>
{{ t('globals.favoriteModules') }}
</q-tooltip>
<FavoriteModules />
</q-btn>
<q-btn rounded dense flat no-wrap id="user">
<q-avatar size="lg">
<q-img
:src="`/api/Images/user/160x160/${user.id}/download?access_token=${token}`"
spinner-color="white"
>
</q-img>
</q-avatar>
<q-tooltip bottom>
{{ t('globals.userPanel') }}
</q-tooltip>
<UserPanel />
</q-btn>
</div>
</q-toolbar>
</q-header>
</template>

View File

@ -1,205 +0,0 @@
<script setup>
import axios from 'axios';
import { onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const $props = defineProps({
autoLoad: {
type: Boolean,
default: false,
},
url: {
type: String,
default: '',
},
data: {
type: Array,
default: null,
},
filter: {
type: Object,
default: null,
},
where: {
type: Object,
default: null,
},
sortBy: {
type: String,
default: '',
},
limit: {
type: String,
default: '',
},
rowsPerPage: {
type: Number,
default: 10,
},
offset: {
type: Number,
default: 500,
},
});
const emit = defineEmits(['onFetch', 'onPaginate']);
defineExpose({ refresh });
onMounted(() => {
if ($props.autoLoad) paginate();
});
watch(
() => $props.data,
() => {
rows.value = $props.data;
}
);
const isLoading = ref(false);
const hasMoreData = ref(false);
const pagination = ref({
sortBy: $props.sortBy,
rowsPerPage: $props.rowsPerPage,
page: 1,
});
const rows = ref(null);
async function fetch() {
const { page, rowsPerPage, sortBy } = pagination.value;
if (!$props.url) return;
const filter = {
limit: rowsPerPage,
skip: rowsPerPage * (page - 1),
};
Object.assign(filter, $props.filter);
if ($props.where) filter.where = $props.where;
if ($props.sortBy) filter.order = $props.sortBy;
if ($props.limit) filter.limit = $props.limit;
if (sortBy) filter.order = sortBy;
const { data } = await axios.get($props.url, {
params: { filter },
});
isLoading.value = false;
return data;
}
async function paginate() {
const { page, rowsPerPage, sortBy, descending } = pagination.value;
const data = await fetch();
if (!data) {
isLoading.value = false;
return;
}
hasMoreData.value = data.length === rowsPerPage;
if (!rows.value) rows.value = [];
for (const row of data) rows.value.push(row);
pagination.value.rowsNumber = rows.value.length;
pagination.value.page = page;
pagination.value.rowsPerPage = rowsPerPage;
pagination.value.sortBy = sortBy;
pagination.value.descending = descending;
isLoading.value = false;
emit('onFetch', rows);
emit('onPaginate', data);
}
async function refresh() {
const { rowsPerPage } = pagination.value;
const data = await fetch();
if (!data) {
isLoading.value = false;
return;
}
hasMoreData.value = data.length === rowsPerPage;
if (!rows.value) rows.value = [];
rows.value = data;
isLoading.value = false;
emit('onFetch', rows);
}
async function onLoad(...params) {
const done = params[1];
if (!rows.value || rows.value.length === 0 || !$props.url) return done(false);
pagination.value.page = pagination.value.page + 1;
await paginate();
const endOfPages = !hasMoreData.value;
done(endOfPages);
}
</script>
<template>
<q-infinite-scroll @load="onLoad" :offset="offset" class="column items-center">
<div v-if="rows" class="card-list q-gutter-y-md">
<slot name="body" :rows="rows"></slot>
<div v-if="!rows.length && !isLoading" class="info-row q-pa-md text-center">
<h5>
{{ t('components.smartCard.noData') }}
</h5>
</div>
<div v-if="isLoading" class="info-row q-pa-md text-center">
<q-spinner color="orange" size="md" />
</div>
</div>
<div v-if="!rows" class="card-list q-gutter-y-md">
<q-card class="card" v-for="$index in $props.rowsPerPage" :key="$index">
<q-item v-ripple class="q-pa-none items-start cursor-pointer q-hoverable">
<q-item-section class="q-pa-md">
<q-skeleton type="rect" class="q-mb-md" square />
<q-skeleton type="text" square />
<q-skeleton type="text" class="q-mb-md" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
</q-item-section>
<q-separator vertical />
<q-card-actions vertical class="justify-between">
<q-skeleton type="circle" class="q-mb-md" size="40px" />
<q-skeleton type="circle" class="q-mb-md" size="40px" />
<q-skeleton type="circle" class="q-mb-md" size="40px" />
</q-card-actions>
</q-item>
</q-card>
</div>
</q-infinite-scroll>
</template>
<style lang="scss" scoped>
.card-list {
width: 100%;
max-width: 60em;
}
.info-row {
width: 100%;
h5 {
margin: 0;
}
}
</style>

View File

@ -0,0 +1,70 @@
<script setup>
import { onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useNavigationStore } from 'src/stores/useNavigationStore';
const navigation = useNavigationStore();
const { t } = useI18n();
onMounted(() => {
navigation.fetchPinned();
});
const pinnedModules = computed(() => navigation.getPinnedModules());
</script>
<template>
<QMenu
anchor="bottom left"
class="row q-pa-md q-col-gutter-lg"
max-width="350px"
max-height="400px"
>
<template v-if="pinnedModules.length">
<div
v-for="item of pinnedModules"
:key="item.title"
class="row no-wrap q-pa-xs flex-item"
>
<QBtn
align="evenly"
padding="16px"
flat
stack
size="lg"
:icon="item.icon"
color="primary"
class="col-4 button"
:to="{ name: item.name }"
>
<div class="text-center text-primary button-text">
{{ t(item.title) }}
</div>
</QBtn>
</div>
</template>
<template v-else>
<div
class="row no-wrap q-pa-xs flex-item text-center text-grey-5"
style="min-width: 200px"
>
{{ t('globals.noPinnedModules') }}
</div>
</template>
</QMenu>
</template>
<style lang="scss" scoped>
.flex-item {
width: 100px;
}
.button {
width: 100%;
line-height: normal;
align-items: center;
}
.button-text {
font-size: 10px;
margin-top: 5px;
}
</style>

View File

@ -22,9 +22,16 @@ const userLocale = computed({
if (value === 'en') value = 'en-GB';
import(`quasar/lang/${value}`).then((language) => {
Quasar.lang.set(language.default);
});
// FIXME: Dynamic imports from absolute paths are not compatible with vite:
// https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations
try {
const langList = import.meta.glob('../../node_modules/quasar/lang/*.mjs');
langList[`../../node_modules/quasar/lang/${value}.mjs`]().then((lang) => {
Quasar.lang.set(lang.default);
});
} catch (error) {
//
}
},
});
@ -45,7 +52,7 @@ onMounted(async () => {
});
function updatePreferences() {
if (user.value.darkMode) {
if (user.value.darkMode !== null) {
darkMode.value = user.value.darkMode;
}
if (user.value.lang) {
@ -63,7 +70,7 @@ async function saveDarkMode(value) {
}
async function saveLanguage(value) {
const query = `/Accounts/${user.value.id}`;
const query = `/VnUsers/${user.value.id}`;
await axios.patch(query, {
lang: value,
});
@ -77,11 +84,13 @@ function logout() {
</script>
<template>
<q-menu anchor="bottom left">
<QMenu anchor="bottom left">
<div class="row no-wrap q-pa-md">
<div class="column panel">
<div class="text-h6 q-mb-md">{{ t('components.userPanel.settings') }}</div>
<q-toggle
<div class="text-h6 q-mb-md">
{{ t('components.userPanel.settings') }}
</div>
<QToggle
v-model="userLocale"
@update:model-value="saveLanguage"
:label="t(`globals.lang['${userLocale}']`)"
@ -90,7 +99,7 @@ function logout() {
false-value="es"
true-value="en"
/>
<q-toggle
<QToggle
v-model="darkMode"
@update:model-value="saveDarkMode"
:label="t(`globals.darkMode`)"
@ -100,22 +109,22 @@ function logout() {
/>
</div>
<q-separator vertical inset class="q-mx-lg" />
<QSeparator vertical inset class="q-mx-lg" />
<div class="column items-center panel">
<q-avatar size="80px">
<q-img
<QAvatar size="80px">
<QImg
:src="`/api/Images/user/160x160/${user.id}/download?access_token=${token}`"
spinner-color="white"
/>
</q-avatar>
</QAvatar>
<div class="text-subtitle1 q-mt-md">
<strong>{{ user.nickname }}</strong>
</div>
<div class="text-subtitle3 text-grey-7 q-mb-xs">@{{ user.name }}</div>
<q-btn
<QBtn
id="logout"
color="orange"
flat
@ -127,7 +136,7 @@ function logout() {
/>
</div>
</div>
</q-menu>
</QMenu>
</template>
<style lang="scss" scoped>

View File

@ -1,98 +0,0 @@
import { jest, describe, expect, it, beforeAll } from '@jest/globals';
import { createWrapper } from 'app/tests/jest/jestHelpers';
import Leftmenu from '../LeftMenu.vue';
const mockPush = jest.fn();
jest.mock('vue-router', () => ({
useRouter: () => ({
push: mockPush,
currentRoute: { value: 'myCurrentRoute' }
}),
}));
jest.mock('src/router/routes', () => ([
{
path: '/',
name: 'Main',
children: [
{
path: '/dashboard',
name: 'Dashboard',
meta: { title: 'dashboard', icon: 'dashboard' }
},
{
path: '/customer',
name: 'Customer',
meta: {
title: 'customers',
icon: 'vn:client'
},
children: [
{
path: '',
name: 'CustomerMain',
children: [
{
path: 'list',
name: 'CustomerList',
meta: {
title: 'list',
icon: 'view_list',
}
},
{
path: 'create',
name: 'CustomerCreate',
meta: {
title: 'createCustomer',
icon: 'vn:addperson',
}
},
]
}
]
}
],
},
]));
describe('Leftmenu', () => {
let vm;
beforeAll(() => {
vm = createWrapper(Leftmenu).vm;
});
it('should return the proper formated object without the children property', async () => {
const expectedMenuItem = {
stateName: 'Dashboard',
name: 'dashboard',
roles: [],
icon: 'dashboard',
title: 'dashboard'
}
const firstMenuItem = vm.navigation.modules.value[0];
expect(firstMenuItem.children).toBeUndefined();
expect(firstMenuItem).toEqual(expect.objectContaining(expectedMenuItem));
});
it('should return a proper formated object with two child items', async () => {
const expectedMenuItem = [{
name: 'CustomerList',
title: 'list',
icon: 'view_list',
stateName: 'CustomerList'
},
{
name: 'CustomerCreate',
title: 'createCustomer',
icon: 'vn:addperson',
stateName: 'CustomerCreate'
}];
const secondMenuItem = vm.navigation.modules.value[1];
expect(secondMenuItem.children).toEqual(expect.arrayContaining(expectedMenuItem));
expect(secondMenuItem.children.length).toEqual(2)
});
});

View File

@ -3,12 +3,13 @@ import { ref } from 'vue';
import { useDialogPluginComponent } from 'quasar';
import { useI18n } from 'vue-i18n';
const $props = defineProps({
address: {
type: String,
default: '',
const props = defineProps({
data: {
type: Object,
requied: true,
default: null,
},
send: {
promise: {
type: Function,
required: true,
},
@ -19,37 +20,51 @@ defineEmits(['confirm', ...useDialogPluginComponent.emits]);
const { dialogRef, onDialogOK } = useDialogPluginComponent();
const { t } = useI18n();
const address = ref($props.address);
const address = ref(props.data.address);
const isLoading = ref(false);
async function confirm() {
isLoading.value = true;
await $props.send(address.value);
isLoading.value = false;
const response = { address };
onDialogOK();
if (props.promise) {
isLoading.value = true;
try {
Object.assign(response, props.data);
await props.promise(response);
} finally {
isLoading.value = false;
}
}
onDialogOK(response);
}
</script>
<template>
<q-dialog ref="dialogRef" persistent>
<q-card class="q-pa-sm">
<q-card-section class="row items-center q-pb-none">
<span class="text-h6 text-grey">{{ t('sendEmailNotification') }}</span>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section class="row items-center">
{{ t('notifyAddress') }}
</q-card-section>
<q-card-section class="q-pt-none">
<q-input dense v-model="address" rounded outlined autofocus />
</q-card-section>
<q-card-actions align="right">
<q-btn :label="t('globals.cancel')" color="primary" flat v-close-popup />
<q-btn :label="t('globals.confirm')" color="primary" :loading="isLoading" @click="confirm" />
</q-card-actions>
</q-card>
</q-dialog>
<QDialog ref="dialogRef" persistent>
<QCard class="q-pa-sm">
<QCardSection class="row items-center q-pb-none">
<span class="text-h6 text-grey">{{ t('Send email notification') }}</span>
<QSpace />
<QBtn icon="close" flat round dense v-close-popup />
</QCardSection>
<QCardSection class="row items-center">
{{ t('The notification will be sent to the following address') }}
</QCardSection>
<QCardSection class="q-pt-none">
<QInput dense v-model="address" rounded outlined autofocus />
</QCardSection>
<QCardActions align="right">
<QBtn :label="t('globals.cancel')" color="primary" flat v-close-popup />
<QBtn
:label="t('globals.confirm')"
color="primary"
:loading="isLoading"
@click="confirm"
unelevated
/>
</QCardActions>
</QCard>
</QDialog>
</template>
<style lang="scss" scoped>
@ -59,14 +74,7 @@ async function confirm() {
</style>
<i18n>
{
"en": {
"sendEmailNotification": "Send email notification",
"notifyAddress": "The notification will be sent to the following address"
},
"es": {
"sendEmailNotification": "Enviar notificación por correo",
"notifyAddress": "La notificación se enviará a la siguiente dirección"
}
}
es:
Send email notification: Enviar notificación por correo
The notification will be sent to the following address: La notificación se enviará a la siguiente dirección
</i18n>

View File

@ -0,0 +1,208 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useSession } from 'src/composables/useSession';
import { useStateStore } from 'stores/useStateStore';
import VnPaginate from 'src/components/ui/VnPaginate.vue';
import VnLogFilter from 'src/components/common/VnLogFilter.vue';
import { toDate } from 'src/filters';
const stateStore = useStateStore();
const route = useRoute();
const session = useSession();
const token = session.getToken();
const { t } = useI18n();
const props = defineProps({
model: {
type: String,
default: null,
},
});
const columns = [
{
name: 'property',
label: 'Property',
field: (row) => t(`properties.${row.property}`),
align: 'left',
},
{
name: 'before',
label: 'Before',
field: (row) => formatValue(row.before),
},
{
name: 'after',
label: 'After',
field: (row) => formatValue(row.after),
},
];
function formatValue(value) {
if (typeof value === 'boolean') {
return value ? t('Yes') : t('No');
}
if (isNaN(value) && !isNaN(Date.parse(value))) {
return toDate(value);
}
if (value === undefined) {
return t('Nothing');
}
return `"${value}"`;
}
function actionColor(action) {
if (action === 'insert') return 'positive';
if (action === 'update') return 'positive';
if (action === 'delete') return 'negative';
}
</script>
<template>
<div class="column items-center">
<QTimeline class="q-pa-md">
<QTimelineEntry heading tag="h4"> {{ t('Audit logs') }} </QTimelineEntry>
<VnPaginate
:data-key="`${props.model}Logs`"
:url="`${props.model}s/${route.params.id}/logs`"
order="id DESC"
:offset="100"
:limit="5"
auto-load
>
<template #body="{ rows }">
<template v-for="log of rows" :key="log.id">
<QTimelineEntry
:avatar="`/api/Images/user/160x160/${log.userFk}/download?access_token=${token}`"
>
<template #subtitle>
{{ log.userName }} -
{{
toDate(log.created, {
dateStyle: 'medium',
timeStyle: 'short',
})
}}
</template>
<template #title>
<QChip :color="actionColor(log.action)">
{{ t(`actions.${log.action}`) }}
</QChip>
{{ t(`models.${log.model}`) }}
</template>
<QTable
:rows="log.changes"
:columns="columns"
row-key="property"
hide-pagination
dense
flat
>
<template #header="propsLabel">
<QTr :props="propsLabel">
<QTh
v-for="col in propsLabel.cols"
:key="col.name"
:props="propsLabel"
>
{{ t(col.label) }}
</QTh>
</QTr>
</template>
</QTable>
</QTimelineEntry>
</template>
</template>
</VnPaginate>
</QTimeline>
</div>
<Teleport v-if="stateStore.isHeaderMounted()" to="#actions-append">
<div class="row q-gutter-x-sm">
<QBtn flat @click="stateStore.toggleRightDrawer()" round dense icon="menu">
<QTooltip bottom anchor="bottom right">
{{ t('globals.collapseMenu') }}
</QTooltip>
</QBtn>
</div>
</Teleport>
<QDrawer v-model="stateStore.rightDrawer" show-if-above side="right" :width="300">
<QScrollArea class="fit text-grey-8">
<VnLogFilter :data-key="`${props.model}Logs`" />
</QScrollArea>
</QDrawer>
</template>
<style lang="scss" scoped>
.q-timeline {
width: 100%;
max-width: 80em;
}
</style>
<i18n>
en:
actions:
insert: Creates
update: Updates
delete: Deletes
models:
Claim: Claim
ClaimDms: Document
ClaimBeginning: Claimed Sales
ClaimObservation: Observation
properties:
id: ID
claimFk: Claim ID
saleFk: Sale ID
quantity: Quantity
observation: Observation
ticketCreated: Created
created: Created
isChargedToMana: Charged to mana
hasToPickUp: Has to pick Up
dmsFk: Document ID
text: Description
claimStateFk: Claim State
workerFk: Worker
clientFk: Customer
rma: RMA
responsibility: Responsibility
packages: Packages
es:
Audit logs: Registros de auditoría
Property: Propiedad
Before: Antes
After: Después
Yes: Si
Nothing: Nada
actions:
insert: Crea
update: Actualiza
delete: Elimina
models:
Claim: Reclamación
ClaimDms: Documento
ClaimBeginning: Línea reclamada
ClaimObservation: Observación
properties:
id: ID
claimFk: ID reclamación
saleFk: ID linea de venta
quantity: Cantidad
observation: Observación
ticketCreated: Creado
created: Creado
isChargedToMana: Cargado a maná
hasToPickUp: Se debe recoger
dmsFk: ID documento
text: Descripción
claimStateFk: Estado de la reclamación
workerFk: Trabajador
clientFk: Cliente
rma: RMA
responsibility: Responsabilidad
packages: Bultos
</i18n>

View File

@ -0,0 +1,79 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import FetchData from 'components/FetchData.vue';
import VnFilterPanel from 'src/components/ui/VnFilterPanel.vue';
const { t } = useI18n();
const props = defineProps({
dataKey: {
type: String,
required: true,
},
});
const workers = ref();
</script>
<template>
<FetchData
url="Workers/activeWithInheritedRole"
:filter="{ where: { role: 'salesPerson' } }"
@on-fetch="(data) => (workers = data)"
auto-load
/>
<VnFilterPanel :data-key="props.dataKey" :search-button="true">
<template #tags="{ tag, formatFn }">
<div class="q-gutter-x-xs">
<strong>{{ t(`params.${tag.label}`) }}: </strong>
<span>{{ formatFn(tag.value) }}</span>
</div>
</template>
<template #body="{ params, searchFn }">
<QDate
v-model="params.created"
@update:model-value="searchFn()"
dense
flat
minimal
>
</QDate>
<QList dense>
<QSeparator />
<QItem>
<QItemSection v-if="!workers">
<QSkeleton type="QInput" class="full-width" />
</QItemSection>
<QItemSection v-if="workers">
<QSelect
:label="t('User')"
v-model="params.userFk"
@update:model-value="searchFn()"
:options="workers"
option-value="id"
option-label="name"
emit-value
map-options
use-input
:input-debounce="0"
/>
</QItemSection>
</QItem>
</QList>
</template>
</VnFilterPanel>
</template>
<i18n>
en:
params:
search: Contains
userFk: User
created: Created
es:
params:
search: Contiene
userFk: Usuario
created: Creada
User: Usuario
</i18n>

View File

@ -0,0 +1,261 @@
<script setup>
import { ref, computed } from 'vue';
import { useDialogPluginComponent } from 'quasar';
import { useI18n } from 'vue-i18n';
const { dialogRef, onDialogOK } = useDialogPluginComponent();
const { t, availableLocales } = useI18n();
defineEmits(['confirm', ...useDialogPluginComponent.emits]);
const props = defineProps({
subject: {
type: String,
required: false,
default: 'Verdnatura',
},
phone: {
type: String,
required: true,
},
template: {
type: String,
required: true,
},
locale: {
type: String,
required: false,
default: 'es',
},
data: {
type: Object,
required: false,
default: null,
},
promise: {
type: Function,
required: true,
},
});
const maxLength = 160;
const locale = ref(props.locale);
const subject = ref(props.subject);
const phone = ref(props.phone);
const message = ref('');
updateMessage();
function updateMessage() {
const params = props.data;
const key = `templates['${props.template}']`;
message.value = t(key, params, { locale: locale.value });
}
const totalLength = computed(() => message.value.length);
const color = computed(() => {
if (totalLength.value == maxLength) return 'negative';
if ((totalLength.value / maxLength) * 100 > 90) return 'warning';
return 'positive';
});
const languages = availableLocales.map((locale) => ({ label: t(locale), value: locale }));
const isLoading = ref(false);
async function send() {
const response = {
destination: phone.value,
message: message.value,
};
if (props.promise) {
isLoading.value = true;
try {
Object.assign(response, props.data);
await props.promise(response);
} finally {
isLoading.value = false;
}
}
onDialogOK(response);
}
</script>
<template>
<QDialog ref="dialogRef" persistent>
<QCard class="q-pa-sm">
<QCardSection class="row items-center q-pb-none">
<span class="text-h6 text-grey">
{{ t('Send SMS') }}
</span>
<QSpace />
<QBtn icon="close" :disable="isLoading" flat round dense v-close-popup />
</QCardSection>
<QCardSection v-if="props.locale">
<QBanner class="bg-amber text-white" rounded dense>
<template #avatar>
<QIcon name="warning" />
</template>
<span
v-html="t('CustomerDefaultLanguage', { locale: t(props.locale) })"
></span>
</QBanner>
</QCardSection>
<QCardSection class="q-pb-xs">
<QSelect
:label="t('Language')"
:options="languages"
v-model="locale"
@update:model-value="updateMessage()"
emit-value
map-options
:input-debounce="0"
rounded
outlined
dense
/>
</QCardSection>
<QCardSection class="q-pb-xs">
<QInput
:label="t('Phone')"
v-model="phone"
rounded
outlined
autofocus
dense
/>
</QCardSection>
<QCardSection class="q-pb-xs">
<QInput
:label="t('Subject')"
v-model="subject"
rounded
outlined
autofocus
dense
/>
</QCardSection>
<QCardSection class="q-mb-md" q-input>
<QInput
:label="t('Message')"
v-model="message"
type="textarea"
:maxlength="maxLength"
:counter="true"
:autogrow="true"
:bottom-slots="true"
:rules="[(value) => value.length < maxLength || 'Error!']"
stack-label
outlined
autofocus
>
<template #append>
<QIcon
v-if="message !== ''"
name="close"
@click="message = ''"
class="cursor-pointer"
/>
</template>
<template #counter>
<QChip :color="color" dense>
{{ totalLength }}/{{ maxLength }}
</QChip>
</template>
</QInput>
</QCardSection>
<QCardActions align="right">
<QBtn
:label="t('globals.cancel')"
color="primary"
:disable="isLoading"
flat
v-close-popup
/>
<QBtn
:label="t('globals.confirm')"
@click="send()"
:loading="isLoading"
color="primary"
unelevated
/>
</QCardActions>
</QCard>
</QDialog>
</template>
<style lang="scss" scoped>
.q-chip {
transition: background 0.36s;
}
.q-card {
width: 500px;
}
</style>
<i18n>
en:
CustomerDefaultLanguage: This customer uses <strong>{locale}</strong> as their default language
templates:
pendingPayment: 'Your order is pending of payment.
Please, enter the website and make the payment with a credit card. Thank you.'
minAmount: 'A minimum amount of 50 (VAT excluded) is required for your order
{ orderId } of { shipped } to receive it without additional shipping costs.'
orderChanges: 'Order {orderId} of { shipped }: { changes }'
en: English
es: Spanish
fr: French
pt: Portuguese
es:
Send SMS: Enviar SMS
CustomerDefaultLanguage: Este cliente utiliza <strong>{locale}</strong> como idioma por defecto
Language: Idioma
Phone: Móvil
Subject: Asunto
Message: Mensaje
templates:
pendingPayment: 'Su pedido está pendiente de pago.
Por favor, entre en la página web y efectue el pago con tarjeta. Muchas gracias.'
minAmount: 'Es necesario un importe mínimo de 50 (Sin IVA) en su pedido
{ orderId } del día { shipped } para recibirlo sin portes adicionales.'
orderChanges: 'Pedido {orderId} día { shipped }: { changes }'
en: Inglés
es: Español
fr: Francés
pt: Portugués
fr:
Send SMS: Envoyer SMS
CustomerDefaultLanguage: Ce client utilise l'{locale} comme langue par défaut
Language: Langage
Phone: Mobile
Subject: Affaire
Message: Message
templates:
pendingPayment: 'Votre commande est en attente de paiement.
Veuillez vous connecter sur le site web et effectuer le paiement par carte. Merci beaucoup.'
minAmount: 'Un montant minimum de 50 (TVA non incluse) est requis pour votre commande
{ orderId } du { shipped } afin de la recevoir sans frais de port supplémentaires.'
orderChanges: 'Commande { orderId } du { shipped }: { changes }'
en: Anglais
es: Espagnol
fr: Français
pt: Portugais
pt:
Send SMS: Enviar SMS
CustomerDefaultLanguage: Este cliente utiliza o <strong>{locale}</strong> como seu idioma padrão
Language: Linguagem
Phone: Móvel
Subject: Assunto
Message: Mensagem
templates:
pendingPayment: 'Seu pedido está pendente de pagamento.
Por favor, acesse o site e faça o pagamento com cartão. Muito obrigado.'
minAmount: 'É necessário um valor mínimo de 50 (sem IVA) em seu pedido
{ orderId } do dia { shipped } para recebê-lo sem custos de envio adicionais.'
orderChanges: 'Pedido { orderId } dia { shipped }: { changes }'
en: Inglês
es: Espanhol
fr: Francês
pt: Português
</i18n>

View File

@ -0,0 +1,139 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { toCurrency } from 'filters/index';
const { t } = useI18n();
const props = defineProps({
quantity: {
type: Number,
required: true,
},
price: {
type: Number,
required: true,
},
discount: {
type: Number,
required: false,
default: 0,
},
mana: {
type: Number,
required: true,
},
promise: {
type: Function,
required: true,
},
data: {
type: Object,
required: false,
default: null,
},
});
const emit = defineEmits(['onUpdate']);
const discount = ref(0);
let canceller;
onMounted(() => {
if (props.discount) {
discount.value = props.discount;
}
});
const total = computed(() => {
const amount = props.price * props.quantity;
const appliedDiscount = (discount.value * amount) / 100;
return amount - appliedDiscount;
});
const newAmount = computed(() => `${t('New amount')}: ${toCurrency(total.value)}`);
const isLoading = ref(false);
async function save({ set }) {
isLoading.value = true;
const response = {
...props.data,
...{
discount: parseInt(discount.value),
},
};
if (props.promise) {
canceller = new AbortController();
Object.assign(response, { canceller });
await props.promise(response);
}
if (set) set();
emit('onUpdate', response);
isLoading.value = false;
}
function cancel({ cancel }) {
if (canceller) {
canceller.abort();
canceller = null;
}
discount.value = props.discount;
if (isLoading.value === true) isLoading.value = false;
if (cancel) cancel();
}
</script>
<template>
<QPopupEdit
v-model="discount"
v-slot="scope"
:title="t('Update discount')"
@cancel="cancel"
>
<QBanner class="bg-primary text-center q-mb-md" rounded dense>
{{ t('Mana') }} {{ toCurrency(props.mana) }}
</QBanner>
<QInput
v-model="scope.value"
type="number"
dense
autofocus
@keyup.enter="save(scope)"
@update:model-value="discount = scope.value"
@focus="($event) => $event.target.select()"
min="0"
max="100"
:disable="isLoading"
:hint="newAmount"
:rules="[
(value) => value.length > 0 || t('Enter a value'),
(value) => (value >= 0 && value <= 100) || t('Invalid discount amount'),
]"
/>
<QCardActions class="q-px-none q-mt-sm" align="right">
<QBtn :label="t('Cancel')" color="primary" flat @click="cancel(scope)" />
<QBtn
:label="t('Update')"
color="primary"
:loading="isLoading"
unelevated
@click="save(scope)"
/>
</QCardActions>
</QPopupEdit>
</template>
<i18n>
es:
New amount: Nuevo importe
Update discount: Actualizar descuento
Mana: Maná
Enter a value: Introduce un valor
Invalid discount amount: Cantidad de descuento incorrecta
Cancel: Cancelar
Update: Actualizar
</i18n>

View File

@ -1,79 +1,110 @@
<script setup>
import { useSlots } from 'vue';
import { onMounted, useSlots, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import SkeletonDescriptor from 'components/ui/SkeletonDescriptor.vue';
defineProps({
const props = defineProps({
url: {
type: String,
default: '',
},
filter: {
type: Object,
default: null,
},
module: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
description: {
type: String,
required: true,
},
});
const slots = useSlots();
const { t } = useI18n();
onMounted(() => fetch());
const emit = defineEmits(['onFetch']);
const entity = ref();
async function fetch() {
const params = {};
if (props.filter) params.filter = JSON.stringify(props.filter);
const { data } = await axios.get(props.url, { params });
entity.value = data;
emit('onFetch', data);
}
watch(props, async () => {
entity.value = null;
await fetch();
});
</script>
<template>
<div class="descriptor">
<div class="header bg-primary q-pa-sm">
<router-link :to="{ name: `${module}List` }">
<q-btn round flat dense size="md" icon="view_list" color="white">
<q-tooltip>{{ t('components.cardDescriptor.mainList') }}</q-tooltip>
</q-btn>
</router-link>
<router-link :to="{ name: `${module}Summary`, params: { id: data.id } }">
<q-btn round flat dense size="md" icon="launch" color="white">
<q-tooltip>{{ t('components.cardDescriptor.summary') }}</q-tooltip>
</q-btn>
</router-link>
<template v-if="entity">
<div class="header bg-primary q-pa-sm">
<RouterLink :to="{ name: `${module}List` }">
<QBtn round flat dense size="md" icon="view_list" color="white">
<QTooltip>
{{ t('components.cardDescriptor.mainList') }}
</QTooltip>
</QBtn>
</RouterLink>
<RouterLink :to="{ name: `${module}Summary`, params: { id: entity.id } }">
<QBtn round flat dense size="md" icon="launch" color="white">
<QTooltip>
{{ t('components.cardDescriptor.summary') }}
</QTooltip>
</QBtn>
</RouterLink>
<q-btn v-if="slots.menu" size="md" icon="more_vert" color="white" round flat dense>
<q-tooltip>{{ t('components.cardDescriptor.moreOptions') }}</q-tooltip>
<q-menu>
<q-list>
<slot name="menu" />
</q-list>
</q-menu>
</q-btn>
</div>
<div v-if="$props.data" class="body q-py-sm">
<q-list>
<q-item-label header class="ellipsis text-h5" :lines="1">
{{ $props.description }}
<q-tooltip>{{ $props.description }}</q-tooltip>
</q-item-label>
<q-item dense>
<q-item-label class="text-subtitle2" caption>#{{ data.id }}</q-item-label>
</q-item>
</q-list>
<slot name="body" />
</div>
<!-- Skeleton -->
<div id="descriptor-skeleton" v-if="!$props.data">
<div class="col q-pl-sm q-pa-sm">
<q-skeleton type="text" square height="45px" />
<q-skeleton type="text" square height="18px" />
<q-skeleton type="text" square height="18px" />
<q-skeleton type="text" square height="18px" />
<QBtn
v-if="slots.menu"
size="md"
icon="more_vert"
color="white"
round
flat
dense
>
<QTooltip>
{{ t('components.cardDescriptor.moreOptions') }}
</QTooltip>
<QMenu>
<QList>
<slot name="menu" :entity="entity" />
</QList>
</QMenu>
</QBtn>
</div>
<q-card-actions>
<q-skeleton size="40px" />
<q-skeleton size="40px" />
<q-skeleton size="40px" />
<q-skeleton size="40px" />
<q-skeleton size="40px" />
</q-card-actions>
</div>
<slot name="before" />
<div class="body q-py-sm">
<QList dense>
<QItemLabel header class="ellipsis text-h5" :lines="1">
<slot name="description" :entity="entity">
<span>
{{ entity.name }}
<QTooltip>{{ entity.name }}</QTooltip>
</span>
</slot>
</QItemLabel>
<QItem dense>
<QItemLabel class="text-subtitle2" caption>
#{{ entity.id }}
</QItemLabel>
</QItem>
</QList>
<slot name="body" :entity="entity" />
</div>
<slot name="after" />
</template>
<!-- Skeleton -->
<SkeletonDescriptor v-if="!entity" />
</div>
</template>

View File

@ -0,0 +1,119 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import axios from 'axios';
import SkeletonSummary from 'components/ui/SkeletonSummary.vue';
onMounted(() => fetch());
const entity = ref();
const props = defineProps({
url: {
type: String,
default: '',
},
filter: {
type: Object,
default: null,
},
});
const emit = defineEmits(['onFetch']);
defineExpose({
entity,
fetch,
});
async function fetch() {
const params = {};
if (props.filter) params.filter = props.filter;
const { data } = await axios.get(props.url, { params });
entity.value = data;
emit('onFetch', data);
}
watch(props, async () => {
entity.value = null;
fetch();
});
</script>
<template>
<div class="summary container">
<QCard>
<SkeletonSummary v-if="!entity" />
<template v-if="entity">
<div class="header bg-primary q-pa-sm q-mb-md">
<slot name="header" :entity="entity">
{{ entity.id }} - {{ entity.name }}
</slot>
</div>
<div class="body q-pa-md q-mb-md">
<slot name="body" :entity="entity" />
</div>
</template>
</QCard>
</div>
</template>
<style lang="scss">
.summary.container {
display: flex;
justify-content: center;
}
.summary {
.q-card {
width: 100%;
max-width: 1200px;
}
.negative {
color: red;
}
.q-list {
.q-item__label--header {
display: flex;
justify-content: space-between;
a {
color: $primary;
}
}
}
.body > .q-card__section.row {
flex-wrap: wrap;
& > .col {
min-width: 250px;
}
}
.header {
text-align: center;
font-size: 18px;
}
#slider-container {
max-width: 80%;
margin: 0 auto;
.q-slider {
.q-slider__marker-labels:nth-child(1) {
transform: none;
}
.q-slider__marker-labels:nth-child(2) {
transform: none;
left: auto !important;
right: 0%;
}
}
}
}
.q-dialog .summary {
max-width: 1200px;
}
</style>

View File

@ -0,0 +1,54 @@
<script setup>
defineProps({
maxLength: {
type: Number,
required: true,
},
item: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="fetchedTags">
<div class="wrap">
<div class="inline-tag" :class="{ empty: !$props.item.value5 }">{{ $props.item.value5 }}</div>
<div class="inline-tag" :class="{ empty: !$props.item.value6 }">{{ $props.item.value6 }}</div>
<div class="inline-tag" :class="{ empty: !$props.item.value7 }">{{ $props.item.value7 }}</div>
<div class="inline-tag" :class="{ empty: !$props.item.value8 }">{{ $props.item.value8 }}</div>
<div class="inline-tag" :class="{ empty: !$props.item.value9 }">{{ $props.item.value9 }}</div>
<div class="inline-tag" :class="{ empty: !$props.item.value10 }">{{ $props.item.value10 }}</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.fetchedTags {
align-items: center;
.wrap {
width: 100%;
flex-wrap: wrap;
display: flex;
}
.inline-tag {
height: 1rem;
margin: 0.05rem;
color: $secondary;
text-align: center;
font-size: smaller;
padding: 1px;
flex: 1;
border: 1px solid $color-spacer;
text-overflow: ellipsis;
overflow: hidden;
min-width: 4rem;
max-width: 4rem;
}
.empty {
border: 1px solid $color-spacer-light;
}
}
</style>

View File

@ -1,19 +1,19 @@
<template>
<div id="descriptor-skeleton">
<div class="col q-pl-sm q-pa-sm">
<q-skeleton type="text" square height="45px" />
<q-skeleton type="text" square height="18px" />
<q-skeleton type="text" square height="18px" />
<q-skeleton type="text" square height="18px" />
<QSkeleton type="text" square height="45px" />
<QSkeleton type="text" square height="18px" />
<QSkeleton type="text" square height="18px" />
<QSkeleton type="text" square height="18px" />
</div>
<q-card-actions>
<q-skeleton size="40px" />
<q-skeleton size="40px" />
<q-skeleton size="40px" />
<q-skeleton size="40px" />
<q-skeleton size="40px" />
</q-card-actions>
<QCardActions>
<QSkeleton size="40px" />
<QSkeleton size="40px" />
<QSkeleton size="40px" />
<QSkeleton size="40px" />
<QSkeleton size="40px" />
</QCardActions>
</div>
</template>

View File

@ -2,31 +2,31 @@
<div class="q-pa-md">
<div class="row q-gutter-md q-mb-md">
<div class="col">
<q-skeleton type="QInput" square />
<QSkeleton type="QInput" square />
</div>
<div class="col">
<q-skeleton type="QInput" square />
<QSkeleton type="QInput" square />
</div>
</div>
<div class="row q-gutter-md q-mb-md">
<div class="col">
<q-skeleton type="QInput" square />
<QSkeleton type="QInput" square />
</div>
<div class="col">
<q-skeleton type="QInput" square />
<QSkeleton type="QInput" square />
</div>
</div>
<div class="row q-gutter-md q-mb-md">
<div class="col">
<q-skeleton type="QInput" square />
<QSkeleton type="QInput" square />
</div>
<div class="col">
<q-skeleton type="QInput" square />
<QSkeleton type="QInput" square />
</div>
</div>
<div class="row q-gutter-md">
<q-skeleton type="QBtn" />
<q-skeleton type="QBtn" />
<QSkeleton type="QBtn" />
<QSkeleton type="QBtn" />
</div>
</div>
</template>

View File

@ -1,47 +1,47 @@
<template>
<div class="header bg-primary q-pa-sm q-mb-md">
<q-skeleton type="rect" square />
<QSkeleton type="rect" square />
</div>
<div class="row q-pa-md q-col-gutter-md q-mb-md">
<div class="col">
<q-skeleton type="rect" class="q-mb-md" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<QSkeleton type="rect" class="q-mb-md" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
</div>
<div class="col">
<q-skeleton type="rect" class="q-mb-md" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<QSkeleton type="rect" class="q-mb-md" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
</div>
<div class="col">
<q-skeleton type="rect" class="q-mb-md" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<QSkeleton type="rect" class="q-mb-md" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
</div>
<div class="col">
<q-skeleton type="rect" class="q-mb-md" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<QSkeleton type="rect" class="q-mb-md" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
</div>
<div class="col">
<q-skeleton type="rect" class="q-mb-md" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<q-skeleton type="text" square />
<QSkeleton type="rect" class="q-mb-md" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
</div>
</div>
</template>

View File

@ -1,20 +0,0 @@
<script setup>
import { nextTick, ref } from 'vue';
const $props = defineProps({
to: {
type: String,
required: true,
},
});
const isHeaderMounted = ref(false);
nextTick(() => {
isHeaderMounted.value = document.querySelector($props.to) !== null;
});
</script>
<template>
<teleport v-if="isHeaderMounted" :to="$props.to">
<slot />
</teleport>
</template>

View File

@ -0,0 +1,102 @@
<script setup>
import { ref } from 'vue';
import { useDialogPluginComponent } from 'quasar';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
icon: {
type: String,
default: null,
},
title: {
type: String,
default: null,
},
message: {
type: String,
default: null,
},
data: {
type: Object,
required: false,
default: null,
},
promise: {
type: Function,
required: false,
default: null,
},
});
defineEmits(['confirm', ...useDialogPluginComponent.emits]);
const { dialogRef, onDialogOK } = useDialogPluginComponent();
const title = props.title || t('Confirm');
const message = props.message || t('Are you sure you want to continue?');
const isLoading = ref(false);
async function confirm() {
isLoading.value = true;
if (props.promise) {
try {
await props.promise(props.data);
} finally {
isLoading.value = false;
}
}
onDialogOK(props.data);
}
</script>
<template>
<QDialog ref="dialogRef" persistent>
<QCard class="q-pa-sm">
<QCardSection class="row items-center q-pb-none">
<QAvatar
:icon="icon"
color="primary"
text-color="white"
size="xl"
v-if="icon"
/>
<span class="text-h6 text-grey">{{ title }}</span>
<QSpace />
<QBtn icon="close" :disable="isLoading" flat round dense v-close-popup />
</QCardSection>
<QCardSection class="row items-center">
<span v-html="message"></span>
</QCardSection>
<QCardActions align="right">
<QBtn
:label="t('globals.cancel')"
color="primary"
:disable="isLoading"
flat
v-close-popup
/>
<QBtn
:label="t('globals.confirm')"
color="primary"
:loading="isLoading"
@click="confirm()"
unelevated
autofocus
/>
</QCardActions>
</QCard>
</QDialog>
</template>
<style lang="scss" scoped>
.q-card {
min-width: 350px;
}
</style>
<i18n>
es:
Confirm: Confirmar
Are you sure you want to continue?: ¿Seguro que quieres continuar?
</i18n>

View File

@ -0,0 +1,203 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useArrayData } from 'composables/useArrayData';
import toDate from 'filters/toDate';
const { t } = useI18n();
const props = defineProps({
dataKey: {
type: String,
required: true,
},
searchButton: {
type: Boolean,
required: false,
default: false,
},
params: {
type: Object,
required: false,
default: null,
},
});
const emit = defineEmits(['refresh', 'clear']);
const arrayData = useArrayData(props.dataKey);
const store = arrayData.store;
const userParams = ref({});
onMounted(() => {
if (props.params) userParams.value = props.params;
const params = store.userParams;
if (Object.keys(params).length > 0) {
userParams.value = Object.assign({}, params);
}
});
const isLoading = ref(false);
async function search() {
const params = userParams.value;
for (const param in params) {
if (params[param] === '' || params[param] === null) {
delete userParams.value[param];
delete store.userParams[param];
}
}
isLoading.value = true;
await arrayData.addFilter({ params });
isLoading.value = false;
}
async function reload() {
isLoading.value = true;
await arrayData.fetch({ append: false });
isLoading.value = false;
emit('refresh');
}
async function clearFilters() {
userParams.value = {};
isLoading.value = true;
await arrayData.applyFilter({ params: {} });
isLoading.value = false;
emit('clear');
}
const tags = computed(() => {
const params = [];
for (const param in store.userParams) {
params.push({
label: param,
value: store.userParams[param],
});
}
return params;
});
async function remove(key) {
delete userParams.value[key];
delete store.userParams[key];
await search();
}
function formatValue(value) {
if (typeof value === 'boolean') {
return value ? t('Yes') : t('No');
}
if (isNaN(value) && !isNaN(Date.parse(value))) {
return toDate(value);
}
return `"${value}"`;
}
</script>
<template>
<QForm @submit="search">
<QList dense>
<QItem class="q-mt-xs">
<QItemSection top>
<QItemLabel header lines="1" class="text-uppercase q-py-xs q-px-none">
{{ t('Applied filters') }}
</QItemLabel>
</QItemSection>
<QItemSection top side>
<div class="q-gutter-xs">
<QBtn
@click="clearFilters"
icon="filter_list_off"
color="primary"
size="sm"
padding="none"
round
flat
dense
>
<QTooltip>{{ t('Remove filters') }}</QTooltip>
</QBtn>
<QBtn
@click="reload"
icon="refresh"
color="primary"
size="sm"
padding="none"
round
flat
dense
>
<QTooltip>{{ t('Refresh') }}</QTooltip>
</QBtn>
</div>
</QItemSection>
</QItem>
<QItem>
<div
v-if="tags.length === 0"
class="text-grey font-xs text-center full-width"
>
{{ t(`No filters applied`) }}
</div>
<div>
<QChip
v-for="chip of tags"
:key="chip.label"
@remove="remove(chip.label)"
icon="label"
color="primary"
class="text-dark"
size="sm"
removable
>
<slot name="tags" :tag="chip" :format-fn="formatValue">
<div class="q-gutter-x-xs">
<strong>{{ chip.label }}:</strong>
<span>"{{ chip.value }}"</span>
</div>
</slot>
</QChip>
</div>
</QItem>
<QSeparator />
<template v-if="props.searchButton">
<QItem>
<QItemSection class="q-py-sm">
<QBtn
:label="t('Search')"
type="submit"
color="primary"
class="full-width"
icon="search"
unelevated
rounded
dense
/>
</QItemSection>
</QItem>
<QSeparator />
</template>
</QList>
<slot name="body" :params="userParams" :search-fn="search"></slot>
</QForm>
<QInnerLoading
:showing="isLoading"
:label="t('globals.pleaseWait')"
color="primary"
/>
</template>
<i18n>
es:
No filters applied: No se han aplicado filtros
Applied filters: Filtros aplicados
Remove filters: Eliminar filtros
Refresh: Refrescar
Search: Buscar
Yes: Si
No: No
</i18n>

View File

@ -0,0 +1,189 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useArrayData } from 'composables/useArrayData';
const { t } = useI18n();
const props = defineProps({
dataKey: {
type: String,
required: true,
},
autoLoad: {
type: Boolean,
default: false,
},
data: {
type: Array,
default: null,
},
url: {
type: String,
default: '',
},
filter: {
type: Object,
default: null,
},
where: {
type: Object,
default: null,
},
order: {
type: String,
default: '',
},
limit: {
type: Number,
default: 10,
},
userParams: {
type: Object,
default: null,
},
offset: {
type: Number,
default: 500,
},
});
const emit = defineEmits(['onFetch', 'onPaginate']);
const isLoading = ref(false);
const pagination = ref({
sortBy: props.order,
rowsPerPage: props.limit,
page: 1,
});
const arrayData = useArrayData(props.dataKey, {
url: props.url,
filter: props.filter,
where: props.where,
limit: props.limit,
order: props.order,
userParams: props.userParams,
});
const store = arrayData.store;
onMounted(() => {
if (props.autoLoad) fetch();
});
watch(
() => props.data,
() => {
store.data = props.data;
}
);
async function fetch() {
await arrayData.fetch({ append: false });
if (!arrayData.hasMoreData.value) {
isLoading.value = false;
}
emit('onFetch', store.data);
}
async function paginate() {
const { page, rowsPerPage, sortBy, descending } = pagination.value;
if (!props.url) return;
isLoading.value = true;
await arrayData.loadMore();
if (!arrayData.hasMoreData.value) {
isLoading.value = false;
return;
}
pagination.value.rowsNumber = store.data.length;
pagination.value.page = page;
pagination.value.rowsPerPage = rowsPerPage;
pagination.value.sortBy = sortBy;
pagination.value.descending = descending;
isLoading.value = false;
emit('onFetch', store.data);
emit('onPaginate');
}
async function onLoad(...params) {
if (!store.data) return;
const done = params[1];
if (store.data.length === 0 || !props.url) return done(false);
pagination.value.page = pagination.value.page + 1;
await paginate();
const endOfPages = !arrayData.hasMoreData.value;
done(endOfPages);
}
</script>
<template>
<div>
<div
v-if="!props.autoLoad && !store.data && !isLoading"
class="info-row q-pa-md text-center"
>
<h5>
{{ t('No data to display') }}
</h5>
</div>
<div
v-if="store.data && store.data.length === 0 && !isLoading"
class="info-row q-pa-md text-center"
>
<h5>
{{ t('No results found') }}
</h5>
</div>
<div v-if="props.autoLoad && !store.data" class="card-list q-gutter-y-md">
<QCard class="card" v-for="$index in $props.limit" :key="$index">
<QItem v-ripple class="q-pa-none items-start cursor-pointer q-hoverable">
<QItemSection class="q-pa-md">
<QSkeleton type="rect" class="q-mb-md" square />
<QSkeleton type="text" square />
<QSkeleton type="text" class="q-mb-md" square />
<QSkeleton type="text" square />
<QSkeleton type="text" square />
</QItemSection>
<QSeparator vertical />
<QCardActions vertical class="justify-between">
<QSkeleton type="circle" class="q-mb-md" size="40px" />
<QSkeleton type="circle" class="q-mb-md" size="40px" />
<QSkeleton type="circle" class="q-mb-md" size="40px" />
</QCardActions>
</QItem>
</QCard>
</div>
</div>
<QInfiniteScroll v-if="store.data" @load="onLoad" :offset="offset">
<slot name="body" :rows="store.data"></slot>
<div v-if="isLoading" class="info-row q-pa-md text-center">
<QSpinner color="orange" size="md" />
</div>
</QInfiniteScroll>
</template>
<style lang="scss" scoped>
.info-row {
width: 100%;
h5 {
margin: 0;
}
}
</style>
<i18n>
es:
No data to display: Sin datos que mostrar
No results found: No se han encontrado resultados
</i18n>

View File

@ -0,0 +1,141 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useArrayData } from 'composables/useArrayData';
const props = defineProps({
dataKey: {
type: String,
required: true,
},
label: {
type: String,
required: false,
default: 'Search',
},
info: {
type: String,
required: false,
default: '',
},
redirect: {
type: Boolean,
required: false,
default: true,
},
url: {
type: String,
default: '',
},
filter: {
type: Object,
default: null,
},
where: {
type: Object,
default: null,
},
order: {
type: String,
default: '',
},
limit: {
type: Number,
default: 10,
},
userParams: {
type: Object,
default: null,
},
});
const router = useRouter();
const route = useRoute();
const arrayData = useArrayData(props.dataKey, { ...props });
const store = arrayData.store;
const searchText = ref('');
onMounted(() => {
const params = store.userParams;
if (params && params.search) {
searchText.value = params.search;
}
});
async function search() {
await arrayData.applyFilter({
params: {
search: searchText.value,
},
});
if (!props.redirect) return;
const rows = store.data;
const module = route.matched[1];
if (rows.length === 1) {
const [firstRow] = rows;
await router.push({ path: `/${module.name}/${firstRow.id}` });
} else if (route.matched.length > 3) {
await router.push({ path: `/${module.name}` });
arrayData.updateStateParams();
}
}
</script>
<template>
<QForm @submit="search">
<QInput
id="searchbar"
v-model="searchText"
:placeholder="props.label"
dense
standout
autofocus
>
<template #prepend>
<QIcon name="search" />
</template>
<template #append>
<QIcon
v-if="searchText !== ''"
name="close"
@click="searchText = ''"
class="cursor-pointer"
/>
<QIcon v-if="props.info" name="info" class="cursor-info">
<QTooltip>{{ props.info }}</QTooltip>
</QIcon>
</template>
</QInput>
</QForm>
</template>
<style lang="scss" scoped>
.q-field {
width: 250px;
}
@media screen and (min-width: $breakpoint-sm-max) {
.q-field {
width: 400px;
}
}
.q-field {
transition: width 0.36s;
}
</style>
<style lang="scss">
.cursor-info {
cursor: help;
}
.body--light #searchbar {
.q-field--standout.q-field--highlighted .q-field__control {
background-color: $grey-7;
color: #333;
}
}
</style>

View File

@ -1,43 +0,0 @@
import { describe, expect, it } from '@jest/globals';
import { useNavigation } from '../useNavigation';
const navigation = useNavigation();
describe('useNavigation', () => {
it('should return the routes for all modules', async () => {
expect(navigation.modules.value.length).toBeGreaterThan(1);
});
it('should return a proper formated object without the children property', async () => {
const expectedMenuItem = {
stateName: 'Dashboard',
name: 'dashboard',
roles: [],
icon: 'dashboard',
title: 'dashboard'
}
const firstMenuItem = navigation.modules.value[0]
expect(firstMenuItem.children).toBeUndefined();
expect(firstMenuItem).toEqual(expect.objectContaining(expectedMenuItem));
});
it('should return a proper formated object with two child items', async () => {
const expectedMenuItem = [{
name: 'CustomerList',
title: 'list',
icon: 'view_list',
stateName: 'CustomerList'
},
{
name: 'CustomerCreate',
title: 'createCustomer',
icon: 'vn:addperson',
stateName: 'CustomerCreate',
roles: ['developer']
}];
const secondMenuItem = navigation.modules.value[1]
expect(secondMenuItem.children).toEqual(expect.arrayContaining(expectedMenuItem));
expect(secondMenuItem.children.length).toEqual(2)
});
});

19
src/composables/getUrl.js Normal file
View File

@ -0,0 +1,19 @@
import axios from 'axios';
export async function getUrl(route, appName = 'salix') {
let url;
const env = process.env.NODE_ENV === 'development' ? 'dev' : process.env.NODE_ENV;
const filter = {
where: {and: [
{appName: appName},
{environment: env}
]}
};
await axios.get('Urls/findOne', {params: {filter}})
.then(res => {
url = res.data.url + route;
});
return url;
}

View File

@ -0,0 +1,172 @@
import { onMounted, ref, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import axios from 'axios';
import { useArrayDataStore } from 'stores/useArrayDataStore';
const arrayDataStore = useArrayDataStore();
export function useArrayData(key, userOptions) {
if (!key) throw new Error('ArrayData: A key is required to use this composable');
if (!arrayDataStore.get(key)) {
arrayDataStore.set(key);
}
const store = arrayDataStore.get(key);
const hasMoreData = ref(false);
const router = useRouter();
const route = useRoute();
let canceller = null;
const page = ref(1);
onMounted(() => {
setOptions();
const query = route.query;
if (query.params) {
store.userParams = JSON.parse(query.params);
}
});
function setOptions() {
const allowedOptions = [
'url',
'filter',
'where',
'order',
'limit',
'skip',
'userParams',
'userFilter'
];
if (typeof userOptions === 'object') {
for (const option in userOptions) {
const isEmpty = userOptions[option] == null || userOptions[option] == ''
if (isEmpty || !allowedOptions.includes(option)) continue;
if (Object.prototype.hasOwnProperty.call(store, option)) {
store[option] = userOptions[option];
}
}
}
}
async function fetch({ append = false }) {
if (!store.url) return;
cancelRequest();
canceller = new AbortController();
const filter = {
order: store.order,
limit: store.limit,
skip: store.skip,
};
Object.assign(filter, store.userFilter);
Object.assign(store.filter, filter);
const params = {
filter: JSON.stringify(store.filter),
};
Object.assign(params, store.userParams);
store.isLoading = true
const response = await axios.get(store.url, {
signal: canceller.signal,
params,
});
const { limit } = filter;
hasMoreData.value = response.data.length === limit;
if (append === true) {
if (!store.data) store.data = [];
for (const row of response.data) store.data.push(row);
}
if (append === false) {
store.data = response.data;
updateStateParams();
}
store.isLoading = false
canceller = null;
}
function destroy() {
if (arrayDataStore.get(key)) {
arrayDataStore.clear(key);
}
}
function cancelRequest() {
if (canceller) {
canceller.abort();
canceller = null;
}
}
async function applyFilter({ filter, params }) {
if (filter) store.userFilter = filter;
if (params) store.userParams = Object.assign({}, params);
await fetch({ append: false });
}
async function addFilter({ filter, params }) {
if (filter) store.userFilter = Object.assign(store.userFilter, filter);
if (params) store.userParams = Object.assign(store.userParams, params);
await fetch({ append: false });
}
async function loadMore() {
if (!hasMoreData.value) return;
store.skip = store.limit * page.value;
page.value += 1;
await fetch({ append: true });
}
async function refresh() {
await fetch({ append: false });
}
function updateStateParams() {
const query = {};
if (store.order) query.order = store.order;
if (store.limit) query.limit = store.limit;
if (store.skip) query.skip = store.skip;
if (store.userParams && Object.keys(store.userParams).length !== 0)
query.params = JSON.stringify(store.userParams);
router.replace({
path: route.path,
query: query,
});
}
const totalRows = computed(() => store.data && store.data.length || 0);
const isLoading = computed(() => store.isLoading || false)
return {
fetch,
applyFilter,
addFilter,
refresh,
destroy,
loadMore,
store,
hasMoreData,
totalRows,
updateStateParams,
isLoading
};
}

View File

@ -1,92 +0,0 @@
import routes from 'src/router/routes';
import { ref } from 'vue';
import axios from 'axios';
const favorites = ref([]);
const modules = ref([]);
const mainRoute = routes.find((route) => route.path === '/');
const moduleRoutes = (mainRoute && mainRoute.children) || [];
for (const route of moduleRoutes) {
const module = {
stateName: route.name,
name: route.name.toLowerCase(),
roles: [],
};
if (route.meta) {
Object.assign(module, route.meta);
}
if (route.children && route.children.length) {
const [moduleMain] = route.children;
const routes = moduleMain.children;
module.children = routes.map((route) => {
const submodule = {
stateName: route.name,
name: route.name,
};
Object.assign(submodule, route.meta);
return submodule;
});
}
modules.value.push(module);
}
export function useNavigation() {
const salixModules = {
customer: 'Clients',
claim: 'Claims',
entry: 'Entries',
invoiceIn: 'Invoices In',
invoiceOut: 'Invoices Out',
item: 'Items',
monitor: 'Monitors',
order: 'Orders',
route: 'Routes',
supplier: 'Suppliers',
ticket: 'Tickets',
travel: 'Travels',
user: 'Users',
worker: 'Workers',
zone: 'Zones',
};
async function fetchFavorites() {
const response = await axios.get('StarredModules/getStarredModules');
const filteredModules = modules.value.filter((module) => {
return response.data.find((element) => element.moduleFk == salixModules[module.name]);
});
return (favorites.value = filteredModules);
}
async function toggleFavorite(moduleName, event) {
if (event.defaultPrevented) return;
event.preventDefault();
event.stopPropagation();
const params = { moduleName: salixModules[moduleName] };
const query = 'StarredModules/toggleStarredModule';
await axios.post(query, params);
updateFavorites(moduleName);
}
function updateFavorites(name) {
if (!favorites.value.find((module) => module.name == name)) {
const newStarreModule = modules.value.find((module) => module.name == name);
favorites.value.push(newStarreModule);
} else {
const moduleToRemove = favorites.value.find((module) => module.name == name);
favorites.value.splice(favorites.value.indexOf(moduleToRemove), 1);
}
}
return { modules, favorites, toggleFavorite, fetchFavorites, updateFavorites };
}

View File

@ -5,16 +5,15 @@ export function useRole() {
const state = useState();
async function fetch() {
const { data } = await axios.get('Accounts/acl');
const roles = data.roles.map(userRoles => userRoles.role.name);
const { data } = await axios.get('VnUsers/acl');
const roles = data.roles.map((userRoles) => userRoles.role.name);
const userData = {
id: data.user.id,
name: data.user.name,
nickname: data.user.nickname,
lang: data.user.lang || 'es',
darkMode: data.user.userConfig.darkMode,
}
};
state.setUser(userData);
state.setRoles(roles);
}
@ -32,6 +31,6 @@ export function useRole() {
return {
fetch,
hasAny,
state
state,
};
}

View File

@ -1,5 +1,7 @@
import { useState } from './useState';
import { useRole } from './useRole';
import { useUserConfig } from './useUserConfig';
export function useSession() {
function getToken() {
@ -36,11 +38,10 @@ export function useSession() {
}
async function login(token, keepLogin) {
const { fetch } = useRole();
setToken({ token, keepLogin });
await fetch();
await useRole().fetch();
await useUserConfig().fetch();
}
function isLoggedIn() {

View File

@ -0,0 +1,17 @@
import axios from 'axios';
import { useState } from './useState';
export function useUserConfig() {
const state = useState();
async function fetch() {
const { data } = await axios.get('UserConfigs/getUserConfig');
const user = state.getUser().value;
user.darkMode = data.darkMode;
state.setUser(user);
}
return {
fetch,
};
}

View File

@ -1,112 +0,0 @@
import * as validator from 'validator';
export const validators = {
presence: ($translate, value) => {
if (validator.isEmpty(value ? String(value) : ''))
throw new Error(_($translate, `Value can't be empty`));
},
absence: ($translate, value) => {
if (!validator.isEmpty(value))
throw new Error(_($translate, `Value should be empty`));
},
length: ($translate, value, conf) => {
let options = {
min: conf.min || conf.is,
max: conf.max || conf.is
};
let val = value ? String(value) : '';
if (!validator.isLength(val, options)) {
if (conf.is) {
throw new Error(_($translate,
`Value should be %s characters long`, [conf.is]));
} else if (conf.min && conf.max) {
throw new Error(_($translate,
`Value should have a length between %s and %s`, [conf.min, conf.max]));
} else if (conf.min) {
throw new Error(_($translate,
`Value should have at least %s characters`, [conf.min]));
} else {
throw new Error(_($translate,
`Value should have at most %s characters`, [conf.max]));
}
}
},
numericality: ($translate, value, conf) => {
if (conf.int) {
if (!validator.isInt(value))
throw new Error(_($translate, `Value should be integer`));
} else if (!validator.isNumeric(value))
throw new Error(_($translate, `Value should be a number`));
},
inclusion: ($translate, value, conf) => {
if (!validator.isIn(value, conf.in))
throw new Error(_($translate, `Invalid value`));
},
exclusion: ($translate, value, conf) => {
if (validator.isIn(value, conf.in))
throw new Error(_($translate, `Invalid value`));
},
format: ($translate, value, conf) => {
if (!validator.matches(value, conf.with))
throw new Error(_($translate, `Invalid value`));
},
custom: ($translate, value, conf) => {
if (!conf.bindedFunction(value))
throw new Error(_($translate, `Invalid value`));
}
};
/**
* Checks if value satisfies a set of validations.
*
* @param {*} value The value
* @param {Array} validations Array with validations
*/
export function validateAll($translate, value, validations) {
for (let conf of validations)
validate($translate, value, conf);
}
/**
* Checks if value satisfies a validation.
*
* @param {*} value The value
* @param {Object} conf The validation configuration
*/
export function validate($translate, value, conf) {
let validator = validators[conf.validation];
try {
let isEmpty = value == null || value === '';
if (isEmpty)
checkNull($translate, value, conf);
if (validator && (!isEmpty || conf.validation == 'presence'))
validator($translate, value, conf);
} catch (e) {
let message = conf.message ? conf.message : e.message;
throw new Error(_($translate, message));
}
}
/**
* Checks if value satisfies a blank or not null validation.
*
* @param {*} value The value
* @param {Object} conf The validation configuration
*/
export function checkNull($translate, value, conf) {
if (conf.allowBlank === false && value === '')
throw new Error(_($translate, `Value can't be blank`));
else if (conf.allowNull === false && value == null)
throw new Error(_($translate, `Value can't be null`));
}
export function _($translate, text, params = []) {
text = $translate.instant(text);
for (let i = 0; i < params.length; i++) {
text = text.replace('%s', params[i]);
}
return text;
}

View File

@ -1,11 +1,32 @@
// app global css in SCSS form
@import './icons.scss';
a {
text-decoration: none;
}
.link {
color: $primary;
cursor: pointer
cursor: pointer;
}
.link:hover {
color: $orange-4;
}
// Removes chrome autofill background
input:-webkit-autofill,
select:-webkit-autofill {
color: $input-text-color !important;
font-family: $typography-font-family;
-webkit-text-fill-color: $input-text-color !important;
-webkit-background-clip: text !important;
background-clip: text !important;
}
body.body--light {
.q-header .q-toolbar {
background-color: white;
color: #555;
}
}

Binary file not shown.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -16,9 +16,16 @@ $primary: #ff9800;
$secondary: #26a69a;
$accent: #9c27b0;
$dark: #1d1d1d;
$positive: #21ba45;
$negative: #c10015;
$info: #31ccec;
$warning: #f2c037;
$color-spacer-light: rgba(255, 255, 255, 0.12);
$color-spacer: rgba(255, 255, 255, 0.3);
$border-thin-light: 1px solid $color-spacer-light;
$dark-shadow-color: #000;
$dark: #292929;
$layout-shadow-dark: 0 0 10px 2px rgba(0, 0, 0, 0.2), 0 0px 10px rgba(0, 0, 0, 0.24);
$spacing-md: 16px;

View File

@ -0,0 +1,4 @@
export default function (value) {
if (value == null || value === '') return '-';
return value;
}

View File

@ -1,11 +1,17 @@
import toLowerCase from './toLowerCase';
import toDate from './toDate';
import toDateString from './toDateString';
import toCurrency from './toCurrency';
import toPercentage from './toPercentage';
import toLowerCamel from './toLowerCamel';
import dashIfEmpty from './dashIfEmpty';
export {
toLowerCase,
toLowerCamel,
toDate,
toDateString,
toCurrency,
toPercentage,
dashIfEmpty,
};

View File

@ -3,10 +3,14 @@ import { useI18n } from 'vue-i18n';
export default function (value, options = {}) {
if (!value) return;
if (!options.dateStyle) options.dateStyle = 'short';
if (!options.dateStyle && !options.timeStyle) {
options.day = '2-digit';
options.month = '2-digit';
options.year = 'numeric';
}
const { locale } = useI18n();
const date = new Date(value);
return new Intl.DateTimeFormat(locale.value, options).format(date)
return new Intl.DateTimeFormat(locale.value, options).format(date);
}

View File

@ -0,0 +1,10 @@
export default function toDateString(date) {
let day = date.getDate();
let month = date.getMonth() + 1;
let year = date.getFullYear();
if (day < 10) day = `0${day}`;
if (month < 10) month = `0${month}`;
return `${year}-${month}-${day}`
}

View File

@ -0,0 +1,5 @@
export default function toLowerCamel(value) {
if (!value) return;
if (typeof (value) !== 'string') return value;
return value.charAt(0).toLowerCase() + value.slice(1);
}

View File

@ -9,7 +9,7 @@ export default {
backToDashboard: 'Return to dashboard',
notifications: 'Notifications',
userPanel: 'User panel',
favoriteModules: 'Favorite modules',
pinnedModules: 'Pinned modules',
darkMode: 'Dark mode',
logOut: 'Log out',
dataSaved: 'Data saved',
@ -30,6 +30,7 @@ export default {
rowAdded: 'Row added',
rowRemoved: 'Row removed',
pleaseWait: 'Please wait...',
noPinnedModules: 'You have dont have any pinned modules',
},
moduleIndex: {
allModules: 'All modules',
@ -59,6 +60,7 @@ export default {
pageTitles: {
customers: 'Customers',
list: 'List',
webPayments: 'Web Payments',
createCustomer: 'Create customer',
summary: 'Summary',
basicData: 'Basic Data',
@ -187,6 +189,49 @@ export default {
selectVideo: 'Select video:',
notFound: 'No videos available',
},
summary: {
state: 'State',
salesPerson: 'Sales person',
agency: 'Agency',
zone: 'Zone',
warehouse: 'Warehouse',
route: 'Route',
invoice: 'Invoice',
shipped: 'Shipped',
landed: 'Landed',
packages: 'Packages',
consigneePhone: 'Consignee phone',
consigneeMobile: 'Consignee mobile',
clientPhone: 'Client phone',
clientMobile: 'Client mobile',
consignee: 'Consignee',
subtotal: 'Subtotal',
vat: 'VAT',
total: 'Total',
saleLines: 'Line items',
item: 'Item',
visible: 'Visible',
available: 'Available',
quantity: 'Quantity',
description: 'Description',
price: 'Price',
discount: 'Discount',
amount: 'Amount',
packing: 'Packing',
hasComponentLack: 'Component lack',
itemShortage: 'Not visible',
claim: 'Claim',
reserved: 'Reserved',
created: 'Created',
package: 'Package',
taxClass: 'Tax class',
services: 'Services',
changeState: 'Change state',
requester: 'Requester',
atender: 'Atender',
request: 'Request',
goTo: 'Go to',
},
},
claim: {
pageTitles: {
@ -196,7 +241,10 @@ export default {
rmaList: 'RMA',
summary: 'Summary',
basicData: 'Basic Data',
lines: 'Lines',
rma: 'RMA',
photos: 'Photos',
log: 'Audit logs',
},
list: {
customer: 'Customer',
@ -240,6 +288,14 @@ export default {
responsibility: 'Responsibility',
company: 'Company',
person: 'Employee/Customer',
notes: 'Notes',
photos: 'Photos',
development: 'Development',
reason: 'Reason',
result: 'Result',
responsible: 'Responsible',
worker: 'Worker',
redelivery: 'Redelivery'
},
basicData: {
customer: 'Customer',
@ -250,6 +306,145 @@ export default {
picked: 'Picked',
returnOfMaterial: 'Return of material authorization (RMA)',
},
photo: {
fileDescription: 'Claim id {claimId} from client {clientName} id {clientId}',
noData: 'There are no images/videos, click here or drag and drop the file',
dragDrop: 'Drag and drop it here',
},
},
invoiceOut: {
pageTitles: {
invoiceOuts: 'Invoices Out',
list: 'List',
createInvoiceOut: 'Create invoice out',
summary: 'Summary',
basicData: 'Basic Data',
},
list: {
ref: 'Reference',
issued: 'Issued',
amount: 'Amount',
client: 'Client',
created: 'Created',
company: 'Company',
dued: 'Due date',
},
card: {
issued: 'Issued',
amount: 'Amount',
client: 'Client',
company: 'Company',
customerCard: 'Customer card',
ticketList: 'Ticket List',
},
summary: {
issued: 'Issued',
created: 'Created',
dued: 'Due',
booked: 'Booked',
company: 'Company',
taxBreakdown: 'Tax breakdown',
type: 'Type',
taxableBase: 'Taxable base',
rate: 'Rate',
fee: 'Fee',
tickets: 'Tickets',
ticketId: 'Ticket id',
nickname: 'Alias',
shipped: 'Shipped',
totalWithVat: 'Amount',
},
},
worker: {
pageTitles: {
workers: 'Workers',
list: 'List',
basicData: 'Basic data',
summary: 'Summary',
notifications: 'Notifications',
},
list: {
name: 'Name',
email: 'Email',
phone: 'Phone',
mobile: 'Mobile',
active: 'Active',
department: 'Department',
schedule: 'Schedule',
},
card: {
workerId: 'Worker ID',
name: 'Name',
email: 'Email',
phone: 'Phone',
mobile: 'Mobile',
active: 'Active',
warehouse: 'Warehouse',
agency: 'Agency',
salesPerson: 'Sales person',
},
summary: {
basicData: 'Basic data',
boss: 'Boss',
phoneExtension: 'Phone extension',
entPhone: 'Enterprise phone',
personalPhone: 'Personal phone',
noBoss: 'No boss',
userData: 'User data',
userId: 'User ID',
role: 'Role',
sipExtension: 'Extension',
},
notificationsManager: {
activeNotifications: 'Active notifications',
availableNotifications: 'Available notifications',
subscribed: 'Subscribed to the notification',
unsubscribed: 'Unsubscribed from the notification',
},
imageNotFound: 'Image not found',
},
wagon: {
pageTitles: {
wagons: 'Wagons',
wagonsList: 'Wagons List',
wagonCreate: 'Create wagon',
wagonEdit: 'Edit wagon',
typesList: 'Types List',
typeCreate: 'Create type',
typeEdit: 'Edit type'
},
type: {
name: 'Name',
submit: 'Submit',
reset: 'Reset',
trayColor: 'Tray color',
removeItem: 'Wagon type removed successfully',
},
list: {
plate: 'Plate',
volume: 'Volume',
type: 'Type',
remove: 'Remove',
removeItem: 'Wagon removed successfully',
},
create: {
plate: 'Plate',
volume: 'Volume',
type: 'Type',
label: 'Label'
},
warnings: {
noData: 'No data available',
nameNotEmpty: 'Name can not be empty',
labelNotEmpty: 'Label can not be empty',
plateNotEmpty: 'Plate can not be empty',
volumeNotEmpty: 'Volume can not be empty',
typeNotEmpty: 'Type can not be empty',
maxTrays: 'You have reached the max number of trays',
minHeightBetweenTrays: 'The minimum height between trays is ',
maxWagonHeight: 'The maximum height of the wagon is ',
uncompleteTrays: 'There are incomplete trays',
}
},
route: {
pageTitles: {
@ -300,7 +495,6 @@ export default {
logOut: 'Log Out',
},
smartCard: {
noData: 'No data to display',
openCard: 'View card',
openSummary: 'Open summary',
viewDescription: 'View description',
@ -310,5 +504,9 @@ export default {
summary: 'Summary',
moreOptions: 'More options',
},
leftMenu: {
addToPinned: 'Add to pinned',
removeFromPinned: 'Remove from pinned',
},
},
};

View File

@ -9,11 +9,11 @@ export default {
backToDashboard: 'Volver al tablón',
notifications: 'Notificaciones',
userPanel: 'Panel de usuario',
favoriteModules: 'Módulos favoritos',
pinnedModules: 'Módulos fijados',
darkMode: 'Modo oscuro',
logOut: 'Cerrar sesión',
dataSaved: 'Datos guardados',
dataDeleted: 'Data deleted',
dataDeleted: 'Datos eliminados',
add: 'Añadir',
create: 'Crear',
save: 'Guardar',
@ -30,6 +30,7 @@ export default {
rowAdded: 'Fila añadida',
rowRemoved: 'Fila eliminada',
pleaseWait: 'Por favor, espera...',
noPinnedModules: 'No has fijado ningún módulo',
},
moduleIndex: {
allModules: 'Todos los módulos',
@ -59,9 +60,10 @@ export default {
pageTitles: {
customers: 'Clientes',
list: 'Listado',
webPayments: 'Pagos Web',
createCustomer: 'Crear cliente',
summary: 'Resumen',
basicData: 'Datos básicos',
summary: 'Resumen'
},
list: {
phone: 'Teléfono',
@ -186,6 +188,49 @@ export default {
selectVideo: 'Seleccionar vídeo:',
notFound: 'No hay vídeos disponibles',
},
summary: {
state: 'Estado',
salesPerson: 'Comercial',
agency: 'Agencia',
zone: 'Zona',
warehouse: 'Almacén',
route: 'Ruta',
invoice: 'Factura',
shipped: 'Enviado',
landed: 'Entregado',
packages: 'Bultos',
consigneePhone: 'Tel. consignatario',
consigneeMobile: 'Móv. consignatario',
clientPhone: 'Tel. cliente',
clientMobile: 'Móv. cliente',
consignee: 'Consignatario',
subtotal: 'Subtotal',
vat: 'IVA',
total: 'Total',
saleLines: 'Líneas del pedido',
item: 'Artículo',
visible: 'Visible',
available: 'Disponible',
quantity: 'Cantidad',
description: 'Descripción',
price: 'Precio',
discount: 'Descuento',
amount: 'Importe',
packing: 'Encajado',
hasComponentLack: 'Faltan componentes',
itemShortage: 'No visible',
claim: 'Reclamación',
reserved: 'Reservado',
created: 'Fecha creación',
package: 'Embalaje',
taxClass: 'Tipo IVA',
services: 'Servicios',
changeState: 'Cambiar estado',
requester: 'Solicitante',
atender: 'Comprador',
request: 'Petición de compra',
goTo: 'Ir a',
},
},
claim: {
pageTitles: {
@ -195,7 +240,10 @@ export default {
rmaList: 'RMA',
summary: 'Resumen',
basicData: 'Datos básicos',
lines: 'Líneas',
rma: 'RMA',
photos: 'Fotos',
log: 'Registros de auditoría',
},
list: {
customer: 'Cliente',
@ -239,6 +287,14 @@ export default {
responsibility: 'Responsabilidad',
company: 'Empresa',
person: 'Comercial/Cliente',
notes: 'Observaciones',
photos: 'Fotos',
development: 'Trazabilidad',
reason: 'Motivo',
result: 'Consecuencias',
responsible: 'Responsable',
worker: 'Trabajador',
redelivery: 'Devolución'
},
basicData: {
customer: 'Cliente',
@ -249,6 +305,146 @@ export default {
picked: 'Recogida',
returnOfMaterial: 'Autorización de retorno de materiales (RMA)',
},
photo: {
fileDescription:
'Reclamacion ID {claimId} del cliente {clientName} id {clientId}',
noData: 'No hay imágenes/videos, haz click aquí o arrastra y suelta el archivo',
dragDrop: 'Arrástralo y sueltalo aquí',
},
},
invoiceOut: {
pageTitles: {
invoiceOuts: 'Fact. emitidas',
list: 'Listado',
createInvoiceOut: 'Crear fact. emitida',
summary: 'Resumen',
basicData: 'Datos básicos',
},
list: {
ref: 'Referencia',
issued: 'Fecha emisión',
amount: 'Importe',
client: 'Cliente',
created: 'Fecha creación',
company: 'Empresa',
dued: 'Fecha vencimineto',
},
card: {
issued: 'Fecha emisión',
amount: 'Importe',
client: 'Cliente',
company: 'Empresa',
customerCard: 'Ficha del cliente',
ticketList: 'Listado de tickets',
},
summary: {
issued: 'Fecha',
created: 'Fecha creación',
dued: 'Vencimiento',
booked: 'Contabilizada',
company: 'Empresa',
taxBreakdown: 'Desglose impositivo',
type: 'Tipo',
taxableBase: 'Base imp.',
rate: 'Tarifa',
fee: 'Cuota',
tickets: 'Tickets',
ticketId: 'Id ticket',
nickname: 'Alias',
shipped: 'F. envío',
totalWithVat: 'Importe',
},
},
worker: {
pageTitles: {
workers: 'Trabajadores',
list: 'Listado',
basicData: 'Datos básicos',
summary: 'Resumen',
notifications: 'Notificaciones',
},
list: {
name: 'Nombre',
email: 'Email',
phone: 'Teléfono',
mobile: 'Móvil',
active: 'Activo',
department: 'Departamento',
schedule: 'Horario',
},
card: {
workerId: 'ID Trabajador',
name: 'Nombre',
email: 'Email',
phone: 'Teléfono',
mobile: 'Móvil',
active: 'Activo',
warehouse: 'Almacén',
agency: 'Empresa',
salesPerson: 'Comercial',
},
summary: {
basicData: 'Datos básicos',
boss: 'Jefe',
phoneExtension: 'Extensión de teléfono',
entPhone: 'Teléfono de empresa',
personalPhone: 'Teléfono personal',
noBoss: 'Sin jefe',
userData: 'Datos de usuario',
userId: 'ID del usuario',
role: 'Rol',
sipExtension: 'Extensión',
},
notificationsManager: {
activeNotifications: 'Notificaciones activas',
availableNotifications: 'Notificaciones disponibles',
subscribed: 'Se ha suscrito a la notificación',
unsubscribed: 'Se ha dado de baja de la notificación',
},
imageNotFound: 'No se ha encontrado la imagen',
},
wagon: {
pageTitles: {
wagons: 'Vagones',
wagonsList: 'Listado vagones',
wagonCreate: 'Crear tipo',
wagonEdit: 'Editar tipo',
typesList: 'Listado tipos',
typeCreate: 'Crear tipo',
typeEdit: 'Editar tipo'
},
type: {
name: 'Nombre',
submit: 'Guardar',
reset: 'Deshacer cambios',
trayColor: 'Color de la bandeja',
removeItem: 'Tipo de vagón borrado correctamente',
},
list: {
plate: 'Matrícula',
volume: 'Volumen',
type: 'Tipo',
remove: 'Borrar',
removeItem: 'Vagón borrado correctamente',
},
create: {
plate: 'Matrícula',
volume: 'Volumen',
type: 'Tipo',
label: 'Etiqueta',
},
warnings: {
noData: 'Sin datos disponibles',
nameNotEmpty: 'El nombre no puede estar vacío',
labelNotEmpty: 'La etiqueta no puede estar vacía',
plateNotEmpty: 'La matrícula no puede estar vacía',
volumeNotEmpty: 'El volumen no puede estar vacío',
typeNotEmpty: 'El tipo no puede estar vacío',
maxTrays: 'Has alcanzado el número máximo de bandejas',
minHeightBetweenTrays: 'La distancia mínima entre bandejas es ',
maxWagonHeight: 'La altura máxima del vagón es ',
uncompleteTrays: 'Hay bandejas sin completar',
}
},
route: {
pageTitles: {
@ -299,7 +495,6 @@ export default {
logOut: 'Cerrar sesión',
},
smartCard: {
noData: 'Sin datos que mostrar',
openCard: 'Ver ficha',
openSummary: 'Abrir detalles',
viewDescription: 'Ver descripción',
@ -309,5 +504,9 @@ export default {
summary: 'Resumen',
moreOptions: 'Más opciones',
},
leftMenu: {
addToPinned: 'Añadir a fijados',
removeFromPinned: 'Eliminar de fijados',
},
},
};

View File

@ -1,16 +1,16 @@
<script setup>
import { useQuasar } from 'quasar';
import Navbar from 'components/Navbar.vue';
import Navbar from 'src/components/NavBar.vue';
const quasar = useQuasar();
</script>
<template>
<q-layout view="hHh LpR fFf">
<QLayout view="hHh LpR fFf">
<Navbar />
<router-view></router-view>
<q-footer v-if="quasar.platform.is.mobile"></q-footer>
</q-layout>
<RouterView></RouterView>
<QFooter v-if="quasar.platform.is.mobile"></QFooter>
</QLayout>
</template>
<style lang="scss" scoped></style>

View File

@ -73,41 +73,66 @@ const statesFilter = {
};
</script>
<template>
<fetch-data
<FetchData
url="Workers/activeWithInheritedRole"
:filter="{ where: { role: 'salesPerson' } }"
@on-fetch="setWorkers"
auto-load
/>
<fetch-data url="ClaimStates" @on-fetch="setClaimStates" auto-load />
<FetchData url="ClaimStates" @on-fetch="setClaimStates" auto-load />
<div class="container">
<q-card>
<form-model :url="`Claims/${route.params.id}`" :filter="claimFilter" model="claim">
<div class="column items-center">
<QCard>
<FormModel
:url="`Claims/${route.params.id}`"
:filter="claimFilter"
model="claim"
>
<template #form="{ data, validate, filter }">
<div class="row q-gutter-md q-mb-md">
<div class="col">
<q-input v-model="data.client.name" :label="t('claim.basicData.customer')" disable />
<QInput
v-model="data.client.name"
:label="t('claim.basicData.customer')"
disable
/>
</div>
<div class="col">
<q-input v-model="data.created" mask="####-##-##" fill-mask="_" autofocus>
<QInput
v-model="data.created"
mask="####-##-##"
fill-mask="_"
autofocus
>
<template #append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date v-model="data.created" mask="YYYY-MM-DD">
<QIcon name="event" class="cursor-pointer">
<QPopupProxy
cover
transition-show="scale"
transition-hide="scale"
>
<QDate
v-model="data.created"
mask="YYYY-MM-DD"
>
<div class="row items-center justify-end">
<q-btn v-close-popup label="Close" color="primary" flat />
<QBtn
v-close-popup
label="Close"
color="primary"
flat
/>
</div>
</q-date>
</q-popup-proxy>
</q-icon>
</QDate>
</QPopupProxy>
</QIcon>
</template>
</q-input>
</QInput>
</div>
</div>
<div class="row q-gutter-md q-mb-md">
<div class="col">
<q-select
<QSelect
v-model="data.workerFk"
:options="workers"
option-value="id"
@ -116,23 +141,25 @@ const statesFilter = {
:label="t('claim.basicData.assignedTo')"
map-options
use-input
@filter="(value, update) => filter(value, update, workerFilter)"
@filter="
(value, update) => filter(value, update, workerFilter)
"
:rules="validate('claim.claimStateFk')"
:input-debounce="0"
>
<template #before>
<q-avatar color="orange">
<q-img
<QAvatar color="orange">
<QImg
v-if="data.workerFk"
:src="`/api/Images/user/160x160/${data.workerFk}/download?access_token=${token}`"
spinner-color="white"
/>
</q-avatar>
</QAvatar>
</template>
</q-select>
</QSelect>
</div>
<div class="col">
<q-select
<QSelect
v-model="data.claimStateFk"
:options="claimStates"
option-value="id"
@ -141,23 +168,25 @@ const statesFilter = {
:label="t('claim.basicData.state')"
map-options
use-input
@filter="(value, update) => filter(value, update, statesFilter)"
@filter="
(value, update) => filter(value, update, statesFilter)
"
:rules="validate('claim.claimStateFk')"
:input-debounce="0"
>
</q-select>
</QSelect>
</div>
</div>
<div class="row q-gutter-md q-mb-md">
<div class="col">
<q-input
<QInput
v-model="data.packages"
:label="t('claim.basicData.packages')"
:rules="validate('claim.packages')"
/>
</div>
<div class="col">
<q-input
<QInput
v-model="data.rma"
:label="t('claim.basicData.returnOfMaterial')"
:rules="validate('claim.rma')"
@ -166,22 +195,21 @@ const statesFilter = {
</div>
<div class="row q-gutter-md q-mb-md">
<div class="col">
<q-checkbox v-model="data.hasToPickUp" :label="t('claim.basicData.picked')" />
<QCheckbox
v-model="data.hasToPickUp"
:label="t('claim.basicData.picked')"
/>
</div>
</div>
</template>
</form-model>
</q-card>
</FormModel>
</QCard>
</div>
</template>
<style lang="scss" scoped>
.container {
display: flex;
justify-content: center;
}
.q-card {
width: 800px;
width: 100%;
max-width: 60em;
}
</style>

View File

@ -1,64 +1,82 @@
<script setup>
import LeftMenu from 'components/LeftMenu.vue';
import { getUrl } from 'composables/getUrl';
import VnSearchbar from 'src/components/ui/VnSearchbar.vue';
import { useStateStore } from 'stores/useStateStore';
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useState } from 'composables/useState';
import { useRoute } from 'vue-router';
import ClaimDescriptor from './ClaimDescriptor.vue';
const stateStore = useStateStore();
const { t } = useI18n();
const state = useState();
const route = useRoute();
const $props = defineProps({
id: {
type: Number,
required: false,
default: null,
},
});
const entityId = computed(() => {
return $props.id || route.params.id;
});
const claimSections = [
{ name: 'Notes', url: '/note/index', icon: 'draft' },
{ name: 'Development', url: '/development', icon: 'vn:traceability' },
{ name: 'Action', url: '/action', icon: 'vn:actions' },
];
let salixUrl;
onMounted(async () => {
salixUrl = await getUrl(`claim/${entityId.value}`);
});
</script>
<template>
<q-drawer v-model="state.drawer.value" show-if-above :width="256" :breakpoint="500">
<q-scroll-area class="fit">
<claim-descriptor />
<q-separator />
<q-list>
<q-item :to="{ name: 'ClaimBasicData' }" clickable v-ripple>
<q-item-section avatar>
<q-icon name="vn:settings" />
</q-item-section>
<q-item-section>{{ t('claim.pageTitles.basicData') }}</q-item-section>
</q-item>
<q-item :to="{ name: 'ClaimRma' }" clickable v-ripple>
<q-item-section avatar>
<q-icon name="vn:barcode" />
</q-item-section>
<q-item-section>{{ t('claim.pageTitles.rma') }}</q-item-section>
</q-item>
</q-list>
</q-scroll-area>
</q-drawer>
<q-page-container>
<q-page class="q-pa-md">
<router-view></router-view>
</q-page>
</q-page-container>
<Teleport to="#searchbar" v-if="stateStore.isHeaderMounted()">
<VnSearchbar
data-key="ClaimList"
url="Claims/filter"
:label="t('Search claim')"
:info="t('You can search by claim id or customer name')"
/>
</Teleport>
<QDrawer v-model="stateStore.leftDrawer" show-if-above :width="256">
<QScrollArea class="fit">
<ClaimDescriptor />
<QSeparator />
<LeftMenu source="card" />
<QSeparator />
<QList>
<QItem
v-for="section in claimSections"
:key="section.name"
active-class="text-primary"
:href="salixUrl + section.url"
clickable
v-ripple
>
<QItemSection avatar>
<QIcon :name="section.icon" />
</QItemSection>
<QItemSection> {{ t(section.name) }} </QItemSection>
</QItem>
</QList>
</QScrollArea>
</QDrawer>
<QPageContainer>
<QPage class="q-pa-md">
<RouterView></RouterView>
</QPage>
</QPageContainer>
</template>
<style lang="scss">
.q-scrollarea__content {
max-width: 100%;
}
</style>
<style lang="scss" scoped>
.descriptor {
max-width: 256px;
h5 {
margin: 0 15px;
}
.header {
display: flex;
justify-content: space-between;
}
.q-card__actions {
justify-content: center;
}
#descriptor-skeleton .q-card__actions {
justify-content: space-between;
}
}
</style>
<i18n>
es:
Search claim: Buscar reclamación
You can search by claim id or customer name: Puedes buscar por id de la reclamación o nombre del cliente
Details: Detalles
Notes: Notas
Development: Trazabilidad
Action: Acción
</i18n>

View File

@ -1,13 +1,12 @@
<script setup>
import { onMounted, computed, ref } from 'vue';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { toDate } from 'src/filters';
import axios from 'axios';
import TicketDescriptorPopover from 'pages/Ticket/Card/TicketDescriptorPopover.vue';
import TicketDescriptorProxy from 'pages/Ticket/Card/TicketDescriptorProxy.vue';
import ClaimDescriptorMenu from 'pages/Claim/Card/ClaimDescriptorMenu.vue';
import CardDescriptor from 'components/ui/CardDescriptor.vue';
import SkeletonDescriptor from 'components/ui/SkeletonDescriptor.vue';
const $props = defineProps({
id: {
@ -17,10 +16,6 @@ const $props = defineProps({
},
});
onMounted(async () => {
await fetch();
});
const route = useRoute();
const { t } = useI18n();
@ -28,28 +23,21 @@ const entityId = computed(() => {
return $props.id || route.params.id;
});
const claim = ref();
async function fetch() {
const filter = {
include: [
{ relation: 'client' },
{ relation: 'claimState' },
{
relation: 'claimState',
const filter = {
include: [
{ relation: 'client' },
{ relation: 'claimState' },
{
relation: 'claimState',
},
{
relation: 'worker',
scope: {
include: { relation: 'user' },
},
{
relation: 'worker',
scope: {
include: { relation: 'user' },
},
},
],
};
const options = { params: { filter } };
const { data } = await axios.get(`Claims/${entityId.value}`, options);
if (data) claim.value = data;
}
},
],
};
function stateColor(code) {
if (code === 'pending') return 'green';
@ -59,62 +47,77 @@ function stateColor(code) {
</script>
<template>
<skeleton-descriptor v-if="!claim" />
<card-descriptor v-if="claim" module="Claim" :data="claim" :description="claim.client.name">
<template #menu>
<claim-descriptor-menu v-if="claim" :claim="claim" />
<CardDescriptor
ref="descriptor"
:url="`Claims/${entityId}`"
:filter="filter"
module="Claim"
>
<template #menu="{ entity }">
<ClaimDescriptorMenu :claim="entity" />
</template>
<template #body>
<q-list>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('claim.card.created') }}</q-item-label>
<q-item-label>{{ toDate(claim.created) }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('claim.card.state') }}</q-item-label>
<q-item-label>
<q-chip :color="stateColor(claim.claimState.code)" dense>
{{ claim.claimState.description }}
</q-chip>
</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('claim.card.ticketId') }}</q-item-label>
<q-item-label class="link">
{{ claim.ticketFk }}
<q-popup-proxy>
<ticket-descriptor-popover :id="claim.ticketFk" />
</q-popup-proxy>
</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('claim.card.assignedTo') }}</q-item-label>
<q-item-label>{{ claim.worker.user.name }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<template #description="{ entity }">
<span>
{{ entity.client.name }}
<QTooltip>{{ entity.client.name }}</QTooltip>
</span>
</template>
<template #body="{ entity }">
<QList>
<QItem>
<QItemSection>
<QItemLabel caption>{{ t('claim.card.created') }}</QItemLabel>
<QItemLabel>{{ toDate(entity.created) }}</QItemLabel>
</QItemSection>
<QItemSection v-if="entity.claimState">
<QItemLabel caption>{{ t('claim.card.state') }}</QItemLabel>
<QItemLabel>
<QBadge :color="stateColor(entity.claimState.code)" dense>
{{ entity.claimState.description }}
</QBadge>
</QItemLabel>
</QItemSection>
</QItem>
<QItem>
<QItemSection>
<QItemLabel caption>
{{ t('claim.card.ticketId') }}
</QItemLabel>
<QItemLabel>
<span class="link">
{{ entity.ticketFk }}
<q-card-actions>
<q-btn
<TicketDescriptorProxy :id="entity.ticketFk" />
</span>
</QItemLabel>
</QItemSection>
<QItemSection v-if="entity.worker">
<QItemLabel caption>
{{ t('claim.card.assignedTo') }}
</QItemLabel>
<QItemLabel>{{ entity.worker.user.name }}</QItemLabel>
</QItemSection>
</QItem>
</QList>
<QCardActions>
<QBtn
size="md"
icon="vn:client"
color="primary"
:to="{ name: 'CustomerCard', params: { id: claim.clientFk } }"
:to="{ name: 'CustomerCard', params: { id: entity.clientFk } }"
>
<q-tooltip>{{ t('claim.card.customerSummary') }}</q-tooltip>
</q-btn>
<q-btn
<QTooltip>{{ t('claim.card.customerSummary') }}</QTooltip>
</QBtn>
<QBtn
size="md"
icon="vn:ticket"
color="primary"
:to="{ name: 'TicketCard', params: { id: claim.ticketFk } }"
:to="{ name: 'TicketCard', params: { id: entity.ticketFk } }"
>
<q-tooltip>{{ t('claim.card.claimedTicket') }}</q-tooltip>
</q-btn>
</q-card-actions>
<QTooltip>{{ t('claim.card.claimedTicket') }}</QTooltip>
</QBtn>
</QCardActions>
</template>
</card-descriptor>
</CardDescriptor>
</template>

View File

@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { usePrintService } from 'composables/usePrintService';
import SendEmailDialog from 'components/common/SendEmailDialog.vue';
import VnConfirm from 'components/ui/VnConfirm.vue';
const $props = defineProps({
claim: {
@ -33,13 +34,15 @@ function confirmPickupOrder() {
quasar.dialog({
component: SendEmailDialog,
componentProps: {
address: customer.email,
data: {
address: customer.email,
},
send: sendPickupOrder,
},
});
}
function sendPickupOrder(address) {
function sendPickupOrder({ address }) {
const id = claim.value.id;
const customer = claim.value.client;
return sendEmail(`Claims/${id}/claim-pickup-email`, {
@ -48,66 +51,61 @@ function sendPickupOrder(address) {
});
}
const showConfirmDialog = ref(false);
async function deleteClaim() {
function confirmRemove() {
quasar
.dialog({
component: VnConfirm,
componentProps: {
title: t('confirmDeletion'),
message: t('confirmDeletionMessage'),
promise: remove,
},
})
.onOk(async () => await router.push({ name: 'ClaimList' }));
}
async function remove() {
const id = claim.value.id;
await axios.delete(`Claims/${id}`);
quasar.notify({
message: t('globals.dataDeleted'),
type: 'positive',
icon: 'check',
});
await router.push({ name: 'ClaimList' });
}
</script>
<template>
<q-item v-ripple clickable>
<q-item-section avatar>
<q-icon name="summarize" />
</q-item-section>
<q-item-section>{{ t('pickupOrder') }}</q-item-section>
<q-item-section side>
<q-icon name="keyboard_arrow_right" />
</q-item-section>
<q-menu anchor="top end" self="top start" auto-close>
<q-list>
<q-item @click="openPickupOrder" v-ripple clickable>
<q-item-section avatar>
<q-icon name="picture_as_pdf" />
</q-item-section>
<q-item-section>{{ t('openPickupOrder') }}</q-item-section>
</q-item>
<q-item @click="confirmPickupOrder" v-ripple clickable>
<q-item-section avatar>
<q-icon name="send" />
</q-item-section>
<q-item-section>{{ t('sendPickupOrder') }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-item>
<q-separator />
<q-item @click="showConfirmDialog = true" v-ripple clickable>
<q-item-section avatar>
<q-icon name="delete" />
</q-item-section>
<q-item-section>{{ t('deleteClaim') }}</q-item-section>
</q-item>
<q-dialog v-model="showConfirmDialog">
<q-card class="q-pa-sm">
<q-card-section class="row items-center q-pb-none">
<span class="text-h6 text-grey">{{ t('confirmDeletion') }}</span>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section class="row items-center">{{ t('confirmDeletionMessage') }}</q-card-section>
<q-card-actions align="right">
<q-btn :label="t('globals.cancel')" color="primary" flat v-close-popup />
<q-btn :label="t('globals.confirm')" color="primary" @click="deleteClaim" />
</q-card-actions>
</q-card>
</q-dialog>
<QItem v-ripple clickable>
<QItemSection avatar>
<QIcon name="summarize" />
</QItemSection>
<QItemSection>{{ t('pickupOrder') }}</QItemSection>
<QItemSection side>
<QIcon name="keyboard_arrow_right" />
</QItemSection>
<QMenu anchor="top end" self="top start" auto-close>
<QList>
<QItem @click="openPickupOrder" v-ripple clickable>
<QItemSection avatar>
<QIcon name="picture_as_pdf" />
</QItemSection>
<QItemSection>{{ t('openPickupOrder') }}</QItemSection>
</QItem>
<QItem @click="confirmPickupOrder" v-ripple clickable>
<QItemSection avatar>
<QIcon name="send" />
</QItemSection>
<QItemSection>{{ t('sendPickupOrder') }}</QItemSection>
</QItem>
</QList>
</QMenu>
</QItem>
<QSeparator />
<QItem @click="confirmRemove()" v-ripple clickable>
<QItemSection avatar>
<QIcon name="delete" />
</QItemSection>
<QItemSection>{{ t('deleteClaim') }}</QItemSection>
</QItem>
</template>
<i18n>

View File

@ -0,0 +1,440 @@
<script setup>
import axios from 'axios';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { useRoute } from 'vue-router';
import { useArrayData } from 'composables/useArrayData';
import { useStateStore } from 'stores/useStateStore';
import VnPaginate from 'components/ui/VnPaginate.vue';
import FetchData from 'components/FetchData.vue';
import VnConfirm from 'components/ui/VnConfirm.vue';
import { toDate, toCurrency, toPercentage } from 'filters/index';
import VnDiscount from 'components/common/vnDiscount.vue';
import ClaimLinesImport from './ClaimLinesImport.vue';
const quasar = useQuasar();
const route = useRoute();
const { t } = useI18n();
const stateStore = useStateStore();
const arrayData = useArrayData('ClaimLines');
const store = arrayData.store;
const claimFilter = {
fields: ['ticketFk'],
};
const linesFilter = {
include: {
relation: 'sale',
scope: {
fields: ['concept', 'ticketFk', 'price', 'quantity', 'discount', 'itemFk'],
include: {
relation: 'ticket',
},
},
},
};
const claim = ref(null);
async function onFetchClaim(data) {
claim.value = data;
fetchMana();
}
const amount = ref(0);
const amountClaimed = ref(0);
async function onFetch(rows) {
amount.value = rows.reduce(
(acumulator, { sale }) => acumulator + sale.price * sale.quantity,
0
);
amountClaimed.value = rows.reduce(
(acumulator, line) => acumulator + line.sale.price * line.quantity,
0
);
}
const columns = computed(() => [
{
name: 'dated',
label: t('Delivered'),
field: ({ sale: { ticket } }) => toDate(ticket.landed),
sortable: true,
},
{
name: 'quantity',
label: t('Quantity'),
field: ({ sale }) => sale.quantity,
sortable: true,
},
{
name: 'claimed',
label: t('Claimed'),
field: (row) => row.quantity,
sortable: true,
},
{
name: 'description',
label: t('Description'),
field: ({ sale }) => sale.concept,
},
{
name: 'price',
label: t('Price'),
field: ({ sale }) => sale.price,
format: (value) => toCurrency(value),
sortable: true,
},
{
name: 'discount',
label: t('Discount'),
field: ({ sale }) => sale.discount,
format: (value) => toPercentage(value / 100),
sortable: true,
},
{
name: 'total',
label: t('Total'),
field: ({ sale }) => {
const amount = sale.price * sale.quantity;
const appliedDiscount = (sale.discount * amount) / 100;
return amount - appliedDiscount;
},
format: (value) => toCurrency(value),
sortable: true,
},
]);
const selected = ref([]);
const mana = ref(0);
async function fetchMana() {
const ticketId = claim.value.ticketFk;
const response = await axios.get(`Tickets/${ticketId}/getSalesPersonMana`);
mana.value = response.data;
}
async function updateQuantity({ id, quantity }) {
if (!id) return;
await axios.patch(`ClaimBeginnings/${id}`, { quantity });
}
async function updateDiscount({ saleFk, discount, canceller }) {
const body = { salesIds: [saleFk], newDiscount: discount };
const claimId = claim.value.ticketFk;
const query = `Tickets/${claimId}/updateDiscount`;
await axios.post(query, body, {
signal: canceller.signal,
});
}
function onUpdateDiscount(response) {
const row = store.data[response.rowIndex];
row.sale.discount = response.discount;
quasar.notify({
message: t('Discount updated'),
type: 'positive',
});
}
async function confirmRemove() {
const rows = selected.value;
const count = rows.length;
if (count === 0) {
return quasar.notify({
message: 'You must select at least one row',
type: 'warning',
});
}
quasar
.dialog({
component: VnConfirm,
componentProps: {
title: t('Delete claimed sales'),
message: t('You are about to remove {count} rows', count, { count }),
data: { rows },
promise: remove,
},
})
.onOk(() => {
for (const row of rows) {
const orgData = store.data;
const index = orgData.findIndex((item) => item.id === row.id);
store.data.splice(index, 1);
selected.value = [];
}
});
}
async function remove({ rows }) {
if (!rows.length) return;
const body = { deletes: rows.map((row) => row.id) };
await axios.post(`ClaimBeginnings/crud`, body);
quasar.notify({
type: 'positive',
message: t('globals.rowRemoved'),
});
}
function showImportDialog() {
quasar
.dialog({
component: ClaimLinesImport,
})
.onOk(() => arrayData.refresh());
}
</script>
<template>
<QPageSticky position="top" :offset="[0, 0]" expand>
<QToolbar class="bg-dark text-white">
<QToolbarTitle> {{ t('Claimed lines') }} </QToolbarTitle>
<QSpace />
<div class="row q-gutter-md">
<div>
{{ t('Amount') }}
<QChip :dense="$q.screen.lt.sm">
{{ toCurrency(amount) }}
</QChip>
</div>
<QSeparator dark vertical />
<div>
{{ t('Amount Claimed') }}
<QChip color="positive" :dense="$q.screen.lt.sm">
{{ toCurrency(amountClaimed) }}
</QChip>
</div>
</div>
</QToolbar>
</QPageSticky>
<FetchData
:url="`Claims/${route.params.id}`"
:filter="claimFilter"
@on-fetch="onFetchClaim"
auto-load
/>
<div class="column items-center">
<div class="list">
<VnPaginate
data-key="ClaimLines"
:url="`Claims/${route.params.id}/lines`"
:filter="linesFilter"
@on-fetch="onFetch"
auto-load
>
<template #body="{ rows }">
<QTable
:columns="columns"
:rows="rows"
:dense="$q.screen.lt.md"
:pagination="{ rowsPerPage: 0 }"
row-key="id"
selection="multiple"
v-model:selected="selected"
hide-pagination
:grid="$q.screen.lt.md"
>
<template #body-cell-claimed="{ row, value }">
<QTd auto-width align="right" class="text-primary">
<span>{{ value }}</span>
<QPopupEdit
v-model="row.quantity"
v-slot="scope"
:title="t('Claimed quantity')"
@update:model-value="updateQuantity(row)"
buttons
>
<QInput
v-model="scope.value"
type="number"
dense
autofocus
@keyup.enter="scope.set"
@focus="($event) => $event.target.select()"
/>
</QPopupEdit>
</QTd>
</template>
<template #body-cell-discount="{ row, value, rowIndex }">
<QTd auto-width align="right" class="text-primary">
{{ value }}
<VnDiscount
:quantity="row.quantity"
:price="row.sale.price"
:discount="row.sale.discount"
:mana="mana"
:promise="updateDiscount"
:data="{ saleFk: row.sale.id, rowIndex: rowIndex }"
@on-update="onUpdateDiscount"
/>
</QTd>
</template>
<!-- View for grid mode -->
<template #item="props">
<div
class="q-mb-md col-12 grid-style-transition"
:style="props.selected ? 'transform: scale(0.95);' : ''"
>
<QCard>
<QCardSection>
<QCheckbox v-model="props.selected" />
</QCardSection>
<QSeparator inset />
<QList dense>
<QItem
v-for="column of props.cols"
:key="column.name"
>
<QItemSection>
<QItemLabel caption>
{{ column.label }}
</QItemLabel>
</QItemSection>
<QItemSection side>
<template
v-if="column.name === 'claimed'"
>
<QItemLabel class="text-primary">
{{ column.value }}
<QPopupEdit
v-model="props.row.quantity"
v-slot="scope"
:title="t('Claimed quantity')"
@update:model-value="
updateQuantity(props.row)
"
buttons
>
<QInput
v-model="scope.value"
type="number"
dense
autofocus
@keyup.enter="scope.set"
@focus="
($event) =>
$event.target.select()
"
/>
</QPopupEdit>
</QItemLabel>
</template>
<template
v-else-if="column.name === 'discount'"
>
<QItemLabel class="text-primary">
{{ column.value }}
<VnDiscount
:quantity="props.row.quantity"
:price="props.row.sale.price"
:discount="
props.row.sale.discount
"
:mana="mana"
:promise="updateDiscount"
:data="{
saleFk: props.row.sale.id,
rowIndex: props.rowIndex,
}"
@on-update="onUpdateDiscount"
/>
</QItemLabel>
</template>
<template v-else>
<QItemLabel>
{{ column.value }}
</QItemLabel>
</template>
</QItemSection>
</QItem>
</QList>
</QCard>
</div>
</template>
</QTable>
</template>
</VnPaginate>
</div>
</div>
<Teleport
v-if="stateStore.isHeaderMounted() && !$q.screen.lt.sm"
to="#actions-prepend"
>
<div class="row q-gutter-x-sm">
<QBtn
v-if="selected.length > 0"
@click="confirmRemove"
icon="delete"
color="primary"
flat
dense
rounded
>
<QTooltip bottom> {{ t('globals.remove') }} </QTooltip>
</QBtn>
<QBtn @click="showImportDialog" icon="add" color="primary" flat dense rounded>
<QTooltip bottom> {{ t('globals.add') }} </QTooltip>
</QBtn>
<QSeparator vertical />
</div>
</Teleport>
<!-- v-if="quasar.platform.is.mobile" -->
<QPageSticky v-if="$q.screen.lt.sm" position="bottom" :offset="[0, 0]" expand>
<QToolbar class="bg-primary text-white q-pa-none">
<QTabs class="full-width" align="justify" inline-label narrow-indicator>
<QTab @click="showImportDialog" icon="add" :label="t('globals.add')" />
<QSeparator vertical inset />
<QTab
@click="confirmRemove"
icon="delete"
:label="t('globals.remove')"
:disable="selected.length === 0"
/>
</QTabs>
</QToolbar>
</QPageSticky>
</template>
<style lang="scss" scoped>
.list {
padding-top: 50px;
max-width: 900px;
width: 100%;
}
.grid-style-transition {
transition: transform 0.28s, background-color 0.28s;
}
</style>
<i18n>
en:
You are about to remove {count} rows: '
You are about to remove <strong>{count}</strong> row |
You are about to remove <strong>{count}</strong> rows'
es:
Claimed lines: Líneas reclamadas
Delivered: Entregado
Quantity: Cantidad
Claimed: Reclamada
Description: Descripción
Price: Precio
Discount: Descuento
Actions: Acciones
Amount: Total
Amount Claimed: Cantidad reclamada
Delete claimed sales: Eliminar ventas reclamadas
Discount updated: Descuento actualizado
Claimed quantity: Cantidad reclamada
You are about to remove {count} rows: '
Vas a eliminar <strong>{count}</strong> línea |
Vas a eliminar <strong>{count}</strong> líneas'
</i18n>

View File

@ -0,0 +1,182 @@
<script setup>
import { ref, computed } from 'vue';
import { useQuasar, useDialogPluginComponent } from 'quasar';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import FetchData from 'components/FetchData.vue';
import { toDate, toCurrency, toPercentage } from 'filters/index';
import axios from 'axios';
defineEmits([...useDialogPluginComponent.emits]);
const { dialogRef, onDialogOK, onDialogCancel } = useDialogPluginComponent();
const route = useRoute();
const quasar = useQuasar();
const { t } = useI18n();
const columns = computed(() => [
{
name: 'delivered',
label: t('Delivered'),
field: (row) => row.landed,
format: (value) => toDate(value),
sortable: true,
},
{
name: 'quantity',
label: t('Quantity'),
field: (row) => row.quantity,
sortable: true,
},
{
name: 'description',
label: t('Description'),
field: (row) => row.concept,
},
{
name: 'price',
label: t('Price'),
field: (row) => row.price,
format: (value) => toCurrency(value),
sortable: true,
},
{
name: 'discount',
label: t('Discount'),
field: (row) => row.discount,
format: (value) => toPercentage(value),
sortable: true,
},
]);
const selected = ref([]);
const claimableSales = ref([]);
const isLoading = ref(false);
let canceller;
async function importLines() {
const sales = selected.value;
if (!sales.length) {
return quasar.notify({
message: 'You must select at least one',
type: 'warning',
});
}
const body = sales.map((row) => ({
claimFk: route.params.id,
saleFk: row.saleFk,
quantity: row.quantity,
}));
canceller = new AbortController();
isLoading.value = true;
const { data } = await axios.post('ClaimBeginnings', body, {
signal: canceller.signal,
});
quasar.notify({
message: 'Lines added to claim',
type: 'positive',
});
onDialogOK(data);
canceller = null;
isLoading.value = false;
}
function cancel() {
if (canceller) {
canceller.abort();
canceller = null;
}
onDialogCancel();
}
</script>
<template>
<FetchData
url="Sales/getClaimableFromTicket?ticketFk=16"
@on-fetch="(data) => (claimableSales = data)"
auto-load
/>
<QDialog ref="dialogRef" persistent>
<QCard>
<QCardSection class="row items-center">
<span class="text-h6 text-grey">{{ t('Available sales lines') }}</span>
<QSpace />
<QBtn icon="close" flat round dense v-close-popup />
</QCardSection>
<QTable
class="my-sticky-header-table"
:columns="columns"
:rows="claimableSales"
:pagination="{ rowsPerPage: 10 }"
row-key="saleFk"
selection="multiple"
v-model:selected="selected"
square
flat
/>
<QSeparator />
<QCardActions align="right">
<QBtn :label="t('globals.cancel')" color="primary" flat @click="cancel" />
<QBtn
:label="t('globals.confirm')"
color="primary"
:loading="isLoading"
@click="importLines"
unelevated
/>
</QCardActions>
</QCard>
</QDialog>
</template>
<style lang="scss" scoped>
.q-card {
max-width: 800px;
}
</style>
<style lang="scss">
.my-sticky-header-table {
height: 400px;
thead tr th {
position: sticky;
z-index: 1;
}
thead tr:first-child th {
/* this is when the loading indicator appears */
top: 0;
}
&.q-table--loading thead tr:last-child th {
/* height of all previous header rows */
top: 48px;
}
// /* prevent scrolling behind sticky top row on focus */
tbody {
/* height of all previous header rows */
scroll-margin-top: 48px;
}
}
</style>
<i18n>
es:
Available sales lines: Líneas de venta disponibles
Delivered: Entrega
Quantity: Cantidad
Description: Descripción
Price: Precio
Discount: Descuento
Lines added to claim: Lineas añadidas a la reclamación
You must select at least one: Debes seleccionar al menos una
</i18n>

View File

@ -0,0 +1,6 @@
<script setup>
import VnLog from 'src/components/common/VnLog.vue';
</script>
<template>
<VnLog model="Claim"></VnLog>
</template>

View File

@ -0,0 +1,384 @@
<script setup>
import axios from 'axios';
import { ref, computed } from 'vue';
import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStateStore } from 'stores/useStateStore';
import { useSession } from 'composables/useSession';
import VnConfirm from 'components/ui/VnConfirm.vue';
import FetchData from 'components/FetchData.vue';
const router = useRouter();
const quasar = useQuasar();
const { t } = useI18n();
const stateStore = useStateStore();
const session = useSession();
const token = session.getToken();
const claimId = computed(() => router.currentRoute.value.params.id);
const claimDms = ref([
{
dmsFk: 1,
},
]);
const client = ref({});
const inputFile = ref();
const files = ref({});
const claimDmsRef = ref();
const dmsType = ref({});
const config = ref({});
const dragFile = ref(false);
const dragFileTimeout = ref();
const claimDmsFilter = ref({
include: [
{
relation: 'client',
scope: {
fields: ['id', 'name'],
},
},
{
relation: 'claimDms',
scope: {
include: {
relation: 'dms',
scope: {
fields: ['contentType'],
},
},
},
},
],
where: { id: claimId.value },
});
const multimediaDialog = ref();
const multimediaSlide = ref();
function openDialog(dmsId) {
multimediaSlide.value = dmsId;
multimediaDialog.value = true;
}
function viewDeleteDms(index) {
quasar
.dialog({
component: VnConfirm,
componentProps: {
title: t('This file will be deleted'),
icon: 'delete',
data: { index },
promise: deleteDms,
},
})
.onOk(() => claimDms.value.splice(index, 1));
}
async function deleteDms({ index }) {
const dmsId = claimDms.value[index].dmsFk;
await axios.post(`ClaimDms/${dmsId}/removeFile`);
quasar.notify({
message: t('globals.dataDeleted'),
type: 'positive',
icon: 'delete',
});
}
function setClaimDms(data) {
if (!data) return;
claimDms.value = data.claimDms.map((media) => {
media.isVideo = media.dms.contentType == 'video/mp4';
media.url = `/api/Claims/${media.dmsFk}/downloadFile?access_token=${token}`;
return media;
});
client.value = data.client;
}
async function create() {
const formData = new FormData();
const inputFiles = files.value;
for (let i = 0; i < inputFiles.length; i++)
formData.append(inputFiles[i].name, inputFiles[i]);
const query = `claims/${claimId.value}/uploadFile`;
const dms = {
hasFile: false,
hasFileAttached: false,
reference: claimId.value,
warehouseId: config.value.warehouseFk,
companyId: config.value.companyFk,
dmsTypeId: dmsType.value.id,
description: t('claim.photo.fileDescription', {
claimId: claimId.value,
clientName: client.value.name,
clientId: client.value.id,
}).toUpperCase(),
};
await axios.post(query, formData, {
params: dms,
});
quasar.notify({
message: t('globals.dataSaved'),
type: 'positive',
icon: 'check',
});
claimDmsRef.value.fetch();
}
function onDrop($data) {
dragFile.value = false;
files.value = $data.dataTransfer.files;
create();
}
function onDrag() {
clearTimeout(dragFileTimeout.value);
dragFileTimeout.value = setTimeout(() => (dragFile.value = false), 500);
dragFile.value = true;
}
</script>
<template>
<FetchData
url="Claims"
:filter="claimDmsFilter"
@on-fetch="([data]) => setClaimDms(data)"
limit="20"
auto-load
ref="claimDmsRef"
/>
<FetchData
url="DmsTypes/findOne"
:filter="{ where: { code: 'claim' } }"
@on-fetch="(data) => (dmsType = data)"
auto-load
/>
<FetchData
url="UserConfigs/getUserConfig"
@on-fetch="(data) => (config = data)"
auto-load
/>
<div
:class="['container', { dragFile }]"
@drop.prevent="onDrop"
@dragenter.prevent
@dragover.prevent="onDrag"
>
<div
class="flex flex-center items-center text-grey q-mt-md column"
v-if="dragFile"
>
<QIcon size="xl" name="file_download" />
<h5>
{{ t('claim.photo.dragDrop') }}
</h5>
</div>
<div
class="text-center text-grey q-mt-md cursor-pointer"
v-if="!claimDms?.length && !dragFile"
@click="inputFile.nativeEl.click()"
>
<QIcon size="xl" name="image"></QIcon>
<QIcon size="xl" name="movie"></QIcon>
<h5>
{{ t('claim.photo.noData') }}
</h5>
</div>
<div class="multimediaParent bg-transparent" v-if="claimDms?.length && !dragFile">
<div
v-for="(media, index) of claimDms"
:key="index"
class="relative-position"
>
<QBtn
icon="delete"
color="primary"
text-color="white"
size="md"
class="all-pointer-events absolute delete-button zindex"
@click.stop="viewDeleteDms(index)"
round
/>
<QIcon
name="play_circle"
color="primary"
size="xl"
class="absolute-center zindex"
v-if="media.isVideo"
@click.stop="openDialog(media.dmsFk)"
>
<QTooltip>Video</QTooltip>
</QIcon>
<QCard class="multimedia relative-position">
<QImg
:src="media.url"
class="rounded-borders cursor-pointer fit"
@click="openDialog(media.dmsFk)"
v-if="!media.isVideo"
>
</QImg>
<video
:src="media.url"
class="rounded-borders cursor-pointer fit"
muted="muted"
v-if="media.isVideo"
@click="openDialog(media.dmsFk)"
/>
</QCard>
</div>
</div>
</div>
<Teleport
v-if="stateStore.isHeaderMounted() && !quasar.platform.is.mobile"
to="#actions-prepend"
>
<div class="row q-gutter-x-sm">
<label for="fileInput">
<QBtn
@click="inputFile.nativeEl.click()"
icon="add"
color="primary"
dense
rounded
>
<QInput
ref="inputFile"
type="file"
style="display: none"
multiple
v-model="files"
@update:model-value="create()"
/>
<QTooltip bottom> {{ t('globals.add') }} </QTooltip>
</QBtn>
</label>
<QSeparator vertical />
</div>
</Teleport>
<QPageSticky
v-if="quasar.platform.is.mobile"
position="bottom"
:offset="[0, 0]"
expand
>
<QToolbar class="bg-primary text-white q-pa-none">
<QTabs class="full-width" align="justify" inline-label narrow-indicator>
<QTab
@click="inputFile.nativeEl.click()"
icon="add_circle"
:label="t('globals.add')"
>
<QInput
ref="inputFile"
type="file"
style="display: none"
multiple
v-model="files"
@update:model-value="create()"
/>
<QTooltip bottom> {{ t('globals.add') }} </QTooltip>
</QTab>
</QTabs>
</QToolbar>
</QPageSticky>
<!-- MULTIMEDIA DIALOG START-->
<QDialog
v-model="multimediaDialog"
transition-show="slide-up"
transition-hide="slide-down"
>
<QToolbar class="absolute zindex close-button">
<QSpace />
<QBtn icon="close" color="primary" round dense v-close-popup />
</QToolbar>
<QCarousel swipeable animated v-model="multimediaSlide" arrows class="fit">
<QCarouselSlide
v-for="media of claimDms"
:key="media.dmsFk"
:name="media.dmsFk"
>
<QImg
:src="media.url"
class="fit"
fit="scale-down"
v-if="!media.isVideo"
/>
<video class="q-ma-none fit" v-if="media.isVideo" controls muted autoplay>
<source :src="media.url" type="video/mp4" />
</video>
</QCarouselSlide>
</QCarousel>
</QDialog>
<!-- MULTIMEDIA DIALOG END-->
</template>
<style lang="scss" scoped>
.container {
min-height: 80vh;
min-width: 80%;
}
.q-dialog__inner--minimized > div {
max-width: 80%;
}
.multimediaParent {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-auto-rows: auto;
grid-gap: 1rem;
}
.multimedia {
transition: all 0.5s;
opacity: 1;
height: 250px;
.q-img {
object-fit: cover;
background-color: black;
}
video {
object-fit: cover;
background-color: black;
}
}
.multimedia:hover {
opacity: 0.5;
}
.delete-button {
top: 10px;
left: 10px;
}
.close-button {
top: 1%;
right: 10%;
}
.zindex {
z-index: 1;
}
.dragFile {
border: 2px dashed $color-spacer;
}
</style>
<i18n>
es:
This file will be deleted: Este archivo va a ser borrado
</i18n>

View File

@ -1,45 +1,62 @@
<script setup>
import axios from 'axios';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { useRoute } from 'vue-router';
import axios from 'axios';
import Paginate from 'components/Paginate.vue';
import { useArrayData } from 'src/composables/useArrayData';
import { useStateStore } from 'stores/useStateStore';
import VnPaginate from 'src/components/ui/VnPaginate.vue';
import FetchData from 'components/FetchData.vue';
import TeleportSlot from 'components/ui/TeleportSlot';
import VnConfirm from 'src/components/ui/VnConfirm.vue';
import { toDate } from 'src/filters';
const quasar = useQuasar();
const route = useRoute();
const { t } = useI18n();
const stateStore = useStateStore();
const arrayData = useArrayData('ClaimRma');
const claim = ref([]);
const fetcher = ref();
const filter = {
include: {
relation: 'rmas',
scope: {
include: {
relation: 'worker',
scope: {
include: {
relation: 'user',
},
},
},
order: 'created DESC',
},
},
const claim = ref();
const claimFilter = {
fields: ['rma'],
};
async function onFetch(data) {
claim.value = data;
const filter = {
include: {
relation: 'worker',
scope: {
include: {
relation: 'user',
},
},
},
order: 'created DESC',
where: {
code: claim.value.rma,
},
};
arrayData.applyFilter({ filter });
}
async function addRow() {
if (!claim.value.rma) {
return quasar.notify({
message: `This claim is not associated to any RMA`,
type: 'negative',
});
}
const formData = {
code: claim.value.rma,
};
await axios.post(`ClaimRmas`, formData);
await fetcher.value.fetch();
await arrayData.refresh();
quasar.notify({
type: 'positive',
@ -48,104 +65,119 @@ async function addRow() {
});
}
const confirmShown = ref(false);
const rmaId = ref(null);
function confirmRemove(id) {
confirmShown.value = true;
rmaId.value = id;
quasar
.dialog({
component: VnConfirm,
componentProps: {
data: { id },
promise: remove,
},
})
.onOk(async () => await arrayData.refresh());
}
async function remove() {
const id = rmaId.value;
async function remove({ id }) {
await axios.delete(`ClaimRmas/${id}`);
await fetcher.value.fetch();
confirmShown.value = false;
quasar.notify({
type: 'positive',
message: t('globals.rowRemoved'),
icon: 'check',
});
}
function hide() {
rmaId.value = null;
}
</script>
<template>
<fetch-data
ref="fetcher"
<FetchData
:url="`Claims/${route.params.id}`"
:filter="filter"
@on-fetch="(data) => (claim = data)"
:filter="claimFilter"
@on-fetch="onFetch"
auto-load
/>
<paginate :data="claim.rmas">
<template #body="{ rows }">
<q-card class="card">
<template v-for="row of rows" :key="row.id">
<q-item class="q-pa-none items-start">
<q-item-section class="q-pa-md">
<q-list>
<q-item class="q-pa-none">
<q-item-section>
<q-item-label caption>{{ t('claim.rma.user') }}</q-item-label>
<q-item-label>{{ row.worker.user.name }}</q-item-label>
</q-item-section>
</q-item>
<q-item class="q-pa-none">
<q-item-section>
<q-item-label caption>{{ t('claim.rma.created') }}</q-item-label>
<q-item-label>
{{ toDate(row.created, { timeStyle: 'medium' }) }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-item-section>
<q-card-actions vertical class="justify-between">
<q-btn flat round color="orange" icon="vn:bin" @click="confirmRemove(row.id)">
<q-tooltip>{{ t('globals.remove') }}</q-tooltip>
</q-btn>
</q-card-actions>
</q-item>
<q-separator />
<div class="column items-center">
<div class="list">
<VnPaginate data-key="ClaimRma" url="ClaimRmas">
<template #body="{ rows }">
<QCard class="card">
<template v-for="(row, index) of rows" :key="row.id">
<QItem class="q-pa-none items-start">
<QItemSection class="q-pa-md">
<QList>
<QItem class="q-pa-none">
<QItemSection>
<QItemLabel caption>
{{ t('claim.rma.user') }}
</QItemLabel>
<QItemLabel>
{{ row.worker.user.name }}
</QItemLabel>
</QItemSection>
</QItem>
<QItem class="q-pa-none">
<QItemSection>
<QItemLabel caption>
{{ t('claim.rma.created') }}
</QItemLabel>
<QItemLabel>
{{
toDate(row.created, {
timeStyle: 'medium',
})
}}
</QItemLabel>
</QItemSection>
</QItem>
</QList>
</QItemSection>
<QCardActions vertical class="justify-between">
<QBtn
flat
round
color="orange"
icon="vn:bin"
@click="confirmRemove(row.id)"
>
<QTooltip>{{ t('globals.remove') }}</QTooltip>
</QBtn>
</QCardActions>
</QItem>
<QSeparator v-if="index !== rows.length - 1" />
</template>
</QCard>
</template>
</q-card>
</template>
</paginate>
<q-dialog v-model="confirmShown" persistent @hide="hide">
<q-card>
<q-card-section class="row items-center">
<q-avatar icon="warning" color="primary" text-color="white" />
<span class="q-ml-sm">{{ t('globals.confirmRemove') }}</span>
</q-card-section>
<q-card-actions align="right">
<q-btn flat :label="t('globals.no')" color="primary" v-close-popup autofocus />
<q-btn flat :label="t('globals.yes')" color="primary" @click="remove()" />
</q-card-actions>
</q-card>
</q-dialog>
<teleport-slot v-if="!quasar.platform.is.mobile" to="#header-actions">
<div class="row q-gutter-x-sm">
<q-btn @click="addRow()" icon="add" color="primary" dense rounded>
<q-tooltip bottom> {{ t('globals.add') }} </q-tooltip>
</q-btn>
<q-separator vertical />
</VnPaginate>
</div>
</teleport-slot>
</div>
<teleport-slot to=".q-footer">
<q-tabs align="justify" inline-label narrow-indicator>
<q-tab @click="addRow()" icon="add_circle" :label="t('globals.add')" />
</q-tabs>
</teleport-slot>
<Teleport
v-if="stateStore.isHeaderMounted() && !quasar.platform.is.mobile"
to="#actions-prepend"
>
<div class="row q-gutter-x-sm">
<QBtn @click="addRow()" icon="add" color="primary" dense rounded>
<QTooltip bottom> {{ t('globals.add') }} </QTooltip>
</QBtn>
<QSeparator vertical />
</div>
</Teleport>
<QPageSticky
v-if="quasar.platform.is.mobile"
position="bottom"
:offset="[0, 0]"
expand
>
<QToolbar class="bg-primary text-white q-pa-none">
<QTabs class="full-width" align="justify" inline-label narrow-indicator>
<QTab @click="addRow()" icon="add_circle" :label="t('globals.add')" />
</QTabs>
</QToolbar>
</QPageSticky>
</template>
<style lang="scss" scoped>
.list {
width: 100%;
max-width: 60em;
}
.q-toolbar {
background-color: $grey-9;
}
@ -157,3 +189,8 @@ function hide() {
z-index: 2998;
}
</style>
<i18n>
es:
This claim is not associated to any RMA: Esta reclamación no está asociada a ninguna ARM
</i18n>

View File

@ -1,15 +1,17 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import { toDate, toCurrency } from 'src/filters';
import SkeletonSummary from 'components/ui/SkeletonSummary.vue';
onMounted(() => fetch());
import CardSummary from 'components/ui/CardSummary.vue';
import WorkerDescriptorProxy from 'pages/Worker/Card/WorkerDescriptorProxy.vue';
import FetchData from 'components/FetchData.vue';
import { useSession } from 'src/composables/useSession';
const route = useRoute();
const { t } = useI18n();
const session = useSession();
const token = session.getToken();
const $props = defineProps({
id: {
@ -20,16 +22,6 @@ const $props = defineProps({
const entityId = computed(() => $props.id || route.params.id);
const claim = ref(null);
const salesClaimed = ref(null);
function fetch() {
const id = entityId.value;
axios.get(`Claims/${id}/getSummary`).then(({ data }) => {
claim.value = data.claim;
salesClaimed.value = data.salesClaimed;
});
}
const detailsColumns = ref([
{
name: 'item',
@ -77,7 +69,8 @@ const detailsColumns = ref([
{
name: 'total',
label: 'claim.summary.total',
field: ({ sale }) => toCurrency(sale.quantity * sale.price * ((100 - sale.discount) / 100)),
field: ({ sale }) =>
toCurrency(sale.quantity * sale.price * ((100 - sale.discount) / 100)),
sortable: true,
},
]);
@ -87,113 +80,333 @@ function stateColor(code) {
if (code === 'managed') return 'orange';
if (code === 'resolved') return 'red';
}
const developmentColumns = ref([
{
name: 'claimReason',
label: 'claim.summary.reason',
field: (row) => row.claimReason.description,
sortable: true,
},
{
name: 'claimResult',
label: 'claim.summary.result',
field: (row) => row.claimResult.description,
sortable: true,
},
{
name: 'claimResponsible',
label: 'claim.summary.responsible',
field: (row) => row.claimResponsible.description,
sortable: true,
},
{
name: 'worker',
label: 'claim.summary.worker',
field: (row) => row.worker.user.nickname,
sortable: true,
},
{
name: 'claimRedelivery',
label: 'claim.summary.redelivery',
field: (row) => row.claimRedelivery.description,
sortable: true,
},
]);
const claimDms = ref([]);
const multimediaDialog = ref();
const multimediaSlide = ref();
const claimDmsFilter = ref({
include: [
{
relation: 'dms',
},
],
where: { claimFk: entityId.value },
});
function setClaimDms(data) {
if (!data) return;
data.forEach((media) => {
claimDms.value.push({
isVideo: media.dms.contentType == 'video/mp4',
url: `/api/Claims/${media.dmsFk}/downloadFile?access_token=${token}`,
dmsFk: media.dmsFk,
});
});
}
function openDialog(dmsId) {
multimediaSlide.value = dmsId;
multimediaDialog.value = true;
}
</script>
<template>
<div class="summary container">
<q-card>
<skeleton-summary v-if="!claim" />
<template v-if="claim">
<div class="header bg-primary q-pa-sm q-mb-md">{{ claim.id }} - {{ claim.client.name }}</div>
<q-list>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('claim.summary.created') }}</q-item-label>
<q-item-label>{{ toDate(claim.created) }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('claim.summary.state') }}</q-item-label>
<q-item-label>
<q-chip :color="stateColor(claim.claimState.code)" dense>
{{ claim.claimState.description }}
</q-chip>
</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('claim.summary.assignedTo') }}</q-item-label>
<q-item-label>{{ claim.worker.user.nickname }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('claim.summary.attendedBy') }}</q-item-label>
<q-item-label>{{ claim.client.salesPersonUser.name }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card-section class="q-pa-md">
<h6>{{ t('claim.summary.details') }}</h6>
<q-table :columns="detailsColumns" :rows="salesClaimed" flat>
<template #header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ t(col.label) }}
</q-th>
</q-tr>
</template>
</q-table>
</q-card-section>
<q-card-section class="q-pa-md">
<h6>{{ t('claim.summary.actions') }}</h6>
<q-separator />
<div id="slider-container">
<q-slider
v-model="claim.responsibility"
label
:label-value="t('claim.summary.responsibility')"
label-always
color="primary"
markers
:marker-labels="[
{ value: 1, label: t('claim.summary.company') },
{ value: 5, label: t('claim.summary.person') },
]"
:min="1"
:max="5"
readonly
/>
<FetchData
url="ClaimDms"
:filter="claimDmsFilter"
@on-fetch="(data) => setClaimDms(data)"
limit="20"
auto-load
/>
<CardSummary ref="summary" :url="`Claims/${entityId}/getSummary`">
<template #header="{ entity: { claim } }">
{{ claim.id }} - {{ claim.client.name }}
</template>
<template #body="{ entity: { developments, observations, claim, salesClaimed } }">
<QCardSection class="row q-pa-none q-col-gutter-md">
<div class="col">
<QList>
<QItem>
<QItemSection>
<QItemLabel caption>
{{ t('claim.summary.created') }}
</QItemLabel>
<QItemLabel>{{ toDate(claim.created) }}</QItemLabel>
</QItemSection>
<QItemSection v-if="claim.claimState">
<QItemLabel caption>
{{ t('claim.summary.state') }}
</QItemLabel>
<QItemLabel>
<QChip
:color="stateColor(claim.claimState.code)"
dense
>
{{ claim.claimState.description }}
</QChip>
</QItemLabel>
</QItemSection>
</QItem>
<QItem>
<QItemSection v-if="claim.worker && claim.worker.user">
<QItemLabel caption>
{{ t('claim.summary.assignedTo') }}
</QItemLabel>
<QItemLabel>
<span class="link">
{{ claim.worker.user.nickname }}
<WorkerDescriptorProxy :id="claim.workerFk" />
</span>
</QItemLabel>
</QItemSection>
<QItemSection
v-if="claim.client && claim.client.salesPersonUser"
>
<QItemLabel caption>
{{ t('claim.summary.attendedBy') }}
</QItemLabel>
<QItemLabel>
<span class="link">
{{ claim.client.salesPersonUser.name }}
<WorkerDescriptorProxy
:id="claim.client.salesPersonFk"
/>
</span>
</QItemLabel>
</QItemSection>
</QItem>
</QList>
</div>
</QCardSection>
<QCardSection class="q-pa-md" v-if="observations.length > 0">
<h6>{{ t('claim.summary.notes') }}</h6>
<div class="note-list" v-for="note in observations" :key="note.id">
<div class="note-caption">
<span
>{{ note.worker.firstName }} {{ note.worker.lastName }}
</span>
<span>{{ toDate(note.created) }}</span>
</div>
</q-card-section>
</template>
</q-card>
</div>
<div class="note-text">
<span>{{ note.text }}</span>
</div>
</div>
</QCardSection>
<QCardSection class="q-pa-md" v-if="salesClaimed.length > 0">
<h6>{{ t('claim.summary.details') }}</h6>
<QTable :columns="detailsColumns" :rows="salesClaimed" flat>
<template #header="props">
<QTr :props="props">
<QTh v-for="col in props.cols" :key="col.name" :props="props">
{{ t(col.label) }}
</QTh>
</QTr>
</template>
</QTable>
</QCardSection>
<QCardSection class="q-pa-md" v-if="claimDms.length > 0">
<h6>{{ t('claim.summary.photos') }}</h6>
<div class="container">
<div class="multimediaParent bg-transparent">
<div
v-for="(media, index) of claimDms"
:key="index"
class="relative-position"
>
<QIcon
name="play_circle"
color="primary"
size="xl"
class="absolute-center zindex"
v-if="media.isVideo"
@click.stop="openDialog(media.dmsFk)"
>
<QTooltip>Video</QTooltip>
</QIcon>
<QCard class="multimedia relative-position">
<QImg
:src="media.url"
class="rounded-borders cursor-pointer fit"
@click="openDialog(media.dmsFk)"
v-if="!media.isVideo"
>
</QImg>
<video
:src="media.url"
class="rounded-borders cursor-pointer fit"
muted="muted"
v-if="media.isVideo"
@click="openDialog(media.dmsFk)"
/>
</QCard>
</div>
</div>
</div>
</QCardSection>
<QCardSection class="q-pa-md" v-if="developments.length > 0">
<h6>{{ t('claim.summary.development') }}</h6>
<QTable :columns="developmentColumns" :rows="developments" flat>
<template #header="props">
<QTr :props="props">
<QTh v-for="col in props.cols" :key="col.name" :props="props">
{{ t(col.label) }}
</QTh>
</QTr>
</template>
</QTable>
</QCardSection>
<QCardSection class="q-pa-md">
<h6>{{ t('claim.summary.actions') }}</h6>
<QSeparator />
<div id="slider-container">
<QSlider
v-model="claim.responsibility"
label
:label-value="t('claim.summary.responsibility')"
label-always
color="primary"
markers
:marker-labels="[
{ value: 1, label: t('claim.summary.company') },
{ value: 5, label: t('claim.summary.person') },
]"
:min="1"
:max="5"
readonly
/>
</div>
</QCardSection>
<QDialog
v-model="multimediaDialog"
transition-show="slide-up"
transition-hide="slide-down"
>
<QToolbar class="absolute zindex close-button">
<QSpace />
<QBtn icon="close" color="primary" round dense v-close-popup />
</QToolbar>
<QCarousel
swipeable
animated
v-model="multimediaSlide"
arrows
class="fit"
>
<QCarouselSlide
v-for="media of claimDms"
:key="media.dmsFk"
:name="media.dmsFk"
>
<QImg
:src="media.url"
class="fit"
fit="scale-down"
v-if="!media.isVideo"
/>
<video
class="q-ma-none fit"
v-if="media.isVideo"
controls
muted
autoplay
>
<source :src="media.url" type="video/mp4" />
</video>
</QCarouselSlide>
</QCarousel>
</QDialog>
</template>
</CardSummary>
</template>
<style lang="scss" scoped>
.container {
display: flex;
justify-content: center;
min-width: 80%;
}
.q-dialog__inner--minimized > div {
max-width: 80%;
}
.q-card {
.multimediaParent {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
grid-auto-rows: auto;
grid-gap: 1rem;
}
.multimedia {
transition: all 0.5s;
opacity: 1;
height: 250px;
.q-img {
object-fit: cover;
background-color: black;
}
video {
object-fit: cover;
background-color: black;
}
}
.multimedia:hover {
opacity: 0.5;
}
.close-button {
top: 1%;
right: 10%;
}
.zindex {
z-index: 1;
}
.note-list {
width: 100%;
max-width: 950px;
border: 0.1rem solid $grey-7;
padding: 0.5rem;
margin-bottom: 0.5rem;
}
.summary {
.header {
text-align: center;
font-size: 18px;
}
#slider-container {
max-width: 80%;
margin: 0 auto;
.q-slider {
.q-slider__marker-labels:nth-child(1) {
transform: none;
}
.q-slider__marker-labels:nth-child(2) {
transform: none;
left: auto !important;
right: 0%;
}
}
}
}
.q-dialog .summary {
max-width: 1200px;
.note-caption {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
color: $grey-7;
}
</style>

View File

@ -15,7 +15,15 @@ const { dialogRef, onDialogHide } = useDialogPluginComponent();
</script>
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide">
<claim-summary v-if="$props.id" :id="$props.id" />
</q-dialog>
<QDialog ref="dialogRef" @hide="onDialogHide">
<ClaimSummary v-if="$props.id" :id="$props.id" />
</QDialog>
</template>
<style lang="scss">
.q-dialog .summary .header {
position: sticky;
z-index: $z-max;
top: 0;
}
</style>

View File

@ -0,0 +1,217 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import FetchData from 'components/FetchData.vue';
import VnFilterPanel from 'src/components/ui/VnFilterPanel.vue';
const { t } = useI18n();
const props = defineProps({
dataKey: {
type: String,
required: true,
},
});
const workers = ref();
const states = ref();
</script>
<template>
<FetchData url="ClaimStates" @on-fetch="(data) => (states = data)" auto-load />
<FetchData
url="Workers/activeWithInheritedRole"
:filter="{ where: { role: 'salesPerson' } }"
@on-fetch="(data) => (workers = data)"
auto-load
/>
<VnFilterPanel :data-key="props.dataKey" :search-button="true">
<template #tags="{ tag, formatFn }">
<div class="q-gutter-x-xs">
<strong>{{ t(`params.${tag.label}`) }}: </strong>
<span>{{ formatFn(tag.value) }}</span>
</div>
</template>
<template #body="{ params, searchFn }">
<QList dense>
<QItem>
<QItemSection>
<QInput
:label="t('Customer ID')"
v-model="params.clientFk"
lazy-rules
>
<template #prepend>
<QIcon name="badge" size="sm"></QIcon>
</template>
</QInput>
</QItemSection>
</QItem>
<QItem>
<QItemSection>
<QInput
:label="t('Client Name')"
v-model="params.clientName"
lazy-rules
/>
</QItemSection>
</QItem>
<QItem>
<QItemSection v-if="!workers">
<QSkeleton type="QInput" class="full-width" />
</QItemSection>
<QItemSection v-if="workers">
<QSelect
:label="t('Salesperson')"
v-model="params.salesPersonFk"
@update:model-value="searchFn()"
:options="workers"
option-value="id"
option-label="name"
emit-value
map-options
use-input
:input-debounce="0"
/>
</QItemSection>
</QItem>
<QItem>
<QItemSection v-if="!workers">
<QSkeleton type="QInput" class="full-width" />
</QItemSection>
<QItemSection v-if="workers">
<QSelect
:label="t('Attender')"
v-model="params.attenderFk"
@update:model-value="searchFn()"
:options="workers"
option-value="id"
option-label="name"
emit-value
map-options
use-input
:input-debounce="0"
/>
</QItemSection>
</QItem>
<QItem>
<QItemSection v-if="!workers">
<QSkeleton type="QInput" class="full-width" />
</QItemSection>
<QItemSection v-if="workers">
<QSelect
:label="t('Responsible')"
v-model="params.claimResponsibleFk"
@update:model-value="searchFn()"
:options="workers"
option-value="id"
option-label="name"
emit-value
map-options
use-input
:input-debounce="0"
/>
</QItemSection>
</QItem>
<QItem class="q-mb-md">
<QItemSection v-if="!states">
<QSkeleton type="QInput" class="full-width" />
</QItemSection>
<QItemSection v-if="states">
<QSelect
:label="t('State')"
v-model="params.claimStateFk"
@update:model-value="searchFn()"
:options="states"
option-value="id"
option-label="description"
emit-value
map-options
/>
</QItemSection>
</QItem>
<QSeparator />
<QExpansionItem :label="t('More options')" expand-separator>
<!-- <QItem>
<QItemSection>
<qSelect
:label="t('Item')"
v-model="params.itemFk"
:options="items"
:loading="loading"
@filter="filterFn"
@virtual-scroll="onScroll"
option-value="id"
option-label="name"
emit-value
map-options
/>
</QItemSection>
</QItem> -->
<QItem>
<QItemSection>
<QInput
v-model="params.created"
:label="t('Created')"
autofocus
readonly
>
<template #append>
<QIcon name="event" class="cursor-pointer">
<QPopupProxy
cover
transition-show="scale"
transition-hide="scale"
>
<QDate v-model="params.created">
<div class="row items-center justify-end">
<QBtn
v-close-popup
label="Close"
color="primary"
flat
/>
</div>
</QDate>
</QPopupProxy>
</QIcon>
</template>
</QInput>
</QItemSection>
</QItem>
</QExpansionItem>
</QList>
</template>
</VnFilterPanel>
</template>
<i18n>
en:
params:
search: Contains
clientFk: Customer
clientName: Customer
salesPersonFk: Salesperson
attenderFk: Attender
claimResponsibleFk: Responsible
claimStateFk: State
created: Created
es:
params:
search: Contiene
clientFk: Cliente
clientName: Cliente
salesPersonFk: Comercial
attenderFk: Asistente
claimResponsibleFk: Responsable
claimStateFk: Estado
created: Creada
Customer ID: ID cliente
Client Name: Nombre del cliente
Salesperson: Comercial
Attender: Asistente
Responsible: Responsable
State: Estado
Item: Artículo
Created: Creada
More options: Más opciones
</i18n>

View File

@ -2,32 +2,19 @@
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import Paginate from 'components/Paginate.vue';
import { toDate } from 'src/filters/index';
import { useStateStore } from 'stores/useStateStore';
import { toDate } from 'filters/index';
import VnPaginate from 'src/components/ui/VnPaginate.vue';
import ClaimSummaryDialog from './Card/ClaimSummaryDialog.vue';
import CustomerDescriptorPopover from 'src/pages/Customer/Card/CustomerDescriptorPopover.vue';
import CustomerDescriptorProxy from 'pages/Customer/Card/CustomerDescriptorProxy.vue';
import VnSearchbar from 'components/ui/VnSearchbar.vue';
import ClaimFilter from './ClaimFilter.vue';
const stateStore = useStateStore();
const router = useRouter();
const quasar = useQuasar();
const { t } = useI18n();
const filter = {
include: [
{
relation: 'client',
},
{
relation: 'claimState',
},
{
relation: 'worker',
scope: {
include: { relation: 'user' },
},
},
],
};
function stateColor(code) {
if (code === 'pending') return 'green';
if (code === 'managed') return 'orange';
@ -49,84 +36,169 @@ function viewSummary(id) {
</script>
<template>
<q-page class="q-pa-md">
<paginate url="/Claims" :filter="filter" sort-by="id DESC" auto-load>
<template #body="{ rows }">
<q-card class="card" v-for="row of rows" :key="row.id">
<q-item class="q-pa-none items-start cursor-pointer q-hoverable" v-ripple clickable>
<q-item-section class="q-pa-md" @click="navigate(row.id)">
<div class="text-h6 link">
{{ row.client.name }}
<q-popup-proxy>
<customer-descriptor-popover :customer="row.client" />
</q-popup-proxy>
</div>
<q-item-label caption>#{{ row.id }}</q-item-label>
<q-list>
<q-item class="q-pa-none">
<q-item-section>
<q-item-label caption>{{ t('claim.list.customer') }}</q-item-label>
<q-item-label>{{ row.client.name }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('claim.list.assignedTo') }}</q-item-label>
<q-item-label>{{ row.worker.user.name }}</q-item-label>
</q-item-section>
</q-item>
<q-item class="q-pa-none">
<q-item-section>
<q-item-label caption>{{ t('claim.list.created') }}</q-item-label>
<q-item-label>{{ toDate(row.created) }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('claim.list.state') }}</q-item-label>
<q-item-label>
<q-chip :color="stateColor(row.claimState.code)" dense>
{{ row.claimState.description }}
</q-chip>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-item-section>
<q-separator vertical />
<q-card-actions vertical class="justify-between">
<!-- <q-btn color="grey-7" round flat icon="more_vert">
<q-tooltip>{{ t('customer.list.moreOptions') }}</q-tooltip>
<q-menu cover auto-close>
<q-list>
<q-item clickable>
<q-item-section avatar>
<q-icon name="add" />
</q-item-section>
<q-item-section>Add a note</q-item-section>
</q-item>
<q-item clickable>
<q-item-section avatar>
<q-icon name="logs" />
</q-item-section>
<q-item-section>Display claim logs</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn> -->
<template v-if="stateStore.isHeaderMounted()">
<Teleport to="#searchbar">
<VnSearchbar
data-key="ClaimList"
:label="t('Search claim')"
:info="t('You can search by claim id or customer name')"
/>
</Teleport>
<Teleport to="#actions-append">
<div class="row q-gutter-x-sm">
<QBtn
flat
@click="stateStore.toggleRightDrawer()"
round
dense
icon="menu"
>
<QTooltip bottom anchor="bottom right">
{{ t('globals.collapseMenu') }}
</QTooltip>
</QBtn>
</div>
</Teleport>
</template>
<QDrawer v-model="stateStore.rightDrawer" side="right" :width="256" show-if-above>
<QScrollArea class="fit text-grey-8">
<ClaimFilter data-key="ClaimList" />
</QScrollArea>
</QDrawer>
<QPage class="column items-center q-pa-md">
<div class="card-list">
<VnPaginate
data-key="ClaimList"
url="Claims/filter"
order="id DESC"
auto-load
>
<template #body="{ rows }">
<QCard class="card q-mb-md" v-for="row of rows" :key="row.id">
<QItem
class="q-pa-none items-start cursor-pointer q-hoverable"
v-ripple
clickable
>
<QItemSection class="q-pa-md" @click="navigate(row.id)">
<div class="text-h6 link">
{{ row.clientName }}
</div>
<QItemLabel caption>#{{ row.id }}</QItemLabel>
<QList>
<QItem class="q-pa-none">
<QItemSection>
<QItemLabel caption>
{{ t('claim.list.customer') }}
</QItemLabel>
<QItemLabel>
{{ row.clientName }}
</QItemLabel>
</QItemSection>
<QItemSection>
<QItemLabel caption>
{{ t('claim.list.assignedTo') }}
</QItemLabel>
<QItemLabel>
{{ row.workerName }}
</QItemLabel>
</QItemSection>
</QItem>
<QItem class="q-pa-none">
<QItemSection>
<QItemLabel caption>
{{ t('claim.list.created') }}
</QItemLabel>
<QItemLabel>
{{ toDate(row.created) }}
</QItemLabel>
</QItemSection>
<QItemSection>
<QItemLabel caption>
{{ t('claim.list.state') }}
</QItemLabel>
<QItemLabel>
<QBadge
:color="stateColor(row.stateCode)"
class="q-ma-none"
dense
>
{{ row.stateDescription }}
</QBadge>
</QItemLabel>
</QItemSection>
</QItem>
</QList>
</QItemSection>
<QSeparator vertical />
<QCardActions vertical class="justify-between">
<!-- <QBtn color="grey-7" round flat icon="more_vert">
<QTooltip>{{ t('customer.list.moreOptions') }}</QTooltip>
<QMenu cover auto-close>
<QList>
<QItem clickable>
<QItemSection avatar>
<QIcon name="add" />
</QItemSection>
<QItemSection>Add a note</QItemSection>
</QItem>
<QItem clickable>
<QItemSection avatar>
<QIcon name="logs" />
</QItemSection>
<QItemSection>Display claim logs</QItemSection>
</QItem>
</QList>
</QMenu>
</QBtn> -->
<q-btn flat round color="orange" icon="arrow_circle_right" @click="navigate(row.id)">
<q-tooltip>{{ t('components.smartCard.openCard') }}</q-tooltip>
</q-btn>
<q-btn flat round color="grey-7" icon="preview" @click="viewSummary(row.id)">
<q-tooltip>{{ t('components.smartCard.openSummary') }}</q-tooltip>
</q-btn>
<q-btn flat round color="grey-7" icon="vn:client">
<q-tooltip>{{ t('components.smartCard.viewDescription') }}</q-tooltip>
<q-popup-proxy>
<customer-descriptor-popover :customer="row.client" />
</q-popup-proxy>
</q-btn>
</q-card-actions>
</q-item>
</q-card>
</template>
</paginate>
</q-page>
<QBtn
flat
round
color="orange"
icon="arrow_circle_right"
@click="navigate(row.id)"
>
<QTooltip>
{{ t('components.smartCard.openCard') }}
</QTooltip>
</QBtn>
<QBtn
flat
round
color="grey-7"
icon="preview"
@click="viewSummary(row.id)"
>
<QTooltip>
{{ t('components.smartCard.openSummary') }}
</QTooltip>
</QBtn>
<QBtn flat round color="grey-7" icon="vn:client">
<QTooltip>
{{ t('components.smartCard.viewDescription') }}
</QTooltip>
<CustomerDescriptorProxy :id="row.clientFk" />
</QBtn>
</QCardActions>
</QItem>
</QCard>
</template>
</VnPaginate>
</div>
</QPage>
</template>
<style lang="scss" scoped>
.card-list {
width: 100%;
max-width: 60em;
}
</style>
<i18n>
es:
Search claim: Buscar reclamación
You can search by claim id or customer name: Puedes buscar por id de la reclamación o nombre del cliente
</i18n>

View File

@ -1,17 +1,17 @@
<script setup>
import { useState } from 'src/composables/useState';
import { useStateStore } from 'stores/useStateStore';
import LeftMenu from 'components/LeftMenu.vue';
const state = useState();
const stateStore = useStateStore();
</script>
<template>
<q-drawer v-model="state.drawer.value" show-if-above :width="256" :breakpoint="500">
<q-scroll-area class="fit text-grey-8">
<QDrawer v-model="stateStore.leftDrawer" show-if-above :width="256">
<QScrollArea class="fit text-grey-8">
<LeftMenu />
</q-scroll-area>
</q-drawer>
<q-page-container>
<router-view></router-view>
</q-page-container>
</QScrollArea>
</QDrawer>
<QPageContainer>
<RouterView></RouterView>
</QPageContainer>
</template>

View File

@ -3,128 +3,153 @@ import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import axios from 'axios';
import Paginate from 'components/Paginate.vue';
import VnPaginate from 'src/components/ui/VnPaginate.vue';
import { useArrayData } from 'src/composables/useArrayData';
import VnConfirm from 'src/components/ui/VnConfirm.vue';
const quasar = useQuasar();
const { t } = useI18n();
const rmas = ref([]);
const card = ref(null);
function onFetch(data) {
rmas.value = data.value;
}
const arrayData = useArrayData('ClaimRmaList');
const isLoading = ref(false);
const input = ref();
const newRma = ref({
code: '',
crated: new Date(),
crated: Date.vnNew(),
});
function onInputUpdate(value) {
newRma.value.code = value.toUpperCase();
}
function submit() {
async function submit() {
const formData = newRma.value;
if (formData.code === '') return;
axios
.post('ClaimRmas', formData)
.then(() => {
newRma.value = {
code: '',
crated: new Date(),
};
})
.then(() => card.value.refresh());
isLoading.value = true;
await axios.post('ClaimRmas', formData);
await arrayData.refresh();
isLoading.value = false;
input.value.$el.focus();
newRma.value = {
code: '',
created: Date.vnNew(),
};
}
const confirmShown = ref(false);
const rmaId = ref(null);
function confirm(id) {
confirmShown.value = true;
rmaId.value = id;
}
function remove() {
const id = rmaId.value;
axios
.delete(`ClaimRmas/${id}`)
.then(() => {
confirmShown.value = false;
quasar.notify({
type: 'positive',
message: 'Entry deleted',
icon: 'check',
});
quasar
.dialog({
component: VnConfirm,
componentProps: {
data: { id },
promise: remove,
},
})
.then(() => card.value.refresh());
.onOk(async () => await arrayData.refresh());
}
function hide() {
rmaId.value = null;
async function remove({ id }) {
await axios.delete(`ClaimRmas/${id}`);
quasar.notify({
type: 'positive',
message: t('globals.rowRemoved'),
});
}
</script>
<template>
<q-page class="q-pa-md sticky">
<q-page-sticky expand position="top" :offset="[16, 16]">
<q-card class="card q-pa-md">
<q-form @submit="submit">
<q-input
<QPage class="column items-center q-pa-md sticky">
<QPageSticky expand position="top" :offset="[16, 16]">
<QCard class="card q-pa-md">
<QForm @submit="submit">
<QInput
ref="input"
v-model="newRma.code"
:label="t('claim.rmaList.code')"
@update:model-value="onInputUpdate"
class="q-mb-md"
:readonly="isLoading"
:loading="isLoading"
autofocus
/>
<div class="text-caption">{{ rmas.length }} {{ t('claim.rmaList.records') }}</div>
</q-form>
</q-card>
</q-page-sticky>
<paginate ref="card" url="/ClaimRmas" @on-fetch="onFetch" sort-by="id DESC" auto-load>
<template #body="{ rows }">
<q-card class="card">
<template v-for="row of rows" :key="row.code">
<q-item class="q-pa-none items-start">
<q-item-section class="q-pa-md">
<q-list>
<q-item class="q-pa-none">
<q-item-section>
<q-item-label caption>{{ t('claim.rmaList.code') }}</q-item-label>
<q-item-label>{{ row.code }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-item-section>
<q-card-actions vertical class="justify-between">
<q-btn flat round color="primary" icon="vn:bin" @click="confirm(row.id)">
<q-tooltip>{{ t('globals.remove') }}</q-tooltip>
</q-btn>
</q-card-actions>
</q-item>
<q-separator />
</template>
</q-card>
</template>
</paginate>
</q-page>
<q-dialog v-model="confirmShown" persistent @hide="hide">
<q-card>
<q-card-section class="row items-center">
<q-avatar icon="warning" color="primary" text-color="white" />
<span class="q-ml-sm">{{ t('globals.confirmRemove') }}</span>
</q-card-section>
<q-card-actions align="right">
<q-btn flat :label="t('globals.no')" color="primary" v-close-popup autofocus />
<q-btn flat :label="t('globals.yes')" color="primary" @click="remove()" />
</q-card-actions>
</q-card>
</q-dialog>
<div class="text-caption">
{{ arrayData.totalRows }} {{ t('claim.rmaList.records') }}
</div>
</QForm>
</QCard>
</QPageSticky>
<div class="card-list">
<VnPaginate
data-key="ClaimRmaList"
url="ClaimRmas"
order="id DESC"
:offset="50"
auto-load
>
<template #body="{ rows }">
<QCard class="card">
<template v-if="isLoading">
<QItem class="q-pa-none items-start">
<QItemSection class="q-pa-md">
<QList>
<QItem class="q-pa-none">
<QItemSection>
<QItemLabel caption>
<QSkeleton />
</QItemLabel>
<QItemLabel>
<QSkeleton type="text" />
</QItemLabel>
</QItemSection>
</QItem>
</QList>
</QItemSection>
<QCardActions vertical class="justify-between">
<QSkeleton
type="circle"
class="q-mb-md"
size="40px"
/>
</QCardActions>
</QItem>
<QSeparator />
</template>
<template v-for="row of rows" :key="row.id">
<QItem class="q-pa-none items-start">
<QItemSection class="q-pa-md">
<QList>
<QItem class="q-pa-none">
<QItemSection>
<QItemLabel caption>{{
t('claim.rmaList.code')
}}</QItemLabel>
<QItemLabel>{{ row.code }}</QItemLabel>
</QItemSection>
</QItem>
</QList>
</QItemSection>
<QCardActions vertical class="justify-between">
<QBtn
flat
round
color="primary"
icon="vn:bin"
@click="confirm(row.id)"
>
<QTooltip>{{ t('globals.remove') }}</QTooltip>
</QBtn>
</QCardActions>
</QItem>
<QSeparator />
</template>
</QCard>
</template>
</VnPaginate>
</div>
</QPage>
</template>
<style lang="scss" scoped>
@ -132,6 +157,7 @@ function hide() {
padding-top: 156px;
}
.card-list,
.card {
width: 100%;
max-width: 60em;

View File

@ -1,48 +0,0 @@
import { jest, describe, expect, it, beforeAll } from '@jest/globals';
import { createWrapper, axios } from 'app/tests/jest/jestHelpers';
import ClaimDescriptorMenu from '../Card/ClaimDescriptorMenu.vue';
const mockPush = jest.fn();
jest.mock('vue-router', () => ({
useRouter: () => ({
push: mockPush,
currentRoute: {
value: {
params: {
id: 1,
},
},
},
}),
}));
describe('ClaimDescriptorMenu', () => {
let vm;
beforeAll(() => {
vm = createWrapper(ClaimDescriptorMenu, {
propsData: {
claim: {
id: 1
}
}
}).vm;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('deleteClaim()', () => {
it('should delete the claim', async () => {
jest.spyOn(axios, 'delete').mockResolvedValue({ data: true });
jest.spyOn(vm.quasar, 'notify');
await vm.deleteClaim();
expect(vm.quasar.notify).toHaveBeenCalledWith(expect.objectContaining(
{ 'type': 'positive' }
));
});
});
});

View File

@ -48,15 +48,23 @@ const filterOptions = {
@on-fetch="setWorkers"
auto-load
/>
<fetch-data url="ContactChannels" @on-fetch="(data) => contactChannels = data" auto-load />
<fetch-data url="BusinessTypes" @on-fetch="(data) => businessTypes = data" auto-load />
<div class="container">
<q-card>
<form-model :url="`Clients/${route.params.id}`" model="customer">
<fetch-data
url="ContactChannels"
@on-fetch="(data) => (contactChannels = data)"
auto-load
/>
<fetch-data
url="BusinessTypes"
@on-fetch="(data) => (businessTypes = data)"
auto-load
/>
<div class="column items-center">
<QCard>
<FormModel :url="`Clients/${route.params.id}`" model="customer">
<template #form="{ data, validate, filter }">
<div class="row q-gutter-md q-mb-md">
<div class="col">
<q-input
<QInput
v-model="data.socialName"
:label="t('customer.basicData.socialName')"
:rules="validate('client.socialName')"
@ -64,7 +72,7 @@ const filterOptions = {
/>
</div>
<div class="col">
<q-select
<QSelect
v-model="data.businessTypeFk"
:options="businessTypes"
option-value="code"
@ -79,7 +87,7 @@ const filterOptions = {
</div>
<div class="row q-gutter-md q-mb-md">
<div class="col">
<q-input
<QInput
v-model="data.contact"
:label="t('customer.basicData.contact')"
:rules="validate('client.contact')"
@ -87,7 +95,7 @@ const filterOptions = {
/>
</div>
<div class="col">
<q-input
<QInput
v-model="data.email"
type="email"
:label="t('customer.basicData.email')"
@ -98,7 +106,7 @@ const filterOptions = {
</div>
<div class="row q-gutter-md q-mb-md">
<div class="col">
<q-input
<QInput
v-model="data.phone"
:label="t('customer.basicData.phone')"
:rules="validate('client.phone')"
@ -106,7 +114,7 @@ const filterOptions = {
/>
</div>
<div class="col">
<q-input
<QInput
v-model="data.mobile"
:label="t('customer.basicData.mobile')"
:rules="validate('client.mobile')"
@ -116,7 +124,7 @@ const filterOptions = {
</div>
<div class="row q-gutter-md q-mb-md">
<div class="col">
<q-select
<QSelect
v-model="data.salesPersonFk"
:options="workers"
option-value="id"
@ -125,23 +133,26 @@ const filterOptions = {
:label="t('customer.basicData.salesPerson')"
map-options
use-input
@filter="(value, update) => filter(value, update, filterOptions)"
@filter="
(value, update) =>
filter(value, update, filterOptions)
"
:rules="validate('client.salesPersonFk')"
:input-debounce="0"
>
<template #before>
<q-avatar color="orange">
<q-img
<template #prepend>
<QAvatar color="orange">
<QImg
v-if="data.salesPersonFk"
:src="`/api/Images/user/160x160/${data.salesPersonFk}/download?access_token=${token}`"
spinner-color="white"
/>
</q-avatar>
</QAvatar>
</template>
</q-select>
</QSelect>
</div>
<div class="col">
<q-select
<QSelect
v-model="data.contactChannelFk"
:options="contactChannels"
option-value="id"
@ -155,17 +166,12 @@ const filterOptions = {
</div>
</div>
</template>
</form-model>
</q-card>
</FormModel>
</QCard>
</div>
</template>
<style lang="scss" scoped>
.container {
display: flex;
justify-content: center;
}
.q-card {
width: 800px;
}

View File

@ -1,45 +1,38 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useState } from 'src/composables/useState';
import { useStateStore } from 'stores/useStateStore';
import CustomerDescriptor from './CustomerDescriptor.vue';
import LeftMenu from 'components/LeftMenu.vue';
import VnSearchbar from 'src/components/ui/VnSearchbar.vue';
const state = useState();
const stateStore = useStateStore();
const { t } = useI18n();
</script>
<template>
<q-drawer v-model="state.drawer.value" show-if-above :width="256" :breakpoint="500">
<q-scroll-area class="fit">
<customer-descriptor />
<q-separator />
<q-list>
<q-item :to="{ name: 'CustomerBasicData' }" clickable v-ripple>
<q-item-section avatar>
<q-icon name="vn:settings" />
</q-item-section>
<q-item-section>{{ t('customer.pageTitles.basicData') }}</q-item-section>
</q-item>
<!-- <q-item clickable v-ripple>
<q-item-section avatar>
<q-icon name="notes" />
</q-item-section>
<q-item-section>Notes</q-item-section>
</q-item>
<q-expansion-item icon="more" label="More options" expand-icon-toggle expand-separator>
<q-list>
<q-item clickable v-ripple>
<q-item-section avatar>
<q-icon name="person" />
</q-item-section>
<q-item-section>Option</q-item-section>
</q-item>
</q-list>
</q-expansion-item> -->
</q-list>
</q-scroll-area>
</q-drawer>
<q-page-container>
<q-page class="q-pa-md">
<router-view></router-view>
</q-page>
</q-page-container>
<Teleport to="#searchbar" v-if="stateStore.isHeaderMounted()">
<VnSearchbar
data-key="CustomerList"
url="Clients/filter"
:label="t('Search customer')"
:info="t('You can search by customer id or name')"
/>
</Teleport>
<QDrawer v-model="stateStore.leftDrawer" show-if-above :width="256">
<QScrollArea class="fit">
<CustomerDescriptor />
<QSeparator />
<LeftMenu source="card" />
</QScrollArea>
</QDrawer>
<QPageContainer>
<QPage class="q-pa-md">
<RouterView></RouterView>
</QPage>
</QPageContainer>
</template>
<i18n>
es:
Search customer: Buscar cliente
You can search by customer id or name: Puedes buscar por id o nombre del cliente
</i18n>

View File

@ -1,11 +1,10 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { toCurrency } from 'src/filters';
import axios from 'axios';
import CardDescriptor from 'components/ui/CardDescriptor.vue';
import SkeletonDescriptor from 'components/ui/SkeletonDescriptor.vue';
import WorkerDescriptorProxy from 'src/pages/Worker/Card/WorkerDescriptorProxy.vue';
const $props = defineProps({
id: {
@ -15,99 +14,153 @@ const $props = defineProps({
},
});
onMounted(async () => {
await fetch();
});
const route = useRoute();
const { t } = useI18n();
const entityId = computed(() => {
return $props.id || route.params.id;
});
const customer = ref();
async function fetch() {
const { data } = await axios.get(`Clients/${entityId.value}/getCard`);
if (data) customer.value = data;
}
</script>
<template>
<skeleton-descriptor v-if="!customer" />
<card-descriptor v-if="customer" module="Customer" :data="customer" :description="customer.name">
<!-- <template #menu>
<q-item clickable v-ripple>Option 1</q-item>
<q-item clickable v-ripple>Option 2</q-item>
</template> -->
<template #body>
<q-list>
<q-item v-if="customer.salesPersonUser">
<q-item-section>
<q-item-label caption>{{ t('customer.card.salesPerson') }}</q-item-label>
<q-item-label>{{ customer.salesPersonUser.name }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('customer.card.credit') }}</q-item-label>
<q-item-label>{{ toCurrency(customer.credit) }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('customer.card.securedCredit') }}</q-item-label>
<q-item-label>{{ toCurrency(customer.creditInsurance) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section v-if="customer.payMethod">
<q-item-label caption>{{ t('customer.card.payMethod') }}</q-item-label>
<q-item-label>{{ customer.payMethod.name }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('customer.card.debt') }}</q-item-label>
<q-item-label>{{ toCurrency(customer.debt) }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card-actions class="q-gutter-md">
<q-icon v-if="customer.isActive == false" name="vn:disabled" size="xs" color="primary">
<q-tooltip>{{ t('customer.card.isDisabled') }}</q-tooltip>
</q-icon>
<q-icon v-if="customer.isFreezed == true" name="vn:frozen" size="xs" color="primary">
<q-tooltip>{{ t('customer.card.isFrozen') }}</q-tooltip>
</q-icon>
<q-icon v-if="customer.debt > customer.credit" name="vn:risk" size="xs" color="primary">
<q-tooltip>{{ t('customer.card.hasDebt') }}</q-tooltip>
</q-icon>
<q-icon v-if="customer.isTaxDataChecked == false" name="vn:no036" size="xs" color="primary">
<q-tooltip>{{ t('customer.card.notChecked') }}</q-tooltip>
</q-icon>
<q-icon v-if="customer.account.active == false" name="vn:noweb" size="xs" color="primary">
<q-tooltip>{{ t('customer.card.noWebAccess') }}</q-tooltip>
</q-icon>
</q-card-actions>
<!-- <q-card-actions>
<q-btn size="md" icon="vn:ticket" color="primary">
<q-tooltip>Ticket list</q-tooltip>
</q-btn>
<CardDescriptor module="Customer" :url="`Clients/${entityId}/getCard`">
<template #body="{ entity }">
<QList dense>
<QItem v-if="entity.salesPersonUser" class="row">
<QItemLabel class="col" caption>
{{ t('customer.card.salesPerson') }}
</QItemLabel>
<QItemLabel class="col q-ma-none">
<span class="link">
{{ entity.salesPersonUser.name }}
<WorkerDescriptorProxy :id="entity.salesPersonFk" />
</span>
</QItemLabel>
</QItem>
<QItem class="row">
<QItemLabel class="col" caption>
{{ t('customer.card.credit') }}
</QItemLabel>
<QItemLabel class="col q-ma-none">
{{ toCurrency(entity.credit) }}
</QItemLabel>
</QItem>
<QItem class="row">
<QItemLabel class="col" caption>
{{ t('customer.card.securedCredit') }}
</QItemLabel>
<QItemLabel class="col q-ma-none">
{{ toCurrency(entity.creditInsurance) }}
</QItemLabel>
</QItem>
<QItem v-if="entity.payMethod" class="row">
<QItemLabel class="col" caption>
{{ t('customer.card.payMethod') }}
</QItemLabel>
<QItemLabel class="col q-ma-none">
{{ entity.payMethod.name }}
</QItemLabel>
</QItem>
<QItem class="row">
<QItemLabel class="col" caption>
{{ t('customer.card.debt') }}
</QItemLabel>
<QItemLabel class="col q-ma-none">
{{ toCurrency(entity.debt) }}
</QItemLabel>
</QItem>
</QList>
<QCardActions class="q-gutter-md">
<QIcon
v-if="entity.isActive == false"
name="vn:disabled"
size="xs"
color="primary"
>
<QTooltip>{{ t('customer.card.isDisabled') }}</QTooltip>
</QIcon>
<QIcon
v-if="entity.isFreezed == true"
name="vn:frozen"
size="xs"
color="primary"
>
<QTooltip>{{ t('customer.card.isFrozen') }}</QTooltip>
</QIcon>
<QIcon
v-if="entity.debt > entity.credit"
name="vn:risk"
size="xs"
color="primary"
>
<QTooltip>{{ t('customer.card.hasDebt') }}</QTooltip>
</QIcon>
<QIcon
v-if="entity.isTaxDataChecked == false"
name="vn:no036"
size="xs"
color="primary"
>
<QTooltip>{{ t('customer.card.notChecked') }}</QTooltip>
</QIcon>
<QIcon
v-if="entity.account && entity.account.active == false"
name="vn:noweb"
size="xs"
color="primary"
>
<QTooltip>{{ t('customer.card.noWebAccess') }}</QTooltip>
</QIcon>
</QCardActions>
<QCardActions>
<QBtn
:to="{
name: 'TicketList',
query: { params: JSON.stringify({ clientFk: entity.id }) },
}"
size="md"
icon="vn:ticket"
color="primary"
>
<QTooltip>{{ t('ticketList') }}</QTooltip>
</QBtn>
<QBtn
:to="{
name: 'InvoiceOutList',
query: { params: JSON.stringify({ clientFk: entity.id }) },
}"
size="md"
icon="vn:invoice-out"
color="primary"
>
<QTooltip>{{ t('invoiceOutList') }}</QTooltip>
</QBtn>
<!--
<QBtn size="md" icon="vn:basketadd" color="primary">
<QTooltip>Order list</QTooltip>
</QBtn>
<q-btn size="md" icon="vn:invoice-out" color="primary">
<q-tooltip>Invoice Out list</q-tooltip>
</q-btn>
<QBtn size="md" icon="face" color="primary">
<QTooltip>View user</QTooltip>
</QBtn>
<q-btn size="md" icon="vn:basketadd" color="primary">
<q-tooltip>Order list</q-tooltip>
</q-btn>
<q-btn size="md" icon="face" color="primary">
<q-tooltip>View user</q-tooltip>
</q-btn>
<q-btn size="md" icon="expand_more" color="primary">
<q-tooltip>More options</q-tooltip>
</q-btn>
</q-card-actions> -->
<QBtn size="md" icon="expand_more" color="primary">
<QTooltip>More options</QTooltip>
</QBtn> -->
</QCardActions>
</template>
</card-descriptor>
</CardDescriptor>
</template>
<i18n>
{
"en": {
"ticketList": "Customer ticket list",
"invoiceOutList": "Customer invoice out list"
},
"es": {
"ticketList": "Listado de tickets del cliente",
"invoiceOutList": "Listado de facturas del cliente"
}
}
</i18n>

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