Run prettier
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing

This commit is contained in:
Jan Bader 2022-03-15 12:49:53 +00:00
parent d8d713f841
commit 61a534610f
42 changed files with 1485 additions and 1187 deletions

View File

@ -1,17 +1,17 @@
module.exports = { module.exports = {
extends: [ extends: [
// add more generic rulesets here, such as: // add more generic rulesets here, such as:
// 'eslint:recommended', // 'eslint:recommended',
'plugin:vue/vue3-recommended', "plugin:vue/vue3-recommended",
// 'plugin:vue/recommended' // Use this if you are using Vue.js 2.x. // 'plugin:vue/recommended' // Use this if you are using Vue.js 2.x.
], ],
rules: { rules: {
// override/add rules settings here, such as: // override/add rules settings here, such as:
// 'vue/no-unused-vars': 'error' // 'vue/no-unused-vars': 'error'
}, },
"parser": "vue-eslint-parser", parser: "vue-eslint-parser",
"parserOptions": { parserOptions: {
"parser": "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
"sourceType": "module" sourceType: "module",
} },
} };

View File

@ -1,13 +1,14 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title> <title>Vite App</title>
</head> </head>
<body class="bg-slate-200 text-slate-800 dark:bg-slate-800 dark:text-slate-200 box-border w-full"> <body
<div id="app"></div> class="bg-slate-200 text-slate-800 dark:bg-slate-800 dark:text-slate-200 box-border w-full">
<script type="module" src="/src/main.ts"></script> <div id="app"></div>
</body> <script type="module" src="/src/main.ts"></script>
</body>
</html> </html>

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };

View File

@ -1,5 +1,5 @@
declare module "*.vue" { declare module "*.vue" {
import { defineComponent } from "vue"; import { defineComponent } from "vue";
const component: ReturnType<typeof defineComponent>; const component: ReturnType<typeof defineComponent>;
export default component; export default component;
} }

View File

@ -26,29 +26,39 @@ export default defineComponent({
</script> </script>
<template> <template>
<div class="flex flex-col md:flex-row flex-1 h-screen"> <div class="flex flex-col md:flex-row flex-1 h-screen">
<router-view name="sidebar"></router-view> <router-view name="sidebar"></router-view>
<div class="flex-1 overflow-auto"> <div class="flex-1 overflow-auto">
<div class="flex bg-gray-400 dark:bg-gray-600 p-4 fixed md:static top-0 left-0 w-full h-14"> <div
<span class="flex bg-gray-400 dark:bg-gray-600 p-4 fixed md:static top-0 left-0 w-full h-14">
class="flex-1 font-bold text-5xl -my-3 hidden md:inline" <span
@click="toggleMenuSize" class="flex-1 font-bold text-5xl -my-3 hidden md:inline"
></span> @click="toggleMenuSize"
<span class="flex-1 font-bold text-5xl -my-3 md:hidden" @click="toggleMenu"></span> ></span
>
<span
class="flex-1 font-bold text-5xl -my-3 md:hidden"
@click="toggleMenu"
></span
>
<span class="flex-1">{{ CurrentBudgetName }}</span> <span class="flex-1">{{ CurrentBudgetName }}</span>
<div class="flex flex-1 flex-row justify-end -mx-4"> <div class="flex flex-1 flex-row justify-end -mx-4">
<router-link class="mx-4" v-if="LoggedIn" to="/dashboard">Dashboard</router-link> <router-link class="mx-4" v-if="LoggedIn" to="/dashboard"
<router-link class="mx-4" v-if="!LoggedIn" to="/login">Login</router-link> >Dashboard</router-link
<a class="mx-4" v-if="LoggedIn" @click="logout">Logout</a> >
</div> <router-link class="mx-4" v-if="!LoggedIn" to="/login"
</div> >Login</router-link
>
<a class="mx-4" v-if="LoggedIn" @click="logout">Logout</a>
</div>
</div>
<div class="p-3 pl-6"> <div class="p-3 pl-6">
<router-view></router-view> <router-view></router-view>
</div> </div>
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,26 +1,26 @@
import { useSessionStore } from "./stores/session"; import { useSessionStore } from "./stores/session";
export const BASE_URL = "/api/v1" export const BASE_URL = "/api/v1";
export function GET(path: string) { export function GET(path: string) {
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
return fetch(BASE_URL + path, { return fetch(BASE_URL + path, {
headers: sessionStore.AuthHeaders, headers: sessionStore.AuthHeaders,
}) });
}; }
export function POST(path: string, body: FormData | string | null) { export function POST(path: string, body: FormData | string | null) {
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
return fetch(BASE_URL + path, { return fetch(BASE_URL + path, {
method: "POST", method: "POST",
headers: sessionStore.AuthHeaders, headers: sessionStore.AuthHeaders,
body: body, body: body,
}) });
} }
export function DELETE(path: string) { export function DELETE(path: string) {
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
return fetch(BASE_URL + path, { return fetch(BASE_URL + path, {
method: "DELETE", method: "DELETE",
headers: sessionStore.AuthHeaders, headers: sessionStore.AuthHeaders,
}) });
} }

View File

@ -19,13 +19,15 @@ function daysSinceLastReconciled() {
</script> </script>
<template> <template>
<span> <span>
<router-link <router-link
:to="'/budget/' + CurrentBudgetID + '/account/' + account.ID"> :to="'/budget/' + CurrentBudgetID + '/account/' + account.ID">
{{account.Name}} {{account.Name}}
</router-link> </router-link>
<span v-if="props.account.LastReconciled.Valid && daysSinceLastReconciled() > 7" class="font-bold bg-gray-500 rounded-md text-sm px-2 mx-2 py-1 no-underline"> <span
{{daysSinceLastReconciled()}} v-if="props.account.LastReconciled.Valid && daysSinceLastReconciled() > 7"
</span> class="font-bold bg-gray-500 rounded-md text-sm px-2 mx-2 py-1 no-underline">
</span> {{daysSinceLastReconciled()}}
</template> </span>
</span>
</template>

View File

@ -81,22 +81,29 @@ function clear() {
</script> </script>
<template> <template>
<div> <div>
<Input <Input
type="text" type="text"
class="border-b-2 border-black" class="border-b-2 border-black"
@keypress="keypress" @keypress="keypress"
v-if="id == undefined" v-if="id == undefined"
v-model="SearchQuery" v-model="SearchQuery" />
/> <span
<span @click="clear" v-if="id != undefined" class="bg-gray-300 dark:bg-gray-700">{{ text }}</span> @click="clear"
<div v-if="Suggestions.length > 0" class="absolute bg-gray-400 dark:bg-gray-600 w-64 p-2"> v-if="id != undefined"
<span class="bg-gray-300 dark:bg-gray-700"
v-for="suggestion in Suggestions" >{{ text }}</span
class="block" >
@click="select" <div
:value="suggestion.ID" v-if="Suggestions.length > 0"
>{{ suggestion.Name }}</span> class="absolute bg-gray-400 dark:bg-gray-600 w-64 p-2">
</div> <span
</div> v-for="suggestion in Suggestions"
</template> class="block"
@click="select"
:value="suggestion.ID"
>{{ suggestion.Name }}</span
>
</div>
</div>
</template>

View File

@ -1,10 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup></script>
</script>
<template> <template>
<button <button class="px-4 rounded-md shadow-sm focus:outline-none focus:ring-2">
class="px-4 rounded-md shadow-sm focus:outline-none focus:ring-2" <slot></slot>
> </button>
<slot></slot> </template>
</button>
</template>

View File

@ -1,8 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup></script>
</script>
<template> <template>
<div class="flex flex-row items-center bg-gray-300 dark:bg-gray-700 rounded-lg"> <div
<slot></slot> class="flex flex-row items-center bg-gray-300 dark:bg-gray-700 rounded-lg">
</div> <slot></slot>
</template> </div>
</template>

View File

@ -3,9 +3,9 @@ const props = defineProps(["modelValue"]);
</script> </script>
<template> <template>
<input <input
type="checkbox" type="checkbox"
:checked="modelValue" :checked="modelValue"
@change="$emit('update:modelValue', ($event.target as HTMLInputElement)?.checked)" @change="$emit('update:modelValue', ($event.target as HTMLInputElement)?.checked)"
class="dark:bg-slate-900"> class="dark:bg-slate-900" />
</template> </template>

View File

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
const props = defineProps<{ const props = defineProps<{
value: number | undefined value: number | undefined
negativeClass?: string negativeClass?: string
positiveClass?: string positiveClass?: string
@ -15,5 +15,9 @@ const formattedValue = computed(() => internalValue.value.toLocaleString(undefin
</script> </script>
<template> <template>
<span class="text-right" :class="internalValue < 0 ? (negativeClass ?? 'negative') : positiveClass">{{ formattedValue }} </span> <span
</template> class="text-right"
:class="internalValue < 0 ? (negativeClass ?? 'negative') : positiveClass"
>{{ formattedValue }} </span
>
</template>

View File

@ -26,11 +26,10 @@ function selectAll(event: FocusEvent) {
</script> </script>
<template> <template>
<Input <Input
type="date" type="date"
ref="input" ref="input"
v-bind:value="dateToYYYYMMDD(modelValue)" v-bind:value="dateToYYYYMMDD(modelValue)"
@input="updateValue" @input="updateValue"
@focus="selectAll" @focus="selectAll" />
/> </template>
</template>

View File

@ -3,8 +3,8 @@ const props = defineProps(["modelValue"]);
</script> </script>
<template> <template>
<input <input
:value="modelValue" :value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement)?.value)" @input="$emit('update:modelValue', ($event.target as HTMLInputElement)?.value)"
class="dark:bg-slate-900"> class="dark:bg-slate-900" />
</template> </template>

View File

@ -30,33 +30,38 @@ function submitDialog() {
</script> </script>
<template> <template>
<button @click="openDialog"> <button @click="openDialog">
<slot name="placeholder"> <slot name="placeholder">
<Card> <Card>
<p class="w-24 text-center text-6xl">+</p> <p class="w-24 text-center text-6xl">+</p>
<span class="text-lg" dark>{{ buttonText }}</span> <span class="text-lg" dark>{{ buttonText }}</span>
</Card> </Card>
</slot> </slot>
</button> </button>
<div <div
v-if="visible" v-if="visible"
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full">
> <div
<div class="relative top-20 mx-auto p-5 w-96 shadow-lg rounded-md bg-white dark:bg-black"> class="relative top-20 mx-auto p-5 w-96 shadow-lg rounded-md bg-white dark:bg-black">
<div class="mt-3 text-center"> <div class="mt-3 text-center">
<h3 class="mt-3 text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">{{ buttonText }}</h3> <h3
<slot></slot> class="mt-3 text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">
<div class="grid grid-cols-2 gap-6"> {{ buttonText }}
<button </h3>
@click="closeDialog" <slot></slot>
class="px-4 py-2 bg-red-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300" <div class="grid grid-cols-2 gap-6">
>Close</button> <button
<button @click="closeDialog"
@click="submitDialog" class="px-4 py-2 bg-red-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300">
class="px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300" Close
>Save</button> </button>
</div> <button
</div> @click="submitDialog"
</div> class="px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300">
</div> Save
</template> </button>
</div>
</div>
</div>
</div>
</template>

View File

@ -38,29 +38,38 @@ function saveTransaction(e: MouseEvent) {
</script> </script>
<template> <template>
<tr> <tr>
<td class="text-sm"> <td class="text-sm">
<DateInput class="border-b-2 border-black" v-model="TX.Date" /> <DateInput class="border-b-2 border-black" v-model="TX.Date" />
</td> </td>
<td> <td>
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" /> <Autocomplete
</td> v-model:text="TX.Payee"
<td> v-model:id="TX.PayeeID"
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" /> v-model:type="payeeType"
</td> model="payees" />
<td> </td>
<Input class="block w-full border-b-2 border-black" type="text" v-model="TX.Memo" /> <td>
</td> <Autocomplete
<td class="text-right"> v-model:text="TX.Category"
<Input v-model:id="TX.CategoryID"
class="text-right block w-full border-b-2 border-black" model="categories" />
type="currency" </td>
v-model="TX.Amount" <td>
/> <Input
</td> class="block w-full border-b-2 border-black"
<td> type="text"
<Button class="bg-blue-500" @click="saveTransaction">Save</Button> v-model="TX.Memo" />
</td> </td>
<td></td> <td class="text-right">
</tr> <Input
</template> class="text-right block w-full border-b-2 border-black"
type="currency"
v-model="TX.Amount" />
</td>
<td>
<Button class="bg-blue-500" @click="saveTransaction">Save</Button>
</td>
<td></td>
</tr>
</template>

View File

@ -52,32 +52,41 @@ function saveTransaction(e: MouseEvent) {
</script> </script>
<template> <template>
<tr> <tr>
<label class="md:hidden">Date</label> <label class="md:hidden">Date</label>
<td class="text-sm"> <td class="text-sm">
<DateInput class="border-b-2 border-black" v-model="TX.Date" /> <DateInput class="border-b-2 border-black" v-model="TX.Date" />
</td> </td>
<label class="md:hidden">Payee</label> <label class="md:hidden">Payee</label>
<td> <td>
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" /> <Autocomplete
</td> v-model:text="TX.Payee"
<label class="md:hidden">Category</label> v-model:id="TX.PayeeID"
<td> v-model:type="payeeType"
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" /> model="payees" />
</td> </td>
<td class="col-span-2"> <label class="md:hidden">Category</label>
<Input class="block w-full border-b-2 border-black" type="text" v-model="TX.Memo" /> <td>
</td> <Autocomplete
<label class="md:hidden">Amount</label> v-model:text="TX.Category"
<td class="text-right"> v-model:id="TX.CategoryID"
<Input model="categories" />
class="text-right block w-full border-b-2 border-black" </td>
type="currency" <td class="col-span-2">
v-model="TX.Amount" <Input
/> class="block w-full border-b-2 border-black"
</td> type="text"
<td class="hidden md:table-cell"> v-model="TX.Memo" />
<Button class="bg-blue-500" @click="saveTransaction">Save</Button> </td>
</td> <label class="md:hidden">Amount</label>
</tr> <td class="text-right">
</template> <Input
class="text-right block w-full border-b-2 border-black"
type="currency"
v-model="TX.Amount" />
</td>
<td class="hidden md:table-cell">
<Button class="bg-blue-500" @click="saveTransaction">Save</Button>
</td>
</tr>
</template>

View File

@ -47,34 +47,45 @@ function getStatusSymbol() {
</script> </script>
<template> <template>
<tr v-if="dateChanged()" class="table-row md:hidden"> <tr v-if="dateChanged()" class="table-row md:hidden">
<td class="bg-gray-200 dark:bg-gray-800 rounded-lg p-2" colspan="5">{{ formatDate(TX.Date) }}</td> <td class="bg-gray-200 dark:bg-gray-800 rounded-lg p-2" colspan="5">
</tr> {{ formatDate(TX.Date) }}
<tr </td>
v-if="!edit" </tr>
class="{{new Date(TX.Date) > new Date() ? 'future' : ''}}" <tr
:class="[index % 6 < 3 ? 'md:bg-gray-300 dark:md:bg-gray-700' : 'md:bg-gray-100 dark:md:bg-gray-900']" v-if="!edit"
> class="{{new Date(TX.Date) > new Date() ? 'future' : ''}}"
<!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">--> :class="[index % 6 < 3 ? 'md:bg-gray-300 dark:md:bg-gray-700' : 'md:bg-gray-100 dark:md:bg-gray-900']">
<td class="hidden md:block">{{ formatDate(TX.Date) }}</td> <!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">-->
<td class="pl-2 md:pl-0">{{ TX.TransferAccount ? "Transfer : " + TX.TransferAccount : TX.Payee }}</td> <td class="hidden md:block">{{ formatDate(TX.Date) }}</td>
<td>{{ TX.CategoryGroup ? TX.CategoryGroup + " : " + TX.Category : "" }}</td> <td class="pl-2 md:pl-0">
<td> {{ TX.TransferAccount ? "Transfer : " + TX.TransferAccount : TX.Payee }}
<a </td>
:href="'/budget/' + CurrentBudgetID + '/transaction/' + TX.ID" <td>
>{{ TX.Memo }}</a> {{ TX.CategoryGroup ? TX.CategoryGroup + " : " + TX.Category : "" }}
</td> </td>
<td> <td>
<Currency class="block" :value="TX.Amount" /> <a
</td> :href="'/budget/' + CurrentBudgetID + '/transaction/' + TX.ID"
<td class="text-right"> >{{ TX.Memo }}</a
{{ TX.GroupID ? "☀" : "" }} >
{{ getStatusSymbol() }} </td>
<a @click="edit = true;"></a> <td>
<Checkbox v-if="Reconciling && TX.Status != 'Reconciled'" v-model="TX.Reconciled" /> <Currency class="block" :value="TX.Amount" />
</td> </td>
</tr> <td class="text-right">
<TransactionEditRow v-if="edit" :transactionid="TX.ID" @save="edit = false" /> {{ TX.GroupID ? "☀" : "" }}
{{ getStatusSymbol() }}
<a @click="edit = true;"></a>
<Checkbox
v-if="Reconciling && TX.Status != 'Reconciled'"
v-model="TX.Reconciled" />
</td>
</tr>
<TransactionEditRow
v-if="edit"
:transactionid="TX.ID"
@save="edit = false" />
</template> </template>
<style> <style>
@ -82,4 +93,4 @@ td {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
} }
</style> </style>

View File

@ -1,7 +1,8 @@
export function formatDate(date: Date): string { export function formatDate(date: Date): string {
return date.toLocaleDateString(undefined, { // you can use undefined as first argument return date.toLocaleDateString(undefined, {
year: "numeric", // you can use undefined as first argument
month: "2-digit", year: "numeric",
day: "2-digit", month: "2-digit",
}); day: "2-digit",
} });
}

View File

@ -20,7 +20,7 @@ function editAccount(e : {cancel:boolean}) : boolean {
if(CurrentAccount.value?.ClearedBalance != 0 && !accountOpen.value){ if(CurrentAccount.value?.ClearedBalance != 0 && !accountOpen.value){
e.cancel = true; e.cancel = true;
error.value = "Cannot close account with balance"; error.value = "Cannot close account with balance";
return false; return false;
} }
error.value = ""; error.value = "";
@ -42,35 +42,29 @@ function openEditAccount(e : any) {
</script> </script>
<template> <template>
<Modal button-text="Edit Account" @open="openEditAccount" @submit="editAccount"> <Modal
<template v-slot:placeholder><span class="ml-2"></span></template> button-text="Edit Account"
<div class="mt-2 px-7 py-3"> @open="openEditAccount"
<Input @submit="editAccount">
class="border-2 dark:border-gray-700" <template v-slot:placeholder><span class="ml-2"></span></template>
type="text" <div class="mt-2 px-7 py-3">
v-model="accountName" <Input
placeholder="Account name" class="border-2 dark:border-gray-700"
required type="text"
/> v-model="accountName"
</div> placeholder="Account name"
<div class="mt-2 px-7 py-3"> required />
<Checkbox </div>
class="border-2" <div class="mt-2 px-7 py-3">
v-model="accountOnBudget" <Checkbox class="border-2" v-model="accountOnBudget" required />
required <label>On Budget</label>
/> </div>
<label>On Budget</label> <div class="mt-2 px-7 py-3">
</div> <Checkbox class="border-2" v-model="accountOpen" required />
<div class="mt-2 px-7 py-3"> <label>Open</label>
<Checkbox </div>
class="border-2" <div v-if="error != ''" class="dark:text-red-300 text-red-700">
v-model="accountOpen" {{ error }}
required </div>
/> </Modal>
<label>Open</label> </template>
</div>
<div v-if="error != ''" class="dark:text-red-300 text-red-700">
{{ error }}
</div>
</Modal>
</template>

View File

@ -11,9 +11,14 @@ function saveBudget() {
</script> </script>
<template> <template>
<Modal button-text="New Budget" @submit="saveBudget"> <Modal button-text="New Budget" @submit="saveBudget">
<div class="mt-2 px-7 py-3"> <div class="mt-2 px-7 py-3">
<Input class="border-2" type="text" v-model="budgetName" placeholder="Budget name" required /> <Input
</div> class="border-2"
</Modal> type="text"
</template> v-model="budgetName"
placeholder="Budget name"
required />
</div>
</Modal>
</template>

View File

@ -3,15 +3,15 @@
@tailwind utilities; @tailwind utilities;
h1 { h1 {
font-size: 200%; font-size: 200%;
} }
a { a {
text-decoration: underline; text-decoration: underline;
} }
#app { #app {
font-family: Avenir, Helvetica, Arial, sans-serif; font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }

View File

@ -1,72 +1,79 @@
import { createApp } from 'vue' import { createApp } from "vue";
import App from './App.vue' import App from "./App.vue";
import './index.css' import "./index.css";
import router from './router' import router from "./router";
import { createPinia } from 'pinia' import { createPinia } from "pinia";
import { useBudgetsStore } from './stores/budget'; import { useBudgetsStore } from "./stores/budget";
import { useAccountStore } from './stores/budget-account' import { useAccountStore } from "./stores/budget-account";
import PiniaLogger from './pinia-logger' import PiniaLogger from "./pinia-logger";
import { useSessionStore } from './stores/session' import { useSessionStore } from "./stores/session";
const app = createApp(App) const app = createApp(App);
app.use(router) app.use(router);
const pinia = createPinia() const pinia = createPinia();
pinia.use(PiniaLogger({ pinia.use(
expanded: false, PiniaLogger({
showDuration: true expanded: false,
})) showDuration: true,
app.use(pinia) })
app.mount('#app') );
app.use(pinia);
app.mount("#app");
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const budgetStore = useBudgetsStore(); const budgetStore = useBudgetsStore();
await budgetStore.SetCurrentBudget((<string>to.params.budgetid)); await budgetStore.SetCurrentBudget(<string>to.params.budgetid);
const accountStore = useAccountStore(); const accountStore = useAccountStore();
await accountStore.SetCurrentAccount((<string>to.params.budgetid), (<string>to.params.accountid)); await accountStore.SetCurrentAccount(
next(); <string>to.params.budgetid,
}) <string>to.params.accountid
);
next();
});
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const token = sessionStore.Session?.Token; const token = sessionStore.Session?.Token;
let loggedIn = false; let loggedIn = false;
if (token != null) { if (token != null) {
const jwt = parseJwt(token); const jwt = parseJwt(token);
if (jwt.exp > Date.now() / 1000) if (jwt.exp > Date.now() / 1000) loggedIn = true;
loggedIn = true; }
}
if (to.matched.some(record => record.meta.requiresAuth)) { if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!loggedIn) { if (!loggedIn) {
next({ path: '/login' }); next({ path: "/login" });
} else { } else {
next(); next();
} }
} else if (to.matched.some((record) => record.meta.hideForAuth)) {
} else if (to.matched.some(record => record.meta.hideForAuth)) { if (loggedIn) {
if (loggedIn) { next({ path: "/dashboard" });
next({ path: '/dashboard' }); } else {
} else { next();
next(); }
} } else {
} else { next();
next(); }
}
}); });
function parseJwt(token: string) { function parseJwt(token: string) {
var base64Url = token.split('.')[1]; var base64Url = token.split(".")[1];
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); var base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
var jsonPayload = decodeURIComponent(atob(base64).split('').map(function (c) { var jsonPayload = decodeURIComponent(
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); atob(base64)
}).join('')); .split("")
.map(function (c) {
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
})
.join("")
);
return JSON.parse(jsonPayload); return JSON.parse(jsonPayload);
}; }
1646426130;
1646426130 1646512855755;
1646512855755

View File

@ -42,94 +42,101 @@ function createReconcilationTransaction() {
</script> </script>
<template> <template>
<div class="grid grid-cols-[1fr_auto]"> <div class="grid grid-cols-[1fr_auto]">
<div> <div>
<h1 class="inline"> <h1 class="inline">
{{ accounts.CurrentAccount?.Name }} {{ accounts.CurrentAccount?.Name }}
</h1> </h1>
<EditAccount /> <EditAccount />
</div> </div>
<div class="text-right flex flex-wrap flex-col md:flex-row justify-end gap-2 max-w-sm"> <div
<span class="rounded-lg p-1 whitespace-nowrap flex-1"> class="text-right flex flex-wrap flex-col md:flex-row justify-end gap-2 max-w-sm">
Working: <span class="rounded-lg p-1 whitespace-nowrap flex-1">
<Currency :value="accounts.CurrentAccount?.WorkingBalance" /> Working:
</span> <Currency :value="accounts.CurrentAccount?.WorkingBalance" />
</span>
<span class="rounded-lg p-1 whitespace-nowrap flex-1"> <span class="rounded-lg p-1 whitespace-nowrap flex-1">
Cleared: Cleared:
<Currency :value="accounts.CurrentAccount?.ClearedBalance" /> <Currency :value="accounts.CurrentAccount?.ClearedBalance" />
</span> </span>
<span <span
class="rounded-lg bg-blue-500 p-1 whitespace-nowrap flex-1" class="rounded-lg bg-blue-500 p-1 whitespace-nowrap flex-1"
v-if="!transactions.Reconciling" v-if="!transactions.Reconciling"
@click="transactions.Reconciling = true" @click="transactions.Reconciling = true">
> Reconciled:
Reconciled: <Currency :value="accounts.CurrentAccount?.ReconciledBalance" />
<Currency :value="accounts.CurrentAccount?.ReconciledBalance" /> </span>
</span>
<span v-if="transactions.Reconciling" class="contents"> <span v-if="transactions.Reconciling" class="contents">
<Button @click="submitReconcilation" <Button
class="bg-blue-500 p-1 whitespace-nowrap flex-1"> @click="submitReconcilation"
My current balance is&nbsp; class="bg-blue-500 p-1 whitespace-nowrap flex-1">
<Currency :value="transactions.ReconcilingBalance" /> My current balance is&nbsp;
</Button> <Currency :value="transactions.ReconcilingBalance" />
</Button>
<Button @click="createReconcilationTransaction" <Button
class="bg-orange-500 p-1 whitespace-nowrap flex-1"> @click="createReconcilationTransaction"
No, it's: class="bg-orange-500 p-1 whitespace-nowrap flex-1">
<Input No, it's:
class="text-right w-20 bg-transparent dark:bg-transparent border-b-2" <Input
type="number" class="text-right w-20 bg-transparent dark:bg-transparent border-b-2"
v-model="TargetReconcilingBalance" type="number"
/> v-model="TargetReconcilingBalance" />
(Difference (Difference
<Currency <Currency
:value="transactions.ReconcilingBalance - TargetReconcilingBalance" :value="transactions.ReconcilingBalance - TargetReconcilingBalance" />)
/>) </Button>
</Button> <Button
<Button class="bg-red-500 p-1 flex-1" @click="cancelReconcilation">Cancel</Button> class="bg-red-500 p-1 flex-1"
</span> @click="cancelReconcilation"
</div> >Cancel</Button
</div> >
</span>
</div>
</div>
<table> <table>
<tr class="font-bold"> <tr class="font-bold">
<td class="hidden md:block" style="width: 90px;">Date</td> <td class="hidden md:block" style="width: 90px;">Date</td>
<td style="max-width: 150px;">Payee</td> <td style="max-width: 150px;">Payee</td>
<td style="max-width: 200px;">Category</td> <td style="max-width: 200px;">Category</td>
<td>Memo</td> <td>Memo</td>
<td class="text-right">Amount</td> <td class="text-right">Amount</td>
<td style="width: 80px;"> <td style="width: 80px;">
<Input v-if="transactions.Reconciling" type="checkbox" @input="setReconciled" /> <Input
</td> v-if="transactions.Reconciling"
</tr> type="checkbox"
<TransactionInputRow @input="setReconciled" />
class="hidden md:table-row" </td>
:budgetid="budgetid" </tr>
:accountid="accountid" <TransactionInputRow
/> class="hidden md:table-row"
<TransactionRow :budgetid="budgetid"
v-for="(transaction, index) in transactions.TransactionsList" :accountid="accountid" />
:key="transaction.ID" <TransactionRow
:transactionid="transaction.ID" v-for="(transaction, index) in transactions.TransactionsList"
:index="index" :key="transaction.ID"
/> :transactionid="transaction.ID"
</table> :index="index" />
<div class="md:hidden"> </table>
<Modal> <div class="md:hidden">
<template v-slot:placeholder> <Modal>
<Button class="fixed right-4 bottom-4 font-bold text-lg bg-blue-500 py-2">+</Button> <template v-slot:placeholder>
</template> <Button
<TransactionInputRow class="fixed right-4 bottom-4 font-bold text-lg bg-blue-500 py-2"
class="grid grid-cols-2" >+</Button
:budgetid="budgetid" >
:accountid="accountid" </template>
/> <TransactionInputRow
</Modal> class="grid grid-cols-2"
</div> :budgetid="budgetid"
:accountid="accountid" />
</Modal>
</div>
</template> </template>
<style> <style>
@ -140,4 +147,4 @@ table {
.negative { .negative {
color: red; color: red;
} }
</style> </style>

View File

@ -8,9 +8,9 @@ onMounted(() => {
</script> </script>
<template> <template>
<h1>Danger Zone</h1> <h1>Danger Zone</h1>
<div class="budget-item"> <div class="budget-item">
<button>Clear database</button> <button>Clear database</button>
<p>This removes all data and starts from scratch. Not undoable!</p> <p>This removes all data and starts from scratch. Not undoable!</p>
</div> </div>
</template> </template>

View File

@ -22,45 +22,59 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
</script> </script>
<template> <template>
<div <div
:class="[ExpandMenu ? 'md:w-72' : 'md:w-36', ShowMenu ? '' : 'hidden']" :class="[ExpandMenu ? 'md:w-72' : 'md:w-36', ShowMenu ? '' : 'hidden']"
class="md:block flex-shrink-0 w-full bg-gray-500 border-r-4 border-black" class="md:block flex-shrink-0 w-full bg-gray-500 border-r-4 border-black">
> <div class="flex flex-col mt-14 md:mt-0">
<div class="flex flex-col mt-14 md:mt-0"> <span
<span class="m-2 p-1 px-3 h-10 overflow-hidden"
class="m-2 p-1 px-3 h-10 overflow-hidden" :class="[ExpandMenu ? 'text-2xl' : 'text-md']">
:class="[ExpandMenu ? 'text-2xl' : 'text-md']" <router-link to="/dashboard" style="font-size:150%"
> ></router-link
<router-link to="/dashboard" style="font-size:150%"></router-link> >
{{ CurrentBudgetName }} {{ CurrentBudgetName }}
</span> </span>
<span class="bg-gray-100 dark:bg-gray-700 p-2 px-3 flex flex-col"> <span class="bg-gray-100 dark:bg-gray-700 p-2 px-3 flex flex-col">
<router-link :to="'/budget/' + CurrentBudgetID + '/budgeting'">Budget</router-link> <router-link :to="'/budget/' + CurrentBudgetID + '/budgeting'"
<br /> >Budget</router-link
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>--> >
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>--> <br />
</span> <!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>-->
<li class="bg-slate-200 dark:bg-slate-700 my-2 p-2 px-3"> <!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>-->
<div class="flex flex-row justify-between font-bold"> </span>
<span>On-Budget Accounts</span> <li class="bg-slate-200 dark:bg-slate-700 my-2 p-2 px-3">
<Currency :class="ExpandMenu ? 'md:inline' : 'md:hidden'" :value="OnBudgetAccountsBalance" /> <div class="flex flex-row justify-between font-bold">
</div> <span>On-Budget Accounts</span>
<div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between"> <Currency
<AccountWithReconciled :account="account" /> :class="ExpandMenu ? 'md:inline' : 'md:hidden'"
<Currency :class="ExpandMenu ? 'md:inline' : 'md:hidden'" :value="account.ClearedBalance" /> :value="OnBudgetAccountsBalance" />
</div> </div>
</li> <div
<li class="bg-slate-200 dark:bg-slate-700 my-2 p-2 px-3"> v-for="account in OnBudgetAccounts"
<div class="flex flex-row justify-between font-bold"> class="flex flex-row justify-between">
<span>Off-Budget Accounts</span> <AccountWithReconciled :account="account" />
<Currency :class="ExpandMenu ? 'md:inline' : 'md:hidden'" :value="OffBudgetAccountsBalance" /> <Currency
</div> :class="ExpandMenu ? 'md:inline' : 'md:hidden'"
<div v-for="account in OffBudgetAccounts" class="flex flex-row justify-between"> :value="account.ClearedBalance" />
<AccountWithReconciled :account="account" /> </div>
<Currency :class="ExpandMenu ? 'md:inline' : 'md:hidden'" :value="account.ClearedBalance" /> </li>
</div> <li class="bg-slate-200 dark:bg-slate-700 my-2 p-2 px-3">
</li> <div class="flex flex-row justify-between font-bold">
<!-- <span>Off-Budget Accounts</span>
<Currency
:class="ExpandMenu ? 'md:inline' : 'md:hidden'"
:value="OffBudgetAccountsBalance" />
</div>
<div
v-for="account in OffBudgetAccounts"
class="flex flex-row justify-between">
<AccountWithReconciled :account="account" />
<Currency
:class="ExpandMenu ? 'md:inline' : 'md:hidden'"
:value="account.ClearedBalance" />
</div>
</li>
<!--
<li class="bg-slate-100 dark:bg-slate-800 my-2 p-2 px-3"> <li class="bg-slate-100 dark:bg-slate-800 my-2 p-2 px-3">
<div class="flex flex-row justify-between font-bold"> <div class="flex flex-row justify-between font-bold">
<span>Closed Accounts</span> <span>Closed Accounts</span>
@ -68,13 +82,15 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
+ Add Account + Add Account
</li> </li>
--> -->
<!--<li> <!--<li>
<router-link :to="'/budget/'+CurrentBudgetID+'/accounts'">Edit accounts</router-link> <router-link :to="'/budget/'+CurrentBudgetID+'/accounts'">Edit accounts</router-link>
</li>--> </li>-->
<li class="bg-red-100 dark:bg-slate-600 my-2 p-2 px-3"> <li class="bg-red-100 dark:bg-slate-600 my-2 p-2 px-3">
<router-link :to="'/budget/' + CurrentBudgetID + '/settings'">Budget-Settings</router-link> <router-link :to="'/budget/' + CurrentBudgetID + '/settings'"
</li> >Budget-Settings</router-link
<!--<li><router-link to="/admin">Admin</router-link></li>--> >
</div> </li>
</div> <!--<li><router-link to="/admin">Admin</router-link></li>-->
</div>
</div>
</template> </template>

View File

@ -69,47 +69,85 @@ function getGroupState(group: { Name: string, Expand: boolean }): boolean {
function assignedChanged(e : Event, category : Category){ function assignedChanged(e : Event, category : Category){
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
const value = target.valueAsNumber; const value = target.valueAsNumber;
POST("/budget/"+CurrentBudgetID.value+"/category/" + category.ID + "/" + selected.value.Year + "/" + (selected.value.Month+1), POST("/budget/"+CurrentBudgetID.value+"/category/" + category.ID + "/" + selected.value.Year + "/" + (selected.value.Month+1),
JSON.stringify({Assigned: category.Assigned})); JSON.stringify({Assigned: category.Assigned}));
} }
</script> </script>
<template> <template>
<h1>Budget for {{ selected.Month + 1 }}/{{ selected.Year }}</h1> <h1>Budget for {{ selected.Month + 1 }}/{{ selected.Year }}</h1>
<span>Available balance: <Currency :value="accountStore.GetIncomeAvailable(selected.Year, selected.Month)" /></span> <span
<div> >Available balance:
<router-link <Currency
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month" :value="accountStore.GetIncomeAvailable(selected.Year, selected.Month)"
>&lt;&lt;</router-link>&nbsp; /></span>
<router-link <div>
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + current.Year + '/' + current.Month" <router-link
>Current Month</router-link>&nbsp; :to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"
<router-link >&lt;&lt;</router-link
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + next.Year + '/' + next.Month" >&nbsp;
>&gt;&gt;</router-link> <router-link
</div> :to="'/budget/' + CurrentBudgetID + '/budgeting/' + current.Year + '/' + current.Month"
<div class="container col-lg-12 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5" id="content"> >Current Month</router-link
<span class="hidden sm:block"></span> >&nbsp;
<span class="hidden lg:block text-right">Leftover</span> <router-link
<span class="hidden sm:block text-right">Assigned</span> :to="'/budget/' + CurrentBudgetID + '/budgeting/' + next.Year + '/' + next.Month"
<span class="hidden sm:block text-right">Activity</span> >&gt;&gt;</router-link
<span class="hidden sm:block text-right">Available</span> >
<template v-for="group in GroupsForMonth"> </div>
<span <div
class="text-lg font-bold mt-2" class="container col-lg-12 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5"
@click="toggleGroup(group)" id="content">
>{{ (getGroupState(group) ? "" : "+") + " " + group.Name }}</span> <span class="hidden sm:block"></span>
<Currency :value="group.AvailableLastMonth" class="hidden lg:block mt-2" positive-class="text-slate-500" negative-class="text-red-700 dark:text-red-400" /> <span class="hidden lg:block text-right">Leftover</span>
<Currency :value="group.Assigned" class="hidden sm:block mx-2 mt-2 text-right" positive-class="text-slate-500" negative-class="text-red-700 dark:text-red-400" /> <span class="hidden sm:block text-right">Assigned</span>
<Currency :value="group.Activity" class="hidden sm:block mt-2" positive-class="text-slate-500" negative-class="text-red-700 dark:text-red-400" /> <span class="hidden sm:block text-right">Activity</span>
<Currency :value="group.Available" class="mt-2" positive-class="text-slate-500" negative-class="text-red-700 dark:text-red-400" /> <span class="hidden sm:block text-right">Available</span>
<template v-for="category in GetCategories(group.Name)" v-if="getGroupState(group)"> <template v-for="group in GroupsForMonth">
<span class="whitespace-nowrap overflow-hidden">{{ category.Name }}</span> <span
<Currency :value="category.AvailableLastMonth" class="hidden lg:block" /> class="text-lg font-bold mt-2"
<Input type="number" v-model="category.Assigned" @input="(evt) => assignedChanged(evt, category)" class="hidden sm:block mx-2 text-right" /> @click="toggleGroup(group)"
<Currency :value="category.Activity" class="hidden sm:block" /> >{{ (getGroupState(group) ? "" : "+") + " " + group.Name }}</span
<Currency :value="accountStore.GetCategoryAvailable(category)" /> >
</template> <Currency
</template> :value="group.AvailableLastMonth"
</div> class="hidden lg:block mt-2"
positive-class="text-slate-500"
negative-class="text-red-700 dark:text-red-400" />
<Currency
:value="group.Assigned"
class="hidden sm:block mx-2 mt-2 text-right"
positive-class="text-slate-500"
negative-class="text-red-700 dark:text-red-400" />
<Currency
:value="group.Activity"
class="hidden sm:block mt-2"
positive-class="text-slate-500"
negative-class="text-red-700 dark:text-red-400" />
<Currency
:value="group.Available"
class="mt-2"
positive-class="text-slate-500"
negative-class="text-red-700 dark:text-red-400" />
<template
v-for="category in GetCategories(group.Name)"
v-if="getGroupState(group)">
<span
class="whitespace-nowrap overflow-hidden"
>{{ category.Name }}</span
>
<Currency
:value="category.AvailableLastMonth"
class="hidden lg:block" />
<Input
type="number"
v-model="category.Assigned"
@input="(evt) => assignedChanged(evt, category)"
class="hidden sm:block mx-2 text-right" />
<Currency :value="category.Activity" class="hidden sm:block" />
<Currency
:value="accountStore.GetCategoryAvailable(category)" />
</template>
</template>
</div>
</template> </template>

View File

@ -11,15 +11,19 @@ const BudgetsList = useSessionStore().BudgetsList;
</script> </script>
<template> <template>
<h1>Budgets</h1> <h1>Budgets</h1>
<div class="grid md:grid-cols-2 gap-6"> <div class="grid md:grid-cols-2 gap-6">
<Card v-for="budget in BudgetsList"> <Card v-for="budget in BudgetsList">
<router-link v-bind:to="'/budget/'+budget.ID+'/budgeting'" class="contents"> <router-link
<!--<svg class="w-24"></svg>--> v-bind:to="'/budget/'+budget.ID+'/budgeting'"
<p class="w-24 text-center text-6xl"></p> class="contents">
<span class="text-lg">{{budget.Name}}{{budget.ID == budgetid ? " *" : ""}}</span> <!--<svg class="w-24"></svg>-->
</router-link> <p class="w-24 text-center text-6xl"></p>
</Card> <span class="text-lg"
<NewBudget /> >{{budget.Name}}{{budget.ID == budgetid ? " *" : ""}}</span
</div> >
</router-link>
</Card>
<NewBudget />
</div>
</template> </template>

View File

@ -1,13 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup></script>
</script>
<template>
<template> <div>
<div> <div class="font-bold" id="content">
<div class="font-bold" id="content"> Willkommen bei Budgeteer, der neuen App für's Budget!
Willkommen bei Budgeteer, der neuen App für's Budget! </div>
</div> <div class="container col-md-4" id="login">
<div class="container col-md-4" id="login"> <router-link to="/login">Login</router-link> or
<router-link to="/login">Login</router-link> or <router-link to="/login">register</router-link> <router-link to="/login">register</router-link>
</div> </div>
</div> </div>
</template> </template>

View File

@ -28,18 +28,27 @@ function formSubmit(e: MouseEvent) {
</script> </script>
<template> <template>
<div> <div>
<Input type="text" v-model="login.user" <Input
placeholder="Username" type="text"
class="border-2 border-black rounded-lg block px-2 my-2 w-48" /> v-model="login.user"
<Input type="password" v-model="login.password" placeholder="Username"
placeholder="Password" class="border-2 border-black rounded-lg block px-2 my-2 w-48" />
class="border-2 border-black rounded-lg block px-2 my-2 w-48" /> <Input
</div> type="password"
<div>{{ error }}</div> v-model="login.password"
<button type="submit" @click="formSubmit" class="bg-blue-300 rounded-lg p-2 w-48">Login</button> placeholder="Password"
<p> class="border-2 border-black rounded-lg block px-2 my-2 w-48" />
New user? </div>
<router-link to="/register">Register</router-link> instead! <div>{{ error }}</div>
</p> <button
</template> type="submit"
@click="formSubmit"
class="bg-blue-300 rounded-lg p-2 w-48">
Login
</button>
<p>
New user?
<router-link to="/register">Register</router-link> instead!
</p>
</template>

View File

@ -1,48 +1,59 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useSessionStore } from "../stores/session"; import { useSessionStore } from "../stores/session";
import Input from "../components/Input.vue"; import Input from "../components/Input.vue";
const error = ref(""); const error = ref("");
const login = ref({ email: "", password: "", name: "" }); const login = ref({ email: "", password: "", name: "" });
const router = useRouter(); // has to be called in setup const router = useRouter(); // has to be called in setup
onMounted(() => { onMounted(() => {
useSessionStore().setTitle("Login"); useSessionStore().setTitle("Login");
}); });
function formSubmit(e: MouseEvent) { function formSubmit(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
useSessionStore().register(login.value) useSessionStore().register(login.value)
.then(x => { .then(x => {
error.value = ""; error.value = "";
router.replace("/dashboard"); router.replace("/dashboard");
return x; return x;
}) })
.catch(x => error.value = "The entered credentials are invalid!"); .catch(x => error.value = "The entered credentials are invalid!");
// TODO display invalidCredentials // TODO display invalidCredentials
// TODO redirect to dashboard on success // TODO redirect to dashboard on success
} }
</script> </script>
<template> <template>
<div> <div>
<Input type="text" v-model="login.name" <Input
placeholder="Name" type="text"
class="border-2 border-black rounded-lg block px-2 my-2 w-48" /> v-model="login.name"
<Input type="text" v-model="login.email" placeholder="Name"
placeholder="Email" class="border-2 border-black rounded-lg block px-2 my-2 w-48" />
class="border-2 border-black rounded-lg block px-2 my-2 w-48" /> <Input
<Input type="password" v-model="login.password" type="text"
placeholder="Password" v-model="login.email"
class="border-2 border-black rounded-lg block px-2 my-2 w-48" /> placeholder="Email"
</div> class="border-2 border-black rounded-lg block px-2 my-2 w-48" />
<div>{{ error }}</div> <Input
<button type="submit" @click="formSubmit" class="bg-blue-300 rounded-lg p-2 w-48">Register</button> type="password"
<p> v-model="login.password"
Existing user? placeholder="Password"
<router-link to="/login">Login</router-link> instead! class="border-2 border-black rounded-lg block px-2 my-2 w-48" />
</p> </div>
</template> <div>{{ error }}</div>
<button
type="submit"
@click="formSubmit"
class="bg-blue-300 rounded-lg p-2 w-48">
Register
</button>
<p>
Existing user?
<router-link to="/login">Login</router-link> instead!
</p>
</template>

View File

@ -72,53 +72,82 @@ function ynabExport() {
saveAs(blob, timeStamp + " " + CurrentBudgetName.value + " - Transactions.tsv"); saveAs(blob, timeStamp + " " + CurrentBudgetName.value + " - Transactions.tsv");
}) })
} }
</script> </script>
<template> <template>
<div> <div>
<h1>Danger Zone</h1> <h1>Danger Zone</h1>
<div class="grid md:grid-cols-2 gap-6"> <div class="grid md:grid-cols-2 gap-6">
<Card class="flex-col p-3"> <Card class="flex-col p-3">
<h2 class="text-lg font-bold">Clear Budget</h2> <h2 class="text-lg font-bold">Clear Budget</h2>
<p>This removes transactions and assignments to start from scratch. Accounts and categories are kept. Not undoable!</p> <p>
This removes transactions and assignments to start from
scratch. Accounts and categories are kept. Not undoable!
</p>
<Button class="bg-red-500 py-2" @click="clearBudget">Clear budget</Button> <Button class="bg-red-500 py-2" @click="clearBudget"
</Card> >Clear budget</Button
<Card class="flex-col p-3"> >
<h2 class="text-lg font-bold">Delete Budget</h2> </Card>
<p>This deletes the whole bugdet including all transactions, assignments, accounts and categories. Not undoable!</p> <Card class="flex-col p-3">
<Button class="bg-red-500 py-2" @click="deleteBudget">Delete budget</button> <h2 class="text-lg font-bold">Delete Budget</h2>
</Card> <p>
<Card class="flex-col p-3"> This deletes the whole bugdet including all transactions,
<h2 class="text-lg font-bold">Fix all historic negative category-balances</h2> assignments, accounts and categories. Not undoable!
<p>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</p> </p>
<Button class="bg-orange-500 py-2" @click="cleanNegative">Fix negative</button> <Button class="bg-red-500 py-2" @click="deleteBudget"
</Card> >Delete budget</Button
<Card class="flex-col p-3"> >
<h2 class="text-lg font-bold">Import YNAB Budget</h2> </Card>
<Card class="flex-col p-3">
<div> <h2 class="text-lg font-bold">
<label for="transactions_file"> Fix all historic negative category-balances
Transaktionen: </h2>
<input type="file" @change="gotTransactions" accept="text/*" /> <p>
</label> This restores YNABs functionality, that would substract any
<br /> overspent categories' balances from next months inflows.
<label for="assignments_file"> </p>
Budget: <Button class="bg-orange-500 py-2" @click="cleanNegative"
<input type="file" @change="gotAssignments" accept="text/*" /> >Fix negative</Button
</label> >
</div> </Card>
<Card class="flex-col p-3">
<h2 class="text-lg font-bold">Import YNAB Budget</h2>
<Button class="bg-blue-500 py-2" :disabled="filesIncomplete" @click="ynabImport">Importieren</Button> <div>
</Card> <label for="transactions_file">
<Card class="flex-col p-3"> Transaktionen:
<h2 class="text-lg font-bold">Export as YNAB TSV</h2> <input
type="file"
@change="gotTransactions"
accept="text/*" />
</label>
<br />
<label for="assignments_file">
Budget:
<input
type="file"
@change="gotAssignments"
accept="text/*" />
</label>
</div>
<div class="flex flex-row"> <Button
<Button class="bg-blue-500 py-2" @click="ynabExport">Export</Button> class="bg-blue-500 py-2"
</div> :disabled="filesIncomplete"
</Card> @click="ynabImport"
</div> >Importieren</Button
</div> >
</template> </Card>
<Card class="flex-col p-3">
<h2 class="text-lg font-bold">Export as YNAB TSV</h2>
<div class="flex flex-row">
<Button class="bg-blue-500 py-2" @click="ynabExport"
>Export</Button
>
</div>
</Card>
</div>
</div>
</template>

View File

@ -1,81 +1,107 @@
import { PiniaPluginContext, StoreGeneric, _ActionsTree, _StoreOnActionListenerContext } from 'pinia'; import {
PiniaPluginContext,
StoreGeneric,
_ActionsTree,
_StoreOnActionListenerContext,
} from "pinia";
const cloneDeep = <T>(obj: T): T => { const cloneDeep = <T>(obj: T): T => {
try { try {
return JSON.parse(JSON.stringify(obj)); return JSON.parse(JSON.stringify(obj));
} catch { } catch {
return { ...obj }; return { ...obj };
} }
}; };
const formatTime = (date = new Date()) => { const formatTime = (date = new Date()) => {
const hours = date.getHours().toString().padStart(2, '0'); const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, "0");
const seconds = date.getSeconds().toString().padStart(2, '0'); const seconds = date.getSeconds().toString().padStart(2, "0");
return `${hours}:${minutes}:${seconds}`; return `${hours}:${minutes}:${seconds}`;
}; };
export interface PiniaLoggerOptions { export interface PiniaLoggerOptions {
disabled?: boolean; disabled?: boolean;
expanded?: boolean; expanded?: boolean;
showDuration?: boolean showDuration?: boolean;
showStoreName?: boolean; showStoreName?: boolean;
logErrors?: boolean; logErrors?: boolean;
} }
export type PiniaActionListenerContext = _StoreOnActionListenerContext<StoreGeneric, string, _ActionsTree>; export type PiniaActionListenerContext = _StoreOnActionListenerContext<
StoreGeneric,
string,
_ActionsTree
>;
const defaultOptions: PiniaLoggerOptions = { const defaultOptions: PiniaLoggerOptions = {
logErrors: true, logErrors: true,
disabled: false, disabled: false,
expanded: true, expanded: true,
showStoreName: true, showStoreName: true,
showDuration: false, showDuration: false,
}; };
export const PiniaLogger = (config = defaultOptions) => (ctx: PiniaPluginContext) => { export const PiniaLogger =
const options = { (config = defaultOptions) =>
...defaultOptions, (ctx: PiniaPluginContext) => {
...config, const options = {
}; ...defaultOptions,
...config,
};
if (options.disabled) return; if (options.disabled) return;
ctx.store.$onAction((action: PiniaActionListenerContext) => {
const startTime = Date.now();
const prevState = cloneDeep(ctx.store.$state);
ctx.store.$onAction((action: PiniaActionListenerContext) => { const log = (isError?: boolean, error?: any) => {
const startTime = Date.now(); const endTime = Date.now();
const prevState = cloneDeep(ctx.store.$state); const duration = endTime - startTime + "ms";
const nextState = cloneDeep(ctx.store.$state);
const storeName = action.store.$id;
const title = `${formatTime()} action 🍍 ${
options.showStoreName ? `[${storeName}] ` : ""
}${action.name} ${
isError ? `failed after ` : ""
}in ${duration}`;
const log = (isError?: boolean, error?: any) => { console[options.expanded ? "group" : "groupCollapsed"](
const endTime = Date.now(); `%c${title}`,
const duration = endTime - startTime + 'ms'; `font-weight: bold; ${isError ? "color: #ed4981;" : ""}`
const nextState = cloneDeep(ctx.store.$state); );
const storeName = action.store.$id; console.log(
const title = `${formatTime()} action 🍍 ${options.showStoreName ? `[${storeName}] ` : ''}${action.name} ${isError ? `failed after ` : ''}in ${duration}`; "%cprev state",
"font-weight: bold; color: grey;",
prevState
);
console.log("%caction", "font-weight: bold; color: #69B7FF;", {
type: action.name,
args:
action.args.length > 0 ? { ...action.args } : undefined,
...(options.showStoreName && { store: action.store.$id }),
...(options.showDuration && { duration }),
...(isError && { error }),
});
console.log(
"%cnext state",
"font-weight: bold; color: #4caf50;",
nextState
);
console.groupEnd();
};
console[options.expanded ? 'group' : 'groupCollapsed'](`%c${title}`, `font-weight: bold; ${isError ? 'color: #ed4981;' : ''}`); action.after(() => {
console.log('%cprev state', 'font-weight: bold; color: grey;', prevState); log();
console.log('%caction', 'font-weight: bold; color: #69B7FF;', { });
type: action.name,
args: action.args.length > 0 ? { ...action.args } : undefined,
...(options.showStoreName && { store: action.store.$id }),
...(options.showDuration && { duration }),
...(isError && { error }),
});
console.log('%cnext state', 'font-weight: bold; color: #4caf50;', nextState);
console.groupEnd();
};
action.after(() => { if (options.logErrors) {
log(); action.onError((error) => {
}); log(true, error);
});
}
});
};
if (options.logErrors) { export default PiniaLogger;
action.onError((error) => {
log(true, error);
});
}
});
};
export default PiniaLogger;

View File

@ -1,30 +1,75 @@
import { createRouter, createWebHistory, RouteLocationNormalized } from 'vue-router' import {
import Dashboard from '../pages/Dashboard.vue'; createRouter,
import Login from '../pages/Login.vue'; createWebHistory,
import Index from '../pages/Index.vue'; RouteLocationNormalized,
import Register from '../pages/Register.vue'; } from "vue-router";
import Account from '@/pages/Account.vue'; import Dashboard from "../pages/Dashboard.vue";
import Settings from '../pages/Settings.vue'; import Login from "../pages/Login.vue";
import Budgeting from '../pages/Budgeting.vue'; import Index from "../pages/Index.vue";
import BudgetSidebar from '../pages/BudgetSidebar.vue'; import Register from "../pages/Register.vue";
import Account from "@/pages/Account.vue";
import Settings from "../pages/Settings.vue";
import Budgeting from "../pages/Budgeting.vue";
import BudgetSidebar from "../pages/BudgetSidebar.vue";
const routes = [ const routes = [
{ path: "/", name: "Index", component: Index }, { path: "/", name: "Index", component: Index },
{ path: "/dashboard", name: "Dashboard", component: Dashboard, meta: { requiresAuth: true } }, {
{ path: "/login", name: "Login", component: Login, meta: { hideForAuth: true } }, path: "/dashboard",
{ path: "/register", name: "Register", component: Register, meta: { hideForAuth: true } }, name: "Dashboard",
{ path: "/budget/:budgetid/budgeting", name: "Budget", redirect: (to : RouteLocationNormalized) => component: Dashboard,
'/budget/' + to.params.budgetid + '/budgeting/' + new Date().getFullYear() + '/' + new Date().getMonth(), meta: { requiresAuth: true },
meta: { requiresAuth: true } },
}, {
{ path: "/budget/:budgetid/budgeting/:year/:month", name: "Budget with date", components: { default: Budgeting, sidebar: BudgetSidebar }, props: true, meta: { requiresAuth: true } }, path: "/login",
{ path: "/budget/:budgetid/Settings", name: "Budget Settings", components: { default: Settings, sidebar: BudgetSidebar }, props: true, meta: { requiresAuth: true } }, name: "Login",
{ path: "/budget/:budgetid/account/:accountid", name: "Account", components: { default: Account, sidebar: BudgetSidebar }, props: true, meta: { requiresAuth: true } }, component: Login,
] meta: { hideForAuth: true },
},
{
path: "/register",
name: "Register",
component: Register,
meta: { hideForAuth: true },
},
{
path: "/budget/:budgetid/budgeting",
name: "Budget",
redirect: (to: RouteLocationNormalized) =>
"/budget/" +
to.params.budgetid +
"/budgeting/" +
new Date().getFullYear() +
"/" +
new Date().getMonth(),
meta: { requiresAuth: true },
},
{
path: "/budget/:budgetid/budgeting/:year/:month",
name: "Budget with date",
components: { default: Budgeting, sidebar: BudgetSidebar },
props: true,
meta: { requiresAuth: true },
},
{
path: "/budget/:budgetid/Settings",
name: "Budget Settings",
components: { default: Settings, sidebar: BudgetSidebar },
props: true,
meta: { requiresAuth: true },
},
{
path: "/budget/:budgetid/account/:accountid",
name: "Account",
components: { default: Account, sidebar: BudgetSidebar },
props: true,
meta: { requiresAuth: true },
},
];
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes, routes,
}) });
export default router export default router;

View File

@ -1,195 +1,233 @@
import { defineStore } from "pinia" import { defineStore } from "pinia";
import { GET, POST } from "../api"; import { GET, POST } from "../api";
import { useBudgetsStore } from "./budget"; import { useBudgetsStore } from "./budget";
import { useSessionStore } from "./session"; import { useSessionStore } from "./session";
import { useTransactionsStore } from "./transactions"; import { useTransactionsStore } from "./transactions";
interface State { interface State {
Accounts: Map<string, Account> Accounts: Map<string, Account>;
CurrentAccountID: string | null CurrentAccountID: string | null;
Categories: Map<string, Category> Categories: Map<string, Category>;
Months: Map<number, Map<number, Map<string, Category>>> Months: Map<number, Map<number, Map<string, Category>>>;
Assignments: [] Assignments: [];
} }
export interface Account { export interface Account {
ID: string ID: string;
Name: string Name: string;
OnBudget: boolean OnBudget: boolean;
IsOpen: boolean IsOpen: boolean;
ClearedBalance: number ClearedBalance: number;
WorkingBalance: number WorkingBalance: number;
ReconciledBalance: number ReconciledBalance: number;
Transactions: string[] Transactions: string[];
LastReconciled: NullDate LastReconciled: NullDate;
} }
interface NullDate { interface NullDate {
Valid: boolean Valid: boolean;
Time: Date Time: Date;
} }
export interface Category { export interface Category {
ID: string ID: string;
Group: string Group: string;
Name: string Name: string;
AvailableLastMonth: number AvailableLastMonth: number;
Assigned: number Assigned: number;
Activity: number Activity: number;
} }
export const useAccountStore = defineStore("budget/account", { export const useAccountStore = defineStore("budget/account", {
state: (): State => ({ state: (): State => ({
Accounts: new Map<string, Account>(), Accounts: new Map<string, Account>(),
CurrentAccountID: null, CurrentAccountID: null,
Months: new Map<number, Map<number, Map<string, Category>>>(), Months: new Map<number, Map<number, Map<string, Category>>>(),
Categories: new Map<string, Category>(), Categories: new Map<string, Category>(),
Assignments: [], Assignments: [],
}), }),
getters: { getters: {
AccountsList(state) { AccountsList(state) {
return [...state.Accounts.values()]; return [...state.Accounts.values()];
}, },
AllCategoriesForMonth: (state) => (year: number, month: number) => { AllCategoriesForMonth: (state) => (year: number, month: number) => {
const yearMap = state.Months.get(year); const yearMap = state.Months.get(year);
const monthMap = yearMap?.get(month); const monthMap = yearMap?.get(month);
return [...monthMap?.values() || []]; return [...(monthMap?.values() || [])];
}, },
GetCategoryAvailable(state) { GetCategoryAvailable(state) {
return (category: Category): number => { return (category: Category): number => {
return category.AvailableLastMonth + Number(category.Assigned) + category.Activity; return (
} category.AvailableLastMonth +
}, Number(category.Assigned) +
GetIncomeCategoryID(state) { category.Activity
const budget = useBudgetsStore(); );
return budget.CurrentBudget?.IncomeCategoryID; };
}, },
GetIncomeAvailable(state) { GetIncomeCategoryID(state) {
return (year: number, month: number) => { const budget = useBudgetsStore();
const IncomeCategoryID = this.GetIncomeCategoryID; return budget.CurrentBudget?.IncomeCategoryID;
if (IncomeCategoryID == null) },
return 0; GetIncomeAvailable(state) {
return (year: number, month: number) => {
const IncomeCategoryID = this.GetIncomeCategoryID;
if (IncomeCategoryID == null) return 0;
const categories = this.AllCategoriesForMonth(year, month); const categories = this.AllCategoriesForMonth(year, month);
const category = categories.filter(x => x.ID == IncomeCategoryID)[0]; const category = categories.filter(
if (category == null) (x) => x.ID == IncomeCategoryID
return 0; )[0];
return category.AvailableLastMonth; if (category == null) return 0;
} return category.AvailableLastMonth;
}, };
CategoryGroupsForMonth(state) { },
return (year: number, month: number) => { CategoryGroupsForMonth(state) {
const categories = this.AllCategoriesForMonth(year, month); return (year: number, month: number) => {
const categoryGroups = []; const categories = this.AllCategoriesForMonth(year, month);
let prev = undefined; const categoryGroups = [];
for (const category of categories) { let prev = undefined;
if (category.ID == this.GetIncomeCategoryID) for (const category of categories) {
continue; if (category.ID == this.GetIncomeCategoryID) continue;
if (prev == undefined || category.Group != prev.Name) { if (prev == undefined || category.Group != prev.Name) {
prev = { prev = {
Name: category.Group, Name: category.Group,
Available: this.GetCategoryAvailable(category), Available: this.GetCategoryAvailable(category),
AvailableLastMonth: category.AvailableLastMonth, AvailableLastMonth: category.AvailableLastMonth,
Activity: category.Activity, Activity: category.Activity,
Assigned: category.Assigned, Assigned: category.Assigned,
} };
categoryGroups.push({ categoryGroups.push({
...prev, ...prev,
Expand: prev.Name != "Hidden Categories", Expand: prev.Name != "Hidden Categories",
}); });
} else { } else {
categoryGroups[categoryGroups.length-1].Available += this.GetCategoryAvailable(category); categoryGroups[categoryGroups.length - 1].Available +=
categoryGroups[categoryGroups.length-1].AvailableLastMonth += category.AvailableLastMonth; this.GetCategoryAvailable(category);
categoryGroups[categoryGroups.length-1].Activity += category.Activity; categoryGroups[
categoryGroups[categoryGroups.length-1].Assigned += category.Assigned; categoryGroups.length - 1
continue; ].AvailableLastMonth += category.AvailableLastMonth;
} categoryGroups[categoryGroups.length - 1].Activity +=
} category.Activity;
return categoryGroups; categoryGroups[categoryGroups.length - 1].Assigned +=
} category.Assigned;
}, continue;
CategoriesForMonthAndGroup(state) { }
return (year: number, month: number, group: string) => { }
const categories = this.AllCategoriesForMonth(year, month); return categoryGroups;
return categories.filter(x => x.Group == group); };
} },
}, CategoriesForMonthAndGroup(state) {
GetAccount(state) { return (year: number, month: number, group: string) => {
return (accountid: string) => { const categories = this.AllCategoriesForMonth(year, month);
return this.Accounts.get(accountid); return categories.filter((x) => x.Group == group);
} };
}, },
CurrentAccount(state): Account | undefined { GetAccount(state) {
if (state.CurrentAccountID == null) return (accountid: string) => {
return undefined; return this.Accounts.get(accountid);
};
},
CurrentAccount(state): Account | undefined {
if (state.CurrentAccountID == null) return undefined;
return this.GetAccount(state.CurrentAccountID); return this.GetAccount(state.CurrentAccountID);
}, },
OnBudgetAccounts(state) { OnBudgetAccounts(state) {
return [...state.Accounts.values()].filter(x => x.OnBudget); return [...state.Accounts.values()].filter((x) => x.OnBudget);
}, },
OnBudgetAccountsBalance(state): number { OnBudgetAccountsBalance(state): number {
return this.OnBudgetAccounts.reduce((prev, curr) => prev + Number(curr.ClearedBalance), 0); return this.OnBudgetAccounts.reduce(
}, (prev, curr) => prev + Number(curr.ClearedBalance),
OffBudgetAccounts(state) { 0
return [...state.Accounts.values()].filter(x => !x.OnBudget); );
}, },
OffBudgetAccountsBalance(state): number { OffBudgetAccounts(state) {
return this.OffBudgetAccounts.reduce((prev, curr) => prev + Number(curr.ClearedBalance), 0); return [...state.Accounts.values()].filter((x) => !x.OnBudget);
}, },
}, OffBudgetAccountsBalance(state): number {
actions: { return this.OffBudgetAccounts.reduce(
async SetCurrentAccount(budgetid: string, accountid: string) { (prev, curr) => prev + Number(curr.ClearedBalance),
if (budgetid == null) 0
return; );
},
},
actions: {
async SetCurrentAccount(budgetid: string, accountid: string) {
if (budgetid == null) return;
this.CurrentAccountID = accountid; this.CurrentAccountID = accountid;
if (accountid == null) if (accountid == null) return;
return; const account = this.GetAccount(accountid)!;
const account = this.GetAccount(accountid)!; useSessionStore().setTitle(account.Name);
useSessionStore().setTitle(account.Name); await this.FetchAccount(account);
await this.FetchAccount(account); },
}, async FetchAccount(account: Account) {
async FetchAccount(account: Account) { const result = await GET(
const result = await GET("/account/" + account.ID + "/transactions"); "/account/" + account.ID + "/transactions"
const response = await result.json(); );
const transactionsStore = useTransactionsStore() const response = await result.json();
const transactions = transactionsStore.AddTransactions(response.Transactions); const transactionsStore = useTransactionsStore();
account.Transactions = transactions; const transactions = transactionsStore.AddTransactions(
}, response.Transactions
async FetchMonthBudget(budgetid: string, year: number, month: number) { );
const result = await GET("/budget/" + budgetid + "/" + year + "/" + (month + 1)); account.Transactions = transactions;
const response = await result.json(); },
if (response.Categories == undefined || response.Categories.length <= 0) async FetchMonthBudget(budgetid: string, year: number, month: number) {
return; const result = await GET(
this.addCategoriesForMonth(year, month, response.Categories); "/budget/" + budgetid + "/" + year + "/" + (month + 1)
}, );
async EditAccount(accountid: string, name: string, onBudget: boolean, isOpen: boolean) { const response = await result.json();
const result = await POST("/account/" + accountid, JSON.stringify({ name: name, onBudget: onBudget, isOpen: isOpen })); if (
const response = await result.json(); response.Categories == undefined ||
useBudgetsStore().MergeBudgetingData(response); response.Categories.length <= 0
)
return;
this.addCategoriesForMonth(year, month, response.Categories);
},
async EditAccount(
accountid: string,
name: string,
onBudget: boolean,
isOpen: boolean
) {
const result = await POST(
"/account/" + accountid,
JSON.stringify({
name: name,
onBudget: onBudget,
isOpen: isOpen,
})
);
const response = await result.json();
useBudgetsStore().MergeBudgetingData(response);
if (!isOpen) { if (!isOpen) {
this.Accounts.delete(accountid); this.Accounts.delete(accountid);
} }
}, },
addCategoriesForMonth(year: number, month: number, categories: Category[]): void { addCategoriesForMonth(
this.$patch((state) => { year: number,
const yearMap = state.Months.get(year) || new Map<number, Map<string, Category>>(); month: number,
const monthMap = yearMap.get(month) || new Map<string, Category>(); categories: Category[]
for (const category of categories) { ): void {
monthMap.set(category.ID, category); this.$patch((state) => {
} const yearMap =
state.Months.get(year) ||
new Map<number, Map<string, Category>>();
const monthMap =
yearMap.get(month) || new Map<string, Category>();
for (const category of categories) {
monthMap.set(category.ID, category);
}
yearMap.set(month, monthMap); yearMap.set(month, monthMap);
state.Months.set(year, yearMap); state.Months.set(year, yearMap);
}); });
}, },
logout() { logout() {
this.$reset() this.$reset();
}, },
} },
});
})

View File

@ -4,67 +4,67 @@ import { useAccountStore } from "./budget-account";
import { Budget, useSessionStore } from "./session"; import { Budget, useSessionStore } from "./session";
interface State { interface State {
CurrentBudgetID: string | null, CurrentBudgetID: string | null;
} }
export const useBudgetsStore = defineStore('budget', { export const useBudgetsStore = defineStore("budget", {
state: (): State => ({ state: (): State => ({
CurrentBudgetID: null, CurrentBudgetID: null,
}), }),
getters: { getters: {
CurrentBudget(): Budget | undefined { CurrentBudget(): Budget | undefined {
if (this.CurrentBudgetID == null) if (this.CurrentBudgetID == null) return undefined;
return undefined;
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
return sessionStore.Budgets.get(this.CurrentBudgetID); return sessionStore.Budgets.get(this.CurrentBudgetID);
}, },
CurrentBudgetName(state): string { CurrentBudgetName(state): string {
return this.CurrentBudget?.Name ?? ""; return this.CurrentBudget?.Name ?? "";
}, },
}, },
actions: { actions: {
ImportYNAB(formData: FormData) { ImportYNAB(formData: FormData) {
return POST( return POST(
"/budget/" + this.CurrentBudgetID + "/import/ynab", "/budget/" + this.CurrentBudgetID + "/import/ynab",
formData, formData
); );
}, },
async NewBudget(budgetName: string): Promise<void> { async NewBudget(budgetName: string): Promise<void> {
const result = await POST( const result = await POST(
"/budget/new", "/budget/new",
JSON.stringify({ name: budgetName }) JSON.stringify({ name: budgetName })
); );
const response = await result.json(); const response = await result.json();
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
sessionStore.Budgets.set(response.ID, response); sessionStore.Budgets.set(response.ID, response);
}, },
async SetCurrentBudget(budgetid: string): Promise<void> { async SetCurrentBudget(budgetid: string): Promise<void> {
this.CurrentBudgetID = budgetid; this.CurrentBudgetID = budgetid;
if (budgetid == null) if (budgetid == null) return;
return
await this.FetchBudget(budgetid); await this.FetchBudget(budgetid);
}, },
async FetchBudget(budgetid: string) { async FetchBudget(budgetid: string) {
const result = await GET("/budget/" + budgetid); const result = await GET("/budget/" + budgetid);
const response = await result.json(); const response = await result.json();
this.MergeBudgetingData(response); this.MergeBudgetingData(response);
}, },
MergeBudgetingData(response: any) { MergeBudgetingData(response: any) {
const accounts = useAccountStore(); const accounts = useAccountStore();
for (const account of response.Accounts || []) { for (const account of response.Accounts || []) {
const existingAccount = accounts.Accounts.get(account.ID); const existingAccount = accounts.Accounts.get(account.ID);
account.Transactions = existingAccount?.Transactions ?? []; account.Transactions = existingAccount?.Transactions ?? [];
if(account.LastReconciled.Valid) if (account.LastReconciled.Valid)
account.LastReconciled.Time = new Date(account.LastReconciled.Time); account.LastReconciled.Time = new Date(
accounts.Accounts.set(account.ID, account); account.LastReconciled.Time
} );
for (const category of response.Categories || []) { accounts.Accounts.set(account.ID, account);
accounts.Categories.set(category.ID, category); }
} for (const category of response.Categories || []) {
}, accounts.Categories.set(category.ID, category);
} }
}) },
},
});

View File

@ -1,62 +1,74 @@
import { StorageSerializers, useStorage } from '@vueuse/core'; import { StorageSerializers, useStorage } from "@vueuse/core";
import { defineStore } from 'pinia' import { defineStore } from "pinia";
import { POST } from '../api'; import { POST } from "../api";
interface State { interface State {
Session: Session | null Session: Session | null;
Budgets: Map<string, Budget>, Budgets: Map<string, Budget>;
} }
interface Session { interface Session {
Token: string Token: string;
User: string User: string;
} }
export interface Budget { export interface Budget {
ID: string ID: string;
Name: string Name: string;
AvailableBalance: number AvailableBalance: number;
IncomeCategoryID: string IncomeCategoryID: string;
} }
export const useSessionStore = defineStore('session', { export const useSessionStore = defineStore("session", {
state: () => ({ state: () => ({
Session: useStorage<Session | null>('session', null, undefined, { serializer: StorageSerializers.object }), Session: useStorage<Session | null>("session", null, undefined, {
Budgets: useStorage<Map<string, Budget>>('budgets', new Map<string, Budget>(), undefined, { serializer: StorageSerializers.map }), serializer: StorageSerializers.object,
}), }),
getters: { Budgets: useStorage<Map<string, Budget>>(
BudgetsList: (state) => [ ...state.Budgets.values() ], "budgets",
AuthHeaders: (state) => ({'Authorization': 'Bearer ' + state.Session?.Token}), new Map<string, Budget>(),
LoggedIn: (state) => state.Session != null, undefined,
}, { serializer: StorageSerializers.map }
actions: { ),
setTitle(title : string) { }),
document.title = "Budgeteer - " + title; getters: {
}, BudgetsList: (state) => [...state.Budgets.values()],
loginSuccess(x : any) { AuthHeaders: (state) => ({
this.Session = { Authorization: "Bearer " + state.Session?.Token,
User: x.User, }),
Token: x.Token, LoggedIn: (state) => state.Session != null,
} },
for (const budget of x.Budgets) { actions: {
this.Budgets.set(budget.ID, budget); setTitle(title: string) {
} document.title = "Budgeteer - " + title;
}, },
async login(login: any) { loginSuccess(x: any) {
const response = await POST("/user/login", JSON.stringify(login)); this.Session = {
const result = await response.json(); User: x.User,
this.loginSuccess(result); Token: x.Token,
return result; };
}, for (const budget of x.Budgets) {
async register(login : any) { this.Budgets.set(budget.ID, budget);
const response = await POST("/user/register", JSON.stringify(login)); }
const result = await response.json(); },
this.loginSuccess(result); async login(login: any) {
return result; const response = await POST("/user/login", JSON.stringify(login));
}, const result = await response.json();
logout() { this.loginSuccess(result);
this.Session = null; return result;
this.Budgets.clear(); },
}, async register(login: any) {
} const response = await POST(
}) "/user/register",
JSON.stringify(login)
);
const result = await response.json();
this.loginSuccess(result);
return result;
},
logout() {
this.Session = null;
this.Budgets.clear();
},
},
});

View File

@ -2,27 +2,27 @@ import { useStorage } from "@vueuse/core";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
interface State { interface State {
Menu: MenuSettings Menu: MenuSettings;
} }
interface MenuSettings { interface MenuSettings {
Show: boolean | null, Show: boolean | null;
Expand: boolean | null, Expand: boolean | null;
} }
export const useSettingsStore = defineStore('settings', { export const useSettingsStore = defineStore("settings", {
state: () => ({ state: () => ({
Menu: useStorage<MenuSettings>('settings', { Menu: useStorage<MenuSettings>("settings", {
Show: null, Show: null,
Expand: false, Expand: false,
}), }),
}), }),
actions: { actions: {
toggleMenu() { toggleMenu() {
this.Menu.Show = !this.Menu.Show; this.Menu.Show = !this.Menu.Show;
}, },
toggleMenuSize() { toggleMenuSize() {
this.Menu.Expand = !this.Menu.Expand; this.Menu.Expand = !this.Menu.Expand;
}, },
} },
}); });

View File

@ -1,105 +1,110 @@
import { defineStore } from "pinia" import { defineStore } from "pinia";
import { POST } from "../api"; import { POST } from "../api";
import { useAccountStore } from "./budget-account"; import { useAccountStore } from "./budget-account";
interface State { interface State {
Transactions: Map<string, Transaction> Transactions: Map<string, Transaction>;
Reconciling: boolean Reconciling: boolean;
} }
export interface Transaction { export interface Transaction {
ID: string ID: string;
Date: Date Date: Date;
TransferAccount: string TransferAccount: string;
CategoryGroup: string CategoryGroup: string;
Category: string Category: string;
CategoryID: string | undefined CategoryID: string | undefined;
Memo: string Memo: string;
Status: string Status: string;
GroupID: string GroupID: string;
Payee: string Payee: string;
PayeeID: string | undefined PayeeID: string | undefined;
Amount: number Amount: number;
Reconciled: boolean Reconciled: boolean;
} }
export const useTransactionsStore = defineStore("budget/transactions", { export const useTransactionsStore = defineStore("budget/transactions", {
state: (): State => ({ state: (): State => ({
Transactions: new Map<string, Transaction>(), Transactions: new Map<string, Transaction>(),
Reconciling: false, Reconciling: false,
}), }),
getters: { getters: {
ReconcilingBalance(state): number { ReconcilingBalance(state): number {
const accountsStore = useAccountStore() const accountsStore = useAccountStore();
let reconciledBalance = accountsStore.CurrentAccount!.ReconciledBalance; let reconciledBalance =
for (const transaction of this.TransactionsList) { accountsStore.CurrentAccount!.ReconciledBalance;
if (transaction.Reconciled) for (const transaction of this.TransactionsList) {
reconciledBalance += transaction.Amount; if (transaction.Reconciled)
} reconciledBalance += transaction.Amount;
return reconciledBalance; }
}, return reconciledBalance;
TransactionsList(state): Transaction[] { },
const accountsStore = useAccountStore() TransactionsList(state): Transaction[] {
return accountsStore.CurrentAccount!.Transactions.map(x => { const accountsStore = useAccountStore();
return this.Transactions.get(x)! return accountsStore.CurrentAccount!.Transactions.map((x) => {
}); return this.Transactions.get(x)!;
}, });
}, },
actions: { },
AddTransactions(transactions: Array<Transaction>) { actions: {
const transactionIds = [] as Array<string>; AddTransactions(transactions: Array<Transaction>) {
this.$patch(() => { const transactionIds = [] as Array<string>;
for (const transaction of transactions) { this.$patch(() => {
transaction.Date = new Date(transaction.Date); for (const transaction of transactions) {
this.Transactions.set(transaction.ID, transaction); transaction.Date = new Date(transaction.Date);
transactionIds.push(transaction.ID); this.Transactions.set(transaction.ID, transaction);
} transactionIds.push(transaction.ID);
}); }
return transactionIds; });
}, return transactionIds;
SetReconciledForAllTransactions(value: boolean) { },
for (const transaction of this.TransactionsList) { SetReconciledForAllTransactions(value: boolean) {
if (transaction.Status == "Reconciled") for (const transaction of this.TransactionsList) {
continue; if (transaction.Status == "Reconciled") continue;
transaction.Reconciled = value; transaction.Reconciled = value;
} }
}, },
async SubmitReconcilation(reconciliationTransactionAmount: number) { async SubmitReconcilation(reconciliationTransactionAmount: number) {
const accountsStore = useAccountStore() const accountsStore = useAccountStore();
const account = accountsStore.CurrentAccount!; const account = accountsStore.CurrentAccount!;
const reconciledTransactions = this.TransactionsList.filter(x => x.Reconciled); const reconciledTransactions = this.TransactionsList.filter(
for (const transaction of reconciledTransactions) { (x) => x.Reconciled
account.ReconciledBalance += transaction.Amount; );
transaction.Status = "Reconciled"; for (const transaction of reconciledTransactions) {
transaction.Reconciled = false; account.ReconciledBalance += transaction.Amount;
} transaction.Status = "Reconciled";
const result = await POST("/account/" + accountsStore.CurrentAccountID + "/reconcile", JSON.stringify({ transaction.Reconciled = false;
transactionIDs: reconciledTransactions.map(x => x.ID), }
reconciliationTransactionAmount: reconciliationTransactionAmount.toString(), const result = await POST(
})); "/account/" + accountsStore.CurrentAccountID + "/reconcile",
const response = await result.json(); JSON.stringify({
const recTrans = response.ReconciliationTransaction; transactionIDs: reconciledTransactions.map((x) => x.ID),
if (recTrans) { reconciliationTransactionAmount:
this.AddTransactions([recTrans]); reconciliationTransactionAmount.toString(),
account.Transactions.unshift(recTrans.ID); })
} );
}, const response = await result.json();
logout() { const recTrans = response.ReconciliationTransaction;
this.$reset() if (recTrans) {
}, this.AddTransactions([recTrans]);
async saveTransaction(payload: string) { account.Transactions.unshift(recTrans.ID);
const accountsStore = useAccountStore() }
const result = await POST("/transaction/new", payload); },
const response = await result.json() as Transaction; logout() {
this.AddTransactions([response]); this.$reset();
accountsStore.CurrentAccount?.Transactions.unshift(response.ID); },
}, async saveTransaction(payload: string) {
async editTransaction(transactionid: string, payload: string) { const accountsStore = useAccountStore();
const result = await POST("/transaction/" + transactionid, payload); const result = await POST("/transaction/new", payload);
const response = await result.json() as Transaction; const response = (await result.json()) as Transaction;
this.AddTransactions([response]); this.AddTransactions([response]);
} accountsStore.CurrentAccount?.Transactions.unshift(response.ID);
} },
async editTransaction(transactionid: string, payload: string) {
}) const result = await POST("/transaction/" + transactionid, payload);
const response = (await result.json()) as Transaction;
this.AddTransactions([response]);
},
},
});

View File

@ -1,10 +1,7 @@
module.exports = { module.exports = {
content: [ content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
"./index.html", theme: {
"./src/**/*.{vue,js,ts,jsx,tsx}" extend: {},
], },
theme: { plugins: [],
extend: {}, };
},
plugins: [],
}

View File

@ -1,10 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "esnext", "target": "esnext",
"module": "esnext", "module": "esnext",
// this enables stricter inference for data properties on `this` // this enables stricter inference for data properties on `this`
"strict": true, "strict": true,
"jsx": "preserve", "jsx": "preserve",
"moduleResolution": "node" "moduleResolution": "node"
} }
} }

View File

@ -1,30 +1,30 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import vue from '@vitejs/plugin-vue' import vue from "@vitejs/plugin-vue";
import path from 'path' import path from "path";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
// https://github.com/vuetifyjs/vuetify-loader/tree/next/packages/vite-plugin // https://github.com/vuetifyjs/vuetify-loader/tree/next/packages/vite-plugin
], ],
define: { 'process.env': {} }, define: { "process.env": {} },
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, 'src'), "@": path.resolve(__dirname, "src"),
}, },
}, },
server: { server: {
host: '0.0.0.0', host: "0.0.0.0",
proxy: { proxy: {
'/api': { "/api": {
target: 'http://10.0.0.162:1323/', target: "http://10.0.0.162:1323/",
changeOrigin: true changeOrigin: true,
} },
} },
} },
/* remove the need to specify .vue files https://vitejs.dev/config/#resolve-extensions /* remove the need to specify .vue files https://vitejs.dev/config/#resolve-extensions
resolve: { resolve: {
extensions: [ extensions: [
'.js', '.js',
@ -37,4 +37,4 @@ export default defineConfig({
] ]
}, },
*/ */
}) });