Merge pull request 'Seperated plugin and api types into subprojects' (#2) from seperate_plugins into develop

Reviewed-on: #2
This commit is contained in:
Tim Gröger 2021-05-26 12:12:44 +00:00
commit 16fd9201ae
112 changed files with 843 additions and 8321 deletions

View File

@ -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.

View File

@ -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',

4
api/components/index.ts Normal file
View File

@ -0,0 +1,4 @@
import IsoDateInput from './IsoDateInput.vue';
import PasswordInput from './PasswordInput.vue';
export { IsoDateInput, PasswordInput };

7
api/index.ts Normal file
View File

@ -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';

40
api/package.json Normal file
View File

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

6
api/shims-vue.d.ts vendored Normal file
View File

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

6
api/src/internal.ts Normal file
View File

@ -0,0 +1,6 @@
import axios from 'axios';
import { createPinia } from 'pinia';
export const api = axios.create();
export const pinia = createPinia();

3
api/src/stores/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './main';
export * from './session';
export * from './user';

View File

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

69
api/src/stores/session.ts Normal file
View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { useMainStore } from 'src/stores';
import { useMainStore } from '../stores';
export function hasPermission(permission: string) {
const store = useMainStore();

16
api/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": "@quasar/app/tsconfig-preset",
"target": "esnext",
"compilerOptions": {
"baseUrl": "./",
"lib": [
"es2020",
"dom"
],
"types": [
"@flaschengeist/types",
"@quasar/app",
"node"
]
}
}

View File

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

6
plugin.config.js Normal file
View File

@ -0,0 +1,6 @@
// You can add your plugins here
module.exports = [
'@flaschengeist/balance',
'@flaschengeist/schedule',
'@flaschengeist/pricelist',
]

View File

@ -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', '.')
}
},

View File

@ -1,3 +0,0 @@
{
"@quasar/qcalendar": {}
}

View File

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

View File

@ -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 }) => {

View File

@ -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

View File

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

View File

@ -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({

View File

@ -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>

View File

@ -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',

View File

@ -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',

137
src/flaschengeist.d.ts vendored
View File

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

View File

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

View File

@ -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({

View File

@ -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',

View File

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

View File

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

View File

@ -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'

View File

@ -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({

117
src/plugins.d.ts vendored
View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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) }}&#8239;
</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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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) }}&#8239;</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>

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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};`">&nbsp;</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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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',
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,9 +0,0 @@
declare namespace FG {
export interface RecurrenceRule {
frequency: string;
interval: number;
count?: number;
until?: Date;
weekdays?: Array<number>;
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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',
};

View File

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

View File

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

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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