Merge branch 'dev' into 4834-create-worker-module
gitea/salix-front/pipeline/head There was a failure building this commit Details

This commit is contained in:
Pau 2022-12-20 08:49:02 +01:00
commit 6e2425fd5a
89 changed files with 7978 additions and 6272 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
.DS_Store
.thumbs.db
node_modules
junit.xml
# Quasar core related directories
.quasar

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"
}

View File

@ -38,6 +38,8 @@ module.exports = {
'^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',

View File

@ -5,6 +5,7 @@
"src/*": ["src/*"],
"app/*": ["*"],
"components/*": ["src/components/*"],
"composables/*": ["src/composables/*"],
"layouts/*": ["src/layouts/*"],
"pages/*": ["src/pages/*"],
"assets/*": ["src/assets/*"],

10724
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,27 +18,29 @@
"test:e2e:ci": "cross-env E2E_TEST=true start-test \"quasar dev\" http-get://localhost:8080 \"cypress run\""
},
"dependencies": {
"@quasar/extras": "^1.15.5",
"axios": "^0.21.1",
"@quasar/extras": "^1.15.8",
"axios": "^1.2.1",
"core-js": "^3.6.5",
"quasar": "^2.10.0",
"pinia": "^2.0.28",
"quasar": "^2.11.1",
"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"
},
"devDependencies": {
"@babel/eslint-parser": "^7.13.14",
"@intlify/vue-i18n-loader": "^4.1.0",
"@pinia/testing": "^0.0.14",
"@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",
"@quasar/quasar-app-extension-testing-unit-jest": "^3.0.0-beta.5",
"eslint": "^8.30.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-jest": "^27.1.7",
"eslint-plugin-vue": "^8.7.1",
"eslint-webpack-plugin": "^3.2.0",
"jest-junit": "^13.0.0",
"prettier": "^2.5.1"
},

View File

@ -9,8 +9,8 @@
// https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js
const ESLintPlugin = require('eslint-webpack-plugin');
const { configure } = require('quasar/wrappers');
const path = require('path');
module.exports = configure(function (ctx) {
return {
@ -23,7 +23,7 @@ module.exports = configure(function (ctx) {
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-webpack/boot-files
boot: ['i18n', 'axios'],
boot: ['i18n', 'axios', 'pinia'],
// https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-css
css: ['app.scss'],
@ -38,8 +38,9 @@ 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
@ -68,15 +69,23 @@ module.exports = configure(function (ctx) {
chainWebpack(chain) {
chain.module
.rule("i18n")
.rule('i18n')
.resourceQuery(/blockType=i18n/)
.type('javascript/auto')
.use("i18n")
.loader("@intlify/vue-i18n-loader")
.use('i18n')
.loader('@intlify/vue-i18n-loader')
.end();
chain.plugin('eslint-webpack-plugin')
.use(ESLintPlugin, [{ extensions: ['js', 'vue'] }]);
chain.plugin('eslint-webpack-plugin').use(ESLintPlugin, [{ extensions: ['js', 'vue'] }]);
},
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'),
};
},
},
@ -91,7 +100,7 @@ module.exports = configure(function (ctx) {
target: 'http://0.0.0.0:3000',
logLevel: 'debug',
changeOrigin: true,
secure: false
secure: false,
},
},
},
@ -100,9 +109,9 @@ module.exports = configure(function (ctx) {
framework: {
config: {
brand: {
primary: 'orange'
primary: 'orange',
},
dark: 'auto'
dark: 'auto',
},
lang: 'es',

View File

@ -1,4 +1,5 @@
<script setup>
import { onMounted } from 'vue';
import axios from 'axios';
import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
@ -8,9 +9,22 @@ import { useSession } from 'src/composables/useSession';
const quasar = useQuasar();
const router = useRouter();
const session = useSession();
const { t } = useI18n();
const { t, availableLocales, locale, fallbackLocale } = useI18n();
const { isLoggedIn } = session;
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);
@ -19,6 +33,11 @@ quasar.iconMapFn = (iconName) => {
cls: `icon-${name}`,
};
}
return {
cls: 'material-symbols-outlined',
content: iconName,
};
};
function responseError(error) {

View File

@ -11,21 +11,14 @@ const session = useSession();
jest.mock('vue-router', () => ({
useRouter: () => ({
push: mockPush,
currentRoute: { value: 'myCurrentRoute' }
currentRoute: { value: 'myCurrentRoute' },
}),
}));
jest.mock('src/composables/useSession', () => ({
useSession: () => ({
isLoggedIn: mockLoggedIn,
destroy: mockDestroy
}),
}));
jest.mock('vue-i18n', () => ({
createI18n: () => { },
useI18n: () => ({
t: () => { }
destroy: mockDestroy,
}),
}));
@ -34,11 +27,10 @@ describe('App', () => {
beforeAll(() => {
const options = {
global: {
stubs: ['router-view']
}
stubs: ['router-view'],
},
};
vm = createWrapper(App, options).vm;
});
it('should return a login error message', async () => {
@ -48,17 +40,17 @@ describe('App', () => {
const response = {
response: {
status: 401
}
status: 401,
},
};
expect(vm.responseError(response)).rejects.toEqual(expect.objectContaining(response));
expect(vm.quasar.notify).toHaveBeenCalledWith(expect.objectContaining(
{
expect(vm.quasar.notify).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Invalid username or password',
type: 'negative',
message: 'login.loginError'
}
));
})
);
});
it('should return an unauthorized error message', async () => {
@ -68,17 +60,17 @@ describe('App', () => {
const response = {
response: {
status: 401
}
status: 401,
},
};
expect(vm.responseError(response)).rejects.toEqual(expect.objectContaining(response));
expect(vm.quasar.notify).toHaveBeenCalledWith(expect.objectContaining(
{
expect(vm.quasar.notify).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Access denied',
type: 'negative',
message: 'errors.statusUnauthorized'
}
));
})
);
expect(session.destroy).toHaveBeenCalled();
});

View File

@ -3,9 +3,11 @@ import { createI18n } from 'vue-i18n';
import messages from 'src/i18n';
const i18n = createI18n({
locale: 'es',
locale: 'en',
fallbackLocale: 'en',
messages,
legacy: false
legacy: false,
missingWarn: false
});
export default boot(({ app }) => {

8
src/boot/pinia.js Normal file
View File

@ -0,0 +1,8 @@
import { boot } from 'quasar/wrappers';
import { createPinia } from 'pinia';
export default boot(({ app }) => {
const pinia = createPinia();
app.use(pinia);
});

View File

@ -6,7 +6,7 @@ import axios from 'axios';
import { useState } from 'src/composables/useState';
import { useValidator } from 'src/composables/useValidator';
import SkeletonForm from 'src/components/SkeletonForm.vue';
import SkeletonForm from 'components/ui/SkeletonForm.vue';
const quasar = useQuasar();
const { t } = useI18n();

View File

@ -1,17 +1,107 @@
<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 { 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'),
@ -22,149 +112,89 @@ async function onToggleFavoriteModule(moduleName, event) {
<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)"
<template v-if="$props.source === 'main'">
<q-item-label header>
{{ t('globals.pinnedModules') }}
</q-item-label>
<template v-for="item in pinnedItems" :key="item.name">
<template v-if="item.children">
<left-menu-item-group :item="item" group="pinnedModules" class="pinned">
<template #side>
<q-btn
v-if="item.isPinned === true"
@click="togglePinned(item, $event)"
icon="vn:pin_off"
size="xs"
flat
round
>
<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>
<q-tooltip>{{ t('components.leftMenu.removeFromPinned') }}</q-tooltip>
</q-btn>
<q-btn
v-if="item.isPinned === false"
@click="togglePinned(item, $event)"
icon="vn:pin"
size="xs"
flat
round
>
<q-tooltip>{{ t('components.leftMenu.addToPinned') }}</q-tooltip>
</q-btn>
</template>
</left-menu-item-group>
</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>
<left-menu-item v-if="!item.children" :item="item" />
</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-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)"
<template v-for="item in items" :key="item.name">
<template v-if="item.children">
<left-menu-item-group :item="item" group="modules">
<template #side>
<q-btn
v-if="item.isPinned === true"
@click="togglePinned(item, $event)"
icon="vn:pin_off"
size="xs"
flat
round
>
<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-tooltip>{{ t('components.leftMenu.removeFromPinned') }}</q-tooltip>
</q-btn>
<q-btn
v-if="item.isPinned === false"
@click="togglePinned(item, $event)"
icon="vn:pin"
size="xs"
flat
round
>
<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>
<q-tooltip>{{ t('components.leftMenu.addToPinned') }}</q-tooltip>
</q-btn>
</template>
</left-menu-item-group>
</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>
<q-separator />
</template>
<template v-if="$props.source === 'card'">
<template v-for="item in items" :key="item.name">
<left-menu-item v-if="!item.children" :item="item" />
</template>
</template>
</q-list>
</q-expansion-item>
</template>
<style>
.module .icon-pin,
.module .icon-pin_off {
.pinned .icon-pin,
.pinned .icon-pin_off {
visibility: hidden;
}
.module:hover .icon-pin,
.module:hover .icon-pin_off {
.pinned:hover .icon-pin,
.pinned:hover .icon-pin_off {
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>
<q-item active-class="text-primary" :to="{ name: item.name }" clickable v-ripple>
<q-item-section avatar v-if="item.icon">
<q-icon :name="item.icon" />
</q-item-section>
<q-item-section avatar v-if="!item.icon">
<q-icon name="disabled_by_default" />
</q-item-section>
<q-item-section>{{ t(item.title) }}</q-item-section>
</q-item>
</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>
<q-expansion-item
:group="props.group"
active-class="text-primary"
:label="item.title"
:to="{ name: item.name }"
expand-separator
:default-opened="isOpened"
>
<template #header>
<q-item-section avatar>
<q-icon :name="item.icon"></q-icon>
</q-item-section>
<q-item-section>{{ t(item.title) }}</q-item-section>
<q-item-section side>
<slot name="side" :item="item" />
</q-item-section>
</template>
<template v-for="section in item.children" :key="section.name">
<left-menu-item :item="section" />
</template>
</q-expansion-item>
</template>

View File

@ -1,9 +1,10 @@
<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 'src/components/UserPanel.vue';
import FavoriteModules from './FavoriteModules.vue';
import UserPanel from 'components/UserPanel.vue';
import PinnedModules from './PinnedModules.vue';
const { t } = useI18n();
const session = useSession();
@ -11,6 +12,8 @@ const state = useState();
const user = state.getUser();
const token = session.getToken();
onMounted(() => (state.headerMounted.value = true));
function onToggleDrawer() {
state.drawer.value = !state.drawer.value;
}
@ -36,42 +39,17 @@ function onToggleDrawer() {
</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">
<!-- <q-btn v-if="$q.screen.gt.xs" dense flat size="md" icon="add">
<q-menu>
<q-list style="min-width: 150px">
<q-item :to="{ path: '/customer/create' }" clickable>
<q-item-section>New customer</q-item-section>
</q-item>
<q-item clickable>
<q-item-section>New ticket</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn> -->
<!-- <q-btn v-if="$q.screen.gt.xs" dense flat round size="md" icon="notifications">
<q-badge color="red" text-color="white" floating> 2 </q-badge>
<div id="header-actions"></div>
<q-btn id="pinnedModules" icon="apps" flat dense rounded>
<q-tooltip bottom>
{{ t('globals.notifications') }}
{{ t('globals.pinnedModules') }}
</q-tooltip>
<q-menu class="q-pa-md" style="min-width: 250px">
<strong>Notifications</strong>
<q-separator />
<div style="text-align: center; font-size: 2em">
<q-spinner-puff color="orange" />
</div>
</q-menu>
</q-btn> -->
<q-btn dense flat no-wrap id="favoriteModules">
<q-avatar size="lg">
<q-icon name="apps" size="s" />
</q-avatar>
<q-tooltip bottom>
{{ t('globals.favoriteModules') }}
</q-tooltip>
<FavoriteModules />
<PinnedModules />
</q-btn>
<q-btn dense flat no-wrap id="user">
<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}`"
@ -79,7 +57,6 @@ function onToggleDrawer() {
>
</q-img>
</q-avatar>
<q-icon name="arrow_drop_down" size="s" />
<q-tooltip bottom>
{{ t('globals.userPanel') }}
</q-tooltip>

View File

@ -1,14 +1,16 @@
<script setup>
import { onMounted } from 'vue';
import { onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useNavigation } from 'src/composables/useNavigation';
import { useNavigationStore } from 'src/stores/useNavigationStore';
const navigation = useNavigationStore();
const { t } = useI18n();
const navigation = useNavigation();
onMounted(() => {
navigation.fetchFavorites();
navigation.fetchPinned();
});
const pinnedModules = computed(() => navigation.getPinnedModules());
</script>
<template>
@ -17,22 +19,22 @@ onMounted(() => {
class="row q-pa-md q-col-gutter-lg"
max-width="350px"
max-height="400px"
v-if="navigation.favorites.value.length"
v-if="pinnedModules.length"
>
<div v-for="module of navigation.favorites.value" :key="module.title" class="row no-wrap q-pa-xs flex-item">
<div v-for="item of pinnedModules" :key="item.title" class="row no-wrap q-pa-xs flex-item">
<q-btn
align="evenly"
padding="16px"
flat
stack
size="lg"
:icon="module.icon"
:icon="item.icon"
color="primary"
class="col-4 button"
:to="{ name: module.stateName }"
:to="{ name: item.name }"
>
<div class="text-center text-primary button-text">
{{ t(`${module.name}.pageTitles.${module.title}`) }}
{{ t(item.title) }}
</div>
</q-btn>
</div>

View File

@ -45,7 +45,7 @@ onMounted(async () => {
});
function updatePreferences() {
if (user.value.darkMode) {
if (user.value.darkMode !== null) {
darkMode.value = user.value.darkMode;
}
if (user.value.lang) {

View File

@ -2,31 +2,32 @@ import { jest, describe, expect, it, beforeAll } from '@jest/globals';
import { createWrapper } from 'app/tests/jest/jestHelpers';
import Leftmenu from '../LeftMenu.vue';
import { createTestingPinia } from '@pinia/testing';
import { useNavigationStore } from 'src/stores/useNavigationStore';
const mockPush = jest.fn();
jest.mock('vue-router', () => ({
useRouter: () => ({
push: mockPush,
currentRoute: { value: 'myCurrentRoute' }
currentRoute: { value: 'myCurrentRoute' },
}),
useRoute: () => ({
matched: [],
}),
}));
jest.mock('src/router/routes', () => ([
{
path: '/',
name: 'Main',
children: [
{
path: '/dashboard',
name: 'Dashboard',
meta: { title: 'dashboard', icon: 'dashboard' }
},
jest.mock('src/router/modules', () => [
{
path: '/customer',
name: 'Customer',
meta: {
title: 'customers',
icon: 'vn:client'
icon: 'vn:client',
},
menus: {
main: ['CustomerList', 'CustomerCreate'],
card: ['CustomerBasicData'],
},
children: [
{
@ -39,7 +40,7 @@ jest.mock('src/router/routes', () => ([
meta: {
title: 'list',
icon: 'view_list',
}
},
},
{
path: 'create',
@ -47,52 +48,57 @@ jest.mock('src/router/routes', () => ([
meta: {
title: 'createCustomer',
icon: 'vn:addperson',
}
},
]
}
]
}
},
],
},
]));
],
},
]);
describe('Leftmenu', () => {
let vm;
beforeAll(() => {
vm = createWrapper(Leftmenu).vm;
let navigation;
beforeAll(async () => {
vm = createWrapper(Leftmenu, {
propsData: {
source: 'main',
},
global: {
plugins: [createTestingPinia({ stubActions: false })],
},
}).vm;
navigation = useNavigationStore();
navigation.modules = ['customer']; // I should mock to have just one module but isn´t working
navigation.fetchPinned = jest.fn().mockReturnValue(Promise.resolve(true));
navigation.getModules = jest.fn().mockReturnValue({
value: [
{
name: 'customer',
title: 'customer.pageTitles.customers',
icon: 'vn:customer',
module: 'customer',
},
],
});
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 = [{
const expectedMenuItem = [
{
name: 'CustomerList',
title: 'list',
title: 'customer.pageTitles.list',
icon: 'view_list',
stateName: 'CustomerList'
},
{
name: 'CustomerCreate',
title: 'createCustomer',
title: 'customer.pageTitles.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)
const firstMenuItem = vm.items[0];
expect(firstMenuItem.children).toEqual(expect.arrayContaining(expectedMenuItem));
});
});

View File

@ -1,6 +1,6 @@
import { jest, describe, expect, it, beforeAll } from '@jest/globals';
import { createWrapper, axios } from 'app/tests/jest/jestHelpers';
import Paginate from '../Paginate.vue';
import Paginate from '../PaginateData.vue';
const mockPush = jest.fn();

View File

@ -0,0 +1,72 @@
<script setup>
import { ref } from 'vue';
import { useDialogPluginComponent } from 'quasar';
import { useI18n } from 'vue-i18n';
const $props = defineProps({
address: {
type: String,
default: '',
},
send: {
type: Function,
required: true,
},
});
defineEmits(['confirm', ...useDialogPluginComponent.emits]);
const { dialogRef, onDialogOK } = useDialogPluginComponent();
const { t } = useI18n();
const address = ref($props.address);
const isLoading = ref(false);
async function confirm() {
isLoading.value = true;
await $props.send(address.value);
isLoading.value = false;
onDialogOK();
}
</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>
</template>
<style lang="scss" scoped>
.q-card {
min-width: 350px;
}
</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"
}
}
</i18n>

View File

@ -98,9 +98,5 @@ const { t } = useI18n();
justify-content: space-between;
align-items: stretch;
}
#descriptor-skeleton .q-card__actions {
justify-content: space-between;
}
}
</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

@ -0,0 +1,24 @@
<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" />
</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>
</template>
<style lang="scss" scoped>
#descriptor-skeleton .q-card__actions {
justify-content: space-between;
}
</style>

View File

@ -0,0 +1,20 @@
<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

@ -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)
});
});

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

@ -0,0 +1,36 @@
import { useSession } from './useSession';
import axios from 'axios';
import { useQuasar } from 'quasar';
export function usePrintService() {
const quasar = useQuasar();
const { getToken } = useSession();
function sendEmail(path, params) {
return axios.post(path, params).then(() =>
quasar.notify({
message: 'Notification sent',
type: 'positive',
icon: 'check',
})
);
}
function openReport(path, params) {
params = Object.assign(
{
access_token: getToken(),
},
params
);
const query = new URLSearchParams(params).toString();
window.open(`api/${path}?${query}`);
}
return {
sendEmail,
openReport,
};
}

View File

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

View File

@ -12,6 +12,7 @@ const user = ref({
const roles = ref([]);
const drawer = ref(true);
const headerMounted = ref(false);
export function useState() {
function getUser() {
@ -67,6 +68,7 @@ export function useState() {
set,
get,
unset,
drawer
drawer,
headerMounted
};
}

View File

@ -1,9 +1,19 @@
// app global css in SCSS form
@import './icons.scss';
.body--dark {
.q-card--dark {
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2), 0 2px 2px rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.12);
}
.q-layout__shadow::after {
box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.2), 0 0px 10px rgba(0, 0, 0, 0.24) !important;
}
}
.link {
color: $primary;
cursor: pointer
cursor: pointer;
}
.link:hover {

View File

@ -22,3 +22,9 @@ $positive: #21ba45;
$negative: #c10015;
$info: #31ccec;
$warning: #f2c037;
$color-spacer-light: rgba(255, 255, 255, .12);
$color-spacer:rgba(255, 255, 255, .3);
$border-thin-light: 1px solid $color-spacer-light;
$spacing-md: 16px;

View File

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

View File

@ -2,10 +2,14 @@ import toLowerCase from './toLowerCase';
import toDate from './toDate';
import toCurrency from './toCurrency';
import toPercentage from './toPercentage';
import toLowerCamel from './toLowerCamel';
import dashIfEmpty from './dashIfEmpty';
export {
toLowerCase,
toLowerCamel,
toDate,
toCurrency,
toPercentage,
dashIfEmpty,
};

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,16 +9,18 @@ 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',
dataDeleted: 'Data deleted',
add: 'Add',
create: 'Create',
save: 'Save',
remove: 'Remove',
reset: 'Reset',
cancel: 'Cancel',
confirm: 'Confirm',
back: 'Back',
yes: 'Yes',
no: 'No',
@ -27,10 +29,10 @@ export default {
confirmRemove: 'You are about to delete this row. Are you sure?',
rowAdded: 'Row added',
rowRemoved: 'Row removed',
pleaseWait: 'Please wait...'
pleaseWait: 'Please wait...',
},
moduleIndex: {
allModules: 'All modules'
allModules: 'All modules',
},
errors: {
statusUnauthorized: 'Access denied',
@ -46,12 +48,12 @@ export default {
keepLogin: 'Keep me logged in',
loginSuccess: 'You have successfully logged in',
loginError: 'Invalid username or password',
fieldRequired: 'This field is required'
fieldRequired: 'This field is required',
},
dashboard: {
pageTitles: {
dashboard: 'Dashboard',
}
},
},
customer: {
pageTitles: {
@ -59,13 +61,13 @@ export default {
list: 'List',
createCustomer: 'Create customer',
summary: 'Summary',
basicData: 'Basic Data'
basicData: 'Basic Data',
},
list: {
phone: 'Phone',
email: 'Email',
customerOrders: 'Display customer orders',
moreOptions: 'More options'
moreOptions: 'More options',
},
card: {
customerList: 'Customer list',
@ -79,7 +81,7 @@ export default {
isFrozen: 'Customer is frozen',
hasDebt: 'Customer has debt',
notChecked: 'Customer not checked',
noWebAccess: 'Web access is disabled'
noWebAccess: 'Web access is disabled',
},
summary: {
basicData: 'Basic data',
@ -146,8 +148,8 @@ export default {
phone: 'Phone',
mobile: 'Mobile',
salesPerson: 'Sales person',
contactChannel: 'Contact channel'
}
contactChannel: 'Contact channel',
},
},
ticket: {
pageTitles: {
@ -156,7 +158,7 @@ export default {
createTicket: 'Create ticket',
summary: 'Summary',
basicData: 'Basic Data',
boxing: 'Boxing'
boxing: 'Boxing',
},
list: {
nickname: 'Nickname',
@ -164,7 +166,7 @@ export default {
shipped: 'Shipped',
landed: 'Landed',
salesPerson: 'Sales person',
total: 'Total'
total: 'Total',
},
card: {
ticketId: 'Ticket ID',
@ -174,7 +176,7 @@ export default {
agency: 'Agency',
shipped: 'Shipped',
warehouse: 'Warehouse',
customerCard: 'Customer card'
customerCard: 'Customer card',
},
boxing: {
expedition: 'Expedition',
@ -183,8 +185,51 @@ export default {
worker: 'Worker',
selectTime: 'Select time:',
selectVideo: 'Select video:',
notFound: 'No videos available'
}
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: {
@ -194,21 +239,21 @@ export default {
rmaList: 'RMA',
summary: 'Summary',
basicData: 'Basic Data',
rma: 'RMA'
rma: 'RMA',
},
list: {
customer: 'Customer',
assignedTo: 'Assigned',
created: 'Created',
state: 'State'
state: 'State',
},
rmaList: {
code: 'Code',
records: 'records'
records: 'records',
},
rma: {
user: 'User',
created: 'Created'
created: 'Created',
},
card: {
claimId: 'Claim ID',
@ -217,7 +262,7 @@ export default {
state: 'State',
ticketId: 'Ticket ID',
customerSummary: 'Customer summary',
claimedTicket: 'Claimed ticket'
claimedTicket: 'Claimed ticket',
},
summary: {
customer: 'Customer',
@ -237,7 +282,7 @@ export default {
actions: 'Actions',
responsibility: 'Responsibility',
company: 'Company',
person: 'Employee/Customer'
person: 'Employee/Customer',
},
basicData: {
customer: 'Customer',
@ -246,7 +291,50 @@ export default {
state: 'State',
packages: 'Packages',
picked: 'Picked',
returnOfMaterial: 'Return of material authorization (RMA)'
returnOfMaterial: 'Return of material authorization (RMA)',
},
},
invoiceOut: {
pageTitles: {
invoiceOuts: 'InvoiceOuts',
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: {
@ -299,12 +387,16 @@ export default {
noData: 'No data to display',
openCard: 'View card',
openSummary: 'Open summary',
viewDescription: 'View description'
viewDescription: 'View description',
},
cardDescriptor: {
mainList: 'Main list',
summary: 'Summary',
moreOptions: 'More options'
}
}
moreOptions: 'More options',
},
leftMenu: {
addToPinned: 'Add to pinned',
removeFromPinned: 'Remove from pinned',
},
},
};

View File

@ -9,16 +9,18 @@ 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',
add: 'Añadir',
create: 'Crear',
save: 'Guardar',
remove: 'Eliminar',
reset: 'Restaurar',
cancel: 'Cancelar',
confirm: 'Confirmar',
back: 'Volver',
yes: 'Si',
no: 'No',
@ -27,10 +29,10 @@ export default {
confirmRemove: 'Vas a eliminar este registro. ¿Continuar?',
rowAdded: 'Fila añadida',
rowRemoved: 'Fila eliminada',
pleaseWait: 'Por favor, espera...'
pleaseWait: 'Por favor, espera...',
},
moduleIndex: {
allModules: 'Todos los módulos'
allModules: 'Todos los módulos',
},
errors: {
statusUnauthorized: 'Acceso denegado',
@ -46,12 +48,12 @@ export default {
keepLogin: 'Mantener sesión iniciada',
loginSuccess: 'Inicio de sesión correcto',
loginError: 'Nombre de usuario o contraseña incorrectos',
fieldRequired: 'Este campo es obligatorio'
fieldRequired: 'Este campo es obligatorio',
},
dashboard: {
pageTitles: {
dashboard: 'Tablón',
}
},
},
customer: {
pageTitles: {
@ -59,13 +61,13 @@ export default {
list: 'Listado',
createCustomer: 'Crear cliente',
summary: 'Resumen',
basicData: 'Datos básicos'
basicData: 'Datos básicos',
},
list: {
phone: 'Teléfono',
email: 'Email',
customerOrders: 'Mostrar órdenes del cliente',
moreOptions: 'Más opciones'
moreOptions: 'Más opciones',
},
card: {
customerId: 'ID cliente',
@ -78,7 +80,7 @@ export default {
isFrozen: 'El cliente está congelado',
hasDebt: 'El cliente tiene riesgo',
notChecked: 'El cliente no está comprobado',
noWebAccess: 'El acceso web está desactivado'
noWebAccess: 'El acceso web está desactivado',
},
summary: {
basicData: 'Datos básicos',
@ -145,8 +147,8 @@ export default {
phone: 'Teléfono',
mobile: 'Móvil',
salesPerson: 'Comercial',
contactChannel: 'Canal de contacto'
}
contactChannel: 'Canal de contacto',
},
},
ticket: {
pageTitles: {
@ -155,7 +157,7 @@ export default {
createTicket: 'Crear ticket',
summary: 'Resumen',
basicData: 'Datos básicos',
boxing: 'Encajado'
boxing: 'Encajado',
},
list: {
nickname: 'Alias',
@ -163,7 +165,7 @@ export default {
shipped: 'Enviado',
landed: 'Entregado',
salesPerson: 'Comercial',
total: 'Total'
total: 'Total',
},
card: {
ticketId: 'ID ticket',
@ -173,7 +175,7 @@ export default {
agency: 'Agencia',
shipped: 'Enviado',
warehouse: 'Almacén',
customerCard: 'Ficha del cliente'
customerCard: 'Ficha del cliente',
},
boxing: {
expedition: 'Expedición',
@ -182,8 +184,51 @@ export default {
worker: 'Trabajador',
selectTime: 'Seleccionar hora:',
selectVideo: 'Seleccionar vídeo:',
notFound: 'No hay vídeos disponibles'
}
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: {
@ -193,21 +238,21 @@ export default {
rmaList: 'RMA',
summary: 'Resumen',
basicData: 'Datos básicos',
rma: 'RMA'
rma: 'RMA',
},
list: {
customer: 'Cliente',
assignedTo: 'Asignada a',
created: 'Creada',
state: 'Estado'
state: 'Estado',
},
rmaList: {
code: 'Código',
records: 'registros'
records: 'registros',
},
rma: {
user: 'Usuario',
created: 'Creado'
created: 'Creado',
},
card: {
claimId: 'ID reclamación',
@ -216,7 +261,7 @@ export default {
state: 'Estado',
ticketId: 'ID ticket',
customerSummary: 'Resumen del cliente',
claimedTicket: 'Ticket reclamado'
claimedTicket: 'Ticket reclamado',
},
summary: {
customer: 'Cliente',
@ -236,7 +281,7 @@ export default {
actions: 'Acciones',
responsibility: 'Responsabilidad',
company: 'Empresa',
person: 'Comercial/Cliente'
person: 'Comercial/Cliente',
},
basicData: {
customer: 'Cliente',
@ -245,9 +290,53 @@ export default {
state: 'Estado',
packages: 'Bultos',
picked: 'Recogida',
returnOfMaterial: 'Autorización de retorno de materiales (RMA)'
}
}, worker: {
returnOfMaterial: 'Autorización de retorno de materiales (RMA)',
},
},
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',
@ -297,12 +386,16 @@ export default {
noData: 'Sin datos que mostrar',
openCard: 'Ver ficha',
openSummary: 'Abrir detalles',
viewDescription: 'Ver descripción'
viewDescription: 'Ver descripción',
},
cardDescriptor: {
mainList: 'Listado principal',
summary: 'Resumen',
moreOptions: 'Más opciones',
}
}
},
leftMenu: {
addToPinned: 'Añadir a fijados',
removeFromPinned: 'Eliminar de fijados',
},
},
};

View File

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

View File

@ -4,8 +4,8 @@ import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useSession } from 'src/composables/useSession';
import FetchData from 'src/components/FetchData.vue';
import FormModel from 'src/components/FormModel.vue';
import FetchData from 'components/FetchData.vue';
import FormModel from 'components/FormModel.vue';
const route = useRoute();
const { t } = useI18n();

View File

@ -1,9 +1,8 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useState } from 'src/composables/useState';
import { useState } from 'composables/useState';
import ClaimDescriptor from './ClaimDescriptor.vue';
import LeftMenu from 'components/LeftMenu.vue';
const { t } = useI18n();
const state = useState();
</script>
<template>
@ -11,20 +10,7 @@ const state = useState();
<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>
<left-menu source="card" />
</q-scroll-area>
</q-drawer>
<q-page-container>

View File

@ -4,8 +4,10 @@ import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { toDate } from 'src/filters';
import axios from 'axios';
import CardDescriptor from 'src/components/ui/CardDescriptor.vue';
import TicketDescriptorPopover from 'src/pages/Ticket/Card/TicketDescriptorPopover.vue';
import TicketDescriptorPopover from 'pages/Ticket/Card/TicketDescriptorPopover.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: {
@ -57,7 +59,11 @@ 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" />
</template>
<template #body>
<q-list>
<q-item>

View File

@ -0,0 +1,132 @@
<script setup>
import axios from 'axios';
import { ref } from 'vue';
import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { usePrintService } from 'composables/usePrintService';
import SendEmailDialog from 'components/common/SendEmailDialog.vue';
const $props = defineProps({
claim: {
type: Object,
required: true,
},
});
const router = useRouter();
const quasar = useQuasar();
const { t } = useI18n();
const { openReport, sendEmail } = usePrintService();
const claim = ref($props.claim);
function openPickupOrder() {
const id = claim.value.id;
openReport(`Claims/${id}/claim-pickup-pdf`, {
recipientId: claim.value.clientFk,
});
}
function confirmPickupOrder() {
const customer = claim.value.client;
quasar.dialog({
component: SendEmailDialog,
componentProps: {
address: customer.email,
send: sendPickupOrder,
},
});
}
function sendPickupOrder(address) {
const id = claim.value.id;
const customer = claim.value.client;
return sendEmail(`Claims/${id}/claim-pickup-email`, {
recipientId: customer.id,
recipient: address,
});
}
const showConfirmDialog = ref(false);
async function deleteClaim() {
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>
</template>
<i18n>
{
"en": {
"pickupOrder": "Pickup order",
"openPickupOrder": "Open pickup order",
"sendPickupOrder": "Send pickup order",
"deleteClaim": "Delete claim",
"confirmDeletion": "Confirm deletion",
"confirmDeletionMessage": "Are you sure you want to delete this claim?"
},
"es": {
"pickupOrder": "Orden de recogida",
"openPickupOrder": "Abrir orden de recogida",
"sendPickupOrder": "Enviar orden de recogida",
"deleteClaim": "Eliminar reclamación",
"confirmDeletion": "Confirmar eliminación",
"confirmDeletionMessage": "Seguro que quieres eliminar esta reclamación?"
}
}
</i18n>

View File

@ -4,8 +4,9 @@ import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { useRoute } from 'vue-router';
import axios from 'axios';
import Paginate from 'src/components/Paginate.vue';
import FetchData from 'src/components/FetchData.vue';
import Paginate from 'src/components/PaginateData.vue';
import FetchData from 'components/FetchData.vue';
import TeleportSlot from 'components/ui/TeleportSlot';
import { toDate } from 'src/filters';
const quasar = useQuasar();
@ -77,19 +78,9 @@ function hide() {
ref="fetcher"
:url="`Claims/${route.params.id}`"
:filter="filter"
@on-fetch="($data) => (claim = $data)"
@on-fetch="(data) => (claim = data)"
auto-load
/>
<div class="sticky-page">
<q-page-sticky expand position="top">
<q-toolbar class="bg-grey-9">
<q-space />
<div class="q-gutter-md">
<q-btn icon="add" :label="t('globals.add')" color="primary" @click="addRow()" />
</div>
</q-toolbar>
</q-page-sticky>
<paginate :data="claim.rmas">
<template #body="{ rows }">
<q-card class="card">
@ -124,7 +115,7 @@ function hide() {
</q-card>
</template>
</paginate>
</div>
<q-dialog v-model="confirmShown" persistent @hide="hide">
<q-card>
<q-card-section class="row items-center">
@ -138,6 +129,20 @@ function hide() {
</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 />
</div>
</teleport-slot>
<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>
</template>
<style lang="scss" scoped>

View File

@ -4,7 +4,7 @@ import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import { toDate, toCurrency } from 'src/filters';
import SkeletonSummary from 'src/components/SkeletonSummary';
import SkeletonSummary from 'components/ui/SkeletonSummary.vue';
onMounted(() => fetch());

View File

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

View File

@ -2,7 +2,7 @@
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import Paginate from 'src/components/Paginate.vue';
import Paginate from 'src/components/PaginateData.vue';
import { toDate } from 'src/filters/index';
import ClaimSummaryDialog from './Card/ClaimSummaryDialog.vue';
import CustomerDescriptorPopover from 'src/pages/Customer/Card/CustomerDescriptorPopover.vue';

View File

@ -1,6 +1,6 @@
<script setup>
import { useState } from 'src/composables/useState';
import LeftMenu from 'src/components/LeftMenu.vue';
import LeftMenu from 'components/LeftMenu.vue';
const state = useState();
</script>

View File

@ -3,7 +3,7 @@ import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import axios from 'axios';
import Paginate from 'src/components/Paginate.vue';
import Paginate from 'src/components/PaginateData.vue';
const quasar = useQuasar();
const { t } = useI18n();

View File

@ -0,0 +1,48 @@
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

@ -4,8 +4,8 @@ import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useSession } from 'src/composables/useSession';
import FetchData from 'src/components/FetchData.vue';
import FormModel from 'src/components/FormModel.vue';
import FetchData from 'components/FetchData.vue';
import FormModel from 'components/FormModel.vue';
const route = useRoute();
const { t } = useI18n();
@ -33,7 +33,7 @@ const filterOptions = {
const id = row.id;
const name = row.name.toLowerCase();
const idMatches = id == search;
const idMatches = id === search;
const nameMatches = name.indexOf(search) > -1;
return idMatches || nameMatches;
@ -48,9 +48,8 @@ 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 />
<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">

View File

@ -1,40 +1,16 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useState } from 'src/composables/useState';
import CustomerDescriptor from './CustomerDescriptor.vue';
import LeftMenu from 'components/LeftMenu.vue';
const state = useState();
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>
<left-menu source="card" />
</q-scroll-area>
</q-drawer>
<q-page-container>

View File

@ -4,7 +4,8 @@ import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { toCurrency } from 'src/filters';
import axios from 'axios';
import CardDescriptor from 'src/components/ui/CardDescriptor.vue';
import CardDescriptor from 'components/ui/CardDescriptor.vue';
import SkeletonDescriptor from 'components/ui/SkeletonDescriptor.vue';
const $props = defineProps({
id: {
@ -34,6 +35,7 @@ async function fetch() {
</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>

View File

@ -4,7 +4,7 @@ import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import { toCurrency, toPercentage, toDate } from 'src/filters';
import SkeletonSummary from 'src/components/SkeletonSummary';
import SkeletonSummary from 'components/ui/SkeletonSummary.vue';
onMounted(() => fetch());

View File

@ -19,3 +19,11 @@ const { dialogRef, onDialogHide } = useDialogPluginComponent();
<customer-summary v-if="$props.id" :id="$props.id" />
</q-dialog>
</template>
<style lang="scss">
.q-dialog .summary .header {
position: sticky;
z-index: $z-max;
top: 0;
}
</style>

View File

@ -2,7 +2,7 @@
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import Paginate from 'src/components/Paginate.vue';
import Paginate from 'src/components/PaginateData.vue';
import CustomerSummaryDialog from './Card/CustomerSummaryDialog.vue';
const router = useRouter();

View File

@ -1,6 +1,6 @@
<script setup>
import { useState } from 'src/composables/useState';
import LeftMenu from 'src/components/LeftMenu.vue';
import LeftMenu from 'components/LeftMenu.vue';
const state = useState();
</script>

View File

@ -1,79 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useState } from 'src/composables/useState';
import LeftMenu from 'src/components/LeftMenu.vue';
import { useNavigation } from 'src/composables/useNavigation';
const { t } = useI18n();
const state = useState();
const modules = useNavigation();
</script>
<template>
<q-drawer v-model="state.drawer.value" show-if-above :width="256" :breakpoint="500">
<q-scroll-area class="fit text-grey-8">
<LeftMenu />
</q-scroll-area>
</q-drawer>
<q-page-container>
<q-page class="q-pa-md">
<!-- <q-banner v-if="$q.screen.gt.xs" inline-actions rounded class="bg-orange text-white q-mb-lg">
Employee notification message
<template #action>
<q-btn flat label="Dismiss" />
</template>
</q-banner> -->
<div class="row items-start wrap q-col-gutter-md q-mb-lg">
<div class="col-12 col-md" v-if="modules.favorites.value.length">
<div class="text-h6 text-grey-8 q-mb-sm">{{ t('globals.favoriteModules') }}</div>
<q-card class="row flex-container">
<div
v-for="module of modules.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="orange-6"
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-card>
</div>
</div>
</q-page>
</q-page-container>
</template>
<style lang="scss" scoped>
.flex-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
max-height: 300px;
overflow: auto;
}
.flex-item {
width: 100px;
}
.button {
width: 100%;
line-height: normal;
align-items: center;
}
.button-text {
font-size: 10px;
margin-top: 5px;
}
</style>

View File

@ -0,0 +1,91 @@
<script setup>
import { onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useState } from 'src/composables/useState';
import LeftMenu from 'components/LeftMenu.vue';
import { useNavigationStore } from 'src/stores/useNavigationStore';
const state = useState();
const navigation = useNavigationStore();
const { t } = useI18n();
onMounted(() => {
navigation.fetchPinned();
});
const pinnedModules = computed(() => navigation.getPinnedModules());
</script>
<template>
<q-drawer v-model="state.drawer.value" show-if-above :width="256" :breakpoint="500">
<q-scroll-area class="fit text-grey-8">
<LeftMenu />
</q-scroll-area>
</q-drawer>
<q-page-container>
<q-page class="q-pa-md">
<div class="row items-start wrap q-col-gutter-md q-mb-lg">
<div class="col-12 col-md">
<div class="text-h6 text-grey-8 q-mb-sm">{{ t('globals.pinnedModules') }}</div>
<q-card class="row flex-container q-pa-md">
<div class="text-grey-5" v-if="pinnedModules.length === 0">
{{ t('pinnedInfo') }}
</div>
<template v-if="pinnedModules.length">
<div v-for="item of pinnedModules" :key="item.title" class="row no-wrap q-pa-xs flex-item">
<q-btn
align="evenly"
padding="16px"
flat
stack
size="lg"
:icon="item.icon"
color="orange-6"
class="col-4 button"
:to="{ name: item.name }"
>
<div class="text-center text-primary button-text">
{{ t(item.title) }}
</div>
</q-btn>
</div>
</template>
</q-card>
</div>
</div>
</q-page>
</q-page-container>
</template>
<style lang="scss" scoped>
.flex-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
max-height: 300px;
overflow: auto;
}
.flex-item {
width: 100px;
}
.button {
width: 100%;
line-height: normal;
align-items: center;
}
.button-text {
font-size: 10px;
margin-top: 5px;
}
</style>
<i18n>
{
"en": {
"pinnedInfo": "Your pinned modules will be shown here..."
},
"es": {
"pinnedInfo": "Tus módulos fijados aparecerán aquí..."
}
}
</i18n>

View File

@ -0,0 +1,18 @@
<script setup>
import { useState } from 'src/composables/useState';
import InvoiceOutDescriptor from './InvoiceOutDescriptor.vue';
const state = useState();
</script>
<template>
<q-drawer v-model="state.drawer.value" show-if-above :width="256" :breakpoint="500">
<q-scroll-area class="fit">
<InvoiceOutDescriptor />
</q-scroll-area>
</q-drawer>
<q-page-container>
<q-page class="q-pa-md">
<router-view></router-view>
</q-page>
</q-page-container>
</template>

View File

@ -0,0 +1,103 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { toCurrency, toDate } from 'src/filters';
import axios from 'axios';
import CardDescriptor from 'src/components/ui/CardDescriptor.vue';
import CustomerDescriptorPopover from 'src/pages/Customer/Card/CustomerDescriptorPopover.vue';
const $props = defineProps({
id: {
type: Number,
required: false,
default: null,
},
});
onMounted(async () => {
await fetch();
});
const route = useRoute();
const { t } = useI18n();
const entityId = computed(() => {
return $props.id || route.params.id;
});
const invoiceOut = ref();
async function fetch() {
const filter = {
include: [
{
relation: 'company',
scope: {
fields: ['id', 'code'],
},
},
{
relation: 'client',
scope: {
fields: ['id', 'name', 'email'],
},
},
],
};
const options = { params: { filter } };
const { data } = await axios.get(`InvoiceOuts/${entityId.value}`, options);
if (data) invoiceOut.value = data;
}
const filter = computed(() => {
return invoiceOut.value ? JSON.stringify({ refFk: invoiceOut.value.ref }) : null;
});
</script>
<template>
<card-descriptor v-if="invoiceOut" module="InvoiceOut" :data="invoiceOut" :description="invoiceOut.ref">
<template #body>
<q-list>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('invoiceOut.card.issued') }}</q-item-label>
<q-item-label>{{ toDate(invoiceOut.issued) }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('invoiceOut.card.amount') }}</q-item-label>
<q-item-label>{{ toCurrency(invoiceOut.amount) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section v-if="invoiceOut.company">
<q-item-label caption>{{ t('invoiceOut.card.client') }}</q-item-label>
<q-item-label class="link">
{{ invoiceOut.client.name }}
<q-popup-proxy>
<customer-descriptor-popover :id="invoiceOut.client.id" />
</q-popup-proxy>
</q-item-label>
</q-item-section>
<q-item-section v-if="invoiceOut.company">
<q-item-label caption>{{ t('invoiceOut.card.company') }}</q-item-label>
<q-item-label>{{ invoiceOut.company.code }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card-actions>
<q-btn
size="md"
icon="vn:client"
color="primary"
:to="{ name: 'CustomerCard', params: { id: invoiceOut.client.id } }"
>
<q-tooltip>{{ t('invoiceOut.card.customerCard') }}</q-tooltip>
</q-btn>
<q-btn size="md" icon="vn:ticket" color="primary" :to="{ name: 'TicketList', params: { q: filter } }">
<q-tooltip>{{ t('invoiceOut.card.ticketList') }}</q-tooltip>
</q-btn>
</q-card-actions>
</template>
</card-descriptor>
</template>

View File

@ -0,0 +1,15 @@
<script setup>
import InvoiceOutDescriptor from './InvoiceOutDescriptor.vue';
const $props = defineProps({
id: {
type: Number,
required: true,
},
});
</script>
<template>
<q-card>
<invoiceOut-descriptor v-if="$props.id" :id="$props.id" />
</q-card>
</template>

View File

@ -0,0 +1,200 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import { toCurrency, toDate } from 'src/filters';
import SkeletonSummary from 'components/ui/SkeletonSummary.vue';
onMounted(() => fetch());
const route = useRoute();
const { t } = useI18n();
const $props = defineProps({
id: {
type: Number,
default: 0,
},
});
const entityId = computed(() => $props.id || route.params.id);
const invoiceOut = ref(null);
const tax = ref(null);
const tikets = ref(null);
function fetch() {
const id = entityId.value;
axios.get(`InvoiceOuts/${id}/summary`).then(({ data }) => {
invoiceOut.value = data.invoiceOut;
tax.value = data.invoiceOut.taxesBreakdown;
});
axios.get(`InvoiceOuts/${id}/getTickets`).then(({ data }) => {
tikets.value = data;
});
}
const taxColumns = ref([
{
name: 'item',
label: 'invoiceOut.summary.type',
field: (row) => row.name,
sortable: true,
},
{
name: 'landed',
label: 'invoiceOut.summary.taxableBase',
field: (row) => row.taxableBase,
format: (value) => toCurrency(value),
sortable: true,
},
{
name: 'quantity',
label: 'invoiceOut.summary.rate',
field: (row) => row.rate,
sortable: true,
},
{
name: 'invoiceOuted',
label: 'invoiceOut.summary.fee',
field: (row) => row.vat,
sortable: true,
},
]);
const ticketsColumns = ref([
{
name: 'item',
label: 'invoiceOut.summary.ticketId',
field: (row) => row.id,
sortable: true,
},
{
name: 'quantity',
label: 'invoiceOut.summary.nickname',
field: (row) => row.nickname,
sortable: true,
},
{
name: 'landed',
label: 'invoiceOut.summary.shipped',
field: (row) => row.shipped,
format: (value) => toDate(value),
sortable: true,
},
{
name: 'landed',
label: 'invoiceOut.summary.totalWithVat',
field: (row) => row.totalWithVat,
format: (value) => toCurrency(value),
sortable: true,
},
]);
</script>
<template>
<div class="summary container">
<q-card>
<skeleton-summary v-if="!invoiceOut" />
<template v-if="invoiceOut">
<div class="header bg-primary q-pa-sm q-mb-md">
{{ invoiceOut.ref }} - {{ invoiceOut.client.socialName }}
</div>
<q-list>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('invoiceOut.summary.issued') }}</q-item-label>
<q-item-label>{{ toDate(invoiceOut.issued) }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('invoiceOut.summary.dued') }}</q-item-label>
<q-item-label>{{ toDate(invoiceOut.dued) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('invoiceOut.summary.created') }}</q-item-label>
<q-item-label>{{ toDate(invoiceOut.created) }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('invoiceOut.summary.booked') }}</q-item-label>
<q-item-label>{{ toDate(invoiceOut.booked) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('invoiceOut.summary.company') }}</q-item-label>
<q-item-label>{{ invoiceOut.company.code }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card-section class="q-pa-md">
<h6>{{ t('invoiceOut.summary.taxBreakdown') }}</h6>
<q-table :columns="taxColumns" :rows="tax" 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('invoiceOut.summary.tickets') }}</h6>
<q-table :columns="ticketsColumns" :rows="tikets" 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>
</template>
</q-card>
</div>
</template>
<style lang="scss" scoped>
.container {
display: flex;
justify-content: center;
}
.q-card {
width: 100%;
min-width: 950px;
max-width: 950px;
}
.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;
}
</style>

View File

@ -0,0 +1,29 @@
<script setup>
import { useDialogPluginComponent } from 'quasar';
import InvoiceOutSummary from './InvoiceOutSummary.vue';
const $props = defineProps({
id: {
type: Number,
required: true,
},
});
defineEmits([...useDialogPluginComponent.emits]);
const { dialogRef, onDialogHide } = useDialogPluginComponent();
</script>
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide">
<invoiceOut-summary v-if="$props.id" :id="$props.id" />
</q-dialog>
</template>
<style lang="scss">
.q-dialog .summary .header {
position: sticky;
z-index: $z-max;
top: 0;
}
</style>

View File

@ -0,0 +1,83 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import Paginate from 'src/components/PaginateData.vue';
import InvoiceOutSummaryDialog from './Card/InvoiceOutSummaryDialog.vue';
import { toDate, toCurrency } from 'src/filters/index';
const router = useRouter();
const quasar = useQuasar();
const { t } = useI18n();
function navigate(id) {
router.push({ path: `/invoiceOut/${id}` });
}
function viewSummary(id) {
quasar.dialog({
component: InvoiceOutSummaryDialog,
componentProps: {
id,
},
});
}
</script>
<template>
<q-page class="q-pa-md">
<paginate url="/InvoiceOuts/filter" sort-by="issued DESC, 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">{{ row.ref }}</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('invoiceOut.list.issued') }}</q-item-label>
<q-item-label>{{ toDate(row.issued) }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('invoiceOut.list.amount') }}</q-item-label>
<q-item-label>{{ toCurrency(row.amount) }}</q-item-label>
</q-item-section>
</q-item>
<q-item class="q-pa-none">
<q-item-section>
<q-item-label caption>{{ t('invoiceOut.list.client') }}</q-item-label>
<q-item-label>{{ row.clientSocialName }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('invoiceOut.list.created') }}</q-item-label>
<q-item-label>{{ toDate(row.created) }}</q-item-label>
</q-item-section>
</q-item>
<q-item class="q-pa-none">
<q-item-section>
<q-item-label caption>{{ t('invoiceOut.list.company') }}</q-item-label>
<q-item-label>{{ row.companyCode }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label caption>{{ t('invoiceOut.list.dued') }}</q-item-label>
<q-item-label>{{ toDate(row.dued) }}</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 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-card-actions>
</q-item>
</q-card>
</template>
</paginate>
</q-page>
</template>

View File

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

View File

@ -1,6 +1,6 @@
import { jest, describe, expect, it, beforeAll } from '@jest/globals';
import { createWrapper, axios } from 'app/tests/jest/jestHelpers';
import Login from '../Login.vue';
import Login from '../LoginMain.vue';
const mockPush = jest.fn();

View File

@ -29,11 +29,11 @@ const { t } = useI18n();
<style lang="scss" scoped></style>
<i18n>
{
'en': {
'notFound': 'Oops. Nothing here...'
"en": {
"notFound": "Oops. Nothing here..."
},
'es': {
'notFound': 'Vaya. Nada por aquí...'
"es": {
"notFound": "Vaya. Nada por aquí..."
}
}
</i18n>

View File

@ -79,6 +79,7 @@ async function getVideoList(expeditionId, timed) {
</script>
<template>
<teleport to=".q-layout">
<q-drawer show-if-above side="right">
<q-scroll-area class="fit">
<q-list bordered separator style="max-width: 318px">
@ -142,6 +143,7 @@ async function getVideoList(expeditionId, timed) {
</q-list>
</q-scroll-area>
</q-drawer>
</teleport>
<q-card>
<q-carousel animated v-model="slide" height="max-content">

View File

@ -1,24 +1,16 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useState } from 'src/composables/useState';
import TicketDescriptor from './TicketDescriptor.vue';
import LeftMenu from 'components/LeftMenu.vue';
const state = useState();
const { t } = useI18n();
</script>
<template>
<q-drawer v-model="state.drawer.value" show-if-above :width="256" :breakpoint="500">
<q-scroll-area class="fit">
<ticket-descriptor />
<q-separator />
<q-list>
<q-item :to="{ name: 'TicketBoxing' }" clickable v-ripple>
<q-item-section avatar>
<q-icon name="vn:package" />
</q-item-section>
<q-item-section>{{ t('ticket.pageTitles.boxing') }}</q-item-section>
</q-item>
</q-list>
<left-menu source="card" />
</q-scroll-area>
</q-drawer>
<q-page-container>

View File

@ -4,8 +4,9 @@ import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { toDate } from 'src/filters';
import axios from 'axios';
import CardDescriptor from 'src/components/ui/CardDescriptor.vue';
import CustomerDescriptorPopover from 'src/pages/Customer/Card/CustomerDescriptorPopover.vue';
import CardDescriptor from 'components/ui/CardDescriptor.vue';
import SkeletonDescriptor from 'components/ui/SkeletonDescriptor.vue';
const $props = defineProps({
id: {
@ -42,6 +43,7 @@ function stateColor(state) {
</script>
<template>
<skeleton-descriptor v-if="!ticket" />
<card-descriptor v-if="ticket" module="Ticket" :data="ticket" :description="ticket.client.name">
<!-- <template #menu>
<q-item clickable v-ripple>Option 1</q-item>

View File

@ -0,0 +1,553 @@
<script setup>
import { onMounted, ref, computed, onUpdated } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import { dashIfEmpty, toDate, toCurrency } from 'src/filters';
import SkeletonSummary from 'components/ui/SkeletonSummary.vue';
import FetchData from 'components/FetchData.vue';
import FetchedTags from 'components/ui/FetchedTags.vue';
onMounted(() => fetch());
onUpdated(() => fetch());
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const $props = defineProps({
id: {
type: Number,
required: false,
default: null,
},
});
const entityId = computed(() => $props.id || route.params.id);
const ticket = ref();
const salesLines = ref(null);
const editableStates = ref([]);
async function fetch() {
const { data } = await axios.get(`Tickets/${entityId.value}/summary`);
if (data) {
ticket.value = data;
salesLines.value = data.sales;
}
}
function stateColor(state) {
if (state.code === 'OK') return 'text-green';
if (state.code === 'FREE') return 'text-blue-3';
if (state.alertLevel === 1) return 'text-primary';
if (state.alertLevel === 0) return 'text-red';
}
function formattedAddress() {
if (!ticket.value) return '';
const address = this.ticket.address;
const postcode = address.postalCode;
const province = address.province ? `(${address.province.name})` : '';
return `${address.street} - ${postcode} - ${address.city} ${province}`;
}
function isEditable() {
try {
return !this.ticket.ticketState.state.alertLevel;
} catch (e) {
console.error(e);
}
return true;
}
async function changeState(value) {
if (!this.ticket.id) return;
const formData = {
ticketFk: this.ticket.id,
code: value,
};
await axios.post(`TicketTrackings/changeState`, formData);
await router.go(route.fullPath);
}
</script>
<template>
<fetch-data url="States/editableStates" @on-fetch="(data) => (editableStates = data)" auto-load />
<div class="summary container">
<q-card>
<skeleton-summary v-if="!ticket" />
<template v-if="ticket">
<div class="header bg-primary q-pa-sm q-mb-md">
<span>
Ticket #{{ ticket.id }} - {{ ticket.client.name }} ({{ ticket.client.id }}) -
{{ ticket.nickname }}
</span>
<q-btn-dropdown
side
top
color="orange-11"
text-color="black"
:label="t('ticket.summary.changeState')"
:disable="!isEditable()"
>
<q-list>
<q-virtual-scroll
style="max-height: 300px"
:items="editableStates"
separator
v-slot="{ item, index }"
>
<q-item :key="index" dense clickable v-close-popup @click="changeState(item.code)">
<q-item-section>
<q-item-label>{{ item.name }}</q-item-label>
</q-item-section>
</q-item>
</q-virtual-scroll>
</q-list>
</q-btn-dropdown>
</div>
<div class="row q-pa-md q-col-gutter-md q-mb-md">
<div class="col">
<q-list>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.state') }}</q-item-label>
<q-item-label :class="stateColor(ticket.ticketState.state)">
{{ ticket.ticketState.state.name }}
</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.salesPerson') }}</q-item-label>
<q-item-label class="link">{{ ticket.client.salesPersonUser.name }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.agency') }}</q-item-label>
<q-item-label>{{ ticket.agencyMode.name }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.zone') }}</q-item-label>
<q-item-label class="link">{{ ticket.routeFk }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.warehouse') }}</q-item-label>
<q-item-label>{{ ticket.warehouse.name }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.invoice') }}</q-item-label>
<q-item-label v-if="ticket.refFk" class="link">{{ ticket.refFk }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
<div class="col">
<q-list>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.shipped') }}</q-item-label>
<q-item-label>{{ toDate(ticket.shipped) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.landed') }}</q-item-label>
<q-item-label>{{ toDate(ticket.landed) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.packages') }}</q-item-label>
<q-item-label>{{ ticket.packages }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.consigneePhone') }}</q-item-label>
<q-item-label>{{ ticket.address.phone }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.consigneeMobile') }}</q-item-label>
<q-item-label>{{ ticket.address.mobile }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.clientPhone') }}</q-item-label>
<q-item-label>{{ ticket.client.phone }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.clientMobile') }}</q-item-label>
<q-item-label>{{ ticket.client.mobile }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.consignee') }}</q-item-label>
<q-item-label>{{ formattedAddress() }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
<div class="col">
<q-list>
<q-item v-for="note in ticket.notes" :key="note.id">
<q-item-section>
<q-item-label caption>
{{ note.observationType.description }}
</q-item-label>
<q-item-label>
{{ note.description }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
<div class="col">
<q-list class="taxes">
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.subtotal') }}</q-item-label>
<q-item-label>{{ toCurrency(ticket.totalWithoutVat) }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.vat') }}</q-item-label>
<q-item-label>{{
toCurrency(ticket.totalWithVat - ticket.totalWithoutVat)
}}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>{{ t('ticket.summary.total') }}</q-item-label>
<q-item-label>{{ toCurrency(ticket.totalWithVat) }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
<div class="row q-pa-md" v-if="salesLines.length > 0">
<div class="col">
<q-list>
<q-item-label header class="text-h6">
{{ t('ticket.summary.saleLines') }}
<router-link
:to="{ name: 'TicketBasicData', params: { id: entityId } }"
target="_blank"
>
<q-icon name="open_in_new" />
</router-link>
</q-item-label>
<q-table :rows="ticket.sales" flat>
<template #header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width>{{ t('ticket.summary.item') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.visible') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.available') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.quantity') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.description') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.price') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.discount') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.amount') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.packing') }}</q-th>
</q-tr>
</template>
<template #body="props">
<q-tr :props="props">
<q-td>
<q-btn
flat
round
size="xs"
icon="vn:claims"
v-if="props.row.claim"
color="primary"
:to="{ name: 'ClaimCard', params: { id: props.row.claim.claimFk } }"
>
<q-tooltip
>{{ t('ticket.summary.claim') }}:
{{ props.row.claim.claimFk }}</q-tooltip
>
</q-btn>
<q-btn
flat
round
size="xs"
icon="vn:claims"
v-if="props.row.claimBeginning"
color="primary"
:to="{
name: 'ClaimCard',
params: { id: props.row.claimBeginning.claimFk },
}"
>
<q-tooltip
>{{ t('ticket.summary.claim') }}:
{{ props.row.claimBeginning.claimFk }}</q-tooltip
>
</q-btn>
<q-icon
name="warning"
v-show="props.row.visible < 0"
size="xs"
color="primary"
>
<q-tooltip
>{{ t('ticket.summary.visible') }}:
{{ props.row.visible }}</q-tooltip
>
</q-icon>
<q-icon
name="vn:reserva"
v-show="props.row.reserved"
size="xs"
color="primary"
>
<q-tooltip>{{ t('ticket.summary.reserved') }}</q-tooltip>
</q-icon>
<q-icon
name="vn:unavailable"
v-show="props.row.itemShortage"
size="xs"
color="primary"
>
<q-tooltip>{{ t('ticket.summary.itemShortage') }}</q-tooltip>
</q-icon>
<q-icon
name="vn:components"
v-show="props.row.hasComponentLack"
size="xs"
color="primary"
>
<q-tooltip>{{ t('ticket.summary.hasComponentLack') }}</q-tooltip>
</q-icon>
</q-td>
<q-td class="link">{{ props.row.itemFk }}</q-td>
<q-td>{{ props.row.visible }}</q-td>
<q-td>{{ props.row.available }}</q-td>
<q-td>{{ props.row.quantity }}</q-td>
<q-td>
<div class="fetched-tags">
<span>{{ props.row.item.name }}</span>
<span v-if="props.row.item.subName" class="subName">{{
props.row.item.subName
}}</span>
</div>
<fetched-tags :item="props.row.item" :max-length="5"></fetched-tags>
</q-td>
<q-td>{{ props.row.price }}</q-td>
<q-td>{{ props.row.discount }} %</q-td>
<q-td
>{{
toCurrency(
props.row.quantity *
props.row.price *
((100 - props.row.discount) / 100)
)
}}
</q-td>
<q-td>{{ dashIfEmpty(props.row.item.itemPackingTypeFk) }}</q-td>
</q-tr>
</template>
</q-table>
</q-list>
</div>
</div>
<div class="row q-pa-md" v-if="ticket.packagings.length > 0 || ticket.services.length > 0">
<div class="col" v-if="ticket.packagings.length > 0">
<q-list>
<q-item-label header class="text-h6">
{{ t('ticket.summary.packages') }}
<q-icon name="open_in_new" />
</q-item-label>
<q-table :rows="ticket.packagings" flat>
<template #header="props">
<q-tr :props="props">
<q-th auto-width>{{ t('ticket.summary.created') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.package') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.quantity') }}</q-th>
</q-tr>
</template>
<template #body="props">
<q-tr :props="props">
<q-td>{{ toDate(props.row.created) }}</q-td>
<q-td>{{ props.row.packaging.item.name }}</q-td>
<q-td>{{ props.row.quantity }}</q-td>
</q-tr>
</template>
</q-table>
</q-list>
</div>
<div class="col" v-if="ticket.services.length > 0">
<q-list>
<q-item-label header class="text-h6">
{{ t('ticket.summary.services') }}
<q-icon name="open_in_new" />
</q-item-label>
<q-table :rows="ticket.services" flat>
<template #header="props">
<q-tr :props="props">
<q-th auto-width>{{ t('ticket.summary.quantity') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.description') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.price') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.taxClass') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.amount') }}</q-th>
</q-tr>
</template>
<template #body="props">
<q-tr :props="props">
<q-td>{{ props.row.quantity }}</q-td>
<q-td>{{ props.row.description }}</q-td>
<q-td>{{ toCurrency(props.row.price) }}</q-td>
<q-td>{{ props.row.taxClass.description }}</q-td>
<q-td>{{ toCurrency(props.row.quantity * props.row.price) }}</q-td>
</q-tr>
</template>
</q-table>
</q-list>
</div>
</div>
<div class="row q-pa-md" v-if="ticket.requests.length > 0">
<div class="col">
<q-list>
<q-item-label header class="text-h6">
{{ t('ticket.summary.request') }}
<q-icon name="open_in_new" />
</q-item-label>
<q-table :rows="ticket.requests" flat>
<template #header="props">
<q-tr :props="props">
<q-th auto-width>{{ t('ticket.summary.description') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.created') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.requester') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.atender') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.quantity') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.price') }}</q-th>
<q-th auto-width>{{ t('ticket.summary.item') }}</q-th>
<q-th auto-width>Ok</q-th>
</q-tr>
</template>
<template #body="props">
<q-tr :props="props">
<q-td>{{ props.row.description }}</q-td>
<q-td>{{ toDate(props.row.created) }}</q-td>
<q-td>{{ props.row.requester.user.name }}</q-td>
<q-td>{{ props.row.atender.user.name }}</q-td>
<q-td>{{ props.row.quantity }}</q-td>
<q-td>{{ toCurrency(props.row.price) }}</q-td>
<q-td v-if="!props.row.sale">-</q-td>
<q-td v-if="props.row.sale" class="link">{{ props.row.sale.itemFk }}</q-td>
<q-td><q-checkbox v-model="props.row.isOk" :disable="true" /></q-td>
</q-tr>
</template>
</q-table>
</q-list>
</div>
</div>
</template>
</q-card>
</div>
</template>
<style lang="scss" scoped>
.container {
display: flex;
justify-content: center;
}
.q-card {
width: 100%;
height: 100%;
max-width: 1200px;
}
.summary {
.q-list {
.q-item__label--header {
display: flex;
justify-content: space-between;
a {
color: $primary;
}
}
}
.fetched-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
& span {
flex-basis: 50%;
}
& span.subName {
flex-basis: 50%;
color: $secondary;
text-transform: uppercase;
font-size: 0.75rem;
}
}
.q-table__container {
text-align: left;
.q-icon {
padding: 2%;
}
}
.taxes {
border: $border-thin-light;
text-align: right;
padding: 8px;
}
.row {
flex-wrap: wrap;
.col {
min-width: 250px;
padding-left: 1.5%;
padding-right: 1.5%;
}
}
.header {
font-size: 18px;
display: flex;
justify-content: space-between;
align-items: center;
align-content: center;
margin: 0;
text-align: center;
span {
flex: 1;
}
.q-btn {
flex: none;
}
}
}
.q-dialog .summary {
max-width: 1200px;
}
</style>

View File

@ -0,0 +1,29 @@
<script setup>
import { useDialogPluginComponent } from 'quasar';
import TicketSummary from './TicketSummary.vue';
const $props = defineProps({
id: {
type: Number,
required: true,
},
});
defineEmits([...useDialogPluginComponent.emits]);
const { dialogRef, onDialogHide } = useDialogPluginComponent();
</script>
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide">
<ticket-summary v-if="$props.id" :id="$props.id" />
</q-dialog>
</template>
<style lang="scss">
.q-dialog .summary .header {
position: sticky;
z-index: $z-max;
top: 0;
}
</style>

View File

@ -1,12 +1,13 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
import Paginate from 'src/components/Paginate.vue';
import Paginate from 'src/components/PaginateData.vue';
import { toDate, toCurrency } from 'src/filters/index';
// import TicketSummary from './Card/TicketSummary.vue';
import TicketSummaryDialog from './Card/TicketSummaryDialog.vue';
const router = useRouter();
const quasar = useQuasar();
const { t } = useI18n();
const filter = {
@ -48,15 +49,13 @@ function navigate(id) {
router.push({ path: `/ticket/${id}` });
}
const preview = ref({
shown: false,
function viewSummary(id) {
quasar.dialog({
component: TicketSummaryDialog,
componentProps: {
id,
},
});
function showPreview(id) {
preview.value.shown = true;
preview.value.data = {
customerId: id,
};
}
</script>
@ -108,42 +107,16 @@ function showPreview(id) {
</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="history" />
</q-item-section>
<q-item-section>Display customer history</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn> -->
<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="showPreview(row.id)">
<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:ticket">
<q-tooltip>{{ t('customer.list.customerOrders') }}</q-tooltip>
</q-btn> -->
</q-card-actions>
</q-item>
</q-card>
</template>
</paginate>
</q-page>
<!-- <q-dialog v-model="preview.shown">
<customer-summary :customer-id="preview.data.customerId" />
</q-dialog> -->
</template>

View File

@ -1,6 +1,6 @@
<script setup>
import { useState } from 'src/composables/useState';
import LeftMenu from 'src/components/LeftMenu.vue';
import LeftMenu from 'components/LeftMenu.vue';
const state = useState();
</script>

View File

@ -1,11 +1,11 @@
import { route } from 'quasar/wrappers';
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router';
// import { Notify } from 'quasar';
import routes from './routes';
import { i18n } from 'src/boot/i18n';
import { useState } from 'src/composables/useState';
import { useSession } from 'src/composables/useSession';
import { useRole } from 'src/composables/useRole';
import { toLowerCamel } from 'src/filters';
const state = useState();
const session = useSession();
@ -46,26 +46,14 @@ export default route(function (/* { store, ssrContext } */) {
}
if (isLoggedIn()) {
// try {
const stateRoles = state.getRoles().value;
if (stateRoles.length === 0) {
await role.fetch();
}
// } catch (error) {
// Notify.create({
// message: t('errors.statusUnauthorized'),
// type: 'negative',
// });
// session.destroy();
// return next({ path: '/login' });
// }
const matches = to.matched;
const hasRequiredRoles = matches.every(route => {
const hasRequiredRoles = matches.every((route) => {
const meta = route.meta;
if (meta && meta.roles)
return role.hasAny(meta.roles)
if (meta && meta.roles) return role.hasAny(meta.roles);
return true;
});
@ -85,7 +73,7 @@ export default route(function (/* { store, ssrContext } */) {
if (matches && matches.length > 1) {
const module = matches[1];
const moduleTitle = module.meta && module.meta.title;
moduleName = module.name.toLowerCase();
moduleName = toLowerCamel(module.name);
if (moduleTitle) {
title = t(`${moduleName}.pageTitles.${moduleTitle}`);
}

View File

@ -5,10 +5,14 @@ export default {
path: '/claim',
meta: {
title: 'claims',
icon: 'vn:claims'
icon: 'vn:claims',
},
component: RouterView,
redirect: { name: 'ClaimMain' },
menus: {
main: ['ClaimList', 'ClaimRmaList'],
card: ['ClaimBasicData', 'ClaimRma'],
},
children: [
{
name: 'ClaimMain',
@ -31,11 +35,11 @@ export default {
meta: {
title: 'rmaList',
icon: 'vn:barcode',
roles: ['claimManager']
roles: ['claimManager'],
},
component: () => import('src/pages/Claim/ClaimRmaList.vue'),
}
]
},
],
},
{
name: 'ClaimCard',
@ -47,7 +51,8 @@ export default {
name: 'ClaimSummary',
path: 'summary',
meta: {
title: 'summary'
title: 'summary',
icon: 'launch',
},
component: () => import('src/pages/Claim/Card/ClaimSummary.vue'),
},
@ -56,7 +61,8 @@ export default {
path: 'basic-data',
meta: {
title: 'basicData',
roles: ['salesPerson']
icon: 'vn:settings',
roles: ['salesPerson'],
},
component: () => import('src/pages/Claim/Card/ClaimBasicData.vue'),
},
@ -65,11 +71,12 @@ export default {
path: 'rma',
meta: {
title: 'rma',
roles: ['claimManager']
icon: 'vn:barcode',
roles: ['claimManager'],
},
component: () => import('src/pages/Claim/Card/ClaimRma.vue')
}
]
component: () => import('src/pages/Claim/Card/ClaimRma.vue'),
},
]
],
},
],
};

View File

@ -5,10 +5,14 @@ export default {
name: 'Customer',
meta: {
title: 'customers',
icon: 'vn:client'
icon: 'vn:client',
},
component: RouterView,
redirect: { name: 'CustomerMain' },
menus: {
main: ['CustomerList', 'CustomerCreate'],
card: ['CustomerBasicData'],
},
children: [
{
path: '',
@ -35,7 +39,7 @@ export default {
},
component: () => import('src/pages/Customer/CustomerCreate.vue'),
},
]
],
},
{
name: 'CustomerCard',
@ -47,7 +51,8 @@ export default {
name: 'CustomerSummary',
path: 'summary',
meta: {
title: 'summary'
title: 'summary',
icon: 'launch',
},
component: () => import('src/pages/Customer/Card/CustomerSummary.vue'),
},
@ -55,11 +60,12 @@ export default {
path: 'basic-data',
name: 'CustomerBasicData',
meta: {
title: 'basicData'
title: 'basicData',
icon: 'vn:settings',
},
component: () => import('src/pages/Customer/Card/CustomerBasicData.vue'),
}
]
},
]
],
},
],
};

View File

@ -0,0 +1,11 @@
import Customer from './customer';
import Ticket from './ticket';
import Claim from './claim';
import InvoiceOut from './invoiceOut';
export default [
Customer,
Ticket,
Claim,
InvoiceOut
]

View File

@ -0,0 +1,50 @@
import { RouterView } from 'vue-router';
export default {
path: '/invoiceOut',
name: 'InvoiceOut',
meta: {
title: 'invoiceOuts',
icon: 'vn:invoice-out'
},
component: RouterView,
redirect: { name: 'InvoiceOutMain' },
menus: {
main: ['InvoiceOutList']
},
children: [
{
path: '',
name: 'InvoiceOutMain',
component: () => import('src/pages/InvoiceOut/InvoiceOutMain.vue'),
redirect: { name: 'InvoiceOutList' },
children: [
{
path: 'list',
name: 'InvoiceOutList',
meta: {
title: 'list',
icon: 'view_list',
},
component: () => import('src/pages/InvoiceOut/InvoiceOutList.vue'),
}
]
},
{
name: 'InvoiceOutCard',
path: ':id',
component: () => import('src/pages/InvoiceOut/Card/InvoiceOutCard.vue'),
redirect: { name: 'InvoiceOutSummary' },
children: [
{
name: 'InvoiceOutSummary',
path: 'summary',
meta: {
title: 'summary'
},
component: () => import('src/pages/InvoiceOut/Card/InvoiceOutSummary.vue'),
}
]
},
]
};

View File

@ -5,10 +5,14 @@ export default {
path: '/ticket',
meta: {
title: 'tickets',
icon: 'vn:ticket'
icon: 'vn:ticket',
},
component: RouterView,
redirect: { name: 'TicketMain' },
menus: {
main: ['TicketList'],
card: ['TicketBoxing'],
},
children: [
{
name: 'TicketMain',
@ -35,8 +39,7 @@ export default {
},
component: () => import('src/pages/Ticket/TicketList.vue'),
},
]
],
},
{
name: 'TicketCard',
@ -48,7 +51,8 @@ export default {
name: 'TicketSummary',
path: 'summary',
meta: {
title: 'summary'
title: 'summary',
icon: 'launch',
},
component: () => import('src/pages/Ticket/Card/TicketSummary.vue'),
},
@ -56,7 +60,8 @@ export default {
name: 'TicketBasicData',
path: 'basic-data',
meta: {
title: 'basicData'
title: 'basicData',
icon: 'vn:settings',
},
component: () => import('src/pages/Ticket/Card/TicketBasicData.vue'),
},
@ -64,11 +69,12 @@ export default {
path: 'boxing',
name: 'TicketBoxing',
meta: {
title: 'boxing'
title: 'boxing',
icon: 'vn:package',
},
component: () => import('src/pages/Ticket/Card/TicketBoxing.vue'),
}
]
},
]
],
},
],
};

View File

@ -2,13 +2,14 @@ import customer from './modules/customer';
import ticket from './modules/ticket';
import claim from './modules/claim';
import worker from './modules/worker';
import invoiceOut from './modules/invoiceOut';
const routes = [
{
path: '/login',
name: 'Login',
meta: { title: 'logIn' },
component: () => import('../pages/Login/Login.vue')
component: () => import('../pages/Login/LoginMain.vue'),
},
{
path: '/',
@ -20,20 +21,21 @@ const routes = [
path: '/dashboard',
name: 'Dashboard',
meta: { title: 'dashboard', icon: 'dashboard' },
component: () => import('../pages/Dashboard/Dashboard.vue'),
component: () => import('../pages/Dashboard/DashboardMain.vue'),
},
// Module routes
customer,
ticket,
claim,
worker,
invoiceOut,
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('../pages/NotFound.vue'),
}
},
],
}
},
];
export default routes;

View File

@ -0,0 +1,85 @@
import axios from 'axios';
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { toLowerCamel } from 'src/filters';
import { useRole } from 'src/composables/useRole';
import routes from 'src/router/modules';
export const useNavigationStore = defineStore('navigationStore', () => {
const modules = ['customer', 'claim', 'ticket', 'invoiceOut'];
const pinnedModules = ref([]);
const role = useRole();
function getModules() {
const modulesRoutes = ref([]);
for (const module of modules) {
const moduleDef = routes.find((route) => toLowerCamel(route.name) === module);
if (!moduleDef) continue;
const item = addMenuItem(module, moduleDef, modulesRoutes.value);
if (!item) continue;
item.module = module;
item.isPinned = false;
if (pinnedModules.value.includes(module)) {
item.isPinned = true;
}
}
return modulesRoutes;
}
function getPinnedModules() {
const modules = getModules();
return modules && modules.value.filter((item) => item.isPinned);
}
function addMenuItem(module, route, parent) {
const { meta } = route;
if (meta && meta.roles && role.hasAny(meta.roles) === false) return;
const item = {
name: route.name,
};
if (meta) {
item.title = `${module}.pageTitles.${meta.title}`;
item.icon = meta.icon;
}
parent.push(item);
return item;
}
async function fetchPinned() {
if (pinnedModules.value.length) return;
const response = await axios.get('StarredModules/getStarredModules');
pinnedModules.value = response.data.map((row) => row.moduleFk);
}
function togglePinned(module) {
if (pinnedModules.value.includes(module)) {
const index = pinnedModules.value.indexOf(module);
pinnedModules.value.splice(index, 1);
return;
}
pinnedModules.value.push(module);
}
return {
modules,
pinnedModules,
getModules,
getPinnedModules,
fetchPinned,
togglePinned,
addMenuItem,
};
});

View File

@ -1,25 +1,27 @@
import { mount, flushPromises } from '@vue/test-utils';
import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-jest';
import { i18n } from 'src/boot/i18n';
import { Notify } from 'quasar';
import { Notify, Dialog } from 'quasar';
import axios from 'axios';
// Specify here Quasar config you'll need to test your component
installQuasarPlugin({
plugins: {
Notify
}
Notify,
Dialog,
},
});
export function createWrapper(component, options) {
const mountOptions = {
global: {
plugins: [i18n]
}
};
const mountOptions = {};
if (options instanceof Object)
Object.assign(mountOptions, options)
if (options instanceof Object) Object.assign(mountOptions, options);
if (mountOptions.global && mountOptions.global.plugins) {
mountOptions.global.plugins.push(i18n);
} else {
if (!mountOptions.global) mountOptions.global = {};
mountOptions.global.plugins = [i18n];
}
const wrapper = mount(component, mountOptions);
const vm = wrapper.vm;
@ -27,7 +29,4 @@ export function createWrapper(component, options) {
return { vm, wrapper };
}
export {
axios,
flushPromises
}
export { axios, flushPromises };