Implement basic budgeting input #39
@ -17,7 +17,7 @@ INSERT INTO assignments (
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3
|
$1, $2, $3
|
||||||
)
|
)
|
||||||
RETURNING id, category_id, date, memo, amount
|
RETURNING category_id, date, memo, amount
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateAssignmentParams struct {
|
type CreateAssignmentParams struct {
|
||||||
@ -30,7 +30,6 @@ func (q *Queries) CreateAssignment(ctx context.Context, arg CreateAssignmentPara
|
|||||||
row := q.db.QueryRowContext(ctx, createAssignment, arg.Date, arg.Amount, arg.CategoryID)
|
row := q.db.QueryRowContext(ctx, createAssignment, arg.Date, arg.Amount, arg.CategoryID)
|
||||||
var i Assignment
|
var i Assignment
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
|
||||||
&i.CategoryID,
|
&i.CategoryID,
|
||||||
&i.Date,
|
&i.Date,
|
||||||
&i.Memo,
|
&i.Memo,
|
||||||
@ -130,3 +129,22 @@ func (q *Queries) GetAssignmentsByMonthAndCategory(ctx context.Context, budgetID
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateAssignment = `-- name: UpdateAssignment :exec
|
||||||
|
INSERT INTO assignments (category_id, date, amount)
|
||||||
|
VALUES($1, $2, $3)
|
||||||
|
ON CONFLICT (category_id, date)
|
||||||
|
DO
|
||||||
|
UPDATE SET amount = $3
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateAssignmentParams struct {
|
||||||
|
CategoryID uuid.UUID
|
||||||
|
Date time.Time
|
||||||
|
Amount numeric.Numeric
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateAssignment(ctx context.Context, arg UpdateAssignmentParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, updateAssignment, arg.CategoryID, arg.Date, arg.Amount)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
@ -40,7 +40,6 @@ type Account struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Assignment struct {
|
type Assignment struct {
|
||||||
ID uuid.UUID
|
|
||||||
CategoryID uuid.UUID
|
CategoryID uuid.UUID
|
||||||
Date time.Time
|
Date time.Time
|
||||||
Memo sql.NullString
|
Memo sql.NullString
|
||||||
|
@ -53,6 +53,13 @@ func (n Numeric) IsZero() bool {
|
|||||||
return float == 0
|
return float == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *Numeric) MatchExpI(exp int32) {
|
||||||
|
diffExp := n.Exp - exp
|
||||||
|
factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil) //nolint:gomnd
|
||||||
|
n.Exp = exp
|
||||||
|
n.Int = big.NewInt(0).Mul(n.Int, factor)
|
||||||
|
}
|
||||||
|
|
||||||
func (n Numeric) MatchExp(exp int32) Numeric {
|
func (n Numeric) MatchExp(exp int32) Numeric {
|
||||||
diffExp := n.Exp - exp
|
diffExp := n.Exp - exp
|
||||||
factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil) //nolint:gomnd
|
factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil) //nolint:gomnd
|
||||||
@ -64,6 +71,22 @@ func (n Numeric) MatchExp(exp int32) Numeric {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *Numeric) SubI(other Numeric) *Numeric {
|
||||||
|
right := other
|
||||||
|
if n.Exp < other.Exp {
|
||||||
|
right = other.MatchExp(n.Exp)
|
||||||
|
} else if n.Exp > other.Exp {
|
||||||
|
n.MatchExpI(other.Exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.Exp == right.Exp {
|
||||||
|
n.Int = big.NewInt(0).Sub(n.Int, right.Int)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("Cannot subtract with different exponents")
|
||||||
|
}
|
||||||
|
|
||||||
func (n Numeric) Sub(other Numeric) Numeric {
|
func (n Numeric) Sub(other Numeric) Numeric {
|
||||||
left := n
|
left := n
|
||||||
right := other
|
right := other
|
||||||
@ -106,6 +129,22 @@ func (n Numeric) Add(other Numeric) Numeric {
|
|||||||
panic("Cannot add with different exponents")
|
panic("Cannot add with different exponents")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *Numeric) AddI(other Numeric) *Numeric {
|
||||||
|
right := other
|
||||||
|
if n.Exp < other.Exp {
|
||||||
|
right = other.MatchExp(n.Exp)
|
||||||
|
} else if n.Exp > other.Exp {
|
||||||
|
n.MatchExpI(other.Exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.Exp == right.Exp {
|
||||||
|
n.Int = big.NewInt(0).Add(n.Int, right.Int)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("Cannot add with different exponents")
|
||||||
|
}
|
||||||
|
|
||||||
func (n Numeric) String() string {
|
func (n Numeric) String() string {
|
||||||
if n.Int == nil || n.Int.Int64() == 0 {
|
if n.Int == nil || n.Int.Int64() == 0 {
|
||||||
return "0"
|
return "0"
|
||||||
|
@ -23,3 +23,10 @@ FROM assignments
|
|||||||
INNER JOIN categories ON categories.id = assignments.category_id
|
INNER JOIN categories ON categories.id = assignments.category_id
|
||||||
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
|
||||||
WHERE category_groups.budget_id = @budget_id;
|
WHERE category_groups.budget_id = @budget_id;
|
||||||
|
|
||||||
|
-- name: UpdateAssignment :exec
|
||||||
|
INSERT INTO assignments (category_id, date, amount)
|
||||||
|
VALUES($1, $2, $3)
|
||||||
|
ON CONFLICT (category_id, date)
|
||||||
|
DO
|
||||||
|
UPDATE SET amount = $3;
|
6
postgres/schema/0017_natural-key-assignments.sql
Normal file
6
postgres/schema/0017_natural-key-assignments.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE assignments DROP id;
|
||||||
|
ALTER TABLE assignments ADD PRIMARY KEY (category_id, date);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE assignments ADD COLUMN id uuid DEFAULT uuid_generate_v4() PRIMARY KEY;
|
@ -3,7 +3,6 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
@ -41,27 +40,6 @@ func NewCategoryWithBalance(category *postgres.GetCategoriesRow) CategoryWithBal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDate(c *gin.Context) (time.Time, error) {
|
|
||||||
var year, month int
|
|
||||||
yearString := c.Param("year")
|
|
||||||
monthString := c.Param("month")
|
|
||||||
if yearString == "" && monthString == "" {
|
|
||||||
return getFirstOfMonthTime(time.Now()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
year, err := strconv.Atoi(yearString)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf("parse year: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
month, err = strconv.Atoi(monthString)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf("parse month: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return getFirstOfMonth(year, month, time.Now().Location()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) budgetingForMonth(c *gin.Context) {
|
func (h *Handler) budgetingForMonth(c *gin.Context) {
|
||||||
budgetID := c.Param("budgetid")
|
budgetID := c.Param("budgetid")
|
||||||
budgetUUID, err := uuid.Parse(budgetID)
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
@ -97,8 +75,16 @@ func (h *Handler) budgetingForMonth(c *gin.Context) {
|
|||||||
|
|
||||||
categoriesWithBalance, moneyUsed := h.calculateBalances(
|
categoriesWithBalance, moneyUsed := h.calculateBalances(
|
||||||
budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
|
budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
|
||||||
|
availableBalance := h.getAvailableBalance(budget, moneyUsed, cumultativeBalances, firstOfNextMonth)
|
||||||
|
for i := range categoriesWithBalance {
|
||||||
|
cat := &categoriesWithBalance[i]
|
||||||
|
if cat.ID != budget.IncomeCategoryID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
availableBalance := h.getAvailableBalance(categories, budget, moneyUsed, cumultativeBalances, firstOfNextMonth)
|
cat.Available = availableBalance
|
||||||
|
cat.AvailableLastMonth = availableBalance
|
||||||
|
}
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
Categories []CategoryWithBalance
|
Categories []CategoryWithBalance
|
||||||
@ -107,27 +93,22 @@ func (h *Handler) budgetingForMonth(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, data)
|
c.JSON(http.StatusOK, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*Handler) getAvailableBalance(categories []postgres.GetCategoriesRow, budget postgres.Budget,
|
func (*Handler) getAvailableBalance(budget postgres.Budget,
|
||||||
moneyUsed numeric.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow,
|
moneyUsed numeric.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow,
|
||||||
firstOfNextMonth time.Time) numeric.Numeric {
|
firstOfNextMonth time.Time) numeric.Numeric {
|
||||||
availableBalance := numeric.Zero()
|
availableBalance := moneyUsed
|
||||||
for _, cat := range categories {
|
|
||||||
if cat.ID != budget.IncomeCategoryID {
|
for _, bal := range cumultativeBalances {
|
||||||
|
if bal.CategoryID != budget.IncomeCategoryID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
availableBalance = moneyUsed
|
|
||||||
|
|
||||||
for _, bal := range cumultativeBalances {
|
if !bal.Date.Before(firstOfNextMonth) {
|
||||||
if bal.CategoryID != cat.ID {
|
continue
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !bal.Date.Before(firstOfNextMonth) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
availableBalance = availableBalance.Add(bal.Transactions)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
availableBalance.AddI(bal.Transactions)
|
||||||
|
availableBalance.AddI(bal.Assignments)
|
||||||
}
|
}
|
||||||
return availableBalance
|
return availableBalance
|
||||||
}
|
}
|
||||||
@ -171,21 +152,18 @@ func (h *Handler) calculateBalances(budget postgres.Budget,
|
|||||||
cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, numeric.Numeric) {
|
cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, numeric.Numeric) {
|
||||||
categoriesWithBalance := []CategoryWithBalance{}
|
categoriesWithBalance := []CategoryWithBalance{}
|
||||||
|
|
||||||
moneyUsed := numeric.Zero()
|
moneyUsed2 := numeric.Zero()
|
||||||
|
moneyUsed := &moneyUsed2
|
||||||
for i := range categories {
|
for i := range categories {
|
||||||
cat := &categories[i]
|
cat := &categories[i]
|
||||||
// do not show hidden categories
|
// do not show hidden categories
|
||||||
categoryWithBalance := h.CalculateCategoryBalances(cat, cumultativeBalances,
|
categoryWithBalance := h.CalculateCategoryBalances(cat, cumultativeBalances,
|
||||||
firstOfNextMonth, &moneyUsed, firstOfMonth, budget)
|
firstOfNextMonth, moneyUsed, firstOfMonth, budget)
|
||||||
|
|
||||||
if cat.ID == budget.IncomeCategoryID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance)
|
categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance)
|
||||||
}
|
}
|
||||||
|
|
||||||
return categoriesWithBalance, moneyUsed
|
return categoriesWithBalance, *moneyUsed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow,
|
func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow,
|
||||||
@ -202,11 +180,11 @@ func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
*moneyUsed = moneyUsed.Sub(bal.Assignments)
|
moneyUsed.SubI(bal.Assignments)
|
||||||
categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Assignments)
|
categoryWithBalance.Available.AddI(bal.Assignments)
|
||||||
categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Transactions)
|
categoryWithBalance.Available.AddI(bal.Transactions)
|
||||||
if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) {
|
if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) {
|
||||||
*moneyUsed = moneyUsed.Add(categoryWithBalance.Available)
|
moneyUsed.AddI(categoryWithBalance.Available)
|
||||||
categoryWithBalance.Available = numeric.Zero()
|
categoryWithBalance.Available = numeric.Zero()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
55
server/category.go
Normal file
55
server/category.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SetCategoryAssignmentRequest struct {
|
||||||
|
Assigned string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) setCategoryAssignment(c *gin.Context) {
|
||||||
|
categoryID := c.Param("categoryid")
|
||||||
|
categoryUUID, err := uuid.Parse(categoryID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"categoryid missing from URL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request SetCategoryAssignmentRequest
|
||||||
|
err = c.BindJSON(&request)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid payload: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
date, err := getDate(c)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("date invalid: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var amount numeric.Numeric
|
||||||
|
err = amount.Set(request.Assigned)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("parse amount: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateArgs := postgres.UpdateAssignmentParams{
|
||||||
|
CategoryID: categoryUUID,
|
||||||
|
Date: date,
|
||||||
|
Amount: amount,
|
||||||
|
}
|
||||||
|
err = h.Service.UpdateAssignment(c.Request.Context(), updateArgs)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("update assignment: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
@ -64,6 +64,7 @@ func (h *Handler) LoadRoutes(router *gin.Engine) {
|
|||||||
budget.POST("/new", h.newBudget)
|
budget.POST("/new", h.newBudget)
|
||||||
budget.GET("/:budgetid", h.budgeting)
|
budget.GET("/:budgetid", h.budgeting)
|
||||||
budget.GET("/:budgetid/:year/:month", h.budgetingForMonth)
|
budget.GET("/:budgetid/:year/:month", h.budgetingForMonth)
|
||||||
|
budget.POST("/:budgetid/category/:categoryid/:year/:month", h.setCategoryAssignment)
|
||||||
budget.GET("/:budgetid/autocomplete/payees", h.autocompletePayee)
|
budget.GET("/:budgetid/autocomplete/payees", h.autocompletePayee)
|
||||||
budget.GET("/:budgetid/autocomplete/categories", h.autocompleteCategories)
|
budget.GET("/:budgetid/autocomplete/categories", h.autocompleteCategories)
|
||||||
budget.DELETE("/:budgetid", h.deleteBudget)
|
budget.DELETE("/:budgetid", h.deleteBudget)
|
||||||
|
30
server/util.go
Normal file
30
server/util.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getDate(c *gin.Context) (time.Time, error) {
|
||||||
|
var year, month int
|
||||||
|
yearString := c.Param("year")
|
||||||
|
monthString := c.Param("month")
|
||||||
|
if yearString == "" && monthString == "" {
|
||||||
|
return getFirstOfMonthTime(time.Now()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
year, err := strconv.Atoi(yearString)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("parse year: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
month, err = strconv.Atoi(monthString)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("parse month: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return getFirstOfMonth(year, month, time.Now().Location()), nil
|
||||||
|
}
|
@ -8,7 +8,6 @@ import { useSettingsStore } from "./stores/settings";
|
|||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(useBudgetsStore, ["CurrentBudgetName"]),
|
...mapState(useBudgetsStore, ["CurrentBudgetName"]),
|
||||||
...mapState(useSettingsStore, ["Menu"]),
|
|
||||||
...mapState(useSessionStore, ["LoggedIn"]),
|
...mapState(useSessionStore, ["LoggedIn"]),
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -27,15 +26,10 @@ export default defineComponent({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col md:flex-row flex-1">
|
<div class="flex flex-col md:flex-row flex-1 h-screen">
|
||||||
<div
|
|
||||||
:class="[Menu.Expand ? 'md:w-72' : 'md:w-36', Menu.Show ? '' : 'hidden']"
|
|
||||||
class="md:block flex-shrink-0 w-full bg-gray-500 border-r-4 border-black"
|
|
||||||
>
|
|
||||||
<router-view name="sidebar"></router-view>
|
<router-view name="sidebar"></router-view>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1">
|
<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 class="flex bg-gray-400 dark:bg-gray-600 p-4 fixed md:static top-0 left-0 w-full h-14">
|
||||||
<span
|
<span
|
||||||
class="flex-1 font-bold text-5xl -my-3 hidden md:inline"
|
class="flex-1 font-bold text-5xl -my-3 hidden md:inline"
|
||||||
|
@ -6,6 +6,7 @@ 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'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
@ -26,3 +27,46 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
await accountStore.SetCurrentAccount((<string>to.params.budgetid), (<string>to.params.accountid));
|
await accountStore.SetCurrentAccount((<string>to.params.budgetid), (<string>to.params.accountid));
|
||||||
next();
|
next();
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const sessionStore = useSessionStore();
|
||||||
|
const token = sessionStore.Session?.Token;
|
||||||
|
let loggedIn = false;
|
||||||
|
|
||||||
|
if (token != null) {
|
||||||
|
const jwt = parseJwt(token);
|
||||||
|
if (jwt.exp > Date.now() / 1000)
|
||||||
|
loggedIn = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.matched.some(record => record.meta.requiresAuth)) {
|
||||||
|
if (!loggedIn) {
|
||||||
|
next({ path: '/login' });
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (to.matched.some(record => record.meta.hideForAuth)) {
|
||||||
|
if (loggedIn) {
|
||||||
|
next({ path: '/dashboard' });
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseJwt(token: string) {
|
||||||
|
var base64Url = token.split('.')[1];
|
||||||
|
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
var jsonPayload = decodeURIComponent(atob(base64).split('').map(function (c) {
|
||||||
|
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||||
|
}).join(''));
|
||||||
|
|
||||||
|
return JSON.parse(jsonPayload);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
1646426130
|
||||||
|
1646512855755
|
@ -5,7 +5,9 @@ import { useBudgetsStore } from "../stores/budget"
|
|||||||
import { Account, useAccountStore } from "../stores/budget-account"
|
import { Account, useAccountStore } from "../stores/budget-account"
|
||||||
import { useSettingsStore } from "../stores/settings"
|
import { useSettingsStore } from "../stores/settings"
|
||||||
|
|
||||||
const ExpandMenu = computed(() => useSettingsStore().Menu.Expand);
|
const settings = useSettingsStore();
|
||||||
|
const ExpandMenu = computed(() => settings.Menu.Expand);
|
||||||
|
const ShowMenu = computed(() => settings.Menu.Show);
|
||||||
|
|
||||||
const budgetStore = useBudgetsStore();
|
const budgetStore = useBudgetsStore();
|
||||||
const CurrentBudgetName = computed(() => budgetStore.CurrentBudgetName);
|
const CurrentBudgetName = computed(() => budgetStore.CurrentBudgetName);
|
||||||
@ -17,63 +19,76 @@ const OffBudgetAccounts = computed(() => accountStore.OffBudgetAccounts);
|
|||||||
const OnBudgetAccountsBalance = computed(() => accountStore.OnBudgetAccountsBalance);
|
const OnBudgetAccountsBalance = computed(() => accountStore.OnBudgetAccountsBalance);
|
||||||
const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBalance);
|
const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBalance);
|
||||||
|
|
||||||
function isRecentlyReconciled(account : Account) {
|
function isRecentlyReconciled(account: Account) {
|
||||||
const now = new Date().getTime();
|
const now = new Date().getTime();
|
||||||
const recently = 7 * 24 * 60 * 60 * 1000;
|
const recently = 7 * 24 * 60 * 60 * 1000;
|
||||||
return new Date(now - recently).getTime() < account.LastReconciled.getTime();
|
return new Date(now - recently).getTime() < account.LastReconciled.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAccountName(account : Account) {
|
function getAccountName(account: Account) {
|
||||||
const reconciledMarker = isRecentlyReconciled(account) ? "" : " *";
|
const reconciledMarker = isRecentlyReconciled(account) ? "" : " *";
|
||||||
return account.Name + reconciledMarker;
|
return account.Name + reconciledMarker;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col mt-14 md:mt-0">
|
<div
|
||||||
<span class="m-2 p-1 px-3 h-10 overflow-hidden" :class="[ExpandMenu ? 'text-2xl' : 'text-md']">
|
:class="[ExpandMenu ? 'md:w-72' : 'md:w-36', ShowMenu ? '' : 'hidden']"
|
||||||
<router-link to="/dashboard" style="font-size:150%">⌂</router-link>
|
class="md:block flex-shrink-0 w-full bg-gray-500 border-r-4 border-black"
|
||||||
{{CurrentBudgetName}}
|
>
|
||||||
</span>
|
<div class="flex flex-col mt-14 md:mt-0">
|
||||||
<span class="bg-gray-100 dark:bg-gray-700 p-2 px-3 flex flex-col">
|
<span
|
||||||
<router-link :to="'/budget/'+CurrentBudgetID+'/budgeting'">Budget</router-link><br />
|
class="m-2 p-1 px-3 h-10 overflow-hidden"
|
||||||
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>-->
|
:class="[ExpandMenu ? 'text-2xl' : 'text-md']"
|
||||||
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>-->
|
>
|
||||||
</span>
|
<router-link to="/dashboard" style="font-size:150%">⌂</router-link>
|
||||||
<li class="bg-slate-200 dark:bg-slate-700 my-2 p-2 px-3">
|
{{ CurrentBudgetName }}
|
||||||
<div class="flex flex-row justify-between font-bold">
|
</span>
|
||||||
<span>On-Budget Accounts</span>
|
<span class="bg-gray-100 dark:bg-gray-700 p-2 px-3 flex flex-col">
|
||||||
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OnBudgetAccountsBalance" />
|
<router-link :to="'/budget/' + CurrentBudgetID + '/budgeting'">Budget</router-link>
|
||||||
</div>
|
<br />
|
||||||
<div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between">
|
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>-->
|
||||||
<router-link :to="'/budget/'+CurrentBudgetID+'/account/'+account.ID">{{getAccountName(account)}}</router-link>
|
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>-->
|
||||||
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.ClearedBalance" />
|
</span>
|
||||||
</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">
|
||||||
<li class="bg-slate-200 dark:bg-slate-700 my-2 p-2 px-3">
|
<span>On-Budget Accounts</span>
|
||||||
<div class="flex flex-row justify-between font-bold">
|
<Currency :class="ExpandMenu ? 'md:inline' : 'md:hidden'" :value="OnBudgetAccountsBalance" />
|
||||||
<span>Off-Budget Accounts</span>
|
</div>
|
||||||
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OffBudgetAccountsBalance" />
|
<div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between">
|
||||||
</div>
|
<router-link
|
||||||
<div v-for="account in OffBudgetAccounts" class="flex flex-row justify-between">
|
:to="'/budget/' + CurrentBudgetID + '/account/' + account.ID"
|
||||||
<router-link :to="'/budget/'+CurrentBudgetID+'/account/'+account.ID">{{getAccountName(account)}}</router-link>
|
>{{ getAccountName(account) }}</router-link>
|
||||||
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.ClearedBalance" />
|
<Currency :class="ExpandMenu ? 'md:inline' : 'md:hidden'" :value="account.ClearedBalance" />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<!--
|
<li class="bg-slate-200 dark:bg-slate-700 my-2 p-2 px-3">
|
||||||
|
<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">
|
||||||
|
<router-link
|
||||||
|
:to="'/budget/' + CurrentBudgetID + '/account/' + account.ID"
|
||||||
|
>{{ getAccountName(account) }}</router-link>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
+ 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'">Budget-Settings</router-link>
|
||||||
</li>
|
</li>
|
||||||
<!--<li><router-link to="/admin">Admin</router-link></li>-->
|
<!--<li><router-link to="/admin">Admin</router-link></li>-->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -2,8 +2,10 @@
|
|||||||
import { computed, defineProps, onMounted, ref, watchEffect } from "vue";
|
import { computed, defineProps, onMounted, ref, watchEffect } from "vue";
|
||||||
import Currency from "../components/Currency.vue";
|
import Currency from "../components/Currency.vue";
|
||||||
import { useBudgetsStore } from "../stores/budget";
|
import { useBudgetsStore } from "../stores/budget";
|
||||||
import { useAccountStore } from "../stores/budget-account";
|
import { Category, useAccountStore } from "../stores/budget-account";
|
||||||
import { useSessionStore } from "../stores/session";
|
import { useSessionStore } from "../stores/session";
|
||||||
|
import Input from "../components/Input.vue";
|
||||||
|
import { POST } from "../api";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
budgetid: string,
|
budgetid: string,
|
||||||
@ -63,10 +65,18 @@ function toggleGroup(group: { Name: string, Expand: boolean }) {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assignedChanged(e : Event, category : Category){
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const value = target.valueAsNumber;
|
||||||
|
POST("/budget/"+CurrentBudgetID.value+"/category/" + category.ID + "/" + selected.value.Year + "/" + (selected.value.Month+1),
|
||||||
|
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>
|
||||||
<div>
|
<div>
|
||||||
<router-link
|
<router-link
|
||||||
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"
|
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"
|
||||||
@ -92,9 +102,9 @@ function getGroupState(group: { Name: string, Expand: boolean }): boolean {
|
|||||||
<template v-for="category in GetCategories(group.Name)" v-if="getGroupState(group)">
|
<template v-for="category in GetCategories(group.Name)" v-if="getGroupState(group)">
|
||||||
<span class="whitespace-nowrap overflow-hidden">{{ category.Name }}</span>
|
<span class="whitespace-nowrap overflow-hidden">{{ category.Name }}</span>
|
||||||
<Currency :value="category.AvailableLastMonth" class="hidden lg:block" />
|
<Currency :value="category.AvailableLastMonth" class="hidden lg:block" />
|
||||||
<Currency :value="category.Assigned" class="hidden sm: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="category.Activity" class="hidden sm:block" />
|
||||||
<Currency :value="category.Available" />
|
<Currency :value="accountStore.GetCategoryAvailable(category)" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
@ -10,15 +10,16 @@ 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 },
|
{ path: "/dashboard", name: "Dashboard", component: Dashboard, meta: { requiresAuth: true } },
|
||||||
{ path: "/login", name: "Login", component: Login },
|
{ path: "/login", name: "Login", component: Login, meta: { hideForAuth: true } },
|
||||||
{ path: "/register", name: "Register", component: Register },
|
{ path: "/register", name: "Register", component: Register, meta: { hideForAuth: true } },
|
||||||
{ path: "/budget/:budgetid/budgeting", name: "Budget", redirect: (to : RouteLocationNormalized) =>
|
{ path: "/budget/:budgetid/budgeting", name: "Budget", redirect: (to : RouteLocationNormalized) =>
|
||||||
'/budget/' + to.params.budgetid + '/budgeting/' + new Date().getFullYear() + '/' + new Date().getMonth()
|
'/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 },
|
{ 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 },
|
{ 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 },
|
{ path: "/budget/:budgetid/account/:accountid", name: "Account", components: { default: Account, sidebar: BudgetSidebar }, props: true, meta: { requiresAuth: true } },
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
@ -31,7 +31,6 @@ export interface Category {
|
|||||||
AvailableLastMonth: number
|
AvailableLastMonth: number
|
||||||
Assigned: number
|
Assigned: number
|
||||||
Activity: number
|
Activity: number
|
||||||
Available: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAccountStore = defineStore("budget/account", {
|
export const useAccountStore = defineStore("budget/account", {
|
||||||
@ -51,12 +50,37 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
const monthMap = yearMap?.get(month);
|
const monthMap = yearMap?.get(month);
|
||||||
return [...monthMap?.values() || []];
|
return [...monthMap?.values() || []];
|
||||||
},
|
},
|
||||||
|
GetCategoryAvailable(state) {
|
||||||
|
return (category : Category) : number => {
|
||||||
|
return category.AvailableLastMonth + Number(category.Assigned) + category.Activity;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
GetIncomeCategoryID(state) {
|
||||||
|
const budget = useBudgetsStore();
|
||||||
|
return budget.CurrentBudget?.IncomeCategoryID;
|
||||||
|
},
|
||||||
|
GetIncomeAvailable(state) {
|
||||||
|
return (year: number, month: number) => {
|
||||||
|
const IncomeCategoryID = this.GetIncomeCategoryID;
|
||||||
|
if(IncomeCategoryID == null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
const categories = this.AllCategoriesForMonth(year, month);
|
||||||
|
const category = categories.filter(x => x.ID == IncomeCategoryID)[0];
|
||||||
|
if (category == null)
|
||||||
|
return 0;
|
||||||
|
return category.AvailableLastMonth;
|
||||||
|
}
|
||||||
|
},
|
||||||
CategoryGroupsForMonth(state) {
|
CategoryGroupsForMonth(state) {
|
||||||
return (year: number, month: number) => {
|
return (year: number, month: number) => {
|
||||||
const categories = this.AllCategoriesForMonth(year, month);
|
const categories = this.AllCategoriesForMonth(year, month);
|
||||||
const categoryGroups = [];
|
const categoryGroups = [];
|
||||||
let prev = undefined;
|
let prev = undefined;
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
|
if (category.ID == this.GetIncomeCategoryID)
|
||||||
|
continue;
|
||||||
|
|
||||||
if (category.Group != prev)
|
if (category.Group != prev)
|
||||||
categoryGroups.push({
|
categoryGroups.push({
|
||||||
Name: category.Group,
|
Name: category.Group,
|
||||||
|
@ -16,6 +16,7 @@ export interface Budget {
|
|||||||
ID: string
|
ID: string
|
||||||
Name: string
|
Name: string
|
||||||
AvailableBalance: number
|
AvailableBalance: number
|
||||||
|
IncomeCategoryID: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSessionStore = defineStore('session', {
|
export const useSessionStore = defineStore('session', {
|
||||||
|
@ -39,7 +39,7 @@ export const useTransactionsStore = defineStore("budget/transactions", {
|
|||||||
return reconciledBalance;
|
return reconciledBalance;
|
||||||
},
|
},
|
||||||
TransactionsList(state): Transaction[] {
|
TransactionsList(state): Transaction[] {
|
||||||
const accountsStore = useAccountStore()
|
const accountsStore = useAccountStore()
|
||||||
return accountsStore.CurrentAccount!.Transactions.map(x => {
|
return accountsStore.CurrentAccount!.Transactions.map(x => {
|
||||||
return this.Transactions.get(x)!
|
return this.Transactions.get(x)!
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user