Compare commits
31 Commits
master
...
create-cat
Author | SHA1 | Date | |
---|---|---|---|
|
adc6e0a2cf | ||
|
4d04529ae2 | ||
|
2559d2dbdb | ||
|
ac39ec07a4 | ||
|
5792aed089 | ||
|
69cb27fc51 | ||
|
907be875c8 | ||
|
cfe8599ed0 | ||
|
98e1fbb071 | ||
|
5d57045efb | ||
|
5456e0223a | ||
a0e6dc0f1b | |||
88283a8817 | |||
31a4135f2e | |||
94c2465109 | |||
6a5bf419e2 | |||
b55744aad7 | |||
5658f31457 | |||
fc15d8b56e | |||
403544e99f | |||
99979a35b0 | |||
49cf6a65da | |||
2b15231ed1 | |||
bae9f030b2 | |||
a06d0df142 | |||
0567619408 | |||
0c375884aa | |||
ca964f1c5f | |||
b2542fa6d1 | |||
77de2a833e | |||
10a870a300 |
@ -1,5 +1,5 @@
|
||||
build/
|
||||
.git/
|
||||
docker/
|
||||
docker-compose.yml
|
||||
README.md
|
||||
Earthfile
|
||||
|
@ -139,4 +139,4 @@ tasks:
|
||||
desc: Run dev environment in docker
|
||||
deps: [dev-docker]
|
||||
cmds:
|
||||
- docker-compose -f docker/docker-compose.dev.yml -p budgeteer up -d
|
||||
- docker-compose -f docker/docker-compose.dev.yml up -d
|
||||
|
12
bass.build
12
bass.build
@ -1,12 +0,0 @@
|
||||
(def go
|
||||
(from (linux/alpine)
|
||||
($ apk add go)))
|
||||
|
||||
(-> ($ go mod download)
|
||||
(with-image go)
|
||||
(with-mount *dir*/go.mod ./go.mod)
|
||||
(with-mount *dir*/go.sum ./go.sum))
|
||||
|
||||
(def go-mods
|
||||
(from go
|
||||
($ go mod download)))
|
@ -1,15 +1,9 @@
|
||||
FROM alpine as godeps
|
||||
RUN apk --no-cache add go
|
||||
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
|
||||
RUN go install github.com/go-task/task/v3/cmd/task@latest
|
||||
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
|
||||
FROM alpine
|
||||
RUN apk --no-cache add go nodejs yarn bash curl git git-perl
|
||||
FROM nixos/nix
|
||||
ENV PATH="/root/.yarn/bin/:${PATH}"
|
||||
WORKDIR /src/web
|
||||
RUN nix-env --install go go-task sqlc nodejs yarn git
|
||||
ADD web/package.json web/yarn.lock /src/web/
|
||||
WORKDIR /src/web
|
||||
RUN yarn
|
||||
WORKDIR /src
|
||||
VOLUME /go
|
||||
VOLUME /.cache
|
||||
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/
|
@ -2,6 +2,6 @@
|
||||
|
||||
tmux new-session -d -s watch 'cd web; yarn dev'
|
||||
tmux split-window;
|
||||
tmux send 'task -w run' ENTER;
|
||||
tmux send 'go-task -w run' ENTER;
|
||||
tmux split-window;
|
||||
tmux a;
|
||||
tmux a;
|
||||
|
@ -1,5 +1,7 @@
|
||||
version: '3.7'
|
||||
|
||||
name: "budgeteer"
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: hub.javil.eu/budgeteer:dev
|
||||
@ -7,7 +9,7 @@ services:
|
||||
ports:
|
||||
- 1323:1323
|
||||
volumes:
|
||||
- ~/budgeteer:/src
|
||||
- ../:/src
|
||||
- go-cache:/go
|
||||
- yarn-cache:/.cache
|
||||
environment:
|
||||
@ -22,7 +24,7 @@ services:
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- ~/budgeteer:/src
|
||||
- ../:/src
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
version: '3.7'
|
||||
|
||||
name: "budgeteer"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: hub.javil.eu/budgeteer:latest
|
||||
|
@ -91,6 +91,24 @@ func (q *Queries) GetCategories(ctx context.Context, budgetID uuid.UUID) ([]GetC
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getCategoryGroupByName = `-- name: GetCategoryGroupByName :one
|
||||
SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups
|
||||
WHERE category_groups.budget_id = $1
|
||||
AND category_groups.name = $2
|
||||
`
|
||||
|
||||
type GetCategoryGroupByNameParams struct {
|
||||
BudgetID uuid.UUID
|
||||
Name string
|
||||
}
|
||||
|
||||
func (q *Queries) GetCategoryGroupByName(ctx context.Context, arg GetCategoryGroupByNameParams) (CategoryGroup, error) {
|
||||
row := q.db.QueryRowContext(ctx, getCategoryGroupByName, arg.BudgetID, arg.Name)
|
||||
var i CategoryGroup
|
||||
err := row.Scan(&i.ID, &i.BudgetID, &i.Name)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getCategoryGroups = `-- name: GetCategoryGroups :many
|
||||
SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups
|
||||
WHERE category_groups.budget_id = $1
|
||||
|
@ -24,6 +24,9 @@ type CreatePayeeParams struct {
|
||||
}
|
||||
|
||||
func (q *Queries) CreatePayee(ctx context.Context, arg CreatePayeeParams) (Payee, error) {
|
||||
if len(arg.Name) > 50 {
|
||||
arg.Name = arg.Name[:50]
|
||||
}
|
||||
row := q.db.QueryRowContext(ctx, createPayee, arg.Name, arg.BudgetID)
|
||||
var i Payee
|
||||
err := row.Scan(&i.ID, &i.BudgetID, &i.Name)
|
||||
|
@ -8,6 +8,11 @@ RETURNING *;
|
||||
SELECT category_groups.* FROM category_groups
|
||||
WHERE category_groups.budget_id = $1;
|
||||
|
||||
-- name: GetCategoryGroupByName :one
|
||||
SELECT category_groups.* FROM category_groups
|
||||
WHERE category_groups.budget_id = $1
|
||||
AND category_groups.name = $2;
|
||||
|
||||
-- name: CreateCategory :one
|
||||
INSERT INTO categories
|
||||
(name, category_group_id)
|
||||
|
35
server/category_group_new.go
Normal file
35
server/category_group_new.go
Normal file
@ -0,0 +1,35 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type newCategoryGroupInformation struct {
|
||||
BudgetID uuid.UUID `json:"budgetId"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
func (h *Handler) newCategoryGroup(c echo.Context) error {
|
||||
var newCategory newCategoryGroupInformation
|
||||
if err := c.Bind(&newCategory); err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotAcceptable, err)
|
||||
}
|
||||
|
||||
if newCategory.Group == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "category group is required")
|
||||
}
|
||||
|
||||
categoryGroup, err := h.Service.CreateCategoryGroup(c.Request().Context(), postgres.CreateCategoryGroupParams{
|
||||
BudgetID: newCategory.BudgetID,
|
||||
Name: newCategory.Group,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, categoryGroup)
|
||||
}
|
48
server/category_new.go
Normal file
48
server/category_new.go
Normal file
@ -0,0 +1,48 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type newCategoryInformation struct {
|
||||
BudgetID uuid.UUID `json:"budgetId"`
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
func (h *Handler) newCategory(c echo.Context) error {
|
||||
var newCategory newCategoryInformation
|
||||
if err := c.Bind(&newCategory); err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotAcceptable, err)
|
||||
}
|
||||
|
||||
if newCategory.Name == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "category name is required")
|
||||
}
|
||||
|
||||
if newCategory.Group == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "category group is required")
|
||||
}
|
||||
|
||||
categoryGroup, err := h.Service.GetCategoryGroupByName(c.Request().Context(), postgres.GetCategoryGroupByNameParams{
|
||||
BudgetID: newCategory.BudgetID,
|
||||
Name: newCategory.Group,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
category, err := h.Service.CreateCategory(c.Request().Context(), postgres.CreateCategoryParams{
|
||||
CategoryGroupID: categoryGroup.ID,
|
||||
Name: newCategory.Name,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, category)
|
||||
}
|
@ -1,11 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.javil.eu/jacob1123/budgeteer"
|
||||
@ -36,6 +33,7 @@ func (h *Handler) Serve() {
|
||||
|
||||
// LoadRoutes initializes all the routes.
|
||||
func (h *Handler) LoadRoutes(router *echo.Echo) {
|
||||
router.Use(middleware.Logger())
|
||||
router.Use(enableCachingForStaticFiles())
|
||||
router.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
Filesystem: h.StaticFS,
|
||||
@ -51,10 +49,16 @@ func (h *Handler) LoadRoutes(router *echo.Echo) {
|
||||
authenticated := api.Group("")
|
||||
{
|
||||
authenticated.Use(h.verifyLoginWithForbidden)
|
||||
authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount)
|
||||
authenticated.POST("/account/:accountid/reconcile", h.reconcileTransactions)
|
||||
authenticated.POST("/account/:accountid", h.editAccount)
|
||||
authenticated.GET("/admin/clear-database", h.clearDatabase)
|
||||
account := authenticated.Group("/account")
|
||||
account.GET("/:accountid/transactions", h.transactionsForAccount)
|
||||
account.POST("/:accountid/reconcile", h.reconcileTransactions)
|
||||
account.POST("/:accountid", h.editAccount)
|
||||
|
||||
category := authenticated.Group("/category")
|
||||
category.POST("/new", h.newCategory)
|
||||
|
||||
categoryGroup := authenticated.Group("/category-group")
|
||||
categoryGroup.POST("/new", h.newCategoryGroup)
|
||||
|
||||
budget := authenticated.Group("/budget")
|
||||
budget.POST("/new", h.newBudget)
|
||||
@ -75,44 +79,16 @@ func (h *Handler) LoadRoutes(router *echo.Echo) {
|
||||
transaction := authenticated.Group("/transaction")
|
||||
transaction.POST("/new", h.newTransaction)
|
||||
transaction.POST("/:transactionid", h.updateTransaction)
|
||||
|
||||
authenticated.GET("/admin/clear-database", h.clearDatabase)
|
||||
}
|
||||
|
||||
api.Any("/*", h.notFound)
|
||||
}
|
||||
|
||||
func (h *Handler) ServeStatic(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
h.ServeStaticFile(c, c.Path())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ServeStaticFile(c echo.Context, fullPath string) {
|
||||
file, err := h.StaticFS.Open(fullPath)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
h.ServeStaticFile(c, path.Join("/", "/index.html"))
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if stat.IsDir() {
|
||||
h.ServeStaticFile(c, path.Join(fullPath, "index.html"))
|
||||
return
|
||||
}
|
||||
|
||||
if file, ok := file.(io.ReadSeeker); ok {
|
||||
http.ServeContent(c.Response().Writer, c.Request(), stat.Name(), stat.ModTime(), file)
|
||||
} else {
|
||||
panic("File does not implement ReadSeeker")
|
||||
}
|
||||
func (h *Handler) notFound(c echo.Context) error {
|
||||
fmt.Println("not found?")
|
||||
return echo.NewHTTPError(http.StatusNotImplemented, "not found")
|
||||
}
|
||||
|
||||
func enableCachingForStaticFiles() echo.MiddlewareFunc {
|
||||
|
@ -9,13 +9,24 @@ export function GET(path: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function POST(path: string, body: FormData | string | null) {
|
||||
export function FORM(path: string, body: FormData) {
|
||||
const sessionStore = useSessionStore();
|
||||
return fetch(BASE_URL + path, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...sessionStore.AuthHeaders,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
|
||||
export function POST(path: string, body: string | null) {
|
||||
const sessionStore = useSessionStore();
|
||||
return fetch(BASE_URL + path, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...sessionStore.AuthHeaders,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: body,
|
||||
});
|
||||
|
49
web/src/components/BudgetingCategory.vue
Normal file
49
web/src/components/BudgetingCategory.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useAccountStore } from '../stores/budget-account';
|
||||
import { Category, useCategoryStore } from '../stores/category';
|
||||
import Currency from './Currency.vue'
|
||||
import Input from './Input.vue';
|
||||
|
||||
const props = defineProps<{category:Category, year: number, month: number}>()
|
||||
|
||||
const assigned = ref(props.category.Assigned);
|
||||
watch(() => props.category.Assigned, () =>{ assigned.value = props.category.Assigned});
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
|
||||
function assignedChanged(_e : Event, category : Category){
|
||||
categoryStore.SetAssigned(category, props.year, props.month, assigned.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template
|
||||
v-for="category in getCategoriesForGroup(group)"
|
||||
:key="category.ID"
|
||||
>
|
||||
<div
|
||||
class="contents"
|
||||
>
|
||||
<span
|
||||
class="whitespace-nowrap overflow-hidden"
|
||||
>{{ category.Name }}</span>
|
||||
<Currency
|
||||
:value="category.AvailableLastMonth"
|
||||
class="hidden lg:block"
|
||||
/>
|
||||
<Input
|
||||
v-model="assigned"
|
||||
type="number"
|
||||
class="hidden sm:block mx-2 text-right"
|
||||
@input="(evt : Event) => assignedChanged(evt, category)"
|
||||
/>
|
||||
<Currency
|
||||
:value="category.Activity"
|
||||
class="hidden sm:block"
|
||||
/>
|
||||
<Currency
|
||||
:value="accountStore.GetCategoryAvailable(category)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
66
web/src/components/BudgetingCategoryGroup.vue
Normal file
66
web/src/components/BudgetingCategoryGroup.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useCategoryStore } from '../stores/category';
|
||||
import { CategoryGroup } from '../stores/category-group';
|
||||
import BudgetingCategory from './BudgetingCategory.vue';
|
||||
import Currency from './Currency.vue'
|
||||
import CreateCategory from '../dialogs/CreateCategory.vue';
|
||||
|
||||
const props = defineProps<{group: CategoryGroup, year: number, month: number}>();
|
||||
|
||||
const categoryStore = useCategoryStore();
|
||||
const categoriesForGroup = computed(() => categoryStore.GetCategoriesForGroup(props.group));
|
||||
|
||||
const expanded = ref(true)
|
||||
function toggleGroup() {
|
||||
expanded.value = !expanded.value;
|
||||
}
|
||||
|
||||
const availableLastMonth = computed(() => categoriesForGroup.value.reduce((prev, current) => prev + current.AvailableLastMonth, 0))
|
||||
const assigned = computed(() => categoriesForGroup.value.reduce((prev, current) => prev + current.Assigned, 0))
|
||||
const activity = computed(() => categoriesForGroup.value.reduce((prev, current) => prev + current.Activity, 0))
|
||||
const available = computed(() => activity.value+assigned.value+availableLastMonth.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="text-lg font-bold mt-2"
|
||||
@click="toggleGroup()"
|
||||
>{{ (expanded ? "−" : "+") + " " + group.Name }}
|
||||
<CreateCategory :category-group="group.Name" />
|
||||
</span>
|
||||
<Currency
|
||||
:value="availableLastMonth"
|
||||
class="hidden lg:block mt-2"
|
||||
positive-class="text-slate-500"
|
||||
negative-class="text-red-700 dark:text-red-400"
|
||||
/>
|
||||
<Currency
|
||||
:value="assigned"
|
||||
class="hidden sm:block mx-2 mt-2 text-right"
|
||||
positive-class="text-slate-500"
|
||||
negative-class="text-red-700 dark:text-red-400"
|
||||
/>
|
||||
<Currency
|
||||
:value="activity"
|
||||
class="hidden sm:block mt-2"
|
||||
positive-class="text-slate-500"
|
||||
negative-class="text-red-700 dark:text-red-400"
|
||||
/>
|
||||
<Currency
|
||||
:value="available"
|
||||
class="mt-2"
|
||||
positive-class="text-slate-500"
|
||||
negative-class="text-red-700 dark:text-red-400"
|
||||
/>
|
||||
<template
|
||||
v-if="expanded">
|
||||
<BudgetingCategory
|
||||
v-for="category in categoriesForGroup"
|
||||
:key="category.ID"
|
||||
:category="category"
|
||||
:year="year"
|
||||
:month="month"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
62
web/src/components/BudgetingSummary.vue
Normal file
62
web/src/components/BudgetingSummary.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useAccountStore } from '../stores/budget-account';
|
||||
import Currency from './Currency.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
year:number,
|
||||
month:number
|
||||
}>();
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
const budgeted = computed(() => accountStore.GetBudgeted(props.year, props.month))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>Budget for {{ month + 1 }}/{{ year }}</h1>
|
||||
<table class="inline-block">
|
||||
<tr>
|
||||
<td>
|
||||
Available last month:
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<Currency :value="accountStore.Available-accountStore.OverspentLastMonth+budgeted.Assigned+budgeted.Deassigned" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Overspent last month:
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<Currency :value="accountStore.OverspentLastMonth" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ (budgeted.Assigned+budgeted.Deassigned)>=0?"Budgeted":"Freed" }} this month:
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<Currency :value="-1*(budgeted.Assigned+budgeted.Deassigned)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="font-bold">
|
||||
<td class="py-2">
|
||||
Available balance:
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<Currency :value="accountStore.Available" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Activity:
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<Currency :value="budgeted.Income + budgeted.Spent" />
|
||||
</td>
|
||||
<td class="text-sm pl-2">
|
||||
= <Currency :value="budgeted.Income" /> - <Currency :value="-1 * budgeted.Spent" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</template>
|
@ -15,10 +15,13 @@ const visible = ref(false);
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
};
|
||||
function openDialog() {
|
||||
emit("open");
|
||||
visible.value = true;
|
||||
|
||||
function openDialog(e : MouseEvent) {
|
||||
e.stopPropagation();
|
||||
emit("open");
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
function submitDialog() {
|
||||
const e = {cancel: false};
|
||||
emit("submit", e);
|
||||
|
53
web/src/dialogs/CreateCategory.vue
Normal file
53
web/src/dialogs/CreateCategory.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import { useAccountStore } from '../stores/budget-account';
|
||||
import Input from '../components/Input.vue';
|
||||
import { useCategoryStore } from '../stores/category';
|
||||
|
||||
const categoryStore = useCategoryStore();
|
||||
|
||||
const props = defineProps<{
|
||||
categoryGroup: string
|
||||
}>();
|
||||
|
||||
const categoryName = ref("");
|
||||
const error = ref("");
|
||||
|
||||
function createCategory(e : {cancel:boolean}) : boolean {
|
||||
error.value = "";
|
||||
categoryStore.CreateCategory(props.categoryGroup, categoryName.value);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
button-text="Create Category"
|
||||
@submit="createCategory"
|
||||
>
|
||||
<template #placeholder>
|
||||
<span class="ml-2">+</span>
|
||||
</template>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
Parent: {{ categoryGroup }}
|
||||
</div>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<Input
|
||||
v-model="categoryName"
|
||||
class="border-2 dark:border-gray-700"
|
||||
type="text"
|
||||
placeholder="Category name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="error != ''"
|
||||
class="dark:text-red-300 text-red-700"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
44
web/src/dialogs/CreateCategoryGroup.vue
Normal file
44
web/src/dialogs/CreateCategoryGroup.vue
Normal file
@ -0,0 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import Input from '../components/Input.vue';
|
||||
import { useCategoryGroupStore } from '../stores/category-group';
|
||||
|
||||
const categoryGroupStore = useCategoryGroupStore();
|
||||
|
||||
const categoryGroupName = ref("");
|
||||
const error = ref("");
|
||||
|
||||
function createCategoryGroup(e : {cancel:boolean}) : boolean {
|
||||
error.value = "";
|
||||
categoryGroupStore.CreateCategoryGroup(categoryGroupName.value);
|
||||
return true;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
button-text="Create Category Group"
|
||||
@submit="createCategoryGroup"
|
||||
>
|
||||
<template #placeholder>
|
||||
<button class="px-2 py-0 w-full bg-slate-400 rounded-md mt-4">Add Group</button>
|
||||
</template>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<Input
|
||||
v-model="categoryGroupName"
|
||||
class="border-2 dark:border-gray-700"
|
||||
type="text"
|
||||
placeholder="Category name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="error != ''"
|
||||
class="dark:text-red-300 text-red-700"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
@ -1,11 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, watchEffect } from "vue";
|
||||
import Currency from "../components/Currency.vue";
|
||||
import { computed, onMounted, watchEffect } from "vue";
|
||||
import { useBudgetsStore } from "../stores/budget";
|
||||
import { Category, useAccountStore } from "../stores/budget-account";
|
||||
import { useCategoryGroupStore } from "../stores/category-group";
|
||||
import { useAccountStore } from "../stores/budget-account";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import Input from "../components/Input.vue";
|
||||
import { POST } from "../api";
|
||||
import BudgetingSummary from "../components/BudgetingSummary.vue";
|
||||
import { Category } from "../stores/category";
|
||||
import CreateCategoryGroup from "../dialogs/CreateCategoryGroup.vue";
|
||||
import BudgetingCategoryGroup from "../components/BudgetingCategoryGroup.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
budgetid: string,
|
||||
@ -13,22 +16,12 @@ const props = defineProps<{
|
||||
month: string,
|
||||
}>()
|
||||
|
||||
const categoryGroupStore = useCategoryGroupStore()
|
||||
const categoryGroups = computed(() => [...categoryGroupStore.CategoryGroups.values()]);
|
||||
|
||||
const budgetsStore = useBudgetsStore();
|
||||
const CurrentBudgetID = computed(() => budgetsStore.CurrentBudgetID);
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
const categoriesForMonth = accountStore.CategoriesForMonthAndGroup;
|
||||
|
||||
function GetCategories(group: string) {
|
||||
return [...categoriesForMonth(selected.value.Year, selected.value.Month, group)];
|
||||
};
|
||||
|
||||
const groupsForMonth = accountStore.CategoryGroupsForMonth;
|
||||
const GroupsForMonth = computed(() => {
|
||||
return [...groupsForMonth(selected.value.Year, selected.value.Month)];
|
||||
});
|
||||
|
||||
|
||||
const previous = computed(() => ({
|
||||
Year: new Date(selected.value.Year, selected.value.Month - 1, 1).getFullYear(),
|
||||
Month: new Date(selected.value.Year, selected.value.Month - 1, 1).getMonth(),
|
||||
@ -55,77 +48,10 @@ onMounted(() => {
|
||||
useSessionStore().setTitle("Budget for " + selected.value.Month + "/" + selected.value.Year);
|
||||
})
|
||||
|
||||
|
||||
const expandedGroups = ref<Map<string, boolean>>(new Map<string, boolean>())
|
||||
|
||||
function toggleGroup(group: { Name: string, Expand: boolean }) {
|
||||
expandedGroups.value.set(group.Name, !(expandedGroups.value.get(group.Name) ?? group.Expand))
|
||||
}
|
||||
|
||||
function getGroupState(group: { Name: string, Expand: boolean }): boolean {
|
||||
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}));
|
||||
}
|
||||
|
||||
const budgeted = computed(() => accountStore.GetBudgeted(selected.value.Year, selected.value.Month))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>Budget for {{ selected.Month + 1 }}/{{ selected.Year }}</h1>
|
||||
<table class="inline-block">
|
||||
<tr>
|
||||
<td>
|
||||
Available last month:
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<Currency :value="accountStore.Available-accountStore.OverspentLastMonth+budgeted.Assigned+budgeted.Deassigned" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Overspent last month:
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<Currency :value="accountStore.OverspentLastMonth" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Budgeted this month:
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<Currency :value="budgeted.Assigned+budgeted.Deassigned" />
|
||||
</td>
|
||||
<td class="text-sm pl-2">
|
||||
= <Currency :value="budgeted.Assigned" /> - <Currency :value="-budgeted.Deassigned" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="font-bold">
|
||||
<td class="py-2">
|
||||
Available balance:
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<Currency :value="accountStore.Available" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Activity:
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<Currency :value="budgeted.Income + budgeted.Spent" />
|
||||
</td>
|
||||
<td class="text-sm pl-2">
|
||||
= <Currency :value="budgeted.Income" /> - <Currency :value="-1 * budgeted.Spent" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<BudgetingSummary :year="selected.Year" :month="selected.Month" />
|
||||
<div>
|
||||
<router-link
|
||||
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"
|
||||
@ -152,68 +78,13 @@ const budgeted = computed(() => accountStore.GetBudgeted(selected.value.Year, se
|
||||
<span class="hidden sm:block text-right">Assigned</span>
|
||||
<span class="hidden sm:block text-right">Activity</span>
|
||||
<span class="hidden sm:block text-right">Available</span>
|
||||
<template
|
||||
v-for="group in GroupsForMonth"
|
||||
<BudgetingCategoryGroup
|
||||
v-for="group in categoryGroups"
|
||||
:key="group.Name"
|
||||
>
|
||||
<span
|
||||
class="text-lg font-bold mt-2"
|
||||
@click="toggleGroup(group)"
|
||||
>{{ (getGroupState(group) ? "−" : "+") + " " + group.Name }}</span>
|
||||
<Currency
|
||||
:value="group.AvailableLastMonth"
|
||||
class="hidden lg:block mt-2"
|
||||
positive-class="text-slate-500"
|
||||
negative-class="text-red-700 dark:text-red-400"
|
||||
/>
|
||||
<Currency
|
||||
:value="group.Assigned"
|
||||
class="hidden sm:block mx-2 mt-2 text-right"
|
||||
positive-class="text-slate-500"
|
||||
negative-class="text-red-700 dark:text-red-400"
|
||||
/>
|
||||
<Currency
|
||||
:value="group.Activity"
|
||||
class="hidden sm:block mt-2"
|
||||
positive-class="text-slate-500"
|
||||
negative-class="text-red-700 dark:text-red-400"
|
||||
/>
|
||||
<Currency
|
||||
:value="group.Available"
|
||||
class="mt-2"
|
||||
positive-class="text-slate-500"
|
||||
negative-class="text-red-700 dark:text-red-400"
|
||||
/>
|
||||
<template
|
||||
v-for="category in GetCategories(group.Name)"
|
||||
:key="category.ID"
|
||||
>
|
||||
<div
|
||||
v-if="getGroupState(group)"
|
||||
class="contents"
|
||||
>
|
||||
<span
|
||||
class="whitespace-nowrap overflow-hidden"
|
||||
>{{ category.Name }}</span>
|
||||
<Currency
|
||||
:value="category.AvailableLastMonth"
|
||||
class="hidden lg:block"
|
||||
/>
|
||||
<Input
|
||||
v-model="category.Assigned"
|
||||
type="number"
|
||||
class="hidden sm:block mx-2 text-right"
|
||||
@input="(evt) => assignedChanged(evt, category)"
|
||||
/>
|
||||
<Currency
|
||||
:value="category.Activity"
|
||||
class="hidden sm:block"
|
||||
/>
|
||||
<Currency
|
||||
:value="accountStore.GetCategoryAvailable(category)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
:group="group"
|
||||
:year="selected.Year"
|
||||
:month="selected.Month"
|
||||
/>
|
||||
<CreateCategoryGroup />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { GET, POST } from "../api";
|
||||
import { useBudgetsStore } from "./budget";
|
||||
import { Category, useCategoryStore } from "./category";
|
||||
import { useCategoryGroupStore } from "./category-group";
|
||||
import { useSessionStore } from "./session";
|
||||
import { Transaction, useTransactionsStore } from "./transactions";
|
||||
|
||||
interface State {
|
||||
Accounts: Map<string, Account>;
|
||||
CurrentAccountID: string | null;
|
||||
Categories: Map<string, Category>;
|
||||
Months: Map<number, Map<number, Map<string, Category>>>;
|
||||
Available: number,
|
||||
OverspentLastMonth: number,
|
||||
@ -22,7 +23,6 @@ export interface Account {
|
||||
ClearedBalance: number;
|
||||
WorkingBalance: number;
|
||||
ReconciledBalance: number;
|
||||
Transactions: string[];
|
||||
LastReconciled: NullDate;
|
||||
}
|
||||
|
||||
@ -31,15 +31,6 @@ interface NullDate {
|
||||
Time: Date;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
ID: string;
|
||||
Group: string;
|
||||
Name: string;
|
||||
AvailableLastMonth: number;
|
||||
Assigned: number;
|
||||
Activity: number;
|
||||
}
|
||||
|
||||
interface BudgetedAmounts {
|
||||
Assigned: number,
|
||||
Deassigned: number,
|
||||
@ -54,7 +45,6 @@ export const useAccountStore = defineStore("budget/account", {
|
||||
Months: new Map<number, Map<number, Map<string, Category>>>(),
|
||||
Available: 0,
|
||||
OverspentLastMonth: 0,
|
||||
Categories: new Map<string, Category>(),
|
||||
Assignments: [],
|
||||
}),
|
||||
getters: {
|
||||
@ -130,15 +120,10 @@ export const useAccountStore = defineStore("budget/account", {
|
||||
Expand: prev.Name != "Hidden Categories",
|
||||
});
|
||||
} else {
|
||||
categoryGroups[categoryGroups.length - 1].Available +=
|
||||
this.GetCategoryAvailable(category);
|
||||
categoryGroups[
|
||||
categoryGroups.length - 1
|
||||
].AvailableLastMonth += category.AvailableLastMonth;
|
||||
categoryGroups[categoryGroups.length - 1].Activity +=
|
||||
category.Activity;
|
||||
categoryGroups[categoryGroups.length - 1].Assigned +=
|
||||
category.Assigned;
|
||||
categoryGroups[categoryGroups.length - 1].Available += this.GetCategoryAvailable(category);
|
||||
categoryGroups[categoryGroups.length - 1].AvailableLastMonth += category.AvailableLastMonth;
|
||||
categoryGroups[categoryGroups.length - 1].Activity += category.Activity;
|
||||
categoryGroups[categoryGroups.length - 1].Assigned += category.Assigned;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@ -200,7 +185,6 @@ export const useAccountStore = defineStore("budget/account", {
|
||||
transactionsStore.AddTransactions(
|
||||
response.Transactions
|
||||
);
|
||||
account.Transactions = response.Transactions.map((x : Transaction) =>x.ID);
|
||||
},
|
||||
async FetchMonthBudget(budgetid: string, year: number, month: number) {
|
||||
const result = await GET(
|
||||
@ -242,22 +226,25 @@ export const useAccountStore = defineStore("budget/account", {
|
||||
available: number,
|
||||
overspentLastMonth: number,
|
||||
): void {
|
||||
this.$patch((state) => {
|
||||
const yearMap =
|
||||
state.Months.get(year) ||
|
||||
new Map<number, Map<string, Category>>();
|
||||
const monthMap =
|
||||
yearMap.get(month) || new Map<string, Category>();
|
||||
for (const category of categories) {
|
||||
monthMap.set(category.ID, category);
|
||||
}
|
||||
useCategoryStore().AddCategory(...categories)
|
||||
useCategoryGroupStore().AddCategoryGroup(...categories.map(x => ({ID: x.CategoryGroupID, Name: x.Group})));
|
||||
|
||||
yearMap.set(month, monthMap);
|
||||
state.Months.set(year, yearMap);
|
||||
this.$patch((state) => {
|
||||
const yearMap =
|
||||
state.Months.get(year) ||
|
||||
new Map<number, Map<string, Category>>();
|
||||
const monthMap =
|
||||
yearMap.get(month) || new Map<string, Category>();
|
||||
for (const category of categories) {
|
||||
monthMap.set(category.ID, category);
|
||||
}
|
||||
|
||||
state.Available = available;
|
||||
state.OverspentLastMonth = overspentLastMonth;
|
||||
});
|
||||
yearMap.set(month, monthMap);
|
||||
state.Months.set(year, yearMap);
|
||||
|
||||
state.Available = available;
|
||||
state.OverspentLastMonth = overspentLastMonth;
|
||||
});
|
||||
},
|
||||
logout() {
|
||||
this.$reset();
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { GET, POST } from "../api";
|
||||
import { GET, POST, FORM } from "../api";
|
||||
import { useAccountStore } from "./budget-account";
|
||||
import { useCategoryStore } from "./category";
|
||||
import { Budget, useSessionStore } from "./session";
|
||||
|
||||
interface State {
|
||||
@ -24,9 +25,9 @@ export const useBudgetsStore = defineStore("budget", {
|
||||
},
|
||||
actions: {
|
||||
ImportYNAB(formData: FormData) {
|
||||
return POST(
|
||||
return FORM(
|
||||
"/budget/" + this.CurrentBudgetID + "/import/ynab",
|
||||
formData
|
||||
formData,
|
||||
);
|
||||
},
|
||||
async NewBudget(budgetName: string): Promise<void> {
|
||||
@ -40,9 +41,13 @@ export const useBudgetsStore = defineStore("budget", {
|
||||
sessionStore.Budgets.set(response.ID, response);
|
||||
},
|
||||
async SetCurrentBudget(budgetid: string): Promise<void> {
|
||||
if(this.CurrentBudgetID == budgetid)
|
||||
return;
|
||||
|
||||
this.CurrentBudgetID = budgetid;
|
||||
|
||||
if (budgetid == null) return;
|
||||
if (budgetid == null)
|
||||
return;
|
||||
|
||||
await this.FetchBudget(budgetid);
|
||||
},
|
||||
@ -54,16 +59,16 @@ export const useBudgetsStore = defineStore("budget", {
|
||||
MergeBudgetingData(response: any) {
|
||||
const accounts = useAccountStore();
|
||||
for (const account of response.Accounts || []) {
|
||||
const existingAccount = accounts.Accounts.get(account.ID);
|
||||
account.Transactions = existingAccount?.Transactions ?? [];
|
||||
if (account.LastReconciled.Valid)
|
||||
account.LastReconciled.Time = new Date(
|
||||
account.LastReconciled.Time
|
||||
);
|
||||
accounts.Accounts.set(account.ID, account);
|
||||
}
|
||||
|
||||
const categories = useCategoryStore();
|
||||
for (const category of response.Categories || []) {
|
||||
accounts.Categories.set(category.ID, category);
|
||||
categories.Categories.set(category.ID, category);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
40
web/src/stores/category-group.ts
Normal file
40
web/src/stores/category-group.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { POST } from "../api";
|
||||
import { useBudgetsStore } from "./budget";
|
||||
|
||||
interface State {
|
||||
CategoryGroups: Map<string, CategoryGroup>;
|
||||
}
|
||||
|
||||
export interface CategoryGroup {
|
||||
ID: string;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
export const useCategoryGroupStore = defineStore("category-group", {
|
||||
state: (): State => ({
|
||||
CategoryGroups: new Map<string, CategoryGroup>(),
|
||||
}),
|
||||
getters: {
|
||||
},
|
||||
actions: {
|
||||
async CreateCategoryGroup(
|
||||
group: string,
|
||||
) {
|
||||
const result = await POST(
|
||||
"/category-group/new",
|
||||
JSON.stringify({
|
||||
budgetId: useBudgetsStore().CurrentBudgetID,
|
||||
group: group,
|
||||
})
|
||||
);
|
||||
const response = await result.json();
|
||||
this.AddCategoryGroup(response);
|
||||
},
|
||||
async AddCategoryGroup(...categoryGroups : CategoryGroup[]){
|
||||
for (const categoryGroup of categoryGroups) {
|
||||
this.CategoryGroups.set(categoryGroup.ID, categoryGroup);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
59
web/src/stores/category.ts
Normal file
59
web/src/stores/category.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { POST } from "../api";
|
||||
import { useBudgetsStore } from "./budget";
|
||||
import { CategoryGroup } from "./category-group";
|
||||
|
||||
interface State {
|
||||
Categories: Map<string, Category>;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
ID: string;
|
||||
CategoryGroupID: string;
|
||||
Group: string;
|
||||
Name: string;
|
||||
AvailableLastMonth: number;
|
||||
Assigned: number;
|
||||
Activity: number;
|
||||
}
|
||||
|
||||
export const useCategoryStore = defineStore("category", {
|
||||
state: (): State => ({
|
||||
Categories: new Map<string, Category>(),
|
||||
}),
|
||||
getters: {
|
||||
GetCategoriesForGroup(state) {
|
||||
return (group : CategoryGroup) : Category[] => {
|
||||
return [...state.Categories.values()].filter(x => x.CategoryGroupID == group.ID);
|
||||
}
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async CreateCategory(
|
||||
group: string,
|
||||
name: string,
|
||||
) {
|
||||
const result = await POST(
|
||||
"/category/new",
|
||||
JSON.stringify({
|
||||
budgetId: useBudgetsStore().CurrentBudgetID,
|
||||
name: name,
|
||||
group: group,
|
||||
})
|
||||
);
|
||||
const response = await result.json();
|
||||
this.AddCategory(response);
|
||||
},
|
||||
async AddCategory(...categories : Category[]){
|
||||
for (const category of categories) {
|
||||
this.Categories.set(category.ID, category);
|
||||
}
|
||||
},
|
||||
async SetAssigned(category : Category, year : number, month : number, assigned : number){
|
||||
this.Categories.get(category.ID)!.Assigned = assigned;
|
||||
const currentBudgetID = useBudgetsStore().CurrentBudgetID;
|
||||
await POST("/budget/"+currentBudgetID+"/category/" + category.ID + "/" + year + "/" + (month+1),
|
||||
JSON.stringify({Assigned: assigned}));
|
||||
}
|
||||
},
|
||||
});
|
@ -52,7 +52,7 @@ export const useTransactionsStore = defineStore("budget/transactions", {
|
||||
const account = accountsStore.CurrentAccount;
|
||||
if(account === undefined)
|
||||
return undefined;
|
||||
const allTransactions = account!.Transactions.map(x => this.Transactions.get(x) ?? {} as Transaction);
|
||||
const allTransactions = [...this.Transactions.values()].filter(x => x.AccountID == account.ID);
|
||||
return groupBy(allTransactions, x => formatDate(x.Date));
|
||||
},
|
||||
TransactionsList(state) : Transaction[] {
|
||||
@ -130,18 +130,15 @@ export const useTransactionsStore = defineStore("budget/transactions", {
|
||||
const recTrans = response.ReconciliationTransaction;
|
||||
if (recTrans) {
|
||||
this.AddTransactions([recTrans]);
|
||||
account.Transactions.unshift(recTrans.ID);
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
this.$reset();
|
||||
},
|
||||
async saveTransaction(payload: string) {
|
||||
const accountsStore = useAccountStore();
|
||||
const result = await POST("/transaction/new", payload);
|
||||
const response = (await result.json()) as Transaction;
|
||||
this.AddTransactions([response]);
|
||||
accountsStore.CurrentAccount?.Transactions.unshift(response.ID);
|
||||
},
|
||||
async editTransaction(transactionid: string, payload: string) {
|
||||
const result = await POST("/transaction/" + transactionid, payload);
|
||||
|
7272
web/yarn.lock
7272
web/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user