Merge pull request 'Seperated plugin and api types into subprojects' (#2) from seperate_plugins into develop
Reviewed-on: #2
This commit is contained in:
commit
16fd9201ae
72
README.md
72
README.md
|
@ -5,6 +5,7 @@ Modular student club administration system, licensed under the MIT license.
|
|||
## Installation
|
||||
|
||||
### Requirements
|
||||
|
||||
```
|
||||
"engines": {
|
||||
"node": ">= 12.22.1",
|
||||
|
@ -12,6 +13,7 @@ Modular student club administration system, licensed under the MIT license.
|
|||
"yarn": ">= 1.21.1"
|
||||
}
|
||||
```
|
||||
|
||||
So on debian (buster and bullseye) you will need to install node.js and yarn beside the debian packages to meet the needed versions.
|
||||
|
||||
```bash
|
||||
|
@ -23,17 +25,40 @@ npm i -g yarn
|
|||
npm i -g @quasar/cli
|
||||
popd
|
||||
```
|
||||
|
||||
### Install the dependencies
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
Be aware npm might not work.
|
||||
|
||||
### Configure Plugins
|
||||
|
||||
You can activate and deactive Plugins in `src/boot/plugins.ts`.
|
||||
You have to set the name of the Plugin into `config.loadModules`.
|
||||
#### Installing a plugin
|
||||
|
||||
Simply add it as a dependency and install it, for example installing the `pricelist`-plugin:
|
||||
|
||||
```sh
|
||||
yarn add '@flaschengeist/pricelist'
|
||||
yarn install
|
||||
```
|
||||
|
||||
#### Enable / Disable a plugin
|
||||
|
||||
After installing a plugin you will have to enable it,
|
||||
this is done by adding it to the `plugin.config.js` file.
|
||||
For the example above the file should look like:
|
||||
|
||||
```js
|
||||
module.exports = [
|
||||
// pricelist plugin:
|
||||
'@flaschengeist/pricelist',
|
||||
];
|
||||
```
|
||||
|
||||
Remember to rebuild the project
|
||||
|
||||
### Configure Backend
|
||||
|
||||
|
@ -41,11 +66,11 @@ The application is using the API of [the backend](https://flaschengeist.dev/Flas
|
|||
This access needs to be configured in `src/config.ts'->config.baseURL
|
||||
|
||||
- either you do have a proxy webserver that maps the '/api' to the backend (http://localhost:5000) or
|
||||
- you do directly configure the backend there:```baseURL: 'http://localhost:5000'```. Be aware not committing this configuration.
|
||||
- you do directly configure the backend there:`baseURL: 'http://localhost:5000'`. Be aware not committing this configuration.
|
||||
|
||||
### Build the application
|
||||
|
||||
```bash
|
||||
```sh
|
||||
yarn quasar build
|
||||
```
|
||||
|
||||
|
@ -76,15 +101,36 @@ yarn run lint
|
|||
|
||||
#### Build a Plugin
|
||||
|
||||
A Flaschengeist-Frontend-Plugin should be placed in `src/plugins`.
|
||||
It needs a `plugin.ts` File which exports a plugin with the following interface:
|
||||
Create a new node.js project and add `@flaschengeist/api` as a peer dependency (e.g. `yarn add --peer '@flaschengeist/api'`).
|
||||
If your plugin depends on an other plugin (e.g. you use the `@flaschengeist/users` plugin / stores),
|
||||
then you have to add that plugin as a peer dependency too.
|
||||
|
||||
```
|
||||
name: string;
|
||||
mainRoutes?: PluginRouteConfig[];
|
||||
outRoutes?: PluginRouteConfig[];
|
||||
requiredModules: string[];
|
||||
version: string;
|
||||
You need to define a main entry point for your plugin (e.g. `"main": "src/index.ts"` in your `package.json`) which exportes
|
||||
a plugin descriptor as the default export (see `@flaschengeist/types` -> `FG_Plugin.Plugin`).
|
||||
E.g.
|
||||
|
||||
```ts
|
||||
import { FG_Plugin } from '@flaschengeist/types';
|
||||
import { routes } from './routes';
|
||||
|
||||
const plugin: FG_Plugin.Plugin = {
|
||||
id: 'com.example.myplugin',
|
||||
name: 'myplugin',
|
||||
innerRoutes: routes,
|
||||
// This are required backend plugins:
|
||||
requiredModules: [['auth'], ['users'], ['roles']],
|
||||
version: '0.0.1',
|
||||
widgets: [
|
||||
{
|
||||
priority: 1,
|
||||
name: 'greeting',
|
||||
permissions: [],
|
||||
widget: defineAsyncComponent(() => import('./components/Widget.vue')),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
```
|
||||
|
||||
You have to import `FG_Plugin` from `plugins.d.ts`.
|
||||
This will add a plugin providing a widget for the dashboard side and some routes.
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType } from 'vue';
|
||||
import { date as q_date } from 'quasar';
|
||||
import { stringIsDate, stringIsTime, stringIsDateTime, Validator } from 'src/utils/validators';
|
||||
import { stringIsDate, stringIsTime, stringIsDateTime, Validator } from '..';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'IsoDateInput',
|
|
@ -0,0 +1,4 @@
|
|||
import IsoDateInput from './IsoDateInput.vue';
|
||||
import PasswordInput from './PasswordInput.vue';
|
||||
|
||||
export { IsoDateInput, PasswordInput };
|
|
@ -0,0 +1,7 @@
|
|||
export { api, pinia } from './src/internal';
|
||||
|
||||
export * from './src/stores/';
|
||||
|
||||
export * from './src/utils/datetime';
|
||||
export * from './src/utils/permission';
|
||||
export * from './src/utils/validators';
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "1.0.0-alpha.1",
|
||||
"name": "@flaschengeist/api",
|
||||
"author": "Tim Gröger <flaschengeist@wu5.de>",
|
||||
"homepage": "https://flaschengeist.dev/Flaschengeist",
|
||||
"description": "Modular student club administration system",
|
||||
"bugs": {
|
||||
"url": "https://flaschengeist.dev/Flaschengeist/flaschengeist-frontend/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --config ./package.json --write '{,!(node_modules)/**/}*.ts'",
|
||||
"lint": "eslint --ext .js,.ts,.vue ./src"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"peerDependencies": {
|
||||
"@quasar/app": "^3.0.0-beta.26",
|
||||
"flaschengeist": "^2.0.0-alpha.1",
|
||||
"pinia": "^2.0.0-alpha.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flaschengeist/types": "git+https://flaschengeist.dev/ferfissimo/flaschengeist-types.git#develop",
|
||||
"@types/node": "^12.20.13",
|
||||
"@typescript-eslint/eslint-plugin": "^4.24.0",
|
||||
"@typescript-eslint/parser": "^4.24.0",
|
||||
"eslint": "^7.26.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-vue": "^7.9.0",
|
||||
"eslint-webpack-plugin": "^2.5.4",
|
||||
"prettier": "^2.3.0",
|
||||
"typescript": "^4.2.4"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"printWidth": 100,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
//https://github.com/vuejs/vue-next/issues/3130
|
||||
declare module '*.vue' {
|
||||
import { ComponentOptions } from 'vue';
|
||||
const component: ComponentOptions;
|
||||
export default component;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import axios from 'axios';
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
export const api = axios.create();
|
||||
|
||||
export const pinia = createPinia();
|
|
@ -0,0 +1,3 @@
|
|||
export * from './main';
|
||||
export * from './session';
|
||||
export * from './user';
|
|
@ -1,9 +1,8 @@
|
|||
import { useUserStore, useSessionStore } from 'src/plugins/user/store';
|
||||
import { translateNotification } from 'src/boot/plugins';
|
||||
import { LocalStorage, SessionStorage } from 'quasar';
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
import { FG_Plugin } from '@flaschengeist/types';
|
||||
import { useSessionStore, useUserStore } from '.';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { api } from 'src/boot/axios';
|
||||
import { api } from '../internal';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
function loadCurrentSession() {
|
||||
|
@ -50,7 +49,7 @@ export const useMainStore = defineStore({
|
|||
const sessionStore = useSessionStore();
|
||||
const session = await sessionStore.getSession(this.session.token);
|
||||
if (session) {
|
||||
this.session = this.session;
|
||||
this.session = session;
|
||||
const userStore = useUserStore();
|
||||
const user = await userStore.getUser(this.session.userid);
|
||||
if (user) {
|
||||
|
@ -113,10 +112,11 @@ export const useMainStore = defineStore({
|
|||
data.forEach((n) => {
|
||||
n.time = new Date(n.time);
|
||||
notifications.push(
|
||||
(
|
||||
flaschengeist?.plugins.filter((p) => p.name === n.plugin)[0]?.notification ||
|
||||
translateNotification
|
||||
)(n)
|
||||
(flaschengeist?.plugins.filter((p) => p.name === n.plugin)[0]?.notification)(
|
||||
/*||
|
||||
translateNotification*/
|
||||
n
|
||||
)
|
||||
);
|
||||
});
|
||||
this.notifications.push(...notifications);
|
|
@ -0,0 +1,69 @@
|
|||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { defineStore } from 'pinia';
|
||||
import { api } from '../internal';
|
||||
import { useMainStore } from '.';
|
||||
|
||||
export const useSessionStore = defineStore({
|
||||
id: 'sessions',
|
||||
|
||||
state: () => ({}),
|
||||
|
||||
getters: {},
|
||||
|
||||
actions: {
|
||||
async getSession(token: string) {
|
||||
return await api
|
||||
.get(`/auth/${token}`)
|
||||
.then(({ data }: AxiosResponse<FG.Session>) => data)
|
||||
.catch(() => null);
|
||||
},
|
||||
|
||||
async getSessions() {
|
||||
try {
|
||||
const { data } = await api.get<FG.Session[]>('/auth');
|
||||
data.forEach((session) => {
|
||||
session.expires = new Date(session.expires);
|
||||
});
|
||||
|
||||
const mainStore = useMainStore();
|
||||
const currentSession = data.find((session) => {
|
||||
return session.token === mainStore.session?.token;
|
||||
});
|
||||
if (currentSession) {
|
||||
mainStore.session = currentSession;
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
return [] as FG.Session[];
|
||||
}
|
||||
},
|
||||
|
||||
async deleteSession(token: string) {
|
||||
const mainStore = useMainStore();
|
||||
if (token === mainStore.session?.token) return mainStore.logout();
|
||||
|
||||
try {
|
||||
await api.delete(`/auth/${token}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!error || !('response' in error) || (<AxiosError>error).response?.status != 401)
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
async updateSession(lifetime: number, token: string) {
|
||||
try {
|
||||
const { data } = await api.put<FG.Session>(`auth/${token}`, { value: lifetime });
|
||||
data.expires = new Date(data.expires);
|
||||
|
||||
const mainStore = useMainStore();
|
||||
if (mainStore.session?.token == data.token) mainStore.session = data;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,7 +1,7 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { api } from 'src/boot/axios';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { AxiosError } from 'axios';
|
||||
import { api } from '../internal';
|
||||
import { useMainStore } from '.';
|
||||
|
||||
export const useUserStore = defineStore({
|
||||
id: 'users',
|
||||
|
@ -110,68 +110,3 @@ export const useUserStore = defineStore({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useSessionStore = defineStore({
|
||||
id: 'sessions',
|
||||
|
||||
state: () => ({}),
|
||||
|
||||
getters: {},
|
||||
|
||||
actions: {
|
||||
async getSession(token: string) {
|
||||
return await api
|
||||
.get(`/auth/${token}`)
|
||||
.then(({ data }: AxiosResponse<FG.Session>) => data)
|
||||
.catch(() => null);
|
||||
},
|
||||
|
||||
async getSessions() {
|
||||
try {
|
||||
const { data } = await api.get<FG.Session[]>('/auth');
|
||||
data.forEach((session) => {
|
||||
session.expires = new Date(session.expires);
|
||||
});
|
||||
|
||||
const mainStore = useMainStore();
|
||||
const currentSession = data.find((session) => {
|
||||
return session.token === mainStore.session?.token;
|
||||
});
|
||||
if (currentSession) {
|
||||
mainStore.session = currentSession;
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
return [] as FG.Session[];
|
||||
}
|
||||
},
|
||||
|
||||
async deleteSession(token: string) {
|
||||
const mainStore = useMainStore();
|
||||
if (token === mainStore.session?.token) return mainStore.logout();
|
||||
|
||||
try {
|
||||
await api.delete(`/auth/${token}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!error || !('response' in error) || (<AxiosError>error).response?.status != 401)
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
async updateSession(lifetime: number, token: string) {
|
||||
try {
|
||||
const { data } = await api.put<FG.Session>(`auth/${token}`, { value: lifetime });
|
||||
data.expires = new Date(data.expires);
|
||||
|
||||
const mainStore = useMainStore();
|
||||
if (mainStore.session?.token == data.token) mainStore.session = data;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import { useMainStore } from 'src/stores';
|
||||
import { useMainStore } from '../stores';
|
||||
|
||||
export function hasPermission(permission: string) {
|
||||
const store = useMainStore();
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "@quasar/app/tsconfig-preset",
|
||||
"target": "esnext",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"lib": [
|
||||
"es2020",
|
||||
"dom"
|
||||
],
|
||||
"types": [
|
||||
"@flaschengeist/types",
|
||||
"@quasar/app",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
20
package.json
20
package.json
|
@ -2,8 +2,8 @@
|
|||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.1",
|
||||
"productName": "Flaschengeist",
|
||||
"name": "flaschengeist-frontend",
|
||||
"productName": "flaschengeist-frontend",
|
||||
"name": "flaschengeist",
|
||||
"author": "Tim Gröger <flaschengeist@wu5.de>",
|
||||
"homepage": "https://flaschengeist.dev/Flaschengeist",
|
||||
"description": "Modular student club administration system",
|
||||
|
@ -12,18 +12,23 @@
|
|||
},
|
||||
"scripts": {
|
||||
"format": "prettier --config ./package.json --write '{,!(node_modules)/**/}*.ts'",
|
||||
"lint": "eslint --ext .js,.ts,.vue ./src"
|
||||
"lint": "eslint --ext .js,.ts,.vue ./src ./api"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flaschengeist/api": "file:./api",
|
||||
"@flaschengeist/users": "git+https://flaschengeist.dev/ferfissimo/flaschengeist-users.git#develop",
|
||||
"@flaschengeist/schedule": "git+https://flaschengeist.dev/ferfissimo/flaschengeist-schedule.git#develop",
|
||||
"@flaschengeist/balance": "git+https://flaschengeist.dev/ferfissimo/flaschengeist-balance.git#develop",
|
||||
"@flaschengeist/pricelist": "git+https://flaschengeist.dev/ferfissimo/flaschengeist-pricelist.git#develop",
|
||||
"axios": "^0.21.1",
|
||||
"cordova": "^10.0.0",
|
||||
"pinia": "^2.0.0-alpha.18",
|
||||
"quasar": "^2.0.0-beta.17"
|
||||
"pinia": "^2.0.0-alpha.19",
|
||||
"quasar": "^2.0.0-beta.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@quasar/app": "^3.0.0-beta.25",
|
||||
"@flaschengeist/types": "git+https://flaschengeist.dev/ferfissimo/flaschengeist-types.git#develop",
|
||||
"@quasar/app": "^3.0.0-beta.26",
|
||||
"@quasar/extras": "^1.10.4",
|
||||
"@quasar/quasar-app-extension-qcalendar": "4.0.0-alpha.8",
|
||||
"@types/node": "^12.20.13",
|
||||
"@types/webpack": "^5.28.0",
|
||||
"@types/webpack-env": "^1.16.0",
|
||||
|
@ -33,6 +38,7 @@
|
|||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-vue": "^7.9.0",
|
||||
"eslint-webpack-plugin": "^2.5.4",
|
||||
"modify-source-webpack-plugin": "^3.0.0-rc.0",
|
||||
"prettier": "^2.3.0",
|
||||
"typescript": "^4.2.4",
|
||||
"vuedraggable": "^4.0.1"
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
// You can add your plugins here
|
||||
module.exports = [
|
||||
'@flaschengeist/balance',
|
||||
'@flaschengeist/schedule',
|
||||
'@flaschengeist/pricelist',
|
||||
]
|
|
@ -9,7 +9,9 @@
|
|||
/* eslint-env node */
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const ESLintPlugin = require('eslint-webpack-plugin')
|
||||
const { configure } = require('quasar/wrappers');
|
||||
const { ModifySourcePlugin } = require('modify-source-webpack-plugin')
|
||||
const { configure } = require('quasar/wrappers')
|
||||
|
||||
|
||||
module.exports = configure(function (/* ctx */) {
|
||||
return {
|
||||
|
@ -53,17 +55,16 @@ module.exports = configure(function (/* ctx */) {
|
|||
build: {
|
||||
vueRouterMode: 'history', // available values: 'hash', 'history'
|
||||
|
||||
// transpile: false,
|
||||
// transpile: false,// eslint-disable-next-line
|
||||
|
||||
|
||||
// Add dependencies for transpiling with Babel (Array of string/regex)
|
||||
// (from node_modules, which are by default not transpiled).
|
||||
// Applies only if "transpile" is set to true.
|
||||
// transpileDependencies: [],
|
||||
|
||||
// rtl: false, // https://quasar.dev/options/rtl-support
|
||||
// preloadChunks: true,
|
||||
// showProgress: false,
|
||||
// gzip: true,
|
||||
// rtl: false, // https://quasa// eslint-disable-next-line
|
||||
|
||||
// analyze: true,
|
||||
|
||||
// Options below are automatically set depending on the env, set them if you want to override
|
||||
|
@ -77,7 +78,23 @@ module.exports = configure(function (/* ctx */) {
|
|||
extensions: [ 'ts', 'js', 'vue' ],
|
||||
exclude: 'node_modules'
|
||||
}])
|
||||
},
|
||||
chain.plugin('modify-source-webpack-plugin')
|
||||
.use(ModifySourcePlugin, [{
|
||||
rules: [
|
||||
{
|
||||
test: /plugins\.ts$/,
|
||||
modify: (src, filename) => {
|
||||
const custom_plgns = require('./plugin.config.js')
|
||||
const required_plgns = require('./src/vendor-plugin.config.js')
|
||||
return src.replace(/\/\* *INSERT_PLUGIN_LIST *\*\//,
|
||||
[...custom_plgns, ...required_plgns].map(v => `import("${v}").then(v => success(v)).catch(() => failure("${v}"))`)
|
||||
.join(','))
|
||||
}
|
||||
}
|
||||
]
|
||||
}])
|
||||
//chain.resolve.alias.set('flaschengeist', '.')
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"@quasar/qcalendar": {}
|
||||
}
|
|
@ -1,10 +1,8 @@
|
|||
import config from 'src/config';
|
||||
import { boot } from 'quasar/wrappers';
|
||||
import { useMainStore, api } from '@flaschengeist/api';
|
||||
import { LocalStorage, Notify } from 'quasar';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { useMainStore } from 'src/stores';
|
||||
|
||||
const api = axios.create();
|
||||
import { AxiosError } from 'axios';
|
||||
import { boot } from 'quasar/wrappers';
|
||||
import config from 'src/config';
|
||||
|
||||
export default boot(({ router }) => {
|
||||
api.defaults.baseURL = LocalStorage.getItem<string>('baseURL') || config.baseURL;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { useMainStore, hasPermissions } from '@flaschengeist/api';
|
||||
import { boot } from 'quasar/wrappers';
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { hasPermissions } from 'src/utils/permission';
|
||||
import { RouteRecord } from 'vue-router';
|
||||
|
||||
export default boot(({ router }) => {
|
||||
|
|
|
@ -1,47 +1,47 @@
|
|||
import { boot } from 'quasar/wrappers';
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
import routes from 'src/router/routes';
|
||||
import { Notify } from 'quasar';
|
||||
import { api } from 'boot/axios';
|
||||
import { boot } from 'quasar/wrappers';
|
||||
import routes from 'src/router/routes';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { RouteRecordRaw } from 'vue-router';
|
||||
import { Notify } from 'quasar';
|
||||
|
||||
const config: { [key: string]: Array<string> } = {
|
||||
// Do not change required Modules !!
|
||||
requiredModules: ['User'],
|
||||
// here you can import plugins.
|
||||
loadModules: ['Balance', 'Schedule', 'Pricelist'],
|
||||
//loadModules: [],
|
||||
};
|
||||
|
||||
/* Stop!
|
||||
|
||||
// do not change anything here !!
|
||||
|
||||
// You can not even read? I said stop!
|
||||
|
||||
// Really are you stupid? Stop scrolling down here!
|
||||
|
||||
// Every line you scroll down, an unicorn will die painfully!
|
||||
|
||||
// Ok you must hate unicorns... But what if I say you I joked... Baby otters will die!
|
||||
|
||||
.-"""-.
|
||||
/ o\
|
||||
| o 0).-.
|
||||
| .-;(_/ .-.
|
||||
\ / /)).---._| `\ ,
|
||||
'. ' /(( `'-./ _/|
|
||||
\ .' ) .-.;` /
|
||||
'. | `\-'
|
||||
'._ -' /
|
||||
``""--`------`
|
||||
*/
|
||||
import { FG_Plugin } from '@flaschengeist/types';
|
||||
|
||||
/****************************************************
|
||||
******** Internal area for some magic **************
|
||||
****************************************************/
|
||||
|
||||
declare type ImportPlgn = { default: FG_Plugin.Plugin };
|
||||
|
||||
function validatePlugin(plugin: FG_Plugin.Plugin) {
|
||||
return (
|
||||
typeof plugin.name === 'string' &&
|
||||
typeof plugin.id === 'string' &&
|
||||
plugin.id.length > 0 &&
|
||||
typeof plugin.version === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/* eslint-disable */
|
||||
// This functions are used by webpack magic
|
||||
// Called when import promise resolved
|
||||
function success(value: ImportPlgn, path: string) {
|
||||
if (validatePlugin(value.default)) PLUGINS.plugins.set(value.default.id, value.default);
|
||||
else failure(path);
|
||||
}
|
||||
// Called when import promise rejected
|
||||
function failure(path = 'unknown') {
|
||||
console.error(`Plugin ${path} could not be found and not imported`);
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
// Here does some magic happens, WebPack will automatically replace the following comment with the import statements
|
||||
const PLUGINS = {
|
||||
context: <Array<() => Promise<ImportPlgn>>>[
|
||||
/*INSERT_PLUGIN_LIST*/
|
||||
],
|
||||
plugins: new Map<string, FG_Plugin.Plugin>(),
|
||||
};
|
||||
|
||||
interface BackendPlugin {
|
||||
permissions: string[];
|
||||
version: string;
|
||||
|
@ -222,81 +222,61 @@ function combineShortcuts(target: FG_Plugin.Shortcut[], source: FG_Plugin.MenuRo
|
|||
/**
|
||||
* Load a Flaschengeist plugin
|
||||
* @param loadedPlugins Flaschgeist object
|
||||
* @param pluginName Plugin to load
|
||||
* @param context RequireContext of plugins
|
||||
* @param plugin Plugin to load
|
||||
* @param router VueRouter instance
|
||||
*/
|
||||
function loadPlugin(
|
||||
loadedPlugins: FG_Plugin.Flaschengeist,
|
||||
pluginName: string,
|
||||
context: __WebpackModuleApi.RequireContext,
|
||||
plugin: FG_Plugin.Plugin,
|
||||
backend: Backend
|
||||
) {
|
||||
// Check if already loaded
|
||||
if (loadedPlugins.plugins.findIndex((p) => p.name === pluginName) !== -1) return true;
|
||||
if (loadedPlugins.plugins.findIndex((p) => p.name === plugin.name) !== -1) return true;
|
||||
|
||||
// Search if plugin is installed
|
||||
const available = context.keys();
|
||||
const plugin = available.includes(`./${pluginName.toLowerCase()}/plugin.ts`)
|
||||
? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
<FG_Plugin.Plugin>context(`./${pluginName.toLowerCase()}/plugin.ts`).default
|
||||
: undefined;
|
||||
|
||||
if (!plugin) {
|
||||
// Plugin is not found, results in an error
|
||||
console.exception(`Could not find required Plugin ${pluginName}`);
|
||||
// Check backend dependencies
|
||||
if (
|
||||
!plugin.requiredModules.every(
|
||||
(required) =>
|
||||
backend.plugins[required[0]] !== undefined &&
|
||||
(required.length == 1 ||
|
||||
true) /* validate the version, semver440 from python is... tricky on node*/
|
||||
)
|
||||
) {
|
||||
console.error(`Plugin ${plugin.name}: Backend modules not satisfied`);
|
||||
return false;
|
||||
} else {
|
||||
// Plugin found. Check backend dependencies
|
||||
if (
|
||||
!plugin.requiredBackendModules.every((required) => backend.plugins[required] !== undefined)
|
||||
) {
|
||||
console.error(`Plugin ${pluginName}: Backend modules not satisfied`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check frontend dependencies
|
||||
if (
|
||||
!plugin.requiredModules.every((required) =>
|
||||
loadPlugin(loadedPlugins, required, context, backend)
|
||||
)
|
||||
) {
|
||||
console.error(`Plugin ${pluginName}: Backend modules not satisfied`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start combining and loading routes, shortcuts etc
|
||||
if (plugin.internalRoutes) {
|
||||
combineRoutes(loadedPlugins.routes, plugin.internalRoutes, '/in');
|
||||
}
|
||||
|
||||
if (plugin.innerRoutes) {
|
||||
// Routes for Vue Router
|
||||
combineMenuRoutes(loadedPlugins.routes, plugin.innerRoutes, '/in');
|
||||
// Combine links for menu
|
||||
plugin.innerRoutes.forEach((route) => combineMenuLinks(loadedPlugins.menuLinks, route));
|
||||
// Combine shortcuts
|
||||
combineShortcuts(loadedPlugins.shortcuts, plugin.innerRoutes);
|
||||
}
|
||||
|
||||
if (plugin.outerRoutes) {
|
||||
combineMenuRoutes(loadedPlugins.routes, plugin.outerRoutes);
|
||||
combineShortcuts(loadedPlugins.outerShortcuts, plugin.outerRoutes);
|
||||
}
|
||||
|
||||
if (plugin.widgets.length > 0) {
|
||||
plugin.widgets.forEach((widget) => (widget.name = plugin.name + '_' + widget.name));
|
||||
Array.prototype.push.apply(loadedPlugins.widgets, plugin.widgets);
|
||||
}
|
||||
|
||||
loadedPlugins.plugins.push({
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
notification: plugin.notification?.bind({}) || translateNotification,
|
||||
});
|
||||
|
||||
return plugin;
|
||||
}
|
||||
|
||||
// Start combining and loading routes, shortcuts etc
|
||||
if (plugin.internalRoutes) {
|
||||
combineRoutes(loadedPlugins.routes, plugin.internalRoutes, '/in');
|
||||
}
|
||||
|
||||
if (plugin.innerRoutes) {
|
||||
// Routes for Vue Router
|
||||
combineMenuRoutes(loadedPlugins.routes, plugin.innerRoutes, '/in');
|
||||
// Combine links for menu
|
||||
plugin.innerRoutes.forEach((route) => combineMenuLinks(loadedPlugins.menuLinks, route));
|
||||
// Combine shortcuts
|
||||
combineShortcuts(loadedPlugins.shortcuts, plugin.innerRoutes);
|
||||
}
|
||||
|
||||
if (plugin.outerRoutes) {
|
||||
combineMenuRoutes(loadedPlugins.routes, plugin.outerRoutes);
|
||||
combineShortcuts(loadedPlugins.outerShortcuts, plugin.outerRoutes);
|
||||
}
|
||||
|
||||
if (plugin.widgets.length > 0) {
|
||||
plugin.widgets.forEach((widget) => (widget.name = plugin.name + '_' + widget.name));
|
||||
Array.prototype.push.apply(loadedPlugins.widgets, plugin.widgets);
|
||||
}
|
||||
|
||||
loadedPlugins.plugins.push({
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
notification: plugin.notification?.bind({}) || translateNotification,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -318,8 +298,9 @@ async function getBackend() {
|
|||
*/
|
||||
export default boot(async ({ router, app }) => {
|
||||
const backend = await getBackend();
|
||||
if (backend === null) {
|
||||
void router.push({ name: 'error' });
|
||||
if (!backend || typeof backend !== 'object' || !('plugins' in backend)) {
|
||||
console.log('Backend error');
|
||||
router.isReady().finally(() => void router.push({ name: 'offline', params: { refresh: 1 } }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -332,39 +313,23 @@ export default boot(async ({ router, app }) => {
|
|||
widgets: [],
|
||||
};
|
||||
|
||||
// get all plugins
|
||||
const pluginsContext = require.context('src/plugins', true, /.+\/plugin.ts$/);
|
||||
const BreakError = {};
|
||||
try {
|
||||
PLUGINS.plugins.forEach((plugin, name) => {
|
||||
if (!loadPlugin(loadedPlugins, plugin, backend)) {
|
||||
void router.push({ name: 'error' });
|
||||
|
||||
// Start loading plugins
|
||||
// Load required modules, if not found or error when loading this will forward the user to the error page
|
||||
config.requiredModules.forEach((required) => {
|
||||
const plugin = loadPlugin(loadedPlugins, required, pluginsContext, backend);
|
||||
if (!plugin) {
|
||||
void router.push({ name: 'error' });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Load user defined plugins
|
||||
// If there is an error with loading a plugin, the user will get informed.
|
||||
const failed: string[] = [];
|
||||
config.loadModules.forEach((required) => {
|
||||
const plugin = loadPlugin(loadedPlugins, required, pluginsContext, backend);
|
||||
if (!plugin) {
|
||||
failed.push(required);
|
||||
}
|
||||
});
|
||||
if (failed.length > 0) {
|
||||
// Log failed plugins
|
||||
console.error('Could not load all plugins', failed);
|
||||
// Inform user about error
|
||||
Notify.create({
|
||||
type: 'negative',
|
||||
message:
|
||||
'Fehler beim Laden: Nicht alle Funktionen stehen zur Verfügung. Bitte wende dich an den Admin!',
|
||||
timeout: 10000,
|
||||
progress: true,
|
||||
Notify.create({
|
||||
type: 'negative',
|
||||
message: `Fehler beim Laden: Bitte wende dich an den Admin (error: PNF-${name}!`,
|
||||
timeout: 10000,
|
||||
progress: true,
|
||||
});
|
||||
throw BreakError;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (e !== BreakError) throw e;
|
||||
}
|
||||
|
||||
// Sort widgets by priority
|
||||
|
|
|
@ -1,13 +1,8 @@
|
|||
import { createPinia, Pinia } from 'pinia';
|
||||
import { useMainStore, pinia } from '@flaschengeist/api';
|
||||
import { boot } from 'quasar/wrappers';
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const pinia = ref<Pinia>();
|
||||
|
||||
export default boot(({ app }) => {
|
||||
pinia.value = createPinia();
|
||||
app.use(pinia.value);
|
||||
app.use(pinia);
|
||||
|
||||
const store = useMainStore();
|
||||
void store.init();
|
||||
|
|
|
@ -34,8 +34,8 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from 'vue';
|
||||
import { formatDateTime } from 'src/utils/datetime';
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
import { formatDateTime } from '@flaschengeist/api';
|
||||
import { FG_Plugin } from '@flaschengeist/types';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
export default defineComponent({
|
||||
|
|
|
@ -2,9 +2,7 @@
|
|||
<q-expansion-item
|
||||
v-if="isGranted(entry)"
|
||||
clickable
|
||||
tag="a"
|
||||
target="self"
|
||||
:label="title"
|
||||
:label="getTitle(entry)"
|
||||
:icon="entry.icon"
|
||||
expand-separator
|
||||
>
|
||||
|
@ -19,7 +17,7 @@
|
|||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
{{ child.title }}
|
||||
{{ getTitle(child) }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
@ -29,9 +27,9 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType } from 'vue';
|
||||
import { hasPermissions } from 'src/utils/permission';
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
import { FG_Plugin } from '@flaschengeist/types';
|
||||
import { useMainStore } from 'app/api';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EssentialExpansionLink',
|
||||
|
@ -46,17 +44,20 @@ export default defineComponent({
|
|||
addShortCut: (val: FG_Plugin.MenuLink) => val.link,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const store = useMainStore();
|
||||
|
||||
function isGranted(val: FG_Plugin.MenuLink) {
|
||||
return computed(() => hasPermissions(val.permissions || []));
|
||||
return !val.permissions || val.permissions.every((p) => store.permissions.includes(p));
|
||||
}
|
||||
const title = computed(() =>
|
||||
typeof props.entry.title === 'function' ? props.entry.title() : props.entry.title
|
||||
);
|
||||
function getTitle(entry: FG_Plugin.MenuLink) {
|
||||
return typeof entry.title === 'function' ? entry.title() : entry.title;
|
||||
}
|
||||
|
||||
function addShortCut(val: FG_Plugin.MenuLink) {
|
||||
emit('addShortCut', val);
|
||||
}
|
||||
|
||||
return { isGranted, title, addShortCut };
|
||||
return { isGranted, getTitle, addShortCut };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -12,8 +12,8 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType } from 'vue';
|
||||
import { hasPermissions } from 'src/utils/permission';
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
import { hasPermissions } from '@flaschengeist/api';
|
||||
import { FG_Plugin } from '@flaschengeist/types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EssentialLink',
|
||||
|
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType } from 'vue';
|
||||
import { hasPermissions } from 'src/utils/permission';
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
import { hasPermissions } from '@flaschengeist/api';
|
||||
import { FG_Plugin } from '@flaschengeist/types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ShortcutLink',
|
||||
|
|
|
@ -1,137 +0,0 @@
|
|||
declare namespace FG {
|
||||
interface Notification {
|
||||
id: number;
|
||||
plugin: string;
|
||||
text: string;
|
||||
data?: unknown;
|
||||
time: Date;
|
||||
}
|
||||
interface User {
|
||||
userid: string;
|
||||
display_name: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
mail: string;
|
||||
birthday?: Date;
|
||||
roles: Array<string>;
|
||||
permissions?: Array<string>;
|
||||
avatar_url?: string;
|
||||
}
|
||||
interface Session {
|
||||
expires: Date;
|
||||
token: string;
|
||||
lifetime: number;
|
||||
browser: string;
|
||||
platform: string;
|
||||
userid: string;
|
||||
}
|
||||
type Permission = string;
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
permissions: Array<Permission>;
|
||||
}
|
||||
interface Transaction {
|
||||
id: number;
|
||||
time: Date;
|
||||
amount: number;
|
||||
reversal_id?: number;
|
||||
author_id?: string;
|
||||
sender_id?: string;
|
||||
original_id?: number;
|
||||
receiver_id?: string;
|
||||
}
|
||||
interface Event {
|
||||
id: number;
|
||||
start: Date;
|
||||
end?: Date;
|
||||
name?: string;
|
||||
description?: string;
|
||||
type: EventType | number;
|
||||
is_template: boolean;
|
||||
jobs: Array<Job>;
|
||||
}
|
||||
interface EventType {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
interface Invite {
|
||||
id: number;
|
||||
job_id: number;
|
||||
invitee_id: string;
|
||||
sender_id: string;
|
||||
}
|
||||
interface Job {
|
||||
id: number;
|
||||
start: Date;
|
||||
end?: Date;
|
||||
type: JobType | number;
|
||||
comment?: string;
|
||||
services: Array<Service>;
|
||||
required_services: number;
|
||||
}
|
||||
interface JobType {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
interface Service {
|
||||
userid: string;
|
||||
is_backup: boolean;
|
||||
value: number;
|
||||
}
|
||||
interface Drink {
|
||||
id: number;
|
||||
article_id?: string;
|
||||
package_size?: number;
|
||||
name: string;
|
||||
volume?: number;
|
||||
cost_per_volume?: number;
|
||||
cost_per_package?: number;
|
||||
tags?: Array<Tag>;
|
||||
type?: DrinkType;
|
||||
volumes: Array<DrinkPriceVolume>;
|
||||
uuid: string;
|
||||
receipt?: Array<string>;
|
||||
}
|
||||
interface DrinkIngredient {
|
||||
id: number;
|
||||
volume: number;
|
||||
ingredient_id: number;
|
||||
}
|
||||
interface DrinkPrice {
|
||||
id: number;
|
||||
price: number;
|
||||
public: boolean;
|
||||
description?: string;
|
||||
}
|
||||
interface DrinkPriceVolume {
|
||||
id: number;
|
||||
volume: number;
|
||||
min_prices: Array<MinPrices>;
|
||||
prices: Array<DrinkPrice>;
|
||||
ingredients: Array<Ingredient>;
|
||||
}
|
||||
interface DrinkType {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
interface ExtraIngredient {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
interface Ingredient {
|
||||
id: number;
|
||||
drink_ingredient?: DrinkIngredient;
|
||||
extra_ingredient?: ExtraIngredient;
|
||||
}
|
||||
interface MinPrices {
|
||||
percentage: number;
|
||||
price: number;
|
||||
}
|
||||
interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
}
|
|
@ -52,14 +52,12 @@
|
|||
@click.capture="openMenu"
|
||||
>
|
||||
<!-- Plugins -->
|
||||
<q-list>
|
||||
<essential-expansion-link
|
||||
v-for="(entry, index) in mainLinks"
|
||||
:key="'plugin' + index"
|
||||
:entry="entry"
|
||||
@add-short-cut="addShortcut"
|
||||
/>
|
||||
</q-list>
|
||||
<essential-expansion-link
|
||||
v-for="(entry, index) in mainLinks"
|
||||
:key="'plugin' + index"
|
||||
:entry="entry"
|
||||
@add-short-cut="addShortcut"
|
||||
/>
|
||||
<q-separator />
|
||||
<essential-link
|
||||
v-for="(entry, index) in essentials"
|
||||
|
@ -74,6 +72,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import EssentialExpansionLink from 'components/navigation/EssentialExpansionLink.vue';
|
||||
import EssentialLink from 'src/components/navigation/EssentialLink.vue';
|
||||
import ShortcutLink from 'src/components/navigation/ShortcutLink.vue';
|
||||
import Notification from 'src/components/Notification.vue';
|
||||
|
@ -86,14 +85,13 @@ import {
|
|||
onBeforeUnmount,
|
||||
ComponentPublicInstance,
|
||||
} from 'vue';
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Screen } from 'quasar';
|
||||
import config from 'src/config';
|
||||
import EssentialExpansionLink from 'components/navigation/EssentialExpansionLink.vue';
|
||||
import draggable from 'vuedraggable';
|
||||
const drag: ComponentPublicInstance = <ComponentPublicInstance>draggable;
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMainStore } from '@flaschengeist/api';
|
||||
import { FG_Plugin } from '@flaschengeist/types';
|
||||
import drag from 'vuedraggable';
|
||||
|
||||
const essentials: FG_Plugin.MenuLink[] = [
|
||||
{
|
||||
title: 'Über Flaschengeist',
|
||||
|
@ -104,7 +102,13 @@ const essentials: FG_Plugin.MenuLink[] = [
|
|||
|
||||
export default defineComponent({
|
||||
name: 'MainLayout',
|
||||
components: { EssentialExpansionLink, EssentialLink, ShortcutLink, Notification, drag },
|
||||
components: {
|
||||
EssentialExpansionLink,
|
||||
EssentialLink,
|
||||
ShortcutLink,
|
||||
Notification,
|
||||
drag: <ComponentPublicInstance>drag,
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const mainStore = useMainStore();
|
||||
|
|
|
@ -25,8 +25,8 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
import { defineComponent, inject } from 'vue';
|
||||
import { FG_Plugin } from '@flaschengeist/types';
|
||||
import ShortcutLink from 'components/navigation/ShortcutLink.vue';
|
||||
|
||||
export default defineComponent({
|
||||
|
|
|
@ -12,8 +12,8 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, inject } from 'vue';
|
||||
import { hasPermissions } from 'src/utils/permission';
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
import { hasPermissions } from '@flaschengeist/api';
|
||||
import { FG_Plugin } from '@flaschengeist/types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Dashboard',
|
||||
|
|
|
@ -37,14 +37,14 @@
|
|||
</q-card-section>
|
||||
<div class="row justify-end">
|
||||
<q-btn
|
||||
v-if="$q.platform.is.cordova || $q.platform.is.electron"
|
||||
v-if="quasar.platform.is.cordova || quasar.platform.is.electron"
|
||||
flat
|
||||
round
|
||||
icon="mdi-menu-down"
|
||||
@click="openServerSettings"
|
||||
/>
|
||||
</div>
|
||||
<q-slide-transition v-if="$q.platform.is.cordova || $q.platform.is.electron">
|
||||
<q-slide-transition v-if="quasar.platform.is.cordova || quasar.platform.is.electron">
|
||||
<div v-show="visible">
|
||||
<q-separator />
|
||||
<q-card-section>
|
||||
|
@ -63,12 +63,10 @@
|
|||
<script lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Loading, Notify } from 'quasar';
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { notEmpty, useMainStore, useUserStore } from '@flaschengeist/api';
|
||||
import { PasswordInput } from '@flaschengeist/api/components';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { setBaseURL, api } from 'boot/axios';
|
||||
import { notEmpty } from 'src/utils/validators';
|
||||
import { useUserStore } from 'src/plugins/user/store';
|
||||
import PasswordInput from 'src/components/utils/PasswordInput.vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
|
||||
export default defineComponent({
|
||||
|
@ -76,6 +74,7 @@ export default defineComponent({
|
|||
components: { PasswordInput },
|
||||
setup() {
|
||||
const mainStore = useMainStore();
|
||||
const userStore = useUserStore();
|
||||
const mainRoute = { name: 'dashboard' };
|
||||
const router = useRouter();
|
||||
|
||||
|
@ -84,7 +83,7 @@ export default defineComponent({
|
|||
const password = ref('');
|
||||
const server = ref<string | undefined>(api.defaults.baseURL);
|
||||
const visible = ref(false);
|
||||
const $q = useQuasar();
|
||||
const quasar = useQuasar();
|
||||
|
||||
function openServerSettings() {
|
||||
visible.value = !visible.value;
|
||||
|
@ -101,7 +100,7 @@ export default defineComponent({
|
|||
const status = await mainStore.login(userid.value, password.value);
|
||||
|
||||
if (status === true) {
|
||||
mainStore.user = (await useUserStore().getUser(userid.value, true)) || undefined;
|
||||
mainStore.user = (await userStore.getUser(userid.value, true)) || undefined;
|
||||
const x = router.currentRoute.value.query['redirect'];
|
||||
void router.push(typeof x === 'string' ? { path: x } : mainRoute);
|
||||
} else {
|
||||
|
@ -158,7 +157,7 @@ export default defineComponent({
|
|||
server,
|
||||
userid,
|
||||
visible,
|
||||
$q,
|
||||
quasar,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -46,9 +46,9 @@ export default defineComponent({
|
|||
const ival = setInterval(() => {
|
||||
reload.value -= 1;
|
||||
if (reload.value <= 0) {
|
||||
if (router.currentRoute.value.params && 'refresh' in router.currentRoute.value.params)
|
||||
router.go(0);
|
||||
const path = router.currentRoute.value.query.redirect;
|
||||
console.log('Offline: ');
|
||||
console.log(path);
|
||||
void router.replace(path ? { path: <string>path } : { name: 'login' });
|
||||
}
|
||||
}, 1000);
|
||||
|
|
|
@ -34,10 +34,10 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useMainStore } from '@flaschengeist/api';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Loading, Notify } from 'quasar';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { useMainStore } from 'src/stores';
|
||||
|
||||
export default defineComponent({
|
||||
// name: 'PageName'
|
||||
|
|
|
@ -60,8 +60,8 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent, inject } from 'vue';
|
||||
import { FG_Plugin } from '@flaschengeist/types';
|
||||
import Developer from 'components/about/Developer.vue';
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
|
||||
const developers = [
|
||||
{
|
||||
|
@ -69,8 +69,7 @@ const developers = [
|
|||
lastname: 'Gröger',
|
||||
club: 'Studentenclub Wu5 e.V.',
|
||||
job: 'Gründer von Flaschengeist; Maintainer',
|
||||
pic:
|
||||
'https://scontent-frt3-2.xx.fbcdn.net/v/t1.0-9/31768724_1663023210401956_3834323197281435648_n.jpg?_nc_cat=109&_nc_sid=09cbfe&_nc_ohc=jWvUfn_xJ9YAX_oJ3CE&_nc_ht=scontent-frt3-2.xx&oh=15249378051f1e27f8b15122effb5c4a&oe=5FAC6A17',
|
||||
pic: 'https://scontent-frt3-2.xx.fbcdn.net/v/t1.0-9/31768724_1663023210401956_3834323197281435648_n.jpg?_nc_cat=109&_nc_sid=09cbfe&_nc_ohc=jWvUfn_xJ9YAX_oJ3CE&_nc_ht=scontent-frt3-2.xx&oh=15249378051f1e27f8b15122effb5c4a&oe=5FAC6A17',
|
||||
description:
|
||||
'Eigentlich wöllte ich jetzt hier echt viel hinschreiben. Aber ich habe keinen Plan was. Früher war ich einfach nur Tim G. und habe für andere den Kaffe geholt. Unter anderen für Ferdinand Thiessen.',
|
||||
},
|
||||
|
@ -78,8 +77,7 @@ const developers = [
|
|||
firstname: 'Ferdinand',
|
||||
lastname: 'Thiessen',
|
||||
club: 'Club Aquarium e.V.',
|
||||
pic:
|
||||
'https://scontent-frx5-1.xx.fbcdn.net/v/t1.0-9/17022243_1418942461493397_9069541318944803902_n.jpg?_nc_cat=110&_nc_sid=174925&_nc_ohc=HjkSm8vcRW8AX8bTnJ8&_nc_ht=scontent-frx5-1.xx&oh=f09bd36525f3c6e55feaafb3b05b43d2&oe=5FAD432A',
|
||||
pic: 'https://scontent-frx5-1.xx.fbcdn.net/v/t1.0-9/17022243_1418942461493397_9069541318944803902_n.jpg?_nc_cat=110&_nc_sid=174925&_nc_ohc=HjkSm8vcRW8AX8bTnJ8&_nc_ht=scontent-frx5-1.xx&oh=f09bd36525f3c6e55feaafb3b05b43d2&oe=5FAD432A',
|
||||
job: 'Backend-Developer; Co-Maintainer',
|
||||
description:
|
||||
'Geiler Typ. Einfach mal so alles Aufgeräumt. Aufeinmal könnte man aus dem Code eine Dokumentation zaubern!',
|
||||
|
@ -90,8 +88,7 @@ const developers = [
|
|||
club: 'Studentenclub Wu5 e.V.',
|
||||
job: 'Eigentlich Frontend-Developer',
|
||||
description: 'Er findet sich langsam rein.',
|
||||
pic:
|
||||
'https://scontent-frt3-1.xx.fbcdn.net/v/t31.0-8/10363433_647611335326483_3447118968375865826_o.jpg?_nc_cat=104&_nc_sid=09cbfe&_nc_ohc=nWMgo-6Ih74AX_NiGUz&_nc_ht=scontent-frt3-1.xx&oh=f16d2edfe86f68d54900099087edb9c9&oe=5FAACFD4',
|
||||
pic: 'https://scontent-frt3-1.xx.fbcdn.net/v/t31.0-8/10363433_647611335326483_3447118968375865826_o.jpg?_nc_cat=104&_nc_sid=09cbfe&_nc_ohc=nWMgo-6Ih74AX_NiGUz&_nc_ht=scontent-frt3-1.xx&oh=f16d2edfe86f68d54900099087edb9c9&oe=5FAACFD4',
|
||||
},
|
||||
];
|
||||
export default defineComponent({
|
||||
|
|
|
@ -1,117 +0,0 @@
|
|||
import { RouteLocationRaw, RouteRecordRaw, RouteRecordName } from 'vue-router';
|
||||
import { Component } from 'vue';
|
||||
|
||||
declare namespace FG_Plugin {
|
||||
/**
|
||||
* Interface defining a Flaschengeist plugin
|
||||
*/
|
||||
interface Plugin {
|
||||
name: string;
|
||||
version: string;
|
||||
widgets: Widget[];
|
||||
/** Pther frontend modules needed for this plugin to work correctly */
|
||||
requiredModules: string[];
|
||||
/** Backend modules needed for this plugin to work correctly */
|
||||
requiredBackendModules: string[];
|
||||
/** Menu entries for authenticated users */
|
||||
innerRoutes?: MenuRoute[];
|
||||
/** Public menu entries (without authentification) */
|
||||
outerRoutes?: MenuRoute[];
|
||||
/** Routes without menu links, for internal usage */
|
||||
internalRoutes?: NamedRouteRecordRaw[];
|
||||
/** Handle notifications, defaults to boot/plugins.ts:translateNotification() */
|
||||
notification?(msg: FG.Notification): FG_Plugin.Notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the loaded state of the Flaschengeist
|
||||
*/
|
||||
interface Flaschengeist {
|
||||
/** All loaded plugins */
|
||||
plugins: LoadedPlugin[];
|
||||
/** All routes, combined from all plugins */
|
||||
routes: RouteRecordRaw[];
|
||||
/** All menu entries */
|
||||
menuLinks: MenuLink[];
|
||||
/** All inner shortcuts */
|
||||
shortcuts: Shortcut[];
|
||||
/** All outer shortcuts */
|
||||
outerShortcuts: Shortcut[];
|
||||
/** All widgets */
|
||||
widgets: Widget[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for a frontend notification
|
||||
*/
|
||||
interface Notification extends FG.Notification {
|
||||
/** If set a button for accepting will be shown, this function will get called before deleting the notification */
|
||||
accept?(): Promise<void>;
|
||||
/** If set this function is called before the notification gets deleted */
|
||||
reject?(): Promise<void>;
|
||||
/** If set the notification text is interpreted as a link to this location */
|
||||
link?: RouteLocationRaw;
|
||||
/** If set this icon is used */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded Flaschengeist plugin
|
||||
*/
|
||||
interface LoadedPlugin {
|
||||
name: string;
|
||||
version: string;
|
||||
notification(msg: FG.Notification): FG_Plugin.Notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a shortcut link
|
||||
*/
|
||||
interface Shortcut {
|
||||
link: RouteRecordName;
|
||||
icon: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a main menu entry along with the route
|
||||
* Used when defining a plugin
|
||||
*/
|
||||
interface MenuRoute extends MenuEntry {
|
||||
route: NamedRouteRecordRaw;
|
||||
shortcut?: boolean;
|
||||
children?: this[];
|
||||
}
|
||||
|
||||
type NamedRouteRecordRaw = RouteRecordRaw & {
|
||||
name: RouteRecordName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines a menu entry in the main menu
|
||||
*/
|
||||
interface MenuLink extends MenuEntry {
|
||||
/** Name of the target route */
|
||||
link: RouteRecordName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for internal use
|
||||
*/
|
||||
interface MenuEntry {
|
||||
title: string | (() => string);
|
||||
icon: string;
|
||||
permissions?: string[];
|
||||
children?: this[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget object for the dashboard
|
||||
*/
|
||||
interface Widget {
|
||||
name: string;
|
||||
priority: number;
|
||||
permissions: FG.Permission[];
|
||||
widget: Component;
|
||||
}
|
||||
}
|
|
@ -1,115 +0,0 @@
|
|||
<template>
|
||||
<q-card>
|
||||
<BalanceHeader v-model="user" :show-selector="showSelector" @open-history="openHistory" />
|
||||
<q-separator />
|
||||
|
||||
<q-card-section v-if="shortCuts" class="row q-col-gutter-md">
|
||||
<div v-for="(shortcut, index) in shortCuts" :key="index" class="col-4">
|
||||
<q-btn
|
||||
v-if="shortcut"
|
||||
push
|
||||
color="primary"
|
||||
style="width: 100%"
|
||||
:label="shortcut.toFixed(2).toString() + ' €'"
|
||||
@click="changeBalance(shortcut)"
|
||||
>
|
||||
<q-popup-proxy context-menu>
|
||||
<q-btn label="Entfernen" @click="removeShortcut(shortcut)" />
|
||||
</q-popup-proxy>
|
||||
<q-tooltip>Rechtsklick um Verknüpfung zu entfernen</q-tooltip>
|
||||
</q-btn>
|
||||
</div></q-card-section
|
||||
>
|
||||
<q-card-section class="row q-col-gutter-md items-center">
|
||||
<div class="col-sm-4 col-xs-12">
|
||||
<q-input
|
||||
v-model.number="amount"
|
||||
type="number"
|
||||
filled
|
||||
label="Eigener Betrag"
|
||||
step="0.1"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm-4 col-xs-6">
|
||||
<q-btn
|
||||
style="width: 100%"
|
||||
color="primary"
|
||||
label="Anschreiben"
|
||||
@click="changeBalance(amount * -1)"
|
||||
><q-tooltip>Rechtsklick um Betrag als Verknüpfung hinzuzufügen</q-tooltip>
|
||||
<q-popup-proxy v-model="showAddShortcut" context-menu>
|
||||
<q-btn label="neue Verknüpfung" @click="addShortcut"></q-btn>
|
||||
</q-popup-proxy>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col-sm-4 col-xs-6">
|
||||
<q-btn
|
||||
v-if="canAddCredit"
|
||||
style="width: 100%"
|
||||
color="secondary"
|
||||
label="Gutschreiben"
|
||||
@click="changeBalance(amount)"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, ref, defineComponent, onBeforeMount } from 'vue';
|
||||
import { hasPermission } from 'src/utils/permission';
|
||||
import BalanceHeader from '../components/BalanceHeader.vue';
|
||||
import PERMISSIONS from '../permissions';
|
||||
import { useBalanceStore } from '../store';
|
||||
import { useMainStore } from 'src/stores';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BalanceAdd',
|
||||
components: { BalanceHeader },
|
||||
emits: { 'open-history': () => true },
|
||||
setup(_, { emit }) {
|
||||
const store = useBalanceStore();
|
||||
const mainStore = useMainStore();
|
||||
|
||||
onBeforeMount(() => {
|
||||
void store.getShortcuts();
|
||||
});
|
||||
|
||||
const amount = ref<number>(0);
|
||||
const showAddShortcut = ref(false);
|
||||
const user = ref(mainStore.currentUser);
|
||||
const shortCuts = computed(() => store.shortcuts);
|
||||
|
||||
const canAddCredit = hasPermission(PERMISSIONS.CREDIT);
|
||||
const showSelector = hasPermission(PERMISSIONS.DEBIT) || hasPermission(PERMISSIONS.CREDIT);
|
||||
|
||||
function addShortcut() {
|
||||
if (amount.value != 0) void store.createShortcut(amount.value * -1);
|
||||
}
|
||||
function removeShortcut(shortcut: number) {
|
||||
void store.removeShortcut(shortcut);
|
||||
}
|
||||
async function changeBalance(amount: number) {
|
||||
await store.changeBalance(amount, user.value);
|
||||
}
|
||||
|
||||
function openHistory() {
|
||||
emit('open-history');
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
addShortcut,
|
||||
canAddCredit,
|
||||
removeShortcut,
|
||||
showAddShortcut,
|
||||
changeBalance,
|
||||
amount,
|
||||
showSelector,
|
||||
shortCuts,
|
||||
openHistory,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,66 +0,0 @@
|
|||
<template>
|
||||
<q-card-section class="fit row justify-left content-center items-center q-col-gutter-sm">
|
||||
<div class="col-5">
|
||||
<div v-if="balance" class="text-h6">
|
||||
Aktueller Stand: {{ balance.balance.toFixed(2) }} €
|
||||
<q-badge v-if="isLocked" color="negative" align="top"> gesperrt </q-badge>
|
||||
</div>
|
||||
<q-spinner v-else color="primary" size="3em" />
|
||||
</div>
|
||||
<div v-if="showSelector" class="col-6">
|
||||
<UserSelector v-model="user" />
|
||||
</div>
|
||||
<div class="col-1 justify-end">
|
||||
<q-btn round flat icon="mdi-format-list-checks" @click="openHistory" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onBeforeMount, PropType } from 'vue';
|
||||
import UserSelector from 'src/plugins/user/components/UserSelector.vue';
|
||||
import { useBalanceStore } from '../store';
|
||||
import { useMainStore } from 'src/stores';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BalanceHeader',
|
||||
components: { UserSelector },
|
||||
props: {
|
||||
showSelector: Boolean,
|
||||
modelValue: {
|
||||
required: true,
|
||||
type: Object as PropType<FG.User>,
|
||||
},
|
||||
},
|
||||
emits: { 'update:modelValue': (u: FG.User) => !!u, 'open-history': () => true },
|
||||
setup(props, { emit }) {
|
||||
const store = useBalanceStore();
|
||||
const mainStore = useMainStore();
|
||||
|
||||
onBeforeMount(() => void store.getBalance(mainStore.currentUser));
|
||||
|
||||
const balance = computed(() =>
|
||||
store.balances.find((x) => x.userid === props.modelValue.userid)
|
||||
);
|
||||
|
||||
const isLocked = computed(
|
||||
() =>
|
||||
balance.value === undefined ||
|
||||
(balance.value.limit !== undefined && balance.value.balance <= balance.value.limit)
|
||||
);
|
||||
const user = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (x: FG.User) => {
|
||||
void store.getBalance(x);
|
||||
emit('update:modelValue', x);
|
||||
},
|
||||
});
|
||||
|
||||
function openHistory() {
|
||||
emit('open-history');
|
||||
}
|
||||
|
||||
return { user, balance, isLocked, openHistory };
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,75 +0,0 @@
|
|||
<template>
|
||||
<q-card>
|
||||
<BalanceHeader v-model="sender" :show-selector="showSelector" @open-history="openHistory" />
|
||||
<q-separator />
|
||||
<q-card-section class="row q-col-gutter-md items-center">
|
||||
<div class="col-sm-4 col-xs-12">
|
||||
<q-input v-model.number="amount" type="number" filled label="Betrag" step="0.1" min="0" />
|
||||
</div>
|
||||
<div class="col-sm-4 col-xs-6">
|
||||
<UserSelector v-model="receiver" label="Empfänger" />
|
||||
</div>
|
||||
<div class="col-sm-4 col-xs-6">
|
||||
<q-btn
|
||||
style="width: 100%"
|
||||
color="primary"
|
||||
:disable="sendDisabled"
|
||||
label="Senden"
|
||||
@click="sendAmount"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, ref, defineComponent } from 'vue';
|
||||
import { hasPermission } from 'src/utils/permission';
|
||||
import UserSelector from 'src/plugins/user/components/UserSelector.vue';
|
||||
import BalanceHeader from '../components/BalanceHeader.vue';
|
||||
import PERMISSIONS from '../permissions';
|
||||
import { useBalanceStore } from '../store';
|
||||
import { useMainStore } from 'src/stores';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BalanceTransfer',
|
||||
components: { BalanceHeader, UserSelector },
|
||||
emits: { 'open-history': () => true },
|
||||
setup(_, { emit }) {
|
||||
const store = useBalanceStore();
|
||||
const mainStore = useMainStore();
|
||||
|
||||
const showSelector = computed(() => hasPermission(PERMISSIONS.SEND_OTHER));
|
||||
const sender = ref<FG.User | undefined>(mainStore.currentUser);
|
||||
const receiver = ref<FG.User | undefined>(undefined);
|
||||
const amount = ref<number>(0);
|
||||
|
||||
const sendDisabled = computed(() => {
|
||||
return !(
|
||||
receiver.value &&
|
||||
sender.value &&
|
||||
sender.value.userid != receiver.value.userid &&
|
||||
amount.value > 0
|
||||
);
|
||||
});
|
||||
|
||||
async function sendAmount() {
|
||||
if (receiver.value) await store.changeBalance(amount.value, receiver.value, sender.value);
|
||||
}
|
||||
|
||||
function openHistory() {
|
||||
emit('open-history');
|
||||
}
|
||||
|
||||
return {
|
||||
sender,
|
||||
receiver,
|
||||
amount,
|
||||
sendAmount,
|
||||
showSelector,
|
||||
sendDisabled,
|
||||
openHistory,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,107 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<q-card flat square>
|
||||
<q-card-section class="row items-center justify-between" horizontal>
|
||||
<div class="col-5 text-left q-px-sm">
|
||||
<div :class="{ 'text-negative': isNegative() }" class="text-weight-bold text-h6">
|
||||
<span v-if="isNegative()">-</span>{{ transaction.amount.toFixed(2) }} €
|
||||
</div>
|
||||
<div class="text-caption">{{ text }}</div>
|
||||
<div class="text-caption">{{ timeStr }}</div>
|
||||
</div>
|
||||
<div class="col-5 q-px-sm text-center">
|
||||
<div v-if="isReversed" class="text-subtitle1">Storniert</div>
|
||||
</div>
|
||||
<div class="col-2 q-pr-sm" style="text-align: right">
|
||||
<q-btn
|
||||
:color="isReversed ? 'positive' : 'negative'"
|
||||
aria-label="Reverse transaction"
|
||||
:icon="!isReversed ? 'mdi-trash-can' : 'mdi-check-bold'"
|
||||
size="sm"
|
||||
round
|
||||
:disable="!canReverse"
|
||||
@click="reverse"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-separator />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, computed, defineComponent, onUnmounted, onMounted, PropType } from 'vue';
|
||||
import { hasPermission } from 'src/utils/permission';
|
||||
import { formatDateTime } from 'src/utils/datetime';
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { useUserStore } from 'src/plugins/user/store';
|
||||
import { useBalanceStore } from '../store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Transaction',
|
||||
props: {
|
||||
transaction: {
|
||||
required: true,
|
||||
type: Object as PropType<FG.Transaction>,
|
||||
},
|
||||
},
|
||||
emits: { 'update:transaction': (t: FG.Transaction) => !!t },
|
||||
setup(props, { emit }) {
|
||||
const mainStore = useMainStore();
|
||||
const userStore = useUserStore();
|
||||
const balanceStore = useBalanceStore();
|
||||
const now = ref(Date.now());
|
||||
const ival = setInterval(() => (now.value = Date.now()), 1000);
|
||||
const text = ref('');
|
||||
|
||||
onUnmounted(() => clearInterval(ival));
|
||||
onMounted(() => refreshText());
|
||||
|
||||
const isNegative = () => props.transaction.sender_id === mainStore.currentUser.userid;
|
||||
|
||||
const refreshText = async () => {
|
||||
if (isNegative()) {
|
||||
text.value = 'Anschreiben';
|
||||
if (props.transaction.receiver_id) {
|
||||
const user = <FG.User>await userStore.getUser(props.transaction.receiver_id);
|
||||
text.value = `Gesendet an ${user.display_name}`;
|
||||
}
|
||||
} else {
|
||||
text.value = 'Gutschrift';
|
||||
if (props.transaction.sender_id) {
|
||||
const user = <FG.User>await userStore.getUser(props.transaction.sender_id);
|
||||
text.value = `Bekommen von ${user.display_name}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isReversed = computed(
|
||||
() => props.transaction.reversal_id != undefined || props.transaction.original_id != undefined
|
||||
);
|
||||
|
||||
const canReverse = computed(
|
||||
() =>
|
||||
!isReversed.value &&
|
||||
(hasPermission('balance_reversal') ||
|
||||
(props.transaction.sender_id === mainStore.currentUser.userid &&
|
||||
now.value - props.transaction.time.getTime() < 10000))
|
||||
);
|
||||
|
||||
function reverse() {
|
||||
if (canReverse.value)
|
||||
void balanceStore.revert(props.transaction).then(() => {
|
||||
emit('update:transaction', props.transaction);
|
||||
});
|
||||
}
|
||||
|
||||
const timeStr = computed(() => {
|
||||
const elapsed = (now.value - props.transaction.time.getTime()) / 1000;
|
||||
if (elapsed < 60) return `Vor ${elapsed.toFixed()} s`;
|
||||
return formatDateTime(props.transaction.time, elapsed > 12 * 60 * 60, true, true) + ' Uhr';
|
||||
});
|
||||
|
||||
return { timeStr, reverse, isNegative, text, refreshText, canReverse, isReversed };
|
||||
},
|
||||
watch: { transaction: 'refreshText' },
|
||||
});
|
||||
</script>
|
|
@ -1,29 +0,0 @@
|
|||
<template>
|
||||
<q-card style="text-align: center">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Gerücht: {{ balance.toFixed(2) }} €</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { useBalanceStore } from '../store';
|
||||
import { computed, defineComponent, onBeforeMount } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BalanceWidget',
|
||||
setup() {
|
||||
const store = useBalanceStore();
|
||||
|
||||
onBeforeMount(() => {
|
||||
const mainStore = useMainStore();
|
||||
void store.getBalance(mainStore.currentUser);
|
||||
});
|
||||
|
||||
const balance = computed(() => store.balance?.balance || NaN);
|
||||
|
||||
return { balance };
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,59 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<q-page padding>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-table :rows="rows" row-key="userid" :columns="columns" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// TODO: Fill usefull data
|
||||
|
||||
import { ref, defineComponent, onMounted } from 'vue';
|
||||
import { useBalanceStore } from '../store';
|
||||
|
||||
export default defineComponent({
|
||||
// name: 'PageName'
|
||||
setup() {
|
||||
const store = useBalanceStore();
|
||||
|
||||
onMounted(() => void store.getBalances().then((balances) => rows.value.push(...balances)));
|
||||
|
||||
const rows = ref(store.balances);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'userid',
|
||||
label: 'Benutzer ID',
|
||||
field: 'userid',
|
||||
required: true,
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'credit',
|
||||
label: 'Haben',
|
||||
field: 'credit',
|
||||
format: (val: number) => val.toFixed(2)
|
||||
},
|
||||
{
|
||||
name: 'debit',
|
||||
label: 'Soll',
|
||||
field: 'debit',
|
||||
format: (val: number) => val.toFixed(2)
|
||||
},
|
||||
{
|
||||
name: 'balance',
|
||||
label: 'Kontostand',
|
||||
format: (_: undefined, row: { debit: number; credit: number }) =>
|
||||
(row.credit - row.debit).toFixed(2)
|
||||
}
|
||||
];
|
||||
return { rows, columns };
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -1,135 +0,0 @@
|
|||
<template>
|
||||
<q-page padding class="fit row justify-center content-start items-start q-gutter-sm">
|
||||
<q-tabs v-if="$q.screen.gt.sm" v-model="tab">
|
||||
<q-tab
|
||||
v-for="(tabindex, index) in tabs"
|
||||
:key="'tab' + index"
|
||||
:name="tabindex.name"
|
||||
:label="tabindex.label"
|
||||
/>
|
||||
</q-tabs>
|
||||
<div v-else class="fit row justify-end">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
icon="mdi-menu"
|
||||
@click="
|
||||
showDrawer = !showDrawer;
|
||||
show = false;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<q-drawer v-model="showDrawer" side="right" behavior="mobile" @click="showDrawer = !showDrawer">
|
||||
<q-list v-if="!$q.screen.gt.sm && !show" v-model="tab">
|
||||
<q-item
|
||||
v-for="(tabindex, index) in tabs"
|
||||
:key="'tab' + index"
|
||||
:active="tab == tabindex.name"
|
||||
clickable
|
||||
@click="tab = tabindex.name"
|
||||
>
|
||||
<q-item-label>{{ tabindex.label }}</q-item-label>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-list v-if="show">
|
||||
<div v-for="(transaction, index) in transactions" :key="index" class="col-sm-12">
|
||||
<Transaction v-model:transaction="transactions[index]" />
|
||||
</div>
|
||||
</q-list>
|
||||
</q-drawer>
|
||||
<q-tab-panels
|
||||
v-model="tab"
|
||||
style="background-color: transparent"
|
||||
class="q-pa-none col-12"
|
||||
animated
|
||||
>
|
||||
<q-tab-panel name="add" class="q-px-xs">
|
||||
<BalanceAdd
|
||||
@open-history="
|
||||
showDrawer = !showDrawer;
|
||||
show = true;
|
||||
"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="transfer" class="q-px-xs">
|
||||
<BalanceTransfer
|
||||
@open-history="
|
||||
showDrawer = !showDrawer;
|
||||
show = true;
|
||||
"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, onMounted } from 'vue';
|
||||
import { hasSomePermissions } from 'src/utils/permission';
|
||||
import PERMISSIONS from '../permissions';
|
||||
import BalanceAdd from '../components/BalanceAdd.vue';
|
||||
import BalanceTransfer from '../components/BalanceTransfer.vue';
|
||||
import Transaction from '../components/Transaction.vue';
|
||||
import { useBalanceStore } from '../store';
|
||||
import { useMainStore } from 'src/stores';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BalanceManage',
|
||||
components: { BalanceAdd, BalanceTransfer, Transaction },
|
||||
setup() {
|
||||
const balanceStore = useBalanceStore();
|
||||
const mainStore = useMainStore();
|
||||
|
||||
const now = new Date();
|
||||
onMounted(() => {
|
||||
void balanceStore.getTransactions(mainStore.currentUser, {
|
||||
from: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
|
||||
});
|
||||
});
|
||||
|
||||
const transactions = computed(() => {
|
||||
return balanceStore.transactions
|
||||
.filter((t) => t.original_id == undefined)
|
||||
.filter((t) => t.time > new Date(now.getFullYear(), now.getMonth(), now.getDate()))
|
||||
.sort((a, b) => (a.time >= b.time ? -1 : 1));
|
||||
});
|
||||
|
||||
const canAdd = () =>
|
||||
hasSomePermissions([PERMISSIONS.DEBIT, PERMISSIONS.CREDIT, PERMISSIONS.DEBIT_OWN]);
|
||||
|
||||
interface Tab {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
...(canAdd() ? [{ name: 'add', label: 'Anschreiben' }] : []),
|
||||
...(hasSomePermissions([PERMISSIONS.SEND, PERMISSIONS.SEND_OTHER])
|
||||
? [{ name: 'transfer', label: 'Übertragen' }]
|
||||
: []),
|
||||
];
|
||||
|
||||
//const drawer = ref<boolean>(false);
|
||||
|
||||
/* const showDrawer = computed({
|
||||
get: () => {
|
||||
return !Screen.gt.sm && drawer.value;
|
||||
},
|
||||
set: (val: boolean) => {
|
||||
drawer.value = val;
|
||||
}
|
||||
});
|
||||
*/
|
||||
const showDrawer = ref<boolean>(false);
|
||||
const tab = ref<string>(canAdd() ? 'add' : 'transfer');
|
||||
const show = ref<boolean>(false);
|
||||
return {
|
||||
showDrawer,
|
||||
tab,
|
||||
tabs,
|
||||
transactions,
|
||||
show,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,166 +0,0 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<div class="text-h4">Aktueller Stand</div>
|
||||
<div style="font-size: 2em">{{ balance.toFixed(2) }} €</div>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section>
|
||||
<q-table
|
||||
v-model:pagination="pagination"
|
||||
title="Buchungen"
|
||||
:rows="data"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
binary-state-sort
|
||||
@request="onRequest"
|
||||
>
|
||||
<template #top>
|
||||
<q-toggle v-model="showCancelled" label="Stornierte einblenden" />
|
||||
</template>
|
||||
<template #body-cell="props">
|
||||
<q-td :props="props" :class="{ 'bg-grey': props.row.reversal_id != null }">
|
||||
{{ props.value }}
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref } from 'vue';
|
||||
import { formatDateTime } from 'src/utils/datetime';
|
||||
import { useBalanceStore } from '../store';
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { useUserStore } from 'src/plugins/user/store';
|
||||
|
||||
export default defineComponent({
|
||||
// name: 'PageName'
|
||||
setup() {
|
||||
const store = useBalanceStore();
|
||||
const mainStore = useMainStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
onMounted(() => {
|
||||
void store.getBalance(mainStore.currentUser);
|
||||
void userStore.getUsers().then(() =>
|
||||
onRequest({
|
||||
pagination: pagination.value,
|
||||
filter: undefined
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const showCancelled = ref(false);
|
||||
const data = ref<FG.Transaction[]>([]);
|
||||
const loading = ref(false);
|
||||
const pagination = ref({
|
||||
sortBy: 'time',
|
||||
descending: false,
|
||||
page: 1,
|
||||
rowsPerPage: 3,
|
||||
rowsNumber: 10
|
||||
});
|
||||
|
||||
interface PaginationInterface {
|
||||
sortBy: string;
|
||||
descending: boolean;
|
||||
page: number;
|
||||
rowsPerPage: number;
|
||||
rowsNumber: number;
|
||||
}
|
||||
|
||||
async function onRequest(props: { pagination: PaginationInterface; filter?: string }) {
|
||||
const { page, rowsPerPage, sortBy, descending } = props.pagination;
|
||||
|
||||
loading.value = true;
|
||||
// get all rows if "All" (0) is selected
|
||||
const fetchCount = rowsPerPage === 0 ? pagination.value.rowsNumber : rowsPerPage;
|
||||
// calculate starting row of data
|
||||
const startRow = (page - 1) * rowsPerPage;
|
||||
try {
|
||||
const result = await store.getTransactions(mainStore.currentUser, {
|
||||
offset: startRow,
|
||||
limit: fetchCount,
|
||||
showCancelled: showCancelled.value,
|
||||
showReversals: false
|
||||
});
|
||||
// clear out existing data and add new
|
||||
data.value.splice(0, data.value.length, ...result.transactions);
|
||||
// don't forget to update local pagination object
|
||||
pagination.value.page = page;
|
||||
pagination.value.rowsPerPage = rowsPerPage;
|
||||
pagination.value.sortBy = sortBy;
|
||||
pagination.value.descending = descending;
|
||||
if (result.count) pagination.value.rowsNumber = result.count;
|
||||
} catch (error) {
|
||||
// ...
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const balance = computed(() => store.balance?.balance || NaN);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'time',
|
||||
label: 'Datum',
|
||||
field: 'time',
|
||||
required: true,
|
||||
sortable: true,
|
||||
format: (val: Date) => formatDateTime(new Date(val), true, true, true)
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
label: 'Type',
|
||||
format: (_: undefined, row: FG.Transaction) => {
|
||||
if (row.sender_id == null) return 'Gutschrift';
|
||||
else {
|
||||
if (row.receiver_id == null) return 'Angeschrieben';
|
||||
else {
|
||||
if (row.receiver_id === mainStore.currentUser.userid) return 'Bekommen von X';
|
||||
else return 'Gesendet an X';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
label: 'Text',
|
||||
format: (_: undefined, row: FG.Transaction) => {
|
||||
if (row.sender_id == null) return 'Gutschrift';
|
||||
else {
|
||||
if (row.receiver_id == null) return 'Angeschrieben';
|
||||
else {
|
||||
if (row.receiver_id === mainStore.currentUser.userid) return 'Bekommen von X';
|
||||
else return 'Gesendet an X';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
label: 'Betrag',
|
||||
field: 'amount',
|
||||
format: (val: number) => `${val.toFixed(2)}€`
|
||||
},
|
||||
{
|
||||
name: 'author_id',
|
||||
label: 'Benutzer',
|
||||
field: 'author_id',
|
||||
format: (val: string) => {
|
||||
const user = userStore.users.filter((x) => x.userid == val);
|
||||
if (user.length > 0) return user[0].display_name;
|
||||
else return val;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return { data, pagination, onRequest, loading, balance, columns, showCancelled };
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -1,21 +0,0 @@
|
|||
const PERMISSIONS = {
|
||||
// Show own and others balance
|
||||
SHOW: 'balance_show',
|
||||
SHOW_OTHER: 'balance_show_others',
|
||||
// Credit balance (give)
|
||||
CREDIT: 'balance_credit',
|
||||
// Debit balance (take)
|
||||
DEBIT: 'balance_debit',
|
||||
// Debit own balance only
|
||||
DEBIT_OWN: 'balance_debit_own',
|
||||
// Send from to other
|
||||
SEND: 'balance_send',
|
||||
// Send from other to another
|
||||
SEND_OTHER: 'balance_send_others',
|
||||
// Can set limit for users
|
||||
SET_LIMIT: 'balance_set_limit',
|
||||
//Allow sending / sub while exceeding the set limit
|
||||
EXCEED_LIMIT: 'balance_exceed_limit',
|
||||
};
|
||||
|
||||
export default PERMISSIONS;
|
|
@ -1,21 +0,0 @@
|
|||
import routes from './routes';
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
const plugin: FG_Plugin.Plugin = {
|
||||
name: 'Balance',
|
||||
innerRoutes: routes,
|
||||
requiredModules: ['User'],
|
||||
requiredBackendModules: ['balance'],
|
||||
version: '0.0.2',
|
||||
widgets: [
|
||||
{
|
||||
priority: 0,
|
||||
name: 'current',
|
||||
permissions: ['balance_show'],
|
||||
widget: defineAsyncComponent(() => import('./components/Widget.vue')),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default plugin;
|
|
@ -1,50 +0,0 @@
|
|||
import { FG_Plugin } from 'src/plugins';
|
||||
import permissions from '../permissions';
|
||||
|
||||
const mainRoutes: FG_Plugin.MenuRoute[] = [
|
||||
{
|
||||
title: 'Gerücht',
|
||||
icon: 'mdi-cash-100',
|
||||
permissions: ['user'],
|
||||
route: {
|
||||
path: 'balance',
|
||||
name: 'balance',
|
||||
redirect: { name: 'balance-view' },
|
||||
},
|
||||
children: [
|
||||
{
|
||||
title: 'Übersicht',
|
||||
icon: 'mdi-cash-check',
|
||||
permissions: [permissions.SHOW],
|
||||
route: {
|
||||
path: 'overview',
|
||||
name: 'balance-view',
|
||||
component: () => import('../pages/Overview.vue'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Buchen',
|
||||
icon: 'mdi-cash-plus',
|
||||
shortcut: true,
|
||||
permissions: [permissions.DEBIT_OWN, permissions.SHOW],
|
||||
route: {
|
||||
path: 'change',
|
||||
name: 'balance-change',
|
||||
component: () => import('../pages/MainPage.vue'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Verwaltung',
|
||||
icon: 'mdi-account-cash',
|
||||
permissions: [permissions.SET_LIMIT, permissions.SHOW_OTHER],
|
||||
route: {
|
||||
path: 'admin',
|
||||
name: 'balance-admin',
|
||||
component: () => import('../pages/Admin.vue'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default mainRoutes;
|
|
@ -1,166 +0,0 @@
|
|||
import { api } from 'src/boot/axios';
|
||||
|
||||
interface BalanceResponse {
|
||||
balance: number;
|
||||
credit: number;
|
||||
debit: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface BalancesResponse extends BalanceResponse {
|
||||
userid: string;
|
||||
}
|
||||
|
||||
export interface TransactionsResponse {
|
||||
transactions: Array<FG.Transaction>;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Notify } from 'quasar';
|
||||
|
||||
function fixTransaction(t: FG.Transaction) {
|
||||
t.time = new Date(t.time);
|
||||
}
|
||||
|
||||
export const useBalanceStore = defineStore({
|
||||
id: 'balance',
|
||||
|
||||
state: () => ({
|
||||
balances: [] as BalancesResponse[],
|
||||
shortcuts: [] as number[],
|
||||
transactions: [] as FG.Transaction[],
|
||||
_balances_dirty: 0,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
balance(): BalancesResponse | undefined {
|
||||
const mainStore = useMainStore();
|
||||
return this.balances.find((v) => v.userid === mainStore.user?.userid);
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async createShortcut(shortcut: number) {
|
||||
const mainStore = useMainStore();
|
||||
this.shortcuts.push(shortcut);
|
||||
this.shortcuts.sort((a, b) => a - b);
|
||||
await api.put(`/users/${mainStore.currentUser.userid}/balance/shortcuts`, this.shortcuts);
|
||||
},
|
||||
|
||||
async removeShortcut(shortcut: number) {
|
||||
const mainStore = useMainStore();
|
||||
this.shortcuts = this.shortcuts.filter((value: number) => value !== shortcut);
|
||||
this.shortcuts.sort((a, b) => a - b);
|
||||
await api.put(`/users/${mainStore.currentUser.userid}/balance/shortcuts`, this.shortcuts);
|
||||
},
|
||||
|
||||
async getShortcuts(force = false) {
|
||||
if (force || this.shortcuts.length == 0) {
|
||||
const mainStore = useMainStore();
|
||||
const { data } = await api.get<number[]>(
|
||||
`/users/${mainStore.currentUser.userid}/balance/shortcuts`
|
||||
);
|
||||
this.shortcuts = data;
|
||||
}
|
||||
},
|
||||
|
||||
async getBalance(user: FG.User) {
|
||||
const { data } = await api.get<BalanceResponse>(`/users/${user.userid}/balance`);
|
||||
const idx = this.balances.findIndex((x) => x.userid === user.userid);
|
||||
if (idx == -1) this.balances.push(Object.assign(data, { userid: user.userid }));
|
||||
else this.balances[idx] = Object.assign(data, { userid: user.userid });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getBalances(force = false) {
|
||||
if (
|
||||
force ||
|
||||
this.balances.length == 0 ||
|
||||
new Date().getTime() - this._balances_dirty > 60000
|
||||
) {
|
||||
const { data } = await api.get<BalancesResponse[]>('/balance');
|
||||
this.balances = data;
|
||||
}
|
||||
return this.balances;
|
||||
},
|
||||
|
||||
async changeBalance(amount: number, user: FG.User, sender: FG.User | undefined = undefined) {
|
||||
const mainStore = useMainStore();
|
||||
try {
|
||||
const { data } = await api.put<FG.Transaction>(`/users/${user.userid}/balance`, {
|
||||
amount,
|
||||
user: user.userid,
|
||||
sender: sender?.userid,
|
||||
});
|
||||
fixTransaction(data);
|
||||
if (
|
||||
user.userid === mainStore.currentUser.userid ||
|
||||
sender?.userid === mainStore.currentUser.userid
|
||||
)
|
||||
this.transactions.push(data);
|
||||
const f = this.balances.find((x) => x.userid === user.userid);
|
||||
if (f) f.balance += amount;
|
||||
if (sender) {
|
||||
const f = this.balances.find((x) => x.userid === sender.userid);
|
||||
if (f) f.balance += -1 * amount;
|
||||
}
|
||||
this._balances_dirty = 0;
|
||||
return data;
|
||||
} catch ({ response }) {
|
||||
// Maybe Balance changed
|
||||
if (response && (<AxiosResponse>response).status == 409) {
|
||||
Notify.create({
|
||||
type: 'negative',
|
||||
group: false,
|
||||
message: 'Das Limit wurde überschritten!',
|
||||
timeout: 10000,
|
||||
progress: true,
|
||||
actions: [{ icon: 'mdi-close', color: 'white' }],
|
||||
});
|
||||
//void this.getTransactions(true);
|
||||
void this.getBalance(sender ? sender : user);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async getTransactions(
|
||||
user: FG.User,
|
||||
filter:
|
||||
| {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
showReversals?: boolean;
|
||||
showCancelled?: boolean;
|
||||
}
|
||||
| undefined = undefined
|
||||
) {
|
||||
if (!filter) filter = { limit: 10 };
|
||||
const { data } = await api.get<TransactionsResponse>(
|
||||
`/users/${user.userid}/balance/transactions`,
|
||||
{ params: filter }
|
||||
);
|
||||
data.transactions.forEach((t) => fixTransaction(t));
|
||||
if (data.transactions) this.transactions.push(...data.transactions);
|
||||
return data;
|
||||
},
|
||||
|
||||
async revert(transaction: FG.Transaction) {
|
||||
try {
|
||||
const { data } = await api.delete<FG.Transaction>(`/balance/${transaction.id}`);
|
||||
fixTransaction(data);
|
||||
const f = this.transactions.find((x) => x.id === transaction.id);
|
||||
if (f) f.reversal_id = data.id;
|
||||
this.transactions.push(data);
|
||||
console.log(data);
|
||||
} catch (error) {
|
||||
// ...
|
||||
}
|
||||
this._balances_dirty = 0;
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,62 +0,0 @@
|
|||
<template>
|
||||
<q-carousel
|
||||
v-model="volume"
|
||||
transition-prev="slide-right"
|
||||
transition-next="slide-left"
|
||||
animated
|
||||
swipeable
|
||||
control-color="primary"
|
||||
arrows
|
||||
>
|
||||
<q-carousel-slide v-for="volume in volumes" :key="volume.id" :name="volume.id">
|
||||
<build-manual-volume-part :volume="volume" />
|
||||
</q-carousel-slide>
|
||||
</q-carousel>
|
||||
<div class="full-width row justify-center q-pa-sm">
|
||||
<div class="q-px-sm">
|
||||
<q-btn-toggle v-model="volume" :options="btn_options" rounded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, ref, computed } from 'vue';
|
||||
import { DrinkPriceVolume } from '../../store';
|
||||
import BuildManualVolumePart from './BuildManualVolumePart.vue';
|
||||
export default defineComponent({
|
||||
name: 'BuildManualVolume',
|
||||
components: { BuildManualVolumePart },
|
||||
props: {
|
||||
volumes: {
|
||||
type: Array as PropType<Array<DrinkPriceVolume>>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const _volume = ref<number>();
|
||||
const volume = computed({
|
||||
get: () => {
|
||||
if (_volume.value !== undefined) {
|
||||
return _volume.value;
|
||||
}
|
||||
return props.volumes[0].id;
|
||||
},
|
||||
set: (val: number) => (_volume.value = val),
|
||||
});
|
||||
const options = computed(() => {
|
||||
let ret: Array<{ label: number; value: number }> = [];
|
||||
props.volumes.forEach((volume: DrinkPriceVolume) => {
|
||||
ret.push({ label: volume.id, value: volume.id });
|
||||
});
|
||||
return ret;
|
||||
});
|
||||
const btn_options = computed<Array<{ label: string; value: number }>>(() => {
|
||||
const retVal: Array<{ label: string; value: number }> = [];
|
||||
props.volumes.forEach((volume: DrinkPriceVolume) => {
|
||||
retVal.push({ label: `${(<number>volume.volume).toFixed(3)}L`, value: volume.id });
|
||||
});
|
||||
return retVal;
|
||||
});
|
||||
return { volume, options, btn_options };
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,65 +0,0 @@
|
|||
<template>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Zutaten</div>
|
||||
<div v-for="ingredient in volume.ingredients" :key="ingredient.id">
|
||||
<div v-if="ingredient.drink_ingredient">
|
||||
<div class="full-width row q-gutter-sm q-py-sm">
|
||||
<div class="col">
|
||||
{{ name(ingredient.drink_ingredient?.ingredient_id) }}
|
||||
</div>
|
||||
<div class="col">
|
||||
{{
|
||||
ingredient.drink_ingredient?.volume
|
||||
? `${ingredient.drink_ingredient?.volume * 100} cl`
|
||||
: ''
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<q-separator />
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="ingredient in volume.ingredients" :key="ingredient.id">
|
||||
<div v-if="ingredient.extra_ingredient">
|
||||
<div class="full-width row q-gutter-sm q-py-sm">
|
||||
<div class="col">
|
||||
{{ ingredient.extra_ingredient?.name }}
|
||||
</div>
|
||||
<div class="col"></div>
|
||||
</div>
|
||||
<q-separator />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Preise</div>
|
||||
<div class="full-width row q-gutter-sm justify-around">
|
||||
<div v-for="price in volume.prices" :key="price.id">
|
||||
<div class="text-body1">{{ price.price.toFixed(2) }}€</div>
|
||||
<q-badge v-if="price.public" class="text-caption"> öffentlich </q-badge>
|
||||
<div class="text-caption text-weight-thin">
|
||||
{{ price.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
import { DrinkPriceVolume, usePricelistStore } from '../../store';
|
||||
export default defineComponent({
|
||||
name: 'BuildManualVolumePart',
|
||||
props: {
|
||||
volume: {
|
||||
type: Object as PropType<DrinkPriceVolume>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const store = usePricelistStore();
|
||||
function name(id: number) {
|
||||
return store.drinks.find((a) => a.id === id)?.name;
|
||||
}
|
||||
return { name };
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,512 +0,0 @@
|
|||
<template>
|
||||
<q-table
|
||||
v-model:pagination="pagination"
|
||||
title="Preistabelle"
|
||||
:columns="columns"
|
||||
:rows="drinks"
|
||||
dense
|
||||
:filter="search"
|
||||
:filter-method="filter"
|
||||
grid
|
||||
:rows-per-page-options="[0]"
|
||||
>
|
||||
<template #top-right>
|
||||
<div class="row justify-end q-gutter-sm">
|
||||
<search-input v-model="search" :keys="search_keys" />
|
||||
<slot></slot>
|
||||
<q-btn v-if="!public && !nodetails" label="Aufpreise">
|
||||
<q-menu anchor="center middle" self="center middle">
|
||||
<min-price-setting />
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="!public && !nodetails && editable && hasPermission(PERMISSIONS.CREATE)"
|
||||
color="primary"
|
||||
round
|
||||
icon="mdi-plus"
|
||||
@click="newDrink"
|
||||
>
|
||||
<q-tooltip> Neues Getränk </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</template>
|
||||
<template #item="props">
|
||||
<div class="q-pa-xs col-xs-12 col-sm-6 col-md-4">
|
||||
<q-card>
|
||||
<q-img style="max-height: 256px" :src="image(props.row.uuid)">
|
||||
<div
|
||||
v-if="!public && !nodetails && editable"
|
||||
class="absolute-top-right justify-end"
|
||||
style="background-color: transparent"
|
||||
>
|
||||
<q-btn
|
||||
round
|
||||
icon="mdi-pencil"
|
||||
style="background-color: rgba(0, 0, 0, 0.5)"
|
||||
@click="editDrink = props.row"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute-bottom-right justify-end">
|
||||
<div class="text-subtitle1 text-right">
|
||||
{{ props.row.name }}
|
||||
</div>
|
||||
<div class="text-caption text-right">
|
||||
{{ props.row.type.name }}
|
||||
</div>
|
||||
</div>
|
||||
</q-img>
|
||||
<q-card-section>
|
||||
<q-badge
|
||||
v-for="tag in props.row.tags"
|
||||
:key="`${props.row.id}-${tag.id}`"
|
||||
class="text-caption"
|
||||
rounded
|
||||
:style="`background-color: ${tag.color}`"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</q-badge>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="!public && !nodetails">
|
||||
<div class="fit row">
|
||||
<q-input
|
||||
v-if="props.row.article_id"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
:model-value="props.row.article_id"
|
||||
outlined
|
||||
readonly
|
||||
label="Artikelnummer"
|
||||
dense
|
||||
/>
|
||||
<q-input
|
||||
v-if="props.row.volume"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
:model-value="props.row.volume"
|
||||
outlined
|
||||
readonly
|
||||
label="Inhalt"
|
||||
dense
|
||||
suffix="L"
|
||||
/>
|
||||
<q-input
|
||||
v-if="props.row.package_size"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
:model-value="props.row.package_size"
|
||||
outlined
|
||||
readonly
|
||||
label="Gebindegröße"
|
||||
dense
|
||||
/>
|
||||
<q-input
|
||||
v-if="props.row.cost_per_package"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
:model-value="props.row.cost_per_package"
|
||||
outlined
|
||||
readonly
|
||||
label="Preis Gebinde"
|
||||
suffix="€"
|
||||
dense
|
||||
/>
|
||||
<q-input
|
||||
v-if="props.row.cost_per_volume"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm q-pb-lg"
|
||||
:model-value="props.row.cost_per_volume"
|
||||
outlined
|
||||
readonly
|
||||
label="Preis pro L"
|
||||
hint="Inkl. 19% Mehrwertsteuer"
|
||||
suffix="€"
|
||||
dense
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="props.row.volumes.length > 0 && notLoading">
|
||||
<drink-price-volumes
|
||||
:model-value="props.row.volumes"
|
||||
:public="public"
|
||||
:nodetails="nodetails"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
</q-table>
|
||||
<q-dialog :model-value="editDrink !== undefined" persistent>
|
||||
<drink-modify
|
||||
:drink="editDrink"
|
||||
@save="editing_drink"
|
||||
@cancel="editDrink = undefined"
|
||||
@delete="deleteDrink"
|
||||
/>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onBeforeMount, ComputedRef, computed, ref } from 'vue';
|
||||
import { Drink, usePricelistStore, DrinkPriceVolume } from 'src/plugins/pricelist/store';
|
||||
import MinPriceSetting from 'src/plugins/pricelist/components/MinPriceSetting.vue';
|
||||
import SearchInput from './SearchInput.vue';
|
||||
import DrinkPriceVolumes from 'src/plugins/pricelist/components/CalculationTable/DrinkPriceVolumes.vue';
|
||||
import DrinkModify from './DrinkModify.vue';
|
||||
import { filter, Search } from '../utils/filter';
|
||||
import { Notify } from 'quasar';
|
||||
import { sort } from '../utils/sort';
|
||||
import { DeleteObjects } from 'src/plugins/pricelist/utils/utils';
|
||||
import { hasPermission } from 'src/utils/permission';
|
||||
import { PERMISSIONS } from 'src/plugins/pricelist/permissions';
|
||||
import { baseURL } from 'src/config';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CalculationTable',
|
||||
components: {
|
||||
SearchInput,
|
||||
MinPriceSetting,
|
||||
DrinkPriceVolumes,
|
||||
DrinkModify,
|
||||
},
|
||||
props: {
|
||||
public: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
nodetails: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const store = usePricelistStore();
|
||||
|
||||
onBeforeMount(() => {
|
||||
void store.getDrinks();
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'picture',
|
||||
label: 'Bild',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
field: 'name',
|
||||
sortable: true,
|
||||
sort,
|
||||
filterable: true,
|
||||
public: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'drink_type',
|
||||
label: 'Kategorie',
|
||||
field: 'type',
|
||||
format: (val: FG.DrinkType) => `${val.name}`,
|
||||
sortable: true,
|
||||
sort: (a: FG.DrinkType, b: FG.DrinkType) => sort(a.name, b.name),
|
||||
filterable: true,
|
||||
public: true,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
label: 'Tags',
|
||||
field: 'tags',
|
||||
format: (val: Array<FG.Tag>) => {
|
||||
let retVal = '';
|
||||
val.forEach((tag, index) => {
|
||||
if (index > 0) {
|
||||
retVal += ', ';
|
||||
}
|
||||
retVal += tag.name;
|
||||
});
|
||||
return retVal;
|
||||
},
|
||||
filterable: true,
|
||||
public: true,
|
||||
},
|
||||
{
|
||||
name: 'article_id',
|
||||
label: 'Artikelnummer',
|
||||
field: 'article_id',
|
||||
sortable: true,
|
||||
sort,
|
||||
filterable: true,
|
||||
public: false,
|
||||
},
|
||||
{
|
||||
name: 'volume_package',
|
||||
label: 'Inhalt in l des Gebinde',
|
||||
field: 'volume',
|
||||
sortable: true,
|
||||
sort,
|
||||
public: false,
|
||||
},
|
||||
{
|
||||
name: 'package_size',
|
||||
label: 'Gebindegröße',
|
||||
field: 'package_size',
|
||||
sortable: true,
|
||||
sort,
|
||||
public: false,
|
||||
},
|
||||
{
|
||||
name: 'cost_per_package',
|
||||
label: 'Preis Netto/Gebinde',
|
||||
field: 'cost_per_package',
|
||||
format: (val: number | null) => (val ? `${val.toFixed(3)}€` : ''),
|
||||
sortable: true,
|
||||
sort,
|
||||
public: false,
|
||||
},
|
||||
{
|
||||
name: 'cost_per_volume',
|
||||
label: 'Preis mit 19%/Liter',
|
||||
field: 'cost_per_volume',
|
||||
format: (val: number | null) => (val ? `${val.toFixed(3)}€` : ''),
|
||||
sortable: true,
|
||||
sort: (a: ComputedRef, b: ComputedRef) => sort(a.value, b.value),
|
||||
},
|
||||
{
|
||||
name: 'volumes',
|
||||
label: 'Preiskalkulation',
|
||||
field: 'volumes',
|
||||
format: (val: Array<DrinkPriceVolume>) => {
|
||||
let retVal = '';
|
||||
val.forEach((val, index) => {
|
||||
if (index > 0) {
|
||||
retVal += ', ';
|
||||
}
|
||||
retVal += val.id;
|
||||
});
|
||||
return retVal;
|
||||
},
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
name: 'receipt',
|
||||
label: 'Bauanleitung',
|
||||
field: 'receipt',
|
||||
format: (val: Array<string>) => {
|
||||
let retVal = '';
|
||||
val.forEach((value, index) => {
|
||||
if (index > 0) {
|
||||
retVal += ', ';
|
||||
}
|
||||
retVal += value;
|
||||
});
|
||||
return retVal;
|
||||
},
|
||||
filterable: true,
|
||||
sortable: false,
|
||||
public: false,
|
||||
},
|
||||
];
|
||||
const column_calc = [
|
||||
{
|
||||
name: 'volume',
|
||||
label: 'Abgabe in l',
|
||||
field: 'volume',
|
||||
},
|
||||
{
|
||||
name: 'min_prices',
|
||||
label: 'Minimal Preise',
|
||||
field: 'min_prices',
|
||||
},
|
||||
{
|
||||
name: 'prices',
|
||||
label: 'Preise',
|
||||
field: 'prices',
|
||||
},
|
||||
];
|
||||
const column_prices = [
|
||||
{
|
||||
name: 'price',
|
||||
label: 'Preis',
|
||||
field: 'price',
|
||||
format: (val: number) => `${val.toFixed(2)}€`,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Beschreibung',
|
||||
field: 'description',
|
||||
},
|
||||
{
|
||||
name: 'public',
|
||||
label: 'Öffentlich',
|
||||
field: 'public',
|
||||
},
|
||||
];
|
||||
|
||||
const search_keys = computed(() =>
|
||||
columns.filter(
|
||||
(column) => column.filterable && (props.public || props.nodetails ? column.public : true)
|
||||
)
|
||||
);
|
||||
|
||||
const pagination = ref({
|
||||
sortBy: 'name',
|
||||
descending: false,
|
||||
rowsPerPage: store.drinks.length,
|
||||
});
|
||||
|
||||
const drinkTypes = computed(() => store.drinkTypes);
|
||||
|
||||
function updateDrink(drink: Drink) {
|
||||
void store.updateDrink(drink);
|
||||
}
|
||||
|
||||
function deleteDrink() {
|
||||
if (editDrink.value) {
|
||||
store.deleteDrink(editDrink.value);
|
||||
}
|
||||
editDrink.value = undefined;
|
||||
}
|
||||
|
||||
const showNewDrink = ref(false);
|
||||
|
||||
const drinkPic = ref<File>();
|
||||
|
||||
function onPictureRejected() {
|
||||
Notify.create({
|
||||
group: false,
|
||||
type: 'negative',
|
||||
message: 'Datei zu groß oder keine gültige Bilddatei.',
|
||||
timeout: 10000,
|
||||
progress: true,
|
||||
actions: [{ icon: 'mdi-close', color: 'white' }],
|
||||
});
|
||||
drinkPic.value = undefined;
|
||||
}
|
||||
|
||||
async function savePicture(drinkPic: File) {
|
||||
if (editDrink.value) {
|
||||
await store.upload_drink_picture(editDrink.value, drinkPic).catch((response: Response) => {
|
||||
if (response && response.status == 400) {
|
||||
onPictureRejected();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePicture() {
|
||||
if (editDrink.value) {
|
||||
await store.delete_drink_picture(editDrink.value);
|
||||
}
|
||||
}
|
||||
|
||||
const search = ref<Search>({
|
||||
value: '',
|
||||
key: '',
|
||||
label: '',
|
||||
});
|
||||
|
||||
const emptyDrink: Drink = {
|
||||
id: -1,
|
||||
article_id: undefined,
|
||||
package_size: undefined,
|
||||
name: '',
|
||||
volume: undefined,
|
||||
cost_per_volume: undefined,
|
||||
cost_per_package: undefined,
|
||||
tags: [],
|
||||
type: undefined,
|
||||
volumes: [],
|
||||
uuid: '',
|
||||
};
|
||||
|
||||
function newDrink() {
|
||||
editDrink.value = Object.assign({}, emptyDrink);
|
||||
}
|
||||
|
||||
const editDrink = ref<Drink>();
|
||||
|
||||
async function editing_drink(
|
||||
drink: Drink,
|
||||
toDeleteObjects: DeleteObjects,
|
||||
drinkPic: File | undefined,
|
||||
deletePic: boolean
|
||||
) {
|
||||
notLoading.value = false;
|
||||
for (const ingredient of toDeleteObjects.ingredients) {
|
||||
await store.deleteIngredient(ingredient);
|
||||
}
|
||||
for (const price of toDeleteObjects.prices) {
|
||||
await store.deletePrice(price);
|
||||
}
|
||||
for (const volume of toDeleteObjects.volumes) {
|
||||
await store.deleteVolume(volume, drink);
|
||||
}
|
||||
if (drink.id > 0) {
|
||||
await store.updateDrink(drink);
|
||||
} else {
|
||||
const _drink = await store.setDrink(drink);
|
||||
if (editDrink.value) {
|
||||
editDrink.value.id = _drink.id;
|
||||
}
|
||||
}
|
||||
if (deletePic) {
|
||||
await deletePicture();
|
||||
}
|
||||
if (drinkPic instanceof File) {
|
||||
await savePicture(drinkPic);
|
||||
}
|
||||
editDrink.value = undefined;
|
||||
notLoading.value = true;
|
||||
}
|
||||
|
||||
function get_volumes(drink_id: number) {
|
||||
return store.drinks.find((a) => a.id === drink_id)?.volumes;
|
||||
}
|
||||
|
||||
const notLoading = ref(true);
|
||||
|
||||
const imageloading = ref<Array<{ id: number; loading: boolean }>>([]);
|
||||
function getImageLoading(id: number) {
|
||||
const loading = imageloading.value.find((a) => a.id === id);
|
||||
if (loading) {
|
||||
return loading.loading;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function image(uuid: string | undefined) {
|
||||
if (uuid) {
|
||||
return `${baseURL.value}/pricelist/picture/${uuid}?size=256`;
|
||||
}
|
||||
return 'no-image.svg';
|
||||
}
|
||||
|
||||
return {
|
||||
drinks: computed(() => store.drinks),
|
||||
pagination,
|
||||
columns,
|
||||
column_calc,
|
||||
column_prices,
|
||||
drinkTypes,
|
||||
updateDrink,
|
||||
deleteDrink,
|
||||
showNewDrink,
|
||||
drinkPic,
|
||||
savePicture,
|
||||
deletePicture,
|
||||
search,
|
||||
filter,
|
||||
search_keys,
|
||||
tags: computed(() => store.tags),
|
||||
editDrink,
|
||||
editing_drink,
|
||||
get_volumes,
|
||||
notLoading,
|
||||
getImageLoading,
|
||||
newDrink,
|
||||
hasPermission,
|
||||
PERMISSIONS,
|
||||
image,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,60 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="full-width row q-gutter-sm justify-between q-py-sm"
|
||||
>
|
||||
<div class="row">
|
||||
<div>{{ index + 1 }}.</div>
|
||||
<div class="q-pl-sm">
|
||||
{{ step }}
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
v-if="editable"
|
||||
round
|
||||
color="negative"
|
||||
size="sm"
|
||||
icon="mdi-delete"
|
||||
@click="deleteStep(index)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="editable" class="full-width row q-gutter-sm justify-between">
|
||||
<q-input v-model="newStep" filled label="Arbeitsschritt" dense />
|
||||
<q-btn label="Schritt hinzufügen" dense @click="addStep" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { PropType, defineComponent, ref } from 'vue';
|
||||
export default defineComponent({
|
||||
name: 'BuildManual',
|
||||
props: {
|
||||
steps: {
|
||||
type: Array as PropType<Array<string>>,
|
||||
default: undefined,
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
deleteStep: (index: number) => index,
|
||||
addStep: (val: string) => val,
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
const newStep = ref('');
|
||||
function deleteStep(index: number) {
|
||||
emit('deleteStep', index);
|
||||
}
|
||||
function addStep() {
|
||||
emit('addStep', newStep.value);
|
||||
newStep.value = '';
|
||||
}
|
||||
return { newStep, addStep, deleteStep };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,340 +0,0 @@
|
|||
<template>
|
||||
<q-carousel
|
||||
v-model="volume"
|
||||
transition-prev="slide-right"
|
||||
transition-next="slide-left"
|
||||
animated
|
||||
swipeable
|
||||
control-color="primary"
|
||||
arrows
|
||||
:keep-alive="false"
|
||||
>
|
||||
<q-carousel-slide v-for="volume in volumes" :key="volume.id" :name="volume.id">
|
||||
<div class="full-width row">
|
||||
<q-input
|
||||
v-model.number="volume._volume"
|
||||
class="q-pa-sm col-10"
|
||||
:outlined="!editable || !volume_can_edit"
|
||||
:filled="editable && volume_can_edit"
|
||||
:readonly="!editable || !volume_can_edit"
|
||||
dense
|
||||
label="Inhalt"
|
||||
mask="#.###"
|
||||
fill-mask="0"
|
||||
suffix="L"
|
||||
min="0"
|
||||
step="0.001"
|
||||
@update:model-value="updateVolume(volume)"
|
||||
/>
|
||||
<div
|
||||
v-if="deleteable && editable && hasPermission(PERMISSIONS.DELETE_VOLUME)"
|
||||
class="q-pa-sm col-2 text-right"
|
||||
>
|
||||
<q-btn round icon="mdi-delete" size="sm" color="negative" @click="deleteVolume">
|
||||
<q-tooltip> Abgabe entfernen </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!public && !nodetails" class="full-width row q-gutter-sm q-pa-sm justify-around">
|
||||
<div v-for="(min_price, index) in volume.min_prices" :key="index">
|
||||
<q-badge class="text-body1" color="primary"> {{ min_price.percentage }}% </q-badge>
|
||||
<div class="text-body1">{{ min_price.price.toFixed(3) }}€</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-pa-sm">
|
||||
<div v-for="(price, index) in volume.prices" :key="price.id">
|
||||
<div class="fit row justify-around q-py-sm">
|
||||
<div
|
||||
v-if="!editable || !hasPermission(PERMISSIONS.EDIT_PRICE)"
|
||||
class="text-body1 col-3"
|
||||
>
|
||||
{{ price.price.toFixed(2) }}€
|
||||
</div>
|
||||
<q-input
|
||||
v-else
|
||||
v-model.number="price.price"
|
||||
class="col-3"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
suffix="€"
|
||||
filled
|
||||
dense
|
||||
label="Preis"
|
||||
@update:model-value="change"
|
||||
/>
|
||||
<div class="text-body1 col-2">
|
||||
<q-toggle
|
||||
v-model="price.public"
|
||||
:disable="!editable || !hasPermission(PERMISSIONS.EDIT_PRICE)"
|
||||
checked-icon="mdi-earth"
|
||||
unchecked-icon="mdi-earth-off"
|
||||
@update:model-value="change"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!editable || !hasPermission(PERMISSIONS.EDIT_PRICE)"
|
||||
class="text-body1 col-5"
|
||||
>
|
||||
{{ price.description }}
|
||||
</div>
|
||||
<q-input
|
||||
v-else
|
||||
v-model="price.description"
|
||||
class="col-5"
|
||||
filled
|
||||
dense
|
||||
label="Beschreibung"
|
||||
@update:model-value="change"
|
||||
/>
|
||||
<div v-if="editable && hasPermission(PERMISSIONS.DELETE_PRICE)" class="col-1">
|
||||
<q-btn round icon="mdi-delete" color="negative" size="xs" @click="deletePrice(price)">
|
||||
<q-tooltip> Preis entfernen </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator v-if="index < volume.prices.length - 1" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!public && !nodetails && isUnderMinPrice"
|
||||
class="fit warning bg-red text-center text-white text-body1"
|
||||
>
|
||||
Einer der Preise ist unterhalb des niedrigsten minimal Preises.
|
||||
</div>
|
||||
<div
|
||||
v-if="editable && hasPermission(PERMISSIONS.EDIT_PRICE)"
|
||||
class="full-width row justify-end text-right"
|
||||
>
|
||||
<q-btn round icon="mdi-plus" size="sm" color="primary">
|
||||
<q-tooltip> Preis hinzufügen </q-tooltip>
|
||||
<q-menu anchor="center middle" self="center middle">
|
||||
<new-price @save="addPrice" />
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-pa-sm">
|
||||
<ingredients
|
||||
v-if="!public && !costPerVolume"
|
||||
v-model="volume.ingredients"
|
||||
:editable="editable && hasPermission(PERMISSIONS.EDIT_INGREDIENTS_DRINK)"
|
||||
@update="updateVolume(volume)"
|
||||
@delete-ingredient="deleteIngredient"
|
||||
/>
|
||||
</div>
|
||||
</q-carousel-slide>
|
||||
</q-carousel>
|
||||
<div class="full-width row justify-center q-pa-sm">
|
||||
<div class="q-px-sm">
|
||||
<q-btn-toggle v-model="volume" :options="options" rounded />
|
||||
</div>
|
||||
<div v-if="editable" class="q-px-sm">
|
||||
<q-btn class="q-px-sm" round icon="mdi-plus" color="primary" size="sm" @click="newVolume">
|
||||
<q-tooltip> Abgabe hinzufügen </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType, ref, onBeforeMount } from 'vue';
|
||||
import { DrinkPriceVolume } from 'src/plugins/pricelist/store';
|
||||
import Ingredients from 'src/plugins/pricelist/components/CalculationTable/Ingredients.vue';
|
||||
import NewPrice from 'src/plugins/pricelist/components/CalculationTable/NewPrice.vue';
|
||||
import { calc_volume, clone } from '../../utils/utils';
|
||||
import { hasPermission } from 'src/utils/permission';
|
||||
import { PERMISSIONS } from '../../permissions';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DrinkPriceVolume',
|
||||
components: { Ingredients, NewPrice },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array as PropType<Array<DrinkPriceVolume>>,
|
||||
required: true,
|
||||
},
|
||||
costPerVolume: {
|
||||
type: undefined,
|
||||
default: undefined,
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
public: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
nodetails: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
'update:modelValue': (val: Array<DrinkPriceVolume>) => val,
|
||||
update: (val: number) => val,
|
||||
'delete-volume': (val: DrinkPriceVolume) => val,
|
||||
'delete-price': (val: FG.DrinkPrice) => val,
|
||||
'delete-ingredient': (val: FG.Ingredient) => val,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
onBeforeMount(() => {
|
||||
//volumes.value = <Array<DrinkPriceVolume>>JSON.parse(JSON.stringify(props.modelValue));
|
||||
volumes.value = clone(props.modelValue);
|
||||
});
|
||||
const volumes = ref<Array<DrinkPriceVolume>>([]);
|
||||
const _volume = ref<number | undefined>();
|
||||
const volume = computed<number | undefined>({
|
||||
get: () => {
|
||||
if (_volume.value !== undefined) {
|
||||
return _volume.value;
|
||||
}
|
||||
if (volumes.value.length > 0) {
|
||||
return volumes.value[0].id;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
set: (val: number | undefined) => (_volume.value = val),
|
||||
});
|
||||
const edit_volume = computed(() => {
|
||||
return volumes.value.find((a) => a.id === volume.value);
|
||||
});
|
||||
const options = computed<Array<{ label: string; value: number }>>(() => {
|
||||
const retVal: Array<{ label: string; value: number }> = [];
|
||||
volumes.value.forEach((volume: DrinkPriceVolume) => {
|
||||
retVal.push({ label: `${(<number>volume.volume).toFixed(3)}L`, value: volume.id });
|
||||
});
|
||||
return retVal;
|
||||
});
|
||||
|
||||
function updateVolume(_volume: DrinkPriceVolume) {
|
||||
const index = volumes.value.findIndex((a) => a.id === _volume.id);
|
||||
if (index > -1) {
|
||||
volumes.value[index].volume = calc_volume(_volume);
|
||||
volumes.value[index]._volume = <number>volumes.value[index].volume;
|
||||
}
|
||||
change();
|
||||
setTimeout(() => {
|
||||
emit('update', index);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
const volume_can_edit = computed(() => {
|
||||
if (edit_volume.value) {
|
||||
return !edit_volume.value.ingredients.some((ingredient) => ingredient.drink_ingredient);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const newVolumeId = ref(-1);
|
||||
|
||||
function newVolume() {
|
||||
const new_volume: DrinkPriceVolume = {
|
||||
id: newVolumeId.value,
|
||||
_volume: 0,
|
||||
volume: 0,
|
||||
prices: [],
|
||||
ingredients: [],
|
||||
min_prices: [],
|
||||
};
|
||||
newVolumeId.value--;
|
||||
volumes.value.push(new_volume);
|
||||
change();
|
||||
_volume.value = volumes.value[volumes.value.length - 1].id;
|
||||
}
|
||||
|
||||
function deleteVolume() {
|
||||
if (edit_volume.value) {
|
||||
if (edit_volume.value.id > 0) {
|
||||
emit('delete-volume', edit_volume.value);
|
||||
}
|
||||
const index = volumes.value.findIndex((a) => a.id === edit_volume.value?.id);
|
||||
if (index > -1) {
|
||||
_volume.value = volumes.value[0].id;
|
||||
volumes.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteable = computed(() => {
|
||||
if (edit_volume.value) {
|
||||
const has_ingredients = edit_volume.value.ingredients.length > 0;
|
||||
const has_prices = edit_volume.value.prices.length > 0;
|
||||
|
||||
return !(has_ingredients || has_prices);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function addPrice(price: FG.DrinkPrice) {
|
||||
if (edit_volume.value) {
|
||||
edit_volume.value.prices.push(price);
|
||||
change();
|
||||
}
|
||||
}
|
||||
|
||||
function deletePrice(price: FG.DrinkPrice) {
|
||||
if (edit_volume.value) {
|
||||
const index = edit_volume.value.prices.findIndex((a) => a.id === price.id);
|
||||
if (index > -1) {
|
||||
if (edit_volume.value.prices[index].id > 0) {
|
||||
emit('delete-price', edit_volume.value.prices[index]);
|
||||
change();
|
||||
}
|
||||
edit_volume.value.prices.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deleteIngredient(ingredient: FG.Ingredient) {
|
||||
emit('delete-ingredient', ingredient);
|
||||
}
|
||||
|
||||
function change() {
|
||||
emit('update:modelValue', volumes.value);
|
||||
}
|
||||
|
||||
const isUnderMinPrice = computed(() => {
|
||||
if (volumes.value) {
|
||||
const this_volume = volumes.value.find((a) => a.id === volume.value);
|
||||
if (this_volume) {
|
||||
if (this_volume.min_prices.length > 0) {
|
||||
const min_price = this_volume.min_prices.sort((a, b) => {
|
||||
if (a.price > b.price) return 1;
|
||||
if (a.price < b.price) return -1;
|
||||
return 0;
|
||||
})[0];
|
||||
console.log('min_price', min_price);
|
||||
return this_volume.prices.some((a) => a.price < min_price.price);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return {
|
||||
volumes,
|
||||
volume,
|
||||
options,
|
||||
updateVolume,
|
||||
volume_can_edit,
|
||||
newVolume,
|
||||
deleteable,
|
||||
addPrice,
|
||||
deletePrice,
|
||||
deleteVolume,
|
||||
deleteIngredient,
|
||||
change,
|
||||
isUnderMinPrice,
|
||||
hasPermission,
|
||||
PERMISSIONS,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.warning {
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
|
@ -1,271 +0,0 @@
|
|||
<template>
|
||||
<div class="full-width">
|
||||
<div
|
||||
v-for="ingredient in edit_ingredients"
|
||||
:key="`ingredient:${ingredient.id}`"
|
||||
class="full-width row justify-evenly q-py-xs"
|
||||
>
|
||||
<div class="full-width row justify-evenly">
|
||||
<div v-if="ingredient.drink_ingredient" class="col">
|
||||
<div class="full-width row justify-evenly q-py-xs">
|
||||
<div class="col">
|
||||
{{ get_drink_ingredient_name(ingredient.drink_ingredient.ingredient_id) }}
|
||||
<q-popup-edit
|
||||
v-if="editable"
|
||||
v-slot="scope"
|
||||
v-model="ingredient.drink_ingredient.ingredient_id"
|
||||
buttons
|
||||
label-cancel="Abbrechen"
|
||||
label-set="Speichern"
|
||||
@save="updateValue"
|
||||
>
|
||||
<q-select
|
||||
v-model="scope.ingredient_id"
|
||||
class="col q-px-sm"
|
||||
label="Getränk"
|
||||
filled
|
||||
dense
|
||||
:options="drinks"
|
||||
option-label="name"
|
||||
option-value="id"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
</q-popup-edit>
|
||||
</div>
|
||||
<div class="col">
|
||||
{{ ingredient.drink_ingredient.volume.toFixed(3) }}L
|
||||
<q-popup-edit
|
||||
v-if="editable"
|
||||
v-slot="scope"
|
||||
v-model="ingredient.drink_ingredient.volume"
|
||||
buttons
|
||||
label-cancel="Abbrechen"
|
||||
label-set="Speichern"
|
||||
@save="updateValue"
|
||||
>
|
||||
<q-input
|
||||
v-model.number="scope.value"
|
||||
class="col q-px-sm"
|
||||
label="Volume"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
suffix="L"
|
||||
step="0.01"
|
||||
min="0"
|
||||
/>
|
||||
</q-popup-edit>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="ingredient.extra_ingredient" class="col">
|
||||
<div class="full-width row justify-evenly q-py-xs">
|
||||
<div class="col">
|
||||
{{ ingredient.extra_ingredient.name }}
|
||||
</div>
|
||||
<div class="col">{{ ingredient.extra_ingredient.price.toFixed(3) }}€</div>
|
||||
</div>
|
||||
<q-popup-edit
|
||||
v-if="editable"
|
||||
v-model="ingredient.extra_ingredient"
|
||||
buttons
|
||||
label-cancel="Abbrechen"
|
||||
label-set="Speichern"
|
||||
@save="updateValue"
|
||||
>
|
||||
<q-select
|
||||
v-model="ingredient.extra_ingredient"
|
||||
filled
|
||||
dense
|
||||
:options="extra_ingredients"
|
||||
option-label="name"
|
||||
/>
|
||||
</q-popup-edit>
|
||||
</div>
|
||||
<div v-if="editable" class="col-1 row justify-end q-pa-xs">
|
||||
<q-btn
|
||||
icon="mdi-delete"
|
||||
round
|
||||
size="xs"
|
||||
color="negative"
|
||||
@click="deleteIngredient(ingredient)"
|
||||
>
|
||||
<q-tooltip> Zutat entfernen </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator />
|
||||
</div>
|
||||
<div v-if="editable" class="full-width row justify-end q-py-xs">
|
||||
<q-btn size="sm" round icon="mdi-plus" color="primary">
|
||||
<q-tooltip> Neue Zutat hinzufügen </q-tooltip>
|
||||
<q-menu anchor="center middle" self="center middle" persistent>
|
||||
<div class="full-width row justify-around q-gutter-sm q-pa-sm">
|
||||
<div class="col">
|
||||
<q-select
|
||||
v-model="newIngredient"
|
||||
filled
|
||||
dense
|
||||
label="Zutat"
|
||||
:options="[...drinks, ...extra_ingredients]"
|
||||
option-label="name"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-slide-transition>
|
||||
<q-input
|
||||
v-if="newIngredient && newIngredient.volume"
|
||||
v-model.number="newIngredientVolume"
|
||||
filled
|
||||
dense
|
||||
label="Volume"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
suffix="L"
|
||||
/>
|
||||
</q-slide-transition>
|
||||
<q-slide-transition>
|
||||
<q-input
|
||||
v-if="newIngredient && newIngredient.price"
|
||||
v-model="newIngredient.price"
|
||||
filled
|
||||
dense
|
||||
label="Preis"
|
||||
disable
|
||||
min="0"
|
||||
step="0.1"
|
||||
fill-mask="0"
|
||||
mask="#.##"
|
||||
suffix="€"
|
||||
/>
|
||||
</q-slide-transition>
|
||||
</div>
|
||||
</div>
|
||||
<div class="full-width row justify-around q-gutter-sm q-pa-sm">
|
||||
<q-btn v-close-popup flat label="Abbrechen" @click="cancelAddIngredient" />
|
||||
<q-btn v-close-popup flat label="Speichern" color="primary" @click="addIngredient" />
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType, ref, onBeforeMount, unref } from 'vue';
|
||||
import { usePricelistStore } from '../../store';
|
||||
import { clone } from '../../utils/utils';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Ingredients',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object as PropType<Array<FG.Ingredient>>,
|
||||
required: true,
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
'update:modelValue': (val: Array<FG.Ingredient>) => val,
|
||||
update: () => true,
|
||||
'delete-ingredient': (val: FG.Ingredient) => val,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
onBeforeMount(() => {
|
||||
//edit_ingredients.value = <Array<FG.Ingredient>>JSON.parse(JSON.stringify(props.modelValue))
|
||||
//edit_ingredients.value = props.modelValue
|
||||
edit_ingredients.value = clone(props.modelValue);
|
||||
});
|
||||
|
||||
const store = usePricelistStore();
|
||||
|
||||
const edit_ingredients = ref<Array<FG.Ingredient>>([]);
|
||||
const newIngredient = ref<FG.Drink | FG.ExtraIngredient>();
|
||||
const newIngredientVolume = ref<number>(0);
|
||||
function addIngredient() {
|
||||
let _ingredient: FG.Ingredient;
|
||||
if ((<FG.Drink>newIngredient.value)?.volume && newIngredient.value) {
|
||||
_ingredient = {
|
||||
id: -1,
|
||||
drink_ingredient: {
|
||||
id: -1,
|
||||
ingredient_id: newIngredient.value.id,
|
||||
volume: newIngredientVolume.value,
|
||||
},
|
||||
extra_ingredient: undefined,
|
||||
};
|
||||
} else {
|
||||
_ingredient = {
|
||||
id: -1,
|
||||
drink_ingredient: undefined,
|
||||
extra_ingredient: <FG.ExtraIngredient>newIngredient.value,
|
||||
};
|
||||
}
|
||||
edit_ingredients.value.push(_ingredient);
|
||||
emit('update:modelValue', unref(edit_ingredients));
|
||||
update();
|
||||
cancelAddIngredient();
|
||||
}
|
||||
|
||||
function updateValue(value: number, initValue: number) {
|
||||
console.log('updateValue', value, initValue);
|
||||
emit('update:modelValue', unref(edit_ingredients));
|
||||
update();
|
||||
}
|
||||
|
||||
function cancelAddIngredient() {
|
||||
setTimeout(() => {
|
||||
(newIngredient.value = undefined), (newIngredientVolume.value = 0);
|
||||
}, 200);
|
||||
}
|
||||
function deleteIngredient(ingredient: FG.Ingredient) {
|
||||
const index = edit_ingredients.value.findIndex((a) => a.id === ingredient.id);
|
||||
if (index > -1) {
|
||||
if (edit_ingredients.value[index].id > 0) {
|
||||
emit('delete-ingredient', edit_ingredients.value[index]);
|
||||
}
|
||||
edit_ingredients.value.splice(index, 1);
|
||||
}
|
||||
emit('update:modelValue', unref(edit_ingredients));
|
||||
update();
|
||||
}
|
||||
const drinks = computed(() =>
|
||||
store.drinks.filter((drink) => {
|
||||
console.log('computed drinks', drink.name, drink.cost_per_volume);
|
||||
return drink.cost_per_volume;
|
||||
})
|
||||
);
|
||||
const extra_ingredients = computed(() => store.extraIngredients);
|
||||
|
||||
function get_drink_ingredient_name(id: number) {
|
||||
return store.drinks.find((a) => a.id === id)?.name;
|
||||
}
|
||||
|
||||
function update() {
|
||||
setTimeout(() => {
|
||||
emit('update');
|
||||
}, 50);
|
||||
}
|
||||
|
||||
return {
|
||||
addIngredient,
|
||||
drinks,
|
||||
extra_ingredients,
|
||||
newIngredient,
|
||||
newIngredientVolume,
|
||||
cancelAddIngredient,
|
||||
updateValue,
|
||||
deleteIngredient,
|
||||
get_drink_ingredient_name,
|
||||
edit_ingredients,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,59 +0,0 @@
|
|||
<template>
|
||||
<div class="row justify-around q-pa-sm">
|
||||
<q-input
|
||||
v-model.number="newPrice.price"
|
||||
dense
|
||||
filled
|
||||
class="q-px-sm"
|
||||
type="number"
|
||||
label="Preis"
|
||||
suffix="€"
|
||||
min="0"
|
||||
step="0.1"
|
||||
/>
|
||||
<q-input
|
||||
v-model="newPrice.description"
|
||||
dense
|
||||
filled
|
||||
class="q-px-sm"
|
||||
label="Beschreibung"
|
||||
clearable
|
||||
/>
|
||||
<q-toggle v-model="newPrice.public" dense class="q-px-sm" label="Öffentlich" />
|
||||
</div>
|
||||
<div class="row justify-between q-pa-sm">
|
||||
<q-btn v-close-popup label="Abbrechen" @click="cancelAddPrice" />
|
||||
<q-btn v-close-popup label="Speichern" color="primary" @click="addPrice(row)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
export default defineComponent({
|
||||
name: 'NewPrice',
|
||||
emits: {
|
||||
save: (val: FG.DrinkPrice) => val,
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
const emptyPrice: FG.DrinkPrice = {
|
||||
id: -1,
|
||||
price: 0,
|
||||
description: '',
|
||||
public: true,
|
||||
};
|
||||
const newPrice = ref(emptyPrice);
|
||||
function addPrice() {
|
||||
emit('save', newPrice.value);
|
||||
cancelAddPrice();
|
||||
}
|
||||
function cancelAddPrice() {
|
||||
setTimeout(() => {
|
||||
newPrice.value = emptyPrice;
|
||||
}, 200);
|
||||
}
|
||||
return { newPrice, addPrice, cancelAddPrice };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,354 +0,0 @@
|
|||
<template>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Getränk Bearbeiten</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="full-width row">
|
||||
<q-input
|
||||
v-model="edit_drink.name"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
filled
|
||||
label="Name"
|
||||
dense
|
||||
/>
|
||||
<q-select
|
||||
v-model="edit_drink.type"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
filled
|
||||
label="Kategorie"
|
||||
dense
|
||||
:options="types"
|
||||
option-label="name"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-img :src="image" style="max-height: 256px" fit="contain" />
|
||||
<div class="full-width row">
|
||||
<div class="col-10 q-pa-sm">
|
||||
<q-file
|
||||
v-model="drinkPic"
|
||||
filled
|
||||
clearable
|
||||
dense
|
||||
@update:model-value="imagePreview"
|
||||
@clear="imgsrc = undefined"
|
||||
>
|
||||
<template #prepend>
|
||||
<q-icon name="mdi-image" />
|
||||
</template>
|
||||
</q-file>
|
||||
</div>
|
||||
<div class="col-2 q-pa-sm text-right">
|
||||
<q-btn round icon="mdi-delete" color="negative" size="sm" @click="delete_pic">
|
||||
<q-tooltip> Bild entfernen </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-select
|
||||
v-model="edit_drink.tags"
|
||||
multiple
|
||||
:options="tags"
|
||||
label="Tags"
|
||||
option-label="name"
|
||||
filled
|
||||
dense
|
||||
>
|
||||
<template #selected-item="item">
|
||||
<q-chip
|
||||
removable
|
||||
:tabindex="item.tabindex"
|
||||
:style="`background-color: ${item.opt.color}`"
|
||||
@remove="item.removeAtIndex(item.index)"
|
||||
>
|
||||
{{ item.opt.name }}
|
||||
</q-chip>
|
||||
</template>
|
||||
<template #option="item">
|
||||
<q-item v-bind="item.itemProps" v-on="item.itemEvents">
|
||||
<q-chip :style="`background-color: ${item.opt.color}`">
|
||||
<q-avatar v-if="item.selected" icon="mdi-check" color="positive" text-color="white" />
|
||||
{{ item.opt.name }}
|
||||
</q-chip>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="fit row">
|
||||
<q-input
|
||||
v-model="edit_drink.article_id"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
filled
|
||||
label="Artikelnummer"
|
||||
dense
|
||||
/>
|
||||
<q-input
|
||||
v-model="edit_drink.volume"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
filled
|
||||
label="Inhalt"
|
||||
dense
|
||||
suffix="L"
|
||||
/>
|
||||
<q-input
|
||||
v-model="edit_drink.package_size"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
filled
|
||||
label="Gebindegröße"
|
||||
dense
|
||||
/>
|
||||
<q-input
|
||||
v-model="edit_drink.cost_per_package"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
filled
|
||||
label="Preis Gebinde"
|
||||
suffix="€"
|
||||
dense
|
||||
/>
|
||||
<q-input
|
||||
v-model="cost_per_volume"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm q-pb-lg"
|
||||
:outlined="auto_cost_per_volume || hasIngredients"
|
||||
:filled="!auto_cost_per_volume && !hasIngredients"
|
||||
:readonly="auto_cost_per_volume || hasIngredients"
|
||||
label="Preis pro L"
|
||||
hint="Inkl. 19% Mehrwertsteuer"
|
||||
suffix="€"
|
||||
dense
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section :key="key">
|
||||
<drink-price-volumes
|
||||
v-model="edit_volumes"
|
||||
:cost-per-volume="cost_per_volume"
|
||||
:editable="hasPermission(PERMISSIONS.EDIT_VOLUME)"
|
||||
@update="updateVolume"
|
||||
@delete-volume="deleteVolume"
|
||||
@delete-price="deletePrice"
|
||||
@delete-ingredient="deleteIngredient"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<build-manual :steps="edit_drink.receipt" @deleteStep="deleteStep" @addStep="addStep" />
|
||||
</q-card-section>
|
||||
<q-card-actions class="justify-around">
|
||||
<q-btn label="Abbrechen" @click="cancel" />
|
||||
<q-btn v-if="can_delete" label="Löschen" color="negative" @click="delete_drink" />
|
||||
<q-btn label="Speichern" color="primary" @click="save" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, ref, onBeforeMount, computed } from 'vue';
|
||||
import { Drink, DrinkPriceVolume, usePricelistStore } from '../store';
|
||||
import DrinkPriceVolumes from './CalculationTable/DrinkPriceVolumes.vue';
|
||||
import { clone, calc_min_prices, DeleteObjects, calc_cost_per_volume } from '../utils/utils';
|
||||
import BuildManual from 'src/plugins/pricelist/components/CalculationTable/BuildManual.vue';
|
||||
import { baseURL } from 'src/config';
|
||||
import { hasPermission } from 'src/utils/permission';
|
||||
import { PERMISSIONS } from 'src/plugins/pricelist/permissions';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DrinkModify',
|
||||
components: { BuildManual, DrinkPriceVolumes },
|
||||
props: {
|
||||
drink: {
|
||||
type: Object as PropType<Drink>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
save: (
|
||||
drink: Drink,
|
||||
toDeleteObjects: DeleteObjects,
|
||||
drinkPic: File | undefined,
|
||||
deletePic: boolean
|
||||
) => (drink && toDeleteObjects) || drinkPic || deletePic,
|
||||
delete: () => true,
|
||||
cancel: () => true,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
onBeforeMount(() => {
|
||||
//edit_drink.value = <Drink>JSON.parse(JSON.stringify(props.drink));
|
||||
edit_drink.value = clone(props.drink);
|
||||
edit_volumes.value = clone(props.drink.volumes);
|
||||
});
|
||||
|
||||
const key = ref(0);
|
||||
|
||||
const store = usePricelistStore();
|
||||
|
||||
const toDeleteObjects = ref<DeleteObjects>({
|
||||
prices: [],
|
||||
volumes: [],
|
||||
ingredients: [],
|
||||
});
|
||||
|
||||
const edit_drink = ref<Drink>();
|
||||
const edit_volumes = ref<Array<DrinkPriceVolume>>([]);
|
||||
function save() {
|
||||
(<Drink>edit_drink.value).volumes = edit_volumes.value;
|
||||
emit('save', <Drink>edit_drink.value, toDeleteObjects.value, drinkPic.value, deletePic.value);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit('cancel');
|
||||
}
|
||||
function updateVolume(index: number) {
|
||||
if (index > -1 && edit_volumes.value) {
|
||||
edit_volumes.value[index].min_prices = calc_min_prices(
|
||||
edit_volumes.value[index],
|
||||
//edit_drink.value.cost_per_volume,
|
||||
cost_per_volume.value,
|
||||
store.min_prices
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function updateVolumes() {
|
||||
setTimeout(() => {
|
||||
edit_volumes.value?.forEach((_, index) => {
|
||||
updateVolume(index);
|
||||
});
|
||||
key.value++;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function deletePrice(price: FG.DrinkPrice) {
|
||||
toDeleteObjects.value.prices.push(price);
|
||||
}
|
||||
|
||||
function deleteVolume(volume: DrinkPriceVolume) {
|
||||
toDeleteObjects.value.volumes.push(volume);
|
||||
}
|
||||
|
||||
function deleteIngredient(ingredient: FG.Ingredient) {
|
||||
toDeleteObjects.value.ingredients.push(ingredient);
|
||||
}
|
||||
|
||||
function addStep(event: string) {
|
||||
edit_drink.value?.receipt?.push(event);
|
||||
}
|
||||
|
||||
function deleteStep(event: number) {
|
||||
edit_drink.value?.receipt?.splice(event, 1);
|
||||
}
|
||||
|
||||
const drinkPic = ref();
|
||||
const imgsrc = ref();
|
||||
|
||||
const deletePic = ref(false);
|
||||
|
||||
function delete_pic() {
|
||||
deletePic.value = true;
|
||||
imgsrc.value = undefined;
|
||||
drinkPic.value = undefined;
|
||||
if (edit_drink.value) {
|
||||
edit_drink.value.uuid = '';
|
||||
}
|
||||
}
|
||||
|
||||
function imagePreview() {
|
||||
if (drinkPic.value && drinkPic.value instanceof File) {
|
||||
let reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
imgsrc.value = e.target?.result;
|
||||
};
|
||||
|
||||
reader.readAsDataURL(drinkPic.value);
|
||||
}
|
||||
}
|
||||
|
||||
const image = computed(() => {
|
||||
if (deletePic.value) {
|
||||
return 'no-image.svg';
|
||||
}
|
||||
if (imgsrc.value) {
|
||||
return <string>imgsrc.value;
|
||||
}
|
||||
if (edit_drink.value?.uuid) {
|
||||
return `${baseURL.value}/pricelist/picture/${edit_drink.value.uuid}?size=256`;
|
||||
}
|
||||
return 'no-image.svg';
|
||||
});
|
||||
|
||||
const can_delete = computed(() => {
|
||||
if (edit_drink.value) {
|
||||
if (edit_drink.value.id < 0) {
|
||||
return false;
|
||||
}
|
||||
const _edit_drink = edit_drink.value;
|
||||
const test = _edit_drink.volumes ? _edit_drink.volumes.length === 0 : true;
|
||||
console.log(test);
|
||||
return test;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
function delete_drink() {
|
||||
emit('delete');
|
||||
}
|
||||
|
||||
const auto_cost_per_volume = computed(
|
||||
() =>
|
||||
!!(
|
||||
edit_drink.value?.cost_per_package &&
|
||||
edit_drink.value?.package_size &&
|
||||
edit_drink.value?.volume
|
||||
)
|
||||
);
|
||||
|
||||
const cost_per_volume = computed({
|
||||
get: () => {
|
||||
let retVal: number;
|
||||
if (auto_cost_per_volume.value) {
|
||||
retVal = <number>calc_cost_per_volume(<Drink>edit_drink.value);
|
||||
} else {
|
||||
retVal = <number>(<Drink>edit_drink.value).cost_per_volume;
|
||||
}
|
||||
updateVolumes();
|
||||
return retVal;
|
||||
},
|
||||
set: (val: number) => ((<Drink>edit_drink.value).cost_per_volume = val),
|
||||
});
|
||||
|
||||
const hasIngredients = computed(() =>
|
||||
edit_volumes.value?.some((a) => a.ingredients.length > 0)
|
||||
);
|
||||
|
||||
return {
|
||||
edit_drink,
|
||||
save,
|
||||
cancel,
|
||||
updateVolume,
|
||||
deletePrice,
|
||||
deleteIngredient,
|
||||
deleteVolume,
|
||||
addStep,
|
||||
deleteStep,
|
||||
tags: computed(() => store.tags),
|
||||
image,
|
||||
imgsrc,
|
||||
drinkPic,
|
||||
imagePreview,
|
||||
delete_pic,
|
||||
types: computed(() => store.drinkTypes),
|
||||
can_delete,
|
||||
delete_drink,
|
||||
auto_cost_per_volume,
|
||||
cost_per_volume,
|
||||
edit_volumes,
|
||||
key,
|
||||
hasIngredients,
|
||||
hasPermission,
|
||||
PERMISSIONS,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,113 +0,0 @@
|
|||
<template>
|
||||
<q-table
|
||||
title="Getränkearten"
|
||||
:rows="rows"
|
||||
:row-key="(row) => row.id"
|
||||
:columns="columns"
|
||||
style="height: 100%"
|
||||
:pagination="pagination"
|
||||
>
|
||||
<template #top-right>
|
||||
<div class="full-width row q-gutter-sm">
|
||||
<q-input v-model="newDrinkType" dense placeholder="Neue Getränkeart" filled />
|
||||
<q-btn round color="primary" icon="mdi-plus" @click="addType">
|
||||
<q-tooltip> Getränkeart hinzufügen </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</template>
|
||||
<template #body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td key="drinkTypeName" :props="props">
|
||||
{{ props.row.name }}
|
||||
<q-popup-edit
|
||||
v-model="props.row.name"
|
||||
buttons
|
||||
label-set="Speichern"
|
||||
label-cancel="Abbrechen"
|
||||
@save="saveChanges(props.row)"
|
||||
>
|
||||
<template #default="scope">
|
||||
<q-input v-model="scope.value" dense label="name" filled />
|
||||
</template>
|
||||
</q-popup-edit>
|
||||
</q-td>
|
||||
<q-td key="actions" :props="props">
|
||||
<q-btn
|
||||
round
|
||||
icon="mdi-delete"
|
||||
color="negative"
|
||||
size="sm"
|
||||
@click="deleteType(props.row.id)"
|
||||
/>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onBeforeMount, ref } from 'vue';
|
||||
import { usePricelistStore } from '../store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DrinkTypes',
|
||||
setup() {
|
||||
const store = usePricelistStore();
|
||||
const newDrinkType = ref('');
|
||||
|
||||
onBeforeMount(() => {
|
||||
void store.getDrinkTypes(true);
|
||||
});
|
||||
const rows = computed(() => store.drinkTypes);
|
||||
const columns = [
|
||||
{
|
||||
name: 'drinkTypeName',
|
||||
label: 'Getränkeart',
|
||||
field: 'name',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: 'Aktionen',
|
||||
field: 'actions',
|
||||
align: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
async function addType() {
|
||||
await store.addDrinkType(newDrinkType.value);
|
||||
newDrinkType.value = '';
|
||||
}
|
||||
|
||||
function saveChanges(drinkType: FG.DrinkType) {
|
||||
setTimeout(() => {
|
||||
const _drinkType = store.drinkTypes.find((a) => a.id === drinkType.id);
|
||||
if (_drinkType) {
|
||||
void store.changeDrinkTypeName(drinkType);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function deleteType(id: number) {
|
||||
void store.removeDrinkType(id);
|
||||
}
|
||||
const pagination = ref({
|
||||
sortBy: 'name',
|
||||
rowsPerPage: 10,
|
||||
});
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
addType,
|
||||
newDrinkType,
|
||||
deleteType,
|
||||
saveChanges,
|
||||
pagination,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,154 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<q-page padding>
|
||||
<q-table
|
||||
title="Getränkearten"
|
||||
:rows="rows"
|
||||
:row-key="(row) => row.id"
|
||||
:columns="columns"
|
||||
:pagination="pagination"
|
||||
>
|
||||
<template #top-right>
|
||||
<div class="full-width row q-gutter-sm">
|
||||
<q-input
|
||||
v-model="newExtraIngredient.name"
|
||||
dense
|
||||
placeholder="Neue Zutatenbezeichnung"
|
||||
label="Neue Zutatenbezeichnung"
|
||||
filled
|
||||
/>
|
||||
<q-input
|
||||
v-model.number="newExtraIngredient.price"
|
||||
dense
|
||||
placeholder="Preis"
|
||||
label="Preis"
|
||||
filled
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
suffix="€"
|
||||
/>
|
||||
<q-btn color="primary" icon="mdi-plus" round @click="addExtraIngredient">
|
||||
<q-tooltip> Zutat hinzufügen </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</template>
|
||||
<template #body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td key="name" :props="props" align="left">
|
||||
{{ props.row.name }}
|
||||
<q-popup-edit
|
||||
v-model="props.row.name"
|
||||
buttons
|
||||
label-set="Speichern"
|
||||
label-cancel="Abbrechen"
|
||||
@save="saveChanges(props.row)"
|
||||
>
|
||||
<template #default="scope">
|
||||
<q-input v-model="scope.value" dense label="name" filled />
|
||||
</template>
|
||||
</q-popup-edit>
|
||||
</q-td>
|
||||
<q-td key="price" :props="props" align="right">
|
||||
{{ props.row.price.toFixed(2) }}€
|
||||
</q-td>
|
||||
<q-td key="actions" :props="props" align="right" auto-width>
|
||||
<q-btn
|
||||
round
|
||||
icon="mdi-delete"
|
||||
color="negative"
|
||||
size="sm"
|
||||
@click="deleteType(props.row)"
|
||||
/>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-page>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, ComputedRef, defineComponent, ref } from 'vue';
|
||||
import { usePricelistStore } from 'src/plugins/pricelist/store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DrinkTypes',
|
||||
setup() {
|
||||
const store = usePricelistStore();
|
||||
const emptyExtraIngredient: FG.ExtraIngredient = {
|
||||
name: '',
|
||||
price: 0,
|
||||
id: -1,
|
||||
};
|
||||
const newExtraIngredient = ref<FG.ExtraIngredient>(emptyExtraIngredient);
|
||||
|
||||
const rows = computed(() => store.extraIngredients);
|
||||
const columns = [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Bezeichnung',
|
||||
field: 'name',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
label: 'Preis',
|
||||
field: 'price',
|
||||
sortable: true,
|
||||
format: (val: number) => `${val.toFixed(2)}€`,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: 'Aktionen',
|
||||
field: 'actions',
|
||||
align: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
async function addExtraIngredient() {
|
||||
await store.setExtraIngredient((<ComputedRef>newExtraIngredient).value);
|
||||
newExtraIngredient.value = Object.assign({}, emptyExtraIngredient);
|
||||
discardChanges();
|
||||
}
|
||||
|
||||
function saveChanges(ingredient: FG.ExtraIngredient) {
|
||||
setTimeout(() => {
|
||||
const _ingredient = store.extraIngredients.find((a) => a.id === ingredient.id);
|
||||
if (_ingredient) {
|
||||
void store.updateExtraIngredient(_ingredient);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function discardChanges() {
|
||||
newExtraIngredient.value.name = '';
|
||||
newExtraIngredient.value.price = 0;
|
||||
}
|
||||
|
||||
function deleteType(extraIngredient: FG.ExtraIngredient) {
|
||||
void store.deleteExtraIngredient(extraIngredient);
|
||||
}
|
||||
|
||||
const pagination = ref({
|
||||
sortBy: 'name',
|
||||
rowsPerPage: 10,
|
||||
});
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
addExtraIngredient,
|
||||
newExtraIngredient,
|
||||
deleteType,
|
||||
discardChanges,
|
||||
saveChanges,
|
||||
pagination,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,55 +0,0 @@
|
|||
<template>
|
||||
<q-list>
|
||||
<div v-for="(min_price, index) in min_prices" :key="index">
|
||||
<q-item>
|
||||
<q-item-section>{{ min_price }}%</q-item-section>
|
||||
<q-btn
|
||||
round
|
||||
icon="mdi-delete"
|
||||
size="sm"
|
||||
color="negative"
|
||||
@click="delete_min_price(min_price)"
|
||||
/>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
</div>
|
||||
</q-list>
|
||||
<q-input v-model.number="new_min_price" class="q-pa-sm" type="number" suffix="%" filled dense />
|
||||
<q-btn class="full-width" label="speichern" @click="save_min_prices"></q-btn>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import { usePricelistStore } from 'src/plugins/pricelist/store';
|
||||
export default defineComponent({
|
||||
name: 'MinPriceSetting',
|
||||
setup() {
|
||||
const store = usePricelistStore();
|
||||
const min_prices = computed(() => store.min_prices);
|
||||
const new_min_price = ref<number>();
|
||||
function save_min_prices() {
|
||||
const index = min_prices.value.findIndex((a) => a === new_min_price.value);
|
||||
if (index < 0) {
|
||||
min_prices.value.push(<number>new_min_price.value);
|
||||
void store.set_min_prices();
|
||||
new_min_price.value = undefined;
|
||||
}
|
||||
}
|
||||
function delete_min_price(min_price: number) {
|
||||
const index = min_prices.value.findIndex((a) => a === min_price);
|
||||
if (index > -1) {
|
||||
min_prices.value.splice(index, 1);
|
||||
void store.set_min_prices();
|
||||
}
|
||||
}
|
||||
return {
|
||||
min_prices,
|
||||
new_min_price,
|
||||
save_min_prices,
|
||||
delete_min_price,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,327 +0,0 @@
|
|||
<template>
|
||||
<q-table
|
||||
title="Preisliste"
|
||||
:columns="columns"
|
||||
:rows="drinks"
|
||||
:visible-columns="visibleColumns"
|
||||
:filter="search"
|
||||
:filter-method="filter"
|
||||
dense
|
||||
:pagination="pagination"
|
||||
:fullscreen="fullscreen"
|
||||
>
|
||||
<template #top-right>
|
||||
<div class="row justify-end q-gutter-sm">
|
||||
<search-input v-model="search" :keys="options" />
|
||||
<q-select
|
||||
v-model="visibleColumns"
|
||||
multiple
|
||||
filled
|
||||
dense
|
||||
options-dense
|
||||
display-value="Sichtbarkeit"
|
||||
emit-value
|
||||
map-options
|
||||
:options="options"
|
||||
option-value="name"
|
||||
options-cover
|
||||
/>
|
||||
<q-btn round icon="mdi-backburger">
|
||||
<q-tooltip anchor='top middle' self='bottom middle'> Reihenfolge ändern </q-tooltip>
|
||||
<q-menu anchor="bottom middle" self="top middle">
|
||||
<drag v-model="order" class="q-list" ghost-class="ghost" group="people" item-key="id">
|
||||
<template #item="{ element }">
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
{{ element.label }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</drag>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
<slot></slot>
|
||||
<q-btn
|
||||
round
|
||||
:icon="fullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
|
||||
@click="fullscreen = !fullscreen"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #body-cell-tags="props">
|
||||
<q-td :props="props">
|
||||
<q-badge
|
||||
v-for="tag in props.row.tags"
|
||||
:key="`${props.row.id}-${tag.id}`"
|
||||
class="text-caption"
|
||||
rounded
|
||||
:style="`background-color: ${tag.color}`"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-public="props">
|
||||
<q-td :props="props">
|
||||
<q-toggle
|
||||
v-model="props.row.public"
|
||||
disable
|
||||
checked-icon="mdi-earth"
|
||||
unchecked-icon="mdi-earth-off"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onBeforeMount, ref, ComponentPublicInstance } from 'vue';
|
||||
import { usePricelistStore, Order } from '../store';
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { Search, filter } from 'src/plugins/pricelist/utils/filter';
|
||||
import SearchInput from 'src/plugins/pricelist/components/SearchInput.vue';
|
||||
import draggable from 'vuedraggable';
|
||||
const drag: ComponentPublicInstance = <ComponentPublicInstance>draggable;
|
||||
|
||||
interface Row {
|
||||
name: string;
|
||||
label: string;
|
||||
field: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
format?: (val: never) => string;
|
||||
align?: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Pricelist',
|
||||
components: { SearchInput, drag },
|
||||
props: {
|
||||
public: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const store = usePricelistStore();
|
||||
const user = ref('');
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (!props.public) {
|
||||
user.value = useMainStore().currentUser.userid;
|
||||
void store.getPriceListColumnOrder(user.value);
|
||||
void store.getDrinks();
|
||||
void store.getPriceCalcColumn(user.value);
|
||||
} else {
|
||||
user.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
const _order = ref<Array<Order>>([
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
label: 'Kategorie',
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
label: 'Tags',
|
||||
},
|
||||
{
|
||||
name: 'volume',
|
||||
label: 'Inhalt',
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
label: 'Preis',
|
||||
},
|
||||
{
|
||||
name: 'public',
|
||||
label: 'Öffentlich',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Beschreibung',
|
||||
},
|
||||
]);
|
||||
|
||||
const order = computed<Array<Order>>({
|
||||
get: () => {
|
||||
if (props.public) {
|
||||
return _order.value;
|
||||
}
|
||||
if (store.pricelist_columns_order.length === 0) {
|
||||
return _order.value;
|
||||
}
|
||||
return store.pricelist_columns_order;
|
||||
},
|
||||
set: (val: Array<Order>) => {
|
||||
if (!props.public) {
|
||||
void store.updatePriceListColumnOrder(user.value, val);
|
||||
} else {
|
||||
_order.value = val;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const _columns: Array<Row> = [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
field: 'name',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
label: 'Kategorie',
|
||||
field: 'type',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
format: (val: FG.DrinkType) => val.name,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
label: 'Tags',
|
||||
field: 'tags',
|
||||
filterable: true,
|
||||
|
||||
format: (val: Array<FG.Tag>) => {
|
||||
let retVal = '';
|
||||
val.forEach((tag, index) => {
|
||||
if (index >= val.length - 1 && index > 0) {
|
||||
retVal += ', ';
|
||||
}
|
||||
retVal += tag.name;
|
||||
});
|
||||
return retVal;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'volume',
|
||||
label: 'Inhalt',
|
||||
field: 'volume',
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
format: (val: number) => `${val.toFixed(3)}L`,
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
label: 'Preis',
|
||||
field: 'price',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
format: (val: number) => `${val.toFixed(2)}€`,
|
||||
},
|
||||
{
|
||||
name: 'public',
|
||||
label: 'Öffentlich',
|
||||
field: 'public',
|
||||
format: (val: boolean) => (val ? 'Öffentlich' : 'nicht Öffentlich'),
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Beschreibung',
|
||||
field: 'description',
|
||||
filterable: true,
|
||||
},
|
||||
];
|
||||
|
||||
const columns = computed(() => {
|
||||
const retVal: Array<Row> = [];
|
||||
if (order.value) {
|
||||
order.value.forEach((col) => {
|
||||
const _col = _columns.find((a) => a.name === col.name);
|
||||
if (_col) {
|
||||
retVal.push(_col);
|
||||
}
|
||||
});
|
||||
retVal.forEach((element, index) => {
|
||||
element.align = 'right';
|
||||
if (index === 0) {
|
||||
element.align = 'left';
|
||||
}
|
||||
});
|
||||
return retVal;
|
||||
}
|
||||
return _columns;
|
||||
});
|
||||
|
||||
const _options = computed(() => {
|
||||
const retVal: Array<{ name: string; label: string; field: string }> = [];
|
||||
columns.value.forEach((col) => {
|
||||
if (props.public) {
|
||||
if (col.name !== 'public') {
|
||||
retVal.push(col);
|
||||
}
|
||||
} else {
|
||||
retVal.push(col);
|
||||
}
|
||||
});
|
||||
return retVal;
|
||||
});
|
||||
|
||||
const _colums = computed<Array<string>>(() => {
|
||||
const retVal: Array<string> = [];
|
||||
columns.value.forEach((col) => {
|
||||
if (props.public) {
|
||||
if (col.name !== 'public') {
|
||||
retVal.push(col.name);
|
||||
}
|
||||
} else {
|
||||
retVal.push(col.name);
|
||||
}
|
||||
});
|
||||
return retVal;
|
||||
});
|
||||
|
||||
const _visibleColumns = ref(_colums.value);
|
||||
|
||||
const visibleColumns = computed({
|
||||
get: () => (props.public ? _visibleColumns.value : store.pricecalc_columns),
|
||||
set: (val) => {
|
||||
if (!props.public) {
|
||||
void store.updatePriceCalcColumn(user.value, val);
|
||||
} else {
|
||||
_visibleColumns.value = val;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const search = ref<Search>({
|
||||
value: '',
|
||||
key: '',
|
||||
label: '',
|
||||
});
|
||||
|
||||
const pagination = ref({
|
||||
sortBy: 'name',
|
||||
rowsPerPage: 10,
|
||||
});
|
||||
|
||||
const fullscreen = ref(false);
|
||||
|
||||
return {
|
||||
drinks: computed(() => store.pricelist),
|
||||
columns,
|
||||
order,
|
||||
visibleColumns,
|
||||
options: _options,
|
||||
search,
|
||||
filter,
|
||||
pagination,
|
||||
fullscreen,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
.ghost
|
||||
opacity: 0.5
|
||||
background: $accent
|
||||
</style>
|
|
@ -1,89 +0,0 @@
|
|||
<template>
|
||||
<q-dialog v-model="alert">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Suche</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<div>
|
||||
Wenn du in die Suche etwas eingibst, wird in allen Spalten gesucht. Mit einem `@` Zeichen,
|
||||
kann man die Suche eingrenzen auf eine Spalte. Zumbeispiel: `Tequilaparty@Tags`
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div>Mögliche Suchbegriffe nach dem @:</div>
|
||||
<div class="fit row q-gutter-sm">
|
||||
<div v-for="key in keys" :key="key.name">
|
||||
{{ key.label }}
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn v-close-popup flat label="OK" color="primary" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-input v-model="v_model" filled dense>
|
||||
<template #append>
|
||||
<q-icon name="mdi-magnify" />
|
||||
</template>
|
||||
<template #prepend>
|
||||
<q-btn icon="mdi-help-circle" flat round @click="alert = true" />
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, PropType, ref } from 'vue';
|
||||
import { Search, Col } from '../utils/filter';
|
||||
export default defineComponent({
|
||||
name: 'SearchInput',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object as PropType<Search>,
|
||||
default: () => ({ value: '', key: undefined, label: '' }),
|
||||
},
|
||||
keys: {
|
||||
type: Object as PropType<Array<Col>>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
'update:modelValue': (val: {
|
||||
value: string;
|
||||
key: string | undefined;
|
||||
label: string | undefined;
|
||||
}) => val,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const v_model = computed<string>({
|
||||
get: () => {
|
||||
if (!props.modelValue.label || props.modelValue.label === '') {
|
||||
return `${props.modelValue.value}`;
|
||||
}
|
||||
return `${props.modelValue.value}@${props.modelValue.label}`;
|
||||
},
|
||||
set: (val: string) => {
|
||||
const split = val.toLowerCase().split('@');
|
||||
if (split.length < 2) {
|
||||
emit('update:modelValue', { value: split[0], label: undefined, key: undefined });
|
||||
} else {
|
||||
props.keys.find((key) => {
|
||||
if (key.label.toLowerCase() === split[1]) {
|
||||
console.log(key.name);
|
||||
emit('update:modelValue', { value: split[0], label: split[1], key: key.name });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const alert = ref(false);
|
||||
return { v_model, alert };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
a
|
|
@ -1,181 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<q-page padding>
|
||||
<q-table
|
||||
title="Tags"
|
||||
:rows="rows"
|
||||
:row-key="(row) => row.id"
|
||||
:columns="columns"
|
||||
:pagination="pagination"
|
||||
>
|
||||
<template #top-right>
|
||||
<q-btn color="primary" icon="mdi-plus" round>
|
||||
<q-tooltip> Tag hinzufügen </q-tooltip>
|
||||
<q-menu v-model="popup" anchor="center middle" self="center middle" persistent>
|
||||
<q-input
|
||||
v-model="newTag.name"
|
||||
filled
|
||||
dense
|
||||
label="Name"
|
||||
class="q-pa-sm"
|
||||
:rule="[notExists]"
|
||||
/>
|
||||
<q-color
|
||||
:model-value="newTag.color"
|
||||
flat
|
||||
class="q-pa-sm"
|
||||
@change="
|
||||
(val) => {
|
||||
newTag.color = val;
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div class="full-width row q-gutter-sm justify-around q-py-sm">
|
||||
<q-btn v-close-popup flat label="Abbrechen" />
|
||||
<q-btn flat label="Speichern" color="primary" @click="save" />
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</template>
|
||||
<template #header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width />
|
||||
</q-tr>
|
||||
</template>
|
||||
<template #body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td key="name" :props="props">
|
||||
{{ props.row.name }}
|
||||
<q-popup-edit
|
||||
v-model="props.row.name"
|
||||
buttons
|
||||
label-cancel="Abbrechen"
|
||||
label-set="Speichern"
|
||||
@update:modelValue="updateTag(props.row)"
|
||||
>
|
||||
<template #default="scope">
|
||||
<q-input v-model="scope.value" :rules="[notExists]" dense filled />
|
||||
</template>
|
||||
</q-popup-edit>
|
||||
</q-td>
|
||||
<q-td key="color" :props="props">
|
||||
<div class="full-width row q-gutter-sm justify-end items-center">
|
||||
<div>
|
||||
{{ props.row.color }}
|
||||
</div>
|
||||
<div class="color-box" :style="`background-color: ${props.row.color};`"> </div>
|
||||
</div>
|
||||
<q-popup-edit
|
||||
v-model="props.row.color"
|
||||
buttons
|
||||
label-cancel="Abbrechen"
|
||||
label-set="Speichern"
|
||||
@update:modelValue="updateTag(props.row)"
|
||||
>
|
||||
<template #default="slot">
|
||||
<div class="full-width row justify-center">
|
||||
<q-color
|
||||
:model-value="slot.value"
|
||||
class="full-width"
|
||||
flat
|
||||
@change="(val) => (slot.value = val)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</q-popup-edit>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-btn
|
||||
icon="mdi-delete"
|
||||
color="negative"
|
||||
round
|
||||
size="sm"
|
||||
@click="deleteTag(props.row)"
|
||||
/>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-page>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, onBeforeMount, computed } from 'vue';
|
||||
import { usePricelistStore } from '../store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Tags',
|
||||
setup() {
|
||||
const store = usePricelistStore();
|
||||
onBeforeMount(() => {
|
||||
void store.getTags();
|
||||
});
|
||||
const columns = [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
field: 'name',
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
label: 'Farbe',
|
||||
field: 'color',
|
||||
},
|
||||
];
|
||||
const rows = computed(() => store.tags);
|
||||
const emptyTag = {
|
||||
id: -1,
|
||||
color: '#1976d2',
|
||||
name: '',
|
||||
};
|
||||
|
||||
async function save() {
|
||||
await store.setTag(newTag.value);
|
||||
popup.value = false;
|
||||
newTag.value = emptyTag;
|
||||
}
|
||||
|
||||
const newTag = ref(emptyTag);
|
||||
const popup = ref(false);
|
||||
function notExists(val: string) {
|
||||
const index = store.tags.findIndex((a) => a.name === val);
|
||||
if (index > -1) {
|
||||
return 'Tag existiert bereits.';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const pagination = ref({
|
||||
sortBy: 'name',
|
||||
rowsPerPage: 10,
|
||||
});
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
newTag,
|
||||
popup,
|
||||
save,
|
||||
updateTag: store.updateTag,
|
||||
notExists,
|
||||
deleteTag: store.deleteTag,
|
||||
pagination,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.color-box {
|
||||
min-width: 28px;
|
||||
min-heigh: 28px;
|
||||
max-width: 28px;
|
||||
max-height: 28px;
|
||||
border-width: 1px;
|
||||
border-color: black;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
|
@ -1,65 +0,0 @@
|
|||
<template>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="q-table__title">Cocktailbuilder</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<ingredients
|
||||
v-model="volume.ingredients"
|
||||
class="q-pa-sm"
|
||||
editable
|
||||
@update:modelValue="update"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="q-table__title">Du solltest mindest sowiel verlangen oder bezahlen:</div>
|
||||
<div class="full-width row q-gutter-sm justify-around">
|
||||
<div v-for="min_price in volume.min_prices" :key="min_price.percentage">
|
||||
<div>
|
||||
<q-badge class="text-h6" color="primary"> {{ min_price.percentage }}% </q-badge>
|
||||
</div>
|
||||
<div>{{ min_price.price.toFixed(2) }}€</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onBeforeMount, ref } from 'vue';
|
||||
import Ingredients from 'src/plugins/pricelist/components/CalculationTable/Ingredients.vue';
|
||||
import { DrinkPriceVolume, usePricelistStore } from 'src/plugins/pricelist/store';
|
||||
import { calc_min_prices } from '../utils/utils';
|
||||
export default defineComponent({
|
||||
name: 'CocktailBuilder',
|
||||
components: { Ingredients },
|
||||
setup() {
|
||||
onBeforeMount(() => {
|
||||
void store.get_min_prices().finally(() => {
|
||||
volume.value.min_prices = calc_min_prices(volume.value, undefined, store.min_prices);
|
||||
});
|
||||
void store.getDrinks();
|
||||
void store.getDrinkTypes();
|
||||
void store.getExtraIngredients();
|
||||
});
|
||||
const store = usePricelistStore();
|
||||
|
||||
const emptyVolume: DrinkPriceVolume = {
|
||||
id: -1,
|
||||
_volume: 0,
|
||||
min_prices: [],
|
||||
prices: [],
|
||||
ingredients: [],
|
||||
};
|
||||
const volume = ref(emptyVolume);
|
||||
|
||||
function update() {
|
||||
volume.value.min_prices = calc_min_prices(volume.value, undefined, store.min_prices);
|
||||
}
|
||||
|
||||
return { volume, update };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,38 +0,0 @@
|
|||
<template>
|
||||
<calculation-table v-if="!list" nodetails>
|
||||
<q-btn icon="mdi-view-list" round @click="list = !list">
|
||||
<q-tooltip> Zur Listenansicht wechseln </q-tooltip>
|
||||
</q-btn>
|
||||
</calculation-table>
|
||||
<pricelist v-if="list">
|
||||
<q-btn icon="mdi-cards-variant" round @click="list = !list">
|
||||
<q-tooltip> Zur Kartenansicht wechseln </q-tooltip>
|
||||
</q-btn>
|
||||
</pricelist>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, onBeforeMount, computed } from 'vue';
|
||||
import CalculationTable from '../components/CalculationTable.vue';
|
||||
import Pricelist from 'src/plugins/pricelist/components/Pricelist.vue';
|
||||
import { usePricelistStore } from 'src/plugins/pricelist/store';
|
||||
import { useMainStore } from 'src/stores';
|
||||
export default defineComponent({
|
||||
name: 'InnerPricelist',
|
||||
components: { Pricelist, CalculationTable },
|
||||
setup() {
|
||||
const store = usePricelistStore();
|
||||
const mainStore = useMainStore();
|
||||
|
||||
onBeforeMount(() => {
|
||||
void store.getDrinks();
|
||||
void store.getPriceListView(mainStore.currentUser.userid);
|
||||
});
|
||||
|
||||
const list = computed({
|
||||
get: () => store.pricelist_view,
|
||||
set: (val: boolean) => store.updatePriceListView(mainStore.currentUser.userid, val),
|
||||
});
|
||||
return { list };
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,36 +0,0 @@
|
|||
<template>
|
||||
<calculation-table v-if="!list" public>
|
||||
<q-btn icon="mdi-view-list" round @click="list = !list">
|
||||
<q-tooltip> Zur Listenansicht wechseln </q-tooltip>
|
||||
</q-btn>
|
||||
</calculation-table>
|
||||
<pricelist v-if="list" public>
|
||||
<q-btn icon="mdi-cards-variant" round @click="list = !list">
|
||||
<q-tooltip> Zur Kartenansicht wechseln </q-tooltip>
|
||||
</q-btn>
|
||||
</pricelist>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue';
|
||||
import CalculationTable from '../components/CalculationTable.vue';
|
||||
import Pricelist from '../components/Pricelist.vue';
|
||||
import { usePricelistStore } from '../store';
|
||||
import { onBeforeMount, ref } from 'vue';
|
||||
export default defineComponent({
|
||||
name: 'OuterPricelist',
|
||||
components: { Pricelist, CalculationTable },
|
||||
setup() {
|
||||
const store = usePricelistStore();
|
||||
|
||||
onBeforeMount(() => {
|
||||
void store.getDrinks();
|
||||
});
|
||||
|
||||
const list = ref(false);
|
||||
return { list };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,175 +0,0 @@
|
|||
<template>
|
||||
<q-table
|
||||
grid
|
||||
title="Rezepte"
|
||||
:rows="drinks"
|
||||
row-key="id"
|
||||
hide-header
|
||||
:filter="search"
|
||||
:filter-method="filter"
|
||||
:columns="options"
|
||||
>
|
||||
<template #top-right>
|
||||
<search-input v-model="search" :keys="search_keys" />
|
||||
</template>
|
||||
<template #item="props">
|
||||
<div class="q-pa-xs col-xs-12 col-sm-6 col-md-4">
|
||||
<q-card>
|
||||
<q-img
|
||||
style="max-height: 256px"
|
||||
loading="lazy"
|
||||
:src="image(props.row.uuid)"
|
||||
placeholder-src="no-image.svg"
|
||||
>
|
||||
<div class="absolute-bottom-right justify-end">
|
||||
<div class="text-subtitle1 text-right">
|
||||
{{ props.row.name }}
|
||||
</div>
|
||||
<div class="text-caption text-right">
|
||||
{{ props.row.type.name }}
|
||||
</div>
|
||||
</div>
|
||||
</q-img>
|
||||
<q-card-section>
|
||||
<q-badge
|
||||
v-for="tag in props.row.tags"
|
||||
:key="`${props.row.id}-${tag.id}`"
|
||||
class="text-caption"
|
||||
rounded
|
||||
:style="`background-color: ${tag.color}`"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</q-badge>
|
||||
</q-card-section>
|
||||
<build-manual-volume :volumes="props.row.volumes" />
|
||||
<q-card-section>
|
||||
<div class="text-h6">Anleitung</div>
|
||||
<build-manual :steps="props.row.receipt" :editable="false" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
</q-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onBeforeMount, ref } from 'vue';
|
||||
import { usePricelistStore } from 'src/plugins/pricelist/store';
|
||||
import BuildManual from 'src/plugins/pricelist/components/CalculationTable/BuildManual.vue';
|
||||
import BuildManualVolume from '../components/BuildManual/BuildManualVolume.vue';
|
||||
import SearchInput from '../components/SearchInput.vue';
|
||||
import { filter, Search } from '../utils/filter';
|
||||
import { sort } from '../utils/sort';
|
||||
import { baseURL } from 'src/config';
|
||||
export default defineComponent({
|
||||
name: 'Reciepts',
|
||||
components: { BuildManual, BuildManualVolume, SearchInput },
|
||||
setup() {
|
||||
const store = usePricelistStore();
|
||||
onBeforeMount(() => {
|
||||
void store.getDrinks();
|
||||
});
|
||||
const drinks = computed(() =>
|
||||
store.drinks.filter((drink) => {
|
||||
return drink.volumes.some((volume) => volume.ingredients.length > 0);
|
||||
})
|
||||
);
|
||||
|
||||
const columns_drinks = [
|
||||
{
|
||||
name: 'picture',
|
||||
label: 'Bild',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
field: 'name',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
name: 'drink_type',
|
||||
label: 'Kategorie',
|
||||
field: 'type',
|
||||
format: (val: FG.DrinkType) => `${val.name}`,
|
||||
sortable: true,
|
||||
sort: (a: FG.DrinkType, b: FG.DrinkType) => sort(a.name, b.name),
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
label: 'Tag',
|
||||
field: 'tags',
|
||||
format: (val: Array<FG.Tag>) => {
|
||||
let retVal = '';
|
||||
val.forEach((tag, index) => {
|
||||
if (index > 0) {
|
||||
retVal += ', ';
|
||||
}
|
||||
retVal += tag.name;
|
||||
});
|
||||
return retVal;
|
||||
},
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
name: 'volumes',
|
||||
label: 'Preise',
|
||||
field: 'volumes',
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
const columns_volumes = [
|
||||
{
|
||||
name: 'volume',
|
||||
label: 'Inhalt',
|
||||
field: 'volume',
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
name: 'prices',
|
||||
label: 'Preise',
|
||||
field: 'prices',
|
||||
},
|
||||
];
|
||||
const columns_prices = [
|
||||
{
|
||||
name: 'price',
|
||||
label: 'Preis',
|
||||
field: 'price',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Beschreibung',
|
||||
field: 'description',
|
||||
},
|
||||
{
|
||||
name: 'public',
|
||||
label: 'Öffentlich',
|
||||
field: 'public',
|
||||
},
|
||||
];
|
||||
|
||||
const search = ref<Search>({ value: '', key: '', label: '' });
|
||||
const search_keys = computed(() => columns_drinks.filter((column) => column.filterable));
|
||||
function image(uuid: string | undefined) {
|
||||
if (uuid) {
|
||||
return `${baseURL.value}/pricelist/picture/${uuid}?size=256`;
|
||||
}
|
||||
return 'no-image.svg';
|
||||
}
|
||||
return {
|
||||
drinks,
|
||||
options: [...columns_drinks, ...columns_volumes, ...columns_prices],
|
||||
search,
|
||||
filter,
|
||||
search_keys,
|
||||
image,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,136 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<q-tabs v-if="$q.screen.gt.sm" v-model="tab">
|
||||
<q-tab
|
||||
v-for="(tabindex, index) in tabs"
|
||||
:key="'tab' + index"
|
||||
:name="tabindex.name"
|
||||
:label="tabindex.label"
|
||||
/>
|
||||
</q-tabs>
|
||||
<div v-else class="fit row justify-end">
|
||||
<q-btn flat round icon="mdi-menu" @click="showDrawer = !showDrawer"></q-btn>
|
||||
</div>
|
||||
<q-drawer v-model="showDrawer" side="right" behavior="mobile" @click="showDrawer = !showDrawer">
|
||||
<q-list v-model="tab">
|
||||
<q-item
|
||||
v-for="(tabindex, index) in tabs"
|
||||
:key="'tab' + index"
|
||||
:active="tab == tabindex.name"
|
||||
clickable
|
||||
@click="tab = tabindex.name"
|
||||
>
|
||||
<q-item-label>{{ tabindex.label }}</q-item-label>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-drawer>
|
||||
<q-page paddding class="fit row justify-center content-start items-start q-gutter-sm">
|
||||
<q-tab-panels
|
||||
v-model="tab"
|
||||
style="background-color: transparent"
|
||||
animated
|
||||
class="q-ma-none q-pa-none fit row justify-center content-start items-start"
|
||||
>
|
||||
<q-tab-panel name="pricelist">
|
||||
<calculation-table editable />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="extra_ingredients">
|
||||
<extra-ingredients />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="drink_types">
|
||||
<drink-types />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="tags">
|
||||
<tags />
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-page>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onBeforeMount, ref } from 'vue';
|
||||
import { Screen } from 'quasar';
|
||||
import DrinkTypes from 'src/plugins/pricelist/components/DrinkTypes.vue';
|
||||
import CalculationTable from 'src/plugins/pricelist/components/CalculationTable.vue';
|
||||
import ExtraIngredients from 'src/plugins/pricelist/components/ExtraIngredients.vue';
|
||||
import Tags from '../components/Tags.vue';
|
||||
import { usePricelistStore } from 'src/plugins/pricelist/store';
|
||||
import { hasPermissions } from 'src/utils/permission';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Settings',
|
||||
components: { ExtraIngredients, DrinkTypes, Tags, CalculationTable },
|
||||
setup() {
|
||||
interface Tab {
|
||||
name: string;
|
||||
label: string;
|
||||
permissions: Array<string>;
|
||||
}
|
||||
const store = usePricelistStore();
|
||||
onBeforeMount(() => {
|
||||
store
|
||||
.getExtraIngredients()
|
||||
.then(() => {
|
||||
console.log(store.extraIngredients);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
void store.getTags();
|
||||
void store.getDrinkTypes();
|
||||
void store.getDrinks();
|
||||
void store.get_min_prices();
|
||||
});
|
||||
|
||||
const drawer = ref<boolean>(false);
|
||||
|
||||
const showDrawer = computed({
|
||||
get: () => {
|
||||
return !Screen.gt.sm && drawer.value;
|
||||
},
|
||||
set: (val: boolean) => {
|
||||
drawer.value = val;
|
||||
},
|
||||
});
|
||||
|
||||
const _tabs: Tab[] = [
|
||||
{ name: 'pricelist', label: 'Getränke', permissions: ['drink_edit'] },
|
||||
{
|
||||
name: 'extra_ingredients',
|
||||
label: 'Zutaten',
|
||||
permissions: ['edit_ingredients', 'delete_ingredients'],
|
||||
},
|
||||
{
|
||||
name: 'drink_types',
|
||||
label: 'Getränketypen',
|
||||
permissions: ['drink_type_edit', 'drink_type_delete'],
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
label: 'Tags',
|
||||
permissions: ['drink_tag_edit', 'drink_tag_create', 'drink_tag_delete'],
|
||||
},
|
||||
];
|
||||
|
||||
const tabs = computed(() => {
|
||||
const retVal: Tab[] = [];
|
||||
_tabs.forEach((tab) => {
|
||||
if (tab.permissions.length > 0) {
|
||||
if (hasPermissions(tab.permissions)) {
|
||||
retVal.push(tab);
|
||||
}
|
||||
}
|
||||
if (tab.permissions.length === 0) {
|
||||
retVal.push(tab);
|
||||
}
|
||||
});
|
||||
return retVal;
|
||||
});
|
||||
|
||||
const tab = ref<string>('pricelist');
|
||||
|
||||
return { tabs, tab, showDrawer };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,33 +0,0 @@
|
|||
export const PERMISSIONS = {
|
||||
CREATE: 'drink_create',
|
||||
|
||||
EDIT: 'drink_edit',
|
||||
|
||||
DELETE: 'drink_delete',
|
||||
|
||||
CREATE_TAG: 'drink_tag_create',
|
||||
|
||||
EDIT_PRICE: 'edit_price',
|
||||
DELETE_PRICE: 'delete_price',
|
||||
|
||||
EDIT_VOLUME: 'edit_volume',
|
||||
DELETE_VOLUME: 'delete_volume',
|
||||
|
||||
EDIT_INGREDIENTS_DRINK: 'edit_ingredients_drink',
|
||||
DELETE_INGREDIENTS_DRINK: 'delete_ingredients_drink',
|
||||
|
||||
EDIT_INGREDIENTS: 'edit_ingredients',
|
||||
DELETE_INGREDIENTS: 'delete_ingredients',
|
||||
|
||||
EDIT_TAG: 'drink_tag_edit',
|
||||
|
||||
DELETE_TAG: 'drink_tag_delete',
|
||||
|
||||
CREATE_TYPE: 'drink_type_create',
|
||||
|
||||
EDIT_TYPE: 'drink_type_edit',
|
||||
|
||||
DELETE_TYPE: 'drink_type_delete',
|
||||
|
||||
EDIT_MIN_PRICES: 'edit_min_prices',
|
||||
};
|
|
@ -1,14 +0,0 @@
|
|||
import { innerRoutes, outerRoutes } from './routes';
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
|
||||
const plugin: FG_Plugin.Plugin = {
|
||||
name: 'Pricelist',
|
||||
innerRoutes,
|
||||
outerRoutes,
|
||||
requiredModules: [],
|
||||
requiredBackendModules: ['pricelist'],
|
||||
version: '0.0.1',
|
||||
widgets: [],
|
||||
};
|
||||
|
||||
export default plugin;
|
|
@ -1,73 +0,0 @@
|
|||
import { FG_Plugin } from 'src/plugins';
|
||||
|
||||
export const innerRoutes: FG_Plugin.MenuRoute[] = [
|
||||
{
|
||||
title: 'Getränke',
|
||||
icon: 'mdi-glass-mug-variant',
|
||||
route: {
|
||||
path: 'drinks',
|
||||
name: 'drinks',
|
||||
redirect: { name: 'drinks-pricelist' },
|
||||
},
|
||||
permissions: ['user'],
|
||||
children: [
|
||||
{
|
||||
title: 'Preisliste',
|
||||
icon: 'mdi-cash-100',
|
||||
shortcut: true,
|
||||
permissions: ['user'],
|
||||
route: {
|
||||
path: 'pricelist',
|
||||
name: 'drinks-pricelist',
|
||||
component: () => import('../pages/InnerPricelist.vue'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Rezepte',
|
||||
shortcut: false,
|
||||
icon: 'mdi-receipt',
|
||||
permissions: ['user'],
|
||||
route: {
|
||||
path: 'reciepts',
|
||||
name: 'reciepts',
|
||||
component: () => import('../pages/Receipts.vue'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Cocktailbuilder',
|
||||
shortcut: false,
|
||||
icon: 'mdi-glass-cocktail',
|
||||
permissions: ['user'],
|
||||
route: {
|
||||
path: 'cocktail-builder',
|
||||
name: 'cocktail-builder',
|
||||
component: () => import('../pages/CocktailBuilder.vue'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Einstellungen',
|
||||
icon: 'mdi-coffee-to-go',
|
||||
shortcut: false,
|
||||
permissions: ['drink_edit', 'drink_tag_edit'],
|
||||
route: {
|
||||
path: 'settings',
|
||||
name: 'drinks-settings',
|
||||
component: () => import('../pages/Settings.vue'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const outerRoutes: FG_Plugin.MenuRoute[] = [
|
||||
{
|
||||
title: 'Preisliste',
|
||||
icon: 'mdi-glass-mug-variant',
|
||||
shortcut: true,
|
||||
route: {
|
||||
path: 'pricelist',
|
||||
name: 'outter-pricelist',
|
||||
component: () => import('../pages/OuterPricelist.vue'),
|
||||
},
|
||||
},
|
||||
];
|
|
@ -1,313 +0,0 @@
|
|||
import { api } from 'src/boot/axios';
|
||||
import { defineStore } from 'pinia';
|
||||
import {
|
||||
calc_volume,
|
||||
calc_cost_per_volume,
|
||||
calc_all_min_prices,
|
||||
} from 'src/plugins/pricelist/utils/utils';
|
||||
|
||||
interface DrinkPriceVolume extends Omit<FG.DrinkPriceVolume, 'volume'> {
|
||||
_volume: number;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
interface Drink extends Omit<Omit<FG.Drink, 'cost_per_volume'>, 'volumes'> {
|
||||
volumes: DrinkPriceVolume[];
|
||||
cost_per_volume?: number;
|
||||
_cost_per_volume?: number;
|
||||
}
|
||||
|
||||
interface Pricelist {
|
||||
name: string;
|
||||
type: FG.DrinkType;
|
||||
tags: Array<FG.Tag>;
|
||||
volume: number;
|
||||
price: number;
|
||||
public: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
class DrinkPriceVolume implements DrinkPriceVolume {
|
||||
constructor({ id, volume, prices, ingredients }: FG.DrinkPriceVolume) {
|
||||
this.id = id;
|
||||
this._volume = volume;
|
||||
this.prices = prices;
|
||||
this.ingredients = ingredients;
|
||||
this.min_prices = [];
|
||||
this.volume = calc_volume(this);
|
||||
}
|
||||
}
|
||||
|
||||
class Drink {
|
||||
constructor({
|
||||
id,
|
||||
article_id,
|
||||
package_size,
|
||||
name,
|
||||
volume,
|
||||
cost_per_volume,
|
||||
cost_per_package,
|
||||
tags,
|
||||
type,
|
||||
uuid,
|
||||
receipt,
|
||||
}: FG.Drink) {
|
||||
this.id = id;
|
||||
this.article_id = article_id;
|
||||
this.package_size = package_size;
|
||||
this.name = name;
|
||||
this.volume = volume;
|
||||
this.cost_per_package = cost_per_package;
|
||||
this._cost_per_volume = cost_per_volume;
|
||||
this.cost_per_volume = calc_cost_per_volume(this);
|
||||
this.tags = tags;
|
||||
this.type = type;
|
||||
this.volumes = [];
|
||||
this.uuid = uuid;
|
||||
this.receipt = receipt || [];
|
||||
}
|
||||
}
|
||||
|
||||
interface Order {
|
||||
label: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const usePricelistStore = defineStore({
|
||||
id: 'pricelist',
|
||||
|
||||
state: () => ({
|
||||
drinkTypes: [] as Array<FG.DrinkType>,
|
||||
drinks: [] as Array<Drink>,
|
||||
extraIngredients: [] as Array<FG.ExtraIngredient>,
|
||||
min_prices: [] as Array<number>,
|
||||
tags: [] as Array<FG.Tag>,
|
||||
pricecalc_columns: [] as Array<string>,
|
||||
pricelist_view: false as boolean,
|
||||
pricelist_columns_order: [] as Array<Order>,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async getDrinkTypes(force = false) {
|
||||
if (force || this.drinks.length == 0) {
|
||||
const { data } = await api.get<Array<FG.DrinkType>>('/pricelist/drink-types');
|
||||
this.drinkTypes = data;
|
||||
}
|
||||
return this.drinkTypes;
|
||||
},
|
||||
async addDrinkType(name: string) {
|
||||
const { data } = await api.post<FG.DrinkType>('/pricelist/drink-types', { name: name });
|
||||
this.drinkTypes.push(data);
|
||||
},
|
||||
async removeDrinkType(id: number) {
|
||||
await api.delete(`/pricelist/drink-types/${id}`);
|
||||
const idx = this.drinkTypes.findIndex((val) => val.id == id);
|
||||
if (idx >= 0) this.drinkTypes.splice(idx, 1);
|
||||
},
|
||||
async changeDrinkTypeName(drinkType: FG.DrinkType) {
|
||||
await api.put(`/pricelist/drink-types/${drinkType.id}`, drinkType);
|
||||
const itm = this.drinkTypes.filter((val) => val.id == drinkType.id);
|
||||
if (itm.length > 0) itm[0].name = drinkType.name;
|
||||
},
|
||||
async getExtraIngredients() {
|
||||
const { data } = await api.get<Array<FG.ExtraIngredient>>(
|
||||
'pricelist/ingredients/extraIngredients'
|
||||
);
|
||||
this.extraIngredients = data;
|
||||
},
|
||||
async setExtraIngredient(ingredient: FG.ExtraIngredient) {
|
||||
const { data } = await api.post<FG.ExtraIngredient>(
|
||||
'pricelist/ingredients/extraIngredients',
|
||||
ingredient
|
||||
);
|
||||
this.extraIngredients.push(data);
|
||||
},
|
||||
async updateExtraIngredient(ingredient: FG.ExtraIngredient) {
|
||||
const { data } = await api.put<FG.ExtraIngredient>(
|
||||
`pricelist/ingredients/extraIngredients/${ingredient.id}`,
|
||||
ingredient
|
||||
);
|
||||
const index = this.extraIngredients.findIndex((a) => a.id === ingredient.id);
|
||||
if (index > -1) {
|
||||
this.extraIngredients[index] = data;
|
||||
} else {
|
||||
this.extraIngredients.push(data);
|
||||
}
|
||||
},
|
||||
async deleteExtraIngredient(ingredient: FG.ExtraIngredient) {
|
||||
await api.delete(`pricelist/ingredients/extraIngredients/${ingredient.id}`);
|
||||
const index = this.extraIngredients.findIndex((a) => a.id === ingredient.id);
|
||||
if (index > -1) {
|
||||
this.extraIngredients.splice(index, 1);
|
||||
}
|
||||
},
|
||||
async getDrinks() {
|
||||
const { data } = await api.get<Array<FG.Drink>>('pricelist/drinks');
|
||||
this.drinks = [];
|
||||
data.forEach((drink) => {
|
||||
const _drink = new Drink(drink);
|
||||
drink.volumes.forEach((volume) => {
|
||||
const _volume = new DrinkPriceVolume(volume);
|
||||
_drink.volumes.push(_volume);
|
||||
});
|
||||
this.drinks.push(_drink);
|
||||
});
|
||||
calc_all_min_prices(this.drinks, this.min_prices);
|
||||
},
|
||||
sortPrices(volume: DrinkPriceVolume) {
|
||||
volume.prices.sort((a, b) => {
|
||||
if (a.price > b.price) return 1;
|
||||
if (b.price > a.price) return -1;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
async deletePrice(price: FG.DrinkPrice) {
|
||||
await api.delete(`pricelist/prices/${price.id}`);
|
||||
},
|
||||
async deleteVolume(volume: DrinkPriceVolume, drink: Drink) {
|
||||
await api.delete(`pricelist/volumes/${volume.id}`);
|
||||
const index = drink.volumes.findIndex((a) => a.id === volume.id);
|
||||
if (index > -1) {
|
||||
drink.volumes.splice(index, 1);
|
||||
}
|
||||
},
|
||||
async deleteIngredient(ingredient: FG.Ingredient) {
|
||||
await api.delete(`pricelist/ingredients/${ingredient.id}`);
|
||||
},
|
||||
async setDrink(drink: Drink) {
|
||||
const { data } = await api.post<FG.Drink>('pricelist/drinks', {
|
||||
...drink,
|
||||
});
|
||||
const _drink = new Drink(data);
|
||||
data.volumes.forEach((volume) => {
|
||||
const _volume = new DrinkPriceVolume(volume);
|
||||
_drink.volumes.push(_volume);
|
||||
});
|
||||
this.drinks.push(_drink);
|
||||
calc_all_min_prices(this.drinks, this.min_prices);
|
||||
return _drink;
|
||||
},
|
||||
async updateDrink(drink: Drink) {
|
||||
const { data } = await api.put<FG.Drink>(`pricelist/drinks/${drink.id}`, {
|
||||
...drink,
|
||||
});
|
||||
const index = this.drinks.findIndex((a) => a.id === data.id);
|
||||
if (index > -1) {
|
||||
const _drink = new Drink(data);
|
||||
data.volumes.forEach((volume) => {
|
||||
const _volume = new DrinkPriceVolume(volume);
|
||||
_drink.volumes.push(_volume);
|
||||
});
|
||||
this.drinks[index] = _drink;
|
||||
}
|
||||
calc_all_min_prices(this.drinks, this.min_prices);
|
||||
},
|
||||
deleteDrink(drink: Drink) {
|
||||
api
|
||||
.delete(`pricelist/drinks/${drink.id}`)
|
||||
.then(() => {
|
||||
const index = this.drinks.findIndex((a) => a.id === drink.id);
|
||||
if (index > -1) {
|
||||
this.drinks.splice(index, 1);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.warn(err));
|
||||
},
|
||||
async get_min_prices() {
|
||||
const { data } = await api.get<Array<number>>('pricelist/settings/min_prices');
|
||||
this.min_prices = data;
|
||||
},
|
||||
async set_min_prices() {
|
||||
await api.post<Array<number>>('pricelist/settings/min_prices', this.min_prices);
|
||||
calc_all_min_prices(this.drinks, this.min_prices);
|
||||
},
|
||||
async upload_drink_picture(drink: Drink, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const { data } = await api.post<FG.Drink>(`pricelist/drinks/${drink.id}/picture`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
const _drink = this.drinks.find((a) => a.id === drink.id);
|
||||
if (_drink) {
|
||||
_drink.uuid = data.uuid;
|
||||
}
|
||||
},
|
||||
async delete_drink_picture(drink: Drink) {
|
||||
await api.delete(`pricelist/drinks/${drink.id}/picture`);
|
||||
drink.uuid = '';
|
||||
},
|
||||
async getTags() {
|
||||
const { data } = await api.get<Array<FG.Tag>>('/pricelist/tags');
|
||||
this.tags = data;
|
||||
},
|
||||
async setTag(tag: FG.Tag) {
|
||||
const { data } = await api.post<FG.Tag>('/pricelist/tags', tag);
|
||||
this.tags.push(data);
|
||||
},
|
||||
async updateTag(tag: FG.Tag) {
|
||||
const { data } = await api.put<FG.Tag>(`/pricelist/tags/${tag.id}`, tag);
|
||||
const index = this.tags.findIndex((a) => a.id === data.id);
|
||||
if (index > -1) {
|
||||
this.tags[index] = data;
|
||||
}
|
||||
},
|
||||
async deleteTag(tag: FG.Tag) {
|
||||
await api.delete(`/pricelist/tags/${tag.id}`);
|
||||
const index = this.tags.findIndex((a) => a.id === tag.id);
|
||||
if (index > -1) {
|
||||
this.tags.splice(index, 1);
|
||||
}
|
||||
},
|
||||
async getPriceCalcColumn(userid: string) {
|
||||
const { data } = await api.get<Array<string>>(`pricelist/users/${userid}/pricecalc_columns`);
|
||||
this.pricecalc_columns = data;
|
||||
},
|
||||
async updatePriceCalcColumn(userid: string, data: Array<string>) {
|
||||
await api.put<Array<string>>(`pricelist/users/${userid}/pricecalc_columns`, data);
|
||||
this.pricecalc_columns = data;
|
||||
},
|
||||
async getPriceListView(userid: string) {
|
||||
const { data } = await api.get<{ value: boolean }>(`pricelist/users/${userid}/pricelist`);
|
||||
this.pricelist_view = data.value;
|
||||
},
|
||||
async updatePriceListView(userid: string, data: boolean) {
|
||||
await api.put<Array<string>>(`pricelist/users/${userid}/pricelist`, { value: data });
|
||||
this.pricelist_view = data;
|
||||
},
|
||||
async getPriceListColumnOrder(userid: string) {
|
||||
const { data } = await api.get<Array<Order>>(
|
||||
`pricelist/users/${userid}/pricecalc_columns_order`
|
||||
);
|
||||
this.pricelist_columns_order = data;
|
||||
},
|
||||
async updatePriceListColumnOrder(userid: string, data: Array<Order>) {
|
||||
await api.put<Array<string>>(`pricelist/users/${userid}/pricecalc_columns_order`, data);
|
||||
this.pricelist_columns_order = data;
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
pricelist() {
|
||||
const retVal: Array<Pricelist> = [];
|
||||
this.drinks.forEach((drink) => {
|
||||
drink.volumes.forEach((volume) => {
|
||||
volume.prices.forEach((price) => {
|
||||
retVal.push({
|
||||
name: drink.name,
|
||||
type: <FG.DrinkType>drink.type,
|
||||
tags: <Array<FG.Tag>>drink.tags,
|
||||
volume: <number>volume.volume,
|
||||
price: price.price,
|
||||
public: price.public,
|
||||
description: <string>price.description,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return retVal;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { DrinkPriceVolume, Drink, Order };
|
|
@ -1,39 +0,0 @@
|
|||
import { Drink } from '../store';
|
||||
|
||||
function filter(
|
||||
rows: Array<Drink>,
|
||||
terms: Search,
|
||||
cols: Array<Col>,
|
||||
cellValue: { (col: Col, row: Drink): string }
|
||||
) {
|
||||
if (terms.value) {
|
||||
return rows.filter((row) => {
|
||||
if (!terms.key || terms.key === '') {
|
||||
return cols.some((col) => {
|
||||
const val = cellValue(col, row) + '';
|
||||
const haystack = val === 'undefined' || val === 'null' ? '' : val.toLowerCase();
|
||||
return haystack.indexOf(terms.value) !== -1;
|
||||
});
|
||||
}
|
||||
const index = cols.findIndex((col) => col.name === terms.key);
|
||||
const val = cellValue(cols[index], row) + '';
|
||||
const haystack = val === 'undefined' || val === 'null' ? '' : val.toLowerCase();
|
||||
return haystack.indexOf(terms.value) !== -1;
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
interface Search {
|
||||
value: string;
|
||||
label: string | undefined;
|
||||
key: string | undefined;
|
||||
}
|
||||
|
||||
interface Col {
|
||||
name: string;
|
||||
label: string;
|
||||
field: string;
|
||||
}
|
||||
|
||||
export { filter, Search, Col };
|
|
@ -1,7 +0,0 @@
|
|||
function sort(a: string | number, b: string | number) {
|
||||
if (a > b) return 1;
|
||||
if (b > a) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export { sort };
|
|
@ -1,84 +0,0 @@
|
|||
import { Drink, DrinkPriceVolume, usePricelistStore } from 'src/plugins/pricelist/store';
|
||||
|
||||
function calc_volume(volume: DrinkPriceVolume) {
|
||||
if (volume.ingredients.some((ingredient) => !!ingredient.drink_ingredient)) {
|
||||
let retVal = 0;
|
||||
volume.ingredients.forEach((ingredient) => {
|
||||
if (ingredient.drink_ingredient?.volume) {
|
||||
retVal += ingredient.drink_ingredient.volume;
|
||||
}
|
||||
});
|
||||
return retVal;
|
||||
} else {
|
||||
return volume._volume;
|
||||
}
|
||||
}
|
||||
|
||||
function calc_cost_per_volume(drink: Drink) {
|
||||
let retVal = drink._cost_per_volume;
|
||||
if (!!drink.volume && !!drink.package_size && !!drink.cost_per_package) {
|
||||
retVal =
|
||||
((drink.cost_per_package || 0) / ((drink.volume || 0) * (drink.package_size || 0))) * 1.19;
|
||||
}
|
||||
|
||||
return retVal ? Math.round(retVal * 1000) / 1000 : retVal;
|
||||
}
|
||||
|
||||
function calc_all_min_prices(drinks: Array<Drink>, min_prices: Array<number>) {
|
||||
drinks.forEach((drink) => {
|
||||
drink.volumes.forEach((volume) => {
|
||||
volume.min_prices = calc_min_prices(volume, drink.cost_per_volume, min_prices);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function helper(volume: DrinkPriceVolume, min_price: number) {
|
||||
let retVal = 0;
|
||||
let extraIngredientPrice = 0;
|
||||
volume.ingredients.forEach((ingredient) => {
|
||||
if (ingredient.drink_ingredient) {
|
||||
const _drink = usePricelistStore().drinks.find(
|
||||
(a) => a.id === ingredient.drink_ingredient?.ingredient_id
|
||||
);
|
||||
retVal += ingredient.drink_ingredient.volume * <number>(<unknown>_drink?.cost_per_volume);
|
||||
}
|
||||
if (ingredient.extra_ingredient) {
|
||||
extraIngredientPrice += ingredient.extra_ingredient.price;
|
||||
}
|
||||
});
|
||||
return (retVal * min_price) / 100 + extraIngredientPrice;
|
||||
}
|
||||
|
||||
function calc_min_prices(
|
||||
volume: DrinkPriceVolume,
|
||||
cost_per_volume: number | undefined,
|
||||
min_prices: Array<number>
|
||||
) {
|
||||
const retVal: Array<FG.MinPrices> = [];
|
||||
volume.min_prices = [];
|
||||
if (min_prices) {
|
||||
min_prices.forEach((min_price) => {
|
||||
let computedMinPrice: number;
|
||||
if (cost_per_volume) {
|
||||
computedMinPrice = (cost_per_volume * <number>volume.volume * min_price) / 100;
|
||||
} else {
|
||||
computedMinPrice = helper(volume, min_price);
|
||||
}
|
||||
retVal.push({ percentage: min_price, price: computedMinPrice });
|
||||
});
|
||||
}
|
||||
return retVal;
|
||||
}
|
||||
|
||||
function clone<T>(o: T): T {
|
||||
return <T>JSON.parse(JSON.stringify(o));
|
||||
}
|
||||
|
||||
interface DeleteObjects {
|
||||
prices: Array<FG.DrinkPrice>;
|
||||
volumes: Array<DrinkPriceVolume>;
|
||||
ingredients: Array<FG.Ingredient>;
|
||||
}
|
||||
export { DeleteObjects };
|
||||
|
||||
export { calc_volume, calc_cost_per_volume, calc_all_min_prices, calc_min_prices, clone };
|
|
@ -1,25 +0,0 @@
|
|||
<template>
|
||||
<q-card class="row justify-center content-center" style="text-align: center">
|
||||
<q-card-section>
|
||||
<div class="text-h6 col-12">Dienste diesen Monat: {{ jobs }}</div>
|
||||
<!--TODO: Filters are deprecated! -->
|
||||
<!--<div class="text-h6 col-12">Nächster Dienst: {{ nextJob | date }}</div>-->
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DummyWidget',
|
||||
setup() {
|
||||
function randomNumber(start: number, end: number) {
|
||||
return start + Math.floor(Math.random() * Math.floor(end));
|
||||
}
|
||||
const jobs = randomNumber(0, 5);
|
||||
const nextJob = new Date(2021, randomNumber(1, 12), randomNumber(1, 31));
|
||||
return { jobs, nextJob };
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,245 +0,0 @@
|
|||
<template>
|
||||
<q-card>
|
||||
<q-form @submit="save()" @reset="reset">
|
||||
<q-card-section class="fit row justify-start content-center items-center">
|
||||
<div class="text-h6 col-xs-12 col-sm-6 q-pa-sm">
|
||||
Veranstaltung <template v-if="modelValue">bearbeiten</template
|
||||
><template v-else>erstellen</template>
|
||||
</div>
|
||||
<q-select
|
||||
:model-value="template"
|
||||
filled
|
||||
label="Vorlage"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
:options="templates"
|
||||
option-label="name"
|
||||
map-options
|
||||
clearable
|
||||
:disable="templates.length == 0"
|
||||
@update:modelValue="fromTemplate"
|
||||
@clear="reset()"
|
||||
/>
|
||||
<q-input
|
||||
v-model="event.name"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Name"
|
||||
type="text"
|
||||
filled
|
||||
/>
|
||||
<q-select
|
||||
v-model="event.type"
|
||||
filled
|
||||
use-input
|
||||
label="Veranstaltungstyp"
|
||||
input-debounce="0"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
:options="eventtypes"
|
||||
option-label="name"
|
||||
option-value="id"
|
||||
emit-value
|
||||
map-options
|
||||
clearable
|
||||
:rules="[notEmpty]"
|
||||
/>
|
||||
<IsoDateInput
|
||||
v-model="event.start"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Veranstaltungsbeginn"
|
||||
:rules="[notEmpty]"
|
||||
/>
|
||||
<IsoDateInput
|
||||
v-model="event.end"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Veranstaltungsende"
|
||||
/>
|
||||
<q-input
|
||||
v-model="event.description"
|
||||
class="col-12 q-pa-sm"
|
||||
label="Beschreibung"
|
||||
type="textarea"
|
||||
filled
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="event.template_id === undefined && modelValue === undefined">
|
||||
<q-btn-toggle
|
||||
v-model="recurrent"
|
||||
spread
|
||||
no-caps
|
||||
:options="[
|
||||
{ label: 'Einmalig', value: false },
|
||||
{ label: 'Wiederkehrend', value: true },
|
||||
]"
|
||||
/>
|
||||
<RecurrenceRule v-if="!!recurrent" v-model="recurrenceRule" />
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section>
|
||||
<q-btn color="primary" label="Schicht hinzufügen" @click="addJob()" />
|
||||
</q-card-section>
|
||||
<q-card-section v-for="(job, index) in event.jobs" :key="index">
|
||||
<q-card class="q-my-auto">
|
||||
<job
|
||||
v-model="event.jobs[index]"
|
||||
:job-can-delete="jobDeleteDisabled"
|
||||
@remove-job="removeJob(index)"
|
||||
/>
|
||||
</q-card>
|
||||
</q-card-section>
|
||||
<q-card-actions align="around">
|
||||
<q-card-actions align="left">
|
||||
<q-btn v-if="!template" color="secondary" label="Neue Vorlage" @click="save(true)" />
|
||||
<q-btn v-else color="negative" label="Vorlage löschen" @click="removeTemplate" />
|
||||
</q-card-actions>
|
||||
<q-card-actions align="right">
|
||||
<q-btn label="Zurücksetzen" type="reset" />
|
||||
<q-btn color="primary" type="submit" label="Speichern" />
|
||||
</q-card-actions>
|
||||
</q-card-actions>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType, ref, onBeforeMount } from 'vue';
|
||||
import { date, ModifyDateOptions } from 'quasar';
|
||||
import { useScheduleStore } from '../../store';
|
||||
import { notEmpty } from 'src/utils/validators';
|
||||
import IsoDateInput from 'src/components/utils/IsoDateInput.vue';
|
||||
import Job from './Job.vue';
|
||||
import RecurrenceRule from './RecurrenceRule.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EditEvent',
|
||||
components: { IsoDateInput, Job, RecurrenceRule },
|
||||
props: {
|
||||
modelValue: {
|
||||
required: false,
|
||||
default: () => undefined,
|
||||
type: Object as PropType<FG.Event | undefined>,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
done: (val: boolean) => typeof val === 'boolean',
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const store = useScheduleStore();
|
||||
|
||||
const emptyJob = {
|
||||
id: NaN,
|
||||
start: new Date(),
|
||||
end: date.addToDate(new Date(), { hours: 1 }),
|
||||
services: [],
|
||||
required_services: 2,
|
||||
type: store.jobTypes[0],
|
||||
};
|
||||
|
||||
const emptyEvent = {
|
||||
id: NaN,
|
||||
start: new Date(),
|
||||
jobs: [Object.assign({}, emptyJob)],
|
||||
type: store.eventTypes[0],
|
||||
is_template: false,
|
||||
};
|
||||
|
||||
const templates = computed(() => store.templates);
|
||||
const template = ref<FG.Event | undefined>(undefined);
|
||||
const event = ref<FG.Event>(props.modelValue || Object.assign({}, emptyEvent));
|
||||
const eventtypes = computed(() => store.eventTypes);
|
||||
const jobDeleteDisabled = computed(() => event.value.jobs.length < 2);
|
||||
const recurrent = ref(false);
|
||||
const recurrenceRule = ref<FG.RecurrenceRule>({ frequency: 'daily', interval: 1 });
|
||||
|
||||
onBeforeMount(() => {
|
||||
void store.getEventTypes();
|
||||
void store.getJobTypes();
|
||||
void store.getTemplates();
|
||||
});
|
||||
|
||||
function addJob() {
|
||||
event.value.jobs.push(Object.assign({}, emptyJob));
|
||||
}
|
||||
|
||||
function removeJob(index: number) {
|
||||
event.value.jobs.splice(index, 1);
|
||||
}
|
||||
|
||||
function fromTemplate(tpl: FG.Event) {
|
||||
template.value = tpl;
|
||||
event.value = Object.assign({}, tpl);
|
||||
}
|
||||
|
||||
async function save(template = false) {
|
||||
event.value.is_template = template;
|
||||
try {
|
||||
await store.addEvent(event.value);
|
||||
if (props.modelValue === undefined && recurrent.value && !event.value.is_template) {
|
||||
let count = 0;
|
||||
const options: ModifyDateOptions = {};
|
||||
switch (recurrenceRule.value.frequency) {
|
||||
case 'daily':
|
||||
options['days'] = 1 * recurrenceRule.value.interval;
|
||||
break;
|
||||
case 'weekly':
|
||||
options['days'] = 7 * recurrenceRule.value.interval;
|
||||
break;
|
||||
case 'monthly':
|
||||
options['months'] = 1 * recurrenceRule.value.interval;
|
||||
break;
|
||||
}
|
||||
while (true) {
|
||||
event.value.start = date.addToDate(event.value.start, options);
|
||||
if (event.value.end) event.value.end = date.addToDate(event.value.end, options);
|
||||
event.value.jobs.forEach((job) => {
|
||||
job.start = date.addToDate(job.start, options);
|
||||
if (job.end) job.end = date.addToDate(job.end, options);
|
||||
});
|
||||
count++;
|
||||
if (
|
||||
count <= 120 &&
|
||||
(!recurrenceRule.value.count || count <= recurrenceRule.value.count) &&
|
||||
(!recurrenceRule.value.until || event.value.start < recurrenceRule.value.until)
|
||||
)
|
||||
await store.addEvent(event.value);
|
||||
else break;
|
||||
}
|
||||
}
|
||||
reset();
|
||||
emit('done', true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTemplate() {
|
||||
if (template.value !== undefined) {
|
||||
await store.removeEvent(template.value.id);
|
||||
template.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
event.value = Object.assign({}, props.modelValue || emptyEvent);
|
||||
template.value = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
jobDeleteDisabled,
|
||||
addJob,
|
||||
eventtypes,
|
||||
templates,
|
||||
removeJob,
|
||||
notEmpty,
|
||||
save,
|
||||
reset,
|
||||
recurrent,
|
||||
fromTemplate,
|
||||
removeTemplate,
|
||||
template,
|
||||
recurrenceRule,
|
||||
event,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style></style>
|
|
@ -1,126 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<q-dialog v-model="edittype">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Editere Diensttyp {{ actualEvent.name }}</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input v-model="newEventName" dense label="name" filled />
|
||||
</q-card-section>
|
||||
<q-card-actions>
|
||||
<q-btn flat color="danger" label="Abbrechen" @click="discardChanges()" />
|
||||
<q-btn flat color="primary" label="Speichern" @click="saveChanges()" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-table title="Veranstaltungstypen" :rows="rows" row-key="jobid" :columns="columns">
|
||||
<template #top-right>
|
||||
<q-input v-model="newEventType" dense placeholder="Neuer Typ" />
|
||||
|
||||
<div></div>
|
||||
<q-btn color="primary" icon="mdi-plus" label="Hinzufügen" @click="addType" />
|
||||
</template>
|
||||
<template #body-cell-actions="props">
|
||||
<!-- <q-btn :label="item"> -->
|
||||
<!-- {{ item.row.name }} -->
|
||||
<q-td :props="props" align="right" :auto-width="true">
|
||||
<q-btn
|
||||
round
|
||||
icon="mdi-pencil"
|
||||
@click="editType({ id: props.row.id, name: props.row.name })"
|
||||
/>
|
||||
<q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" />
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed, onBeforeMount } from 'vue';
|
||||
import { useScheduleStore } from '../../store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EventTypes',
|
||||
components: {},
|
||||
setup() {
|
||||
const store = useScheduleStore();
|
||||
const newEventType = ref('');
|
||||
const edittype = ref(false);
|
||||
const emptyEvent: FG.EventType = { id: -1, name: '' };
|
||||
const actualEvent = ref(emptyEvent);
|
||||
const newEventName = ref('');
|
||||
|
||||
onBeforeMount(async () => await store.getEventTypes());
|
||||
|
||||
const rows = computed(() => store.eventTypes);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Veranstaltungstyp',
|
||||
field: 'name',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: 'Aktionen',
|
||||
field: 'actions',
|
||||
align: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
async function addType() {
|
||||
await store.addEventType(newEventType.value);
|
||||
// if null then conflict with name
|
||||
newEventType.value = '';
|
||||
}
|
||||
|
||||
function editType(event: FG.EventType) {
|
||||
edittype.value = true;
|
||||
actualEvent.value = event;
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
try {
|
||||
await store.renameEventType(actualEvent.value.id, newEventName.value);
|
||||
} finally {
|
||||
discardChanges();
|
||||
}
|
||||
}
|
||||
|
||||
function discardChanges() {
|
||||
actualEvent.value = emptyEvent;
|
||||
newEventName.value = '';
|
||||
edittype.value = false;
|
||||
}
|
||||
|
||||
async function deleteType(id: number) {
|
||||
await store.removeEventType(id);
|
||||
}
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
addType,
|
||||
newEventType,
|
||||
deleteType,
|
||||
edittype,
|
||||
editType,
|
||||
actualEvent,
|
||||
newEventName,
|
||||
discardChanges,
|
||||
saveChanges,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,113 +0,0 @@
|
|||
<template>
|
||||
<q-card-section class="fit row justify-start content-center items-center">
|
||||
<q-card-section class="fit row justify-start content-center items-center">
|
||||
<IsoDateInput
|
||||
v-model="job.start"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Beginn"
|
||||
type="datetime"
|
||||
:rules="[notEmpty]"
|
||||
/>
|
||||
<IsoDateInput
|
||||
v-model="job.end"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Ende"
|
||||
type="datetime"
|
||||
:rules="[notEmpty, isAfterDate]"
|
||||
/>
|
||||
<q-select
|
||||
v-model="job.type"
|
||||
filled
|
||||
use-input
|
||||
label="Dienstart"
|
||||
input-debounce="0"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
:options="jobtypes"
|
||||
option-label="name"
|
||||
option-value="id"
|
||||
map-options
|
||||
clearable
|
||||
:rules="[notEmpty]"
|
||||
/>
|
||||
<q-input
|
||||
v-model="job.required_services"
|
||||
filled
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Dienstanzahl"
|
||||
type="number"
|
||||
:rules="[notEmpty]"
|
||||
/>
|
||||
<q-input
|
||||
v-model="job.comment"
|
||||
class="col-12 q-pa-sm"
|
||||
label="Beschreibung"
|
||||
type="textarea"
|
||||
filled
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-btn label="Schicht löschen" color="negative" :disabled="jobCanDelete" @click="removeJob" />
|
||||
</q-card-section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, onBeforeMount, PropType } from 'vue';
|
||||
import IsoDateInput from 'src/components/utils/IsoDateInput.vue';
|
||||
import { notEmpty } from 'src/utils/validators';
|
||||
import { useScheduleStore } from '../../store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Job',
|
||||
components: { IsoDateInput },
|
||||
props: {
|
||||
modelValue: {
|
||||
required: true,
|
||||
type: Object as PropType<FG.Job>,
|
||||
},
|
||||
jobCanDelete: Boolean,
|
||||
},
|
||||
emits: {
|
||||
'remove-job': () => true,
|
||||
'update:modelValue': (job: FG.Job) => !!job,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const store = useScheduleStore();
|
||||
|
||||
onBeforeMount(() => store.getJobTypes());
|
||||
|
||||
const jobtypes = computed(() => store.jobTypes);
|
||||
|
||||
const job = new Proxy(props.modelValue, {
|
||||
get(target, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
return ((props.modelValue as unknown) as Record<string, unknown>)[prop];
|
||||
}
|
||||
},
|
||||
set(obj, prop, value) {
|
||||
if (typeof prop === 'string') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
emit('update:modelValue', Object.assign({}, props.modelValue, { [prop]: value }));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
function removeJob() {
|
||||
emit('remove-job');
|
||||
}
|
||||
|
||||
function isAfterDate(val: string) {
|
||||
return props.modelValue.start < new Date(val) || 'Ende muss hinter dem Start liegen';
|
||||
}
|
||||
|
||||
return {
|
||||
job,
|
||||
jobtypes,
|
||||
removeJob,
|
||||
notEmpty,
|
||||
isAfterDate,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style></style>
|
|
@ -1,125 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<q-dialog v-model="edittype">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Editere Diensttyp {{ actualJob.name }}</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input v-model="newJobName" dense label="name" filled />
|
||||
</q-card-section>
|
||||
<q-card-actions>
|
||||
<q-btn flat color="danger" label="Abbrechen" @click="discardChanges()" />
|
||||
<q-btn flat color="primary" label="Speichern" @click="saveChanges()" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-table title="Diensttypen" :rows="rows" row-key="jobid" :columns="columns">
|
||||
<template #top-right>
|
||||
<q-input v-model="newJob" dense placeholder="Neuer Typ" />
|
||||
|
||||
<div></div>
|
||||
<q-btn color="primary" icon="mdi-plus" label="Hinzufügen" @click="addType" />
|
||||
</template>
|
||||
<template #body-cell-actions="props">
|
||||
<!-- <q-btn :label="item"> -->
|
||||
<!-- {{ item.row.name }} -->
|
||||
<q-td :props="props" align="right" :auto-width="true">
|
||||
<q-btn
|
||||
round
|
||||
icon="mdi-pencil"
|
||||
@click="editType({ id: props.row.id, name: props.row.name })"
|
||||
/>
|
||||
<q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" />
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed, onBeforeMount } from 'vue';
|
||||
import { useScheduleStore } from '../../store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JobTypes',
|
||||
components: {},
|
||||
setup() {
|
||||
const store = useScheduleStore();
|
||||
const newJob = ref('');
|
||||
const edittype = ref(false);
|
||||
const emptyJob: FG.JobType = { id: -1, name: '' };
|
||||
const actualJob = ref(emptyJob);
|
||||
const newJobName = ref('');
|
||||
|
||||
onBeforeMount(() => store.getJobTypes());
|
||||
|
||||
const rows = computed(() => store.jobTypes);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'jobname',
|
||||
label: 'Name',
|
||||
field: 'name',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: 'Aktionen',
|
||||
field: 'actions',
|
||||
align: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
async function addType() {
|
||||
await store.addJobType(newJob.value);
|
||||
newJob.value = '';
|
||||
}
|
||||
|
||||
function editType(job: FG.JobType) {
|
||||
edittype.value = true;
|
||||
actualJob.value = job;
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
try {
|
||||
await store.renameJobType(actualJob.value.id, newJobName.value);
|
||||
} finally {
|
||||
discardChanges();
|
||||
}
|
||||
}
|
||||
|
||||
function discardChanges() {
|
||||
actualJob.value = emptyJob;
|
||||
newJobName.value = '';
|
||||
edittype.value = false;
|
||||
}
|
||||
|
||||
function deleteType(id: number) {
|
||||
void store.removeJobType(id);
|
||||
}
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
addType,
|
||||
newJob,
|
||||
deleteType,
|
||||
edittype,
|
||||
editType,
|
||||
actualJob,
|
||||
newJobName,
|
||||
discardChanges,
|
||||
saveChanges,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,96 +0,0 @@
|
|||
<template>
|
||||
<q-card class="fit row justify-start content-center items-center">
|
||||
<q-input
|
||||
v-model="rule.interval"
|
||||
filled
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Interval"
|
||||
type="number"
|
||||
:rules="[notEmpty]"
|
||||
/>
|
||||
<q-select
|
||||
v-model="rule.frequency"
|
||||
filled
|
||||
label="Wiederholung"
|
||||
input-debounce="200"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
:options="freqTypes"
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
<q-input
|
||||
v-model="rule.count"
|
||||
filled
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Anzahl Wiederholungen"
|
||||
type="number"
|
||||
/>
|
||||
<IsoDateInput
|
||||
v-model="rule.until"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Wiederholen bis"
|
||||
type="date"
|
||||
/>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import IsoDateInput from 'src/components/utils/IsoDateInput.vue';
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
import { notEmpty } from 'src/utils/validators';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'RecurrenceRule',
|
||||
components: { IsoDateInput },
|
||||
props: {
|
||||
modelValue: {
|
||||
required: true,
|
||||
type: Object as PropType<FG.RecurrenceRule>,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
'update:modelValue': (rule: FG.RecurrenceRule) => !!rule,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const freqTypes = [
|
||||
{ label: 'Täglich', value: 'daily' },
|
||||
{ label: 'Wöchentlich', value: 'weekly' },
|
||||
{ label: 'Monatlich', value: 'monthly' },
|
||||
{ label: 'Jährlich', value: 'yearly' },
|
||||
];
|
||||
|
||||
const rule = new Proxy(props.modelValue, {
|
||||
get(target, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
return ((props.modelValue as unknown) as Record<string, unknown>)[prop];
|
||||
}
|
||||
},
|
||||
set(target, prop, value) {
|
||||
if (typeof prop === 'string') {
|
||||
const obj = Object.assign({}, props.modelValue);
|
||||
if (prop == 'frequency' && typeof value === 'string') obj.frequency = value;
|
||||
else if (prop == 'interval') {
|
||||
obj.interval = typeof value === 'string' ? parseInt(value) : <number>value;
|
||||
} else if (prop == 'count') {
|
||||
obj.until = undefined;
|
||||
obj.count = typeof value === 'string' ? parseInt(value) : <number>value;
|
||||
} else if (prop == 'until' && (value instanceof Date || value === undefined)) {
|
||||
obj.count = undefined;
|
||||
obj.until = <Date | undefined>value;
|
||||
} else return false;
|
||||
emit('update:modelValue', obj);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
rule,
|
||||
notEmpty,
|
||||
freqTypes,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style></style>
|
|
@ -1,241 +0,0 @@
|
|||
<template>
|
||||
<q-dialog
|
||||
:model-value="editor !== undefined"
|
||||
persistent
|
||||
transition-show="scale"
|
||||
transition-hide="scale"
|
||||
>
|
||||
<q-card>
|
||||
<div class="column">
|
||||
<div class="col" align="right" style="position: sticky; top: 0; z-index: 999">
|
||||
<q-btn round color="negative" icon="close" dense rounded @click="editDone(false)" />
|
||||
</div>
|
||||
<div class="col" style="margin: 0; padding: 0; margin-top: -2.4em">
|
||||
<edit-event v-model="editor" @done="editDone" />
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-page padding>
|
||||
<q-card>
|
||||
<div style="max-width: 1800px; width: 100%">
|
||||
<q-toolbar class="bg-primary text-white q-my-md shadow-2 items-center row justify-center">
|
||||
<div class="row justify-center items-center">
|
||||
<q-btn flat dense label="Prev" @click="calendarPrev" />
|
||||
<q-separator vertical />
|
||||
<q-btn flat dense
|
||||
>{{ asMonth(selectedDate) }} {{ asYear(selectedDate) }}
|
||||
<q-popup-proxy
|
||||
transition-show="scale"
|
||||
transition-hide="scale"
|
||||
@before-show="updateProxy"
|
||||
>
|
||||
<q-date v-model="proxyDate">
|
||||
<div class="row items-center justify-end q-gutter-sm">
|
||||
<q-btn v-close-popup label="Cancel" color="primary" flat />
|
||||
<q-btn
|
||||
v-close-popup
|
||||
label="OK"
|
||||
color="primary"
|
||||
flat
|
||||
@click="saveNewSelectedDate(proxyDate)"
|
||||
/>
|
||||
</div>
|
||||
</q-date>
|
||||
</q-popup-proxy>
|
||||
</q-btn>
|
||||
<q-separator vertical />
|
||||
<q-btn flat dense label="Next" @click="calendarNext" />
|
||||
</div>
|
||||
<!-- <q-space /> -->
|
||||
|
||||
<q-btn-toggle
|
||||
v-model="calendarView"
|
||||
class="row absolute-right"
|
||||
flat
|
||||
stretch
|
||||
toggle-color=""
|
||||
:options="[
|
||||
{ label: 'Tag', value: 'day' },
|
||||
{ label: 'Woche', value: 'week' },
|
||||
]"
|
||||
/>
|
||||
</q-toolbar>
|
||||
<q-calendar-agenda
|
||||
v-model="selectedDate"
|
||||
:view="calendarRealView"
|
||||
:max-days="calendarDays"
|
||||
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||
locale="de-de"
|
||||
style="height: 100%; min-height: 400px"
|
||||
>
|
||||
<template #day="{ scope: { timestamp } }">
|
||||
<div itemref="" class="q-pb-sm" style="min-height: 200px">
|
||||
<eventslot
|
||||
v-for="(agenda, index) in events[timestamp.weekday]"
|
||||
:key="index"
|
||||
v-model="events[timestamp.weekday][index]"
|
||||
@removeEvent="remove"
|
||||
@editEvent="edit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</q-calendar-agenda>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onBeforeMount, ref } from 'vue';
|
||||
import { useScheduleStore } from '../../store';
|
||||
import Eventslot from './slots/EventSlot.vue';
|
||||
import { date } from 'quasar';
|
||||
import { startOfWeek } from 'src/utils/datetime';
|
||||
import EditEvent from '../management/EditEvent.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AgendaView',
|
||||
components: { Eventslot, EditEvent },
|
||||
|
||||
setup() {
|
||||
const store = useScheduleStore();
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
const selectedDate = ref(date.formatDate(new Date(), 'YYYY-MM-DD'));
|
||||
const proxyDate = ref('');
|
||||
const calendarView = ref('week');
|
||||
|
||||
const calendarRealView = computed(() => (calendarDays.value != 7 ? 'day' : 'week'));
|
||||
const calendarDays = computed(() =>
|
||||
// <= 1023 is the breakpoint for sm to md
|
||||
calendarView.value == 'day' ? 1 : windowWidth.value <= 1023 ? 3 : 7
|
||||
);
|
||||
const events = ref<Agendas>({});
|
||||
const editor = ref<FG.Event | undefined>(undefined);
|
||||
|
||||
interface Agendas {
|
||||
[index: number]: FG.Event[];
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
window.addEventListener('resize', () => {
|
||||
windowWidth.value = window.innerWidth;
|
||||
});
|
||||
|
||||
await loadAgendas();
|
||||
});
|
||||
|
||||
async function edit(id: number) {
|
||||
editor.value = await store.getEvent(id);
|
||||
}
|
||||
function editDone(changed: boolean) {
|
||||
if (changed) void loadAgendas();
|
||||
editor.value = undefined;
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (await store.removeEvent(id)) {
|
||||
// Successfull removed
|
||||
for (const idx in events.value) {
|
||||
const i = events.value[idx].findIndex((event) => event.id === id);
|
||||
if (i !== -1) {
|
||||
events.value[idx].splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not found, this means our eventa are outdated
|
||||
await loadAgendas();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgendas() {
|
||||
const selected = new Date(selectedDate.value);
|
||||
console.log(selected);
|
||||
const start = calendarRealView.value === 'day' ? selected : startOfWeek(selected);
|
||||
const end = date.addToDate(start, { days: calendarDays.value });
|
||||
|
||||
events.value = {};
|
||||
const list = await store.getEvents({ from: start, to: end });
|
||||
list.forEach((event) => {
|
||||
const day = event.start.getDay();
|
||||
|
||||
if (!events.value[day]) {
|
||||
events.value[day] = [];
|
||||
}
|
||||
events.value[day].push(event);
|
||||
});
|
||||
}
|
||||
|
||||
function calendarNext() {
|
||||
selectedDate.value = date.formatDate(
|
||||
date.addToDate(selectedDate.value, { days: calendarDays.value }),
|
||||
'YYYY-MM-DD'
|
||||
);
|
||||
void loadAgendas();
|
||||
}
|
||||
|
||||
function calendarPrev() {
|
||||
selectedDate.value = date.formatDate(
|
||||
date.subtractFromDate(selectedDate.value, { days: calendarDays.value }),
|
||||
'YYYY-MM-DD'
|
||||
);
|
||||
void loadAgendas();
|
||||
}
|
||||
|
||||
function updateProxy() {
|
||||
proxyDate.value = selectedDate.value;
|
||||
}
|
||||
function saveNewSelectedDate() {
|
||||
proxyDate.value = date.formatDate(proxyDate.value, 'YYYY-MM-DD');
|
||||
selectedDate.value = proxyDate.value;
|
||||
}
|
||||
function asMonth(value: string) {
|
||||
if (value) {
|
||||
return date.formatDate(new Date(value), 'MMMM', {
|
||||
months: [
|
||||
'Januar',
|
||||
'Februar',
|
||||
'März',
|
||||
'April',
|
||||
'Mai',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'August',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Dezember',
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
function asYear(value: string) {
|
||||
if (value) {
|
||||
return date.formatDate(new Date(value), 'YYYY');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
asYear,
|
||||
asMonth,
|
||||
selectedDate,
|
||||
edit,
|
||||
editor,
|
||||
editDone,
|
||||
events,
|
||||
calendarNext,
|
||||
calendarPrev,
|
||||
updateProxy,
|
||||
saveNewSelectedDate,
|
||||
proxyDate,
|
||||
remove,
|
||||
calendarDays,
|
||||
calendarView,
|
||||
calendarRealView,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style></style>
|
|
@ -1,99 +0,0 @@
|
|||
<template>
|
||||
<q-card
|
||||
class="q-mx-xs q-mt-sm justify-start content-center items-center rounded-borders shadow-5"
|
||||
bordered
|
||||
>
|
||||
<q-card-section class="text-primary q-pa-xs">
|
||||
<div class="text-weight-bolder text-center" style="font-size: 1.5vw">
|
||||
{{ event.type.name }}
|
||||
<template v-if="event.name"
|
||||
>: <span style="font-size: 1.2vw">{{ event.name }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="event.description" class="text-weight-medium" style="font-size: 1vw">
|
||||
{{ event.description }}
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section class="q-pa-xs">
|
||||
<!-- Jobs -->
|
||||
<JobSlot
|
||||
v-for="(job, index) in event.jobs"
|
||||
:key="index"
|
||||
v-model="event.jobs[index]"
|
||||
class="col q-my-xs"
|
||||
:event-id="event.id"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-actions v-if="canEdit || canDelete" vertical align="center">
|
||||
<q-btn
|
||||
v-if="canEdit"
|
||||
color="secondary"
|
||||
flat
|
||||
label="Bearbeiten"
|
||||
style="min-width: 95%"
|
||||
@click="edit"
|
||||
/>
|
||||
<q-btn
|
||||
v-if="canDelete"
|
||||
color="negative"
|
||||
flat
|
||||
label="Löschen"
|
||||
style="min-width: 95%"
|
||||
@click="remove"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, PropType } from 'vue';
|
||||
import { hasPermission } from 'src/utils/permission';
|
||||
import { PERMISSIONS } from 'src/plugins/schedule/permissions';
|
||||
import JobSlot from './JobSlot.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Eventslot',
|
||||
components: { JobSlot },
|
||||
props: {
|
||||
modelValue: {
|
||||
required: true,
|
||||
type: Object as PropType<FG.Event>,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
'update:modelValue': (val: FG.Event) => !!val,
|
||||
removeEvent: (val: number) => typeof val === 'number',
|
||||
editEvent: (val: number) => !!val,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const canDelete = computed(() => hasPermission(PERMISSIONS.DELETE));
|
||||
const canEdit = computed(
|
||||
() =>
|
||||
hasPermission(PERMISSIONS.EDIT) &&
|
||||
(props.modelValue?.end || props.modelValue.start) > new Date()
|
||||
);
|
||||
const event = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
});
|
||||
|
||||
function remove() {
|
||||
emit('removeEvent', props.modelValue.id);
|
||||
}
|
||||
function edit() {
|
||||
emit('editEvent', props.modelValue.id);
|
||||
}
|
||||
|
||||
return {
|
||||
canDelete,
|
||||
canEdit,
|
||||
edit,
|
||||
event,
|
||||
remove,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,142 +0,0 @@
|
|||
<template>
|
||||
<q-card bordered>
|
||||
<div class="text-weight-medium q-px-xs">
|
||||
{{ asHour(modelValue.start) }}
|
||||
<template v-if="modelValue.end">- {{ asHour(modelValue.end) }}</template>
|
||||
</div>
|
||||
<div class="q-px-xs">
|
||||
{{ modelValue.type.name }}
|
||||
</div>
|
||||
<div class="col-auto q-px-xs" style="font-size: 10px">
|
||||
{{ modelValue.comment }}
|
||||
</div>
|
||||
<div>
|
||||
<q-select
|
||||
:model-value="modelValue.services"
|
||||
filled
|
||||
:option-label="(opt) => userDisplay(opt)"
|
||||
multiple
|
||||
disable
|
||||
use-chips
|
||||
stack-label
|
||||
label="Dienste"
|
||||
class="col-auto q-px-xs"
|
||||
style="font-size: 6px"
|
||||
counter
|
||||
:max-values="modelValue.required_services"
|
||||
>
|
||||
</q-select>
|
||||
<div class="row col-12 justify-end">
|
||||
<q-btn v-if="canEnroll" flat color="primary" label="Eintragen" @click="enrollForJob" />
|
||||
<q-btn v-if="isEnrolled" flat color="negative" label="Austragen" @click="signOutFromJob" />
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onBeforeMount, computed, PropType } from 'vue';
|
||||
import { Notify } from 'quasar';
|
||||
import { asHour } from 'src/utils/datetime';
|
||||
import { useUserStore } from 'src/plugins/user/store';
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { useScheduleStore } from 'src/plugins/schedule/store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JobSlot',
|
||||
props: {
|
||||
modelValue: {
|
||||
required: true,
|
||||
type: Object as PropType<FG.Job>,
|
||||
},
|
||||
eventId: {
|
||||
required: true,
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
emits: { 'update:modelValue': (v: FG.Job) => !!v },
|
||||
setup(props, { emit }) {
|
||||
const store = useScheduleStore();
|
||||
const mainStore = useMainStore();
|
||||
const userStore = useUserStore();
|
||||
const availableUsers = null;
|
||||
|
||||
onBeforeMount(async () => userStore.getUsers());
|
||||
|
||||
function userDisplay(service: FG.Service) {
|
||||
return userStore.findUser(service.userid)?.display_name || service.userid;
|
||||
}
|
||||
|
||||
const isEnrolled = computed(
|
||||
() =>
|
||||
props.modelValue.services.findIndex(
|
||||
(service) => service.userid == mainStore.currentUser.userid
|
||||
) !== -1
|
||||
);
|
||||
|
||||
const canEnroll = computed(() => {
|
||||
const is = isEnrolled.value;
|
||||
let sum = 0;
|
||||
props.modelValue.services.forEach((s) => (sum += s.value));
|
||||
return sum < props.modelValue.required_services && !is;
|
||||
});
|
||||
|
||||
async function enrollForJob() {
|
||||
const newService: FG.Service = {
|
||||
userid: mainStore.currentUser.userid,
|
||||
is_backup: false,
|
||||
value: 1,
|
||||
};
|
||||
try {
|
||||
const job = await store.updateJob(props.eventId, props.modelValue.id, { user: newService });
|
||||
emit('update:modelValue', job);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
Notify.create({
|
||||
group: false,
|
||||
type: 'negative',
|
||||
message: 'Fehler beim Eintragen als Dienst',
|
||||
timeout: 10000,
|
||||
progress: true,
|
||||
actions: [{ icon: 'mdi-close', color: 'white' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
async function signOutFromJob() {
|
||||
const newService: FG.Service = {
|
||||
userid: mainStore.currentUser.userid,
|
||||
is_backup: false,
|
||||
value: -1,
|
||||
};
|
||||
try {
|
||||
const job = await store.updateJob(props.eventId, props.modelValue.id, {
|
||||
user: newService,
|
||||
});
|
||||
emit('update:modelValue', job);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
Notify.create({
|
||||
group: false,
|
||||
type: 'negative',
|
||||
message: 'Fehler beim Austragen als Dienst',
|
||||
timeout: 10000,
|
||||
progress: true,
|
||||
actions: [{ icon: 'mdi-close', color: 'white' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
availableUsers,
|
||||
enrollForJob,
|
||||
isEnrolled,
|
||||
signOutFromJob,
|
||||
canEnroll,
|
||||
userDisplay,
|
||||
asHour,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,9 +0,0 @@
|
|||
declare namespace FG {
|
||||
export interface RecurrenceRule {
|
||||
frequency: string;
|
||||
interval: number;
|
||||
count?: number;
|
||||
until?: Date;
|
||||
weekdays?: Array<number>;
|
||||
}
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
<template>
|
||||
<q-page padding class="fit row justify-center content-start items-start q-gutter-sm">
|
||||
<EditEvent v-model="event" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { onBeforeMount, defineComponent, ref } from 'vue';
|
||||
import EditEvent from '../components/management/EditEvent.vue';
|
||||
import { useScheduleStore } from '../store';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
export default defineComponent({
|
||||
components: { EditEvent },
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const store = useScheduleStore();
|
||||
const event = ref<FG.Event | undefined>(undefined);
|
||||
onBeforeMount(async () => {
|
||||
if ('id' in route.params && typeof route.params.id === 'string')
|
||||
event.value = await store.getEvent(parseInt(route.params.id));
|
||||
});
|
||||
|
||||
return {
|
||||
event,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,95 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<q-tabs v-if="$q.screen.gt.sm" v-model="tab">
|
||||
<q-tab
|
||||
v-for="(tabindex, index) in tabs"
|
||||
:key="'tab' + index"
|
||||
:name="tabindex.name"
|
||||
:label="tabindex.label"
|
||||
/>
|
||||
</q-tabs>
|
||||
<div v-else class="fit row justify-end">
|
||||
<q-btn flat round icon="mdi-menu" @click="showDrawer = !showDrawer" />
|
||||
</div>
|
||||
<q-drawer v-model="showDrawer" side="right" behavior="mobile" @click="showDrawer = !showDrawer">
|
||||
<q-list v-model="tab">
|
||||
<q-item
|
||||
v-for="(tabindex, index) in tabs"
|
||||
:key="'tab' + index"
|
||||
:active="tab == tabindex.name"
|
||||
clickable
|
||||
@click="tab = tabindex.name"
|
||||
>
|
||||
<q-item-label>{{ tabindex.label }}</q-item-label>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-drawer>
|
||||
<q-page padding class="fit row justify-center content-start items-start q-gutter-sm">
|
||||
<q-tab-panels
|
||||
v-model="tab"
|
||||
style="background-color: transparent"
|
||||
class="q-ma-none q-pa-none fit row justify-center content-start items-start"
|
||||
animated
|
||||
>
|
||||
<q-tab-panel name="create">
|
||||
<EditEvent />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="eventtypes">
|
||||
<EventTypes />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="jobtypes">
|
||||
<JobTypes v-if="canEditJobTypes" />
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-page>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import EventTypes from '../components/management/EventTypes.vue';
|
||||
import JobTypes from '../components/management/JobTypes.vue';
|
||||
import EditEvent from '../components/management/EditEvent.vue';
|
||||
import { hasPermission } from 'src/utils/permission';
|
||||
import { PERMISSIONS } from '../permissions';
|
||||
import { Screen } from 'quasar';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EventManagement',
|
||||
components: { EditEvent, EventTypes, JobTypes },
|
||||
setup() {
|
||||
const canEditJobTypes = computed(() => hasPermission(PERMISSIONS.JOB_TYPE));
|
||||
|
||||
interface Tab {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ name: 'create', label: 'Veranstaltungen' },
|
||||
{ name: 'eventtypes', label: 'Veranstaltungsarten' },
|
||||
{ name: 'jobtypes', label: 'Dienstarten' },
|
||||
];
|
||||
|
||||
const drawer = ref<boolean>(false);
|
||||
|
||||
const showDrawer = computed({
|
||||
get: () => {
|
||||
return !Screen.gt.sm && drawer.value;
|
||||
},
|
||||
set: (val: boolean) => {
|
||||
drawer.value = val;
|
||||
},
|
||||
});
|
||||
|
||||
const tab = ref<string>('create');
|
||||
|
||||
return {
|
||||
canEditJobTypes,
|
||||
showDrawer,
|
||||
tab,
|
||||
tabs,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,87 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<q-tabs v-if="$q.screen.gt.sm" v-model="tab">
|
||||
<q-tab
|
||||
v-for="(tabindex, index) in tabs"
|
||||
:key="'tab' + index"
|
||||
:name="tabindex.name"
|
||||
:label="tabindex.label"
|
||||
/>
|
||||
</q-tabs>
|
||||
<div v-else class="fit row justify-end">
|
||||
<q-btn flat round icon="mdi-menu" @click="showDrawer = !showDrawer" />
|
||||
</div>
|
||||
<q-drawer v-model="showDrawer" side="right" behavior="mobile" @click="showDrawer = !showDrawer">
|
||||
<q-list v-model="tab">
|
||||
<q-item
|
||||
v-for="(tabindex, index) in tabs"
|
||||
:key="'tab' + index"
|
||||
:active="tab == tabindex.name"
|
||||
clickable
|
||||
@click="tab = tabindex.name"
|
||||
>
|
||||
<q-item-label>{{ tabindex.label }}</q-item-label>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-drawer>
|
||||
<q-page padding class="fit row justify-center content-start items-start q-gutter-sm">
|
||||
<q-tab-panels
|
||||
v-model="tab"
|
||||
style="background-color: transparent"
|
||||
class="q-ma-none q-pa-none fit row justify-center content-start items-start"
|
||||
animated
|
||||
>
|
||||
<q-tab-panel name="agendaView">
|
||||
<AgendaView />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="eventtypes">
|
||||
<EventTypes />
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-page>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import EventTypes from '../components/management/EventTypes.vue';
|
||||
//import CreateEvent from '../components/management/CreateEvent.vue';
|
||||
import AgendaView from '../components/overview/AgendaView.vue';
|
||||
import { Screen } from 'quasar';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EventOverview',
|
||||
components: { AgendaView, EventTypes },
|
||||
setup() {
|
||||
interface Tab {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ name: 'agendaView', label: 'Kalendar' },
|
||||
// { name: 'eventtypes', label: 'Veranstaltungsarten' },
|
||||
// { name: 'jobtypes', label: 'Dienstarten' }
|
||||
];
|
||||
|
||||
const drawer = ref<boolean>(false);
|
||||
|
||||
const showDrawer = computed({
|
||||
get: () => {
|
||||
return !Screen.gt.sm && drawer.value;
|
||||
},
|
||||
set: (val: boolean) => {
|
||||
drawer.value = val;
|
||||
},
|
||||
});
|
||||
|
||||
const tab = ref<string>('agendaView');
|
||||
|
||||
return {
|
||||
showDrawer,
|
||||
tab,
|
||||
tabs,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,8 +0,0 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<q-card>
|
||||
<q-card-section class="row"> </q-card-section>
|
||||
<q-card-section> </q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
|
@ -1,16 +0,0 @@
|
|||
export const PERMISSIONS = {
|
||||
// Can create events
|
||||
CREATE: 'events_create',
|
||||
// Can edit events
|
||||
EDIT: 'events_edit',
|
||||
// Can delete events
|
||||
DELETE: 'events_delete',
|
||||
// Can create and edit EventTypes
|
||||
EVENT_TYPE: 'events_event_type',
|
||||
// Can create and edit JobTypes
|
||||
JOB_TYPE: 'events_job_type',
|
||||
// Can self assign to jobs
|
||||
ASSIGN: 'events_assign',
|
||||
// Can assign other users to jobs
|
||||
ASSIGN_OTHER: 'events_assign_other',
|
||||
};
|
|
@ -1,22 +0,0 @@
|
|||
import { defineAsyncComponent } from 'vue';
|
||||
import { innerRoutes, privateRoutes } from './routes';
|
||||
import { FG_Plugin } from 'src/plugins';
|
||||
|
||||
const plugin: FG_Plugin.Plugin = {
|
||||
name: 'Schedule',
|
||||
innerRoutes,
|
||||
internalRoutes: privateRoutes,
|
||||
requiredModules: ['User'],
|
||||
requiredBackendModules: ['events'],
|
||||
version: '0.0.1',
|
||||
widgets: [
|
||||
{
|
||||
priority: 0,
|
||||
name: 'stats',
|
||||
permissions: [],
|
||||
widget: defineAsyncComponent(() => import('./components/Widget.vue')),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default plugin;
|
|
@ -1,56 +0,0 @@
|
|||
import { FG_Plugin } from 'src/plugins';
|
||||
import { PERMISSIONS } from '../permissions';
|
||||
|
||||
export const innerRoutes: FG_Plugin.MenuRoute[] = [
|
||||
{
|
||||
title: 'Dienste',
|
||||
icon: 'mdi-briefcase',
|
||||
permissions: ['user'],
|
||||
route: {
|
||||
path: 'schedule',
|
||||
name: 'schedule',
|
||||
redirect: { name: 'schedule-overview' },
|
||||
},
|
||||
children: [
|
||||
{
|
||||
title: 'Dienstübersicht',
|
||||
icon: 'mdi-account-group',
|
||||
shortcut: true,
|
||||
route: {
|
||||
path: 'schedule-overview',
|
||||
name: 'schedule-overview',
|
||||
component: () => import('../pages/Overview.vue'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Dienstverwaltung',
|
||||
icon: 'mdi-account-details',
|
||||
shortcut: false,
|
||||
permissions: [PERMISSIONS.CREATE],
|
||||
route: {
|
||||
path: 'schedule-management',
|
||||
name: 'schedule-management',
|
||||
component: () => import('../pages/Management.vue'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Dienstanfragen',
|
||||
icon: 'mdi-account-switch',
|
||||
shortcut: false,
|
||||
route: {
|
||||
path: 'schedule-requests',
|
||||
name: 'schedule-requests',
|
||||
component: () => import('../pages/Requests.vue'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const privateRoutes: FG_Plugin.NamedRouteRecordRaw[] = [
|
||||
{
|
||||
name: 'events-edit',
|
||||
path: 'schedule/:id/edit',
|
||||
component: () => import('../pages/Event.vue'),
|
||||
},
|
||||
];
|
|
@ -1,165 +0,0 @@
|
|||
import { api } from 'src/boot/axios';
|
||||
import { AxiosError } from 'axios';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
interface UserService {
|
||||
user: FG.Service;
|
||||
}
|
||||
|
||||
function fixJob(job: FG.Job) {
|
||||
job.start = new Date(job.start);
|
||||
if (job.end) job.end = new Date(job.end);
|
||||
}
|
||||
|
||||
function fixEvent(event: FG.Event) {
|
||||
event.start = new Date(event.start);
|
||||
if (event.end) event.end = new Date(event.end);
|
||||
|
||||
event.jobs.forEach((job) => fixJob(job));
|
||||
}
|
||||
|
||||
export const useScheduleStore = defineStore({
|
||||
id: 'schedule',
|
||||
|
||||
state: () => ({
|
||||
jobTypes: [] as FG.JobType[],
|
||||
eventTypes: [] as FG.EventType[],
|
||||
templates: [] as FG.Event[],
|
||||
}),
|
||||
|
||||
getters: {},
|
||||
|
||||
actions: {
|
||||
async getJobTypes(force = false) {
|
||||
if (force || this.jobTypes.length == 0)
|
||||
try {
|
||||
const { data } = await api.get<FG.JobType[]>('/events/job-types');
|
||||
this.jobTypes = data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
return this.jobTypes;
|
||||
},
|
||||
|
||||
async addJobType(name: string) {
|
||||
await api.post<FG.JobType>('/events/job-types', { name: name });
|
||||
//TODO: HAndle new JT
|
||||
},
|
||||
|
||||
async removeJobType(id: number) {
|
||||
await api.delete(`/events/job-types/${id}`);
|
||||
//Todo Handle delete JT
|
||||
},
|
||||
|
||||
async renameJobType(id: number, newName: string) {
|
||||
await api.put(`/events/job-types/${id}`, { name: newName });
|
||||
// TODO handle rename
|
||||
},
|
||||
|
||||
async getEventTypes(force = false) {
|
||||
if (force || this.eventTypes.length == 0)
|
||||
try {
|
||||
const { data } = await api.get<FG.EventType[]>('/events/event-types');
|
||||
this.eventTypes = data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
return this.eventTypes;
|
||||
},
|
||||
|
||||
/** Add new EventType
|
||||
*
|
||||
* @param name Name of new EventType
|
||||
* @returns EventType object or null if name already exists
|
||||
* @throws Exception if requests fails because of an other reason
|
||||
*/
|
||||
async addEventType(name: string) {
|
||||
try {
|
||||
const { data } = await api.post<FG.EventType>('/events/event-types', { name: name });
|
||||
return data;
|
||||
} catch (error) {
|
||||
if ('response' in error) {
|
||||
const ae = <AxiosError>error;
|
||||
if (ae.response && ae.response.status == 409 /* CONFLICT */) return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async removeEvent(id: number) {
|
||||
try {
|
||||
await api.delete(`/events/${id}`);
|
||||
const idx = this.templates.findIndex((v) => v.id === id);
|
||||
if (idx !== -1) this.templates.splice(idx, 1);
|
||||
} catch (e) {
|
||||
const error = <AxiosError>e;
|
||||
if (error.response && error.response.status === 404) return false;
|
||||
throw e;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
async removeEventType(id: number) {
|
||||
await api.delete(`/events/event-types/${id}`);
|
||||
// TODO handle delete
|
||||
},
|
||||
|
||||
async renameEventType(id: number, newName: string) {
|
||||
try {
|
||||
await api.put(`/events/event-types/${id}`, { name: newName });
|
||||
// TODO handle rename
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ('response' in error) {
|
||||
const ae = <AxiosError>error;
|
||||
if (ae.response && ae.response.status == 409 /* CONFLICT */) return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getTemplates(force = false) {
|
||||
if (force || this.templates.length == 0) {
|
||||
const { data } = await api.get<FG.Event[]>('/events/templates');
|
||||
data.forEach((element) => fixEvent(element));
|
||||
this.templates = data;
|
||||
}
|
||||
return this.templates;
|
||||
},
|
||||
|
||||
async getEvents(filter: { from?: Date; to?: Date } | undefined = undefined) {
|
||||
try {
|
||||
const { data } = await api.get<FG.Event[]>('/events', { params: filter });
|
||||
data.forEach((element) => fixEvent(element));
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getEvent(id: number) {
|
||||
try {
|
||||
const { data } = await api.get<FG.Event>(`/events/${id}`);
|
||||
fixEvent(data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async updateJob(eventId: number, jobId: number, service: FG.Service | UserService) {
|
||||
try {
|
||||
const { data } = await api.put<FG.Job>(`/events/${eventId}/jobs/${jobId}`, service);
|
||||
fixJob(data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async addEvent(event: FG.Event) {
|
||||
const { data } = await api.post<FG.Event>('/events', event);
|
||||
if (data.is_template) this.templates.push(data);
|
||||
return data;
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,39 +0,0 @@
|
|||
<template>
|
||||
<q-card class="12">
|
||||
<q-card-section class="fit row justify-start content-center items-center">
|
||||
<div class="col-xs-12 col-sm-6 text-center text-h6">Neues Mitglied</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<MainUserSettings :user="user" :new-user="true" @update:user="setUser" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import MainUserSettings from 'src/plugins/user/components/settings/MainUserSettings.vue';
|
||||
import { useUserStore } from '../store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NewUser',
|
||||
components: { MainUserSettings },
|
||||
setup() {
|
||||
const userStore = useUserStore();
|
||||
const user = ref<FG.User>({
|
||||
userid: '',
|
||||
display_name: '',
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
mail: '',
|
||||
roles: [],
|
||||
});
|
||||
|
||||
async function setUser(value: FG.User) {
|
||||
await userStore.createUser(value);
|
||||
}
|
||||
return { user, setUser };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,40 +0,0 @@
|
|||
<template>
|
||||
<q-card class="col-12">
|
||||
<q-card-section class="fit row justify-start content-center items-center">
|
||||
<div class="col-xs-12 col-sm-6 text-center text-h6">Benutzereinstellungen</div>
|
||||
<div class="col-xs-12 col-sm-6 q-pa-sm">
|
||||
<UserSelector v-model="user" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
<MainUserSettings :user="user" @update:user="updateUser" />
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import UserSelector from '../components/UserSelector.vue';
|
||||
import MainUserSettings from '../components/settings/MainUserSettings.vue';
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { useUserStore } from '../store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'UpdateUser',
|
||||
components: { UserSelector, MainUserSettings },
|
||||
setup() {
|
||||
const mainStore = useMainStore();
|
||||
const userStore = useUserStore();
|
||||
const user = ref(mainStore.currentUser);
|
||||
|
||||
async function updateUser(value: FG.User) {
|
||||
await userStore.updateUser(value);
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
updateUser,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,43 +0,0 @@
|
|||
<template>
|
||||
<q-select
|
||||
v-model="selected"
|
||||
filled
|
||||
:label="label"
|
||||
:options="users"
|
||||
option-label="display_name"
|
||||
option-value="userid"
|
||||
map-options
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType, onBeforeMount } from 'vue';
|
||||
import { useUserStore } from '../store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'UserSelector',
|
||||
props: {
|
||||
label: { type: String, default: 'Benutzer' },
|
||||
modelValue: { default: undefined, type: Object as PropType<FG.User | undefined> },
|
||||
},
|
||||
emits: { 'update:modelValue': (user: FG.User) => !!user },
|
||||
setup(props, { emit }) {
|
||||
const userStore = useUserStore();
|
||||
|
||||
onBeforeMount(() => {
|
||||
void userStore.getUsers(false);
|
||||
});
|
||||
|
||||
const users = computed(() => userStore.users);
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: FG.User | undefined) => (value ? emit('update:modelValue', value) : undefined),
|
||||
});
|
||||
|
||||
return {
|
||||
selected,
|
||||
users,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,72 +0,0 @@
|
|||
<template>
|
||||
<q-card style="text-align: center">
|
||||
<q-card-section class="row justify-center content-stretch">
|
||||
<div v-if="avatar" class="col-4">
|
||||
<div style="width: 100%; padding-bottom: 100%; position: relative">
|
||||
<q-avatar style="position: absolute; top: 0; left: 0; width: 100%; height: 100%">
|
||||
<img :src="avatarLink" :onerror="error" />
|
||||
</q-avatar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<span class="text-h6">Hallo {{ name }}</span
|
||||
><br />
|
||||
<span v-if="hasBirthday">Herzlichen Glückwunsch zum Geburtstag!<br /></span>
|
||||
<span v-if="birthday.length > 0"
|
||||
>Heute <span v-if="birthday.length === 1">hat </span><span v-else>haben </span
|
||||
><span v-for="(user, index) in birthday" :key="index"
|
||||
>{{ user.display_name }}<span v-if="index < birthday.length - 1">, </span></span
|
||||
>
|
||||
Geburtstag.</span
|
||||
>
|
||||
<span v-else>Heute stehen keine Geburtstage an</span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useMainStore } from 'src/stores';
|
||||
import { computed, defineComponent, onMounted, ref } from 'vue';
|
||||
import { useUserStore } from '../store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Greeting',
|
||||
setup() {
|
||||
const mainStore = useMainStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// Ensure users are loaded,so we can query birthdays
|
||||
onMounted(() => userStore.getUsers(false));
|
||||
|
||||
const avatar = ref(true);
|
||||
const name = ref(mainStore.currentUser.firstname);
|
||||
const avatarLink = ref(mainStore.currentUser.avatar_url);
|
||||
|
||||
function error() {
|
||||
avatar.value = false;
|
||||
}
|
||||
|
||||
function userHasBirthday(user: FG.User) {
|
||||
const today = new Date();
|
||||
return (
|
||||
user.birthday &&
|
||||
user.birthday.getMonth() === today.getMonth() &&
|
||||
user.birthday.getDate() === today.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
const hasBirthday = computed(() => {
|
||||
return userHasBirthday(mainStore.currentUser);
|
||||
});
|
||||
|
||||
const birthday = computed(() =>
|
||||
userStore.users
|
||||
.filter(userHasBirthday)
|
||||
.filter((user) => user.userid !== mainStore.currentUser.userid)
|
||||
);
|
||||
|
||||
return { avatar, avatarLink, error, name, hasBirthday, birthday };
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,218 +0,0 @@
|
|||
<template>
|
||||
<q-form @submit="save" @reset="reset">
|
||||
<q-card-section class="fit row justify-start content-center items-center">
|
||||
<q-input
|
||||
v-model="userModel.firstname"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Vorname"
|
||||
:rules="[notEmpty]"
|
||||
autocomplete="given-name"
|
||||
filled
|
||||
/>
|
||||
<q-input
|
||||
v-model="userModel.lastname"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Nachname"
|
||||
:rules="[notEmpty]"
|
||||
autocomplete="family-name"
|
||||
filled
|
||||
/>
|
||||
<q-input
|
||||
v-model="userModel.display_name"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Angezeigter Name"
|
||||
:rules="[notEmpty]"
|
||||
autocomplete="nickname"
|
||||
filled
|
||||
/>
|
||||
<q-input
|
||||
v-model="userModel.mail"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="E-Mail"
|
||||
:rules="[isEmail, notEmpty]"
|
||||
autocomplete="email"
|
||||
filled
|
||||
/>
|
||||
<q-input
|
||||
v-model="userModel.userid"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Benutzername"
|
||||
:readonly="!newUser"
|
||||
:rules="newUser ? [isFreeUID, notEmpty] : []"
|
||||
autocomplete="username"
|
||||
filled
|
||||
/>
|
||||
<q-select
|
||||
v-model="userModel.roles"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Rollen"
|
||||
filled
|
||||
multiple
|
||||
use-chips
|
||||
:readonly="!canSetRoles"
|
||||
:options="allRoles"
|
||||
option-label="name"
|
||||
option-value="name"
|
||||
/>
|
||||
<IsoDateInput
|
||||
v-model="userModel.birthday"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
label="Geburtstag"
|
||||
autocomplete="bday"
|
||||
/>
|
||||
<q-file
|
||||
v-model="avatar"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
filled
|
||||
label="Avatar"
|
||||
accept=".jpg, image/*"
|
||||
max-file-size="204800"
|
||||
hint="Bilddateien, max. 200 KiB"
|
||||
@rejected="onAvatarRejected"
|
||||
>
|
||||
<template #append>
|
||||
<q-icon name="mdi-file-image" @click.stop />
|
||||
</template>
|
||||
</q-file>
|
||||
</q-card-section>
|
||||
<q-separator v-if="!newUser" />
|
||||
<q-card-section v-if="!newUser" class="fit row justify-start content-center items-center">
|
||||
<PasswordInput
|
||||
v-if="isCurrentUser"
|
||||
v-model="password"
|
||||
:rules="[notEmpty]"
|
||||
filled
|
||||
label="Passwort"
|
||||
autocomplete="current-password"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
hint="Passwort muss immer eingetragen werden"
|
||||
/>
|
||||
<PasswordInput
|
||||
v-model="newPassword"
|
||||
filled
|
||||
label="Neues Password"
|
||||
autocomplete="new-password"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn label="Reset" type="reset" />
|
||||
<q-btn color="primary" type="submit" label="Speichern" />
|
||||
</q-card-actions>
|
||||
</q-form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Notify } from 'quasar';
|
||||
import { hasPermission } from 'src/utils/permission';
|
||||
import { notEmpty, isEmail } from 'src/utils/validators';
|
||||
import IsoDateInput from 'src/components/utils/IsoDateInput.vue';
|
||||
import PasswordInput from 'src/components/utils/PasswordInput.vue';
|
||||
import { defineComponent, computed, ref, onBeforeMount, PropType, watch } from 'vue';
|
||||
import { useUserStore } from '../../store';
|
||||
import { useMainStore } from 'src/stores';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MainUserSettings',
|
||||
components: { IsoDateInput, PasswordInput },
|
||||
props: {
|
||||
user: {
|
||||
required: true,
|
||||
type: Object as PropType<FG.User>,
|
||||
},
|
||||
newUser: { type: Boolean, required: true },
|
||||
},
|
||||
emits: {
|
||||
'update:user': (payload: FG.User) => !!payload,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const userStore = useUserStore();
|
||||
const mainStore = useMainStore();
|
||||
|
||||
onBeforeMount(() => {
|
||||
void userStore.getRoles(false);
|
||||
});
|
||||
|
||||
const password = ref('');
|
||||
const newPassword = ref('');
|
||||
const avatar = ref<File | FileList | string[]>();
|
||||
const userModel = ref(props.user);
|
||||
|
||||
const canSetRoles = computed(() => hasPermission('users_set_roles'));
|
||||
const allRoles = computed(() => userStore.roles.map((role) => role.name));
|
||||
const isCurrentUser = computed(() => userModel.value.userid === mainStore.currentUser.userid);
|
||||
|
||||
/* Reset model if props changed */
|
||||
watch(
|
||||
() => props.user,
|
||||
() => (userModel.value = props.user)
|
||||
);
|
||||
|
||||
function onAvatarRejected() {
|
||||
Notify.create({
|
||||
group: false,
|
||||
type: 'negative',
|
||||
message: 'Datei zu groß oder keine gültige Bilddatei.',
|
||||
timeout: 10000,
|
||||
progress: true,
|
||||
actions: [{ icon: 'mdi-close', color: 'white' }],
|
||||
});
|
||||
avatar.value = undefined;
|
||||
}
|
||||
|
||||
function save() {
|
||||
let changed = userModel.value;
|
||||
if (typeof changed.birthday === 'string') changed.birthday = new Date(changed.birthday);
|
||||
changed = Object.assign(changed, {
|
||||
password: password.value,
|
||||
});
|
||||
if (newPassword.value != '') {
|
||||
changed = Object.assign(changed, {
|
||||
new_password: newPassword.value,
|
||||
});
|
||||
}
|
||||
|
||||
emit('update:user', changed);
|
||||
|
||||
if (avatar.value)
|
||||
userStore
|
||||
.uploadAvatar(changed, avatar.value instanceof File ? avatar.value : avatar.value[0])
|
||||
.catch((response: Response) => {
|
||||
if (response && response.status == 400) {
|
||||
onAvatarRejected();
|
||||
}
|
||||
});
|
||||
reset();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
userModel.value = props.user;
|
||||
password.value = '';
|
||||
newPassword.value = '';
|
||||
}
|
||||
|
||||
function isFreeUID(val: string) {
|
||||
return (
|
||||
userStore.users.findIndex((user) => user.userid === val) === -1 ||
|
||||
'Benutzername ist schon vergeben'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
allRoles,
|
||||
avatar,
|
||||
canSetRoles,
|
||||
isCurrentUser,
|
||||
isEmail,
|
||||
isFreeUID,
|
||||
newPassword,
|
||||
notEmpty,
|
||||
onAvatarRejected,
|
||||
password,
|
||||
reset,
|
||||
save,
|
||||
userModel,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -1,160 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<q-card class="col-12">
|
||||
<q-form @submit="save" @reset="reset">
|
||||
<q-card-section class="fit row justify-start content-center items-center">
|
||||
<span class="col-xs-12 col-sm-6 text-center text-h6"> Rollen und Berechtigungen </span>
|
||||
<q-select
|
||||
filled
|
||||
use-input
|
||||
label="Rolle"
|
||||
input-debounce="0"
|
||||
class="col-xs-12 col-sm-6 q-pa-sm"
|
||||
:model-value="role"
|
||||
:options="roles"
|
||||
option-label="name"
|
||||
option-value="name"
|
||||
map-options
|
||||
clearable
|
||||
@new-value="createRole"
|
||||
@update:modelValue="updateRole"
|
||||
@clear="removeRole"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section v-if="role">
|
||||
<q-input v-if="role.id !== -1" v-model="newRoleName" filled label="neuer Name" />
|
||||
<q-scroll-area style="height: 40vh; width: 100%" class="background-like-input">
|
||||
<q-option-group
|
||||
:model-value="role.permissions"
|
||||
:options="permissions"
|
||||
color="primary"
|
||||
type="checkbox"
|
||||
@update:modelValue="updatePermissions"
|
||||
/>
|
||||
</q-scroll-area>
|
||||
</q-card-section>
|
||||
<q-card-actions v-if="role" align="right">
|
||||
<q-btn label="Löschen" color="negative" @click="remove" />
|
||||
<q-btn label="Reset" type="reset" />
|
||||
<q-btn color="primary" type="submit" label="Speichern" />
|
||||
</q-card-actions>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, onBeforeMount } from 'vue';
|
||||
import { useUserStore } from '../../store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'RoleSettings',
|
||||
setup() {
|
||||
const userStore = useUserStore();
|
||||
|
||||
onBeforeMount(() => {
|
||||
void userStore.getRoles();
|
||||
void userStore.getPermissions();
|
||||
});
|
||||
|
||||
const role = ref<FG.Role | null>(null);
|
||||
const roles = computed(() => userStore.roles);
|
||||
const permissions = computed(() =>
|
||||
userStore.permissions.map((perm) => {
|
||||
return {
|
||||
value: perm,
|
||||
label: perm,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const newRoleName = ref<string>('');
|
||||
|
||||
function createRole(name: string, done: (arg0: string, arg1: string) => void): void {
|
||||
role.value = { name: name, permissions: [], id: -1 };
|
||||
done(name, 'add-unique');
|
||||
}
|
||||
|
||||
function removeRole(): void {
|
||||
role.value = null;
|
||||
}
|
||||
|
||||
function updatePermissions(permissions: string[]) {
|
||||
if (role.value) {
|
||||
role.value.permissions = permissions;
|
||||
}
|
||||
}
|
||||
|
||||
function updateRole(rl: FG.Role | string | null) {
|
||||
if (typeof rl === 'string' || rl === null) return;
|
||||
role.value = {
|
||||
id: rl.id,
|
||||
name: rl.name,
|
||||
permissions: Array.from(rl.permissions),
|
||||
};
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (role.value) {
|
||||
if (role.value.id === -1)
|
||||
void userStore.newRole(role.value).then((createdRole: FG.Role) => {
|
||||
role.value = createdRole;
|
||||
});
|
||||
else {
|
||||
if (newRoleName.value !== '') role.value.name = newRoleName.value;
|
||||
void userStore.updateRole(role.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (role.value && role.value.id !== -1) {
|
||||
const original = roles.value.find((value) => value.name === role.value?.name);
|
||||
if (original) updateRole(original);
|
||||
} else {
|
||||
role.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function remove() {
|
||||
if (role.value) {
|
||||
if (role.value.id === -1) {
|
||||
role.value = null;
|
||||
} else {
|
||||
void userStore.deleteRole(role.value).then(() => (role.value = null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
roles,
|
||||
role,
|
||||
permissions,
|
||||
createRole,
|
||||
updateRole,
|
||||
updatePermissions,
|
||||
save,
|
||||
reset,
|
||||
removeRole,
|
||||
remove,
|
||||
newRoleName,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
// Same colors like qinput with filled attribute set
|
||||
.body--light .background-like-input
|
||||
background-color: rgba(0, 0, 0, 0.05)
|
||||
&:hover
|
||||
background: rgba(0,0,0,.1)
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.42)
|
||||
|
||||
.body--dark .background-like-input
|
||||
background-color: rgba(255, 255, 255, 0.07)
|
||||
&:hover
|
||||
background: rgba(255, 255, 255, 0.14)
|
||||
border-bottom: 1px solid #fff
|
||||
</style>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue