84 Commits
0.5.1 ... 0.6.0

Author SHA1 Message Date
8eabf3d1c8 Merge pull request 'Fix input on mobile' (#44) from mobile-input into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #44
2022-03-15 22:12:31 +01:00
f0084b683e Fix ts issues
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-03-15 21:11:24 +00:00
84c95d0c4e Bring submit button to work
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2022-03-15 21:05:12 +00:00
6576c994c9 Improve layout 2022-03-15 20:21:34 +00:00
a552983ac4 Merge pull request 'Setup and enable ESLint' (#43) from eslint into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #43
2022-03-15 21:11:01 +01:00
f99efe0e8e Finish Rename
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-03-15 20:09:59 +00:00
e1b6056d77 Fix more imports
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2022-03-15 20:08:36 +00:00
94329ac331 Fix imports in router
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2022-03-15 20:05:42 +00:00
bfa67578cf Fix tsc issues
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2022-03-15 20:04:01 +00:00
eae196aa69 Run eslint in CI 2022-03-15 20:00:12 +00:00
d2dd7d5280 Ignore left-over issues
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2022-03-15 19:58:16 +00:00
f707197f42 Ignore index.css 2022-03-15 19:58:03 +00:00
66322ebd86 Fix some linter issues 2022-03-15 19:50:49 +00:00
3ea90a5ebe Run eslint with autofix 2022-03-15 12:55:22 +00:00
d717ef1b4d Use spaces 2022-03-15 12:52:23 +00:00
61a534610f Run prettier
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-03-15 12:50:20 +00:00
d8d713f841 Change prettier configuration 2022-03-15 12:50:20 +00:00
7ac06012ec Initialize eslint 2022-03-15 12:50:20 +00:00
9af412a8bd Merge pull request 'Hide hidden categories from autocomplete results' (#42) from hide-hidden-categories into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #42
2022-03-15 13:50:10 +01:00
3b61028576 Hide hidden categories from searchresults 2022-03-15 13:50:10 +01:00
87b78e7b67 Make search queries case insensitive 2022-03-15 13:50:10 +01:00
6a71d9643c Merge pull request 'Persist last_reconciled instead of using last transactions date' (#41) from reconciled-as-field into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #41
2022-03-14 21:01:08 +01:00
450324d29e Extract AccountWithReconciled
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-03-14 19:48:45 +00:00
6f4e286b7a Initialize last_reconciled from transactions 2022-03-14 19:46:21 +00:00
324e13f5c5 Show age in days 2022-03-14 19:34:17 +00:00
0201b368d4 Add column and use instead of last transactions date
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-03-14 19:26:54 +00:00
984a2aa296 Merge pull request 'Show sums for category groups' (#40) from category-group-summary into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #40
2022-03-14 20:15:20 +01:00
d1a0296a58 Add margin before group
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-03-14 19:12:46 +00:00
d9efa9f4a1 Improve layout of groups 2022-03-14 19:10:09 +00:00
b176ce26ba Implement summing per category group
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-03-05 22:23:41 +00:00
97de326527 Use new UpdateAssignmentWithDifference for ynab-import to prevent errors on duplicates 2022-03-05 22:23:13 +00:00
a62ab543b0 Merge pull request 'Implement basic budgeting input' (#39) from budgeting into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #39
2022-03-05 22:12:14 +01:00
244333adbc Fix null reference in GetIncomeAvailable
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-03-05 20:48:36 +00:00
67ef4745a0 Handle login redirect 2022-03-05 20:48:27 +00:00
3856e8a8a5 Update assignment on modification
All checks were successful
continuous-integration/drone/push Build is passing
2022-03-04 22:24:50 +00:00
db96fddde2 Implement update Assignment on backend 2022-03-04 22:24:38 +00:00
ecb801b2f2 Move getDate to util file 2022-03-04 21:56:24 +00:00
fd6b77f154 Replace id column with natural PK 2022-03-04 21:56:12 +00:00
442e87234c Show available income in Budgeting header
All checks were successful
continuous-integration/drone/push Build is passing
2022-03-04 21:31:13 +00:00
caf39fe5ea Set Available balance of income category 2022-03-04 21:18:44 +00:00
0dde94f855 Remove logging 2022-03-04 21:15:24 +00:00
34bbdf48b7 Also return income category 2022-03-04 21:13:59 +00:00
1e495a5e47 Remove unneeded loop 2022-03-04 21:13:50 +00:00
fc29b1409d Use in-place additions more 2022-03-04 21:13:38 +00:00
8ecfd6b87a Use in-place calculations 2022-03-04 20:55:38 +00:00
d89e702669 Move sidebar layout to BudgetSidebar 2022-03-04 20:55:14 +00:00
3795150508 Implement numeric operations in-place 2022-03-04 20:54:23 +00:00
67c013799d Calculate available balance locally 2022-03-03 10:44:50 +00:00
0038db90ac Merge pull request 'Fix docker build' (#38) from docker-build into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #38
2022-03-02 23:55:29 +01:00
9759a7cc1e Fix docker build
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-03-02 22:55:21 +00:00
81c31a3d5f Merge pull request 'Cleanup' (#37) from cleanup into master
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #37
2022-03-02 23:53:15 +01:00
38e21786a7 Fix app bar for small screens
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-03-02 22:52:12 +00:00
971c3d3be5 Improve sidebar and show next to appbar instead of below 2022-03-02 22:52:12 +00:00
946f14c1cc Fix const 2022-03-02 22:51:20 +00:00
9ce0da0182 Move CSS to _index.css 2022-03-02 22:51:20 +00:00
4c93e4635d Extract const for default expiration 2022-03-02 22:51:20 +00:00
f3a50c790b Hide .task, build and dist folders 2022-03-02 22:51:20 +00:00
0c5f68ed80 Hide files generated by sqlc 2022-03-02 22:51:20 +00:00
7fdd8bd935 Split jwt into two files 2022-03-02 22:51:20 +00:00
d4287f8aac Remove example config 2022-03-02 22:51:20 +00:00
6712af10d9 Move production Dockerfile to docker directory 2022-03-02 22:51:20 +00:00
70edb382e1 Move compose files to docker directory 2022-03-02 22:51:20 +00:00
390a042441 Rename development Dockerfile and build script 2022-03-02 22:51:20 +00:00
e8028dae34 Remove woodpecker config 2022-03-02 22:51:20 +00:00
fc249adc9e Remove Earthfile 2022-03-02 22:51:20 +00:00
c186a14644 go mod tidy 2022-03-02 22:51:20 +00:00
8a27303670 Remove earthignore 2022-03-02 22:51:20 +00:00
44e9bb6ec0 Merge pull request 'Implement closing of accounts' (#36) from closed-accounts into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #36
2022-03-02 22:54:02 +01:00
42d431ba8b Fix modal layout for dark theme
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-03-02 21:52:35 +00:00
3727061065 Prevent closing of accounts with balance 2022-03-02 21:52:16 +00:00
c7a8adb3ab Remove closed account from sidebar
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-03-02 21:22:15 +00:00
29f534bf10 Hide closed accounts menuentry 2022-03-02 21:22:06 +00:00
15381c84f6 Navigate to budgeting from closed account 2022-03-02 21:21:56 +00:00
a0cabbf4f7 Pass is_open to API from EditAccount 2022-03-02 21:16:06 +00:00
f26ee8f472 Add ability to change is_open from API 2022-03-02 21:15:56 +00:00
4fb3c2a335 Show is_open in EditAccount 2022-03-02 21:14:12 +00:00
8899ff5772 Return and filter by is_open 2022-03-02 21:14:00 +00:00
347a0c9e50 Add column is_open to accounts 2022-03-02 21:13:42 +00:00
66b8e1f69f Fix edit-accounts' font being off 2022-03-02 21:07:53 +00:00
5d1b49c896 Merge pull request 'Implement custom checkbox' (#35) from checkbox into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #35
2022-03-02 21:34:27 +01:00
42dc51fe9a Improve layout of Reconcilation
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-03-02 20:33:01 +00:00
1ca95f8768 Remove old logging calls
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2022-03-02 20:06:33 +00:00
a73f7c2934 Bind to value instead of using v-model 2022-03-02 20:06:16 +00:00
489aa88c4b Add custom checkbox component 2022-03-02 20:06:06 +00:00
84 changed files with 2343 additions and 1099 deletions

View File

@ -32,7 +32,7 @@ steps:
from_secret: docker_password from_secret: docker_password
repo: hub.javil.eu/budgeteer repo: hub.javil.eu/budgeteer
context: build context: build
dockerfile: build/Dockerfile dockerfile: docker/Dockerfile
tags: tags:
- latest - latest
when: when:
@ -51,7 +51,7 @@ steps:
from_secret: docker_password from_secret: docker_password
repo: hub.javil.eu/budgeteer repo: hub.javil.eu/budgeteer
context: build context: build
dockerfile: build/Dockerfile dockerfile: docker/Dockerfile
auto_tag: true auto_tag: true
when: when:
event: event:

View File

@ -1,10 +0,0 @@
build/
.git/
docker-compose.yml
README.md
Earthfile
config.example.json
.gitignore
.vscode/
budgeteer
budgeteer.exe

View File

@ -1,7 +1,11 @@
{ {
"files.exclude": { "files.exclude": {
"**/node_modules": true, "**/node_modules": true,
"**/vendor": true "**/vendor": true,
"**/*.sql.go": true,
".task/": true,
"build/": true,
"web/dist/": true
}, },
"gopls": { "gopls": {
"formatting.gofumpt": true, "formatting.gofumpt": true,

View File

@ -1,23 +0,0 @@
pipeline:
build:
name: Taskfile.dev
image: hub.javil.eu/budgeteer:dev
pull: true
commands:
- task ci
docker:
image: plugins/docker
secrets: [ docker_username, docker_password ]
settings:
registry: hub.javil.eu
repo: hub.javil.eu/budgeteer
context: build
dockerfile: build/Dockerfile
tags:
- latest
when:
event: [push, tag, deployment]
image_pull_secrets:
- hub.javil.eu

View File

@ -1,21 +0,0 @@
FROM golang:1.17
WORKDIR /src
build:
COPY go.mod go.sum .
RUN go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build go build -o build/budgeteer ./cmd/budgeteer
SAVE ARTIFACT build/budgeteer /budgeteer AS LOCAL build/budgeteer
docker:
WORKDIR /app
COPY +build/budgeteer .
ENTRYPOINT ["/app/budgeteer"]
SAVE IMAGE hub.javil.eu/budgeteer:latest
run:
LOCALLY
WITH DOCKER --load=+docker
RUN docker-compose up -d
END

View File

@ -78,6 +78,7 @@ tasks:
- yarn - yarn
- yarn build - yarn build
- yarn run vue-tsc --noEmit - yarn run vue-tsc --noEmit
- yarn run eslint "./src/**"
docker: docker:
desc: Build budgeeter:latest desc: Build budgeeter:latest
@ -96,7 +97,7 @@ tasks:
- ./docker/build.sh - ./docker/build.sh
- ./web/package.json - ./web/package.json
cmds: cmds:
- docker build -t {{.IMAGE_NAME}}:dev . -f docker/Dockerfile - docker build -t {{.IMAGE_NAME}}:dev . -f docker/Dockerfile.dev
- docker push {{.IMAGE_NAME}}:dev - docker push {{.IMAGE_NAME}}:dev
run: run:

View File

@ -1,3 +0,0 @@
FROM scratch
COPY ./budgeteer /app/budgeteer
ENTRYPOINT ["/app/budgeteer"]

View File

@ -1,6 +0,0 @@
{
"DatabaseHost": "localhost",
"DatabaseUser": "user",
"DatabasePassword": "thisismypassword",
"DatabaseName": "budgeteer"
}

View File

@ -1,17 +1,3 @@
FROM alpine as godeps FROM scratch
RUN apk --no-cache add go COPY ./budgeteer /app/budgeteer
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest ENTRYPOINT ["/app/budgeteer"]
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 tmux
ADD docker/build.sh /
RUN yarn global add @vue/cli
ENV PATH="/root/.yarn/bin/:${PATH}"
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

17
docker/Dockerfile.dev Normal file
View File

@ -0,0 +1,17 @@
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 tmux
ADD docker/dev.sh /
RUN yarn global add @vue/cli
ENV PATH="/root/.yarn/bin/:${PATH}"
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 /dev.sh

2
go.mod
View File

@ -11,7 +11,7 @@ require (
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa
) )
require github.com/DATA-DOG/go-txdb v0.1.5 // indirect require github.com/DATA-DOG/go-txdb v0.1.5
require ( require (
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect

1
go.sum
View File

@ -74,6 +74,7 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=

View File

@ -12,10 +12,11 @@ import (
// TokenVerifier verifies Tokens. // TokenVerifier verifies Tokens.
type TokenVerifier struct { type TokenVerifier struct {
secret string Expiration time.Duration
secret string
} }
var ErrEmptySecret = fmt.Errorf("secret is required") const DefaultExpiration = time.Hour * time.Duration(72)
func NewTokenVerifier(secret string) (*TokenVerifier, error) { func NewTokenVerifier(secret string) (*TokenVerifier, error) {
if secret == "" { if secret == "" {
@ -23,20 +24,16 @@ func NewTokenVerifier(secret string) (*TokenVerifier, error) {
} }
return &TokenVerifier{ return &TokenVerifier{
secret: secret, Expiration: DefaultExpiration,
secret: secret,
}, nil }, nil
} }
// Token contains everything to authenticate a user. var (
type Token struct { ErrUnexpectedSigningMethod = fmt.Errorf("unexpected signing method")
username string ErrInvalidToken = fmt.Errorf("token is invalid")
name string ErrTokenExpired = fmt.Errorf("token has expired")
expiry float64 ErrEmptySecret = fmt.Errorf("secret is required")
id uuid.UUID
}
const (
expiration = 72
) )
// CreateToken creates a new token from username and name. // CreateToken creates a new token from username and name.
@ -48,7 +45,7 @@ func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"usr": user.Email, "usr": user.Email,
"name": user.Name, "name": user.Name,
"exp": time.Now().Add(time.Hour * expiration).Unix(), "exp": time.Now().Add(tv.Expiration).Unix(),
"id": user.ID, "id": user.ID,
}) })
@ -61,13 +58,7 @@ func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
return t, nil return t, nil
} }
var ( // VerifyToken verifies a given string-token.
ErrUnexpectedSigningMethod = fmt.Errorf("unexpected signing method")
ErrInvalidToken = fmt.Errorf("token is invalid")
ErrTokenExpired = fmt.Errorf("token has expired")
)
// VerifyToken verifys a given string-token.
func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) { //nolint:ireturn func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) { //nolint:ireturn
if tv.secret == "" { if tv.secret == "" {
return nil, ErrEmptySecret return nil, ErrEmptySecret
@ -96,36 +87,3 @@ func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error
} }
return tkn, nil return tkn, nil
} }
func verifyToken(token *jwt.Token) (jwt.MapClaims, error) {
if !token.Valid {
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, ErrInvalidToken
}
if !claims.VerifyExpiresAt(time.Now().Unix(), true) {
return nil, ErrTokenExpired
}
return claims, nil
}
func (t *Token) GetName() string {
return t.name
}
func (t *Token) GetUsername() string {
return t.username
}
func (t *Token) GetExpiry() float64 {
return t.expiry
}
func (t *Token) GetID() uuid.UUID {
return t.id
}

49
jwt/token.go Normal file
View File

@ -0,0 +1,49 @@
package jwt
import (
"time"
"github.com/dgrijalva/jwt-go"
"github.com/google/uuid"
)
// Token contains everything to authenticate a user.
type Token struct {
username string
name string
expiry float64
id uuid.UUID
}
func verifyToken(token *jwt.Token) (jwt.MapClaims, error) {
if !token.Valid {
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, ErrInvalidToken
}
if !claims.VerifyExpiresAt(time.Now().Unix(), true) {
return nil, ErrTokenExpired
}
return claims, nil
}
func (t *Token) GetName() string {
return t.name
}
func (t *Token) GetUsername() string {
return t.username
}
func (t *Token) GetExpiry() float64 {
return t.expiry
}
func (t *Token) GetID() uuid.UUID {
return t.id
}

View File

@ -5,7 +5,7 @@ package postgres
import ( import (
"context" "context"
"time" "database/sql"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric" "git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
@ -15,7 +15,7 @@ const createAccount = `-- name: CreateAccount :one
INSERT INTO accounts INSERT INTO accounts
(name, budget_id) (name, budget_id)
VALUES ($1, $2) VALUES ($1, $2)
RETURNING id, budget_id, name, on_budget RETURNING id, budget_id, name, on_budget, is_open, last_reconciled
` `
type CreateAccountParams struct { type CreateAccountParams struct {
@ -31,12 +31,14 @@ func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (A
&i.BudgetID, &i.BudgetID,
&i.Name, &i.Name,
&i.OnBudget, &i.OnBudget,
&i.IsOpen,
&i.LastReconciled,
) )
return i, err return i, err
} }
const getAccount = `-- name: GetAccount :one const getAccount = `-- name: GetAccount :one
SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget FROM accounts SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget, accounts.is_open, accounts.last_reconciled FROM accounts
WHERE accounts.id = $1 WHERE accounts.id = $1
` `
@ -48,13 +50,16 @@ func (q *Queries) GetAccount(ctx context.Context, id uuid.UUID) (Account, error)
&i.BudgetID, &i.BudgetID,
&i.Name, &i.Name,
&i.OnBudget, &i.OnBudget,
&i.IsOpen,
&i.LastReconciled,
) )
return i, err return i, err
} }
const getAccounts = `-- name: GetAccounts :many const getAccounts = `-- name: GetAccounts :many
SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget FROM accounts SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget, accounts.is_open, accounts.last_reconciled FROM accounts
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
AND accounts.is_open = TRUE
ORDER BY accounts.name ORDER BY accounts.name
` `
@ -72,6 +77,8 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
&i.BudgetID, &i.BudgetID,
&i.Name, &i.Name,
&i.OnBudget, &i.OnBudget,
&i.IsOpen,
&i.LastReconciled,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -87,13 +94,13 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
} }
const getAccountsWithBalance = `-- name: GetAccountsWithBalance :many const getAccountsWithBalance = `-- name: GetAccountsWithBalance :many
SELECT accounts.id, accounts.name, accounts.on_budget, SELECT accounts.id, accounts.name, accounts.on_budget, accounts.is_open, accounts.last_reconciled,
(SELECT MAX(transactions.date) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.status = 'Reconciled')::date as last_reconciled,
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW())::decimal(12,2) as working_balance, (SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW())::decimal(12,2) as working_balance,
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status IN ('Cleared', 'Reconciled'))::decimal(12,2) as cleared_balance, (SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status IN ('Cleared', 'Reconciled'))::decimal(12,2) as cleared_balance,
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status = 'Reconciled')::decimal(12,2) as reconciled_balance (SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status = 'Reconciled')::decimal(12,2) as reconciled_balance
FROM accounts FROM accounts
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
AND accounts.is_open = TRUE
ORDER BY accounts.name ORDER BY accounts.name
` `
@ -101,7 +108,8 @@ type GetAccountsWithBalanceRow struct {
ID uuid.UUID ID uuid.UUID
Name string Name string
OnBudget bool OnBudget bool
LastReconciled time.Time IsOpen bool
LastReconciled sql.NullTime
WorkingBalance numeric.Numeric WorkingBalance numeric.Numeric
ClearedBalance numeric.Numeric ClearedBalance numeric.Numeric
ReconciledBalance numeric.Numeric ReconciledBalance numeric.Numeric
@ -120,6 +128,7 @@ func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.OnBudget, &i.OnBudget,
&i.IsOpen,
&i.LastReconciled, &i.LastReconciled,
&i.WorkingBalance, &i.WorkingBalance,
&i.ClearedBalance, &i.ClearedBalance,
@ -141,7 +150,8 @@ func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID
const searchAccounts = `-- name: SearchAccounts :many const searchAccounts = `-- name: SearchAccounts :many
SELECT accounts.id, accounts.budget_id, accounts.name, 'account' as type FROM accounts SELECT accounts.id, accounts.budget_id, accounts.name, 'account' as type FROM accounts
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
AND accounts.name LIKE $2 AND accounts.is_open = TRUE
AND accounts.name ILIKE $2
ORDER BY accounts.name ORDER BY accounts.name
` `
@ -185,28 +195,48 @@ func (q *Queries) SearchAccounts(ctx context.Context, arg SearchAccountsParams)
return items, nil return items, nil
} }
const setLastReconciled = `-- name: SetLastReconciled :exec
UPDATE accounts
SET last_reconciled = NOW()
WHERE accounts.id = $1
`
func (q *Queries) SetLastReconciled(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, setLastReconciled, id)
return err
}
const updateAccount = `-- name: UpdateAccount :one const updateAccount = `-- name: UpdateAccount :one
UPDATE accounts UPDATE accounts
SET name = $1, SET name = $1,
on_budget = $2 on_budget = $2,
WHERE accounts.id = $3 is_open = $3
RETURNING id, budget_id, name, on_budget WHERE accounts.id = $4
RETURNING id, budget_id, name, on_budget, is_open, last_reconciled
` `
type UpdateAccountParams struct { type UpdateAccountParams struct {
Name string Name string
OnBudget bool OnBudget bool
IsOpen bool
ID uuid.UUID ID uuid.UUID
} }
func (q *Queries) UpdateAccount(ctx context.Context, arg UpdateAccountParams) (Account, error) { func (q *Queries) UpdateAccount(ctx context.Context, arg UpdateAccountParams) (Account, error) {
row := q.db.QueryRowContext(ctx, updateAccount, arg.Name, arg.OnBudget, arg.ID) row := q.db.QueryRowContext(ctx, updateAccount,
arg.Name,
arg.OnBudget,
arg.IsOpen,
arg.ID,
)
var i Account var i Account
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.BudgetID, &i.BudgetID,
&i.Name, &i.Name,
&i.OnBudget, &i.OnBudget,
&i.IsOpen,
&i.LastReconciled,
) )
return i, err return i, err
} }

View File

@ -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,41 @@ 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
}
const updateAssignmentWithDifference = `-- name: UpdateAssignmentWithDifference :exec
INSERT INTO assignments (category_id, date, amount)
VALUES($1, $2, $3)
ON CONFLICT (category_id, date)
DO
UPDATE SET amount = assignments.amount + $3
`
type UpdateAssignmentWithDifferenceParams struct {
CategoryID uuid.UUID
Date time.Time
Amount numeric.Numeric
}
func (q *Queries) UpdateAssignmentWithDifference(ctx context.Context, arg UpdateAssignmentWithDifferenceParams) error {
_, err := q.db.ExecContext(ctx, updateAssignmentWithDifference, arg.CategoryID, arg.Date, arg.Amount)
return err
}

View File

@ -122,7 +122,8 @@ SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.
FROM categories FROM categories
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 = $1 WHERE category_groups.budget_id = $1
AND categories.name LIKE $2 AND categories.name ILIKE $2
AND category_groups.name != 'Hidden Categories'
ORDER BY category_groups.name, categories.name ORDER BY category_groups.name, categories.name
` `

View File

@ -32,14 +32,15 @@ func (e *TransactionStatus) Scan(src interface{}) error {
} }
type Account struct { type Account struct {
ID uuid.UUID ID uuid.UUID
BudgetID uuid.UUID BudgetID uuid.UUID
Name string Name string
OnBudget bool OnBudget bool
IsOpen bool
LastReconciled sql.NullTime
} }
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

View File

@ -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"

View File

@ -60,7 +60,7 @@ func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, e
const searchPayees = `-- name: SearchPayees :many const searchPayees = `-- name: SearchPayees :many
SELECT payees.id, payees.budget_id, payees.name, 'payee' as type FROM payees SELECT payees.id, payees.budget_id, payees.name, 'payee' as type FROM payees
WHERE payees.budget_id = $1 WHERE payees.budget_id = $1
AND payees.name LIKE $2 AND payees.name ILIKE $2
ORDER BY payees.name ORDER BY payees.name
` `

View File

@ -11,27 +11,35 @@ WHERE accounts.id = $1;
-- name: GetAccounts :many -- name: GetAccounts :many
SELECT accounts.* FROM accounts SELECT accounts.* FROM accounts
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
AND accounts.is_open = TRUE
ORDER BY accounts.name; ORDER BY accounts.name;
-- name: GetAccountsWithBalance :many -- name: GetAccountsWithBalance :many
SELECT accounts.id, accounts.name, accounts.on_budget, SELECT accounts.id, accounts.name, accounts.on_budget, accounts.is_open, accounts.last_reconciled,
(SELECT MAX(transactions.date) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.status = 'Reconciled')::date as last_reconciled,
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW())::decimal(12,2) as working_balance, (SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW())::decimal(12,2) as working_balance,
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status IN ('Cleared', 'Reconciled'))::decimal(12,2) as cleared_balance, (SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status IN ('Cleared', 'Reconciled'))::decimal(12,2) as cleared_balance,
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status = 'Reconciled')::decimal(12,2) as reconciled_balance (SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status = 'Reconciled')::decimal(12,2) as reconciled_balance
FROM accounts FROM accounts
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
AND accounts.is_open = TRUE
ORDER BY accounts.name; ORDER BY accounts.name;
-- name: SearchAccounts :many -- name: SearchAccounts :many
SELECT accounts.id, accounts.budget_id, accounts.name, 'account' as type FROM accounts SELECT accounts.id, accounts.budget_id, accounts.name, 'account' as type FROM accounts
WHERE accounts.budget_id = @budget_id WHERE accounts.budget_id = @budget_id
AND accounts.name LIKE @search AND accounts.is_open = TRUE
AND accounts.name ILIKE @search
ORDER BY accounts.name; ORDER BY accounts.name;
-- name: UpdateAccount :one -- name: UpdateAccount :one
UPDATE accounts UPDATE accounts
SET name = $1, SET name = $1,
on_budget = $2 on_budget = $2,
WHERE accounts.id = $3 is_open = $3
RETURNING *; WHERE accounts.id = $4
RETURNING *;
-- name: SetLastReconciled :exec
UPDATE accounts
SET last_reconciled = NOW()
WHERE accounts.id = $1;

View File

@ -22,4 +22,18 @@ SELECT assignments.date, categories.name as category, category_groups.name as gr
FROM assignments 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;
-- name: UpdateAssignmentWithDifference :exec
INSERT INTO assignments (category_id, date, amount)
VALUES($1, $2, $3)
ON CONFLICT (category_id, date)
DO
UPDATE SET amount = assignments.amount + $3;

View File

@ -25,6 +25,7 @@ SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.
FROM categories FROM categories
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
AND categories.name LIKE @search AND categories.name ILIKE @search
AND category_groups.name != 'Hidden Categories'
ORDER BY category_groups.name, categories.name; ORDER BY category_groups.name, categories.name;
--ORDER BY levenshtein(payees.name, $2); --ORDER BY levenshtein(payees.name, $2);

View File

@ -12,6 +12,6 @@ ORDER BY name;
-- name: SearchPayees :many -- name: SearchPayees :many
SELECT payees.*, 'payee' as type FROM payees SELECT payees.*, 'payee' as type FROM payees
WHERE payees.budget_id = @budget_id WHERE payees.budget_id = @budget_id
AND payees.name LIKE @search AND payees.name ILIKE @search
ORDER BY payees.name; ORDER BY payees.name;
--ORDER BY levenshtein(payees.name, $2); --ORDER BY levenshtein(payees.name, $2);

View File

@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE accounts ADD COLUMN is_open BOOLEAN NOT NULL DEFAULT TRUE;
-- +goose Down
ALTER TABLE accounts DROP COLUMN is_open;

View 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;

View File

@ -0,0 +1,12 @@
-- +goose Up
ALTER TABLE accounts ADD COLUMN last_reconciled date NULL;
UPDATE accounts
SET last_reconciled = (
SELECT MAX(transactions.date)
FROM transactions
WHERE transactions.account_id = accounts.id
AND transactions.status = 'Reconciled'
);
-- +goose Down
ALTER TABLE accounts DROP COLUMN last_reconciled;

View File

@ -91,12 +91,12 @@ func (ynab *YNABImport) ImportAssignments(context context.Context, r io.Reader)
continue continue
} }
assignment := CreateAssignmentParams{ assignment := UpdateAssignmentWithDifferenceParams{
Date: date, Date: date,
CategoryID: category.UUID, CategoryID: category.UUID,
Amount: amount, Amount: amount,
} }
_, err = ynab.queries.CreateAssignment(context, assignment) err = ynab.queries.UpdateAssignmentWithDifference(context, assignment)
if err != nil { if err != nil {
return fmt.Errorf("save assignment %v: %w", assignment, err) return fmt.Errorf("save assignment %v: %w", assignment, err)
} }
@ -226,7 +226,8 @@ func (ynab *YNABImport) GetTransaction(context context.Context, record []string)
} }
func (ynab *YNABImport) ImportRegularTransaction(context context.Context, payeeName string, func (ynab *YNABImport) ImportRegularTransaction(context context.Context, payeeName string,
transaction CreateTransactionParams) error { transaction CreateTransactionParams,
) error {
payeeID, err := ynab.GetPayee(context, payeeName) payeeID, err := ynab.GetPayee(context, payeeName)
if err != nil { if err != nil {
return fmt.Errorf("get payee %s: %w", payeeName, err) return fmt.Errorf("get payee %s: %w", payeeName, err)
@ -242,7 +243,8 @@ func (ynab *YNABImport) ImportRegularTransaction(context context.Context, payeeN
func (ynab *YNABImport) ImportTransferTransaction(context context.Context, payeeName string, func (ynab *YNABImport) ImportTransferTransaction(context context.Context, payeeName string,
transaction CreateTransactionParams, openTransfers *[]Transfer, transaction CreateTransactionParams, openTransfers *[]Transfer,
account *Account, amount numeric.Numeric) error { account *Account, amount numeric.Numeric,
) error {
transferToAccountName := payeeName[11:] transferToAccountName := payeeName[11:]
transferToAccount, err := ynab.GetAccount(context, transferToAccountName) transferToAccount, err := ynab.GetAccount(context, transferToAccountName)
if err != nil { if err != nil {

View File

@ -39,6 +39,7 @@ type TransactionsResponse struct {
type EditAccountRequest struct { type EditAccountRequest struct {
Name string `json:"name"` Name string `json:"name"`
OnBudget bool `json:"onBudget"` OnBudget bool `json:"onBudget"`
IsOpen bool `json:"isOpen"`
} }
func (h *Handler) editAccount(c *gin.Context) { func (h *Handler) editAccount(c *gin.Context) {
@ -59,6 +60,7 @@ func (h *Handler) editAccount(c *gin.Context) {
updateParams := postgres.UpdateAccountParams{ updateParams := postgres.UpdateAccountParams{
Name: request.Name, Name: request.Name,
OnBudget: request.OnBudget, OnBudget: request.OnBudget,
IsOpen: request.IsOpen,
ID: accountUUID, ID: accountUUID,
} }
account, err := h.Service.UpdateAccount(c.Request.Context(), updateParams) account, err := h.Service.UpdateAccount(c.Request.Context(), updateParams)

View File

@ -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
View 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
}
}

View File

@ -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)

View File

@ -65,6 +65,12 @@ func (h *Handler) reconcileTransactions(c *gin.Context) {
return return
} }
err = h.Service.SetLastReconciled(c.Request.Context(), accountUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("set last reconciled: %w", err))
return
}
err = tx.Commit() err = tx.Commit()
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("commit: %w", err)) c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("commit: %w", err))

30
server/util.go Normal file
View 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
}

17
web/.eslintrc.js Normal file
View File

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

View File

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

View File

@ -1,33 +1,44 @@
{ {
"name": "web", "name": "web",
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"serve": "vite preview", "serve": "vite preview",
"build": "vite build", "build": "vite build",
"dev": "vite", "dev": "vite",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@mdi/font": "5.9.55", "@mdi/font": "5.9.55",
"@vueuse/core": "^7.6.1", "@vueuse/core": "^7.6.1",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"pinia": "^2.0.11", "pinia": "^2.0.11",
"postcss": "^8.4.6", "postcss": "^8.4.6",
"tailwindcss": "^3.0.18", "tailwindcss": "^3.0.18",
"vue": "^3.2.25", "vue": "^3.2.25",
"vue-router": "^4.0.12" "vue-router": "^4.0.12"
}, },
"devDependencies": { "devDependencies": {
"@types/file-saver": "^2.0.5", "@types/file-saver": "^2.0.5",
"@vitejs/plugin-vue": "^2.0.0", "@typescript-eslint/parser": "^5.13.0",
"@vue/cli-plugin-babel": "5.0.0-beta.7", "@vitejs/plugin-vue": "^2.0.0",
"@vue/cli-plugin-typescript": "~4.5.0", "@vue/cli-plugin-babel": "5.0.0-beta.7",
"@vue/cli-service": "5.0.0-beta.7", "@vue/cli-plugin-typescript": "~4.5.0",
"sass": "^1.38.0", "@vue/cli-service": "5.0.0-beta.7",
"sass-loader": "^10.0.0", "eslint": "^8.10.0",
"typescript": "^4.5.5", "eslint-plugin-vue": "^8.5.0",
"vite": "^2.7.2", "prettier": "2.5.1",
"vue-tsc": "^0.32.0" "sass": "^1.38.0",
} "sass-loader": "^10.0.0",
"typescript": "^4.5.5",
"vite": "^2.7.2",
"vue-tsc": "^0.32.0"
},
"prettier": {
"bracketSameLine": true,
"embeddedLanguageFormatting": "off",
"tabWidth": 4,
"useTabs": false
},
"eslintIgnore": ["index.css"]
} }

View File

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

View File

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

View File

@ -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,39 +26,50 @@ export default defineComponent({
</script> </script>
<template> <template>
<div class="box-border w-full"> <div class="flex flex-col md:flex-row flex-1 h-screen">
<div class="flex bg-gray-400 dark:bg-gray-600 p-4 m-2 rounded-lg"> <router-view name="sidebar" />
<span class="flex-1 font-bold text-5xl -my-3 hidden md:inline" @click="toggleMenuSize"></span>
<span class="flex-1 font-bold text-5xl -my-3 md:hidden" @click="toggleMenu"></span>
<span class="flex-1">{{ CurrentBudgetName }}</span> <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"
>
<span
class="flex-1 font-bold text-5xl -my-3 hidden md:inline"
@click="toggleMenuSize"
></span>
<span
class="flex-1 font-bold text-5xl -my-3 md:hidden"
@click="toggleMenu"
></span>
<div class="flex flex-1 flex-row justify-end -mx-4"> <span class="flex-1">{{ CurrentBudgetName }}</span>
<router-link class="mx-4" v-if="LoggedIn" to="/dashboard">Dashboard</router-link>
<router-link class="mx-4" v-if="!LoggedIn" to="/login">Login</router-link> <div class="flex flex-1 flex-row justify-end -mx-4">
<a class="mx-4" v-if="LoggedIn" @click="logout">Logout</a> <router-link
</div> v-if="LoggedIn"
class="mx-4"
to="/dashboard"
>
Dashboard
</router-link>
<router-link
v-if="!LoggedIn"
class="mx-4"
to="/login"
>
Login
</router-link>
<a
v-if="LoggedIn"
class="mx-4"
@click="logout"
>Logout</a>
</div> </div>
</div>
<div class="flex flex-col md:flex-row flex-1"> <div class="p-3 pl-6">
<div <router-view />
:class="[Menu.Expand ? 'md:w-72' : 'md:w-36', Menu.Show ? '' : 'hidden']" </div>
class="md:block flex-shrink-0 w-full"
>
<router-view name="sidebar"></router-view>
</div>
<div class="flex-1 p-6">
<router-view></router-view>
</div>
</div>
</div> </div>
</div>
</template> </template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

View File

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

View File

@ -0,0 +1,35 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useBudgetsStore } from '../stores/budget';
import { Account } from '../stores/budget-account';
const props = defineProps<{
account: Account
}>();
const budgetStore = useBudgetsStore();
const CurrentBudgetID = computed(() => budgetStore.CurrentBudgetID);
const days = 24 * 60 * 60 * 1000;
function daysSinceLastReconciled() {
const now = new Date().getTime();
const diff = new Date(now).getTime() - props.account.LastReconciled.Time.getTime();
return Math.floor(diff / days);
}
</script>
<template>
<span>
<router-link
:to="'/budget/' + CurrentBudgetID + '/account/' + account.ID"
>
{{ account.Name }}
</router-link>
<span
v-if="props.account.LastReconciled.Valid && daysSinceLastReconciled() > 7"
class="font-bold bg-gray-500 rounded-md text-sm px-2 mx-2 py-1 no-underline"
>
{{ daysSinceLastReconciled() }}
</span>
</span>
</template>

View File

@ -11,9 +11,9 @@ export interface Suggestion {
} }
const props = defineProps<{ const props = defineProps<{
text: String, text: string,
id: String | undefined, id: string | undefined,
model: String, model: string,
type?: string | undefined, type?: string | undefined,
}>(); }>();
@ -81,22 +81,30 @@ function clear() {
</script> </script>
<template> <template>
<div> <div>
<Input <Input
type="text" v-if="id == undefined"
class="border-b-2 border-black" v-model="SearchQuery"
@keypress="keypress" type="text"
v-if="id == undefined" class="border-b-2 border-black"
v-model="SearchQuery" @keypress="keypress"
/> />
<span @click="clear" v-if="id != undefined" class="bg-gray-300 dark:bg-gray-700">{{ text }}</span> <span
<div v-if="Suggestions.length > 0" class="absolute bg-gray-400 dark:bg-gray-600 w-64 p-2"> v-if="id != undefined"
<span class="bg-gray-300 dark:bg-gray-700"
v-for="suggestion in Suggestions" @click="clear"
class="block" >{{ text }}</span>
@click="select" <div
:value="suggestion.ID" v-if="Suggestions.length > 0"
>{{ suggestion.Name }}</span> class="absolute bg-gray-400 dark:bg-gray-600 w-64 p-2"
</div> >
<span
v-for="suggestion in Suggestions"
:key="suggestion.ID"
class="block"
:value="suggestion.ID"
@click="select"
>{{ suggestion.Name }}</span>
</div> </div>
</template> </div>
</template>

View File

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

View File

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

View File

@ -0,0 +1,15 @@
<script lang="ts" setup>
const props = defineProps<{modelValue?: boolean}>();
const emits = defineEmits<{
(e: "update:modelValue", value: boolean): void
}>();
</script>
<template>
<input
type="checkbox"
:checked="modelValue"
class="dark:bg-slate-900"
@change="emits('update:modelValue', ($event.target as HTMLInputElement)?.checked)"
>
</template>

View File

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

View File

@ -1,9 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import Input from './Input.vue'; import Input from './Input.vue';
const props = defineProps(["modelValue"]); const props = defineProps<{
modelValue?: Date
}>();
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
function dateToYYYYMMDD(d: Date) : string { function dateToYYYYMMDD(d: Date | undefined) : string {
if(d == null) if(d == null)
return ""; return "";
// alternative implementations in https://stackoverflow.com/q/23593052/1850609 // alternative implementations in https://stackoverflow.com/q/23593052/1850609
@ -27,10 +29,10 @@ function selectAll(event: FocusEvent) {
<template> <template>
<Input <Input
type="date"
ref="input" ref="input"
v-bind:value="dateToYYYYMMDD(modelValue)" type="date"
:value="dateToYYYYMMDD(modelValue)"
@input="updateValue" @input="updateValue"
@focus="selectAll" @focus="selectAll"
/> />
</template> </template>

View File

@ -1,10 +1,17 @@
<script lang="ts" setup> <script lang="ts" setup>
const props = defineProps(["modelValue"]); const props = defineProps<{
modelValue?: number | string
}>();
const emits = defineEmits<{
(e: "update:modelValue", value: number | string): void
}>();
</script> </script>
<template> <template>
<input <input
v-model="modelValue" :value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement)?.value)" class="dark:bg-slate-900"
class="dark:bg-slate-900"> @input="emits('update:modelValue', ($event.target as HTMLInputElement)?.value)"
</template> >
</template>

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import Card from '../components/Card.vue'; import RowCard from './RowCard.vue';
import { ref } from "vue"; import { ref } from "vue";
const props = defineProps<{ const props = defineProps<{
@ -7,7 +7,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'submit'): void, (e: 'submit', event : {cancel:boolean}): boolean,
(e: 'open'): void, (e: 'open'): void,
}>(); }>();
@ -20,39 +20,58 @@ function openDialog() {
visible.value = true; visible.value = true;
}; };
function submitDialog() { function submitDialog() {
const e = {cancel: false};
emit("submit", e);
if(e.cancel)
return;
visible.value = false; visible.value = false;
emit("submit");
} }
</script> </script>
<template> <template>
<button @click="openDialog"> <button @click="openDialog">
<slot name="placeholder"> <slot name="placeholder">
<Card> <RowCard>
<p class="w-24 text-center text-6xl">+</p> <p class="w-24 text-center text-6xl">
<span class="text-lg" dark>{{ buttonText }}</span> +
</Card> </p>
</slot> <span
</button> class="text-lg"
dark
>{{ buttonText }}</span>
</RowCard>
</slot>
</button>
<div
v-if="visible"
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full"
>
<div <div
v-if="visible" class="relative top-20 mx-auto p-5 w-96 shadow-lg rounded-md bg-white dark:bg-black"
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full"
> >
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white"> <div class="mt-3 text-center">
<div class="mt-3 text-center"> <h3
<h3 class="mt-3 text-lg leading-6 font-medium text-gray-900">{{ buttonText }}</h3> class="mt-3 text-lg leading-6 font-medium text-gray-900 dark:text-gray-100"
<slot></slot> >
<div class="grid grid-cols-2 gap-6"> {{ buttonText }}
<button </h3>
@click="closeDialog" <slot />
class="px-4 py-2 bg-red-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300" <div class="grid grid-cols-2 gap-6">
>Close</button> <button
<button class="px-4 py-2 bg-red-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300"
@click="submitDialog" @click="closeDialog"
class="px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300" >
>Save</button> Close
</div> </button>
</div> <button
class="px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300"
@click="submitDialog"
>
Save
</button>
</div> </div>
</div>
</div> </div>
</template> </div>
</template>

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import { useAccountStore } from '../stores/budget-account'
import DateInput from "./DateInput.vue"; import DateInput from "./DateInput.vue";
import { useTransactionsStore } from "../stores/transactions"; import { useTransactionsStore } from "../stores/transactions";
import Input from "./Input.vue"; import Input from "./Input.vue";
import Button from "./Button.vue"; import Button from "./SimpleButton.vue";
const props = defineProps<{ const props = defineProps<{
transactionid: string transactionid: string
@ -38,29 +38,50 @@ function saveTransaction(e: MouseEvent) {
</script> </script>
<template> <template>
<tr> <tr>
<td class="text-sm"> <td class="text-sm">
<DateInput class="border-b-2 border-black" v-model="TX.Date" /> <DateInput
</td> v-model="TX.Date"
<td> class="border-b-2 border-black"
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" /> />
</td> </td>
<td> <td>
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" /> <Autocomplete
</td> v-model:text="TX.Payee"
<td> v-model:id="TX.PayeeID"
<Input class="block w-full border-b-2 border-black" type="text" v-model="TX.Memo" /> v-model:type="payeeType"
</td> model="payees"
<td class="text-right"> />
<Input </td>
class="text-right block w-full border-b-2 border-black" <td>
type="currency" <Autocomplete
v-model="TX.Amount" v-model:text="TX.Category"
/> v-model:id="TX.CategoryID"
</td> model="categories"
<td> />
<Button class="bg-blue-500" @click="saveTransaction">Save</Button> </td>
</td> <td>
<td></td> <Input
</tr> v-model="TX.Memo"
</template> class="block w-full border-b-2 border-black"
type="text"
/>
</td>
<td class="text-right">
<Input
v-model="TX.Amount"
class="text-right block w-full border-b-2 border-black"
type="currency"
/>
</td>
<td>
<Button
class="bg-blue-500"
@click="saveTransaction"
>
Save
</Button>
</td>
<td />
</tr>
</template>

View File

@ -3,7 +3,7 @@ import { computed, ref } from "vue";
import Autocomplete from '../components/Autocomplete.vue' import Autocomplete from '../components/Autocomplete.vue'
import { Transaction, useTransactionsStore } from "../stores/transactions"; import { Transaction, useTransactionsStore } from "../stores/transactions";
import DateInput from "./DateInput.vue"; import DateInput from "./DateInput.vue";
import Button from "./Button.vue"; import Button from "./SimpleButton.vue";
import Input from "./Input.vue"; import Input from "./Input.vue";
const props = defineProps<{ const props = defineProps<{
@ -47,37 +47,64 @@ const payload = computed(() => JSON.stringify({
const transactionsStore = useTransactionsStore(); const transactionsStore = useTransactionsStore();
function saveTransaction(e: MouseEvent) { function saveTransaction(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
Save();
}
function Save() {
transactionsStore.saveTransaction(payload.value); transactionsStore.saveTransaction(payload.value);
} }
defineExpose({Save});
</script> </script>
<template> <template>
<tr> <tr>
<label class="md:hidden">Date</label> <label class="md:hidden">Date</label>
<td class="text-sm"> <td class="text-sm">
<DateInput class="border-b-2 border-black" v-model="TX.Date" /> <DateInput
</td> v-model="TX.Date"
<label class="md:hidden">Payee</label> class="border-b-2 border-black"
<td> />
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" /> </td>
</td> <label class="md:hidden">Payee</label>
<label class="md:hidden">Category</label> <td>
<td> <Autocomplete
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" /> v-model:text="TX.Payee"
</td> v-model:id="TX.PayeeID"
<td class="col-span-2"> v-model:type="payeeType"
<Input class="block w-full border-b-2 border-black" type="text" v-model="TX.Memo" /> model="payees"
</td> />
<label class="md:hidden">Amount</label> </td>
<td class="text-right"> <label class="md:hidden">Category</label>
<Input <td>
class="text-right block w-full border-b-2 border-black" <Autocomplete
type="currency" v-model:text="TX.Category"
v-model="TX.Amount" v-model:id="TX.CategoryID"
/> model="categories"
</td> />
<td class="hidden md:table-cell"> </td>
<Button class="bg-blue-500" @click="saveTransaction">Save</Button> <td class="col-span-2">
</td> <Input
</tr> v-model="TX.Memo"
</template> class="block w-full border-b-2 border-black"
type="text"
/>
</td>
<label class="md:hidden">Amount</label>
<td class="text-right">
<Input
v-model="TX.Amount"
class="text-right block w-full border-b-2 border-black"
type="currency"
/>
</td>
<td class="hidden md:table-cell">
<Button
class="bg-blue-500"
@click="saveTransaction"
>
Save
</Button>
</td>
</tr>
</template>

View File

@ -7,6 +7,7 @@ import TransactionEditRow from "./TransactionEditRow.vue";
import { formatDate } from "../date"; import { formatDate } from "../date";
import { useAccountStore } from "../stores/budget-account"; import { useAccountStore } from "../stores/budget-account";
import Input from "./Input.vue"; import Input from "./Input.vue";
import Checkbox from "./Checkbox.vue";
const props = defineProps<{ const props = defineProps<{
transactionid: string, transactionid: string,
@ -46,34 +47,54 @@ function getStatusSymbol() {
</script> </script>
<template> <template>
<tr v-if="dateChanged()" class="table-row md:hidden"> <tr v-if="dateChanged()" class="table-row md:hidden">
<td class="bg-gray-200 dark:bg-gray-800 rounded-lg p-2" colspan="5">{{ formatDate(TX.Date) }}</td> <td class="py-2" colspan="5">
</tr> <span class="bg-gray-400 dark:bg-slate-600 rounded-lg p-1 px-2 w-full block">
<tr {{ formatDate(TX.Date) }}
v-if="!edit" </span>
class="{{new Date(TX.Date) > new Date() ? 'future' : ''}}" </td>
:class="[index % 6 < 3 ? 'md:bg-gray-300 dark:md:bg-gray-700' : 'md:bg-gray-100 dark:md:bg-gray-900']" </tr>
> <tr
<!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">--> v-if="!edit"
<td class="hidden md:block">{{ formatDate(TX.Date) }}</td> class="{{new Date(TX.Date) > new Date() ? 'future' : ''}}"
<td class="pl-2 md:pl-0">{{ TX.TransferAccount ? "Transfer : " + TX.TransferAccount : TX.Payee }}</td> :class="[index % 6 < 3 ? 'md:bg-gray-300 dark:md:bg-gray-700' : 'md:bg-gray-100 dark:md:bg-gray-900']"
<td>{{ TX.CategoryGroup ? TX.CategoryGroup + " : " + TX.Category : "" }}</td> >
<td> <!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">-->
<a <td class="hidden md:block">
:href="'/budget/' + CurrentBudgetID + '/transaction/' + TX.ID" {{ formatDate(TX.Date) }}
>{{ TX.Memo }}</a> </td>
</td> <td class="pl-2 md:pl-0">
<td> {{ TX.TransferAccount ? "Transfer : " + TX.TransferAccount : TX.Payee }}
<Currency class="block" :value="TX.Amount" /> </td>
</td> <td>
<td class="text-right"> {{ TX.CategoryGroup ? TX.CategoryGroup + " : " + TX.Category : "" }}
{{ TX.GroupID ? "☀" : "" }} </td>
{{ getStatusSymbol() }} <td>
<a @click="edit = true;"></a> <a
<Input v-if="Reconciling && TX.Status != 'Reconciled'" type="checkbox" v-model="TX.Reconciled" /> :href="'/budget/' + CurrentBudgetID + '/transaction/' + TX.ID"
</td> >{{ TX.Memo }}</a>
</tr> </td>
<TransactionEditRow v-if="edit" :transactionid="TX.ID" @save="edit = false" /> <td>
<Currency
class="block"
:value="TX.Amount"
/>
</td>
<td class="text-right">
{{ TX.GroupID ? "☀" : "" }}
{{ getStatusSymbol() }}
<a @click="edit = true;"></a>
<Checkbox
v-if="Reconciling && TX.Status != 'Reconciled'"
v-model="TX.Reconciled"
/>
</td>
</tr>
<TransactionEditRow
v-if="edit"
:transactionid="TX.ID"
@save="edit = false"
/>
</template> </template>
<style> <style>
@ -81,4 +102,4 @@ td {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
} }
</style> </style>

View File

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

View File

@ -3,43 +3,83 @@ import { computed, ref } from 'vue';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
import { useAccountStore } from '../stores/budget-account'; import { useAccountStore } from '../stores/budget-account';
import Input from '../components/Input.vue'; import Input from '../components/Input.vue';
import Checkbox from '../components/Checkbox.vue';
import { useRouter } from 'vue-router';
import { useBudgetsStore } from '../stores/budget';
const router = useRouter();
const accountStore = useAccountStore(); const accountStore = useAccountStore();
const CurrentAccount = computed(() => accountStore.CurrentAccount); const CurrentAccount = computed(() => accountStore.CurrentAccount);
const accountName = ref(""); const accountName = ref("");
const accountOnBudget = ref(true); const accountOnBudget = ref(true);
const accountOpen = ref(true);
const error = ref("");
function editAccount(e : any) { function editAccount(e : {cancel:boolean}) : boolean {
accountStore.EditAccount(CurrentAccount.value?.ID ?? "", accountName.value, accountOnBudget.value); if(CurrentAccount.value?.ClearedBalance != 0 && !accountOpen.value){
e.cancel = true;
error.value = "Cannot close account with balance";
return false;
}
error.value = "";
accountStore.EditAccount(CurrentAccount.value?.ID ?? "", accountName.value, accountOnBudget.value, accountOpen.value);
// account closed, move to Budget
if(!accountOpen.value){
const currentBudgetID = useBudgetsStore().CurrentBudgetID;
router.replace('/budget/'+currentBudgetID+'/budgeting');
}
return true;
} }
function openEditAccount(e : any) { function openEditAccount(e : any) {
accountName.value = CurrentAccount.value?.Name ?? ""; accountName.value = CurrentAccount.value?.Name ?? "";
accountOnBudget.value = CurrentAccount.value?.OnBudget ?? true; accountOnBudget.value = CurrentAccount.value?.OnBudget ?? true;
accountOpen.value = CurrentAccount.value?.IsOpen ?? true;
} }
</script> </script>
<template> <template>
<Modal button-text="Edit Account" @open="openEditAccount" @submit="editAccount"> <Modal
<template v-slot:placeholder></template> button-text="Edit Account"
<div class="mt-2 px-7 py-3"> @open="openEditAccount"
<Input @submit="editAccount"
class="border-2" >
type="text" <template #placeholder>
v-model="accountName" <span class="ml-2"></span>
placeholder="Account name" </template>
required <div class="mt-2 px-7 py-3">
/> <Input
</div> v-model="accountName"
<div class="mt-2 px-7 py-3"> class="border-2 dark:border-gray-700"
<Input type="text"
class="border-2" placeholder="Account name"
type="checkbox" required
v-model="accountOnBudget" />
required </div>
/> <div class="mt-2 px-7 py-3">
<label>On Budget</label> <Checkbox
</div> v-model="accountOnBudget"
</Modal> class="border-2"
</template> required
/>
<label>On Budget</label>
</div>
<div class="mt-2 px-7 py-3">
<Checkbox
v-model="accountOpen"
class="border-2"
required
/>
<label>Open</label>
</div>
<div
v-if="error != ''"
class="dark:text-red-300 text-red-700"
>
{{ error }}
</div>
</Modal>
</template>

View File

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

View File

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

View File

@ -1,28 +1,79 @@
import { createApp } from 'vue' import { createApp } from "vue";
import App from './App.vue' import App from "./App.vue";
import './index.css' import "./index.css";
import router from './router' import router from "./router";
import { createPinia } from 'pinia' import { createPinia } from "pinia";
import { useBudgetsStore } from './stores/budget'; import { useBudgetsStore } from "./stores/budget";
import { useAccountStore } from './stores/budget-account' import { useAccountStore } from "./stores/budget-account";
import PiniaLogger from './pinia-logger' import PiniaLogger from "./pinia-logger";
import { useSessionStore } from "./stores/session";
const app = createApp(App) const app = createApp(App);
app.use(router) app.use(router);
const pinia = createPinia() const pinia = createPinia();
pinia.use(PiniaLogger({ pinia.use(
expanded: false, PiniaLogger({
showDuration: true expanded: false,
})) showDuration: true,
app.use(pinia) })
app.mount('#app') );
app.use(pinia);
app.mount("#app");
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const budgetStore = useBudgetsStore(); const budgetStore = useBudgetsStore();
await budgetStore.SetCurrentBudget((<string>to.params.budgetid)); await budgetStore.SetCurrentBudget(<string>to.params.budgetid);
const accountStore = useAccountStore(); const accountStore = useAccountStore();
await accountStore.SetCurrentAccount((<string>to.params.budgetid), (<string>to.params.accountid)); await accountStore.SetCurrentAccount(
next(); <string>to.params.budgetid,
}) <string>to.params.accountid
);
next();
});
router.beforeEach((to, from, next) => {
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;

View File

@ -5,16 +5,23 @@ import TransactionRow from "../components/TransactionRow.vue";
import TransactionInputRow from "../components/TransactionInputRow.vue"; import TransactionInputRow from "../components/TransactionInputRow.vue";
import { useAccountStore } from "../stores/budget-account"; import { useAccountStore } from "../stores/budget-account";
import EditAccount from "../dialogs/EditAccount.vue"; import EditAccount from "../dialogs/EditAccount.vue";
import Button from "../components/Button.vue"; import Button from "../components/SimpleButton.vue";
import { useTransactionsStore } from "../stores/transactions"; import { useTransactionsStore } from "../stores/transactions";
import Modal from "../components/Modal.vue"; import Modal from "../components/Modal.vue";
import Input from "../components/Input.vue"; import Input from "../components/Input.vue";
import Checkbox from "../components/Checkbox.vue";
defineProps<{ defineProps<{
budgetid: string budgetid: string
accountid: string accountid: string
}>() }>()
const modalInputRow = ref<typeof TransactionInputRow | null>(null);
function submitModal() {
modalInputRow.value!.Save();
}
const accounts = useAccountStore(); const accounts = useAccountStore();
const transactions = useTransactionsStore(); const transactions = useTransactionsStore();
const TargetReconcilingBalance = ref(0); const TargetReconcilingBalance = ref(0);
@ -42,77 +49,125 @@ function createReconcilationTransaction() {
</script> </script>
<template> <template>
<div class="grid grid-cols-[1fr_auto]"> <div class="grid grid-cols-[1fr_auto]">
<h1 class="inline"> <div>
{{ accounts.CurrentAccount?.Name }} <h1 class="inline">
<EditAccount /> {{ accounts.CurrentAccount?.Name }}
</h1> </h1>
<EditAccount />
<div class="text-right flex flex-wrap flex-col md:flex-row justify-end gap-2 max-w-sm">
<span class="border-2 rounded-lg p-1 whitespace-nowrap flex-1">
Working:
<Currency :value="accounts.CurrentAccount?.WorkingBalance" />
</span>
<span class="border-2 rounded-lg p-1 whitespace-nowrap flex-1">
Cleared:
<Currency :value="accounts.CurrentAccount?.ClearedBalance" />
</span>
<span
class="border-2 border-blue-500 rounded-lg bg-blue-500 p-1 whitespace-nowrap flex-1"
v-if="!transactions.Reconciling"
@click="transactions.Reconciling = true"
>
Reconciled:
<Currency :value="accounts.CurrentAccount?.ReconciledBalance" />
</span>
</div>
<span v-if="transactions.Reconciling" class="border-2 block bg-gray-200 dark:bg-gray-800 rounded-lg p-2">
Is
<Currency :value="transactions.ReconcilingBalance" />your current balance?
<Button class="bg-blue-500 mx-3 py-2" @click="submitReconcilation">Yes!</Button>
<br />No, it's:
<Input class="text-right" type="number" v-model="TargetReconcilingBalance" />
Difference:
<Currency :value="transactions.ReconcilingBalance - TargetReconcilingBalance" />
<Button
class="bg-orange-500 mx-3 py-2"
v-if="Math.abs(transactions.ReconcilingBalance - TargetReconcilingBalance) > 0.01"
@click="createReconcilationTransaction"
>Create reconciling Transaction</Button>
<Button class="bg-red-500 mx-3 py-2" @click="cancelReconcilation">Cancel</Button>
</span>
</div> </div>
<table> <div
<tr class="font-bold"> class="text-right flex flex-wrap flex-col md:flex-row justify-end gap-2 max-w-sm"
<td class="hidden md:block" style="width: 90px;">Date</td> >
<td style="max-width: 150px;">Payee</td> <span class="rounded-lg p-1 whitespace-nowrap flex-1">
<td style="max-width: 200px;">Category</td> Working:
<td>Memo</td> <Currency :value="accounts.CurrentAccount?.WorkingBalance" />
<td class="text-right">Amount</td> </span>
<td style="width: 80px;">
<Input v-if="transactions.Reconciling" type="checkbox" @input="setReconciled" /> <span class="rounded-lg p-1 whitespace-nowrap flex-1">
</td> Cleared:
</tr> <Currency :value="accounts.CurrentAccount?.ClearedBalance" />
<TransactionInputRow class="hidden md:table-row" :budgetid="budgetid" :accountid="accountid" /> </span>
<TransactionRow
v-for="(transaction, index) in transactions.TransactionsList" <span
:key="transaction.ID" v-if="!transactions.Reconciling"
:transactionid="transaction.ID" class="rounded-lg bg-blue-500 p-1 whitespace-nowrap flex-1"
:index="index" @click="transactions.Reconciling = true"
>
Reconciled:
<Currency :value="accounts.CurrentAccount?.ReconciledBalance" />
</span>
<span
v-if="transactions.Reconciling"
class="contents"
>
<Button
class="bg-blue-500 p-1 whitespace-nowrap flex-1"
@click="submitReconcilation"
>
My current balance is&nbsp;
<Currency :value="transactions.ReconcilingBalance" />
</Button>
<Button
class="bg-orange-500 p-1 whitespace-nowrap flex-1"
@click="createReconcilationTransaction"
>
No, it's:
<Input
v-model="TargetReconcilingBalance"
class="text-right w-20 bg-transparent dark:bg-transparent border-b-2"
type="number"
/>
(Difference
<Currency
:value="transactions.ReconcilingBalance - TargetReconcilingBalance"
/>)
</Button>
<Button
class="bg-red-500 p-1 flex-1"
@click="cancelReconcilation"
>Cancel</Button>
</span>
</div>
</div>
<table>
<tr class="font-bold">
<td
class="hidden md:block"
style="width: 90px;"
>
Date
</td>
<td style="max-width: 150px;">
Payee
</td>
<td style="max-width: 200px;">
Category
</td>
<td>Memo</td>
<td class="text-right">
Amount
</td>
<td style="width: 80px;">
<Checkbox
v-if="transactions.Reconciling"
@input="setReconciled"
/> />
</table> </td>
<div class="md:hidden"> </tr>
<Modal> <TransactionInputRow
<template v-slot:placeholder> class="hidden md:table-row"
<Button class="fixed right-4 bottom-4 font-bold text-lg bg-blue-500 py-2">+</Button> :budgetid="budgetid"
</template> :accountid="accountid"
<TransactionInputRow class="grid grid-cols-2" :budgetid="budgetid" :accountid="accountid" /> />
</Modal> <TransactionRow
</div> v-for="(transaction, index) in transactions.TransactionsList"
:key="transaction.ID"
:transactionid="transaction.ID"
:index="index"
/>
</table>
<div class="md:hidden">
<Modal @submit="submitModal">
<template #placeholder>
<Button
class="fixed right-4 bottom-4 font-bold text-lg bg-blue-500 py-2"
>
+
</Button>
</template>
<TransactionInputRow
ref="modalInputRow"
class="grid grid-cols-2"
:budgetid="budgetid"
:accountid="accountid"
/>
</Modal>
</div>
</template> </template>
<style> <style>
@ -123,4 +178,4 @@ table {
.negative { .negative {
color: red; color: red;
} }
</style> </style>

View File

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

View File

@ -1,11 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from "vue"; import { computed } 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 { Account, useAccountStore } from "../stores/budget-account" import { Account, useAccountStore } from "../stores/budget-account";
import { useSettingsStore } from "../stores/settings" import { useSettingsStore } from "../stores/settings";
import AccountWithReconciled from "../components/AccountWithReconciled.vue";
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);
@ -16,65 +19,87 @@ const OnBudgetAccounts = computed(() => accountStore.OnBudgetAccounts);
const OffBudgetAccounts = computed(() => accountStore.OffBudgetAccounts); 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) {
const now = new Date().getTime();
const recently = 7 * 24 * 60 * 60 * 1000;
console.log(account.Name, account.LastReconciled, now, recently, new Date(now-recently));
return new Date(now - recently).getTime() < account.LastReconciled.getTime();
}
function getAccountName(account : Account) {
const reconciledMarker = isRecentlyReconciled(account) ? "" : " *";
return account.Name + reconciledMarker;
}
</script> </script>
<template> <template>
<div class="flex flex-col"> <div
<span class="m-1 p-1 px-3 text-xl"> :class="[ExpandMenu ? 'md:w-72' : 'md:w-36', ShowMenu ? '' : 'hidden']"
<router-link to="/dashboard"></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-orange-200 dark:bg-orange-700 rounded-lg m-1 p-1 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
<li class="bg-orange-200 dark:bg-orange-700 rounded-lg m-1 p-1 px-3"> to="/dashboard"
<div class="flex flex-row justify-between font-bold"> style="font-size: 150%"
<span>On-Budget Accounts</span> ></router-link>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OnBudgetAccountsBalance" /> {{ CurrentBudgetName }}
</div> </span>
<div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between"> <span class="bg-gray-100 dark:bg-gray-700 p-2 px-3 flex flex-col">
<router-link :to="'/budget/'+CurrentBudgetID+'/account/'+account.ID">{{getAccountName(account)}}</router-link> <router-link :to="'/budget/' + CurrentBudgetID + '/budgeting'">Budget</router-link>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.ClearedBalance" /> <br>
</div> <!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>-->
</li> <!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>-->
<li class="bg-red-200 dark:bg-red-800 rounded-lg m-1 p-1 px-3"> </span>
<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>Off-Budget Accounts</span> <div class="flex flex-row justify-between font-bold">
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OffBudgetAccountsBalance" /> <span>On-Budget Accounts</span>
</div> <Currency
<div v-for="account in OffBudgetAccounts" class="flex flex-row justify-between"> :class="ExpandMenu ? 'md:inline' : 'md:hidden'"
<router-link :to="'/budget/'+CurrentBudgetID+'/account/'+account.ID">{{getAccountName(account)}}</router-link> :value="OnBudgetAccountsBalance"
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.ClearedBalance" /> />
</div> </div>
</li> <div
<li class="bg-red-200 dark:bg-red-800 rounded-lg m-1 p-1 px-3"> v-for="account in OnBudgetAccounts"
:key="account.ID"
class="flex flex-row justify-between"
>
<AccountWithReconciled :account="account" />
<Currency
:class="ExpandMenu ? 'md:inline' : 'md:hidden'"
:value="account.ClearedBalance"
/>
</div>
</li>
<li class="bg-slate-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"
:key="account.ID"
class="flex flex-row justify-between"
>
<AccountWithReconciled :account="account" />
<Currency
:class="ExpandMenu ? 'md:inline' : 'md:hidden'"
:value="account.ClearedBalance"
/>
</div>
</li>
<!--
<li class="bg-slate-100 dark:bg-slate-800 my-2 p-2 px-3">
<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>
</li>
<!--<li>
<router-link :to="'/budget/'+CurrentBudgetID+'/accounts'">Edit accounts</router-link>
</li>-->
<li class="bg-red-200 dark:bg-red-800 rounded-lg m-1 p-1 px-3">
+ Add Account + Add Account
</li> </li>
<li class="bg-red-200 dark:bg-red-800 rounded-lg m-1 p-1 px-3"> -->
<router-link :to="'/budget/'+CurrentBudgetID+'/settings'">Budget-Settings</router-link> <!--<li>
</li> <router-link :to="'/budget/'+CurrentBudgetID+'/accounts'">Edit accounts</router-link>
<!--<li><router-link to="/admin">Admin</router-link></li>--> </li>-->
</div> <li class="bg-red-100 dark:bg-slate-600 my-2 p-2 px-3">
</template> <router-link :to="'/budget/' + CurrentBudgetID + '/settings'">
Budget-Settings
</router-link>
</li>
<!--<li><router-link to="/admin">Admin</router-link></li>-->
</div>
</div>
</template>

View File

@ -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,
@ -57,46 +59,115 @@ onMounted(() => {
const expandedGroups = ref<Map<string, boolean>>(new Map<string, boolean>()) const expandedGroups = ref<Map<string, boolean>>(new Map<string, boolean>())
function toggleGroup(group: { Name: string, Expand: boolean }) { function toggleGroup(group: { Name: string, Expand: boolean }) {
console.log(expandedGroups.value);
expandedGroups.value.set(group.Name, !(expandedGroups.value.get(group.Name) ?? group.Expand)) expandedGroups.value.set(group.Name, !(expandedGroups.value.get(group.Name) ?? group.Expand))
} }
function getGroupState(group: { Name: string, Expand: boolean }): boolean { function getGroupState(group: { Name: string, Expand: boolean }): boolean {
return expandedGroups.value.get(group.Name) ?? group.Expand; return expandedGroups.value.get(group.Name) ?? group.Expand;
} }
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>
<div> <span>Available balance:
<router-link <Currency
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month" :value="accountStore.GetIncomeAvailable(selected.Year, selected.Month)"
>&lt;&lt;</router-link>&nbsp; /></span>
<router-link <div>
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + current.Year + '/' + current.Month" <router-link
>Current Month</router-link>&nbsp; :to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"
<router-link >
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + next.Year + '/' + next.Month" &lt;&lt;
>&gt;&gt;</router-link> </router-link>&nbsp;
</div> <router-link
<div class="container col-lg-12 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5" id="content"> :to="'/budget/' + CurrentBudgetID + '/budgeting/' + current.Year + '/' + current.Month"
<span class="hidden sm:block"></span> >
<span class="hidden lg:block text-right">Leftover</span> Current Month
<span class="hidden sm:block text-right">Assigned</span> </router-link>&nbsp;
<span class="hidden sm:block text-right">Activity</span> <router-link
<span class="hidden sm:block text-right">Available</span> :to="'/budget/' + CurrentBudgetID + '/budgeting/' + next.Year + '/' + next.Month"
<template v-for="group in GroupsForMonth"> >
<a &gt;&gt;
class="text-lg font-bold col-span-2 sm:col-span-4 lg:col-span-5" </router-link>
@click="toggleGroup(group)" </div>
>{{ (getGroupState(group) ? "" : "+") + " " + group.Name }}</a> <div
<template v-for="category in GetCategories(group.Name)" v-if="getGroupState(group)"> id="content"
<span class="whitespace-nowrap overflow-hidden">{{ category.Name }}</span> class="container col-lg-12 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5"
<Currency :value="category.AvailableLastMonth" class="hidden lg:block" /> >
<Currency :value="category.Assigned" class="hidden sm:block" /> <span class="hidden sm:block" />
<Currency :value="category.Activity" class="hidden sm:block" /> <span class="hidden lg:block text-right">Leftover</span>
<Currency :value="category.Available" /> <span class="hidden sm:block text-right">Assigned</span>
</template> <span class="hidden sm:block text-right">Activity</span>
</template> <span class="hidden sm:block text-right">Available</span>
</div> <template
v-for="group in GroupsForMonth"
: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>
</div>
</template> </template>

View File

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import NewBudget from '../dialogs/NewBudget.vue'; import NewBudget from '../dialogs/NewBudget.vue';
import Card from '../components/Card.vue'; import RowCard from '../components/RowCard.vue';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
const props = defineProps<{ const props = defineProps<{
@ -11,15 +11,22 @@ const BudgetsList = useSessionStore().BudgetsList;
</script> </script>
<template> <template>
<h1>Budgets</h1> <h1>Budgets</h1>
<div class="grid md:grid-cols-2 gap-6"> <div class="grid md:grid-cols-2 gap-6">
<Card v-for="budget in BudgetsList"> <RowCard
<router-link v-bind:to="'/budget/'+budget.ID+'/budgeting'" class="contents"> v-for="budget in BudgetsList"
<!--<svg class="w-24"></svg>--> :key="budget.ID"
<p class="w-24 text-center text-6xl"></p> >
<span class="text-lg">{{budget.Name}}{{budget.ID == budgetid ? " *" : ""}}</span> <router-link
</router-link> :to="'/budget/'+budget.ID+'/budgeting'"
</Card> class="contents"
<NewBudget /> >
</div> <!--<svg class="w-24"></svg>-->
<p class="w-24 text-center text-6xl" />
<span class="text-lg">{{ budget.Name
}}{{ budget.ID == budgetid ? " *" : "" }}</span>
</router-link>
</RowCard>
<NewBudget />
</div>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,17 +4,16 @@ import { useAccountStore } from "./budget-account";
import { Budget, useSessionStore } from "./session"; import { Budget, useSessionStore } from "./session";
interface State { interface State {
CurrentBudgetID: string | null, CurrentBudgetID: string | null;
} }
export const useBudgetsStore = defineStore('budget', { export const useBudgetsStore = defineStore("budget", {
state: (): State => ({ state: (): State => ({
CurrentBudgetID: null, CurrentBudgetID: null,
}), }),
getters: { getters: {
CurrentBudget(): Budget | undefined { CurrentBudget(): Budget | undefined {
if (this.CurrentBudgetID == null) if (this.CurrentBudgetID == null) return undefined;
return undefined;
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
return sessionStore.Budgets.get(this.CurrentBudgetID); return sessionStore.Budgets.get(this.CurrentBudgetID);
@ -27,7 +26,7 @@ export const useBudgetsStore = defineStore('budget', {
ImportYNAB(formData: FormData) { ImportYNAB(formData: FormData) {
return POST( return POST(
"/budget/" + this.CurrentBudgetID + "/import/ynab", "/budget/" + this.CurrentBudgetID + "/import/ynab",
formData, formData
); );
}, },
async NewBudget(budgetName: string): Promise<void> { async NewBudget(budgetName: string): Promise<void> {
@ -43,8 +42,7 @@ export const useBudgetsStore = defineStore('budget', {
async SetCurrentBudget(budgetid: string): Promise<void> { async SetCurrentBudget(budgetid: string): Promise<void> {
this.CurrentBudgetID = budgetid; this.CurrentBudgetID = budgetid;
if (budgetid == null) if (budgetid == null) return;
return
await this.FetchBudget(budgetid); await this.FetchBudget(budgetid);
}, },
@ -58,12 +56,15 @@ export const useBudgetsStore = defineStore('budget', {
for (const account of response.Accounts || []) { for (const account of response.Accounts || []) {
const existingAccount = accounts.Accounts.get(account.ID); const existingAccount = accounts.Accounts.get(account.ID);
account.Transactions = existingAccount?.Transactions ?? []; account.Transactions = existingAccount?.Transactions ?? [];
account.LastReconciled = new Date(account.LastReconciled); if (account.LastReconciled.Valid)
account.LastReconciled.Time = new Date(
account.LastReconciled.Time
);
accounts.Accounts.set(account.ID, account); accounts.Accounts.set(account.ID, account);
} }
for (const category of response.Categories || []) { for (const category of response.Categories || []) {
accounts.Categories.set(category.ID, category); accounts.Categories.set(category.ID, category);
} }
}, },
} },
}) });

View File

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

View File

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

View File

@ -1,26 +1,26 @@
import { defineStore } from "pinia" import { defineStore } from "pinia";
import { POST } from "../api"; import { POST } from "../api";
import { useAccountStore } from "./budget-account"; import { useAccountStore } from "./budget-account";
interface State { interface State {
Transactions: Map<string, Transaction> Transactions: Map<string, Transaction>;
Reconciling: boolean Reconciling: boolean;
} }
export interface Transaction { export interface Transaction {
ID: string ID: string;
Date: Date Date: Date;
TransferAccount: string TransferAccount: string;
CategoryGroup: string CategoryGroup: string;
Category: string Category: string;
CategoryID: string | undefined CategoryID: string | undefined;
Memo: string Memo: string;
Status: string Status: string;
GroupID: string GroupID: string;
Payee: string Payee: string;
PayeeID: string | undefined PayeeID: string | undefined;
Amount: number Amount: number;
Reconciled: boolean Reconciled: boolean;
} }
export const useTransactionsStore = defineStore("budget/transactions", { export const useTransactionsStore = defineStore("budget/transactions", {
@ -30,8 +30,9 @@ export const useTransactionsStore = defineStore("budget/transactions", {
}), }),
getters: { getters: {
ReconcilingBalance(state): number { ReconcilingBalance(state): number {
const accountsStore = useAccountStore() const accountsStore = useAccountStore();
let reconciledBalance = accountsStore.CurrentAccount!.ReconciledBalance; let reconciledBalance =
accountsStore.CurrentAccount!.ReconciledBalance;
for (const transaction of this.TransactionsList) { for (const transaction of this.TransactionsList) {
if (transaction.Reconciled) if (transaction.Reconciled)
reconciledBalance += transaction.Amount; reconciledBalance += transaction.Amount;
@ -39,9 +40,9 @@ 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)!;
}); });
}, },
}, },
@ -59,48 +60,51 @@ export const useTransactionsStore = defineStore("budget/transactions", {
}, },
SetReconciledForAllTransactions(value: boolean) { SetReconciledForAllTransactions(value: boolean) {
for (const transaction of this.TransactionsList) { for (const transaction of this.TransactionsList) {
if (transaction.Status == "Reconciled") if (transaction.Status == "Reconciled") continue;
continue;
transaction.Reconciled = value; transaction.Reconciled = value;
} }
}, },
async SubmitReconcilation(reconciliationTransactionAmount: number) { async SubmitReconcilation(reconciliationTransactionAmount: number) {
const accountsStore = useAccountStore() const accountsStore = useAccountStore();
const account = accountsStore.CurrentAccount!; const account = accountsStore.CurrentAccount!;
const reconciledTransactions = this.TransactionsList.filter(x => x.Reconciled); const reconciledTransactions = this.TransactionsList.filter(
(x) => x.Reconciled
);
for (const transaction of reconciledTransactions) { for (const transaction of reconciledTransactions) {
account.ReconciledBalance += transaction.Amount; account.ReconciledBalance += transaction.Amount;
transaction.Status = "Reconciled"; transaction.Status = "Reconciled";
transaction.Reconciled = false; transaction.Reconciled = false;
} }
const result = await POST("/account/" + accountsStore.CurrentAccountID + "/reconcile", JSON.stringify({ const result = await POST(
transactionIDs: reconciledTransactions.map(x => x.ID), "/account/" + accountsStore.CurrentAccountID + "/reconcile",
reconciliationTransactionAmount: reconciliationTransactionAmount.toString(), JSON.stringify({
})); transactionIDs: reconciledTransactions.map((x) => x.ID),
reconciliationTransactionAmount:
reconciliationTransactionAmount.toString(),
})
);
const response = await result.json(); const response = await result.json();
const recTrans = response.ReconciliationTransaction; const recTrans = response.ReconciliationTransaction;
if (recTrans) { if (recTrans) {
this.AddTransactions([recTrans]); this.AddTransactions([recTrans]);
account.Transactions.unshift(recTrans.ID); account.Transactions.unshift(recTrans.ID);
} }
console.log("Reconcile: " + response.message);
}, },
logout() { logout() {
this.$reset() this.$reset();
}, },
async saveTransaction(payload: string) { async saveTransaction(payload: string) {
const accountsStore = useAccountStore() const accountsStore = useAccountStore();
const result = await POST("/transaction/new", payload); const result = await POST("/transaction/new", payload);
const response = await result.json() as Transaction; const response = (await result.json()) as Transaction;
this.AddTransactions([response]); this.AddTransactions([response]);
accountsStore.CurrentAccount?.Transactions.unshift(response.ID); accountsStore.CurrentAccount?.Transactions.unshift(response.ID);
}, },
async editTransaction(transactionid: string, payload: string) { async editTransaction(transactionid: string, payload: string) {
const result = await POST("/transaction/" + transactionid, payload); const result = await POST("/transaction/" + transactionid, payload);
const response = await result.json() as Transaction; const response = (await result.json()) as Transaction;
this.AddTransactions([response]); this.AddTransactions([response]);
} },
} },
});
})

View File

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

View File

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

View File

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

View File

@ -957,6 +957,21 @@
resolved "https://registry.yarnpkg.com/@emmetio/scanner/-/scanner-1.0.0.tgz#065b2af6233fe7474d44823e3deb89724af42b5f" resolved "https://registry.yarnpkg.com/@emmetio/scanner/-/scanner-1.0.0.tgz#065b2af6233fe7474d44823e3deb89724af42b5f"
integrity sha512-8HqW8EVqjnCmWXVpqAOZf+EGESdkR27odcMMMGefgKXtar00SoYNSryGv//TELI4T3QFsECo78p+0lmalk/CFA== integrity sha512-8HqW8EVqjnCmWXVpqAOZf+EGESdkR27odcMMMGefgKXtar00SoYNSryGv//TELI4T3QFsECo78p+0lmalk/CFA==
"@eslint/eslintrc@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.2.0.tgz#7ce1547a5c46dfe56e1e45c3c9ed18038c721c6a"
integrity sha512-igm9SjJHNEJRiUnecP/1R5T3wKLEJ7pL6e2P+GUSfCd0dGjPYYZve08uzw8L2J8foVHFz+NGu12JxRcU2gGo6w==
dependencies:
ajv "^6.12.4"
debug "^4.3.2"
espree "^9.3.1"
globals "^13.9.0"
ignore "^4.0.6"
import-fresh "^3.2.1"
js-yaml "^4.1.0"
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@hapi/address@2.x.x": "@hapi/address@2.x.x":
version "2.1.4" version "2.1.4"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5"
@ -1001,6 +1016,20 @@
dependencies: dependencies:
"@hapi/hoek" "^9.0.0" "@hapi/hoek" "^9.0.0"
"@humanwhocodes/config-array@^0.9.2":
version "0.9.5"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7"
integrity sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==
dependencies:
"@humanwhocodes/object-schema" "^1.2.1"
debug "^4.1.1"
minimatch "^3.0.4"
"@humanwhocodes/object-schema@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
"@jridgewell/resolve-uri@^3.0.3": "@jridgewell/resolve-uri@^3.0.3":
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.0.4.tgz#b876e3feefb9c8d3aa84014da28b5e52a0640d72" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.0.4.tgz#b876e3feefb9c8d3aa84014da28b5e52a0640d72"
@ -1281,6 +1310,50 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@typescript-eslint/parser@^5.13.0":
version "5.13.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.13.0.tgz#0394ed8f2f849273c0bf4b811994d177112ced5c"
integrity sha512-GdrU4GvBE29tm2RqWOM0P5QfCtgCyN4hXICj/X9ibKED16136l9ZpoJvCL5pSKtmJzA+NRDzQ312wWMejCVVfg==
dependencies:
"@typescript-eslint/scope-manager" "5.13.0"
"@typescript-eslint/types" "5.13.0"
"@typescript-eslint/typescript-estree" "5.13.0"
debug "^4.3.2"
"@typescript-eslint/scope-manager@5.13.0":
version "5.13.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.13.0.tgz#cf6aff61ca497cb19f0397eea8444a58f46156b6"
integrity sha512-T4N8UvKYDSfVYdmJq7g2IPJYCRzwtp74KyDZytkR4OL3NRupvswvmJQJ4CX5tDSurW2cvCc1Ia1qM7d0jpa7IA==
dependencies:
"@typescript-eslint/types" "5.13.0"
"@typescript-eslint/visitor-keys" "5.13.0"
"@typescript-eslint/types@5.13.0":
version "5.13.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.13.0.tgz#da1de4ae905b1b9ff682cab0bed6b2e3be9c04e5"
integrity sha512-LmE/KO6DUy0nFY/OoQU0XelnmDt+V8lPQhh8MOVa7Y5k2gGRd6U9Kp3wAjhB4OHg57tUO0nOnwYQhRRyEAyOyg==
"@typescript-eslint/typescript-estree@5.13.0":
version "5.13.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.13.0.tgz#b37c07b748ff030a3e93d87c842714e020b78141"
integrity sha512-Q9cQow0DeLjnp5DuEDjLZ6JIkwGx3oYZe+BfcNuw/POhtpcxMTy18Icl6BJqTSd+3ftsrfuVb7mNHRZf7xiaNA==
dependencies:
"@typescript-eslint/types" "5.13.0"
"@typescript-eslint/visitor-keys" "5.13.0"
debug "^4.3.2"
globby "^11.0.4"
is-glob "^4.0.3"
semver "^7.3.5"
tsutils "^3.21.0"
"@typescript-eslint/visitor-keys@5.13.0":
version "5.13.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.13.0.tgz#f45ff55bcce16403b221ac9240fbeeae4764f0fd"
integrity sha512-HLKEAS/qA1V7d9EzcpLFykTePmOQqOFim8oCvhY3pZgQ8Hi38hYpHd9e5GN6nQBFQNecNhws5wkS9Y5XIO0s/g==
dependencies:
"@typescript-eslint/types" "5.13.0"
eslint-visitor-keys "^3.0.0"
"@vitejs/plugin-vue@^2.0.0": "@vitejs/plugin-vue@^2.0.0":
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-2.1.0.tgz#ddf5e0059f84f2ff649afc25ce5a59211e670542" resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-2.1.0.tgz#ddf5e0059f84f2ff649afc25ce5a59211e670542"
@ -2089,6 +2162,11 @@ acorn-import-assertions@^1.7.6:
resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz#ba2b5939ce62c238db6d93d81c9b111b29b855e9" resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz#ba2b5939ce62c238db6d93d81c9b111b29b855e9"
integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw== integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==
acorn-jsx@^5.3.1:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn-node@^1.6.1: acorn-node@^1.6.1:
version "1.8.2" version "1.8.2"
resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8" resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8"
@ -2118,7 +2196,7 @@ acorn@^7.0.0, acorn@^7.1.1:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
acorn@^8.0.4, acorn@^8.0.5, acorn@^8.4.1: acorn@^8.0.4, acorn@^8.0.5, acorn@^8.4.1, acorn@^8.7.0:
version "8.7.0" version "8.7.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf"
integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ== integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==
@ -2160,7 +2238,7 @@ ajv-keywords@^5.0.0:
dependencies: dependencies:
fast-deep-equal "^3.1.3" fast-deep-equal "^3.1.3"
ajv@^6.1.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5: ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5:
version "6.12.6" version "6.12.6"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
@ -2277,6 +2355,11 @@ argparse@^1.0.7:
dependencies: dependencies:
sprintf-js "~1.0.2" sprintf-js "~1.0.2"
argparse@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
arr-diff@^4.0.0: arr-diff@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
@ -3351,7 +3434,7 @@ cross-spawn@^6.0.0:
shebang-command "^1.2.0" shebang-command "^1.2.0"
which "^1.2.9" which "^1.2.9"
cross-spawn@^7.0.3: cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3" version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
@ -3528,7 +3611,7 @@ debug@^3.1.1:
dependencies: dependencies:
ms "^2.1.1" ms "^2.1.1"
debug@^4.1.0, debug@^4.1.1: debug@^4.1.0, debug@^4.1.1, debug@^4.3.2:
version "4.3.3" version "4.3.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664"
integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==
@ -3552,6 +3635,11 @@ deep-equal@^1.0.1:
object-keys "^1.1.1" object-keys "^1.1.1"
regexp.prototype.flags "^1.2.0" regexp.prototype.flags "^1.2.0"
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^1.5.2: deepmerge@^1.5.2:
version "1.5.2" version "1.5.2"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-1.5.2.tgz#10499d868844cdad4fee0842df8c7f6f0c95a753" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-1.5.2.tgz#10499d868844cdad4fee0842df8c7f6f0c95a753"
@ -3724,6 +3812,13 @@ dns-txt@^2.0.2:
dependencies: dependencies:
buffer-indexof "^1.0.0" buffer-indexof "^1.0.0"
doctrine@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==
dependencies:
esutils "^2.0.2"
doctypes@^1.1.0: doctypes@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9" resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9"
@ -4046,6 +4141,21 @@ escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
escape-string-regexp@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
eslint-plugin-vue@^8.5.0:
version "8.5.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-8.5.0.tgz#65832bba43ca713fa5da16bdfcf55d0095677f6f"
integrity sha512-i1uHCTAKOoEj12RDvdtONWrGzjFm/djkzqfhmQ0d6M/W8KM81mhswd/z+iTZ0jCpdUedW3YRgcVfQ37/J4zoYQ==
dependencies:
eslint-utils "^3.0.0"
natural-compare "^1.4.0"
semver "^7.3.5"
vue-eslint-parser "^8.0.1"
eslint-scope@5.1.1: eslint-scope@5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
@ -4062,11 +4172,93 @@ eslint-scope@^4.0.3:
esrecurse "^4.1.0" esrecurse "^4.1.0"
estraverse "^4.1.1" estraverse "^4.1.1"
eslint-scope@^7.0.0, eslint-scope@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642"
integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==
dependencies:
esrecurse "^4.3.0"
estraverse "^5.2.0"
eslint-utils@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672"
integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==
dependencies:
eslint-visitor-keys "^2.0.0"
eslint-visitor-keys@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303"
integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==
eslint-visitor-keys@^3.0.0, eslint-visitor-keys@^3.1.0, eslint-visitor-keys@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826"
integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
eslint@^8.10.0:
version "8.10.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.10.0.tgz#931be395eb60f900c01658b278e05b6dae47199d"
integrity sha512-tcI1D9lfVec+R4LE1mNDnzoJ/f71Kl/9Cv4nG47jOueCMBrCCKYXr4AUVS7go6mWYGFD4+EoN6+eXSrEbRzXVw==
dependencies:
"@eslint/eslintrc" "^1.2.0"
"@humanwhocodes/config-array" "^0.9.2"
ajv "^6.10.0"
chalk "^4.0.0"
cross-spawn "^7.0.2"
debug "^4.3.2"
doctrine "^3.0.0"
escape-string-regexp "^4.0.0"
eslint-scope "^7.1.1"
eslint-utils "^3.0.0"
eslint-visitor-keys "^3.3.0"
espree "^9.3.1"
esquery "^1.4.0"
esutils "^2.0.2"
fast-deep-equal "^3.1.3"
file-entry-cache "^6.0.1"
functional-red-black-tree "^1.0.1"
glob-parent "^6.0.1"
globals "^13.6.0"
ignore "^5.2.0"
import-fresh "^3.0.0"
imurmurhash "^0.1.4"
is-glob "^4.0.0"
js-yaml "^4.1.0"
json-stable-stringify-without-jsonify "^1.0.1"
levn "^0.4.1"
lodash.merge "^4.6.2"
minimatch "^3.0.4"
natural-compare "^1.4.0"
optionator "^0.9.1"
regexpp "^3.2.0"
strip-ansi "^6.0.1"
strip-json-comments "^3.1.0"
text-table "^0.2.0"
v8-compile-cache "^2.0.3"
espree@^9.0.0, espree@^9.3.1:
version "9.3.1"
resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.1.tgz#8793b4bc27ea4c778c19908e0719e7b8f4115bcd"
integrity sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==
dependencies:
acorn "^8.7.0"
acorn-jsx "^5.3.1"
eslint-visitor-keys "^3.3.0"
esprima@^4.0.0: esprima@^4.0.0:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
esquery@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5"
integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==
dependencies:
estraverse "^5.1.0"
esrecurse@^4.1.0, esrecurse@^4.3.0: esrecurse@^4.1.0, esrecurse@^4.3.0:
version "4.3.0" version "4.3.0"
resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
@ -4079,7 +4271,7 @@ estraverse@^4.1.1:
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
estraverse@^5.2.0: estraverse@^5.1.0, estraverse@^5.2.0:
version "5.3.0" version "5.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
@ -4289,6 +4481,11 @@ fast-json-stable-stringify@^2.0.0:
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
fast-levenshtein@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
fastq@^1.6.0: fastq@^1.6.0:
version "1.13.0" version "1.13.0"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c"
@ -4315,6 +4512,13 @@ figures@^2.0.0:
dependencies: dependencies:
escape-string-regexp "^1.0.5" escape-string-regexp "^1.0.5"
file-entry-cache@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"
integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==
dependencies:
flat-cache "^3.0.4"
file-saver@^2.0.5: file-saver@^2.0.5:
version "2.0.5" version "2.0.5"
resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38" resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38"
@ -4388,6 +4592,19 @@ find-up@^4.0.0, find-up@^4.1.0:
locate-path "^5.0.0" locate-path "^5.0.0"
path-exists "^4.0.0" path-exists "^4.0.0"
flat-cache@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11"
integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==
dependencies:
flatted "^3.1.0"
rimraf "^3.0.2"
flatted@^3.1.0:
version "3.2.5"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.5.tgz#76c8584f4fc843db64702a6bd04ab7a8bd666da3"
integrity sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==
flush-write-stream@^1.0.0: flush-write-stream@^1.0.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8"
@ -4529,6 +4746,11 @@ function-bind@^1.1.1:
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
functional-red-black-tree@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
gensync@^1.0.0-beta.2: gensync@^1.0.0-beta.2:
version "1.0.0-beta.2" version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
@ -4626,7 +4848,14 @@ globals@^11.1.0:
resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
globby@^11.0.1, globby@^11.0.2, globby@^11.0.3: globals@^13.6.0, globals@^13.9.0:
version "13.12.1"
resolved "https://registry.yarnpkg.com/globals/-/globals-13.12.1.tgz#ec206be932e6c77236677127577aa8e50bf1c5cb"
integrity sha512-317dFlgY2pdJZ9rspXDks7073GpDmXdfbM3vYYp0HAMKGDh1FfWPleI2ljVNLQX5M5lXcAslTcPTrOrMEFOjyw==
dependencies:
type-fest "^0.20.2"
globby@^11.0.1, globby@^11.0.2, globby@^11.0.3, globby@^11.0.4:
version "11.1.0" version "11.1.0"
resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==
@ -4961,7 +5190,7 @@ iferr@^0.1.5:
resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE=
ignore@^4.0.3: ignore@^4.0.3, ignore@^4.0.6:
version "4.0.6" version "4.0.6"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
@ -4976,7 +5205,7 @@ immutable@^4.0.0:
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0.tgz#b86f78de6adef3608395efb269a91462797e2c23" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0.tgz#b86f78de6adef3608395efb269a91462797e2c23"
integrity sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw== integrity sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==
import-fresh@^3.1.0, import-fresh@^3.2.1: import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
version "3.3.0" version "3.3.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==
@ -5373,6 +5602,13 @@ js-yaml@^3.13.1:
argparse "^1.0.7" argparse "^1.0.7"
esprima "^4.0.0" esprima "^4.0.0"
js-yaml@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
dependencies:
argparse "^2.0.1"
jsbn@~0.1.0: jsbn@~0.1.0:
version "0.1.1" version "0.1.1"
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
@ -5413,6 +5649,11 @@ json-schema@0.4.0:
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
json-stable-stringify-without-jsonify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
json-stringify-safe@~5.0.1: json-stringify-safe@~5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
@ -5513,6 +5754,14 @@ launch-editor@^2.2.1, launch-editor@^2.3.0:
picocolors "^1.0.0" picocolors "^1.0.0"
shell-quote "^1.6.1" shell-quote "^1.6.1"
levn@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
dependencies:
prelude-ls "^1.2.1"
type-check "~0.4.0"
lilconfig@^2.0.3, lilconfig@^2.0.4: lilconfig@^2.0.3, lilconfig@^2.0.4:
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.4.tgz#f4507d043d7058b380b6a8f5cb7bcd4b34cee082" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.4.tgz#f4507d043d7058b380b6a8f5cb7bcd4b34cee082"
@ -5591,6 +5840,11 @@ lodash.memoize@^4.1.2:
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash.uniq@^4.5.0: lodash.uniq@^4.5.0:
version "4.5.0" version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
@ -5979,6 +6233,11 @@ nanomatch@^1.2.9:
snapdragon "^0.8.1" snapdragon "^0.8.1"
to-regex "^3.0.1" to-regex "^3.0.1"
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
negotiator@0.6.3: negotiator@0.6.3:
version "0.6.3" version "0.6.3"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
@ -6235,6 +6494,18 @@ opener@^1.5.2:
resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598"
integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==
optionator@^0.9.1:
version "0.9.1"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499"
integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==
dependencies:
deep-is "^0.1.3"
fast-levenshtein "^2.0.6"
levn "^0.4.1"
prelude-ls "^1.2.1"
type-check "^0.4.0"
word-wrap "^1.2.3"
ora@^3.4.0: ora@^3.4.0:
version "3.4.0" version "3.4.0"
resolved "https://registry.yarnpkg.com/ora/-/ora-3.4.0.tgz#bf0752491059a3ef3ed4c85097531de9fdbcd318" resolved "https://registry.yarnpkg.com/ora/-/ora-3.4.0.tgz#bf0752491059a3ef3ed4c85097531de9fdbcd318"
@ -6817,7 +7088,12 @@ postcss@^8.1.10, postcss@^8.2.15, postcss@^8.2.6, postcss@^8.3.5, postcss@^8.4.5
picocolors "^1.0.0" picocolors "^1.0.0"
source-map-js "^1.0.2" source-map-js "^1.0.2"
"prettier@^1.18.2 || ^2.0.0": prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@2.5.1, "prettier@^1.18.2 || ^2.0.0":
version "2.5.1" version "2.5.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a"
integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg== integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==
@ -7196,6 +7472,11 @@ regexp.prototype.flags@^1.2.0:
call-bind "^1.0.2" call-bind "^1.0.2"
define-properties "^1.1.3" define-properties "^1.1.3"
regexpp@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==
regexpu-core@^5.0.1: regexpu-core@^5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.0.1.tgz#c531122a7840de743dcf9c83e923b5560323ced3" resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.0.1.tgz#c531122a7840de743dcf9c83e923b5560323ced3"
@ -7975,6 +8256,11 @@ strip-indent@^2.0.0:
resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68" resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g= integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=
strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
stylehacks@^5.0.2: stylehacks@^5.0.2:
version "5.0.2" version "5.0.2"
resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-5.0.2.tgz#fa10e5181c6e8dc0bddb4a3fb372e9ac42bba2ad" resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-5.0.2.tgz#fa10e5181c6e8dc0bddb4a3fb372e9ac42bba2ad"
@ -8112,6 +8398,11 @@ terser@^5.10.0, terser@^5.7.2:
source-map "~0.7.2" source-map "~0.7.2"
source-map-support "~0.5.20" source-map-support "~0.5.20"
text-table@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
thenify-all@^1.0.0: thenify-all@^1.0.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
@ -8288,6 +8579,13 @@ tsutils@^2.29.0:
dependencies: dependencies:
tslib "^1.8.1" tslib "^1.8.1"
tsutils@^3.21.0:
version "3.21.0"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==
dependencies:
tslib "^1.8.1"
tty-browserify@0.0.0: tty-browserify@0.0.0:
version "0.0.0" version "0.0.0"
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
@ -8305,6 +8603,18 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0:
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
dependencies:
prelude-ls "^1.2.1"
type-fest@^0.20.2:
version "0.20.2"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
type-fest@^0.6.0: type-fest@^0.6.0:
version "0.6.0" version "0.6.0"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
@ -8472,6 +8782,11 @@ uuid@^8.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
v8-compile-cache@^2.0.3:
version "2.3.0"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==
validate-npm-package-license@^3.0.1: validate-npm-package-license@^3.0.1:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
@ -8644,6 +8959,19 @@ vue-demi@*:
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.12.1.tgz#f7e18efbecffd11ab069d1472d7a06e319b4174c" resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.12.1.tgz#f7e18efbecffd11ab069d1472d7a06e319b4174c"
integrity sha512-QL3ny+wX8c6Xm1/EZylbgzdoDolye+VpCXRhI2hug9dJTP3OUJ3lmiKN3CsVV3mOJKwFi0nsstbgob0vG7aoIw== integrity sha512-QL3ny+wX8c6Xm1/EZylbgzdoDolye+VpCXRhI2hug9dJTP3OUJ3lmiKN3CsVV3mOJKwFi0nsstbgob0vG7aoIw==
vue-eslint-parser@^8.0.1:
version "8.3.0"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-8.3.0.tgz#5d31129a1b3dd89c0069ca0a1c88f970c360bd0d"
integrity sha512-dzHGG3+sYwSf6zFBa0Gi9ZDshD7+ad14DGOdTLjruRVgZXe2J+DcZ9iUhyR48z5g1PqRa20yt3Njna/veLJL/g==
dependencies:
debug "^4.3.2"
eslint-scope "^7.0.0"
eslint-visitor-keys "^3.1.0"
espree "^9.0.0"
esquery "^1.4.0"
lodash "^4.17.21"
semver "^7.3.5"
vue-hot-reload-api@^2.3.0: vue-hot-reload-api@^2.3.0:
version "2.3.4" version "2.3.4"
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2" resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2"
@ -8953,6 +9281,11 @@ with@^7.0.0:
assert-never "^1.2.1" assert-never "^1.2.1"
babel-walk "3.0.0-canary-5" babel-walk "3.0.0-canary-5"
word-wrap@^1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
worker-farm@^1.7.0: worker-farm@^1.7.0:
version "1.7.0" version "1.7.0"
resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8" resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8"