Add basic frontend & account sidebar
This commit is contained in:
parent
21cac8d2ed
commit
1194e36e6e
26 changed files with 4399 additions and 38 deletions
152
.eslintrc.js
Normal file
152
.eslintrc.js
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
/*
|
||||||
|
* Hack to automatically load globally installed eslint modules
|
||||||
|
* on Archlinux systems placed in /usr/lib/node_modules
|
||||||
|
*
|
||||||
|
* Source: https://github.com/eslint/eslint/issues/11914#issuecomment-569108633
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Module = require('module')
|
||||||
|
|
||||||
|
const hacks = [
|
||||||
|
'@babel/eslint-parser',
|
||||||
|
'eslint-plugin-vue',
|
||||||
|
]
|
||||||
|
|
||||||
|
const ModuleFindPath = Module._findPath
|
||||||
|
Module._findPath = (request, paths, isMain) => {
|
||||||
|
const r = ModuleFindPath(request, paths, isMain)
|
||||||
|
if (!r && hacks.includes(request)) {
|
||||||
|
return require.resolve(`/usr/lib/node_modules/${request}`)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ESLint configuration derived as differences from eslint:recommended
|
||||||
|
* with changes I found useful to ensure code quality and equal formatting
|
||||||
|
* https://eslint.org/docs/user-guide/configuring
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
extends: [
|
||||||
|
'plugin:vue/recommended',
|
||||||
|
'eslint:recommended', // https://eslint.org/docs/rules/
|
||||||
|
],
|
||||||
|
|
||||||
|
globals: {
|
||||||
|
process: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
parser: '@babel/eslint-parser',
|
||||||
|
requireConfigFile: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
// required to lint *.vue files
|
||||||
|
'vue',
|
||||||
|
],
|
||||||
|
|
||||||
|
reportUnusedDisableDirectives: true,
|
||||||
|
|
||||||
|
root: true,
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
'array-bracket-newline': ['error', { multiline: true }],
|
||||||
|
'array-bracket-spacing': ['error'],
|
||||||
|
'arrow-body-style': ['error', 'as-needed'],
|
||||||
|
'arrow-parens': ['error', 'as-needed'],
|
||||||
|
'arrow-spacing': ['error', { after: true, before: true }],
|
||||||
|
'block-spacing': ['error'],
|
||||||
|
'brace-style': ['error', '1tbs'],
|
||||||
|
'camelcase': ['error'],
|
||||||
|
'comma-dangle': ['error', 'always-multiline'],
|
||||||
|
'comma-spacing': ['error'],
|
||||||
|
'comma-style': ['error', 'last'],
|
||||||
|
'curly': ['error'],
|
||||||
|
'default-case-last': ['error'],
|
||||||
|
'default-param-last': ['error'],
|
||||||
|
'dot-location': ['error', 'property'],
|
||||||
|
'dot-notation': ['error'],
|
||||||
|
'eol-last': ['error', 'always'],
|
||||||
|
'eqeqeq': ['error', 'always', { null: 'ignore' }],
|
||||||
|
'func-call-spacing': ['error', 'never'],
|
||||||
|
'function-paren-newline': ['error', 'multiline'],
|
||||||
|
'generator-star-spacing': ['off'], // allow async-await
|
||||||
|
'implicit-arrow-linebreak': ['error'],
|
||||||
|
'indent': ['error', 2],
|
||||||
|
'key-spacing': ['error', { afterColon: true, beforeColon: false, mode: 'strict' }],
|
||||||
|
'keyword-spacing': ['error'],
|
||||||
|
'linebreak-style': ['error', 'unix'],
|
||||||
|
'lines-between-class-members': ['error'],
|
||||||
|
'multiline-comment-style': ['warn'],
|
||||||
|
'newline-per-chained-call': ['error'],
|
||||||
|
'no-alert': ['error'],
|
||||||
|
'no-console': ['off'],
|
||||||
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', // allow debugger during development
|
||||||
|
'no-duplicate-imports': ['error'],
|
||||||
|
'no-else-return': ['error'],
|
||||||
|
'no-empty-function': ['error'],
|
||||||
|
'no-extra-parens': ['error'],
|
||||||
|
'no-implicit-coercion': ['error'],
|
||||||
|
'no-lonely-if': ['error'],
|
||||||
|
'no-multi-spaces': ['error'],
|
||||||
|
'no-multiple-empty-lines': ['warn', { max: 2, maxBOF: 0, maxEOF: 0 }],
|
||||||
|
'no-promise-executor-return': ['error'],
|
||||||
|
'no-return-assign': ['error'],
|
||||||
|
'no-script-url': ['error'],
|
||||||
|
'no-template-curly-in-string': ['error'],
|
||||||
|
'no-trailing-spaces': ['error'],
|
||||||
|
'no-unneeded-ternary': ['error'],
|
||||||
|
'no-unreachable-loop': ['error'],
|
||||||
|
'no-unsafe-optional-chaining': ['error'],
|
||||||
|
'no-useless-return': ['error'],
|
||||||
|
'no-var': ['error'],
|
||||||
|
'no-warning-comments': ['error'],
|
||||||
|
'no-whitespace-before-property': ['error'],
|
||||||
|
'object-curly-newline': ['error', { consistent: true }],
|
||||||
|
'object-curly-spacing': ['error', 'always'],
|
||||||
|
'object-shorthand': ['error'],
|
||||||
|
'padded-blocks': ['error', 'never'],
|
||||||
|
'prefer-arrow-callback': ['error'],
|
||||||
|
'prefer-const': ['error'],
|
||||||
|
'prefer-object-spread': ['error'],
|
||||||
|
'prefer-rest-params': ['error'],
|
||||||
|
'prefer-template': ['error'],
|
||||||
|
'quote-props': ['error', 'consistent-as-needed', { keywords: false }],
|
||||||
|
'quotes': ['error', 'single', { allowTemplateLiterals: true }],
|
||||||
|
'require-atomic-updates': ['error'],
|
||||||
|
'require-await': ['error'],
|
||||||
|
'semi': ['error', 'never'],
|
||||||
|
'sort-imports': ['error', { ignoreCase: true, ignoreDeclarationSort: false, ignoreMemberSort: false }],
|
||||||
|
'sort-keys': ['error', 'asc', { caseSensitive: true, natural: false }],
|
||||||
|
'space-before-blocks': ['error', 'always'],
|
||||||
|
'space-before-function-paren': ['error', 'never'],
|
||||||
|
'space-in-parens': ['error', 'never'],
|
||||||
|
'space-infix-ops': ['error'],
|
||||||
|
'space-unary-ops': ['error', { nonwords: false, words: true }],
|
||||||
|
'spaced-comment': ['warn', 'always'],
|
||||||
|
'switch-colon-spacing': ['error'],
|
||||||
|
'template-curly-spacing': ['error', 'never'],
|
||||||
|
'unicode-bom': ['error', 'never'],
|
||||||
|
'vue/new-line-between-multi-line-property': ['error'],
|
||||||
|
'vue/no-empty-component-block': ['error'],
|
||||||
|
'vue/no-reserved-component-names': ['error'],
|
||||||
|
'vue/no-template-target-blank': ['error'],
|
||||||
|
'vue/no-unused-properties': ['error'],
|
||||||
|
'vue/no-unused-refs': ['error'],
|
||||||
|
'vue/no-useless-mustaches': ['error'],
|
||||||
|
'vue/order-in-components': ['off'], // Collides with sort-keys
|
||||||
|
'vue/require-name-property': ['error'],
|
||||||
|
'vue/v-for-delimiter-style': ['error'],
|
||||||
|
'vue/v-on-function-call': ['error'],
|
||||||
|
'wrap-iife': ['error'],
|
||||||
|
'yoda': ['error'],
|
||||||
|
},
|
||||||
|
}
|
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
accounting
|
||||||
|
node_modules
|
||||||
|
pkg/frontend/assets/app.*
|
||||||
|
pkg/frontend/assets/*.ttf
|
||||||
|
pkg/frontend/assets/*.woff2
|
||||||
|
test.db
|
2
.npmrc
Normal file
2
.npmrc
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
@fortawesome:registry=https://npm.fontawesome.com/
|
||||||
|
//npm.fontawesome.com/:_authToken=${FONTAWESOME_NPM_AUTH_TOKEN}
|
30
Dockerfile
Normal file
30
Dockerfile
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
FROM golang:alpine as builder
|
||||||
|
|
||||||
|
COPY . /go/src/accounting
|
||||||
|
WORKDIR /go/src/accounting
|
||||||
|
|
||||||
|
RUN set -ex \
|
||||||
|
&& apk add --no-cache \
|
||||||
|
git \
|
||||||
|
make \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
&& make build
|
||||||
|
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
LABEL maintainer "Knut Ahlers <knut@ahlers.me>"
|
||||||
|
|
||||||
|
RUN set -ex \
|
||||||
|
&& apk --no-cache add \
|
||||||
|
ca-certificates
|
||||||
|
|
||||||
|
COPY --from=builder /go/src/accounting/accounting /usr/local/bin/accounting
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/accounting"]
|
||||||
|
CMD ["--"]
|
||||||
|
|
||||||
|
# vim: set ft=Dockerfile:
|
19
Makefile
Normal file
19
Makefile
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
PORT := 5000
|
||||||
|
|
||||||
|
default: build
|
||||||
|
|
||||||
|
build: frontend
|
||||||
|
go build \
|
||||||
|
-ldflags "-X main.version=$(shell git describe --tags --always || echo dev)" \
|
||||||
|
-mod=readonly \
|
||||||
|
-trimpath
|
||||||
|
|
||||||
|
.PHONY: frontend
|
||||||
|
frontend: node_modules
|
||||||
|
node ci/build.mjs
|
||||||
|
|
||||||
|
node_modules:
|
||||||
|
vault2env --key secret/jenkins/fontawesome -- npm ci --include=dev
|
||||||
|
|
||||||
|
run: frontend
|
||||||
|
envrun -- go run . --listen=:$(PORT)
|
30
ci/build.mjs
Normal file
30
ci/build.mjs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import esbuild from 'esbuild'
|
||||||
|
import { sassPlugin } from 'esbuild-sass-plugin'
|
||||||
|
import vuePlugin from 'esbuild-plugin-vue3'
|
||||||
|
|
||||||
|
esbuild.build({
|
||||||
|
assetNames: '[name]',
|
||||||
|
bundle: true,
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'dev'),
|
||||||
|
},
|
||||||
|
entryPoints: ['frontend/main.js'],
|
||||||
|
legalComments: 'none',
|
||||||
|
loader: {
|
||||||
|
'.ttf': 'file',
|
||||||
|
'.woff2': 'file',
|
||||||
|
},
|
||||||
|
minify: true,
|
||||||
|
outfile: 'pkg/frontend/assets/app.js',
|
||||||
|
plugins: [
|
||||||
|
sassPlugin(),
|
||||||
|
vuePlugin(),
|
||||||
|
],
|
||||||
|
target: [
|
||||||
|
'chrome87',
|
||||||
|
'edge87',
|
||||||
|
'es2020',
|
||||||
|
'firefox84',
|
||||||
|
'safari14',
|
||||||
|
],
|
||||||
|
})
|
280
frontend/components/accountsSidebar.vue
Normal file
280
frontend/components/accountsSidebar.vue
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<router-link
|
||||||
|
to="/"
|
||||||
|
class="d-flex align-items-center fs-5 mb-3 mb-md-0 me-md-auto text-start text-white text-decoration-none"
|
||||||
|
>
|
||||||
|
<i class="fas fa-fw fa-hand-holding-dollar me-2" />
|
||||||
|
My Budget
|
||||||
|
</router-link>
|
||||||
|
<!--
|
||||||
|
<router-link
|
||||||
|
to="/"
|
||||||
|
class="btn fs-5 text-start w-100"
|
||||||
|
>
|
||||||
|
<i class="fas fa-fw fa-chart-line" />
|
||||||
|
Reports
|
||||||
|
</router-link>
|
||||||
|
-->
|
||||||
|
<hr>
|
||||||
|
<ul class="list-unstyled lh-lg ps-0">
|
||||||
|
<li class="mb-1 fw-semibold">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="fas fa-fw fa-credit-card me-1" /> Budget
|
||||||
|
<span :class="{'ms-auto': true, 'text-danger': budgetSum < 0}">
|
||||||
|
{{ budgetSum.toFixed(2) }} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 ps-3 small">
|
||||||
|
<li>
|
||||||
|
<router-link
|
||||||
|
v-for="acc in budgetAccounts"
|
||||||
|
:key="acc.id"
|
||||||
|
class="d-flex align-items-center text-white text-decoration-none"
|
||||||
|
:to="{ name: 'account-transactions', params: { id: acc.id }}"
|
||||||
|
>
|
||||||
|
{{ acc.name }}
|
||||||
|
<span :class="{'ms-auto': true, 'text-danger': acc.balance < 0}">
|
||||||
|
{{ acc.balance.toFixed(2) }} €
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="mb-1 fw-semibold">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="fas fa-fw fa-coin me-1" /> Tracking
|
||||||
|
<span :class="{'ms-auto': true, 'text-danger': trackingSum < 0}">
|
||||||
|
{{ trackingSum.toFixed(2) }} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 ps-3 small">
|
||||||
|
<li>
|
||||||
|
<router-link
|
||||||
|
v-for="acc in trackingAccounts"
|
||||||
|
:key="acc.id"
|
||||||
|
class="d-flex align-items-center text-white text-decoration-none"
|
||||||
|
:to="{ name: 'account-transactions', params: { id: acc.id }}"
|
||||||
|
>
|
||||||
|
{{ acc.name }}
|
||||||
|
<span :class="{'ms-auto': true, 'text-danger': acc.balance < 0}">
|
||||||
|
{{ acc.balance.toFixed(2) }} €
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm w-100"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#createAccountModal"
|
||||||
|
>
|
||||||
|
<i class="fas fa-fw fa-circle-plus mr-1" />
|
||||||
|
Add Account
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="createAccountModal"
|
||||||
|
ref="createAccountModal"
|
||||||
|
class="modal fade"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="exampleModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog modal-sm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1
|
||||||
|
id="exampleModalLabel"
|
||||||
|
class="modal-title fs-5"
|
||||||
|
>
|
||||||
|
Add Account
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
data-bs-dismiss="modal"
|
||||||
|
aria-label="Close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label
|
||||||
|
for="createAccountModalName"
|
||||||
|
class="form-label"
|
||||||
|
>Account Name</label>
|
||||||
|
<input
|
||||||
|
id="createAccountModalName"
|
||||||
|
v-model.trim="modals.addAccount.name"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label
|
||||||
|
for="createAccountModalType"
|
||||||
|
class="form-label"
|
||||||
|
>Account Type</label>
|
||||||
|
<select
|
||||||
|
v-model="modals.addAccount.type"
|
||||||
|
class="form-select"
|
||||||
|
>
|
||||||
|
<option value="budget">
|
||||||
|
Budget
|
||||||
|
</option>
|
||||||
|
<option value="tracking">
|
||||||
|
Tracking
|
||||||
|
</option>
|
||||||
|
<option value="category">
|
||||||
|
Category
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label
|
||||||
|
for="createAccountModalBalance"
|
||||||
|
class="form-label"
|
||||||
|
>Starting Balance</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
id="createAccountModalBalance"
|
||||||
|
v-model.number="modals.addAccount.startingBalance"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<span class="input-group-text">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="addAccount"
|
||||||
|
>
|
||||||
|
<i class="fas fa-fw fa-circle-plus mr-1" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Modal } from 'bootstrap'
|
||||||
|
|
||||||
|
const startingBalanceAcc = '00000000-0000-0000-0000-000000000002'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
computed: {
|
||||||
|
budgetAccounts() {
|
||||||
|
const accs = (this.accounts || []).filter(acc => acc.type === 'budget')
|
||||||
|
accs.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
return accs
|
||||||
|
},
|
||||||
|
|
||||||
|
budgetSum() {
|
||||||
|
return this.budgetAccounts.reduce((sum, acc) => sum + acc.balance, 0)
|
||||||
|
},
|
||||||
|
|
||||||
|
trackingAccounts() {
|
||||||
|
const accs = (this.accounts || []).filter(acc => acc.type === 'tracking')
|
||||||
|
accs.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
return accs
|
||||||
|
},
|
||||||
|
|
||||||
|
trackingSum() {
|
||||||
|
return this.trackingAccounts.reduce((sum, acc) => sum + acc.balance, 0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
modals: {
|
||||||
|
addAccount: {
|
||||||
|
name: '',
|
||||||
|
startingBalance: 0,
|
||||||
|
type: 'budget',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
addAccount() {
|
||||||
|
return fetch('/api/accounts', {
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: this.modals.addAccount.name,
|
||||||
|
type: this.modals.addAccount.type,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.then(resp => resp.json())
|
||||||
|
.then(account => {
|
||||||
|
if (account.type === 'budget') {
|
||||||
|
return fetch('/api/transactions', {
|
||||||
|
body: JSON.stringify({
|
||||||
|
account: account.id,
|
||||||
|
amount: this.modals.addAccount.startingBalance,
|
||||||
|
category: startingBalanceAcc,
|
||||||
|
cleared: true,
|
||||||
|
description: 'Starting Balance',
|
||||||
|
time: new Date(),
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
} else if (account.type === 'tracking') {
|
||||||
|
return fetch('/api/transactions', {
|
||||||
|
body: JSON.stringify({
|
||||||
|
account: account.id,
|
||||||
|
amount: this.modals.addAccount.startingBalance,
|
||||||
|
cleared: true,
|
||||||
|
description: 'Starting Balance',
|
||||||
|
time: new Date(),
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
} else if (account.type === 'category') {
|
||||||
|
return fetch(`/api/accounts/${startingBalanceAcc}/transfer/${account.id}?amount=${this.modals.addAccount.startingBalance}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw new Error('invalid account type detected')
|
||||||
|
})
|
||||||
|
.then(() => this.$emit('update-accounts'))
|
||||||
|
.then(() => {
|
||||||
|
Modal.getInstance(this.$refs.createAccountModal).toggle()
|
||||||
|
|
||||||
|
this.modals.addAccount = {
|
||||||
|
name: '',
|
||||||
|
startingBalance: 0,
|
||||||
|
type: 'budget',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
name: 'AccountingAppSidebar',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
accounts: {
|
||||||
|
required: true,
|
||||||
|
type: Array,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
46
frontend/components/app.vue
Normal file
46
frontend/components/app.vue
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
<template>
|
||||||
|
<div class="d-flex h-100">
|
||||||
|
<div
|
||||||
|
class="d-flex flex-column flex-shrink-0 p-3"
|
||||||
|
style="width: 300px"
|
||||||
|
>
|
||||||
|
<accounts-sidebar
|
||||||
|
:accounts="accounts"
|
||||||
|
@updateAccounts="fetchAccounts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column flex-grow-1 p-3">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import accountsSidebar from './accountsSidebar.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { accountsSidebar },
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.fetchAccounts()
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
accounts: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
fetchAccounts() {
|
||||||
|
return fetch('/api/accounts?with-balances')
|
||||||
|
.then(resp => resp.json())
|
||||||
|
.then(data => {
|
||||||
|
this.accounts = data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
name: 'AccountingAppMain',
|
||||||
|
}
|
||||||
|
</script>
|
26
frontend/lato.scss
Normal file
26
frontend/lato.scss
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
/* lato-regular - latin-ext_latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Lato';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('latofont/lato-v20-latin-ext_latin-regular.woff2') format('woff2');
|
||||||
|
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* lato-italic - latin-ext_latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Lato';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('latofont/lato-v20-latin-ext_latin-italic.woff2') format('woff2');
|
||||||
|
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* lato-700 - latin-ext_latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Lato';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url('latofont/lato-v20-latin-ext_latin-700.woff2') format('woff2');
|
||||||
|
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||||
|
}
|
94
frontend/latofont/OFL.txt
Executable file
94
frontend/latofont/OFL.txt
Executable file
|
@ -0,0 +1,94 @@
|
||||||
|
Copyright (c) 2010-2015, Łukasz Dziedzic (dziedzic@typoland.com),
|
||||||
|
with Reserved Font Name Lato.
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
BIN
frontend/latofont/lato-v20-latin-ext_latin-700.woff2
Normal file
BIN
frontend/latofont/lato-v20-latin-ext_latin-700.woff2
Normal file
Binary file not shown.
BIN
frontend/latofont/lato-v20-latin-ext_latin-italic.woff2
Normal file
BIN
frontend/latofont/lato-v20-latin-ext_latin-italic.woff2
Normal file
Binary file not shown.
BIN
frontend/latofont/lato-v20-latin-ext_latin-regular.woff2
Normal file
BIN
frontend/latofont/lato-v20-latin-ext_latin-regular.woff2
Normal file
Binary file not shown.
20
frontend/main.js
Normal file
20
frontend/main.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
/* eslint-disable sort-imports */
|
||||||
|
import { createApp, h } from 'vue'
|
||||||
|
|
||||||
|
import './style.scss'
|
||||||
|
|
||||||
|
import appMain from './components/app.vue'
|
||||||
|
import router from './router.js'
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
name: 'AccountingApp',
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return h(appMain)
|
||||||
|
},
|
||||||
|
|
||||||
|
router,
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
13
frontend/router.js
Normal file
13
frontend/router.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ component: null, name: 'budget', path: '/' },
|
||||||
|
{ component: null, name: 'account-transactions', path: '/accounts/:id' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
10
frontend/style.scss
Normal file
10
frontend/style.scss
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
// Force local fonts
|
||||||
|
$web-font-path: '';
|
||||||
|
|
||||||
|
@import "../node_modules/bootstrap/dist/css/bootstrap.css";
|
||||||
|
@import "lato";
|
||||||
|
|
||||||
|
$fa-font-path: "../node_modules/@fortawesome/fontawesome-pro/webfonts";
|
||||||
|
@import "../node_modules/@fortawesome/fontawesome-pro/scss/fontawesome.scss";
|
||||||
|
@import "../node_modules/@fortawesome/fontawesome-pro/scss/solid.scss";
|
||||||
|
@import "../node_modules/@fortawesome/fontawesome-pro/scss/brands.scss";
|
2
main.go
2
main.go
|
@ -7,6 +7,7 @@ import (
|
||||||
|
|
||||||
"git.luzifer.io/luzifer/accounting/pkg/api"
|
"git.luzifer.io/luzifer/accounting/pkg/api"
|
||||||
"git.luzifer.io/luzifer/accounting/pkg/database"
|
"git.luzifer.io/luzifer/accounting/pkg/database"
|
||||||
|
"git.luzifer.io/luzifer/accounting/pkg/frontend"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
@ -60,6 +61,7 @@ func main() {
|
||||||
|
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
api.RegisterHandler(router.PathPrefix("/api").Subrouter(), dbc, logrus.StandardLogger())
|
api.RegisterHandler(router.PathPrefix("/api").Subrouter(), dbc, logrus.StandardLogger())
|
||||||
|
frontend.RegisterHandler(router, logrus.StandardLogger())
|
||||||
|
|
||||||
var hdl http.Handler = router
|
var hdl http.Handler = router
|
||||||
hdl = httpHelper.GzipHandler(hdl)
|
hdl = httpHelper.GzipHandler(hdl)
|
||||||
|
|
3471
package-lock.json
generated
Normal file
3471
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
package.json
Normal file
18
package.json
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/eslint-parser": "^7.23.3",
|
||||||
|
"esbuild": "^0.19.11",
|
||||||
|
"esbuild-plugin-vue3": "^0.4.0",
|
||||||
|
"esbuild-sass-plugin": "^2.16.1",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-plugin-vue": "^9.20.1"
|
||||||
|
},
|
||||||
|
"name": "accounting",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-pro": "^6.4.2",
|
||||||
|
"bootstrap": "^5.3.2",
|
||||||
|
"vue": "^3.4.14",
|
||||||
|
"vue-router": "^4.2.5"
|
||||||
|
}
|
||||||
|
}
|
|
@ -45,7 +45,7 @@ func (a apiServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect)
|
http.Redirect(w, r, u.String(), http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a apiServer) handleGetAccount(w http.ResponseWriter, r *http.Request) {
|
func (a apiServer) handleGetAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -69,9 +69,12 @@ func (a apiServer) handleGetAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a apiServer) handleListAccounts(w http.ResponseWriter, r *http.Request) {
|
func (a apiServer) handleListAccounts(w http.ResponseWriter, r *http.Request) {
|
||||||
var payload any
|
var (
|
||||||
|
payload any
|
||||||
|
showHidden = r.URL.Query().Has("with-hidden")
|
||||||
|
)
|
||||||
if r.URL.Query().Has("with-balances") {
|
if r.URL.Query().Has("with-balances") {
|
||||||
accs, err := a.dbc.ListAccountBalances()
|
accs, err := a.dbc.ListAccountBalances(showHidden)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.errorResponse(w, err, "getting account balances", http.StatusInternalServerError)
|
a.errorResponse(w, err, "getting account balances", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
@ -80,14 +83,14 @@ func (a apiServer) handleListAccounts(w http.ResponseWriter, r *http.Request) {
|
||||||
} else {
|
} else {
|
||||||
at := database.AccountType(r.URL.Query().Get("account-type"))
|
at := database.AccountType(r.URL.Query().Get("account-type"))
|
||||||
if at.IsValid() {
|
if at.IsValid() {
|
||||||
accs, err := a.dbc.ListAccountsByType(at)
|
accs, err := a.dbc.ListAccountsByType(at, showHidden)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.errorResponse(w, err, "getting accounts", http.StatusInternalServerError)
|
a.errorResponse(w, err, "getting accounts", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload = accs
|
payload = accs
|
||||||
} else {
|
} else {
|
||||||
accs, err := a.dbc.ListAccounts()
|
accs, err := a.dbc.ListAccounts(showHidden)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.errorResponse(w, err, "getting accounts", http.StatusInternalServerError)
|
a.errorResponse(w, err, "getting accounts", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
@ -116,7 +119,7 @@ func (a apiServer) handleTransferMoney(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if amount, err = strconv.ParseFloat(mux.Vars(r)["to"], 64); err != nil {
|
if amount, err = strconv.ParseFloat(r.URL.Query().Get("amount"), 64); err != nil {
|
||||||
a.errorResponse(w, err, "parsing amount", http.StatusBadRequest)
|
a.errorResponse(w, err, "parsing amount", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@ func (a apiServer) handleCreateTransaction(w http.ResponseWriter, r *http.Reques
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect)
|
http.Redirect(w, r, u.String(), http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a apiServer) handleDeleteTransaction(w http.ResponseWriter, r *http.Request) {
|
func (a apiServer) handleDeleteTransaction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
@ -1,13 +1,41 @@
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import "github.com/google/uuid"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
const constAcctIDNamespace = "17de217e-94d7-4a9b-8833-ecca7f0eb6ca"
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const constAcctIDNamespace = "00000000-0000-0000-0000-%012s"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// UnallocatedMoney is a category UUID which is automatically created
|
// UnallocatedMoney is a category UUID which is automatically created
|
||||||
// during database migration phase and therefore always available
|
// during database migration phase and therefore always available
|
||||||
UnallocatedMoney = uuid.NewSHA1(uuid.MustParse(constAcctIDNamespace), []byte("unallocated-money"))
|
UnallocatedMoney = makeConstAcctID(1)
|
||||||
|
// StartingBalance is a category UUID which is automatically created
|
||||||
|
// and hidden during database migration and used in frontend as constant
|
||||||
|
StartingBalance = makeConstAcctID(2) //nolint:gomnd
|
||||||
|
|
||||||
invalidAcc = uuid.NewSHA1(uuid.MustParse(constAcctIDNamespace), []byte("INVALID ACCOUNT"))
|
invalidAcc = makeConstAcctID(math.MaxUint32)
|
||||||
|
|
||||||
|
migrateCreateAccounts = []Account{
|
||||||
|
{
|
||||||
|
BaseModel: BaseModel{ID: UnallocatedMoney},
|
||||||
|
Hidden: false,
|
||||||
|
Name: "Unallocated Money",
|
||||||
|
Type: AccountTypeCategory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BaseModel: BaseModel{ID: StartingBalance},
|
||||||
|
Hidden: true,
|
||||||
|
Name: "Starting Balance",
|
||||||
|
Type: AccountTypeCategory,
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func makeConstAcctID(fixedNumber uint32) uuid.UUID {
|
||||||
|
return uuid.MustParse(fmt.Sprintf(constAcctIDNamespace, strconv.FormatUint(uint64(fixedNumber), 16)))
|
||||||
|
}
|
||||||
|
|
|
@ -60,15 +60,11 @@ func New(dbtype, dsn string) (*Client, error) {
|
||||||
return nil, fmt.Errorf("migrating database schema: %w", err)
|
return nil, fmt.Errorf("migrating database schema: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = db.Save(&Account{
|
for i := range migrateCreateAccounts {
|
||||||
BaseModel: BaseModel{
|
a := migrateCreateAccounts[i]
|
||||||
ID: UnallocatedMoney,
|
if err = db.Save(&a).Error; err != nil {
|
||||||
},
|
return nil, fmt.Errorf("ensuring default account %q: %w", a.Name, err)
|
||||||
Name: "Unallocated Money",
|
}
|
||||||
Type: AccountTypeCategory,
|
|
||||||
Hidden: false,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return nil, fmt.Errorf("ensuring unallocated money category: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
|
@ -146,8 +142,8 @@ func (c *Client) GetTransactionByID(id uuid.UUID) (tx Transaction, err error) {
|
||||||
|
|
||||||
// ListAccountBalances returns a list of accounts with their
|
// ListAccountBalances returns a list of accounts with their
|
||||||
// corresponding balance
|
// corresponding balance
|
||||||
func (c *Client) ListAccountBalances() (a []AccountBalance, err error) {
|
func (c *Client) ListAccountBalances(showHidden bool) (a []AccountBalance, err error) {
|
||||||
accs, err := c.ListAccounts()
|
accs, err := c.ListAccounts(showHidden)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("listing accounts: %w", err)
|
return nil, fmt.Errorf("listing accounts: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -191,9 +187,17 @@ func (c *Client) ListAccountBalances() (a []AccountBalance, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAccounts returns a list of all accounts
|
// ListAccounts returns a list of all accounts
|
||||||
func (c *Client) ListAccounts() (a []Account, err error) {
|
//
|
||||||
|
//revive:disable-next-line:flag-parameter
|
||||||
|
func (c *Client) ListAccounts(showHidden bool) (a []Account, err error) {
|
||||||
if err = c.retryRead(func(db *gorm.DB) error {
|
if err = c.retryRead(func(db *gorm.DB) error {
|
||||||
return db.Find(&a, "hidden = ?", false).Error
|
q := db.Model(&Account{})
|
||||||
|
|
||||||
|
if !showHidden {
|
||||||
|
q = q.Where("hidden = ?", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.Find(&a).Error
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return a, fmt.Errorf("listing accounts: %w", err)
|
return a, fmt.Errorf("listing accounts: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -202,9 +206,17 @@ func (c *Client) ListAccounts() (a []Account, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAccountsByType returns a list of all accounts of the given type
|
// ListAccountsByType returns a list of all accounts of the given type
|
||||||
func (c *Client) ListAccountsByType(at AccountType) (a []Account, err error) {
|
//
|
||||||
|
//revive:disable-next-line:flag-parameter
|
||||||
|
func (c *Client) ListAccountsByType(at AccountType, showHidden bool) (a []Account, err error) {
|
||||||
if err = c.retryRead(func(db *gorm.DB) error {
|
if err = c.retryRead(func(db *gorm.DB) error {
|
||||||
return db.Find(&a, "type = ?", at).Error
|
q := db.Where("type = ?", at)
|
||||||
|
|
||||||
|
if !showHidden {
|
||||||
|
q = q.Where("hidden = ?", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.Find(&a).Error
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return a, fmt.Errorf("listing accounts: %w", err)
|
return a, fmt.Errorf("listing accounts: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,29 +43,29 @@ func TestAccountManagement(t *testing.T) {
|
||||||
assert.Equal(t, "test", act.Name)
|
assert.Equal(t, "test", act.Name)
|
||||||
|
|
||||||
// List all accounts
|
// List all accounts
|
||||||
accs, err := dbc.ListAccounts()
|
accs, err := dbc.ListAccounts(false)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, accs, 2)
|
assert.Len(t, accs, 2)
|
||||||
|
|
||||||
// Hide account and list again
|
// Hide account and list again
|
||||||
assert.NoError(t, dbc.UpdateAccountHidden(actID, true))
|
assert.NoError(t, dbc.UpdateAccountHidden(actID, true))
|
||||||
accs, err = dbc.ListAccounts()
|
accs, err = dbc.ListAccounts(false)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, accs, 1)
|
assert.Len(t, accs, 1)
|
||||||
|
|
||||||
// Unhide account and list again
|
// Unhide account and list again
|
||||||
assert.NoError(t, dbc.UpdateAccountHidden(actID, false))
|
assert.NoError(t, dbc.UpdateAccountHidden(actID, false))
|
||||||
accs, err = dbc.ListAccounts()
|
accs, err = dbc.ListAccounts(false)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, accs, 2)
|
assert.Len(t, accs, 2)
|
||||||
|
|
||||||
// List accounts from other type
|
// List accounts from other type
|
||||||
accs, err = dbc.ListAccountsByType(AccountTypeCategory)
|
accs, err = dbc.ListAccountsByType(AccountTypeCategory, false)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, accs, 1)
|
assert.Len(t, accs, 1)
|
||||||
|
|
||||||
// List accounts from existing type
|
// List accounts from existing type
|
||||||
accs, err = dbc.ListAccountsByType(AccountTypeBudget)
|
accs, err = dbc.ListAccountsByType(AccountTypeBudget, false)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, accs, 1)
|
assert.Len(t, accs, 1)
|
||||||
|
|
||||||
|
@ -128,7 +128,7 @@ func TestTransactions(t *testing.T) {
|
||||||
assert.NotEqual(t, uuid.Nil, tx.ID)
|
assert.NotEqual(t, uuid.Nil, tx.ID)
|
||||||
|
|
||||||
// Now we should have money…
|
// Now we should have money…
|
||||||
bals, err := dbc.ListAccountBalances()
|
bals, err := dbc.ListAccountBalances(false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
checkAcctBal(bals, tb1.ID, 1000)
|
checkAcctBal(bals, tb1.ID, 1000)
|
||||||
checkAcctBal(bals, tb2.ID, 0)
|
checkAcctBal(bals, tb2.ID, 0)
|
||||||
|
@ -138,7 +138,7 @@ func TestTransactions(t *testing.T) {
|
||||||
|
|
||||||
// Lets redistribute the money
|
// Lets redistribute the money
|
||||||
require.NoError(t, dbc.TransferMoney(UnallocatedMoney, tc.ID, 500))
|
require.NoError(t, dbc.TransferMoney(UnallocatedMoney, tc.ID, 500))
|
||||||
bals, err = dbc.ListAccountBalances()
|
bals, err = dbc.ListAccountBalances(false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
checkAcctBal(bals, tb1.ID, 1000)
|
checkAcctBal(bals, tb1.ID, 1000)
|
||||||
checkAcctBal(bals, tb2.ID, 0)
|
checkAcctBal(bals, tb2.ID, 0)
|
||||||
|
@ -148,7 +148,7 @@ func TestTransactions(t *testing.T) {
|
||||||
|
|
||||||
// Now transfer some money to another budget account
|
// Now transfer some money to another budget account
|
||||||
require.NoError(t, dbc.TransferMoney(tb1.ID, tb2.ID, 100))
|
require.NoError(t, dbc.TransferMoney(tb1.ID, tb2.ID, 100))
|
||||||
bals, err = dbc.ListAccountBalances()
|
bals, err = dbc.ListAccountBalances(false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
checkAcctBal(bals, tb1.ID, 900)
|
checkAcctBal(bals, tb1.ID, 900)
|
||||||
checkAcctBal(bals, tb2.ID, 100)
|
checkAcctBal(bals, tb2.ID, 100)
|
||||||
|
@ -158,7 +158,7 @@ func TestTransactions(t *testing.T) {
|
||||||
|
|
||||||
// And some to a tracking account (needs category)
|
// And some to a tracking account (needs category)
|
||||||
require.NoError(t, dbc.TransferMoneyWithCategory(tb1.ID, tt.ID, 100, tc.ID))
|
require.NoError(t, dbc.TransferMoneyWithCategory(tb1.ID, tt.ID, 100, tc.ID))
|
||||||
bals, err = dbc.ListAccountBalances()
|
bals, err = dbc.ListAccountBalances(false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
checkAcctBal(bals, tb1.ID, 800)
|
checkAcctBal(bals, tb1.ID, 800)
|
||||||
checkAcctBal(bals, tb2.ID, 100)
|
checkAcctBal(bals, tb2.ID, 100)
|
||||||
|
@ -178,7 +178,7 @@ func TestTransactions(t *testing.T) {
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, lltx.Cleared)
|
assert.False(t, lltx.Cleared)
|
||||||
bals, err = dbc.ListAccountBalances()
|
bals, err = dbc.ListAccountBalances(false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
checkAcctBal(bals, tb1.ID, 700)
|
checkAcctBal(bals, tb1.ID, 700)
|
||||||
checkAcctBal(bals, tb2.ID, 100)
|
checkAcctBal(bals, tb2.ID, 100)
|
||||||
|
@ -197,7 +197,7 @@ func TestTransactions(t *testing.T) {
|
||||||
|
|
||||||
// Oh, wrong category
|
// Oh, wrong category
|
||||||
require.NoError(t, dbc.UpdateTransactionCategory(lltx.ID, UnallocatedMoney))
|
require.NoError(t, dbc.UpdateTransactionCategory(lltx.ID, UnallocatedMoney))
|
||||||
bals, err = dbc.ListAccountBalances()
|
bals, err = dbc.ListAccountBalances(false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
checkAcctBal(bals, tb1.ID, 700)
|
checkAcctBal(bals, tb1.ID, 700)
|
||||||
checkAcctBal(bals, tb2.ID, 100)
|
checkAcctBal(bals, tb2.ID, 100)
|
||||||
|
@ -219,7 +219,7 @@ func TestTransactions(t *testing.T) {
|
||||||
|
|
||||||
// We made an error and didn't pay the landlord
|
// We made an error and didn't pay the landlord
|
||||||
require.NoError(t, dbc.DeleteTransaction(lltx.ID))
|
require.NoError(t, dbc.DeleteTransaction(lltx.ID))
|
||||||
bals, err = dbc.ListAccountBalances()
|
bals, err = dbc.ListAccountBalances(false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
checkAcctBal(bals, tb1.ID, 800)
|
checkAcctBal(bals, tb1.ID, 800)
|
||||||
checkAcctBal(bals, tb2.ID, 100)
|
checkAcctBal(bals, tb2.ID, 100)
|
||||||
|
|
20
pkg/frontend/assets/index.html
Normal file
20
pkg/frontend/assets/index.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html class="h-100" lang="en" data-bs-theme="dark">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="/assets/app.css" rel="stylesheet">
|
||||||
|
<title>Accounting</title>
|
||||||
|
<style>
|
||||||
|
[v-cloak] { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="h-100">
|
||||||
|
<div id="app" class="h-100" v-cloak></div>
|
||||||
|
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
79
pkg/frontend/frontend.go
Normal file
79
pkg/frontend/frontend.go
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
// Package frontend contains compiled frontend assets and a registration
|
||||||
|
// for the HTTP listeners
|
||||||
|
package frontend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
frontendServer struct {
|
||||||
|
router *mux.Router
|
||||||
|
log *logrus.Logger
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/*
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
// RegisterHandler takes a Router and registers the frontend onto that
|
||||||
|
// router
|
||||||
|
func RegisterHandler(router *mux.Router, logger *logrus.Logger) {
|
||||||
|
srv := frontendServer{router, logger}
|
||||||
|
|
||||||
|
router.
|
||||||
|
PathPrefix("/assets").
|
||||||
|
Handler(http.StripPrefix("/assets", http.HandlerFunc(srv.handleAsset))).
|
||||||
|
Methods(http.MethodGet)
|
||||||
|
router.NotFoundHandler = http.HandlerFunc(srv.handleIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f frontendServer) handleAsset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
asset, err := assets.Open(path.Join("assets", r.URL.Path))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
http.Error(w, "that's not the file you're looking for", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, fmt.Sprintf("opening asset: %s", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := asset.Close(); err != nil {
|
||||||
|
f.log.WithError(err).Error("closing assets file (leaked fd)")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
|
||||||
|
|
||||||
|
if _, err = io.Copy(w, asset); err != nil {
|
||||||
|
f.log.WithError(err).Debug("copying index to browser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f frontendServer) handleIndex(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
index, err := assets.Open("assets/index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("opening index: %s", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := index.Close(); err != nil {
|
||||||
|
f.log.WithError(err).Error("closing assets file (leaked fd)")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err = io.Copy(w, index); err != nil {
|
||||||
|
f.log.WithError(err).Debug("copying index to browser")
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue