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