Move source from flaschengeist main source tree
This commit is contained in:
parent
924728f60d
commit
9bf9c67b5a
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"version": "1.0.0-alpha.1",
|
||||||
|
"name": "@flaschengeist/balance",
|
||||||
|
"author": "Ferdinand <rpm@fthiessen.de>",
|
||||||
|
"homepage": "https://flaschengeist.dev/Flaschengeist",
|
||||||
|
"description": "Flaschengeist balance plugin",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://flaschengeist.dev/Flaschengeist/flaschengeist-balance"
|
||||||
|
},
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"valid": "tsc --noEmit",
|
||||||
|
"pretty": "prettier --config ./package.json --write '{,!(node_modules)/**/}*.ts'",
|
||||||
|
"lint": "eslint --ext .js,.ts,.vue ./src"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@flaschengeist/types": "git+https://flaschengeist.dev/ferfissimo/flaschengeist-types.git#develop",
|
||||||
|
"@quasar/app": "^3.0.0-beta.25",
|
||||||
|
"axios": "^0.21.1",
|
||||||
|
"prettier": "^2.3.0",
|
||||||
|
"typescript": "^4.2.4",
|
||||||
|
"pinia": "^2.0.0-alpha.19",
|
||||||
|
"quasar": "^2.0.0-beta.18",
|
||||||
|
"@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"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@flaschengeist/api": "1.0.0-alpha.1",
|
||||||
|
"@flaschengeist/users": "1.0.0-alpha.1"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,114 @@
|
||||||
|
<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, useMainStore } from '@flaschengeist/api';
|
||||||
|
import BalanceHeader from '../components/BalanceHeader.vue';
|
||||||
|
import PERMISSIONS from '../permissions';
|
||||||
|
import { useBalanceStore } from '../store';
|
||||||
|
|
||||||
|
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>
|
|
@ -0,0 +1,67 @@
|
||||||
|
<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 '@flaschengeist/users/src/components/UserSelector.vue';
|
||||||
|
import { useMainStore } from '@flaschengeist/api';
|
||||||
|
import { useBalanceStore } from '../store';
|
||||||
|
|
||||||
|
|
||||||
|
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>
|
|
@ -0,0 +1,74 @@
|
||||||
|
<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, useMainStore } from '@flaschengeist/api';
|
||||||
|
import UserSelector from '@flaschengeist/users/src/components/UserSelector.vue';
|
||||||
|
import BalanceHeader from '../components/BalanceHeader.vue';
|
||||||
|
import PERMISSIONS from '../permissions';
|
||||||
|
import { useBalanceStore } from '../store';
|
||||||
|
|
||||||
|
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>
|
|
@ -0,0 +1,104 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<q-card flat square>
|
||||||
|
<q-card-section class="row items-center justify-between" horizontal>
|
||||||
|
<div class="col-5 text-left q-px-sm">
|
||||||
|
<div :class="{ 'text-negative': isNegative() }" class="text-weight-bold text-h6">
|
||||||
|
<span v-if="isNegative()">-</span>{{ transaction.amount.toFixed(2) }} €
|
||||||
|
</div>
|
||||||
|
<div class="text-caption">{{ text }}</div>
|
||||||
|
<div class="text-caption">{{ timeStr }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-5 q-px-sm text-center">
|
||||||
|
<div v-if="isReversed" class="text-subtitle1">Storniert</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-2 q-pr-sm" style="text-align: right">
|
||||||
|
<q-btn
|
||||||
|
:color="isReversed ? 'positive' : 'negative'"
|
||||||
|
aria-label="Reverse transaction"
|
||||||
|
:icon="!isReversed ? 'mdi-trash-can' : 'mdi-check-bold'"
|
||||||
|
size="sm"
|
||||||
|
round
|
||||||
|
:disable="!canReverse"
|
||||||
|
@click="reverse"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<q-separator />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { formatDateTime, hasPermission, useMainStore, useUserStore } from '@flaschengeist/api';
|
||||||
|
import { ref, computed, defineComponent, onUnmounted, onMounted, PropType } from 'vue';
|
||||||
|
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>
|
|
@ -0,0 +1,29 @@
|
||||||
|
<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 { computed, defineComponent, onBeforeMount } from 'vue';
|
||||||
|
import { useMainStore } from '@flaschengeist/api';
|
||||||
|
import { useBalanceStore } from '../store';
|
||||||
|
|
||||||
|
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>
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { FG_Plugin } from '@flaschengeist/types';
|
||||||
|
import { defineAsyncComponent } from 'vue';
|
||||||
|
import routes from './routes';
|
||||||
|
|
||||||
|
const plugin: FG_Plugin.Plugin = {
|
||||||
|
id: 'balance',
|
||||||
|
name: 'Balance',
|
||||||
|
innerRoutes: routes,
|
||||||
|
requiredModules: [['balance']],
|
||||||
|
version: '0.0.2',
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
priority: 0,
|
||||||
|
name: 'current',
|
||||||
|
permissions: ['balance_show'],
|
||||||
|
widget: defineAsyncComponent(() => import('./components/Widget.vue')),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
|
@ -0,0 +1,59 @@
|
||||||
|
<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>
|
|
@ -0,0 +1,134 @@
|
||||||
|
<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, useMainStore } from '@flaschengeist/api';
|
||||||
|
import BalanceTransfer from '../components/BalanceTransfer.vue';
|
||||||
|
import Transaction from '../components/Transaction.vue';
|
||||||
|
import BalanceAdd from '../components/BalanceAdd.vue';
|
||||||
|
import { useBalanceStore } from '../store';
|
||||||
|
import PERMISSIONS from '../permissions';
|
||||||
|
|
||||||
|
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>
|
|
@ -0,0 +1,164 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section class="text-center">
|
||||||
|
<div class="text-h4">Aktueller Stand</div>
|
||||||
|
<div style="font-size: 2em">{{ balance.toFixed(2) }} €</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator />
|
||||||
|
<q-card-section>
|
||||||
|
<q-table
|
||||||
|
v-model:pagination="pagination"
|
||||||
|
title="Buchungen"
|
||||||
|
:rows="data"
|
||||||
|
:columns="columns"
|
||||||
|
row-key="id"
|
||||||
|
:loading="loading"
|
||||||
|
binary-state-sort
|
||||||
|
@request="onRequest"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<q-toggle v-model="showCancelled" label="Stornierte einblenden" />
|
||||||
|
</template>
|
||||||
|
<template #body-cell="props">
|
||||||
|
<q-td :props="props" :class="{ 'bg-grey': props.row.reversal_id != null }">
|
||||||
|
{{ props.value }}
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { formatDateTime, useMainStore, useUserStore } from '@flaschengeist/api';
|
||||||
|
import { computed, defineComponent, onMounted, ref } from 'vue';
|
||||||
|
import { useBalanceStore } from '../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>
|
|
@ -0,0 +1,21 @@
|
||||||
|
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;
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { FG_Plugin } from '@flaschengeist/types';
|
||||||
|
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;
|
|
@ -0,0 +1,6 @@
|
||||||
|
//https://github.com/vuejs/vue-next/issues/3130
|
||||||
|
declare module '*.vue' {
|
||||||
|
import { ComponentOptions } from 'vue';
|
||||||
|
const component: ComponentOptions;
|
||||||
|
export default component;
|
||||||
|
}
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { api, useMainStore } from '@flaschengeist/api';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { Notify } from 'quasar';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"extends": "@quasar/app/tsconfig-preset",
|
||||||
|
"target": "esnext",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "src/",
|
||||||
|
"lib": [
|
||||||
|
"es2020",
|
||||||
|
"dom"
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
"@flaschengeist/types",
|
||||||
|
"@quasar/app",
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue