[core] Seperated all plugins from the main source tree

This commit is contained in:
Ferdinand Thiessen 2021-05-25 16:13:15 +02:00
parent f9c9f6efbe
commit 8c9db67b95
107 changed files with 348 additions and 7626 deletions

View File

@ -40,7 +40,7 @@
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, PropType } from 'vue'; import { computed, defineComponent, PropType } from 'vue';
import { date as q_date } from 'quasar'; 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({ export default defineComponent({
name: 'IsoDateInput', 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 { LocalStorage, SessionStorage } from 'quasar';
import { FG_Plugin } from '@flaschengeist/typings'; import { FG_Plugin } from '@flaschengeist/types';
import { useSessionStore, useUserStore } from '.';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { api } from 'src/boot/axios'; import { api } from '../internal';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
function loadCurrentSession() { function loadCurrentSession() {
@ -119,10 +118,11 @@ export const useMainStore = defineStore({
data.forEach((n) => { data.forEach((n) => {
n.time = new Date(n.time); n.time = new Date(n.time);
notifications.push( notifications.push(
( (flaschengeist?.plugins.filter((p) => p.name === n.plugin)[0]?.notification)(
flaschengeist?.plugins.filter((p) => p.name === n.plugin)[0]?.notification || /*||
translateNotification translateNotification*/
)(n) n
)
); );
}); });
this.notifications.push(...notifications); 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 { defineStore } from 'pinia';
import { api } from 'src/boot/axios'; import { AxiosError } from 'axios';
import { AxiosError, AxiosResponse } from 'axios'; import { api } from '../internal';
import { useMainStore } from 'src/stores'; import { useMainStore } from '.';
export const useUserStore = defineStore({ export const useUserStore = defineStore({
id: 'users', 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) { export function hasPermission(permission: string) {
const store = useMainStore(); 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

@ -15,14 +15,16 @@
"lint": "eslint --ext .js,.ts,.vue ./src" "lint": "eslint --ext .js,.ts,.vue ./src"
}, },
"dependencies": { "dependencies": {
"@flaschengeist/api": "file:./api",
"@flaschengeist/users": "git+https://flaschengeist.dev/ferfissimo/flaschengeist-users.git#develop",
"axios": "^0.21.1", "axios": "^0.21.1",
"cordova": "^10.0.0", "cordova": "^10.0.0",
"pinia": "^2.0.0-alpha.18", "pinia": "^2.0.0-alpha.19",
"quasar": "^2.0.0-beta.17" "quasar": "^2.0.0-beta.18"
}, },
"devDependencies": { "devDependencies": {
"@flaschengeist/typings": "file:../flaschengeist-typings", "@flaschengeist/types": "git+https://flaschengeist.dev/ferfissimo/flaschengeist-types.git#develop",
"@quasar/app": "^3.0.0-beta.25", "@quasar/app": "^3.0.0-beta.26",
"@quasar/extras": "^1.10.4", "@quasar/extras": "^1.10.4",
"@quasar/quasar-app-extension-qcalendar": "4.0.0-alpha.8", "@quasar/quasar-app-extension-qcalendar": "4.0.0-alpha.8",
"@types/node": "^12.20.13", "@types/node": "^12.20.13",
@ -34,6 +36,7 @@
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-vue": "^7.9.0", "eslint-plugin-vue": "^7.9.0",
"eslint-webpack-plugin": "^2.5.4", "eslint-webpack-plugin": "^2.5.4",
"modify-source-webpack-plugin": "^3.0.0-rc.0",
"prettier": "^2.3.0", "prettier": "^2.3.0",
"typescript": "^4.2.4", "typescript": "^4.2.4",
"vuedraggable": "^4.0.1" "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/balances', */
'@flaschengeist/users',
/* '@flaschengeist/schedule', */
]

View File

@ -9,8 +9,10 @@
/* eslint-env node */ /* eslint-env node */
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const ESLintPlugin = require('eslint-webpack-plugin') 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 */) { module.exports = configure(function (/* ctx */) {
return { return {
// https://quasar.dev/quasar-cli/supporting-ts // https://quasar.dev/quasar-cli/supporting-ts
@ -53,17 +55,16 @@ module.exports = configure(function (/* ctx */) {
build: { build: {
vueRouterMode: 'history', // available values: 'hash', 'history' vueRouterMode: 'history', // available values: 'hash', 'history'
// transpile: false, // transpile: false,// eslint-disable-next-line
// Add dependencies for transpiling with Babel (Array of string/regex) // Add dependencies for transpiling with Babel (Array of string/regex)
// (from node_modules, which are by default not transpiled). // (from node_modules, which are by default not transpiled).
// Applies only if "transpile" is set to true. // Applies only if "transpile" is set to true.
// transpileDependencies: [], // transpileDependencies: [],
// rtl: false, // https://quasar.dev/options/rtl-support // rtl: false, // https://quasa// eslint-disable-next-line
// preloadChunks: true,
// showProgress: false,
// gzip: true,
// analyze: true, // analyze: true,
// Options below are automatically set depending on the env, set them if you want to override // 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' ], extensions: [ 'ts', 'js', 'vue' ],
exclude: 'node_modules' 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,10 +1,8 @@
import config from 'src/config'; import { useMainStore, api } from '@flaschengeist/api';
import { boot } from 'quasar/wrappers';
import { LocalStorage, Notify } from 'quasar'; import { LocalStorage, Notify } from 'quasar';
import axios, { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { useMainStore } from 'src/stores'; import { boot } from 'quasar/wrappers';
import config from 'src/config';
const api = axios.create();
export default boot(({ router }) => { export default boot(({ router }) => {
api.defaults.baseURL = LocalStorage.getItem<string>('baseURL') || config.baseURL; 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 { boot } from 'quasar/wrappers';
import { useMainStore } from 'src/stores';
import { hasPermissions } from 'src/utils/permission';
import { RouteRecord } from 'vue-router'; import { RouteRecord } from 'vue-router';
export default boot(({ router }) => { export default boot(({ router }) => {

View File

@ -4,43 +4,44 @@ import { boot } from 'quasar/wrappers';
import routes from 'src/router/routes'; import routes from 'src/router/routes';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { RouteRecordRaw } from 'vue-router'; import { RouteRecordRaw } from 'vue-router';
import { FG_Plugin } from '@flaschengeist/typings'; import { FG_Plugin } from '@flaschengeist/types';
const config: { [key: string]: Array<string> } = {
// Do not change required Modules !!
requiredModules: ['User'],
// here you can import plugins.
loadModules: ['Balance', 'Schedule', 'Pricelist'],
};
/* 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).-.
| .-;(_/ .-.
\ / /)).---._| `\ ,
'. ' /(( `'-./ _/|
\ .' ) .-.;` /
'. | `\-'
'._ -' /
``""--`------`
*/
/**************************************************** /****************************************************
******** Internal area for some magic ************** ******** 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 { interface BackendPlugin {
permissions: string[]; permissions: string[];
version: string; version: string;
@ -221,81 +222,61 @@ function combineShortcuts(target: FG_Plugin.Shortcut[], source: FG_Plugin.MenuRo
/** /**
* Load a Flaschengeist plugin * Load a Flaschengeist plugin
* @param loadedPlugins Flaschgeist object * @param loadedPlugins Flaschgeist object
* @param pluginName Plugin to load * @param plugin Plugin to load
* @param context RequireContext of plugins
* @param router VueRouter instance * @param router VueRouter instance
*/ */
function loadPlugin( function loadPlugin(
loadedPlugins: FG_Plugin.Flaschengeist, loadedPlugins: FG_Plugin.Flaschengeist,
pluginName: string, plugin: FG_Plugin.Plugin,
context: __WebpackModuleApi.RequireContext,
backend: Backend backend: Backend
) { ) {
// Check if already loaded // 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 // Check backend dependencies
const available = context.keys(); if (
const plugin = available.includes(`./${pluginName.toLowerCase()}/plugin.ts`) !plugin.requiredModules.every(
? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access (required) =>
<FG_Plugin.Plugin>context(`./${pluginName.toLowerCase()}/plugin.ts`).default backend.plugins[required[0]] !== undefined &&
: undefined; (required.length == 1 ||
true) /* validate the version, semver440 from python is... tricky on node*/
if (!plugin) { )
// Plugin is not found, results in an error ) {
console.exception(`Could not find required Plugin ${pluginName}`); console.error(`Plugin ${plugin.name}: Backend modules not satisfied`);
return false; 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;
} }
/** /**
@ -317,8 +298,9 @@ async function getBackend() {
*/ */
export default boot(async ({ router, app }) => { export default boot(async ({ router, app }) => {
const backend = await getBackend(); const backend = await getBackend();
if (backend === null) { if (!backend || typeof backend !== 'object' || !('plugins' in backend)) {
void router.push({ name: 'error' }); console.log('Backend error');
router.isReady().finally(() => void router.push({ name: 'offline', params: { refresh: 1 } }));
return; return;
} }
@ -331,39 +313,23 @@ export default boot(async ({ router, app }) => {
widgets: [], widgets: [],
}; };
// get all plugins const BreakError = {};
const pluginsContext = require.context('src/plugins', true, /.+\/plugin.ts$/); try {
PLUGINS.plugins.forEach((plugin, name) => {
if (!loadPlugin(loadedPlugins, plugin, backend)) {
void router.push({ name: 'error' });
// Start loading plugins Notify.create({
// Load required modules, if not found or error when loading this will forward the user to the error page type: 'negative',
config.requiredModules.forEach((required) => { message: `Fehler beim Laden: Bitte wende dich an den Admin (error: PNF-${name}!`,
const plugin = loadPlugin(loadedPlugins, required, pluginsContext, backend); timeout: 10000,
if (!plugin) { progress: true,
void router.push({ name: 'error' }); });
return; throw BreakError;
} }
});
// 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,
}); });
} catch (e) {
if (e !== BreakError) throw e;
} }
// Sort widgets by priority // 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 { boot } from 'quasar/wrappers';
import { useMainStore } from 'src/stores';
import { ref } from 'vue';
export const pinia = ref<Pinia>();
export default boot(({ app }) => { export default boot(({ app }) => {
pinia.value = createPinia(); app.use(pinia);
app.use(pinia.value);
const store = useMainStore(); const store = useMainStore();
void store.init(); void store.init();

View File

@ -34,8 +34,8 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType, computed } from 'vue'; import { defineComponent, PropType, computed } from 'vue';
import { formatDateTime } from 'src/utils/datetime'; import { formatDateTime } from '@flaschengeist/api';
import { FG_Plugin } from '@flaschengeist/typings'; import { FG_Plugin } from '@flaschengeist/types';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
export default defineComponent({ export default defineComponent({

View File

@ -30,8 +30,8 @@
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, PropType } from 'vue'; import { computed, defineComponent, PropType } from 'vue';
import { hasPermissions } from 'src/utils/permission'; import { hasPermissions } from '@flaschengeist/api';
import { FG_Plugin } from '@flaschengeist/typings'; import { FG_Plugin } from '@flaschengeist/types';
export default defineComponent({ export default defineComponent({
name: 'EssentialExpansionLink', name: 'EssentialExpansionLink',

View File

@ -12,8 +12,8 @@
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, PropType } from 'vue'; import { computed, defineComponent, PropType } from 'vue';
import { hasPermissions } from 'src/utils/permission'; import { hasPermissions } from '@flaschengeist/api';
import { FG_Plugin } from '@flaschengeist/typings'; import { FG_Plugin } from '@flaschengeist/types';
export default defineComponent({ export default defineComponent({
name: 'EssentialLink', name: 'EssentialLink',

View File

@ -8,8 +8,8 @@
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, PropType } from 'vue'; import { computed, defineComponent, PropType } from 'vue';
import { hasPermissions } from 'src/utils/permission'; import { hasPermissions } from '@flaschengeist/api';
import { FG_Plugin } from '@flaschengeist/typings'; import { FG_Plugin } from '@flaschengeist/types';
export default defineComponent({ export default defineComponent({
name: 'ShortcutLink', name: 'ShortcutLink',

View File

@ -89,11 +89,11 @@ import {
import { Screen } from 'quasar'; import { Screen } from 'quasar';
import config from 'src/config'; import config from 'src/config';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useMainStore } from 'src/stores'; import { useMainStore } from '@flaschengeist/api';
import { FG_Plugin } from '@flaschengeist/typings'; import { FG_Plugin } from '@flaschengeist/types';
import EssentialExpansionLink from 'components/navigation/EssentialExpansionLink.vue'; import EssentialExpansionLink from 'components/navigation/EssentialExpansionLink.vue';
import draggable from 'vuedraggable'; import drag from 'vuedraggable';
const drag: ComponentPublicInstance = <ComponentPublicInstance>draggable;
const essentials: FG_Plugin.MenuLink[] = [ const essentials: FG_Plugin.MenuLink[] = [
{ {
title: 'Über Flaschengeist', title: 'Über Flaschengeist',
@ -104,7 +104,13 @@ const essentials: FG_Plugin.MenuLink[] = [
export default defineComponent({ export default defineComponent({
name: 'MainLayout', name: 'MainLayout',
components: { EssentialExpansionLink, EssentialLink, ShortcutLink, Notification, drag }, components: {
EssentialExpansionLink,
EssentialLink,
ShortcutLink,
Notification,
drag: <ComponentPublicInstance>drag,
},
setup() { setup() {
const router = useRouter(); const router = useRouter();
const mainStore = useMainStore(); const mainStore = useMainStore();

View File

@ -26,7 +26,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, inject } from 'vue'; import { defineComponent, inject } from 'vue';
import { FG_Plugin } from '@flaschengeist/typings'; import { FG_Plugin } from '@flaschengeist/types';
import ShortcutLink from 'components/navigation/ShortcutLink.vue'; import ShortcutLink from 'components/navigation/ShortcutLink.vue';
export default defineComponent({ export default defineComponent({

View File

@ -12,8 +12,8 @@
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, inject } from 'vue'; import { computed, defineComponent, inject } from 'vue';
import { hasPermissions } from 'src/utils/permission'; import { hasPermissions } from '@flaschengeist/api';
import { FG_Plugin } from '@flaschengeist/typings'; import { FG_Plugin } from '@flaschengeist/types';
export default defineComponent({ export default defineComponent({
name: 'Dashboard', name: 'Dashboard',

View File

@ -37,14 +37,14 @@
</q-card-section> </q-card-section>
<div class="row justify-end"> <div class="row justify-end">
<q-btn <q-btn
v-if="$q.platform.is.cordova || $q.platform.is.electron" v-if="quasar.platform.is.cordova || quasar.platform.is.electron"
flat flat
round round
icon="mdi-menu-down" icon="mdi-menu-down"
@click="openServerSettings" @click="openServerSettings"
/> />
</div> </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"> <div v-show="visible">
<q-separator /> <q-separator />
<q-card-section> <q-card-section>
@ -63,12 +63,10 @@
<script lang="ts"> <script lang="ts">
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { Loading, Notify } from 'quasar'; 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 { defineComponent, ref } from 'vue';
import { setBaseURL, api } from 'boot/axios'; 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'; import { useQuasar } from 'quasar';
export default defineComponent({ export default defineComponent({
@ -76,6 +74,7 @@ export default defineComponent({
components: { PasswordInput }, components: { PasswordInput },
setup() { setup() {
const mainStore = useMainStore(); const mainStore = useMainStore();
const userStore = useUserStore();
const mainRoute = { name: 'dashboard' }; const mainRoute = { name: 'dashboard' };
const router = useRouter(); const router = useRouter();
@ -84,7 +83,7 @@ export default defineComponent({
const password = ref(''); const password = ref('');
const server = ref<string | undefined>(api.defaults.baseURL); const server = ref<string | undefined>(api.defaults.baseURL);
const visible = ref(false); const visible = ref(false);
const $q = useQuasar(); const quasar = useQuasar();
function openServerSettings() { function openServerSettings() {
visible.value = !visible.value; visible.value = !visible.value;
@ -101,7 +100,7 @@ export default defineComponent({
const status = await mainStore.login(userid.value, password.value); const status = await mainStore.login(userid.value, password.value);
if (status === true) { 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']; const x = router.currentRoute.value.query['redirect'];
void router.push(typeof x === 'string' ? { path: x } : mainRoute); void router.push(typeof x === 'string' ? { path: x } : mainRoute);
} else { } else {
@ -158,7 +157,7 @@ export default defineComponent({
server, server,
userid, userid,
visible, visible,
$q, quasar,
}; };
}, },
}); });

View File

@ -46,9 +46,9 @@ export default defineComponent({
const ival = setInterval(() => { const ival = setInterval(() => {
reload.value -= 1; reload.value -= 1;
if (reload.value <= 0) { 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; const path = router.currentRoute.value.query.redirect;
console.log('Offline: ');
console.log(path);
void router.replace(path ? { path: <string>path } : { name: 'login' }); void router.replace(path ? { path: <string>path } : { name: 'login' });
} }
}, 1000); }, 1000);

View File

@ -34,10 +34,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { useMainStore } from '@flaschengeist/api';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { Loading, Notify } from 'quasar'; import { Loading, Notify } from 'quasar';
import { defineComponent, ref } from 'vue'; import { defineComponent, ref } from 'vue';
import { useMainStore } from 'src/stores';
export default defineComponent({ export default defineComponent({
// name: 'PageName' // name: 'PageName'

View File

@ -60,7 +60,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, inject } from 'vue'; import { defineComponent, inject } from 'vue';
import { FG_Plugin } from '@flaschengeist/typings'; import { FG_Plugin } from '@flaschengeist/types';
import Developer from 'components/about/Developer.vue'; import Developer from 'components/about/Developer.vue';
const developers = [ const developers = [

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 { FG_Plugin } from '@flaschengeist/typings';
import { defineAsyncComponent } from 'vue';
import routes from './routes';
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 '@flaschengeist/typings';
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 '@flaschengeist/typings';
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 '@flaschengeist/typings';
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 { innerRoutes, privateRoutes } from './routes';
import { FG_Plugin } from '@flaschengeist/typings';
import { defineAsyncComponent } from 'vue';
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 '@flaschengeist/typings';
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>

View File

@ -1,168 +0,0 @@
<template>
<q-card class="col-12" height="">
<q-card-section v-if="isThisSession(modelValue.token)" class="text-caption">
Diese Session.
</q-card-section>
<q-card-section>
<div class="row">
<div class="col-xs-12 col-sm-6">
Browser:
<q-icon :name="getBrowserIcon(modelValue.browser)" size="24px" />
{{ modelValue.browser }}
</div>
<div class="col-xs-12 col-sm-6">
Plattform:
<q-icon :name="getPlatformIcon(modelValue.platform)" size="24px" />
{{ modelValue.platform }}
</div>
</div>
<div v-if="!isEdit" class="row">
<div class="col-xs-12 col-sm-6">
Lebenszeit:
{{ modelValue.lifetime }}
</div>
<div class="col-xs-12 col-sm-6">Läuft aus: {{ dateTime(modelValue.expires) }}</div>
</div>
<div v-else class="row q-my-sm">
<q-input
v-model="computedLifetime"
class="col-xs-12 col-sm-6 q-px-sm"
type="number"
label="Zeit"
filled
/>
<q-select v-model="option" class="col-xs-12 col-sm-6 q-px-sm" :options="options" filled />
</div>
</q-card-section>
<q-card-actions v-if="!isEdit" align="right">
<q-btn flat round dense icon="mdi-pencil" @click="edit(true)" />
<q-btn flat round dense icon="mdi-delete" @click="deleteSession(modelValue.token)" />
</q-card-actions>
<q-card-actions v-else align="right">
<q-btn flat dense label="Abbrechen" @click="edit(false)" />
<q-btn flat dense label="Speichern" @click="save" />
</q-card-actions>
</q-card>
</template>
<script lang="ts">
import { defineComponent, ref, computed, PropType } from 'vue';
import { formatDateTime } from 'src/utils/datetime';
import { useMainStore } from 'src/stores';
import { useSessionStore } from '../../store';
export default defineComponent({
name: 'Session',
props: {
modelValue: {
required: true,
type: Object as PropType<FG.Session>,
},
},
emits: {
'update:modelValue': (s: FG.Session) => !!s,
delete: () => true,
},
setup(props, { emit }) {
const sessionStore = useSessionStore();
const mainStore = useMainStore();
const dateTime = (date: Date) => formatDateTime(date, true);
const options = ref(['Minuten', 'Stunden', 'Tage']);
const option = ref<string>(options.value[0]);
const lifetime = ref(0);
function getBrowserIcon(browser: string) {
return browser == 'firefox'
? 'mdi-firefox'
: browser == 'chrome'
? 'mdi-google-chrome'
: browser == 'safari'
? 'mdi-apple-safari'
: 'mdi-help';
}
function getPlatformIcon(platform: string) {
return platform == 'linux'
? 'mdi-linux'
: platform == 'windows'
? 'mdi-microsoft-windows'
: platform == 'macos'
? 'mdi-apple'
: platform == 'iphone'
? 'mdi-cellphone-iphone'
: platform == 'android'
? 'mdi-cellphone-android'
: 'mdi-help';
}
async function deleteSession(token: string) {
await sessionStore.deleteSession(token);
emit('delete');
}
function isThisSession(token: string) {
return mainStore.session?.token === token;
}
const isEdit = ref(false);
const computedLifetime = computed({
get: () => {
switch (option.value) {
case options.value[0]:
return (lifetime.value / 60).toFixed(2);
case options.value[1]:
return (lifetime.value / (60 * 60)).toFixed(2);
case options.value[2]:
return (lifetime.value / (60 * 60 * 24)).toFixed(2);
}
throw 'Invalid option';
},
set: (val) => {
if (val) {
switch (option.value) {
case options.value[0]:
lifetime.value = parseFloat(val) * 60;
break;
case options.value[1]:
lifetime.value = parseFloat(val) * 60 * 60;
break;
case options.value[2]:
lifetime.value = parseFloat(val) * 60 * 60 * 24;
break;
}
}
},
});
function edit(value: boolean) {
lifetime.value = props.modelValue.lifetime;
isEdit.value = value;
}
async function save() {
isEdit.value = false;
await sessionStore.updateSession(lifetime.value, props.modelValue.token);
emit(
'update:modelValue',
Object.assign(Object.assign({}, props.modelValue), { lifetime: lifetime.value })
);
}
return {
getBrowserIcon,
getPlatformIcon,
isThisSession,
deleteSession,
isEdit,
dateTime,
edit,
options,
option,
lifetime,
computedLifetime,
save,
};
},
});
</script>

View File

@ -1,14 +0,0 @@
export interface LoginData {
userid: string;
password: string;
}
export interface LoginResponse {
user: FG.User;
session: FG.Session;
permissions: FG.Permission[];
}
export interface CurrentUserResponse extends FG.User {
permissions: FG.Permission[];
}

View File

@ -1,93 +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" />
</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-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="user">
<UpdateUser />
</q-tab-panel>
<q-tab-panel name="newUser">
<NewUser />
</q-tab-panel>
<q-tab-panel name="roles">
<RoleSettings v-if="canEditRoles" />
</q-tab-panel>
</q-tab-panels>
</q-page>
</template>
<script lang="ts">
import { Screen } from 'quasar';
import { PERMISSIONS } from '../permissions';
import NewUser from '../components/NewUser.vue';
import { computed, defineComponent, ref } from 'vue';
import { hasPermission } from 'src/utils/permission';
import UpdateUser from '../components/UpdateUser.vue';
import RoleSettings from '../components/settings/RoleSettings.vue';
export default defineComponent({
name: 'AdminSettings',
components: { RoleSettings, UpdateUser, NewUser },
setup() {
const canEditRoles = computed(() => hasPermission(PERMISSIONS.ROLES_EDIT));
interface Tab {
name: string;
label: string;
}
const tabs: Tab[] = [
{ name: 'user', label: 'Mitglieder' },
{ name: 'newUser', label: 'Neues Mitglied' },
{ name: 'roles', label: 'Rollen' },
];
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>('user');
return {
canEditRoles,
showDrawer,
tab,
tabs,
};
},
});
</script>

View File

@ -1,57 +0,0 @@
<template>
<div>
<q-page padding class="fit row justify-center content-center items-center q-gutter-sm">
<q-card class="col-12">
<q-card-section class="fit row justify-start content-center items-center">
<div class="col-12 text-center text-h6">Benutzereinstellungen</div>
</q-card-section>
<MainUserSettings :user="currentUser" @update:user="updateUser" />
</q-card>
<div class="col-12 text-left text-h6">Aktive Sessions:</div>
<Session
v-for="(session, index) in sessions"
:key="'session' + index"
v-model="sessions[index]"
@delete="removeSession(session)"
/>
</q-page>
</div>
</template>
<script lang="ts">
import { defineComponent, onBeforeMount, ref } from 'vue';
import Session from '../components/settings/Session.vue';
import MainUserSettings from '../components/settings/MainUserSettings.vue';
import { useMainStore } from 'src/stores';
import { useSessionStore } from '../store';
import { useUserStore } from '../store';
export default defineComponent({
// name: 'PageName'
components: { Session, MainUserSettings },
setup() {
const mainStore = useMainStore();
const sessionStore = useSessionStore();
const userStore = useUserStore();
onBeforeMount(() => sessionStore.getSessions().then((s) => (sessions.value = s)));
const currentUser = ref(mainStore.currentUser);
const sessions = ref([] as FG.Session[]);
async function updateUser(value: FG.User) {
await userStore.updateUser(value);
}
function removeSession(s: FG.Session) {
sessions.value = sessions.value.filter((ss) => ss.token !== s.token);
}
return {
currentUser,
sessions,
updateUser,
removeSession,
};
},
});
</script>

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