Improve mobile usability #30

Merged
jacob1123 merged 14 commits from mobile-usability into master 2022-03-01 21:33:07 +01:00
11 changed files with 150 additions and 104 deletions

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
@ -29,11 +30,14 @@ func main() {
panic("couldn't open static files") panic("couldn't open static files")
} }
tokenVerifier, err := jwt.NewTokenVerifier(cfg.SessionSecret)
if err != nil {
panic(fmt.Errorf("couldn't create token verifier: %w", err))
}
handler := &server.Handler{ handler := &server.Handler{
Service: queries, Service: queries,
TokenVerifier: &jwt.TokenVerifier{ TokenVerifier: tokenVerifier,
Secret: cfg.SessionSecret,
},
CredentialsVerifier: &bcrypt.Verifier{}, CredentialsVerifier: &bcrypt.Verifier{},
StaticFS: http.FS(static), StaticFS: http.FS(static),
} }

View File

@ -12,7 +12,19 @@ import (
// TokenVerifier verifies Tokens. // TokenVerifier verifies Tokens.
type TokenVerifier struct { type TokenVerifier struct {
Secret string secret string
}
var ErrEmptySecret = fmt.Errorf("secret is required")
func NewTokenVerifier(secret string) (*TokenVerifier, error) {
if secret == "" {
return nil, ErrEmptySecret
}
return &TokenVerifier{
secret: secret,
}, nil
} }
// Token contains everything to authenticate a user. // Token contains everything to authenticate a user.
@ -29,6 +41,10 @@ const (
// CreateToken creates a new token from username and name. // CreateToken creates a new token from username and name.
func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) { func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
if tv.secret == "" {
return "", ErrEmptySecret
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"usr": user.Email, "usr": user.Email,
"name": user.Name, "name": user.Name,
@ -37,7 +53,7 @@ func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
}) })
// Generate encoded token and send it as response. // Generate encoded token and send it as response.
t, err := token.SignedString([]byte(tv.Secret)) t, err := token.SignedString([]byte(tv.secret))
if err != nil { if err != nil {
return "", fmt.Errorf("create token: %w", err) return "", fmt.Errorf("create token: %w", err)
} }
@ -53,11 +69,15 @@ var (
// VerifyToken verifys a given string-token. // VerifyToken verifys a given string-token.
func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) { //nolint:ireturn func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) { //nolint:ireturn
if tv.secret == "" {
return nil, ErrEmptySecret
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("method '%v': %w", token.Header["alg"], ErrUnexpectedSigningMethod) return nil, fmt.Errorf("method '%v': %w", token.Header["alg"], ErrUnexpectedSigningMethod)
} }
return []byte(tv.Secret), nil return []byte(tv.secret), nil
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("parse jwt: %w", err) return nil, fmt.Errorf("parse jwt: %w", err)

View File

@ -28,11 +28,10 @@ func TestRegisterUser(t *testing.T) { //nolint:funlen
return return
} }
tokenVerifier, _ := jwt.NewTokenVerifier("this_is_my_demo_secret_for_unit_tests")
h := Handler{ h := Handler{
Service: database, Service: database,
TokenVerifier: &jwt.TokenVerifier{ TokenVerifier: tokenVerifier,
Secret: "this_is_my_demo_secret_for_unit_tests",
},
CredentialsVerifier: &bcrypt.Verifier{}, CredentialsVerifier: &bcrypt.Verifier{},
} }

View File

@ -3,7 +3,7 @@
<template> <template>
<button <button
class="px-4 py-2 text-base font-medium 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> <slot></slot>
</button> </button>

View File

@ -2,7 +2,7 @@
</script> </script>
<template> <template>
<div class="flex flex-row items-center bg-gray-300 h-32 rounded-lg"> <div class="flex flex-row items-center bg-gray-300 rounded-lg">
<slot></slot> <slot></slot>
</div> </div>
</template> </template>

View File

@ -3,7 +3,7 @@ import Card from '../components/Card.vue';
import { ref } from "vue"; import { ref } from "vue";
const props = defineProps<{ const props = defineProps<{
buttonText: string, buttonText?: string,
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -3,6 +3,7 @@ import { computed, ref } from "vue";
import Autocomplete from '../components/Autocomplete.vue' import Autocomplete from '../components/Autocomplete.vue'
import { Transaction, useTransactionsStore } from "../stores/transactions"; import { Transaction, useTransactionsStore } from "../stores/transactions";
import DateInput from "./DateInput.vue"; import DateInput from "./DateInput.vue";
import Button from "./Button.vue";
const props = defineProps<{ const props = defineProps<{
budgetid: string budgetid: string
@ -51,28 +52,31 @@ function saveTransaction(e: MouseEvent) {
<template> <template>
<tr> <tr>
<td style="width: 90px;" class="text-sm"> <label class="md:hidden">Date</label>
<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 style="max-width: 150px;"> <label class="md:hidden">Payee</label>
<td>
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" /> <Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" />
</td> </td>
<td style="max-width: 200px;"> <label class="md:hidden">Category</label>
<td>
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" /> <Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" />
</td> </td>
<td> <td class="col-span-2">
<input class="block w-full border-b-2 border-black" type="text" v-model="TX.Memo" /> <input class="block w-full border-b-2 border-black" type="text" v-model="TX.Memo" />
</td> </td>
<td style="width: 80px;" class="text-right"> <label class="md:hidden">Amount</label>
<td class="text-right">
<input <input
class="text-right block w-full border-b-2 border-black" class="text-right block w-full border-b-2 border-black"
type="currency" type="currency"
v-model="TX.Amount" v-model="TX.Amount"
/> />
</td> </td>
<td style="width: 20px;"> <td class="hidden md:table-cell">
<input type="submit" @click="saveTransaction" value="Save" /> <Button class="bg-blue-500" @click="saveTransaction">Save</Button>
</td> </td>
<td style="width: 20px;"></td>
</tr> </tr>
</template> </template>

View File

@ -5,6 +5,7 @@ import { useTransactionsStore } from "../stores/transactions";
import Currency from "./Currency.vue"; import Currency from "./Currency.vue";
import TransactionEditRow from "./TransactionEditRow.vue"; import TransactionEditRow from "./TransactionEditRow.vue";
import { formatDate } from "../date"; import { formatDate } from "../date";
import { useAccountStore } from "../stores/budget-account";
const props = defineProps<{ const props = defineProps<{
transactionid: string, transactionid: string,
@ -18,17 +19,43 @@ const Reconciling = computed(() => useTransactionsStore().Reconciling);
const transactionsStore = useTransactionsStore(); const transactionsStore = useTransactionsStore();
const TX = transactionsStore.Transactions.get(props.transactionid)!; const TX = transactionsStore.Transactions.get(props.transactionid)!;
function dateChanged() {
const currentAccount = useAccountStore().CurrentAccount;
if (currentAccount == null)
return true;
const transactionIndex = currentAccount.Transactions.indexOf(props.transactionid);
if(transactionIndex<=0)
return true;
const previousTransactionId = currentAccount.Transactions[transactionIndex-1];
const previousTransaction = transactionsStore.Transactions.get(previousTransactionId);
return TX.Date.getTime() != previousTransaction?.Date.getTime();
}
function getStatusSymbol() {
if(TX.Status == "Reconciled")
return "✔";
if(TX.Status == "Uncleared")
return "*";
return "✘";
}
</script> </script>
<template> <template>
<tr v-if="dateChanged()" class="table-row md:hidden">
<td class="bg-gray-200 rounded-lg p-2" colspan="5">{{ formatDate(TX.Date) }}</td>
</tr>
<tr <tr
v-if="!edit" v-if="!edit"
class="{{new Date(TX.Date) > new Date() ? 'future' : ''}}" class="{{new Date(TX.Date) > new Date() ? 'future' : ''}}"
:class="[index % 6 < 3 ? 'bg-gray-300' : 'bg-gray-100']" :class="[index % 6 < 3 ? 'md:bg-gray-300' : 'md:bg-gray-100']"
> >
<!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">--> <!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">-->
<td>{{ formatDate(TX.Date) }}</td> <td class="hidden md:block">{{ formatDate(TX.Date) }}</td>
<td>{{ TX.TransferAccount ? "Transfer : " + TX.TransferAccount : TX.Payee }}</td> <td class="pl-2 md:pl-0">{{ TX.TransferAccount ? "Transfer : " + TX.TransferAccount : TX.Payee }}</td>
<td>{{ TX.CategoryGroup ? TX.CategoryGroup + " : " + TX.Category : "" }}</td> <td>{{ TX.CategoryGroup ? TX.CategoryGroup + " : " + TX.Category : "" }}</td>
<td> <td>
<a <a
@ -38,13 +65,11 @@ const TX = transactionsStore.Transactions.get(props.transactionid)!;
<td> <td>
<Currency class="block" :value="TX.Amount" /> <Currency class="block" :value="TX.Amount" />
</td> </td>
<td>{{ TX.Status == "Reconciled" ? "✔" : (TX.Status == "Uncleared" ? "" : "*") }}</td>
<td class="text-right"> <td class="text-right">
{{ TX.GroupID ? "☀" : "" }} {{ TX.GroupID ? "☀" : "" }}
{{ getStatusSymbol() }}
<a @click="edit = true;"></a> <a @click="edit = true;"></a>
</td> <input v-if="Reconciling && TX.Status != 'Reconciled'" type="checkbox" v-model="TX.Reconciled" />
<td v-if="Reconciling && TX.Status != 'Reconciled'">
<input type="checkbox" v-model="TX.Reconciled" />
</td> </td>
</tr> </tr>
<TransactionEditRow v-if="edit" :transactionid="TX.ID" @save="edit = false" /> <TransactionEditRow v-if="edit" :transactionid="TX.ID" @save="edit = false" />

View File

@ -7,6 +7,7 @@ import { useAccountStore } from "../stores/budget-account";
import EditAccount from "../dialogs/EditAccount.vue"; import EditAccount from "../dialogs/EditAccount.vue";
import Button from "../components/Button.vue"; import Button from "../components/Button.vue";
import { useTransactionsStore } from "../stores/transactions"; import { useTransactionsStore } from "../stores/transactions";
import Modal from "../components/Modal.vue";
defineProps<{ defineProps<{
budgetid: string budgetid: string
@ -47,18 +48,18 @@ function createReconcilationTransaction() {
</h1> </h1>
<div class="text-right"> <div class="text-right">
<span class="border-2 rounded-lg p-1"> <span class="border-2 rounded-lg p-1 whitespace-nowrap">
Working: Working:
<Currency :value="accounts.CurrentAccount?.WorkingBalance" /> <Currency :value="accounts.CurrentAccount?.WorkingBalance" />
</span> </span>
<span class="border-2 rounded-lg p-1 ml-2"> <span class="border-2 rounded-lg p-1 ml-2 whitespace-nowrap">
Cleared: Cleared:
<Currency :value="accounts.CurrentAccount?.ClearedBalance" /> <Currency :value="accounts.CurrentAccount?.ClearedBalance" />
</span> </span>
<span <span
class="border-2 border-blue-500 rounded-lg bg-blue-500 ml-2 p-1" class="border-2 border-blue-500 rounded-lg bg-blue-500 ml-2 p-1 whitespace-nowrap"
v-if="!transactions.Reconciling" v-if="!transactions.Reconciling"
@click="transactions.Reconciling = true" @click="transactions.Reconciling = true"
> >
@ -70,34 +71,32 @@ function createReconcilationTransaction() {
<span v-if="transactions.Reconciling" class="border-2 block bg-gray-200 rounded-lg p-2"> <span v-if="transactions.Reconciling" class="border-2 block bg-gray-200 rounded-lg p-2">
Is Is
<Currency :value="transactions.ReconcilingBalance" />your current balance? <Currency :value="transactions.ReconcilingBalance" />your current balance?
<Button class="bg-blue-500 mx-3" @click="submitReconcilation">Yes!</Button> <Button class="bg-blue-500 mx-3 py-2" @click="submitReconcilation">Yes!</Button>
<br />No, it's: <br />No, it's:
<input class="text-right" type="number" v-model="TargetReconcilingBalance" /> <input class="text-right" type="number" v-model="TargetReconcilingBalance" />
Difference: Difference:
<Currency :value="transactions.ReconcilingBalance - TargetReconcilingBalance" /> <Currency :value="transactions.ReconcilingBalance - TargetReconcilingBalance" />
<Button <Button
class="bg-orange-500 mx-3" class="bg-orange-500 mx-3 py-2"
v-if="Math.abs(transactions.ReconcilingBalance - TargetReconcilingBalance) > 0.01" v-if="Math.abs(transactions.ReconcilingBalance - TargetReconcilingBalance) > 0.01"
@click="createReconcilationTransaction" @click="createReconcilationTransaction"
>Create reconciling Transaction</Button> >Create reconciling Transaction</Button>
<Button class="bg-red-500 mx-3" @click="cancelReconcilation">Cancel</Button> <Button class="bg-red-500 mx-3 py-2" @click="cancelReconcilation">Cancel</Button>
</span> </span>
</div> </div>
<table> <table>
<tr class="font-bold"> <tr class="font-bold">
<td 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: 20px;"></td> <td style="width: 80px;">
<td style="width: 40px;"></td> <input v-if="transactions.Reconciling" type="checkbox" @input="setReconciled" />
<td style="width: 20px;" v-if="transactions.Reconciling">
<input type="checkbox" @input="setReconciled" />
</td> </td>
</tr> </tr>
<TransactionInputRow :budgetid="budgetid" :accountid="accountid" /> <TransactionInputRow class="hidden md:table-row" :budgetid="budgetid" :accountid="accountid" />
<TransactionRow <TransactionRow
v-for="(transaction, index) in transactions.TransactionsList" v-for="(transaction, index) in transactions.TransactionsList"
:key="transaction.ID" :key="transaction.ID"
@ -105,6 +104,14 @@ function createReconcilationTransaction() {
:index="index" :index="index"
/> />
</table> </table>
<div class="md:hidden">
<Modal>
<template v-slot:placeholder>
<Button class="fixed right-4 bottom-4 font-bold text-lg bg-blue-500 py-2">+</Button>
</template>
<TransactionInputRow class="grid grid-cols-2" :budgetid="budgetid" :accountid="accountid" />
</Modal>
</div>
</template> </template>
<style> <style>

View File

@ -17,7 +17,7 @@ const CurrentBudgetID = computed(() => budgetsStore.CurrentBudgetID);
const accountStore = useAccountStore(); const accountStore = useAccountStore();
const categoriesForMonth = accountStore.CategoriesForMonthAndGroup; const categoriesForMonth = accountStore.CategoriesForMonthAndGroup;
function GetCategories(group : string) { function GetCategories(group: string) {
return [...categoriesForMonth(selected.value.Year, selected.value.Month, group)]; return [...categoriesForMonth(selected.value.Year, selected.value.Month, group)];
}; };
@ -56,12 +56,12 @@ onMounted(() => {
const expandedGroups = ref<Map<string, boolean>>(new Map<string, boolean>()) const expandedGroups = ref<Map<string, boolean>>(new Map<string, boolean>())
function toggleGroup(group : {Name : string, Expand: boolean}) { function toggleGroup(group: { Name: string, Expand: boolean }) {
console.log(expandedGroups.value); console.log(expandedGroups.value);
expandedGroups.value.set(group.Name, !(expandedGroups.value.get(group.Name) ?? group.Expand)) expandedGroups.value.set(group.Name, !(expandedGroups.value.get(group.Name) ?? group.Expand))
} }
function getGroupState(group : {Name : string, Expand: boolean}) : boolean { function getGroupState(group: { Name: string, Expand: boolean }): boolean {
return expandedGroups.value.get(group.Name) ?? group.Expand; return expandedGroups.value.get(group.Name) ?? group.Expand;
} }
</script> </script>
@ -71,43 +71,32 @@ function getGroupState(group : {Name : string, Expand: boolean}) : boolean {
<div> <div>
<router-link <router-link
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month" :to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"
>Previous Month</router-link>- >&lt;&lt;</router-link>&nbsp;
<router-link <router-link
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + current.Year + '/' + current.Month" :to="'/budget/' + CurrentBudgetID + '/budgeting/' + current.Year + '/' + current.Month"
>Current Month</router-link>- >Current Month</router-link>&nbsp;
<router-link <router-link
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + next.Year + '/' + next.Month" :to="'/budget/' + CurrentBudgetID + '/budgeting/' + next.Year + '/' + next.Month"
>Next Month</router-link> >&gt;&gt;</router-link>
</div> </div>
<table class="container col-lg-12" id="content"> <div class="container col-lg-12 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5" id="content">
<tr> <span class="hidden sm:block"></span>
<th>Category</th> <span class="hidden lg:block text-right">Leftover</span>
<th></th> <span class="hidden sm:block text-right">Assigned</span>
<th></th> <span class="hidden sm:block text-right">Activity</span>
<th>Leftover</th> <span class="hidden sm:block text-right">Available</span>
<th>Assigned</th> <template v-for="group in GroupsForMonth">
<th>Activity</th> <a
<th>Available</th> class="text-lg font-bold col-span-2 sm:col-span-4 lg:col-span-5"
</tr> @click="toggleGroup(group)"
<tbody v-for="group in GroupsForMonth"> >{{ (getGroupState(group) ? "" : "+") + " " + group.Name }}</a>
<a class="text-lg font-bold" @click="toggleGroup(group)">{{ (getGroupState(group) ? "" : "+") + " " + group.Name }}</a> <template v-for="category in GetCategories(group.Name)" v-if="getGroupState(group)">
<tr v-for="category in GetCategories(group.Name)" v-if="getGroupState(group)"> <span class="whitespace-nowrap overflow-hidden">{{ category.Name }}</span>
<td>{{ category.Name }}</td> <Currency :value="category.AvailableLastMonth" class="hidden lg:block" />
<td></td> <Currency :value="category.Assigned" class="hidden sm:block" />
<td></td> <Currency :value="category.Activity" class="hidden sm:block" />
<td class="text-right">
<Currency :value="category.AvailableLastMonth" />
</td>
<td class="text-right">
<Currency :value="category.Assigned" />
</td>
<td class="text-right">
<Currency :value="category.Activity" />
</td>
<td class="text-right">
<Currency :value="category.Available" /> <Currency :value="category.Available" />
</td> </template>
</tr> </template>
</tbody> </div>
</table>
</template> </template>

View File

@ -82,22 +82,21 @@ function ynabExport() {
<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" @click="clearBudget">Clear budget</Button> <Button class="bg-red-500 py-2" @click="clearBudget">Clear budget</Button>
</Card> </Card>
<Card class="flex-col p-3"> <Card class="flex-col p-3">
<h2 class="text-lg font-bold">Delete Budget</h2> <h2 class="text-lg font-bold">Delete Budget</h2>
<p>This deletes the whole bugdet including all transactions, assignments, accounts and categories. Not undoable!</p> <p>This deletes the whole bugdet including all transactions, assignments, accounts and categories. Not undoable!</p>
<Button class="bg-red-500" @click="deleteBudget">Delete budget</button> <Button class="bg-red-500 py-2" @click="deleteBudget">Delete budget</button>
</Card> </Card>
<Card class="flex-col p-3"> <Card class="flex-col p-3">
<h2 class="text-lg font-bold">Fix all historic negative category-balances</h2> <h2 class="text-lg font-bold">Fix all historic negative category-balances</h2>
<p>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</p> <p>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</p>
<Button class="bg-orange-500" @click="cleanNegative">Fix negative</button> <Button class="bg-orange-500 py-2" @click="cleanNegative">Fix negative</button>
</Card> </Card>
<Card class="flex-col p-3"> <Card class="flex-col p-3">
<h2 class="text-lg font-bold">Import YNAB Budget</h2> <h2 class="text-lg font-bold">Import YNAB Budget</h2>
<div class="flex flex-row">
<div> <div>
<label for="transactions_file"> <label for="transactions_file">
Transaktionen: Transaktionen:
@ -110,14 +109,13 @@ function ynabExport() {
</label> </label>
</div> </div>
<Button class="bg-blue-500" :disabled="filesIncomplete" @click="ynabImport">Importieren</Button> <Button class="bg-blue-500 py-2" :disabled="filesIncomplete" @click="ynabImport">Importieren</Button>
</div>
</Card> </Card>
<Card class="flex-col p-3"> <Card class="flex-col p-3">
<h2 class="text-lg font-bold">Export as YNAB TSV</h2> <h2 class="text-lg font-bold">Export as YNAB TSV</h2>
<div class="flex flex-row"> <div class="flex flex-row">
<Button class="bg-blue-500" @click="ynabExport">Export</Button> <Button class="bg-blue-500 py-2" @click="ynabExport">Export</Button>
</div> </div>
</Card> </Card>
</div> </div>