31 Commits
0.4.1 ... 0.4.3

Author SHA1 Message Date
af8fda178d Merge pull request 'Improve CI image' (#24) from ci-improvements into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #24
2022-02-26 00:01:31 +01:00
71f4d2384b Fetch image only if not in PR
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-02-25 23:00:15 +00:00
eb52bd5def Fix pull definition
Some checks reported errors
continuous-integration/drone/pr Build encountered an error
continuous-integration/drone/push Build is passing
2022-02-25 22:57:19 +00:00
adcb64f4ad Remove unused method
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-02-25 22:55:48 +00:00
a73c5ce04b Build images only on master push or tag
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2022-02-25 22:54:51 +00:00
52bb343402 Reorganize routes and remove legacy ones
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2022-02-25 22:52:08 +00:00
27ce8d250f Move WORKDIR call
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-02-25 22:44:09 +00:00
408207df92 Fix yarn call not doing anything
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-02-25 22:39:49 +00:00
98890f10eb Merge pull request 'Implement editing of transactions' (#23) from edit-transaction into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #23
2022-02-25 23:35:10 +01:00
5621d63436 Actually call backend for edit
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-02-25 22:34:09 +00:00
05099e469f Delete transaction when amount is zero 2022-02-25 22:33:58 +00:00
ae9e9d34c9 Use type instead of isAccount flag
All checks were successful
continuous-integration/drone/push Build is passing
2022-02-25 22:28:22 +00:00
32439e3e87 rename type to model 2022-02-25 22:16:41 +00:00
4ed15b740b Remove unneeded imports and methods 2022-02-25 22:14:56 +00:00
5f161b2163 Rename Transaction to TX to match input row 2022-02-25 22:14:13 +00:00
10ea73663f Extract UpdateTransaction 2022-02-25 22:09:59 +00:00
07804e4241 Update existing transaction if transactionid was passed 2022-02-25 22:05:15 +00:00
2f4f8a7568 Extract method CreateTransferForOtherAccount 2022-02-25 22:02:57 +00:00
13d0194632 Pass date using ISO without time
All checks were successful
continuous-integration/drone/push Build is passing
2022-02-25 21:52:56 +00:00
c864666eb6 Use local date format
All checks were successful
continuous-integration/drone/push Build is passing
2022-02-25 21:50:15 +00:00
464931babe Pass amount as string 2022-02-25 21:46:20 +00:00
480a95e096 Implement custom date input 2022-02-25 21:45:58 +00:00
2d37ec147c Pass only category Id as categories are not to be created on the fly 2022-02-25 21:21:55 +00:00
75b48be20d Also pass Suggestion-Object from EditRow 2022-02-25 21:20:41 +00:00
306edbf817 Update TransactionInputRow to new models 2022-02-25 21:19:34 +00:00
be3829baf8 Replace modelValue by models for id and name 2022-02-25 21:10:21 +00:00
a452482381 Normalize transaction store 2022-02-25 20:47:58 +00:00
0f6990407d Remove widths from rows as header decides 2022-02-25 20:37:49 +00:00
97be5abc8c Add abilty to switch to edit mode 2022-02-25 20:37:31 +00:00
1e80ba6ca8 Also return PayeeID and CategoryID from backend 2022-02-25 20:36:24 +00:00
1331304639 Extract EditAccount Dialog 2022-02-25 20:17:54 +00:00
22 changed files with 400 additions and 190 deletions

View File

@ -4,11 +4,23 @@ type: docker
name: budgeteer
steps:
- name: Taskfile.dev
- name: Taskfile.dev PR
image: hub.javil.eu/budgeteer:dev
pull: true
commands:
- task ci
when:
event:
- pull_request
- name: Taskfile.dev
image: hub.javil.eu/budgeteer:dev
pull: always
commands:
- task ci
when:
event:
exclude:
- pull_request
- name: docker
image: plugins/docker
@ -24,10 +36,26 @@ steps:
tags:
- latest
when:
branch:
- master
event:
exclude:
- pull_request
- push
- name: docker tag
image: plugins/docker
settings:
registry: hub.javil.eu
username:
from_secret: docker_user
password:
from_secret: docker_password
repo: hub.javil.eu/budgeteer
context: build
dockerfile: build/Dockerfile
auto_tag: true
when:
event:
- tag
image_pull_secrets:
- hub.javil.eu

View File

@ -9,8 +9,9 @@ RUN apk --no-cache add go nodejs yarn bash curl git git-perl tmux
ADD docker/build.sh /
RUN yarn global add @vue/cli
ENV PATH="/root/.yarn/bin/:${PATH}"
WORKDIR /src
WORKDIR /src/web
ADD web/package.json web/yarn.lock /src/web/
RUN yarn
WORKDIR /src
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/
CMD /build.sh

View File

@ -130,7 +130,7 @@ func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID
}
const searchAccounts = `-- name: SearchAccounts :many
SELECT accounts.id, accounts.budget_id, accounts.name, true as is_account FROM accounts
SELECT accounts.id, accounts.budget_id, accounts.name, 'account' as type FROM accounts
WHERE accounts.budget_id = $1
AND accounts.name LIKE $2
ORDER BY accounts.name
@ -142,10 +142,10 @@ type SearchAccountsParams struct {
}
type SearchAccountsRow struct {
ID uuid.UUID
BudgetID uuid.UUID
Name string
IsAccount bool
ID uuid.UUID
BudgetID uuid.UUID
Name string
Type interface{}
}
func (q *Queries) SearchAccounts(ctx context.Context, arg SearchAccountsParams) ([]SearchAccountsRow, error) {
@ -161,7 +161,7 @@ func (q *Queries) SearchAccounts(ctx context.Context, arg SearchAccountsParams)
&i.ID,
&i.BudgetID,
&i.Name,
&i.IsAccount,
&i.Type,
); err != nil {
return nil, err
}

View File

@ -118,7 +118,8 @@ func (q *Queries) GetCategoryGroups(ctx context.Context, budgetID uuid.UUID) ([]
}
const searchCategories = `-- name: SearchCategories :many
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id, 'category' as type
FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = $1
AND categories.name LIKE $2
@ -133,6 +134,7 @@ type SearchCategoriesParams struct {
type SearchCategoriesRow struct {
Name interface{}
ID uuid.UUID
Type interface{}
}
func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesParams) ([]SearchCategoriesRow, error) {
@ -144,7 +146,7 @@ func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesPara
var items []SearchCategoriesRow
for rows.Next() {
var i SearchCategoriesRow
if err := rows.Scan(&i.Name, &i.ID); err != nil {
if err := rows.Scan(&i.Name, &i.ID, &i.Type); err != nil {
return nil, err
}
items = append(items, i)

View File

@ -58,7 +58,7 @@ func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, e
}
const searchPayees = `-- name: SearchPayees :many
SELECT payees.id, payees.budget_id, payees.name FROM payees
SELECT payees.id, payees.budget_id, payees.name, 'payee' as type FROM payees
WHERE payees.budget_id = $1
AND payees.name LIKE $2
ORDER BY payees.name
@ -69,16 +69,28 @@ type SearchPayeesParams struct {
Search string
}
func (q *Queries) SearchPayees(ctx context.Context, arg SearchPayeesParams) ([]Payee, error) {
type SearchPayeesRow struct {
ID uuid.UUID
BudgetID uuid.UUID
Name string
Type interface{}
}
func (q *Queries) SearchPayees(ctx context.Context, arg SearchPayeesParams) ([]SearchPayeesRow, error) {
rows, err := q.db.QueryContext(ctx, searchPayees, arg.BudgetID, arg.Search)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Payee
var items []SearchPayeesRow
for rows.Next() {
var i Payee
if err := rows.Scan(&i.ID, &i.BudgetID, &i.Name); err != nil {
var i SearchPayeesRow
if err := rows.Scan(
&i.ID,
&i.BudgetID,
&i.Name,
&i.Type,
); err != nil {
return nil, err
}
items = append(items, i)

View File

@ -22,7 +22,7 @@ GROUP BY accounts.id, accounts.name
ORDER BY accounts.name;
-- name: SearchAccounts :many
SELECT accounts.id, accounts.budget_id, accounts.name, true as is_account FROM accounts
SELECT accounts.id, accounts.budget_id, accounts.name, 'account' as type FROM accounts
WHERE accounts.budget_id = @budget_id
AND accounts.name LIKE @search
ORDER BY accounts.name;

View File

@ -21,7 +21,8 @@ WHERE category_groups.budget_id = $1
ORDER BY category_groups.name, categories.name;
-- name: SearchCategories :many
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id, 'category' as type
FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = @budget_id
AND categories.name LIKE @search

View File

@ -10,7 +10,7 @@ WHERE payees.budget_id = $1
ORDER BY name;
-- name: SearchPayees :many
SELECT payees.* FROM payees
SELECT payees.*, 'payee' as type FROM payees
WHERE payees.budget_id = @budget_id
AND payees.name LIKE @search
ORDER BY payees.name;

View File

@ -13,10 +13,9 @@ UPDATE transactions
SET date = $1,
memo = $2,
amount = $3,
account_id = $4,
payee_id = $5,
category_id = $6
WHERE id = $7;
payee_id = $4,
category_id = $5
WHERE id = $6;
-- name: DeleteTransaction :exec
DELETE FROM transactions
@ -25,7 +24,7 @@ WHERE id = $1;
-- name: GetAllTransactionsForBudget :many
SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status,
accounts.name as account,
accounts.name as account, transactions.payee_id, transactions.category_id,
COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category,
@ -47,7 +46,7 @@ ORDER BY transactions.date DESC;
-- name: GetTransactionsForAccount :many
SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status,
accounts.name as account,
accounts.name as account, transactions.payee_id, transactions.category_id,
COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category,

View File

@ -83,7 +83,7 @@ func (q *Queries) DeleteTransaction(ctx context.Context, id uuid.UUID) error {
const getAllTransactionsForBudget = `-- name: GetAllTransactionsForBudget :many
SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status,
accounts.name as account,
accounts.name as account, transactions.payee_id, transactions.category_id,
COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category,
@ -111,6 +111,8 @@ type GetAllTransactionsForBudgetRow struct {
GroupID uuid.NullUUID
Status TransactionStatus
Account string
PayeeID uuid.NullUUID
CategoryID uuid.NullUUID
Payee string
CategoryGroup string
Category string
@ -134,6 +136,8 @@ func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid
&i.GroupID,
&i.Status,
&i.Account,
&i.PayeeID,
&i.CategoryID,
&i.Payee,
&i.CategoryGroup,
&i.Category,
@ -211,7 +215,7 @@ func (q *Queries) GetTransactionsByMonthAndCategory(ctx context.Context, budgetI
const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status,
accounts.name as account,
accounts.name as account, transactions.payee_id, transactions.category_id,
COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category,
@ -240,6 +244,8 @@ type GetTransactionsForAccountRow struct {
GroupID uuid.NullUUID
Status TransactionStatus
Account string
PayeeID uuid.NullUUID
CategoryID uuid.NullUUID
Payee string
CategoryGroup string
Category string
@ -263,6 +269,8 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
&i.GroupID,
&i.Status,
&i.Account,
&i.PayeeID,
&i.CategoryID,
&i.Payee,
&i.CategoryGroup,
&i.Category,
@ -286,17 +294,15 @@ UPDATE transactions
SET date = $1,
memo = $2,
amount = $3,
account_id = $4,
payee_id = $5,
category_id = $6
WHERE id = $7
payee_id = $4,
category_id = $5
WHERE id = $6
`
type UpdateTransactionParams struct {
Date time.Time
Memo string
Amount numeric.Numeric
AccountID uuid.UUID
PayeeID uuid.NullUUID
CategoryID uuid.NullUUID
ID uuid.UUID
@ -307,7 +313,6 @@ func (q *Queries) UpdateTransaction(ctx context.Context, arg UpdateTransactionPa
arg.Date,
arg.Memo,
arg.Amount,
arg.AccountID,
arg.PayeeID,
arg.CategoryID,
arg.ID,

View File

@ -41,20 +41,12 @@ func (h *Handler) LoadRoutes(router *gin.Engine) {
router.Use(enableCachingForStaticFiles())
router.NoRoute(h.ServeStatic)
withLogin := router.Group("")
withLogin.Use(h.verifyLoginWithRedirect)
withBudget := router.Group("")
withBudget.Use(h.verifyLoginWithForbidden)
withBudget.GET("/budget/:budgetid/:year/:month", h.budgeting)
withBudget.GET("/budget/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
api := router.Group("/api/v1")
unauthenticated := api.Group("/user")
unauthenticated.GET("/login", func(c *gin.Context) { c.Redirect(http.StatusPermanentRedirect, "/login") })
unauthenticated.POST("/login", h.loginPost)
unauthenticated.POST("/register", h.registerPost)
anonymous := api.Group("/user")
anonymous.GET("/login", func(c *gin.Context) { c.Redirect(http.StatusPermanentRedirect, "/login") })
anonymous.POST("/login", h.loginPost)
anonymous.POST("/register", h.registerPost)
authenticated := api.Group("")
authenticated.Use(h.verifyLoginWithForbidden)
@ -62,18 +54,19 @@ func (h *Handler) LoadRoutes(router *gin.Engine) {
authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount)
authenticated.POST("/account/:accountid", h.editAccount)
authenticated.GET("/admin/clear-database", h.clearDatabase)
authenticated.GET("/budget/:budgetid", h.budgeting)
authenticated.GET("/budget/:budgetid/:year/:month", h.budgetingForMonth)
authenticated.GET("/budget/:budgetid/autocomplete/payees", h.autocompletePayee)
authenticated.GET("/budget/:budgetid/autocomplete/categories", h.autocompleteCategories)
authenticated.DELETE("/budget/:budgetid", h.deleteBudget)
authenticated.POST("/budget/:budgetid/import/ynab", h.importYNAB)
authenticated.POST("/budget/:budgetid/export/ynab/transactions", h.exportYNABTransactions)
authenticated.POST("/budget/:budgetid/export/ynab/assignments", h.exportYNABAssignments)
authenticated.POST("/budget/:budgetid/settings/clear", h.clearBudget)
budget := authenticated.Group("/budget")
budget.POST("/new", h.newBudget)
budget.GET("/:budgetid", h.budgeting)
budget.GET("/:budgetid/:year/:month", h.budgetingForMonth)
budget.GET("/:budgetid/autocomplete/payees", h.autocompletePayee)
budget.GET("/:budgetid/autocomplete/categories", h.autocompleteCategories)
budget.DELETE("/:budgetid", h.deleteBudget)
budget.POST("/:budgetid/import/ynab", h.importYNAB)
budget.POST("/:budgetid/export/ynab/transactions", h.exportYNABTransactions)
budget.POST("/:budgetid/export/ynab/assignments", h.exportYNABAssignments)
budget.POST("/:budgetid/settings/clear", h.clearBudget)
budget.POST("/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
transaction := authenticated.Group("/transaction")
transaction.POST("/new", h.newTransaction)

View File

@ -53,18 +53,6 @@ func (h *Handler) verifyLoginWithForbidden(c *gin.Context) {
c.Next()
}
func (h *Handler) verifyLoginWithRedirect(c *gin.Context) {
token, err := h.verifyLogin(c)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
c.Abort()
return
}
c.Set(ParamName, token)
c.Next()
}
type loginInformation struct {
Password string `json:"password"`
User string `json:"user"`

View File

@ -15,19 +15,16 @@ import (
type NewTransactionPayload struct {
Date JSONDate `json:"date"`
Payee struct {
ID uuid.NullUUID
Name string
IsAccount bool
} `json:"payee"`
Category struct {
ID uuid.NullUUID
Name string
} `json:"category"`
Memo string `json:"memo"`
Amount string `json:"amount"`
BudgetID uuid.UUID `json:"budgetId"`
AccountID uuid.UUID `json:"accountId"`
State string `json:"state"`
Type string
} `json:"payee"`
CategoryID uuid.NullUUID `json:"categoryId"`
Memo string `json:"memo"`
Amount string `json:"amount"`
BudgetID uuid.UUID `json:"budgetId"`
AccountID uuid.UUID `json:"accountId"`
State string `json:"state"`
}
func (h *Handler) newTransaction(c *gin.Context) {
@ -44,26 +41,27 @@ func (h *Handler) newTransaction(c *gin.Context) {
return
}
newTransaction := postgres.CreateTransactionParams{
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
Status: postgres.TransactionStatus(payload.State),
transactionID := c.Param("transactionid")
if transactionID != "" {
h.UpdateTransaction(payload, amount, transactionID, c)
return
}
if payload.Payee.IsAccount {
newTransaction.GroupID = uuid.NullUUID{UUID: uuid.New(), Valid: true}
newTransaction.Amount = amount.Neg()
newTransaction.AccountID = payload.Payee.ID.UUID
newTransaction.CategoryID = uuid.NullUUID{}
newTransaction := postgres.CreateTransactionParams{
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
Status: postgres.TransactionStatus(payload.State),
CategoryID: payload.CategoryID,
AccountID: payload.AccountID,
}
_, err = h.Service.CreateTransaction(c.Request.Context(), newTransaction)
if payload.Payee.Type == "account" {
err := h.CreateTransferForOtherAccount(newTransaction, amount, payload, c)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transfer transaction: %w", err))
c.AbortWithError(http.StatusInternalServerError, err)
return
}
newTransaction.Amount = amount
} else {
payeeID, err := GetPayeeID(c.Request.Context(), payload, h)
if err != nil {
@ -72,17 +70,54 @@ func (h *Handler) newTransaction(c *gin.Context) {
newTransaction.PayeeID = payeeID
}
newTransaction.CategoryID = payload.Category.ID
newTransaction.AccountID = payload.AccountID
transaction, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
return
}
c.JSON(http.StatusOK, transaction)
}
func (h *Handler) UpdateTransaction(payload NewTransactionPayload, amount numeric.Numeric, transactionID string, c *gin.Context) {
transactionUUID := uuid.MustParse(transactionID)
if amount.IsZero() {
err := h.Service.DeleteTransaction(c.Request.Context(), transactionUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("delete transaction: %w", err))
}
return
}
editTransaction := postgres.UpdateTransactionParams{
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
PayeeID: payload.Payee.ID,
CategoryID: payload.CategoryID,
ID: transactionUUID,
}
err := h.Service.UpdateTransaction(c.Request.Context(), editTransaction)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("edit transaction: %w", err))
}
}
func (h *Handler) CreateTransferForOtherAccount(newTransaction postgres.CreateTransactionParams, amount numeric.Numeric, payload NewTransactionPayload, c *gin.Context) error {
newTransaction.GroupID = uuid.NullUUID{UUID: uuid.New(), Valid: true}
newTransaction.Amount = amount.Neg()
newTransaction.AccountID = payload.Payee.ID.UUID
// transfer does not need category. Either it's account is off-budget or no category was supplied.
newTransaction.CategoryID = uuid.NullUUID{}
_, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
if err != nil {
return fmt.Errorf("create transfer transaction: %w", err)
}
return nil
}
func GetPayeeID(context context.Context, payload NewTransactionPayload, h *Handler) (uuid.NullUUID, error) {
payeeID := payload.Payee.ID
if payeeID.Valid {

View File

@ -1,43 +1,38 @@
<script lang="ts" setup>
import { defineComponent, PropType, ref, watch } from "vue"
import { ref, watch } from "vue"
import { GET } from "../api";
import { useBudgetsStore } from "../stores/budget";
export interface Suggestion {
ID: string
Name: string
}
interface Data {
Selected: Suggestion | undefined
SearchQuery: String
Suggestions: Suggestion[]
Type: string
}
const props = defineProps<{
modelValue: Suggestion | undefined,
type: String
text: String,
id: String | undefined,
model: String,
type?: string | undefined,
}>();
const Selected = ref<Suggestion | undefined>(props.modelValue || undefined);
const SearchQuery = ref(props.modelValue?.Name || "");
const SearchQuery = ref(props.text || "");
const Suggestions = ref<Array<Suggestion>>([]);
const emit = defineEmits(["update:modelValue"]);
const emit = defineEmits(["update:id", "update:text", "update:type"]);
watch(SearchQuery, () => {
load(SearchQuery.value);
});
function saveTransaction(e: MouseEvent) {
e.preventDefault();
};
function load(text: String) {
emit('update:modelValue', { ID: null, Name: text });
emit('update:id', null);
emit('update:text', text);
emit('update:type', undefined);
if (text == "") {
Suggestions.value = [];
return;
}
const budgetStore = useBudgetsStore();
GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + props.type + "?s=" + text)
GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + props.model + "?s=" + text)
.then(x => x.json())
.then(x => {
let suggestions = x || [];
@ -56,13 +51,13 @@ function keypress(e: KeyboardEvent) {
const currentIndex = inputElements.indexOf(el);
const nextElement = inputElements[currentIndex < inputElements.length - 1 ? currentIndex + 1 : 0];
(<HTMLInputElement>nextElement).focus();
}
};
function selectElement(element: Suggestion) {
Selected.value = element;
emit('update:id', element.ID);
emit('update:text', element.Name);
emit('update:type', element.Type);
Suggestions.value = [];
emit('update:modelValue', element);
};
function select(e: MouseEvent) {
const target = (<HTMLInputElement>e.target);
@ -74,8 +69,9 @@ function select(e: MouseEvent) {
selectElement(selected);
};
function clear() {
Selected.value = undefined;
emit('update:modelValue', { ID: null, Name: SearchQuery.value });
emit('update:id', null);
emit('update:text', SearchQuery.value);
emit('update:type', undefined);
};
</script>
@ -84,10 +80,10 @@ function clear() {
<input
class="border-b-2 border-black"
@keypress="keypress"
v-if="Selected == undefined"
v-if="id == undefined"
v-model="SearchQuery"
/>
<span @click="clear" v-if="Selected != undefined" class="bg-gray-300">{{ Selected.Name }}</span>
<span @click="clear" v-if="id != undefined" class="bg-gray-300">{{ text }}</span>
<div v-if="Suggestions.length > 0" class="absolute bg-gray-400 w-64 p-2">
<span
v-for="suggestion in Suggestions"

View File

@ -0,0 +1,33 @@
<script lang="ts" setup>
const props = defineProps(["modelValue"]);
const emit = defineEmits(['update:modelValue']);
function dateToYYYYMMDD(d: Date) : string {
// alternative implementations in https://stackoverflow.com/q/23593052/1850609
//return new Date(d.getTime() - (d.getTimezoneOffset() * 60 * 1000)).toISOString().split('T')[0];
return d.toISOString().split('T')[0];
}
function updateValue(event: Event) {
const target = event.target as HTMLInputElement;
emit('update:modelValue', target.valueAsDate);
}
function selectAll(event: FocusEvent) {
// Workaround for Safari bug
// http://stackoverflow.com/questions/1269722/selecting-text-on-focus-using-jquery-not-working-in-safari-and-chrome
setTimeout(function () {
const target = event.target as HTMLInputElement;
target.select()
}, 0)
}
</script>
<template>
<input
type="date"
ref="input"
v-bind:value="dateToYYYYMMDD(modelValue)"
@input="updateValue"
@focus="selectAll"
/>
</template>

View File

@ -0,0 +1,60 @@
<script lang="ts" setup>
import { computed, ref } from "vue";
import Autocomplete from './Autocomplete.vue'
import { useAccountStore } from '../stores/budget-account'
import DateInput from "./DateInput.vue";
const props = defineProps<{
transactionid: string
}>()
const accountStore = useAccountStore();
const TX = accountStore.Transactions.get(props.transactionid)!;
const payeeType = ref<string|undefined>(undefined);
const payload = computed(() => JSON.stringify({
date: TX.Date.toISOString().split("T")[0],
payee: {
Name: TX.Payee,
ID: TX.PayeeID,
Type: payeeType.value,
},
categoryId: TX.CategoryID,
memo: TX.Memo,
amount: TX.Amount.toString(),
state: "Uncleared"
}));
function saveTransaction(e: MouseEvent) {
e.preventDefault();
accountStore.editTransaction(TX.ID, payload.value);
}
</script>
<template>
<tr>
<td style="width: 90px;" class="text-sm">
<DateInput class="border-b-2 border-black" v-model="TX.Date" />
</td>
<td style="max-width: 150px;">
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" />
</td>
<td style="max-width: 200px;">
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" />
</td>
<td>
<input class="block w-full border-b-2 border-black" type="text" v-model="TX.Memo" />
</td>
<td style="width: 80px;" class="text-right">
<input
class="text-right block w-full border-b-2 border-black"
type="currency"
v-model="TX.Amount"
/>
</td>
<td style="width: 20px;">
<input type="submit" @click="saveTransaction" value="Save" />
</td>
<td style="width: 20px;"></td>
</tr>
</template>

View File

@ -1,27 +1,43 @@
<script lang="ts" setup>
import { computed, ref } from "vue";
import Autocomplete, { Suggestion } from '../components/Autocomplete.vue'
import { useAccountStore } from '../stores/budget-account'
import { Transaction, useAccountStore } from '../stores/budget-account'
import DateInput from "./DateInput.vue";
const props = defineProps<{
budgetid: string
accountid: string
}>()
const TransactionDate = ref(new Date().toISOString().substring(0, 10));
const Payee = ref<Suggestion | undefined>(undefined);
const Category = ref<Suggestion | undefined>(undefined);
const Memo = ref("");
const Amount = ref("0");
const TX = ref<Transaction>({
Date: new Date(),
Memo: "",
Amount: 0,
Payee: "",
PayeeID: undefined,
Category: "",
CategoryID: undefined,
CategoryGroup: "",
GroupID: "",
ID: "",
Status: "Uncleared",
TransferAccount: "",
});
const payeeType = ref<string|undefined>(undefined);
const payload = computed(() => JSON.stringify({
budgetId: props.budgetid,
accountId: props.accountid,
date: TransactionDate.value,
payee: Payee.value,
category: Category.value,
memo: Memo.value,
amount: Amount.value,
date: TX.value.Date.toISOString().split("T")[0],
payee: {
Name: TX.value.Payee,
ID: TX.value.PayeeID,
Type: payeeType.value,
},
categoryId: TX.value.CategoryID,
memo: TX.value.Memo,
amount: TX.value.Amount.toString(),
state: "Uncleared"
}));
@ -35,22 +51,22 @@ function saveTransaction(e: MouseEvent) {
<template>
<tr>
<td style="width: 90px;" class="text-sm">
<input class="border-b-2 border-black" type="date" v-model="TransactionDate" />
<DateInput class="border-b-2 border-black" v-model="TX.Date" />
</td>
<td style="max-width: 150px;">
<Autocomplete v-model="Payee" type="payees" />
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" />
</td>
<td style="max-width: 200px;">
<Autocomplete v-model="Category" type="categories" />
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" />
</td>
<td>
<input class="block w-full border-b-2 border-black" type="text" v-model="Memo" />
<input class="block w-full border-b-2 border-black" type="text" v-model="TX.Memo" />
</td>
<td style="width: 80px;" class="text-right">
<input
class="text-right block w-full border-b-2 border-black"
type="currency"
v-model="Amount"
v-model="TX.Amount"
/>
</td>
<td style="width: 20px;">

View File

@ -1,24 +1,28 @@
<script lang="ts" setup>
import { computed } from "vue";
import { computed, ref } from "vue";
import { useBudgetsStore } from "../stores/budget";
import { Transaction } from "../stores/budget-account";
import Currency from "./Currency.vue";
import TransactionEditRow from "./TransactionEditRow.vue";
import { formatDate } from "../date";
const props = defineProps<{
transaction: Transaction,
index: number,
}>();
const edit = ref(false);
const CurrentBudgetID = computed(()=> useBudgetsStore().CurrentBudgetID);
</script>
<template>
<tr class="{{transaction.Date.After now ? 'future' : ''}}"
<tr v-if="!edit" class="{{new Date(transaction.Date) > new Date() ? 'future' : ''}}"
:class="[index % 6 < 3 ? 'bg-gray-300' : 'bg-gray-100']">
<!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">-->
<td style="width: 90px;">{{ transaction.Date.substring(0, 10) }}</td>
<td style="max-width: 150px;">{{ transaction.TransferAccount ? "Transfer : " + transaction.TransferAccount : transaction.Payee }}</td>
<td style="max-width: 200px;">
<td>{{ formatDate(transaction.Date) }}</td>
<td>{{ transaction.TransferAccount ? "Transfer : " + transaction.TransferAccount : transaction.Payee }}</td>
<td>
{{ transaction.CategoryGroup ? transaction.CategoryGroup + " : " + transaction.Category : "" }}
</td>
<td>
@ -29,11 +33,12 @@ const CurrentBudgetID = computed(()=> useBudgetsStore().CurrentBudgetID);
<td>
<Currency class="block" :value="transaction.Amount" />
</td>
<td style="width: 20px;">
<td>
{{ transaction.Status == "Reconciled" ? "✔" : (transaction.Status == "Uncleared" ? "" : "*") }}
</td>
<td style="width: 20px;">{{ transaction.GroupID ? "☀" : "" }}</td>
<td class="text-right">{{ transaction.GroupID ? "☀" : "" }}<a @click="edit = true;"></a></td>
</tr>
<TransactionEditRow v-if="edit" :transactionid="transaction.ID" />
</template>
<style>

7
web/src/date.ts Normal file
View File

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

View File

@ -0,0 +1,44 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import Modal from '../components/Modal.vue';
import { useAccountStore } from '../stores/budget-account';
const accountStore = useAccountStore();
const CurrentAccount = computed(() => accountStore.CurrentAccount);
const accountName = ref("");
const accountOnBudget = ref(true);
function editAccount(e : any) {
accountStore.EditAccount(CurrentAccount.value?.ID ?? "", accountName.value, accountOnBudget.value);
}
function openEditAccount(e : any) {
accountName.value = CurrentAccount.value?.Name ?? "";
accountOnBudget.value = CurrentAccount.value?.OnBudget ?? true;
}
</script>
<template>
<Modal button-text="Edit Account" @open="openEditAccount" @submit="editAccount">
<template v-slot:placeholder></template>
<div class="mt-2 px-7 py-3">
<input
class="border-2"
type="text"
v-model="accountName"
placeholder="Account name"
required
/>
</div>
<div class="mt-2 px-7 py-3">
<input
class="border-2"
type="checkbox"
v-model="accountOnBudget"
required
/>
<label>On Budget</label>
</div>
</Modal>
</template>

View File

@ -4,7 +4,7 @@ import Currency from "../components/Currency.vue";
import TransactionRow from "../components/TransactionRow.vue";
import TransactionInputRow from "../components/TransactionInputRow.vue";
import { useAccountStore } from "../stores/budget-account";
import Modal from "../components/Modal.vue";
import EditAccount from "../dialogs/EditAccount.vue";
const props = defineProps<{
budgetid: string
@ -15,42 +15,11 @@ const accountStore = useAccountStore();
const CurrentAccount = computed(() => accountStore.CurrentAccount);
const TransactionsList = computed(() => accountStore.TransactionsList);
const accountName = ref("");
const accountOnBudget = ref(true);
function editAccount(e : any) {
accountStore.EditAccount(CurrentAccount.value?.ID ?? "", accountName.value, accountOnBudget.value);
}
function openEditAccount(e : any) {
accountName.value = CurrentAccount.value?.Name ?? "";
accountOnBudget.value = CurrentAccount.value?.OnBudget ?? true;
}
</script>
<template>
<h1 class="inline">{{ CurrentAccount?.Name }}</h1>
<Modal button-text="Edit Account" @open="openEditAccount" @submit="editAccount">
<template v-slot:placeholder></template>
<div class="mt-2 px-7 py-3">
<input
class="border-2"
type="text"
v-model="accountName"
placeholder="Account name"
required
/>
</div>
<div class="mt-2 px-7 py-3">
<input
class="border-2"
type="checkbox"
v-model="accountOnBudget"
required
/>
<label>On Budget</label>
</div>
</Modal>
<EditAccount />
<p>
Current Balance:
@ -64,7 +33,7 @@ function openEditAccount(e : any) {
<td>Memo</td>
<td class="text-right">Amount</td>
<td style="width: 20px;"></td>
<td style="width: 20px;"></td>
<td style="width: 40px;"></td>
</tr>
<TransactionInputRow :budgetid="budgetid" :accountid="accountid" />
<TransactionRow

View File

@ -8,20 +8,22 @@ interface State {
CurrentAccountID: string | null,
Categories: Map<string, Category>,
Months: Map<number, Map<number, Map<string, Category>>>,
Transactions: any[],
Transactions: Map<string, Transaction>,
Assignments: []
}
export interface Transaction {
ID: string,
Date: string,
Date: Date,
TransferAccount: string,
CategoryGroup: string,
Category: string,
CategoryID: string | undefined,
Memo: string,
Status: string,
GroupID: string,
Payee: string,
PayeeID: string | undefined,
Amount: number,
}
@ -30,6 +32,7 @@ export interface Account {
Name: string
OnBudget: boolean
Balance: number
Transactions: string[]
}
export interface Category {
@ -48,7 +51,7 @@ export const useAccountStore = defineStore("budget/account", {
CurrentAccountID: null,
Months: new Map<number, Map<number, Map<string, Category>>>(),
Categories: new Map<string, Category>(),
Transactions: [],
Transactions: new Map<string, Transaction>(),
Assignments: []
}),
getters: {
@ -100,8 +103,10 @@ export const useAccountStore = defineStore("budget/account", {
OffBudgetAccountsBalance(state): number {
return this.OffBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0);
},
TransactionsList(state) {
return (state.Transactions || []);
TransactionsList(state) : Transaction[] {
return this.CurrentAccount!.Transactions.map(x => {
return this.Transactions.get(x)!
});
}
},
actions: {
@ -110,16 +115,22 @@ export const useAccountStore = defineStore("budget/account", {
return
this.CurrentAccountID = accountid;
if (this.CurrentAccount == undefined)
const account = this.CurrentAccount;
if (account == undefined)
return
useSessionStore().setTitle(this.CurrentAccount.Name);
await this.FetchAccount(accountid);
useSessionStore().setTitle(account.Name);
await this.FetchAccount(account);
},
async FetchAccount(accountid: string) {
const result = await GET("/account/" + accountid + "/transactions");
async FetchAccount(account: Account) {
const result = await GET("/account/" + account.ID + "/transactions");
const response = await result.json();
this.Transactions = response.Transactions;
account.Transactions = [];
for (const transaction of response.Transactions) {
transaction.Date = new Date(transaction.Date);
this.Transactions.set(transaction.ID, transaction);
account.Transactions.push(transaction.ID);
}
},
async FetchMonthBudget(budgetid: string, year: number, month: number) {
const result = await GET("/budget/" + budgetid + "/" + year + "/" + month);
@ -151,7 +162,12 @@ export const useAccountStore = defineStore("budget/account", {
async saveTransaction(payload: string) {
const result = await POST("/transaction/new", payload);
const response = await result.json();
this.Transactions.unshift(response);
this.CurrentAccount?.Transactions.unshift(response);
},
async editTransaction(transactionid : string, payload: string) {
const result = await POST("/transaction/" + transactionid, payload);
const response = await result.json();
this.CurrentAccount?.Transactions.unshift(response);
}
}