Balance: Added Transfer and Admin view + more

* some work on reverting transactions.
* Added TODO comments on incomplete features
This commit is contained in:
Ferdinand Thiessen 2021-01-21 14:24:46 +01:00
parent 7748d2d8a3
commit 01143e08e8
9 changed files with 502 additions and 134 deletions

View File

@ -0,0 +1,44 @@
<template>
<q-card-section class="fit row justify-left content-center items-center q-col-gutter-sm">
<div class="text-h6 col-6">
Aktueller Stand: {{ balance.balance.toFixed(2) }}
<q-badge color="negative" align="top" v-if="isLocked"> gesperrt </q-badge>
</div>
<div v-if="showSelector" class="col-6">
<UserSelector :user="user" @update:user="userUpdated" />
</div>
</q-card-section>
</template>
<script lang="ts">
import { ref, computed, defineComponent, onBeforeMount } from '@vue/composition-api';
import UserSelector from 'src/plugins/user/components/UserSelector.vue';
import { StateInterfaceBalance, UserBalance } from '../store/balance';
import { Store } from 'vuex';
interface Props {
showSelector: boolean;
}
export default defineComponent({
name: 'BalanceHeader',
components: { UserSelector },
props: ['showSelector'],
setup(props: Props, { root, emit }) {
onBeforeMount(() => void store.dispatch('balance/getBalance'));
const store = <Store<StateInterfaceBalance>>root.$store;
const user = ref(<FG.User>store.state.user.currentUser);
const balance = computed(() => {const balances = store.state.balance.balances; return balances.get(user.value.userid) || {balance: 0, limit: null} ;});
const isLocked = computed(() => balance.value.limit !== null && balance.value.balance >= balance.value.limit);
function userUpdated(selectedUser: FG.User) {
void store.dispatch('balance/getBalance', selectedUser);
user.value = selectedUser;
emit('update:user', selectedUser);
}
return { user, balance, isLocked, userUpdated };
}
});
</script>

View File

@ -0,0 +1,76 @@
<template>
<q-card>
<q-card-actions align="right">
<q-btn
:color="color()"
icon="mdi-trash-can"
aria-label="Löschen"
:disable="disabled()"
@click="reverse(transaction)"
/>
</q-card-actions>
<q-card-section>
<span>{{ timeStr }}: {{ transaction.amount }}</span>
</q-card-section>
</q-card>
</template>
<script lang="ts">
// TODO: Better styling
import { ref, computed, defineComponent, onUnmounted } from '@vue/composition-api';
import { hasPermission } from 'src/utils/permission';
import { formatDateTime } from 'src/utils/datetime';
import { StateInterfaceBalance } from 'src/plugins/balance/store/balance';
import { Store } from 'vuex';
interface Props {
transaction: FG.Transaction;
}
export default defineComponent({
name: 'Transaction',
props: ['transaction'],
setup(props: Props, { root, emit }) {
const now = ref(Date.now());
const ival = setInterval(() => (now.value = Date.now()), 1000);
const store: Store<StateInterfaceBalance> = <Store<StateInterfaceBalance>>root.$store;
onUnmounted(() => clearInterval(ival));
function canReverse(transaction: FG.Transaction) {
return (
hasPermission('balance_reversal', store) ||
(transaction.sender_id === store.state.user.currentUser?.userid &&
Date.now() - transaction.time.getTime() < 10000)
);
}
function color() {
return canReverse(props.transaction) ? 'negative' : 'grey';
}
function disabled() {
return !canReverse(props.transaction);
}
function reverse(transaction: FG.Transaction) {
if (canReverse(transaction))
store
.dispatch('balance/revert', transaction)
.then(() => {
emit('reversed', transaction.id);
})
.catch(error => console.log(error));
}
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, disabled, color, reverse };
}
});
</script>

View File

@ -1,95 +1,123 @@
<template> <template>
<q-page padding> <q-page padding class="fit row justify-left q-col-gutter-sm">
<div class="col-12">
<q-card> <q-card>
<q-card-section class="row"> <BalanceHeader @update:user="userUpdated" :showSelector="showSelector" />
<div class="col-4 row q-pa-sm"> <q-separator />
<q-card-section class="row q-col-gutter-md" v-if="shortCuts">
<div v-for="(amount, index) in shortCuts" v-bind:key="index" class="col-4">
<q-btn <q-btn
class="col" color="primary"
color="green" style="width: 100%"
label="2€" :label="amount.toFixed(2).toString() + ' €'"
@click="changeBalance(-2)" @click="changeBalance(amount)"
/>
</div>
<div class="col-4 row q-pa-sm">
<q-btn
class="col"
color="green"
label="1€"
@click="changeBalance(-1)"
/>
</div>
<div class="col-4 row q-pa-sm">
<q-btn
class="col"
color="green"
label="0,50€"
@click="changeBalance(-0.5)"
/>
</div>
<div class="col-4 row q-pa-sm">
<q-btn
class="col"
color="green"
label="0,40€"
@click="changeBalance(-0.4)"
/>
</div>
<div class="col-4 row q-pa-sm">
<q-btn
class="col"
color="green"
label="0,20€"
@click="changeBalance(-0.2)"
/>
</div>
<div class="col-4 row q-pa-sm">
<q-btn
class="col"
color="green"
label="0,10€"
@click="changeBalance(-0.1)"
/> />
</div> </div>
</q-card-section> </q-card-section>
<q-card-section> <q-card-section class="row q-col-gutter-md items-center">
<div class="text-h6">{{ balance.toFixed(2) }} </div> <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 context-menu v-model="showAddShortcut">
<q-btn label="neue Verknüpfung" @click="addShortcut" /></q-popup-proxy
></q-btn>
</div>
<div class="col-sm-4 col-xs-6">
<q-btn
style="width: 100%"
color="secondary"
label="Gutschreiben"
@click="changeBalance(amount)"
/>
</div>
</q-card-section> </q-card-section>
<q-card-actions>
<q-btn label="test" @click="$store.dispatch('balance/getBalance')" />
</q-card-actions>
</q-card> </q-card>
</div>
<div v-for="(transaction, index) in transactions" v-bind:key="index" class="col-sm-4 col-xs-6">
<Transaction :transaction="transaction" @reversed="reversed" />
</div>
</q-page> </q-page>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, onBeforeMount } from '@vue/composition-api'; // TODO: Shortcuts are not displayed when first loaded
import { BalanceInterface } from 'src/plugins/balance/store/balance';
import { computed, ref, defineComponent, onBeforeMount } from '@vue/composition-api';
import { hasPermission } from 'src/utils/permission';
import { StateInterfaceBalance } from '../store/balance';
import { Store } from 'vuex'; import { Store } from 'vuex';
import Transaction from '../components/Transaction.vue';
import BalanceHeader from '../components/BalanceHeader.vue';
import PERMISSIONS from '../permissions';
export default defineComponent({ export default defineComponent({
// name: 'PageName' name: 'BalanceAdd',
components: { Transaction, BalanceHeader },
setup(_, { root }) { setup(_, { root }) {
onBeforeMount(() => { onBeforeMount(() => void store.dispatch('balance/getShortcuts'));
store.dispatch('balance/getBalance').catch(err => { const store = <Store<StateInterfaceBalance>>root.$store;
console.warn(err);
});
});
const store: Store<{ balance: BalanceInterface }> = < const amount = ref<number>(0);
Store<{ balance: BalanceInterface }> const showAddShortcut = ref(false);
>root.$store; const transactions = ref<FG.Transaction[]>([]);
const user = ref(store.state.user.currentUser);
const shortCuts = ref(store.state.balance.shortcuts);
const balance = computed<number>(() => { const showSelector = computed(
return store.state.balance.balance; () => hasPermission(PERMISSIONS.DEBIT, store) || hasPermission(PERMISSIONS.CREDIT, store)
}); );
function addShortcut() {
// TODO: Save shortcuts on backend
showAddShortcut.value = false;
console.log(amount);
}
function userUpdated(selectedUser: FG.User) {
user.value = selectedUser;
}
function changeBalance(amount: number) { function changeBalance(amount: number) {
store store
.dispatch('balance/changeBalance', amount) .dispatch('balance/changeBalance', { amount: amount, user: user.value?.userid })
.then((transaction: FG.Transaction) => {
if (transactions.value.length > 5) transactions.value.pop();
transaction.time = new Date(transaction.time);
transactions.value.unshift(transaction);
console.log(transactions.value);
})
.catch(err => console.log(err)); .catch(err => console.log(err));
} }
return { balance, changeBalance }; function reversed(id: number) {
transactions.value = transactions.value.filter(t => t.id != id);
}
return {
user,
addShortcut,
showAddShortcut,
changeBalance,
transactions,
amount,
showSelector,
shortCuts,
reversed,
userUpdated
};
} }
}); });
</script> </script>

View File

@ -0,0 +1,54 @@
<template>
<div>
<q-page padding>
<q-card>
<q-card-section>
<q-table :data="rows" row-key="userid" :columns="columns" />
</q-card-section>
</q-card>
</q-page>
</div>
</template>
<script lang="ts">
// TODO: Load all users / balances
// TODO: Fill usefull data
import { computed, defineComponent } from '@vue/composition-api';
import { StateInterfaceBalance } from '../store/balance';
import { Store } from 'vuex';
export default defineComponent({
// name: 'PageName'
setup(_, { root }) {
const store = <Store<StateInterfaceBalance>>root.$store;
const rows = computed(() => {
const fo: Array<{ userid: string; balance: number }> = [];
store.state.balance.balances.forEach((value, key) =>
fo.push(Object.assign(value, { userid: key }))
);
return fo;
});
const columns = [
{
name: 'userid',
label: 'Benutzer ID',
field: 'userid',
required: true,
align: 'left',
sortable: true
},
{ name: 'balance', label: 'Kontostand', field: 'balance' },
{
name: 'limit',
label: 'Limit',
field: 'limit',
format: (val: number) => (val === null ? 'keins' : val)
}
];
return { rows, columns };
}
});
</script>

View File

@ -0,0 +1,111 @@
<template>
<q-page padding class="fit row justify-left q-col-gutter-sm">
<div class="col-12">
<q-card>
<BalanceHeader @update:user="senderUpdated" :showSelector="showSelector" />
<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 :user="receiver" @update:user="receiverUpdated" 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>
</div>
<div v-for="(transaction, index) in transactions" v-bind:key="index" class="col-sm-4 col-xs-6">
<Transaction :transaction="transaction" @reversed="reversed" />
</div>
</q-page>
</template>
<script lang="ts">
import { computed, ref, defineComponent } from '@vue/composition-api';
import { hasPermission } from 'src/utils/permission';
import { StateInterfaceBalance } from '../store/balance';
import { Store } from 'vuex';
import UserSelector from 'src/plugins/user/components/UserSelector.vue';
import Transaction from '../components/Transaction.vue';
import BalanceHeader from '../components/BalanceHeader.vue';
import PERMISSIONS from '../permissions';
export default defineComponent({
name: 'BalanceTransfer',
components: { Transaction, BalanceHeader, UserSelector },
setup(_, { root }) {
const store: Store<StateInterfaceBalance> = <Store<StateInterfaceBalance>>root.$store;
const showSelector = computed(() => hasPermission(PERMISSIONS.SEND_OTHER, store));
const sender = ref(store.state.user.currentUser);
const receiver = ref<FG.User | undefined>(undefined);
const amount = ref<number>(0);
const transactions = ref<FG.Transaction[]>([]);
const sendDisabled = computed(() => {
return !(
receiver.value &&
sender.value &&
sender.value.userid != receiver.value.userid &&
amount.value > 0
);
});
function senderUpdated(selectedUser: FG.User) {
console.log(selectedUser);
sender.value = selectedUser;
}
function receiverUpdated(selectedUser: FG.User) {
receiver.value = selectedUser;
}
function reversed(id: number) {
transactions.value = transactions.value.filter(value => value.id != id);
}
function sendAmount() {
store
.dispatch('balance/changeBalance', {
amount: amount.value,
sender: sender.value?.userid,
user: receiver.value?.userid
})
.then((transaction: FG.Transaction) => {
if (transactions.value.length > 5) transactions.value.pop();
transaction.time = new Date(transaction.time);
transactions.value.unshift(transaction);
})
.catch(err => console.log(err));
}
return {
sender,
receiver,
amount,
sendAmount,
transactions,
showSelector,
senderUpdated,
receiverUpdated,
sendDisabled,
reversed
};
}
});
</script>

View File

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

View File

@ -1,12 +1,12 @@
import { Module } from 'vuex'; import { Module } from 'vuex';
import { StateInterface } from 'src/store'; import { StateInterface } from 'src/store';
import mainRoutes from './routes'; import routes from './routes';
import { FG_Plugin } from 'src/plugins'; import { FG_Plugin } from 'src/plugins';
import balance, { BalanceInterface } from './store/balance'; import balance, { BalanceInterface } from './store/balance';
const plugin: FG_Plugin.Plugin = { const plugin: FG_Plugin.Plugin = {
name: 'Balance', name: 'Balance',
mainRoutes, mainRoutes: routes,
requiredModules: ['User'], requiredModules: ['User'],
requiredBackendModules: ['balance'], requiredBackendModules: ['balance'],
version: '0.0.1', version: '0.0.1',

View File

@ -1,24 +1,5 @@
import { FG_Plugin } from 'src/plugins'; import { FG_Plugin } from 'src/plugins';
import permissions from '../permissions';
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'
};
const mainRoutes: FG_Plugin.PluginRouteConfig[] = [ const mainRoutes: FG_Plugin.PluginRouteConfig[] = [
{ {
@ -26,17 +7,33 @@ const mainRoutes: FG_Plugin.PluginRouteConfig[] = [
icon: 'mdi-cash-100', icon: 'mdi-cash-100',
path: 'balance', path: 'balance',
name: 'balance', name: 'balance',
component: () => import('../pages/MainPage.vue'), redirect: { name: 'balance-add' },
meta: { permissions: ['user'] }, meta: { permissions: ['user'] },
children: [ children: [
{ {
title: 'Anschreiben', title: 'Anschreiben',
icon: 'mdi-cash-100', icon: 'mdi-cash-plus',
path: 'balance-add', path: 'add',
name: 'balance-add', name: 'balance-add',
shortcut: true, shortcut: true,
meta: { permissions: [permissions.DEBIT_OWN, permissions.SHOW] }, meta: { permissions: [permissions.DEBIT_OWN, permissions.SHOW] },
component: () => import('../pages/Add.vue') component: () => import('../pages/Add.vue')
},
{
title: 'Übertragen',
icon: 'mdi-cash-refund',
path: 'transfer',
name: 'balance-transfer',
meta: { permissions: [permissions.SEND] },
component: () => import('../pages/Transfer.vue')
},
{
title: 'Verwaltung',
icon: 'mdi-account-cash',
path: 'admin',
name: 'balance-admin',
meta: { permissions: [permissions.DEBIT_OWN, permissions.SHOW] },
component: () => import('../pages/Admin.vue')
} }
] ]
} }

View File

@ -9,51 +9,75 @@ interface BalanceResponse {
debit: number; debit: number;
} }
export interface BalanceInterface extends BalanceResponse { export interface UserBalance extends BalanceResponse {
limit: number; limit: number | null;
}
export interface BalanceInterface {
balances: Map<string, UserBalance>;
shortcuts: Array<number>;
loading: number; loading: number;
} }
export interface StateInterfaceBalance extends StateInterface {
balance: BalanceInterface;
}
const state: BalanceInterface = { const state: BalanceInterface = {
balance: 0, balances: new Map<string, UserBalance>(),
credit: 0, shortcuts: [],
debit: 0,
limit: 0,
loading: 0 loading: 0
}; };
const mutations: MutationTree<BalanceInterface> = { const mutations: MutationTree<BalanceInterface> = {
setBalance(state, data: number) { setBalance(state, data: { userid: string; balance: BalanceResponse }) {
state.balance = data; state.balances.set(
data.userid,
Object.assign({ limit: state.balances.get(data.userid)?.limit || null }, data.balance)
);
}, },
setCredit(state, data: number) { changeBalance(state, data: { userid: string; amount: number }) {
state.credit = data; const user = <UserBalance>state.balances.get(data.userid);
if (data.amount < 0) user.debit += data.amount;
else user.credit += data.amount;
user.balance += data.amount;
}, },
setDebit(state, data: number) { setLimit(state, data: { userid: string; limit: number | null }) {
state.debit = data; if (state.balances.has(data.userid))
}, (<UserBalance>state.balances.get(data.userid)).limit = data.limit;
setLimit(state, data: number) { else state.balances.set(data.userid, { balance: 0, debit: 0, credit: 0, limit: data.limit });
state.limit = data;
}, },
setLoading(state, data = true) { setLoading(state, data = true) {
if (data) state.loading += 1; if (data) state.loading += 1;
else state.loading -= 1; else state.loading -= 1;
},
setShortcuts(state, data: Array<number>) {
state.shortcuts = data.sort().reverse();
} }
}; };
const actions: ActionTree<BalanceInterface, StateInterface> = { const actions: ActionTree<BalanceInterface, StateInterface> = {
getBalance({ commit, rootState }) { getShortcuts({ commit, state, rootState }, force = false) {
if (force || state.shortcuts.length == 0) {
commit('setLoading'); commit('setLoading');
axios const user = <FG.User>rootState.user.currentUser;
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions */ return axios
.get(`/users/${rootState.user.currentUser?.userid}/balance`) .get(`/users/${user.userid}/balance/shortcuts`)
.then(({ data }: AxiosResponse<BalanceResponse>) => { .then(({ data }: AxiosResponse<BalanceResponse>) => {
commit('setBalance', data.balance); commit('setShortcuts', data);
commit('setCredit', data.credit); return data;
commit('setDebit', data.debit);
}) })
.catch(err => { .finally(() => commit('setLoading', false));
console.warn(err); }
},
getBalance({ commit, rootState }, user: FG.User | undefined = undefined) {
commit('setLoading');
if (!user) user = <FG.User>rootState.user.currentUser;
return axios
.get(`/users/${user.userid}/balance`)
.then(({ data }: AxiosResponse<BalanceResponse>) => {
commit('setBalance', { userid: user?.userid, balance: data });
return data;
}) })
.finally(() => commit('setLoading', false)); .finally(() => commit('setLoading', false));
}, },
@ -70,19 +94,32 @@ const actions: ActionTree<BalanceInterface, StateInterface> = {
}) })
.finally(() => commit('setLoading', false)); .finally(() => commit('setLoading', false));
}, },
changeBalance({ rootState, dispatch, commit }, amount: number) { revert({ dispatch }, transaction: FG.Transaction) {
commit('setLoading'); return axios.delete(`/balance/${transaction.id}`).then(() => {
axios
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions */
.put(`/users/${rootState.user.currentUser?.userid}/balance`, <
{ amount: number }
>{
amount: amount
})
.then(() => {
dispatch('getBalance').catch(err => console.warn(err)); dispatch('getBalance').catch(err => console.warn(err));
});
},
changeBalance({ dispatch, commit }, data: { amount: number; user: string; sender?: string }) {
commit('setLoading');
return axios
.put(`/users/${data.user}/balance`, data)
.then((response: AxiosResponse<FG.Transaction>) => {
commit(state.balances.has(data.user) ? 'changeBalance' : 'setBalance', {
userid: data.user,
amount: data.amount
});
if (data.sender)
commit(state.balances.has(data.sender) ? 'changeBalance' : 'setBalance', {
userid: data.sender,
amount: -1 * data.amount
});
return response.data;
})
.catch(err => {
// Maybe Balance changed
dispatch('getBalance').catch(err => console.warn(err));
console.warn(err);
}) })
.catch(err => console.warn(err))
.finally(() => commit('setLoading', false)); .finally(() => commit('setLoading', false));
} }
}; };