235 Commits

Author SHA1 Message Date
0aa877d7d4 Merge pull request 'Use vue's Composition API' (#10) from vue-composition into master
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #10
2022-02-14 23:44:00 +01:00
87a70ee5fa Revert
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
continuous-integration/drone/pr Build is passing
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-14 22:39:14 +00:00
0a030eaee1 Convert other pages to composition API 2022-02-14 22:24:42 +00:00
d11c0036b5 Do not use a store for API 2022-02-14 08:12:41 +00:00
ca93e9cd55 Migrate Account.vue to composition API
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
2022-02-14 08:06:16 +00:00
a061ffd350 Merge pull request 'Implement minor fixes' (#9) from minor-fixes into master
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
continuous-integration/drone/push Build is passing
Reviewed-on: #9
2022-02-14 08:48:07 +01:00
5633c029ac Update Earthfile and production docker-compose.yml
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-13 13:20:18 +00:00
a97d050ead Specify Map serializer for budgets
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-13 13:16:50 +00:00
958929fd16 Update docker-compose to use new tag 2022-02-13 13:16:50 +00:00
a61d80ee1f Implement SPA handling in Backend 2022-02-13 13:16:50 +00:00
41c5095b8b Merge pull request 'Use woodpecker for CI' (#8) from woodpecker into master
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #8
2022-02-13 14:16:38 +01:00
c074dfe865 Fix Taskfile
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-12 00:00:33 +00:00
fa8a2854f2 Add node packages to image
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-12 00:00:19 +00:00
15bb73de30 Add secrects
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-11 23:54:26 +00:00
e506510fde Remove user and add go deps from builder image 2022-02-11 23:49:43 +00:00
11ac8758da Disable docker from taskfile
Some checks failed
continuous-integration/drone/pr Build is passing
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is passing
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-11 23:43:55 +00:00
3db5e1e72c Reenable docker push
Some checks failed
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/pr Build is failing
ci/woodpecker/pr/woodpecker Pipeline failed
2022-02-11 23:42:03 +00:00
4e2a783b2e Extract variable 2022-02-11 23:40:46 +00:00
bb83563bc6 Use docker build-target
Some checks failed
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-11 23:35:15 +00:00
0a21c59eff Fetch deps before build
Some checks failed
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-11 23:33:47 +00:00
3308b58524 Fix sources in Taskfile
Some checks failed
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-11 23:30:53 +00:00
941b642f39 Build docker within task
Some checks failed
continuous-integration/drone/push Build is failing
ci/woodpecker/push/woodpecker Pipeline failed
2022-02-11 23:16:57 +00:00
6a77c71df4 Always pull dev image
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is failing
2022-02-11 22:59:38 +00:00
bf20914c1c Use default build name
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is failing
2022-02-11 22:58:31 +00:00
7874ef69a2 Fix Taskfile
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is failing
2022-02-11 22:57:26 +00:00
2e719b590e Add admin page 2022-02-11 22:52:00 +00:00
95d8e4fccc Rewrite event filter
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
continuous-integration/drone/push Build is failing
2022-02-11 22:49:06 +00:00
7cf106eb85 Rename 2022-02-11 22:41:35 +00:00
148fc18cd8 Fix schema 2022-02-11 22:41:07 +00:00
47095ae6ec Add .woodpecker.yaml 2022-02-11 22:39:19 +00:00
200fa1835a Merge pull request 'Replace vuex by pinia' (#7) from pinia into master
Reviewed-on: #7
2022-02-11 23:20:06 +01:00
cd0b370b50 Fix typescript errors 2022-02-11 22:18:00 +00:00
1af69b047d Fix mapping for menu in App.vue 2022-02-11 22:03:37 +00:00
f6cb6d4163 Do not use useStorage for root state 2022-02-11 22:03:26 +00:00
45389e01be Use vueuse useStorage instead of manually using localStorage 2022-02-11 21:38:06 +00:00
5868c3310e Fix Map.values() access and convert to array 2022-02-10 18:07:52 +00:00
1dc818f51f Add pinia-logger 2022-02-10 16:30:53 +00:00
0e342f2bcf Try to move perstisting to stores 2022-02-10 16:30:42 +00:00
21dcd7837b Bugfixes 2022-02-10 16:07:29 +00:00
c693625e34 Remove vuex 2022-02-10 15:49:30 +00:00
8b0e368d58 Fix return type of AuthHeaders 2022-02-10 13:35:51 +00:00
0c094d6f6b And on 2022-02-09 23:16:16 +00:00
9b8ae7a44d And on… 2022-02-09 23:14:48 +00:00
08330ce33c Migrate farther 2022-02-09 23:12:26 +00:00
2d0737a10c First step from vuex to pinia 2022-02-09 23:08:43 +00:00
2137460187 Merge pull request 'Implement first unit-tests' (#6) from unit-tests into master
Reviewed-on: #6
2022-02-09 23:41:08 +01:00
6ab8a96888 Implement first db-test using go-txdb 2022-02-09 22:40:35 +00:00
c3db535e10 go get go-txdb 2022-02-09 22:40:35 +00:00
5d292b2a31 Add first test 2022-02-09 22:40:35 +00:00
e934d407c2 Use simple connection string in config 2022-02-09 22:40:35 +00:00
2c71c521f9 Add bordersr to inputs 2022-02-09 22:40:35 +00:00
3bd5845068 Focus next element on Enter 2022-02-09 22:40:35 +00:00
c3a1564c4e Extract LoadRoutes method 2022-02-09 22:40:35 +00:00
922041856f Extract struct for return value of getTransactionsForAccount 2022-02-09 22:40:35 +00:00
6cafaec422 Inline tv and bv vars 2022-02-09 22:40:35 +00:00
499d78a781 Merge pull request 'design-improvements' (#5) from design-improvements into master
Reviewed-on: #5
2022-02-09 23:40:27 +01:00
227c2a7564 Align amount input right 2022-02-09 22:36:21 +00:00
07cd7a7c2b Add table Header 2022-02-09 22:36:21 +00:00
bd1e1cbfb8 Move Transaction row to own component 2022-02-09 22:36:21 +00:00
09a242f6f6 Merge pull request 'Show transfer account in transaction list' (#4) from show-transfer-account into master
Reviewed-on: #4
2022-02-09 23:35:36 +01:00
4eeecc2bd9 Show transfer account if filled 2022-02-08 21:28:55 +00:00
378d764220 Merge pull request 'Convert frontend to Vue' (#3) from vue into master
Reviewed-on: #3
2022-02-08 22:20:11 +01:00
2559e40ebd Merge branch 'master' into vue 2022-02-08 21:19:47 +00:00
0579ec5106 Save and restore ExpandMenu state 2022-02-08 21:14:44 +00:00
33bdfaddc4 Move budgeting to own route 2022-02-08 21:14:36 +00:00
eb195dfd29 Redirect to current route from site without date 2022-02-08 11:13:55 +00:00
d2b414f328 Always treat Currency.value as number 2022-02-08 10:53:06 +00:00
e873795562 Show sum for all on/off-budget accounts 2022-02-07 22:38:46 +00:00
33c83c0a69 Shrink date input to match column width 2022-02-07 22:19:59 +00:00
2646c5d3b7 Use Currency component for Account header 2022-02-07 22:19:48 +00:00
3ef5836607 Implement simple keyboard selection 2022-02-07 22:19:24 +00:00
b5c657978c Alternate row colors for transactions 2022-02-07 16:39:06 +00:00
0136e3b978 Use currency component for transactions 2022-02-07 16:38:53 +00:00
427e7e5359 Extract value formatting to computed property 2022-02-07 16:38:26 +00:00
139b6ec636 Convert multiple class binding to array syntax 2022-02-07 16:38:08 +00:00
9dad1dabbd Implement new transaction 2022-02-07 16:17:14 +00:00
95fcb9a586 Split Menu between md and smaller devices 2022-02-07 16:07:54 +00:00
3c1d83d8a2 Fix negative values smaller than zero 2022-02-07 16:07:37 +00:00
f09a2a4ca7 Fix multiplication instead of division 2022-02-06 22:21:04 +00:00
487aa89f18 Use Numeric in JSON output 2022-02-06 22:12:48 +00:00
5763409aa8 Split store 2022-02-06 20:15:59 +00:00
5ca09d2825 Fix calling dispatch for mutation 2022-02-06 20:15:29 +00:00
e18e68c964 Comment not implemented routes 2022-02-06 20:15:14 +00:00
065159a817 Cleanup unused backend code and routes 2022-02-06 20:15:00 +00:00
68c2d3ff28 Fix typing issues in Settings 2022-02-05 15:00:26 +00:00
0f2db4985f Remove old plugins 2022-02-05 14:54:14 +00:00
f532ffc0c1 Rename routes.ts 2022-02-05 14:36:20 +00:00
4edd1a8bf1 Cleanup more deps 2022-02-05 14:36:01 +00:00
a7890bce16 Add vue shim 2022-02-05 14:27:20 +00:00
ddaf647d87 Add getters for current budget ID and Name 2022-02-05 14:26:56 +00:00
80b5a7abfe Small layout improvements 2022-02-05 13:48:31 +00:00
2204188600 Actually call API 2022-02-04 21:43:51 +00:00
3d1d1308ac Use JSON params instead of PostForm 2022-02-04 21:43:35 +00:00
d5904a7c4a Implement testing endpoint for new transaction 2022-02-04 21:43:03 +00:00
d9c03e231e Improve error when no auth supplied 2022-02-04 21:40:22 +00:00
dba1e8c276 Change type of modelValue 2022-02-04 21:18:24 +00:00
ebc2286116 Display name if selected 2022-02-04 21:14:02 +00:00
d647650142 Order payees by name 2022-02-04 21:13:40 +00:00
057c576831 Implement autocomplete for categories 2022-02-04 21:13:30 +00:00
056071c6e6 Make autocomplete usable for multiple types 2022-02-04 20:47:50 +00:00
0305aa86c1 Add Card component 2022-02-04 20:44:08 +00:00
d9aed7603e Implement custom autocomplete component 2022-02-04 20:43:57 +00:00
e18ef79839 Implement minimal autocomplete for Payees 2022-02-04 20:24:29 +00:00
b93e44930a Use hamburger menu icon instead of 'Home'-text 2022-02-04 20:01:26 +00:00
c8e9a8b375 Improve login page 2022-02-04 19:59:21 +00:00
1d0fe60ea4 Improve layout 2022-02-04 19:54:32 +00:00
3f7f646120 Extract card and use for budget list 2022-02-04 17:16:03 +00:00
dfa8f369f0 Make it work slightly ;-) 2022-02-04 16:56:17 +00:00
d825379a01 Try to use tailwind 2022-02-04 16:34:23 +00:00
f7dfc7b455 Initialize tailwindcss 2022-02-04 15:53:56 +00:00
2203542d90 Regenerate yarn.lock 2022-02-04 15:53:14 +00:00
72f9a6413f Remove vuetify 2022-02-04 15:51:50 +00:00
bf25922fc4 Add fuzzy module 2022-02-04 15:46:17 +00:00
46d727c650 Try to implement autocomplete for payees 2022-02-01 21:08:37 +00:00
b6628dd8cb Fallback to empty list if budgets or accounts are unset 2022-02-01 20:23:46 +00:00
132ae75755 Add index page 2022-02-01 20:23:32 +00:00
2b885ca189 Implement dummy row for new transaction 2022-02-01 20:23:19 +00:00
37a842b395 Add main.ts 2022-02-01 08:15:19 +00:00
9353d82648 Fix diverse errors 2022-01-31 21:45:19 +00:00
b350fe7d74 Handle some differences between js/ts 2022-01-31 21:37:24 +00:00
7cba471de7 Convert most code to ts 2022-01-31 21:14:13 +00:00
27508af3a7 Convert Register.vue to TS 2022-01-31 20:47:56 +00:00
e0981630ab Enable typescript support 2022-01-31 20:47:45 +00:00
a7178e39c9 Add vue CLI to dev image 2022-01-31 20:40:15 +00:00
29f0c51e35 Extract some types 2022-01-31 20:34:32 +00:00
9c0126b14c Format document 2022-01-31 19:58:06 +00:00
34b9c14419 Redirect to dashboard and remove budget on delete 2022-01-31 16:12:17 +00:00
8e2a62929e Fix ignoring return value 2022-01-31 16:03:56 +00:00
95b1ac5943 Implement Delete Budget 2022-01-31 15:50:13 +00:00
1be3b6930d Install go packages as non-root in Dockerfile 2022-01-31 15:49:49 +00:00
e5fe3d06c4 Remember menu state 2022-01-31 14:43:49 +00:00
136f15badc Move showMenu to store 2022-01-31 14:43:20 +00:00
f091ce8945 Handle LOGIN via action 2022-01-29 23:32:11 +00:00
24370c9d32 Show negative colors in red 2022-01-29 23:29:17 +00:00
e8dbb54086 Merge stores and handle fetching from router 2022-01-29 23:26:57 +00:00
6f7aa28d22 Enable vuex logging 2022-01-29 22:39:13 +00:00
e85cf0ea55 Improve layout of Account view 2022-01-29 22:38:23 +00:00
58683a33fb Improve layout of Account 2022-01-28 10:46:56 +00:00
a45d02f9e0 Redesign app bar 2022-01-28 10:35:29 +00:00
0334b35041 Improve Layout of BudgetSettings 2022-01-28 09:44:30 +00:00
b52c4cb787 Make layout more flexible 2022-01-27 21:41:01 +00:00
0db6d6c67a Organize settings in cards 2022-01-27 21:37:04 +00:00
0b1f673c05 Extract action for API calls 2022-01-26 21:55:36 +00:00
0e438e0244 Implement clear 2022-01-26 21:38:41 +00:00
430c4d52da Formatting & remove redundant headline 2022-01-26 21:33:19 +00:00
d5a414266b Split build into dev and prod 2022-01-26 21:33:05 +00:00
47cbaf9660 Implement YNAB Import in Vue 2022-01-26 21:12:42 +00:00
7776ba90f0 Add interactive build to dev container 2022-01-26 20:47:08 +00:00
80c00171c8 Add dev docker-compose 2022-01-26 20:46:22 +00:00
9cc06bbe93 Add plain run command without docker and rename old run to rundocker 2022-01-26 20:45:46 +00:00
829881bd2a Fix missing exeExt in sources 2022-01-26 20:45:26 +00:00
0ad97ce4ff Remove unused HTML endpoints 2022-01-26 20:22:39 +00:00
1aa6d8d58f Add build script to dev container 2022-01-26 20:22:23 +00:00
db8553995c Add simple data stucture to README 2022-01-25 22:31:47 +00:00
de6054359a Remove dashboard call and fetch Budgets with login 2022-01-25 22:29:35 +00:00
9e3dde8076 Rename module 2022-01-25 20:58:32 +00:00
d6165c6ede Add Settings.vue 2022-01-25 20:56:57 +00:00
404bd6625f Add mutation-types.js 2022-01-25 20:56:43 +00:00
463a52fb19 Merge modules 2022-01-25 20:56:32 +00:00
ffed94f586 Implement dummy Budget-Settings and extract setTitle mutation 2022-01-25 20:40:00 +00:00
74c4c7cb02 Improve login/logout
Extract mutation types to mutation-types.js
2022-01-25 20:30:47 +00:00
6dcf7da861 Implement watch for accounts as well 2022-01-25 20:06:26 +00:00
4f2b3b7b22 Display selected budget and update sidebar on change 2022-01-25 19:25:11 +00:00
3b130b8621 Fix amount display and hide account 2022-01-25 19:20:54 +00:00
a4f94cd9d9 Remove old logging 2022-01-25 19:18:19 +00:00
c2bbaebfd2 Implement API endpoint for transactions 2022-01-25 19:18:08 +00:00
458f4f0e8f Implement account-view 2022-01-25 19:03:58 +00:00
e184ef933f Add container to wrap budget list 2022-01-25 12:54:33 +00:00
a2cc19d310 Use props for budgetid 2022-01-25 12:54:25 +00:00
626e0ada40 Add toggle menu button 2022-01-25 12:54:06 +00:00
339c230756 fetchBudget whenever current budget changes 2022-01-25 08:55:54 +00:00
59ae9f978a Rename Budget to BudgetSidebar 2022-01-25 08:55:39 +00:00
af3252277c Implement currentBudget and move infos to sidebar 2022-01-25 08:37:38 +00:00
33990bdccf Exclude node_modules and vendor in VSCode 2022-01-24 19:24:12 +00:00
5fb7542176 Add new budget dialog 2022-01-24 19:23:49 +00:00
c2bcd815d5 API returns Budget directly 2022-01-24 19:22:53 +00:00
be821bc90a Remove unneeded assignment 2022-01-24 19:22:38 +00:00
746680a4d7 Add empty styles file 2022-01-24 16:00:55 +00:00
fa4c9bcaba Add vuetify and webfontloader plugins 2022-01-24 16:00:39 +00:00
3de0447eba Implement register and login using vuetify 2022-01-24 16:00:01 +00:00
d59b9bdbec Add vuetify 2022-01-24 14:47:47 +00:00
93b1712fb5 Add Dockerfile for dev environment 2022-01-24 14:45:05 +00:00
b2b934cfb3 Add logout action 2022-01-23 22:38:38 +00:00
1d39fc7976 Remove old code for accounts 2022-01-23 22:38:25 +00:00
dd160cab17 Convert logout link to method 2022-01-23 22:38:12 +00:00
0e3ece9830 Add Getter for on/offbudget accounts and remove from backend 2022-01-23 22:33:36 +00:00
27298a9860 Add budget module to store 2022-01-23 22:24:40 +00:00
e26bb257cc Add dummy-logout link 2022-01-23 22:24:28 +00:00
0bd63636bb Implement Budget-List via Getter 2022-01-23 22:24:17 +00:00
6086447126 Implement budget display 2022-01-23 22:24:02 +00:00
aae8bbb44e Return JSON and move /budget/:budgetid to API endpoint 2022-01-23 22:23:30 +00:00
f33f0880c4 Move store to own folder 2022-01-23 21:54:46 +00:00
c069a33890 Remove logging after api request 2022-01-23 21:37:26 +00:00
8b4ebadec2 Only save partial state 2022-01-23 21:36:46 +00:00
2b556222ed Fix display of menu-items by login state 2022-01-23 21:35:43 +00:00
3da2e0f2f8 Remove authentication Cookies from Backend 2022-01-23 21:35:23 +00:00
4f72751ed6 Extract variable 2022-01-23 21:15:33 +00:00
c2326060b3 Rename index.js to routes.js 2022-01-22 16:23:10 +00:00
dad3a149d8 Use router-link to budget page 2022-01-22 16:21:53 +00:00
1bdd66a45a Remove HelloWorld and logging, add Budget page 2022-01-22 16:21:53 +00:00
b0ba63118e Add login page 2022-01-22 16:11:42 +00:00
18d11358b2 Remove logo 2022-01-22 16:11:34 +00:00
4af82805ff Save store to localStorage 2022-01-22 16:10:46 +00:00
15ab8a3dac Use Header instead of Cookie 2022-01-21 15:07:15 +00:00
99cf27b649 Return forbidden status in API 2022-01-21 15:07:01 +00:00
bc78cab715 Add proxy to backend to vite config 2022-01-21 14:20:58 +00:00
ddab5998bc Move dashboard to api 2022-01-21 14:20:45 +00:00
929db00a47 Get Dashboards on mounted
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-19 21:32:02 +00:00
8a6322bc7e Fix binding href 2022-01-19 21:32:02 +00:00
5601c965dc Fix script exporting computed 2022-01-19 21:32:02 +00:00
2fba4381df Allow access from other hosts 2022-01-19 21:32:02 +00:00
bdf942a17b Import esm-bundler 2022-01-19 21:32:02 +00:00
d354a680f9 Set version correctly 2022-01-19 21:32:02 +00:00
6899e4c88e Try to add store 2022-01-19 20:53:19 +00:00
e0f822dbcc Add vuex 2022-01-18 13:30:47 +00:00
d0e52ddfa6 Implement basic routing 2022-01-18 13:29:51 +00:00
2112192670 Add frontend to build script 2022-01-18 13:29:24 +00:00
13063b47ba Merge pull request 'Add row to create new transaction' (#2) from new-transaction-row into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #2
2022-01-18 11:43:20 +01:00
3cda536854 Add vue-router 2022-01-17 22:17:29 +00:00
6d49a549a0 Remove templating completely 2022-01-17 22:17:23 +00:00
aa33c148cb Remove templates and add vue template 2022-01-17 22:05:43 +00:00
663f247080 Import and display Status
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-16 15:43:05 +00:00
e094c2a740 Merge branch 'master' into new-transaction-row
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2022-01-10 11:18:56 +01:00
7fba9e7e4c Fix .drone.yml
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-10 10:18:42 +00:00
cc58c0d012 Add row to create new transaction 2022-01-10 10:15:55 +00:00
e138c264ea Merge pull request 'Import transfers as actual Transfers' (#1) from handle-transfers into master
Reviewed-on: #1
2022-01-10 11:15:05 +01:00
a8edeaafa1 Disable docker build for prs 2022-01-10 10:14:10 +00:00
6fe30231d8 Save all unmatched transfers as regular transactions
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2022-01-10 10:10:56 +00:00
49af9cd2ef Fix logging wrong objects 2022-01-10 10:10:56 +00:00
ac27dc783e Also fetch GroupID and highlight groups in transactions-view 2022-01-10 10:10:56 +00:00
3c17d674f9 Fix migration 2022-01-10 10:10:56 +00:00
92725c0f26 Reword clear actions description
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-10 10:10:47 +00:00
2ec9c923df Implement matching 2022-01-10 10:10:02 +00:00
beff7afcf7 Add group_id 2022-01-10 10:10:02 +00:00
337d588c3c Include all accounts
All checks were successful
continuous-integration/drone/push Build is passing
Before this change, accounts without transactions in the specified timespan had not been included
2022-01-10 10:09:34 +00:00
126 changed files with 11028 additions and 3046 deletions

View File

@ -8,3 +8,4 @@ config.example.json
.vscode/
budgeteer
budgeteer.exe
**/node_modules/

View File

@ -6,8 +6,9 @@ name: budgeteer
steps:
- name: Taskfile.dev
image: hub.javil.eu/budgeteer:dev
pull: true
commands:
- task build
- task
- name: docker
image: plugins/docker
@ -22,6 +23,11 @@ steps:
dockerfile: build/Dockerfile
tags:
- latest
when:
event:
exclude:
- pull_request
image_pull_secrets:
- hub.javil.eu

6
.gitignore vendored
View File

@ -4,6 +4,7 @@
# Unignore all with extensions
!*.*
!Dockerfile
# Unignore all dirs
!*/
@ -86,3 +87,8 @@ GitHub.sublime-settings
!.vscode/*.code-snippets
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,sublimetext,go
node_modules
.DS_Store
dist
dist-ssr
*.local

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"files.exclude": {
"**/node_modules": true,
"**/vendor": true
}
}

23
.woodpecker.yml Normal file
View File

@ -0,0 +1,23 @@
pipeline:
build:
name: Taskfile.dev
image: hub.javil.eu/budgeteer:dev
pull: true
commands:
- task
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,3 +0,0 @@
FROM golang:1.17
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
RUN go install github.com/go-task/task/v3/cmd/task@latest

View File

@ -12,7 +12,7 @@ docker:
WORKDIR /app
COPY +build/budgeteer .
ENTRYPOINT ["/app/budgeteer"]
SAVE IMAGE budgeteer:latest
SAVE IMAGE hub.javil.eu/budgeteer:latest
run:
LOCALLY

View File

@ -1,3 +1,20 @@
# Budgeteer
Budgeting Web-Application
## Data structure
1 User
N Budgets
AccountID[]
CategoryID[]
PayeeID[]
N Accounts
TransactionID[]
N Categories
AssignmentID[]
N Payees
N Transactions
N Assignments

View File

@ -1,9 +1,12 @@
version: '3'
vars:
IMAGE_NAME: hub.javil.eu/budgeteer
tasks:
default:
cmds:
- task: build
- task: build-prod
sqlc:
desc: sqlc code generation
@ -27,7 +30,6 @@ tasks:
build:
desc: Build budgeteer
deps: [gomod, sqlc]
sources:
- ./go.mod
- ./go.sum
@ -37,7 +39,7 @@ tasks:
- ./http/*.go
- ./jwt/*.go
- ./postgres/*.go
- ./web/**/*
- ./web/dist/**/*
- ./postgres/schema/*
generates:
- build/budgeteer{{exeExt}}
@ -46,15 +48,54 @@ tasks:
cmds:
- go build -o ./build/budgeteer{{exeExt}} ./cmd/budgeteer
build-dev:
desc: Build budgeteer in dev mode
deps: [gomod, sqlc]
cmds:
- task: build
build-prod:
desc: Build budgeteer in prod mode
deps: [gomod, sqlc, frontend]
cmds:
- task: build
frontend:
desc: Build vue frontend
dir: web
sources:
- web/src/**/*
generates:
- web/dist/**/*
cmds:
- yarn
- yarn build
docker:
desc: Build budgeeter:latest
deps: [build]
deps: [build-prod]
sources:
- ./build/budgeteer
- ./build/budgeteer{{exeExt}}
- ./build/Dockerfile
cmds:
- docker build -t budgeteer:latest -t hub.javil.eu/budgeteer:latest ./build
- docker build -t {{.IMAGE_NAME}}:latest ./build
- docker push {{.IMAGE_NAME}}:latest
dev-docker:
desc: Build budgeeter:dev
sources:
- ./docker/Dockerfile
cmds:
- docker build -t {{.IMAGE_NAME}}:dev . -f docker/Dockerfile
- docker push {{.IMAGE_NAME}}:dev
run:
desc: Start budgeteer
deps: [build-dev]
cmds:
- ./build/budgeteer{{exeExt}}
rundocker:
desc: Start docker-compose
deps: [docker]
cmds:

View File

@ -16,19 +16,16 @@ func main() {
log.Fatalf("Could not load config: %v", err)
}
bv := &bcrypt.Verifier{}
q, err := postgres.Connect(cfg.DatabaseHost, cfg.DatabaseUser, cfg.DatabasePassword, cfg.DatabaseName)
q, err := postgres.Connect("pgx", cfg.DatabaseConnection)
if err != nil {
log.Fatalf("Failed connecting to DB: %v", err)
}
tv := &jwt.TokenVerifier{}
h := &http.Handler{
Service: q,
TokenVerifier: tv,
CredentialsVerifier: bv,
TokenVerifier: &jwt.TokenVerifier{},
CredentialsVerifier: &bcrypt.Verifier{},
}
h.Serve()
}

View File

@ -6,23 +6,13 @@ import (
// Config contains all needed configurations
type Config struct {
DatabaseUser string
DatabaseHost string
DatabasePassword string
DatabaseName string
DatabaseConnection string
}
// LoadConfig from path
func LoadConfig() (*Config, error) {
configuration := Config{
DatabaseUser: os.Getenv("BUDGETEER_DB_USER"),
DatabaseHost: os.Getenv("BUDGETEER_DB_HOST"),
DatabasePassword: os.Getenv("BUDGETEER_DB_PASS"),
DatabaseName: os.Getenv("BUDGETEER_DB_NAME"),
}
if configuration.DatabaseName == "" {
configuration.DatabaseName = "budgeteer"
DatabaseConnection: os.Getenv("BUDGETEER_DB"),
}
return &configuration, nil

42
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,42 @@
version: '3.7'
services:
app:
image: hub.javil.eu/budgeteer:dev
container_name: budgeteer
stdin_open: true # docker run -i
tty: true # docker run -t
ports:
- 1323:1323
- 3000:3000
user: '1000'
volumes:
- ~/budgeteer:/src
- ~/.gitconfig:/.gitconfig
- ~/.go:/go
- ~/.cache:/.cache
environment:
BUDGETEER_DB: postgres://budgeteer:budgeteer@db:5432/budgeteer
depends_on:
- db
db:
image: postgres:14
ports:
- 5432:5432
volumes:
- db:/var/lib/postgresql/data
environment:
POSTGRES_USER: budgeteer
POSTGRES_PASSWORD: budgeteer
POSTGRES_DBE: budgeteer
adminer:
image: adminer
ports:
- 1424:8080
depends_on:
- db
volumes:
db:

View File

@ -2,7 +2,7 @@ version: '3.7'
services:
app:
image: budgeteer:latest
image: hub.javil.eu/budgeteer:latest
container_name: budgeteer
ports:
- 1323:1323

16
docker/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM alpine as godeps
RUN apk add go
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
RUN go install github.com/go-task/task/v3/cmd/task@latest
FROM alpine
RUN apk add go
RUN apk add nodejs yarn bash curl git git-perl tmux
ADD docker/build.sh /
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /usr/local/bin/
RUN yarn global add @vue/cli
ENV PATH="/root/.yarn/bin/:${PATH}"
WORKDIR /src
ADD web/package.json /src/web/
RUN yarn
CMD /build.sh

7
docker/build.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/sh
tmux new-session -d -s watch 'cd web; yarn dev'
tmux split-window;
tmux send 'task -w run' ENTER;
tmux split-window;
tmux a;

2
go.mod
View File

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

2
go.sum
View File

@ -4,6 +4,8 @@ github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/ClickHouse/clickhouse-go v1.5.1/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI=
github.com/DATA-DOG/go-txdb v0.1.5 h1:kKzz+LYk9qw1+fMyo8/9yDQiNXrJ2HbfX/TY61HkkB4=
github.com/DATA-DOG/go-txdb v0.1.5/go.mod h1:DhAhxMXZpUJVGnT+p9IbzJoRKvlArO2pkHjnGX7o0n0=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=

View File

@ -8,20 +8,11 @@ import (
"github.com/google/uuid"
)
type AccountData struct {
AlwaysNeededData
Account *postgres.Account
Categories []postgres.GetCategoriesRow
Transactions []postgres.GetTransactionsForAccountRow
}
func (h *Handler) account(c *gin.Context) {
data := c.MustGet("data").(AlwaysNeededData)
func (h *Handler) transactionsForAccount(c *gin.Context) {
accountID := c.Param("accountid")
accountUUID, err := uuid.Parse(accountID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
c.AbortWithError(http.StatusBadRequest, err)
return
}
@ -31,24 +22,16 @@ func (h *Handler) account(c *gin.Context) {
return
}
categories, err := h.Service.GetCategories(c.Request.Context(), data.Budget.ID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
transactions, err := h.Service.GetTransactionsForAccount(c.Request.Context(), accountUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
d := AccountData{
data,
&account,
categories,
transactions,
c.JSON(http.StatusOK, TransactionsResponse{account, transactions})
}
c.HTML(http.StatusOK, "account.html", d)
type TransactionsResponse struct {
Account postgres.Account
Transactions []postgres.GetTransactionsForAccountRow
}

79
http/account_test.go Normal file
View File

@ -0,0 +1,79 @@
package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/jwt"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
txdb "github.com/DATA-DOG/go-txdb"
)
func init() {
txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test")
}
func TestListTimezonesHandler(t *testing.T) {
db, err := postgres.Connect("pgtx", "example")
if err != nil {
t.Errorf("could not connect to db: %s", err)
return
}
h := Handler{
Service: db,
TokenVerifier: &jwt.TokenVerifier{},
CredentialsVerifier: &bcrypt.Verifier{},
}
rr := httptest.NewRecorder()
c, engine := gin.CreateTestContext(rr)
h.LoadRoutes(engine)
t.Run("RegisterUser", func(t *testing.T) {
c.Request, err = http.NewRequest(http.MethodPost, "/api/v1/user/register", strings.NewReader(`{"password":"pass","email":"info@example.com","name":"Test"}`))
if err != nil {
t.Errorf("error creating request: %s", err)
return
}
h.registerPost(c)
if rr.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK)
}
var response LoginResponse
err = json.NewDecoder(rr.Body).Decode(&response)
if err != nil {
t.Error(err.Error())
t.Error("Error registering")
}
if len(response.Token) == 0 {
t.Error("Did not get a token")
}
})
t.Run("GetTransactions", func(t *testing.T) {
c.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil)
if rr.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK)
}
var response TransactionsResponse
err = json.NewDecoder(rr.Body).Decode(&response)
if err != nil {
t.Error(err.Error())
t.Error("Error retreiving list of transactions.")
}
if len(response.Transactions) == 0 {
t.Error("Did not get any transactions.")
}
})
}

View File

@ -1,19 +0,0 @@
package http
import (
"net/http"
"github.com/gin-gonic/gin"
)
type AccountsData struct {
AlwaysNeededData
}
func (h *Handler) accounts(c *gin.Context) {
d := AccountsData{
c.MustGet("data").(AlwaysNeededData),
}
c.HTML(http.StatusOK, "accounts.html", d)
}

View File

@ -9,18 +9,7 @@ import (
"github.com/pressly/goose/v3"
)
type AdminData struct {
}
func (h *Handler) admin(c *gin.Context) {
d := AdminData{}
c.HTML(http.StatusOK, "admin.html", d)
}
func (h *Handler) clearDatabase(c *gin.Context) {
d := AdminData{}
if err := goose.Reset(h.Service.DB, "schema"); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
}
@ -28,30 +17,26 @@ func (h *Handler) clearDatabase(c *gin.Context) {
if err := goose.Up(h.Service.DB, "schema"); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
}
c.HTML(http.StatusOK, "admin.html", d)
}
type SettingsData struct {
AlwaysNeededData
}
func (h *Handler) settings(c *gin.Context) {
d := SettingsData{
c.MustGet("data").(AlwaysNeededData),
}
c.HTML(http.StatusOK, "settings.html", d)
}
func (h *Handler) clearBudget(c *gin.Context) {
func (h *Handler) deleteBudget(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
c.AbortWithStatus(http.StatusBadRequest)
return
}
h.clearBudgetData(c, budgetUUID)
err = h.Service.DeleteBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
func (h *Handler) clearBudgetData(c *gin.Context, budgetUUID uuid.UUID) {
rows, err := h.Service.DeleteAllAssignments(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
@ -69,6 +54,17 @@ func (h *Handler) clearBudget(c *gin.Context) {
fmt.Printf("Deleted %d transactions\n", rows)
}
func (h *Handler) clearBudget(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatus(http.StatusBadRequest)
return
}
h.clearBudgetData(c, budgetUUID)
}
func (h *Handler) cleanNegativeBudget(c *gin.Context) {
/*budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
@ -113,5 +109,4 @@ func (h *Handler) cleanNegativeBudget(c *gin.Context) {
break
}
}*/
}

View File

@ -1,57 +0,0 @@
package http
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AlwaysNeededData struct {
Budget postgres.Budget
Accounts []postgres.GetAccountsWithBalanceRow
OnBudgetAccounts []postgres.GetAccountsWithBalanceRow
OffBudgetAccounts []postgres.GetAccountsWithBalanceRow
}
func (h *Handler) getImportantData(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
c.Abort()
return
}
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
accounts, err := h.Service.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
var onBudgetAccounts, offBudgetAccounts []postgres.GetAccountsWithBalanceRow
for _, account := range accounts {
if account.OnBudget {
onBudgetAccounts = append(onBudgetAccounts, account)
} else {
offBudgetAccounts = append(offBudgetAccounts, account)
}
}
base := AlwaysNeededData{
Accounts: accounts,
OnBudgetAccounts: onBudgetAccounts,
OffBudgetAccounts: offBudgetAccounts,
Budget: budget,
}
c.Set("data", base)
c.Next()
}

54
http/autocomplete.go Normal file
View File

@ -0,0 +1,54 @@
package http
import (
"fmt"
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func (h *Handler) autocompleteCategories(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL"))
return
}
query := c.Request.URL.Query().Get("s")
searchParams := postgres.SearchCategoriesParams{
BudgetID: budgetUUID,
Search: "%" + query + "%",
}
categories, err := h.Service.SearchCategories(c.Request.Context(), searchParams)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, categories)
}
func (h *Handler) autocompletePayee(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL"))
return
}
query := c.Request.URL.Query().Get("s")
searchParams := postgres.SearchPayeesParams{
BudgetID: budgetUUID,
Search: query + "%",
}
payees, err := h.Service.SearchPayees(c.Request.Context(), searchParams)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, payees)
}

View File

@ -1,64 +1,36 @@
package http
import (
"fmt"
"net/http"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AllAccountsData struct {
AlwaysNeededData
Account *postgres.Account
Categories []postgres.GetCategoriesRow
Transactions []postgres.GetTransactionsForBudgetRow
}
func (h *Handler) allAccounts(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
return
}
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
transactions, err := h.Service.GetTransactionsForBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
d := AllAccountsData{
c.MustGet("data").(AlwaysNeededData),
&postgres.Account{
Name: "All accounts",
},
categories,
transactions,
}
c.HTML(http.StatusOK, "account.html", d)
type newBudgetInformation struct {
Name string `json:"name"`
}
func (h *Handler) newBudget(c *gin.Context) {
budgetName, succ := c.GetPostForm("name")
if !succ {
c.AbortWithStatus(http.StatusNotAcceptable)
var newBudget newBudgetInformation
err := c.BindJSON(&newBudget)
if err != nil {
c.AbortWithError(http.StatusNotAcceptable, err)
return
}
if newBudget.Name == "" {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("Budget name is needed"))
return
}
userID := c.MustGet("token").(budgeteer.Token).GetID()
_, err := h.Service.NewBudget(c.Request.Context(), budgetName, userID)
budget, err := h.Service.NewBudget(c.Request.Context(), newBudget.Name, userID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, budget)
}

View File

@ -8,17 +8,9 @@ import (
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type BudgetingData struct {
AlwaysNeededData
Categories []CategoryWithBalance
AvailableBalance float64
Date time.Time
Next time.Time
Previous time.Time
}
func getFirstOfMonth(year, month int, location *time.Location) time.Time {
return time.Date(year, time.Month(month), 1, 0, 0, 0, 0, location)
}
@ -32,10 +24,10 @@ func getFirstOfMonthTime(date time.Time) time.Time {
type CategoryWithBalance struct {
*postgres.GetCategoriesRow
Available float64
AvailableLastMonth float64
Activity float64
Assigned float64
Available postgres.Numeric
AvailableLastMonth postgres.Numeric
Activity postgres.Numeric
Assigned postgres.Numeric
}
func getDate(c *gin.Context) (time.Time, error) {
@ -59,9 +51,19 @@ func getDate(c *gin.Context) (time.Time, error) {
return getFirstOfMonth(year, month, time.Now().Location()), nil
}
func (h *Handler) budgeting(c *gin.Context) {
alwaysNeededData := c.MustGet("data").(AlwaysNeededData)
budgetUUID := alwaysNeededData.Budget.ID
func (h *Handler) budgetingForMonth(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL"))
return
}
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
firstOfMonth, err := getDate(c)
if err != nil {
@ -69,17 +71,13 @@ func (h *Handler) budgeting(c *gin.Context) {
return
}
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
firstOfPreviousMonth := firstOfMonth.AddDate(0, -1, 0)
d := BudgetingData{
AlwaysNeededData: alwaysNeededData,
Date: firstOfMonth,
Next: firstOfNextMonth,
Previous: firstOfPreviousMonth,
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("load balances: %w", err))
@ -87,16 +85,14 @@ func (h *Handler) budgeting(c *gin.Context) {
}
// skip everything in the future
categoriesWithBalance, moneyUsed, err := h.calculateBalances(c, alwaysNeededData.Budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
categoriesWithBalance, moneyUsed, err := h.calculateBalances(c, budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
if err != nil {
return
}
d.Categories = categoriesWithBalance
data := c.MustGet("data").(AlwaysNeededData)
var availableBalance float64 = 0
availableBalance := postgres.NewZeroNumeric()
for _, cat := range categories {
if cat.ID != data.Budget.IncomeCategoryID {
if cat.ID != budget.IncomeCategoryID {
continue
}
availableBalance = moneyUsed
@ -110,29 +106,68 @@ func (h *Handler) budgeting(c *gin.Context) {
continue
}
availableBalance += bal.Transactions.GetFloat64()
availableBalance = availableBalance.Add(bal.Transactions)
}
}
d.AvailableBalance = availableBalance
data := struct {
Categories []CategoryWithBalance
AvailableBalance postgres.Numeric
}{categoriesWithBalance, availableBalance}
c.JSON(http.StatusOK, data)
c.HTML(http.StatusOK, "budgeting.html", d)
}
func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, float64, error) {
func (h *Handler) budgeting(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL"))
return
}
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
accounts, err := h.Service.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
data := struct {
Accounts []postgres.GetAccountsWithBalanceRow
Budget postgres.Budget
}{accounts, budget}
c.JSON(http.StatusOK, data)
}
func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, postgres.Numeric, error) {
categoriesWithBalance := []CategoryWithBalance{}
hiddenCategory := CategoryWithBalance{
GetCategoriesRow: &postgres.GetCategoriesRow{
Name: "",
Group: "Hidden Categories",
},
Available: postgres.NewZeroNumeric(),
AvailableLastMonth: postgres.NewZeroNumeric(),
Activity: postgres.NewZeroNumeric(),
Assigned: postgres.NewZeroNumeric(),
}
var moneyUsed float64 = 0
moneyUsed := postgres.NewZeroNumeric()
for i := range categories {
cat := &categories[i]
categoryWithBalance := CategoryWithBalance{
GetCategoriesRow: cat,
Available: postgres.NewZeroNumeric(),
AvailableLastMonth: postgres.NewZeroNumeric(),
Activity: postgres.NewZeroNumeric(),
Assigned: postgres.NewZeroNumeric(),
}
for _, bal := range cumultativeBalances {
if bal.CategoryID != cat.ID {
@ -143,29 +178,28 @@ func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firs
continue
}
moneyUsed -= bal.Assignments.GetFloat64()
categoryWithBalance.Available += bal.Assignments.GetFloat64()
categoryWithBalance.Available += bal.Transactions.GetFloat64()
if categoryWithBalance.Available < 0 && bal.Date.Before(firstOfMonth) {
moneyUsed += categoryWithBalance.Available
categoryWithBalance.Available = 0
moneyUsed = moneyUsed.Sub(bal.Assignments)
categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Assignments)
categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Transactions)
if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) {
moneyUsed = moneyUsed.Add(categoryWithBalance.Available)
categoryWithBalance.Available = postgres.NewZeroNumeric()
}
if bal.Date.Before(firstOfMonth) {
categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available
} else if bal.Date.Before(firstOfNextMonth) {
categoryWithBalance.Activity = bal.Transactions.GetFloat64()
categoryWithBalance.Assigned = bal.Assignments.GetFloat64()
categoryWithBalance.Activity = bal.Transactions
categoryWithBalance.Assigned = bal.Assignments
}
}
// do not show hidden categories
if cat.Group == "Hidden Categories" {
hiddenCategory.Available += categoryWithBalance.Available
hiddenCategory.AvailableLastMonth += categoryWithBalance.AvailableLastMonth
hiddenCategory.Activity += categoryWithBalance.Activity
hiddenCategory.Assigned += categoryWithBalance.Assigned
hiddenCategory.Available = hiddenCategory.Available.Add(categoryWithBalance.Available)
hiddenCategory.AvailableLastMonth = hiddenCategory.AvailableLastMonth.Add(categoryWithBalance.AvailableLastMonth)
hiddenCategory.Activity = hiddenCategory.Activity.Add(categoryWithBalance.Activity)
hiddenCategory.Assigned = hiddenCategory.Assigned.Add(categoryWithBalance.Assigned)
continue
}

View File

@ -18,7 +18,7 @@ func (h *Handler) dashboard(c *gin.Context) {
d := DashboardData{
Budgets: budgets,
}
c.HTML(http.StatusOK, "dashboard.html", d)
c.JSON(http.StatusOK, d)
}
type DashboardData struct {

View File

@ -1,10 +1,12 @@
package http
import (
"errors"
"io"
"io/fs"
"net/http"
"path"
"strings"
"time"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/bcrypt"
@ -19,54 +21,38 @@ type Handler struct {
Service *postgres.Database
TokenVerifier budgeteer.TokenVerifier
CredentialsVerifier *bcrypt.Verifier
StaticFS http.FileSystem
}
const (
expiration = 72
authCookie = "authentication"
)
// Serve starts the HTTP Server
// Serve starts the http server
func (h *Handler) Serve() {
router := gin.Default()
router.FuncMap["now"] = time.Now
templates, err := NewTemplates(router.FuncMap)
if err != nil {
panic(err)
h.LoadRoutes(router)
router.Run(":1323")
}
router.HTMLRender = templates
static, err := fs.Sub(web.Static, "static")
// LoadRoutes initializes all the routes
func (h *Handler) LoadRoutes(router *gin.Engine) {
static, err := fs.Sub(web.Static, "dist")
if err != nil {
panic("couldn't open static files")
}
router.Use(enableCachingForStaticFiles())
router.StaticFS("/static", http.FS(static))
h.StaticFS = http.FS(static)
router.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", nil) })
router.GET("/login", h.login)
router.GET("/register", h.register)
router.Use(enableCachingForStaticFiles())
router.NoRoute(h.ServeStatic)
withLogin := router.Group("")
withLogin.Use(h.verifyLoginWithRedirect)
withLogin.GET("/dashboard", h.dashboard)
withLogin.GET("/admin", h.admin)
withLogin.GET("/admin/clear-database", h.clearDatabase)
withBudget := router.Group("")
withBudget.Use(h.verifyLoginWithRedirect)
withBudget.Use(h.getImportantData)
withBudget.GET("/budget/:budgetid", h.budgeting)
withBudget.Use(h.verifyLoginWithForbidden)
withBudget.GET("/budget/:budgetid/:year/:month", h.budgeting)
withBudget.GET("/budget/:budgetid/all-accounts", h.allAccounts)
withBudget.GET("/budget/:budgetid/accounts", h.accounts)
withBudget.GET("/budget/:budgetid/account/:accountid", h.account)
withBudget.GET("/budget/:budgetid/settings", h.settings)
withBudget.GET("/budget/:budgetid/settings/clear", h.clearBudget)
withBudget.GET("/budget/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
withBudget.GET("/budget/:budgetid/transaction/:transactionid", h.transaction)
api := router.Group("/api/v1")
@ -76,10 +62,17 @@ func (h *Handler) Serve() {
unauthenticated.POST("/register", h.registerPost)
authenticated := api.Group("")
authenticated.Use(h.verifyLoginWithRedirect)
user := authenticated.Group("/user")
user.GET("/logout", logout)
authenticated.Use(h.verifyLoginWithForbidden)
authenticated.GET("/dashboard", h.dashboard)
authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount)
authenticated.GET("/admin/clear-database", h.clearDatabase)
authenticated.GET("/budget/:budgetid", h.budgeting)
authenticated.GET("/budget/:budgetid/:year/:month", h.budgetingForMonth)
authenticated.GET("/budget/:budgetid/autocomplete/payees", h.autocompletePayee)
authenticated.GET("/budget/:budgetid/autocomplete/categories", h.autocompleteCategories)
authenticated.DELETE("/budget/:budgetid", h.deleteBudget)
authenticated.POST("/budget/:budgetid/import/ynab", h.importYNAB)
authenticated.POST("/budget/:budgetid/settings/clear", h.clearBudget)
budget := authenticated.Group("/budget")
budget.POST("/new", h.newBudget)
@ -87,9 +80,35 @@ func (h *Handler) Serve() {
transaction := authenticated.Group("/transaction")
transaction.POST("/new", h.newTransaction)
transaction.POST("/:transactionid", h.newTransaction)
transaction.POST("/import/ynab", h.importYNAB)
}
func (h *Handler) ServeStatic(c *gin.Context) {
h.ServeStaticFile(c, c.Request.URL.Path)
}
router.Run(":1323")
func (h *Handler) ServeStaticFile(c *gin.Context, fullPath string) {
file, err := h.StaticFS.Open(fullPath)
if errors.Is(err, fs.ErrNotExist) {
h.ServeStaticFile(c, path.Join("/", "/index.html"))
return
}
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
stat, err := file.Stat()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
if stat.IsDir() {
h.ServeStaticFile(c, path.Join(fullPath, "index.html"))
return
}
http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), file.(io.ReadSeeker))
}
func enableCachingForStaticFiles() gin.HandlerFunc {

30
http/json-date.go Normal file
View File

@ -0,0 +1,30 @@
package http
import (
"encoding/json"
"strings"
"time"
)
type JSONDate time.Time
// Implement Marshaler and Unmarshaler interface
func (j *JSONDate) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), "\"")
t, err := time.Parse("2006-01-02", s)
if err != nil {
return err
}
*j = JSONDate(t)
return nil
}
func (j JSONDate) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Time(j))
}
// Maybe a Format function for printing your date
func (j JSONDate) Format(s string) string {
t := time.Time(j)
return t.Format(s)
}

View File

@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net/http"
"time"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres"
@ -12,20 +11,32 @@ import (
)
func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) {
tokenString, err := c.Cookie(authCookie)
if err != nil {
return nil, fmt.Errorf("get cookie: %w", err)
tokenString := c.GetHeader("Authorization")
if len(tokenString) < 8 {
return nil, fmt.Errorf("no authorization header supplied")
}
tokenString = tokenString[7:]
token, err := h.TokenVerifier.VerifyToken(tokenString)
if err != nil {
c.SetCookie(authCookie, "", -1, "", "", false, false)
return nil, fmt.Errorf("verify token '%s': %w", tokenString, err)
}
return token, nil
}
func (h *Handler) verifyLoginWithForbidden(c *gin.Context) {
token, err := h.verifyLogin(c)
if err != nil {
//c.Header("WWW-Authenticate", "Bearer")
c.AbortWithError(http.StatusForbidden, err)
return
}
c.Set("token", token)
c.Next()
}
func (h *Handler) verifyLoginWithRedirect(c *gin.Context) {
token, err := h.verifyLogin(c)
if err != nil {
@ -38,43 +49,25 @@ func (h *Handler) verifyLoginWithRedirect(c *gin.Context) {
c.Next()
}
func (h *Handler) login(c *gin.Context) {
if _, err := h.verifyLogin(c); err == nil {
c.Redirect(http.StatusTemporaryRedirect, "/dashboard")
return
}
c.HTML(http.StatusOK, "login.html", nil)
}
func (h *Handler) register(c *gin.Context) {
if _, err := h.verifyLogin(c); err == nil {
c.Redirect(http.StatusTemporaryRedirect, "/dashboard")
return
}
c.HTML(http.StatusOK, "register.html", nil)
}
func logout(c *gin.Context) {
clearLogin(c)
}
func clearLogin(c *gin.Context) {
c.SetCookie(authCookie, "", -1, "", "", false, true)
type loginInformation struct {
Password string `json:"password"`
User string `json:"user"`
}
func (h *Handler) loginPost(c *gin.Context) {
username, _ := c.GetPostForm("username")
password, _ := c.GetPostForm("password")
var login loginInformation
err := c.BindJSON(&login)
if err != nil {
return
}
user, err := h.Service.GetUserByUsername(c.Request.Context(), username)
user, err := h.Service.GetUserByUsername(c.Request.Context(), login.User)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
if err = h.CredentialsVerifier.Verify(password, user.Password); err != nil {
if err = h.CredentialsVerifier.Verify(login.Password, user.Password); err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
@ -86,37 +79,68 @@ func (h *Handler) loginPost(c *gin.Context) {
go h.Service.UpdateLastLogin(context.Background(), user.ID)
maxAge := (int)((expiration * time.Hour).Seconds())
c.SetCookie(authCookie, t, maxAge, "", "", false, true)
c.JSON(http.StatusOK, map[string]string{
"token": t,
})
}
func (h *Handler) registerPost(c *gin.Context) {
email, _ := c.GetPostForm("email")
password, _ := c.GetPostForm("password")
name, _ := c.GetPostForm("name")
_, err := h.Service.GetUserByUsername(c.Request.Context(), email)
if err == nil {
c.AbortWithStatus(http.StatusUnauthorized)
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
if err != nil {
return
}
hash, err := h.CredentialsVerifier.Hash(password)
c.JSON(http.StatusOK, LoginResponse{t, user, budgets})
}
type LoginResponse struct {
Token string
User postgres.User
Budgets []postgres.Budget
}
type registerInformation struct {
Password string `json:"password"`
Email string `json:"email"`
Name string `json:"name"`
}
func (h *Handler) registerPost(c *gin.Context) {
var register registerInformation
c.BindJSON(&register)
if register.Email == "" || register.Password == "" || register.Name == "" {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("e-mail, password and name are required"))
return
}
_, err := h.Service.GetUserByUsername(c.Request.Context(), register.Email)
if err == nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("email is already taken"))
return
}
hash, err := h.CredentialsVerifier.Hash(register.Password)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
c.AbortWithError(http.StatusBadRequest, err)
return
}
createUser := postgres.CreateUserParams{
Name: name,
Name: register.Name,
Password: hash,
Email: email,
Email: register.Email,
}
_, err = h.Service.CreateUser(c.Request.Context(), createUser)
user, err := h.Service.CreateUser(c.Request.Context(), createUser)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
}
t, err := h.TokenVerifier.CreateToken(&user)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
}
go h.Service.UpdateLastLogin(context.Background(), user.ID)
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
if err != nil {
return
}
c.JSON(http.StatusOK, LoginResponse{t, user, budgets})
}

View File

@ -1,45 +0,0 @@
package http
import (
"fmt"
"html/template"
"io/fs"
"git.javil.eu/jacob1123/budgeteer/web"
"github.com/gin-gonic/gin/render"
)
type Templates struct {
templates map[string]*template.Template
}
func NewTemplates(funcMap template.FuncMap) (*Templates, error) {
templates, err := fs.Glob(web.Templates, "*.tpl")
if err != nil {
return nil, fmt.Errorf("glob: %w", err)
}
result := &Templates{
templates: make(map[string]*template.Template, 0),
}
pages, err := fs.Glob(web.Templates, "*.html")
for _, page := range pages {
allTemplates := append(templates, page)
tpl, err := template.New(page).Funcs(funcMap).ParseFS(web.Templates, allTemplates...)
fmt.Printf("page: %s, templates: %v\n", page, templates)
if err != nil {
return nil, err
}
result.templates[page] = tpl
}
return result, nil
}
func (tpl *Templates) Instance(name string, obj interface{}) render.Render {
return render.HTML{
Template: tpl.templates[name],
Name: name,
Data: obj,
}
}

View File

@ -1,62 +0,0 @@
package http
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type TransactionData struct {
AlwaysNeededData
Transaction *postgres.Transaction
Account *postgres.Account
Categories []postgres.GetCategoriesRow
Payees []postgres.Payee
}
func (h *Handler) transaction(c *gin.Context) {
data := c.MustGet("data").(AlwaysNeededData)
transactionID := c.Param("transactionid")
transactionUUID, err := uuid.Parse(transactionID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
return
}
transaction, err := h.Service.GetTransaction(c.Request.Context(), transactionUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
account, err := h.Service.GetAccount(c.Request.Context(), transaction.AccountID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
categories, err := h.Service.GetCategories(c.Request.Context(), data.Budget.ID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
payees, err := h.Service.GetPayees(c.Request.Context(), data.Budget.ID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
d := TransactionData{
data,
&transaction,
&account,
categories,
payees,
}
c.HTML(http.StatusOK, "transaction.html", d)
}

View File

@ -7,63 +7,54 @@ import (
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type NewTransactionPayload struct {
Date JSONDate `json:"date"`
Payee struct {
ID uuid.NullUUID
Name string
} `json:"payee"`
Category struct {
ID uuid.NullUUID
Name string
} `json:"category"`
Memo string `json:"memo"`
Amount string `json:"amount"`
BudgetID uuid.UUID `json:"budget_id"`
AccountID uuid.UUID `json:"account_id"`
State string `json:"state"`
}
func (h *Handler) newTransaction(c *gin.Context) {
transactionMemo, _ := c.GetPostForm("memo")
transactionAccountID, err := getUUID(c, "account_id")
var payload NewTransactionPayload
err := c.BindJSON(&payload)
if err != nil {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("account_id: %w", err))
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactionCategoryID, err := getNullUUIDFromForm(c, "category_id")
if err != nil {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("category_id: %w", err))
return
}
transactionPayeeID, err := getNullUUIDFromForm(c, "payee_id")
if err != nil {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("payee_id: %w", err))
return
}
transactionDate, succ := c.GetPostForm("date")
if !succ {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("date missing"))
return
}
transactionDateValue, err := time.Parse("2006-01-02", transactionDate)
if err != nil {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("date is not a valid date"))
return
}
transactionAmount, succ := c.GetPostForm("amount")
if !succ {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("amount missing"))
return
}
fmt.Printf("%v\n", payload)
amount := postgres.Numeric{}
amount.Set(transactionAmount)
amount.Set(payload.Amount)
transactionUUID, err := getNullUUIDFromParam(c, "transactionid")
/*transactionUUID, err := getNullUUIDFromParam(c, "transactionid")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("parse transaction id: %w", err))
return
}
}*/
if !transactionUUID.Valid {
//if !transactionUUID.Valid {
new := postgres.CreateTransactionParams{
Memo: transactionMemo,
Date: transactionDateValue,
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
AccountID: transactionAccountID,
PayeeID: transactionPayeeID,
CategoryID: transactionCategoryID,
AccountID: payload.AccountID,
PayeeID: payload.Payee.ID, //TODO handle new payee
CategoryID: payload.Category.ID, //TODO handle new category
Status: postgres.TransactionStatus(payload.State),
}
_, err = h.Service.CreateTransaction(c.Request.Context(), new)
if err != nil {
@ -71,8 +62,8 @@ func (h *Handler) newTransaction(c *gin.Context) {
}
return
}
// }
/*
_, delete := c.GetPostForm("delete")
if delete {
err = h.Service.DeleteTransaction(c.Request.Context(), transactionUUID.UUID)
@ -84,15 +75,15 @@ func (h *Handler) newTransaction(c *gin.Context) {
update := postgres.UpdateTransactionParams{
ID: transactionUUID.UUID,
Memo: transactionMemo,
Date: transactionDateValue,
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
AccountID: transactionAccountID,
PayeeID: transactionPayeeID,
CategoryID: transactionCategoryID,
PayeeID: payload.Payee.ID, //TODO handle new payee
CategoryID: payload.Category.ID, //TODO handle new category
}
err = h.Service.UpdateTransaction(c.Request.Context(), update)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("update transaction: %w", err))
}
}*/
}

View File

@ -10,7 +10,7 @@ import (
)
func (h *Handler) importYNAB(c *gin.Context) {
budgetID, succ := c.GetPostForm("budget_id")
budgetID, succ := c.Params.Get("budgetid")
if !succ {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
return

View File

@ -87,9 +87,8 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
const getAccountsWithBalance = `-- name: GetAccountsWithBalance :many
SELECT accounts.id, accounts.name, accounts.on_budget, SUM(transactions.amount)::decimal(12,2) as balance
FROM accounts
LEFT JOIN transactions ON transactions.account_id = accounts.id
LEFT JOIN transactions ON transactions.account_id = accounts.id AND transactions.date < NOW()
WHERE accounts.budget_id = $1
AND transactions.date < NOW()
GROUP BY accounts.id, accounts.name
ORDER BY accounts.name
`

View File

@ -34,6 +34,15 @@ func (q *Queries) CreateBudget(ctx context.Context, arg CreateBudgetParams) (Bud
return i, err
}
const deleteBudget = `-- name: DeleteBudget :exec
DELETE FROM budgets WHERE id = $1
`
func (q *Queries) DeleteBudget(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteBudget, id)
return err
}
const getBudget = `-- name: GetBudget :one
SELECT id, name, last_modification, income_category_id FROM budgets
WHERE id = $1

View File

@ -116,3 +116,44 @@ func (q *Queries) GetCategoryGroups(ctx context.Context, budgetID uuid.UUID) ([]
}
return items, nil
}
const searchCategories = `-- name: SearchCategories :many
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = $1
AND categories.name LIKE $2
ORDER BY category_groups.name, categories.name
`
type SearchCategoriesParams struct {
BudgetID uuid.UUID
Search string
}
type SearchCategoriesRow struct {
Name interface{}
ID uuid.UUID
}
func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesParams) ([]SearchCategoriesRow, error) {
rows, err := q.db.QueryContext(ctx, searchCategories, arg.BudgetID, arg.Search)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SearchCategoriesRow
for rows.Next() {
var i SearchCategoriesRow
if err := rows.Scan(&i.Name, &i.ID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -18,9 +18,8 @@ type Database struct {
}
// Connect to a database
func Connect(server string, user string, password string, database string) (*Database, error) {
connString := fmt.Sprintf("postgres://%s:%s@%s/%s", user, password, server, database)
conn, err := sql.Open("pgx", connString)
func Connect(typ string, connString string) (*Database, error) {
conn, err := sql.Open(typ, connString)
if err != nil {
return nil, fmt.Errorf("open connection: %w", err)
}

View File

@ -4,11 +4,32 @@ package postgres
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
)
type TransactionStatus string
const (
TransactionStatusReconciled TransactionStatus = "Reconciled"
TransactionStatusCleared TransactionStatus = "Cleared"
TransactionStatusUncleared TransactionStatus = "Uncleared"
)
func (e *TransactionStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = TransactionStatus(s)
case string:
*e = TransactionStatus(s)
default:
return fmt.Errorf("unsupported scan type for TransactionStatus: %T", src)
}
return nil
}
type Account struct {
ID uuid.UUID
BudgetID uuid.UUID
@ -64,7 +85,8 @@ type Transaction struct {
AccountID uuid.UUID
CategoryID uuid.NullUUID
PayeeID uuid.NullUUID
TransferID uuid.NullUUID
GroupID uuid.NullUUID
Status TransactionStatus
}
type TransactionsByMonth struct {

View File

@ -1,11 +1,20 @@
package postgres
import "github.com/jackc/pgtype"
import (
"fmt"
"math/big"
"github.com/jackc/pgtype"
)
type Numeric struct {
pgtype.Numeric
}
func NewZeroNumeric() Numeric {
return Numeric{pgtype.Numeric{Exp: 0, Int: big.NewInt(0), Status: pgtype.Present, NaN: false}}
}
func (n Numeric) GetFloat64() float64 {
if n.Status != pgtype.Present {
return 0
@ -33,3 +42,88 @@ func (n Numeric) IsZero() bool {
float := n.GetFloat64()
return float == 0
}
func (n Numeric) MatchExp(exp int32) Numeric {
diffExp := n.Exp - exp
factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil)
return Numeric{pgtype.Numeric{
Exp: exp,
Int: big.NewInt(0).Mul(n.Int, factor),
Status: n.Status,
NaN: n.NaN,
}}
}
func (n Numeric) Sub(o Numeric) Numeric {
left := n
right := o
if n.Exp < o.Exp {
right = o.MatchExp(n.Exp)
} else if n.Exp > o.Exp {
left = n.MatchExp(o.Exp)
}
if left.Exp == right.Exp {
return Numeric{pgtype.Numeric{
Exp: left.Exp,
Int: big.NewInt(0).Sub(left.Int, right.Int),
}}
}
panic("Cannot subtract with different exponents")
}
func (n Numeric) Add(o Numeric) Numeric {
left := n
right := o
if n.Exp < o.Exp {
right = o.MatchExp(n.Exp)
} else if n.Exp > o.Exp {
left = n.MatchExp(o.Exp)
}
if left.Exp == right.Exp {
return Numeric{pgtype.Numeric{
Exp: left.Exp,
Int: big.NewInt(0).Add(left.Int, right.Int),
}}
}
panic("Cannot add with different exponents")
}
func (n Numeric) MarshalJSON() ([]byte, error) {
if n.Int.Int64() == 0 {
return []byte("\"0\""), nil
}
s := fmt.Sprintf("%d", n.Int)
bytes := []byte(s)
exp := n.Exp
for exp > 0 {
bytes = append(bytes, byte('0'))
exp--
}
if exp == 0 {
return bytes, nil
}
length := int32(len(bytes))
var bytesWithSeparator []byte
exp = -exp
for length <= exp {
bytes = append(bytes, byte('0'))
length++
}
split := length - exp
bytesWithSeparator = append(bytesWithSeparator, bytes[:split]...)
if split == 1 && n.Int.Int64() < 0 {
bytesWithSeparator = append(bytesWithSeparator, byte('0'))
}
bytesWithSeparator = append(bytesWithSeparator, byte('.'))
bytesWithSeparator = append(bytesWithSeparator, bytes[split:]...)
return bytesWithSeparator, nil
}

View File

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

View File

@ -16,8 +16,7 @@ ORDER BY accounts.name;
-- name: GetAccountsWithBalance :many
SELECT accounts.id, accounts.name, accounts.on_budget, SUM(transactions.amount)::decimal(12,2) as balance
FROM accounts
LEFT JOIN transactions ON transactions.account_id = accounts.id
LEFT JOIN transactions ON transactions.account_id = accounts.id AND transactions.date < NOW()
WHERE accounts.budget_id = $1
AND transactions.date < NOW()
GROUP BY accounts.id, accounts.name
ORDER BY accounts.name;

View File

@ -32,3 +32,6 @@ FROM (
INNER JOIN accounts ON accounts.id = transactions.account_id
WHERE accounts.budget_id = @budget_id
) dates;
-- name: DeleteBudget :exec
DELETE FROM budgets WHERE id = $1;

View File

@ -19,3 +19,11 @@ SELECT categories.*, category_groups.name as group FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = $1
ORDER BY category_groups.name, categories.name;
-- name: SearchCategories :many
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = @budget_id
AND categories.name LIKE @search
ORDER BY category_groups.name, categories.name;
--ORDER BY levenshtein(payees.name, $2);

View File

@ -8,3 +8,10 @@ RETURNING *;
SELECT payees.* FROM payees
WHERE payees.budget_id = $1
ORDER BY name;
-- name: SearchPayees :many
SELECT payees.* FROM payees
WHERE payees.budget_id = @budget_id
AND payees.name LIKE @search
ORDER BY payees.name;
--ORDER BY levenshtein(payees.name, $2);

View File

@ -4,8 +4,8 @@ WHERE id = $1;
-- name: CreateTransaction :one
INSERT INTO transactions
(date, memo, amount, account_id, payee_id, category_id)
VALUES ($1, $2, $3, $4, $5, $6)
(date, memo, amount, account_id, payee_id, category_id, group_id, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: UpdateTransaction :exec
@ -23,7 +23,7 @@ DELETE FROM transactions
WHERE id = $1;
-- name: GetTransactionsForBudget :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount,
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id, transactions.status,
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
@ -35,8 +35,19 @@ ORDER BY transactions.date DESC
LIMIT 200;
-- name: GetTransactionsForAccount :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount,
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status,
accounts.name as account,
COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category,
(
SELECT CONCAT(otherAccounts.name)
FROM transactions otherTransactions
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
WHERE otherTransactions.group_id = transactions.group_id
AND otherTransactions.id != transactions.id
) as transfer_account
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id

View File

@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE transactions ADD COLUMN group_id uuid NULL;
-- +goose Down
ALTER TABLE transactions DROP COLUMN group_id;

View File

@ -0,0 +1,12 @@
-- +goose Up
CREATE TYPE transaction_status AS ENUM (
'Reconciled',
'Cleared',
'Uncleared'
);
ALTER TABLE transactions ADD COLUMN status transaction_status NOT NULL DEFAULT 'Uncleared';
-- +goose Down
ALTER TABLE transactions DROP COLUMN status;
DROP TYPE transaction_status;

View File

@ -0,0 +1,5 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS "fuzzystrmatch";
-- +goose Down
DROP EXTENSION "fuzzystrmatch";

View File

@ -1,6 +0,0 @@
-- +goose Up
ALTER TABLE transactions ADD COLUMN
transfer_id uuid NULL REFERENCES transactions (id);
-- +goose Down
ALTER TABLE transactions DROP COLUMN transfer_id;

View File

@ -12,9 +12,9 @@ import (
const createTransaction = `-- name: CreateTransaction :one
INSERT INTO transactions
(date, memo, amount, account_id, payee_id, category_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, date, memo, amount, account_id, category_id, payee_id, transfer_id
(date, memo, amount, account_id, payee_id, category_id, group_id, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, date, memo, amount, account_id, category_id, payee_id, group_id, status
`
type CreateTransactionParams struct {
@ -24,6 +24,8 @@ type CreateTransactionParams struct {
AccountID uuid.UUID
PayeeID uuid.NullUUID
CategoryID uuid.NullUUID
GroupID uuid.NullUUID
Status TransactionStatus
}
func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (Transaction, error) {
@ -34,6 +36,8 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
arg.AccountID,
arg.PayeeID,
arg.CategoryID,
arg.GroupID,
arg.Status,
)
var i Transaction
err := row.Scan(
@ -44,7 +48,8 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
&i.AccountID,
&i.CategoryID,
&i.PayeeID,
&i.TransferID,
&i.GroupID,
&i.Status,
)
return i, err
}
@ -75,7 +80,7 @@ func (q *Queries) DeleteTransaction(ctx context.Context, id uuid.UUID) error {
}
const getTransaction = `-- name: GetTransaction :one
SELECT id, date, memo, amount, account_id, category_id, payee_id, transfer_id FROM transactions
SELECT id, date, memo, amount, account_id, category_id, payee_id, group_id, status FROM transactions
WHERE id = $1
`
@ -90,7 +95,8 @@ func (q *Queries) GetTransaction(ctx context.Context, id uuid.UUID) (Transaction
&i.AccountID,
&i.CategoryID,
&i.PayeeID,
&i.TransferID,
&i.GroupID,
&i.Status,
)
return i, err
}
@ -130,8 +136,19 @@ func (q *Queries) GetTransactionsByMonthAndCategory(ctx context.Context, budgetI
}
const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount,
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status,
accounts.name as account,
COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category,
(
SELECT CONCAT(otherAccounts.name)
FROM transactions otherTransactions
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
WHERE otherTransactions.group_id = transactions.group_id
AND otherTransactions.id != transactions.id
) as transfer_account
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id
@ -147,10 +164,13 @@ type GetTransactionsForAccountRow struct {
Date time.Time
Memo string
Amount Numeric
GroupID uuid.NullUUID
Status TransactionStatus
Account string
Payee string
CategoryGroup string
Category string
TransferAccount interface{}
}
func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]GetTransactionsForAccountRow, error) {
@ -167,10 +187,13 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
&i.Date,
&i.Memo,
&i.Amount,
&i.GroupID,
&i.Status,
&i.Account,
&i.Payee,
&i.CategoryGroup,
&i.Category,
&i.TransferAccount,
); err != nil {
return nil, err
}
@ -186,7 +209,7 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
}
const getTransactionsForBudget = `-- name: GetTransactionsForBudget :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount,
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id, transactions.status,
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
@ -203,6 +226,8 @@ type GetTransactionsForBudgetRow struct {
Date time.Time
Memo string
Amount Numeric
GroupID uuid.NullUUID
Status TransactionStatus
Account string
Payee string
CategoryGroup string
@ -223,6 +248,8 @@ func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UU
&i.Date,
&i.Memo,
&i.Amount,
&i.GroupID,
&i.Status,
&i.Account,
&i.Payee,
&i.CategoryGroup,

View File

@ -113,6 +113,13 @@ func (ynab *YNABImport) ImportAssignments(r io.Reader) error {
return nil
}
type Transfer struct {
CreateTransactionParams
TransferToAccount *Account
FromAccount string
ToAccount string
}
// ImportTransactions expects a TSV-file as exported by YNAB in the following format:
func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
@ -125,7 +132,7 @@ func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
return fmt.Errorf("could not read from tsv: %w", err)
}
var openTransfers []CreateTransactionParams
var openTransfers []Transfer
count := 0
for _, record := range csvData[1:] {
@ -158,12 +165,23 @@ func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
return fmt.Errorf("could not parse amount from (%s/%s): %w", inflow, outflow, err)
}
statusEnum := TransactionStatusUncleared
status := record[10]
switch status {
case "Cleared":
statusEnum = TransactionStatusCleared
case "Reconciled":
statusEnum = TransactionStatusReconciled
case "Uncleared":
}
transaction := CreateTransactionParams{
Date: date,
Memo: memo,
AccountID: account.ID,
CategoryID: category,
Amount: amount,
Status: statusEnum,
}
payeeName := record[3]
@ -174,8 +192,49 @@ func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
if err != nil {
return fmt.Errorf("Could not get transfer account %s: %w", transferToAccountName, err)
}
openTransfers = append(openTransfers, transaction)
fmt.Printf("Found transfer from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64())
transfer := Transfer{
transaction,
transferToAccount,
accountName,
transferToAccountName,
}
found := false
for i, openTransfer := range openTransfers {
if openTransfer.TransferToAccount.ID != transfer.AccountID {
continue
}
if openTransfer.AccountID != transfer.TransferToAccount.ID {
continue
}
if openTransfer.Amount.GetFloat64() != -1*transfer.Amount.GetFloat64() {
continue
}
fmt.Printf("Matched transfers from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64())
openTransfers[i] = openTransfers[len(openTransfers)-1]
openTransfers = openTransfers[:len(openTransfers)-1]
found = true
groupID := uuid.New()
transfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
openTransfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
_, err = ynab.queries.CreateTransaction(ynab.Context, transfer.CreateTransactionParams)
if err != nil {
return fmt.Errorf("could not save transaction %v: %w", transfer.CreateTransactionParams, err)
}
_, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams)
if err != nil {
return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err)
}
break
}
if !found {
openTransfers = append(openTransfers, transfer)
}
} else {
payeeID, err := ynab.GetPayee(payeeName)
if err != nil {
@ -189,11 +248,17 @@ func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
}
}
//status := record[10]
count++
}
for _, openTransfer := range openTransfers {
fmt.Printf("Saving unmatched transfer from %s to %s on %s over %f as regular transaction\n", openTransfer.FromAccount, openTransfer.ToAccount, openTransfer.Date, openTransfer.Amount.GetFloat64())
_, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams)
if err != nil {
return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err)
}
}
fmt.Printf("Imported %d transactions\n", count)
return nil

View File

@ -1,36 +0,0 @@
{{template "base" .}}
{{define "title"}}{{.Account.Name}}{{end}}
{{define "new"}}
{{template "transaction-new" .}}
{{end}}
{{define "main"}}
<div class="budget-item">
<a href="#newtransactionmodal" data-bs-toggle="modal" data-bs-target="#newtransactionmodal">New Transaction</a>
<span class="time"></span>
</div>
<table class="container col-lg-12" id="content">
{{range .Transactions}}
<tr class="{{if .Date.After now}}future{{end}}">
<td>{{.Date.Format "02.01.2006"}}</td>
<td>
{{.Account}}
</td>
<td>
{{.Payee}}
</td>
<td>
{{if .CategoryGroup}}
{{.CategoryGroup}} : {{.Category}}
{{end}}
</td>
<td>
<a href="/budget/{{$.Budget.ID}}/transaction/{{.ID}}">{{.Memo}}</a>
</td>
{{template "amount-cell" .Amount}}
</tr>
{{end}}
</table>
{{end}}

View File

@ -1,17 +0,0 @@
{{define "title"}}
Accounts
{{end}}
{{define "new"}}
{{end}}
{{template "base" .}}
{{define "main"}}
{{range .Accounts}}
<div class="budget-item">
<a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
<span class="time">{{printf "%.2f" .Balance.GetFloat64}}</span>
</div>
{{end}}
{{end}}

View File

@ -1,23 +0,0 @@
{{define "amount"}}
<span class="right {{if .IsZero}}zero{{else if not .IsPositive}}negative{{end}}">
{{printf "%.2f" .GetFloat64}}
</span>
{{end}}
{{define "amount-cell"}}
<td class="right {{if .IsZero}}zero{{else if not .IsPositive}}negative{{end}}">
{{printf "%.2f" .GetFloat64}}
</td>
{{end}}
{{define "amountf64"}}
<span class="right {{if eq . 0.0}}zero{{else if lt . 0.0}}negative{{end}}">
{{printf "%.2f" .}}
</span>
{{end}}
{{define "amountf64-cell"}}
<td class="right {{if eq . 0.0}}zero{{else if lt . 0.0}}negative{{end}}">
{{printf "%.2f" .}}
</td>
{{end}}

View File

@ -1,39 +0,0 @@
{{define "base"}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link href="/static/css/bootstrap.min.css" rel="stylesheet" />
<link href="/static/css/main.css" rel="stylesheet" />
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://malsup.github.io/jquery.form.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
<script src="/static/js/main.js"></script>
<title>{{template "title" .}} - Budgeteer</title>
{{block "more-head" .}}{{end}}
</head>
<body>
<div id="wrapper">
<div id="sidebar">
{{block "sidebar" .}}
{{template "budget-sidebar" .}}
{{end}}
</div>
<div id="content">
<div class="container" id="head">
{{template "title" .}}
</div>
<div class="container col-lg-12" id="content">
{{template "main" .}}
</div>
{{block "new" .}}{{end}}
</div>
</div>
</body>
</html>
{{end}}

View File

@ -1,40 +0,0 @@
{{define "budget-new"}}
<div id="newbudgetmodal" class="modal fade" role="dialog">
<div class="modal-dialog" role="document">
<script>
$(document).ready(function () {
$('#errorcreatingbudget').hide();
$('#newbudgetform').ajaxForm({
error: function() {
$('#errorcreatingbudget').show();
}
});
});
</script>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Budget</h5>
<button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form id="newbudgetform" action="/api/v1/budget/new" method="POST">
<div class="modal-body">
<div class="form-group">
<label for="name">Name</label>
<input type="text" name="name" class="form-control" placeholder="Name" />
</div>
<div id="errorcreatingbudget">
Error creating budget.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<input type="submit" class="btn btn-primary" value="Create" class="form-control" />
</div>
</form>
</div>
</div>
</div>
{{end}}

View File

@ -1,48 +0,0 @@
{{define "budget-sidebar"}}
<h1><a href="/dashboard">⌂</a> {{.Budget.Name}}</h1>
<ul>
<li><a href="/budget/{{.Budget.ID}}">Budget</a></li>
<li>Reports (Coming Soon)</li>
<li><a href="/budget/{{.Budget.ID}}/all-accounts">All Accounts</a></li>
<li>
On-Budget Accounts
<ul class="two-valued">
{{- range .OnBudgetAccounts}}
<li>
<a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
{{- template "amount" .Balance}}
</li>
{{- end}}
</ul>
</li>
<li>
Off-Budget Accounts
<ul class="two-valued">
{{- range .OffBudgetAccounts}}
<li>
<a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
{{template "amount" .Balance -}}
</li>
{{- end}}
</ul>
</li>
<li>
Closed Accounts
</li>
<li>
<a href="/budget/{{$.Budget.ID}}/accounts">Edit accounts</a>
</li>
<li>
+ Add Account
</li>
<li>
<a href="/budget/{{.Budget.ID}}/settings">Budget-Settings</a>
</li>
<li>
<a href="/admin">Admin</a>
</li>
<li>
<a href="/api/v1/user/logout">Logout</a>
</li>
</ul>
{{end}}

View File

@ -1,50 +0,0 @@
{{template "base" .}}
{{define "title"}}
{{printf "Budget for %s %d" .Date.Month .Date.Year}}
{{end}}
{{define "new"}}
{{end}}
{{define "main"}}
<div class="budget-item">
<a href="#newtransactionmodal" data-bs-toggle="modal" data-bs-target="#newtransactionmodal">New Transaction</a>
<span class="time"></span>
</div>
<div>
<a href="{{printf "/budget/%s/%d/%d" .Budget.ID .Previous.Year .Previous.Month}}">Previous Month</a> -
<a href="{{printf "/budget/%s" .Budget.ID}}">Current Month</a> -
<a href="{{printf "/budget/%s/%d/%d" .Budget.ID .Next.Year .Next.Month}}">Next Month</a>
</div>
<div>
<span>Available Balance: </span>{{template "amountf64" .AvailableBalance}}
</div>
<table class="container col-lg-12" id="content">
<tr>
<th>Group</th>
<th>Category</th>
<th></th>
<th></th>
<th>Leftover</th>
<th>Assigned</th>
<th>Activity</th>
<th>Available</th>
</tr>
{{range .Categories}}
<tr>
<td>{{.Group}}</td>
<td>{{.Name}}</td>
<td>
</td>
<td>
</td>
{{template "amountf64-cell" .AvailableLastMonth}}
{{template "amountf64-cell" .Assigned}}
{{template "amountf64-cell" .Activity}}
{{template "amountf64-cell" .Available}}
</tr>
{{end}}
</table>
{{end}}

View File

@ -1,26 +0,0 @@
{{define "title"}}
Budgets
{{end}}
{{define "new"}}
{{template "budget-new"}}
{{end}}
{{define "sidebar"}}
Please select a budget.
{{end}}
{{template "base" .}}
{{define "main"}}
{{range .Budgets}}
<div class="budget-item">
<a href="budget/{{.ID}}">{{.Name}}</a>
<span class="time"></span>
</div>
{{end}}
<div class="budget-item">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newbudgetmodal">New Budget</a>
<span class="time"></span>
</div>
{{end}}

View File

@ -1,17 +1,13 @@
{{define "title"}}
Start
{{end}}
{{define "new"}}
{{end}}
{{template "base" .}}
{{define "main"}}
<div class="container col-md-8 col-ld-8" id="content">
Willkommen bei Budgeteer, der neuen App für's Budget!
</div>
<div class="container col-md-4" id="login">
<a href="/login">Login</a> or <a href="/login">register</a>
</div>
{{end}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -1,42 +0,0 @@
{{template "base" .}}
{{define "title"}}Login{{end}}
{{define "more-head"}}
<script>
$(document).ready(function() {
$('#invalidCredentials').hide();
$('#loginForm').ajaxForm({
success: function() {
window.location.href = "/dashboard";
},
error: function() {
$('#invalidCredentials').show();
}
});
});
</script>
{{end}}
{{define "main"}}
<form id="loginForm" action="/api/v1/user/login" method="POST" class="center-block">
<div class="form-group">
<label for="username">User</label>
<input type="text" name="username" class="form-control" placeholder="User" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" class="form-control" placeholder="Password" />
<p id="invalidCredentials">
The entered credentials are invalid
</p>
</div>
<input type="submit" value="Login" class="btn btn-default" />
<p>
New user? <a href="/register">Register</a> instead!
</p>
</form>
{{end}}

30
web/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "web",
"version": "0.0.0",
"scripts": {
"serve": "vite preview",
"build": "vite build",
"dev": "vite",
"preview": "vite preview"
},
"dependencies": {
"@mdi/font": "5.9.55",
"@vueuse/core": "^7.6.1",
"autoprefixer": "^10.4.2",
"pinia": "^2.0.11",
"postcss": "^8.4.6",
"tailwindcss": "^3.0.18",
"vue": "^3.2.25",
"vue-router": "^4.0.12"
},
"devDependencies": {
"@vitejs/plugin-vue": "^2.0.0",
"@vue/cli-plugin-babel": "5.0.0-beta.7",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-service": "5.0.0-beta.7",
"sass": "^1.38.0",
"sass-loader": "^10.0.0",
"vite": "^2.7.2",
"vue-cli-plugin-vuetify": "~2.4.5"
}
}

6
web/postcss.config.js Normal file
View File

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

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,72 +0,0 @@
{{define "title"}}Register{{end}}
{{template "base" .}}
{{define "more-head"}}
<script>
function checkPasswordMatchUi() {
if(checkPasswordMatch())
$("#divCheckPasswordMatch").html("Passwords match.");
else
$("#divCheckPasswordMatch").html("Passwords do not match!");
}
function checkPasswordMatch() {
var password = $("#password").val();
var confirmPassword = $("#password_confirm").val();
return password == confirmPassword;
}
$(document).ready(function () {
$("#password, #password_confirm").keyup(checkPasswordMatchUi);
$('#invalidCredentials').hide();
$('#loginForm').ajaxForm({
beforeSubmit: function(a, b, c) {
var match = checkPasswordMatch();
if(!match){
$("#divCheckPasswordMatch").fadeOut(300).fadeIn(300).fadeOut(300).fadeIn(300);
}
return match;
},
success: function() {
window.location.href = "/dashboard";
},
error: function() {
$('#invalidCredentials').show();
}
});
});
</script>
{{end}}
{{define "main"}}
<form id="loginForm" action="/api/v1/user/register" method="POST" class="center-block">
<div class="form-group">
<label for="email">E-Mail</label>
<input type="text" name="email" class="form-control" placeholder="E-Mail" />
</div>
<div class="form-group">
<label for="name">Name</label>
<input type="text" name="name" class="form-control" placeholder="Name" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" id="password" class="form-control" placeholder="Password" />
<input type="password" id="password_confirm" class="form-control" placeholder="Verify password" />
</div>
<div id="divCheckPasswordMatch"></div>
<div id="invalidCredentials">
Username already exists
</div>
<input type="submit" value="Login" class="form-control" />
<p>
Existing user? <a href="/login">Login</a> instead!
</p>
</form>
{{end}}

View File

@ -1,32 +0,0 @@
{{define "title"}}
Settings for Budget "{{.Budget.Name}}"
{{end}}
{{template "base" .}}
{{define "main"}}
<h1>Danger Zone</h1>
<div class="budget-item">
<a href="/budget/{{.Budget.ID}}/settings/clear">Clear database</a>
<p>This removes all data and starts from scratch. Not undoable!</p>
</div>
<div class="budget-item">
<a href="/budget/{{.Budget.ID}}/settings/clean-negative">Fix all historic negative category-balances</a>
<p>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</p>
</div>
<div class="budget-item">
<form method="POST" action="/api/v1/transaction/import/ynab" enctype="multipart/form-data">
<input type="hidden" name="budget_id" value="{{.Budget.ID}}" />
<label for="transactions_file">
Transaktionen:
<input type="file" name="transactions" accept="text/*" />
</label>
<br />
<label for="assignments_file">
Budget:
<input type="file" name="assignments" accept="text/*" />
</label>
<button type="submit">Importieren</button>
</form>
</div>
{{end}}

5
web/src/@types/shims-vue.d.ts vendored Normal file
View File

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

65
web/src/App.vue Normal file
View File

@ -0,0 +1,65 @@
<script lang="ts">
import { mapState } from "pinia";
import { defineComponent } from "vue";
import { useBudgetsStore } from "./stores/budget";
import { useSessionStore } from "./stores/session";
import { useSettingsStore } from "./stores/settings";
export default defineComponent({
computed: {
...mapState(useBudgetsStore, ["CurrentBudgetName"]),
...mapState(useSettingsStore, ["Menu"]),
...mapState(useSessionStore, ["LoggedIn"]),
},
methods: {
logout() {
useSessionStore().logout();
this.$router.push("/login");
},
toggleMenu() {
useSettingsStore().toggleMenu();
},
toggleMenuSize() {
useSettingsStore().toggleMenuSize();
}
},
})
</script>
<template>
<div class="box-border w-full">
<div class="flex bg-gray-400 p-4 m-2 rounded-lg">
<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 flex-1 flex-row justify-end -mx-4">
<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>
<a class="mx-4" v-if="LoggedIn" @click="logout">Logout</a>
</div>
</div>
<div class="flex flex-col md:flex-row flex-1">
<div
:class="[Menu.Expand ? 'md:w-72' : 'md:w-36', Menu.Show ? '' : 'hidden']"
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>
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

26
web/src/api.ts Normal file
View File

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

View File

@ -0,0 +1,101 @@
<script lang="ts">
import { defineComponent, PropType } from "vue"
import { GET } from "../api";
import { useBudgetsStore } from "../stores/budget";
export interface Suggestion {
ID : string
Name : string
}
interface Data {
Selected: Suggestion | undefined
SearchQuery: String
Suggestions: Suggestion[]
}
export default defineComponent({
data() {
return {
Selected: undefined,
SearchQuery: this.modelValue || "",
Suggestions: new Array<Suggestion>(),
} as Data
},
props: {
modelValue: Object as PropType<Suggestion>,
type: String
},
watch: {
SearchQuery() {
this.load(this.$data.SearchQuery);
}
},
methods: {
saveTransaction(e : MouseEvent) {
e.preventDefault();
},
load(text : String) {
this.$emit('update:modelValue', {ID: null, Name: text});
if (text == ""){
this.$data.Suggestions = [];
return;
}
const budgetStore = useBudgetsStore();
GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + this.type + "?s=" + text)
.then(x=>x.json())
.then(x => {
let suggestions = x || [];
if(suggestions.length > 10){
suggestions = suggestions.slice(0, 10);
}
this.$data.Suggestions = suggestions;
});
},
keypress(e : KeyboardEvent) {
console.log(e.key);
if(e.key == "Enter") {
const selected = this.$data.Suggestions[0];
this.selectElement(selected);
const el = (<HTMLInputElement>e.target);
const inputElements = Array.from(el.ownerDocument.querySelectorAll('input:not([disabled]):not([readonly])'));
const currentIndex = inputElements.indexOf(el);
const nextElement = inputElements[currentIndex < inputElements.length - 1 ? currentIndex + 1 : 0];
(<HTMLInputElement>nextElement).focus();
}
},
selectElement(element : Suggestion) {
this.$data.Selected = element;
this.$data.Suggestions = [];
this.$emit('update:modelValue', element);
},
select(e : MouseEvent) {
const target = (<HTMLInputElement>e.target);
const valueAttribute = target.attributes.getNamedItem("value");
let selectedID = "";
if(valueAttribute != null)
selectedID = valueAttribute.value;
const selected = this.$data.Suggestions.filter(x => x.ID == selectedID)[0];
this.selectElement(selected);
},
clear() {
this.$data.Selected = undefined;
this.$emit('update:modelValue', {ID: null, Name: this.$data.SearchQuery});
}
}
})
</script>
<template>
<div>
<input class="border-b-2 border-black" @keypress="keypress" v-if="Selected == undefined" v-model="SearchQuery" />
<span @click="clear" v-if="Selected != undefined" class="bg-gray-300">{{Selected.Name}}</span>
<div v-if="Suggestions.length > 0" class="absolute bg-gray-400 w-64 p-2">
<span v-for="suggestion in Suggestions" class="block" @click="select" :value="suggestion.ID">
{{suggestion.Name}}
</span>
</div>
</div>
</template>

View File

@ -0,0 +1,13 @@
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
})
</script>
<template>
<div class="flex flex-row items-center bg-gray-300 h-32 rounded-lg">
<slot></slot>
</div>
</template>

View File

@ -0,0 +1,19 @@
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
props: ["value"],
computed: {
formattedValue() {
return Number(this.value).toLocaleString(undefined, {
minimumFractionDigits: 2,
});
}
}
})
</script>
<template>
<span class="text-right" :class="value < 0 ? 'negative' : ''">{{formattedValue}} </span>
</template>

View File

@ -0,0 +1,45 @@
<script lang="ts">
import { mapState } from "pinia";
import { defineComponent } from "vue";
import { useBudgetsStore } from "../stores/budget";
import Currency from "./Currency.vue";
export default defineComponent({
props: [ "transaction", "index" ],
components: { Currency },
computed: {
...mapState(useBudgetsStore, ["CurrentBudgetID"])
}
})
</script>
<template>
<tr class="{{transaction.Date.After now ? 'future' : ''}}"
:class="[index % 6 < 3 ? 'bg-gray-300' : 'bg-gray-100']">
<!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">-->
<td style="width: 90px;">{{ transaction.Date.substring(0, 10) }}</td>
<td style="max-width: 150px;">{{ transaction.TransferAccount ? "Transfer : " + transaction.TransferAccount : transaction.Payee }}</td>
<td style="max-width: 200px;">
{{ transaction.CategoryGroup ? transaction.CategoryGroup + " : " + transaction.Category : "" }}
</td>
<td>
<a :href="'/budget/' + CurrentBudgetID + '/transaction/' + transaction.ID">
{{ transaction.Memo }}
</a>
</td>
<td>
<Currency class="block" :value="transaction.Amount" />
</td>
<td style="width: 20px;">
{{ transaction.Status == "Reconciled" ? "✔" : (transaction.Status == "Uncleared" ? "" : "*") }}
</td>
<td style="width: 20px;">{{ transaction.GroupID ? "☀" : "" }}</td>
</tr>
</template>
<style>
td {
overflow: hidden;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,45 @@
<script lang="ts">
import Card from '../components/Card.vue';
import { defineComponent } from "vue";
import { useBudgetsStore } from '../stores/budget';
export default defineComponent({
data() {
return {
dialog: false,
budgetName: ""
}
},
components: { Card },
methods: {
saveBudget() {
useBudgetsStore().NewBudget(this.$data.budgetName);
this.$data.dialog = false;
},
newBudget() {
this.$data.dialog = true;
}
}
})
</script>
<template>
<Card>
<p class="w-24 text-center text-6xl">+</p>
<button class="text-lg" dark @click="newBudget">New Budget</button>
</Card>
<div v-if="dialog" justify="center">
<div>
<div>
<span class="text-h5">New Budget</span>
</div>
<div>
<input type="text" v-model="budgetName" label="Budget name" required />
</div>
<div>
<button @click="dialog = false">Close</button>
<button @click="saveBudget">Save</button>
</div>
</div>
</div>
</template>

11
web/src/index.css Normal file
View File

@ -0,0 +1,11 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
h1 {
font-size: 200%;
}
a {
text-decoration: underline;
}

25
web/src/main.ts Normal file
View File

@ -0,0 +1,25 @@
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
import router from './router'
import { createPinia } from 'pinia'
import { useBudgetsStore } from './stores/budget';
import { useAccountStore } from './stores/budget-account'
import PiniaLogger from './pinia-logger'
const app = createApp(App)
app.use(router)
const pinia = createPinia()
pinia.use(PiniaLogger())
app.use(pinia)
app.mount('#app')
router.beforeEach(async (to, from, next) => {
const budgetStore = useBudgetsStore();
await budgetStore.SetCurrentBudget((<string>to.params.budgetid));
const accountStore = useAccountStore();
await accountStore.SetCurrentAccount((<string>to.params.budgetid), (<string>to.params.accountid));
next();
})

97
web/src/pages/Account.vue Normal file
View File

@ -0,0 +1,97 @@
<script lang="ts" setup>
import { computed, ref } from "vue"
import Autocomplete, { Suggestion } from '../components/Autocomplete.vue'
import Currency from "../components/Currency.vue";
import TransactionRow from "../components/TransactionRow.vue";
import { POST } from "../api";
import { useAccountStore } from "../stores/budget-account";
const props = defineProps<{
budgetid: string
accountid: string
}>()
const TransactionDate = ref(new Date().toISOString().substring(0, 10));
const Payee = ref<Suggestion | undefined>(undefined);
const Category = ref<Suggestion | undefined>(undefined);
const Memo = ref("");
const Amount = ref(0);
function saveTransaction(e: MouseEvent) {
e.preventDefault();
POST("/transaction/new", JSON.stringify({
budget_id: props.budgetid,
account_id: props.accountid,
date: TransactionDate.value,
payee: Payee.value,
category: Category.value,
memo: Memo.value,
amount: Amount,
state: "Uncleared"
}))
.then(x => x.json());
}
const accountStore = useAccountStore();
const CurrentAccount = accountStore.CurrentAccount;
const TransactionsList = accountStore.TransactionsList;
</script>
<template>
<h1>{{ CurrentAccount?.Name }}</h1>
<p>
Current Balance:
<Currency :value="CurrentAccount?.Balance" />
</p>
<table>
<tr class="font-bold">
<td 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: 20px;"></td>
<td style="width: 20px;"></td>
</tr>
<tr>
<td style="width: 90px;" class="text-sm">
<input class="border-b-2 border-black" type="date" v-model="TransactionDate" />
</td>
<td style="max-width: 150px;">
<Autocomplete v-model="Payee" type="payees" />
</td>
<td style="max-width: 200px;">
<Autocomplete v-model="Category" type="categories" />
</td>
<td>
<input class="block w-full border-b-2 border-black" type="text" v-model="Memo" />
</td>
<td style="width: 80px;" class="text-right">
<input
class="text-right block w-full border-b-2 border-black"
type="currency"
v-model="Amount"
/>
</td>
<td style="width: 20px;">
<input type="submit" @click="saveTransaction" value="Save" />
</td>
<td style="width: 20px;"></td>
</tr>
<TransactionRow
v-for="(transaction, index) in TransactionsList"
:transaction="transaction"
:index="index"
/>
</table>
</template>
<style>
table {
width: 100%;
table-layout: fixed;
}
.negative {
color: red;
}
</style>

View File

@ -1,18 +1,15 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
{{define "title"}}
Admin
{{end}}
onMounted(() => {
document.title = "Budgeteer - Admin";
})
</script>
{{define "sidebar"}}
Settings for all Budgets
{{end}}
{{template "base" .}}
{{define "main"}}
<template>
<h1>Danger Zone</h1>
<div class="budget-item">
<button>Clear database</button>
<p>This removes all data and starts from scratch. Not undoable!</p>
</div>
{{end}}
</template>

View File

@ -0,0 +1,70 @@
<script lang="ts" setup>
import Currency from "../components/Currency.vue"
import { useBudgetsStore } from "../stores/budget"
import { useAccountStore } from "../stores/budget-account"
import { useSettingsStore } from "../stores/settings"
const props = defineProps<{
budgetid: string,
accountid: string,
}>();
const ExpandMenu = useSettingsStore().Menu.Expand;
const budgetStore = useBudgetsStore();
const CurrentBudgetName = budgetStore.CurrentBudgetName;
const CurrentBudgetID = budgetStore.CurrentBudgetID;
const accountStore = useAccountStore();
const OnBudgetAccounts = accountStore.OnBudgetAccounts;
const OffBudgetAccounts = accountStore.OffBudgetAccounts;
const OnBudgetAccountsBalance = accountStore.OnBudgetAccountsBalance;
const OffBudgetAccountsBalance = accountStore.OffBudgetAccountsBalance;
</script>
<template>
<div class="flex flex-col">
<span class="m-1 p-1 px-3 text-xl">
<router-link to="/dashboard"></router-link>
{{CurrentBudgetName}}
</span>
<span class="bg-orange-200 rounded-lg m-1 p-1 px-3 flex flex-col">
<router-link :to="'/budget/'+budgetid+'/budgeting'">Budget</router-link><br />
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>-->
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>-->
</span>
<li class="bg-orange-200 rounded-lg m-1 p-1 px-3">
<div class="flex flex-row justify-between font-bold">
<span>On-Budget Accounts</span>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OnBudgetAccountsBalance" />
</div>
<div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between">
<router-link :to="'/budget/'+budgetid+'/account/'+account.ID">{{account.Name}}</router-link>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" />
</div>
</li>
<li class="bg-red-200 rounded-lg m-1 p-1 px-3">
<div class="flex flex-row justify-between font-bold">
<span>Off-Budget Accounts</span>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OffBudgetAccountsBalance" />
</div>
<div v-for="account in OffBudgetAccounts" class="flex flex-row justify-between">
<router-link :to="'/budget/'+budgetid+'/account/'+account.ID">{{account.Name}}</router-link>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" />
</div>
</li>
<li class="bg-red-200 rounded-lg m-1 p-1 px-3">
Closed Accounts
</li>
<!--<li>
<router-link :to="'/budget/'+CurrentBudgetID+'/accounts'">Edit accounts</router-link>
</li>-->
<li class="bg-red-200 rounded-lg m-1 p-1 px-3">
+ Add Account
</li>
<li class="bg-red-200 rounded-lg m-1 p-1 px-3">
<router-link :to="'/budget/'+CurrentBudgetID+'/settings'">Budget-Settings</router-link>
</li>
<!--<li><router-link to="/admin">Admin</router-link></li>-->
</div>
</template>

View File

@ -0,0 +1,95 @@
<script lang="ts" setup>
import { computed, defineProps, onMounted, PropType, watch, watchEffect } from "vue";
import Currency from "../components/Currency.vue";
import { useBudgetsStore } from "../stores/budget";
import { useAccountStore } from "../stores/budget-account";
interface Date {
Year: number,
Month: number,
}
const props = defineProps<{
budgetid: string,
year: string,
month: string,
}>()
const budgetsStore = useBudgetsStore();
const CurrentBudgetID = computed(() => budgetsStore.CurrentBudgetID);
const categoriesForMonth = useAccountStore().CategoriesForMonth;
const Categories = computed(() => {
return [...categoriesForMonth(selected.value.Year, selected.value.Month)];
});
const previous = computed(() => ({
Year: new Date(selected.value.Year, selected.value.Month - 1, 1).getFullYear(),
Month: new Date(selected.value.Year, selected.value.Month - 1, 1).getMonth(),
}));
const current = computed(() => ({
Year: new Date().getFullYear(),
Month: new Date().getMonth(),
}));
const selected = computed(() => ({
Year: Number(props.year) ?? current.value.Year,
Month: Number(props.month ?? current.value.Month)
}));
const next = computed(() => ({
Year: new Date(selected.value.Year, Number(props.month) + 1, 1).getFullYear(),
Month: new Date(selected.value.Year, Number(props.month) + 1, 1).getMonth(),
}));
watchEffect(() => {
if (props.year != undefined && props.month != undefined)
return useAccountStore().FetchMonthBudget(props.budgetid ?? "", Number(props.year), Number(props.month));
});
/*{{define "title"}}
{{printf "Budget for %s %d" .Date.Month .Date.Year}}
{{end}}*/
</script>
<template>
<h1>Budget for {{ selected.Month + 1 }}/{{ selected.Year }}</h1>
<div>
<router-link
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"
>Previous Month</router-link>-
<router-link
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + current.Year + '/' + current.Month"
>Current Month</router-link>-
<router-link
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + next.Year + '/' + next.Month"
>Next Month</router-link>
</div>
<table class="container col-lg-12" id="content">
<tr>
<th>Group</th>
<th>Category</th>
<th></th>
<th></th>
<th>Leftover</th>
<th>Assigned</th>
<th>Activity</th>
<th>Available</th>
</tr>
<tr v-for="category in Categories">
<td>{{ category.Group }}</td>
<td>{{ category.Name }}</td>
<td></td>
<td></td>
<td class="text-right">
<Currency :value="category.AvailableLastMonth" />
</td>
<td class="text-right">
<Currency :value="category.Assigned" />
</td>
<td class="text-right">
<Currency :value="category.Activity" />
</td>
<td class="text-right">
<Currency :value="category.Available" />
</td>
</tr>
</table>
</template>

View File

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

13
web/src/pages/Index.vue Normal file
View File

@ -0,0 +1,13 @@
<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>

48
web/src/pages/Login.vue Normal file
View File

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

View File

@ -0,0 +1,61 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useSessionStore } from '../stores/session';
const error = ref("");
const login = ref({ email: "", password: "", name: "" });
const showPassword = ref(false);
function formSubmit(e: FormDataEvent) {
e.preventDefault();
useSessionStore().register(login)
.then(() => error.value = "")
.catch(() => error.value = "Something went wrong!");
// TODO display invalidCredentials
// TODO redirect to dashboard on success
}
</script>
<template>
<v-container>
<v-row>
<v-col cols="12">
<v-text-field v-model="login.email" type="text" label="E-Mail" />
</v-col>
<v-col cols="12">
<v-text-field v-model="login.name" type="text" label="Name" />
</v-col>
<v-col cols="6">
<v-text-field
v-model="login.password"
label="Password"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword = showPassword"
:error-message="error"
error-count="2"
error
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="login.password"
label="Repeat password"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword = showPassword"
:error-message="error"
error-count="2"
error
/>
</v-col>
</v-row>
<div class="form-group">{{ error }}</div>
<v-btn type="submit" @click="formSubmit">Register</v-btn>
<p>
Existing user?
<router-link to="/login">Login</router-link>instead!
</p>
</v-container>
</template>

125
web/src/pages/Settings.vue Normal file
View File

@ -0,0 +1,125 @@
<script lang="ts" setup>
import { computed, defineComponent, onMounted, ref } from "vue"
import { useRouter } from "vue-router";
import { DELETE, POST } from "../api";
import { useBudgetsStore } from "../stores/budget";
import { useSessionStore } from "../stores/session";
const transactionsFile = ref<File | undefined>(undefined);
const assignmentsFile = ref<File | undefined>(undefined);
const filesIncomplete = computed(() => transactionsFile.value == undefined || assignmentsFile.value == undefined);
onMounted(() => {
document.title = "Budgeteer - Settings";
});
function gotAssignments(e: Event) {
const input = (<HTMLInputElement>e.target);
if (input.files != null)
assignmentsFile.value = input.files[0];
}
function gotTransactions(e: Event) {
const input = (<HTMLInputElement>e.target);
if (input.files != null)
transactionsFile.value = input.files[0];
};
function deleteBudget() {
const currentBudgetID = useBudgetsStore().CurrentBudgetID;
if (currentBudgetID == null)
return;
DELETE("/budget/" + currentBudgetID);
const budgetStore = useSessionStore();
budgetStore.Budgets.delete(currentBudgetID);
useRouter().push("/")
};
function clearBudget() {
const currentBudgetID = useBudgetsStore().CurrentBudgetID;
POST("/budget/" + currentBudgetID + "/settings/clear", null)
};
function cleanNegative() {
// <a href="/budget/{{.Budget.ID}}/settings/clean-negative">Fix all historic negative category-balances</a>
};
function ynabImport() {
if (transactionsFile.value == undefined || assignmentsFile.value == undefined)
return
let formData = new FormData();
formData.append("transactions", transactionsFile.value);
formData.append("assignments", assignmentsFile.value);
const budgetStore = useBudgetsStore();
budgetStore.ImportYNAB(formData);
};
</script>
<template>
<v-container>
<h1>Danger Zone</h1>
<v-row>
<v-col cols="12" md="6" xl="3">
<v-card>
<v-card-header>
<v-card-header-text>
<v-card-title>Clear Budget</v-card-title>
<v-card-subtitle>This removes transactions and assignments to start from scratch. Accounts and categories are kept. Not undoable!</v-card-subtitle>
</v-card-header-text>
</v-card-header>
<v-card-actions class="justify-center">
<v-btn @click="clearBudget">Clear budget</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" md="6" xl="3">
<v-card>
<v-card-header>
<v-card-header-text>
<v-card-title>Delete Budget</v-card-title>
<v-card-subtitle>This deletes the whole bugdet including all transactions, assignments, accounts and categories. Not undoable!</v-card-subtitle>
</v-card-header-text>
</v-card-header>
<v-card-actions class="justify-center">
<v-btn @click="deleteBudget">Delete budget</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" md="6" xl="3">
<v-card>
<v-card-header>
<v-card-header-text>
<v-card-title>Fix all historic negative category-balances</v-card-title>
<v-card-subtitle>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</v-card-subtitle>
</v-card-header-text>
</v-card-header>
<v-card-actions class="justify-center">
<v-btn @click="cleanNegative">Fix negative</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" xl="6">
<v-card>
<v-card-header>
<v-card-header-text>
<v-card-title>Import YNAB Budget</v-card-title>
</v-card-header-text>
</v-card-header>
<label for="transactions_file">
Transaktionen:
<input type="file" @change="gotTransactions" accept="text/*" />
</label>
<br />
<label for="assignments_file">
Budget:
<input type="file" @change="gotAssignments" accept="text/*" />
</label>
<v-card-actions class="justify-center">
<v-btn :disabled="filesIncomplete" @click="ynabImport">Importieren</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-card></v-card>
</v-container>
</template>

82
web/src/pinia-logger.ts Normal file
View File

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

29
web/src/router/index.ts Normal file
View File

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

View File

@ -0,0 +1,109 @@
import { defineStore } from "pinia"
import { GET } from "../api";
import { useSessionStore } from "./session";
interface State {
Accounts: Map<string, Account>,
CurrentAccountID: string | null,
Categories: Map<string, Category>,
Months: Map<number, Map<number, Map<string, Category>>>,
Transactions: [],
Assignments: []
}
export interface Account {
ID: string
Name: string
OnBudget: boolean
Balance: Number
}
export interface Category {
ID: string
Group: string
Name: string
AvailableLastMonth: number
Assigned: number
Activity: number
Available: number
}
export const useAccountStore = defineStore("budget/account", {
state: (): State => ({
Accounts: new Map<string, Account>(),
CurrentAccountID: null,
Months: new Map<number, Map<number, Map<string, Category>>>(),
Categories: new Map<string, Category>(),
Transactions: [],
Assignments: []
}),
getters: {
AccountsList(state) {
return [ ...state.Accounts.values() ];
},
CategoriesForMonth: (state) => (year : number, month : number) => {
console.log("MTH", state.Months)
const yearMap = state.Months.get(year);
return [ ...yearMap?.get(month)?.values() || [] ];
},
CurrentAccount(state) : Account | undefined {
if (state.CurrentAccountID == null)
return undefined;
return state.Accounts.get(state.CurrentAccountID);
},
OnBudgetAccounts(state) {
return [ ...state.Accounts.values() ].filter(x => x.OnBudget);
},
OnBudgetAccountsBalance(state) : Number {
return this.OnBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0);
},
OffBudgetAccounts(state) {
return [ ...state.Accounts.values() ].filter(x => !x.OnBudget);
},
OffBudgetAccountsBalance(state) : Number {
return this.OffBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0);
},
TransactionsList(state) {
return (state.Transactions || []);
}
},
actions: {
async SetCurrentAccount(budgetid : string, accountid : string) {
if (budgetid == null)
return
this.CurrentAccountID = accountid;
if (this.CurrentAccount == undefined)
return
useSessionStore().setTitle(this.CurrentAccount.Name);
await this.FetchAccount(accountid);
},
async FetchAccount(accountid : string) {
const result = await GET("/account/" + accountid + "/transactions");
const response = await result.json();
this.Transactions = response.Transactions;
},
async FetchMonthBudget(budgetid : string, year : number, month : number) {
const result = await GET("/budget/" + budgetid + "/" + year + "/" + month);
const response = await result.json();
this.addCategoriesForMonth(year, month, response.Categories);
},
addCategoriesForMonth(year : number, month : number, categories : Category[]) : void {
const yearMap = this.Months.get(year) || new Map<number, Map<string, Category>>();
this.Months.set(year, yearMap);
const monthMap = yearMap.get(month) || new Map<string, Category>();
yearMap.set(month, monthMap);
for (const category of categories){
monthMap.set(category.ID, category);
}
},
logout() {
this.$reset()
},
}
})

62
web/src/stores/budget.ts Normal file
View File

@ -0,0 +1,62 @@
import { defineStore } from "pinia";
import { GET, POST } from "../api";
import { useAccountStore } from "./budget-account";
import { Budget, useSessionStore } from "./session";
interface State {
CurrentBudgetID: string | null,
}
export const useBudgetsStore = defineStore('budget', {
state: (): State => ({
CurrentBudgetID: null,
}),
getters: {
CurrentBudget(): Budget | undefined {
if (this.CurrentBudgetID == null)
return undefined;
const sessionStore = useSessionStore();
return sessionStore.Budgets.get(this.CurrentBudgetID);
},
CurrentBudgetName(state): string {
return this.CurrentBudget?.Name ?? "";
},
},
actions: {
ImportYNAB(formData: FormData) {
return POST(
"/budget/" + this.CurrentBudgetID + "/import/ynab",
formData,
);
},
async NewBudget(budgetName: string): Promise<void> {
const result = await POST(
"/budget/new",
JSON.stringify({ name: budgetName })
);
const response = await result.json();
const sessionStore = useSessionStore();
sessionStore.Budgets.set(response.ID, response);
},
async SetCurrentBudget(budgetid: string): Promise<void> {
this.CurrentBudgetID = budgetid;
if (budgetid == null)
return
await this.FetchBudget(budgetid);
},
async FetchBudget(budgetid: string) {
const result = await GET("/budget/" + budgetid);
const response = await result.json();
for (const account of response.Accounts || []) {
useAccountStore().Accounts.set(account.ID, account);
}
for (const category of response.Categories || []) {
useAccountStore().Categories.set(category.ID, category);
}
},
}
})

56
web/src/stores/session.ts Normal file
View File

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

View File

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

View File

@ -0,0 +1,2 @@
// Place SASS variable overrides here
// $font-size-root: 18px;

View File

@ -1,935 +0,0 @@
# Apache Server Configs v2.11.0 | MIT License
# https://github.com/h5bp/server-configs-apache
# (!) Using `.htaccess` files slows down Apache, therefore, if you have
# access to the main server configuration file (which is usually called
# `httpd.conf`), you should add this logic there.
#
# https://httpd.apache.org/docs/current/howto/htaccess.html.
# ######################################################################
# # CROSS-ORIGIN #
# ######################################################################
# ----------------------------------------------------------------------
# | Cross-origin requests |
# ----------------------------------------------------------------------
# Allow cross-origin requests.
#
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS
# http://enable-cors.org/
# http://www.w3.org/TR/cors/
# <IfModule mod_headers.c>
# Header set Access-Control-Allow-Origin "*"
# </IfModule>
# ----------------------------------------------------------------------
# | Cross-origin images |
# ----------------------------------------------------------------------
# Send the CORS header for images when browsers request it.
#
# https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image
# https://blog.chromium.org/2011/07/using-cross-domain-images-in-webgl-and.html
<IfModule mod_setenvif.c>
<IfModule mod_headers.c>
<FilesMatch "\.(bmp|cur|gif|ico|jpe?g|png|svgz?|webp)$">
SetEnvIf Origin ":" IS_CORS
Header set Access-Control-Allow-Origin "*" env=IS_CORS
</FilesMatch>
</IfModule>
</IfModule>
# ----------------------------------------------------------------------
# | Cross-origin web fonts |
# ----------------------------------------------------------------------
# Allow cross-origin access to web fonts.
<IfModule mod_headers.c>
<FilesMatch "\.(eot|otf|tt[cf]|woff2?)$">
Header set Access-Control-Allow-Origin "*"
</FilesMatch>
</IfModule>
# ----------------------------------------------------------------------
# | Cross-origin resource timing |
# ----------------------------------------------------------------------
# Allow cross-origin access to the timing information for all resources.
#
# If a resource isn't served with a `Timing-Allow-Origin` header that
# would allow its timing information to be shared with the document,
# some of the attributes of the `PerformanceResourceTiming` object will
# be set to zero.
#
# http://www.w3.org/TR/resource-timing/
# http://www.stevesouders.com/blog/2014/08/21/resource-timing-practical-tips/
# <IfModule mod_headers.c>
# Header set Timing-Allow-Origin: "*"
# </IfModule>
# ######################################################################
# # ERRORS #
# ######################################################################
# ----------------------------------------------------------------------
# | Custom error messages/pages |
# ----------------------------------------------------------------------
# Customize what Apache returns to the client in case of an error.
# https://httpd.apache.org/docs/current/mod/core.html#errordocument
ErrorDocument 404 /404.html
# ----------------------------------------------------------------------
# | Error prevention |
# ----------------------------------------------------------------------
# Disable the pattern matching based on filenames.
#
# This setting prevents Apache from returning a 404 error as the result
# of a rewrite when the directory with the same name does not exist.
#
# https://httpd.apache.org/docs/current/content-negotiation.html#multiviews
Options -MultiViews
# ######################################################################
# # INTERNET EXPLORER #
# ######################################################################
# ----------------------------------------------------------------------
# | Document modes |
# ----------------------------------------------------------------------
# Force Internet Explorer 8/9/10 to render pages in the highest mode
# available in the various cases when it may not.
#
# https://hsivonen.fi/doctype/#ie8
#
# (!) Starting with Internet Explorer 11, document modes are deprecated.
# If your business still relies on older web apps and services that were
# designed for older versions of Internet Explorer, you might want to
# consider enabling `Enterprise Mode` throughout your company.
#
# http://msdn.microsoft.com/en-us/library/ie/bg182625.aspx#docmode
# http://blogs.msdn.com/b/ie/archive/2014/04/02/stay-up-to-date-with-enterprise-mode-for-internet-explorer-11.aspx
<IfModule mod_headers.c>
Header set X-UA-Compatible "IE=edge"
# `mod_headers` cannot match based on the content-type, however,
# the `X-UA-Compatible` response header should be send only for
# HTML documents and not for the other resources.
<FilesMatch "\.(appcache|atom|bbaw|bmp|crx|css|cur|eot|f4[abpv]|flv|geojson|gif|htc|ico|jpe?g|js|json(ld)?|m4[av]|manifest|map|mp4|oex|og[agv]|opus|otf|pdf|png|rdf|rss|safariextz|svgz?|swf|topojson|tt[cf]|txt|vcard|vcf|vtt|webapp|web[mp]|woff2?|xloc|xml|xpi)$">
Header unset X-UA-Compatible
</FilesMatch>
</IfModule>
# ----------------------------------------------------------------------
# | Iframes cookies |
# ----------------------------------------------------------------------
# Allow cookies to be set from iframes in Internet Explorer.
#
# http://msdn.microsoft.com/en-us/library/ms537343.aspx
# http://www.w3.org/TR/2000/CR-P3P-20001215/
# <IfModule mod_headers.c>
# Header set P3P "policyref=\"/w3c/p3p.xml\", CP=\"IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT\""
# </IfModule>
# ######################################################################
# # MEDIA TYPES AND CHARACTER ENCODINGS #
# ######################################################################
# ----------------------------------------------------------------------
# | Media types |
# ----------------------------------------------------------------------
# Serve resources with the proper media types (f.k.a. MIME types).
#
# https://www.iana.org/assignments/media-types/media-types.xhtml
# https://httpd.apache.org/docs/current/mod/mod_mime.html#addtype
<IfModule mod_mime.c>
# Data interchange
AddType application/json json map topojson
AddType application/ld+json jsonld
AddType application/vnd.geo+json geojson
AddType application/xml atom rdf rss xml
# JavaScript
# Normalize to standard type.
# https://tools.ietf.org/html/rfc4329#section-7.2
AddType application/javascript js
# Manifest files
# If you are providing a web application manifest file (see
# the specification: https://w3c.github.io/manifest/), it is
# recommended that you serve it with the `application/manifest+json`
# media type.
#
# Because the web application manifest file doesn't have its
# own unique file extension, you can set its media type either
# by matching:
#
# 1) the exact location of the file (this can be done using a
# directive such as `<Location>`, but it will NOT work in
# the `.htaccess` file, so you will have to do it in the main
# server configuration file or inside of a `<VirtualHost>`
# container)
#
# e.g.:
#
# <Location "/.well-known/manifest.json">
# AddType application/manifest+json json
# </Location>
#
# 2) the filename (this can be problematic as you will need to
# ensure that you don't have any other file with the same name
# as the one you gave to your web application manifest file)
#
# e.g.:
#
# <Files "manifest.json">
# AddType application/manifest+json json
# </Files>
AddType application/x-web-app-manifest+json webapp
AddType text/cache-manifest appcache manifest
# Media files
AddType audio/mp4 f4a f4b m4a
AddType audio/ogg oga ogg opus
AddType image/bmp bmp
AddType image/webp webp
AddType video/mp4 f4v f4p m4v mp4
AddType video/ogg ogv
AddType video/webm webm
AddType video/x-flv flv
AddType image/svg+xml svg svgz
# Serving `.ico` image files with a different media type
# prevents Internet Explorer from displaying then as images:
# https://github.com/h5bp/html5-boilerplate/commit/37b5fec090d00f38de64b591bcddcb205aadf8ee
AddType image/x-icon cur ico
# Web fonts
AddType application/font-woff woff
AddType application/font-woff2 woff2
AddType application/vnd.ms-fontobject eot
# Browsers usually ignore the font media types and simply sniff
# the bytes to figure out the font type.
# https://mimesniff.spec.whatwg.org/#matching-a-font-type-pattern
#
# However, Blink and WebKit based browsers will show a warning
# in the console if the following font types are served with any
# other media types.
AddType application/x-font-ttf ttc ttf
AddType font/opentype otf
# Other
AddType application/octet-stream safariextz
AddType application/x-bb-appworld bbaw
AddType application/x-chrome-extension crx
AddType application/x-opera-extension oex
AddType application/x-xpinstall xpi
AddType text/vcard vcard vcf
AddType text/vnd.rim.location.xloc xloc
AddType text/vtt vtt
AddType text/x-component htc
</IfModule>
# ----------------------------------------------------------------------
# | Character encodings |
# ----------------------------------------------------------------------
# Serve all resources labeled as `text/html` or `text/plain`
# with the media type `charset` parameter set to `UTF-8`.
#
# https://httpd.apache.org/docs/current/mod/core.html#adddefaultcharset
AddDefaultCharset utf-8
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Serve the following file types with the media type `charset`
# parameter set to `UTF-8`.
#
# https://httpd.apache.org/docs/current/mod/mod_mime.html#addcharset
<IfModule mod_mime.c>
AddCharset utf-8 .atom \
.bbaw \
.css \
.geojson \
.js \
.json \
.jsonld \
.rdf \
.rss \
.topojson \
.vtt \
.webapp \
.xloc \
.xml
</IfModule>
# ######################################################################
# # REWRITES #
# ######################################################################
# ----------------------------------------------------------------------
# | Rewrite engine |
# ----------------------------------------------------------------------
# (1) Turn on the rewrite engine (this is necessary in order for
# the `RewriteRule` directives to work).
#
# https://httpd.apache.org/docs/current/mod/mod_rewrite.html#RewriteEngine
#
# (2) Enable the `FollowSymLinks` option if it isn't already.
#
# https://httpd.apache.org/docs/current/mod/core.html#options
#
# (3) If your web host doesn't allow the `FollowSymlinks` option,
# you need to comment it out or remove it, and then uncomment
# the `Options +SymLinksIfOwnerMatch` line (4), but be aware
# of the performance impact.
#
# https://httpd.apache.org/docs/current/misc/perf-tuning.html#symlinks
#
# (4) Some cloud hosting services will require you set `RewriteBase`.
#
# http://www.rackspace.com/knowledge_center/frequently-asked-question/why-is-modrewrite-not-working-on-my-site
# https://httpd.apache.org/docs/current/mod/mod_rewrite.html#rewritebase
#
# (5) Depending on how your server is set up, you may also need to
# use the `RewriteOptions` directive to enable some options for
# the rewrite engine.
#
# https://httpd.apache.org/docs/current/mod/mod_rewrite.html#rewriteoptions
<IfModule mod_rewrite.c>
# (1)
RewriteEngine On
# (2)
Options +FollowSymlinks
# (3)
# Options +SymLinksIfOwnerMatch
# (4)
# RewriteBase /
# (5)
# RewriteOptions <options>
</IfModule>
# ----------------------------------------------------------------------
# | Forcing `https://` |
# ----------------------------------------------------------------------
# Redirect from the `http://` to the `https://` version of the URL.
# https://wiki.apache.org/httpd/RewriteHTTPToHTTPS
# <IfModule mod_rewrite.c>
# RewriteEngine On
# RewriteCond %{HTTPS} !=on
# RewriteRule ^(.*)$ https://%{HTTP_HOST}/$1 [R=301,L]
# </IfModule>
# ----------------------------------------------------------------------
# | Suppressing / Forcing the `www.` at the beginning of URLs |
# ----------------------------------------------------------------------
# The same content should never be available under two different
# URLs, especially not with and without `www.` at the beginning.
# This can cause SEO problems (duplicate content), and therefore,
# you should choose one of the alternatives and redirect the other
# one.
#
# By default `Option 1` (no `www.`) is activated.
# http://no-www.org/faq.php?q=class_b
#
# If you would prefer to use `Option 2`, just comment out all the
# lines from `Option 1` and uncomment the ones from `Option 2`.
#
# (!) NEVER USE BOTH RULES AT THE SAME TIME!
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Option 1: rewrite www.example.com → example.com
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L]
</IfModule>
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Option 2: rewrite example.com → www.example.com
#
# Be aware that the following might not be a good idea if you use "real"
# subdomains for certain parts of your website.
# <IfModule mod_rewrite.c>
# RewriteEngine On
# RewriteCond %{HTTPS} !=on
# RewriteCond %{HTTP_HOST} !^www\. [NC]
# RewriteCond %{SERVER_ADDR} !=127.0.0.1
# RewriteCond %{SERVER_ADDR} !=::1
# RewriteRule ^ http://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# </IfModule>
# ######################################################################
# # SECURITY #
# ######################################################################
# ----------------------------------------------------------------------
# | Clickjacking |
# ----------------------------------------------------------------------
# Protect website against clickjacking.
#
# The example below sends the `X-Frame-Options` response header with
# the value `DENY`, informing browsers not to display the content of
# the web page in any frame.
#
# This might not be the best setting for everyone. You should read
# about the other two possible values the `X-Frame-Options` header
# field can have: `SAMEORIGIN` and `ALLOW-FROM`.
# https://tools.ietf.org/html/rfc7034#section-2.1.
#
# Keep in mind that while you could send the `X-Frame-Options` header
# for all of your websites pages, this has the potential downside that
# it forbids even non-malicious framing of your content (e.g.: when
# users visit your website using a Google Image Search results page).
#
# Nonetheless, you should ensure that you send the `X-Frame-Options`
# header for all pages that allow a user to make a state changing
# operation (e.g: pages that contain one-click purchase links, checkout
# or bank-transfer confirmation pages, pages that make permanent
# configuration changes, etc.).
#
# Sending the `X-Frame-Options` header can also protect your website
# against more than just clickjacking attacks:
# https://cure53.de/xfo-clickjacking.pdf.
#
# https://tools.ietf.org/html/rfc7034
# http://blogs.msdn.com/b/ieinternals/archive/2010/03/30/combating-clickjacking-with-x-frame-options.aspx
# https://www.owasp.org/index.php/Clickjacking
# <IfModule mod_headers.c>
# Header set X-Frame-Options "DENY"
# # `mod_headers` cannot match based on the content-type, however,
# # the `X-Frame-Options` response header should be send only for
# # HTML documents and not for the other resources.
# <FilesMatch "\.(appcache|atom|bbaw|bmp|crx|css|cur|eot|f4[abpv]|flv|geojson|gif|htc|ico|jpe?g|js|json(ld)?|m4[av]|manifest|map|mp4|oex|og[agv]|opus|otf|pdf|png|rdf|rss|safariextz|svgz?|swf|topojson|tt[cf]|txt|vcard|vcf|vtt|webapp|web[mp]|woff2?|xloc|xml|xpi)$">
# Header unset X-Frame-Options
# </FilesMatch>
# </IfModule>
# ----------------------------------------------------------------------
# | Content Security Policy (CSP) |
# ----------------------------------------------------------------------
# Mitigate the risk of cross-site scripting and other content-injection
# attacks.
#
# This can be done by setting a `Content Security Policy` which
# whitelists trusted sources of content for your website.
#
# The example header below allows ONLY scripts that are loaded from the
# current website's origin (no inline scripts, no CDN, etc). That almost
# certainly won't work as-is for your website!
#
# For more details on how to craft a reasonable policy for your website,
# read: http://www.html5rocks.com/en/tutorials/security/content-security-policy/
# (or the specification: http://www.w3.org/TR/CSP11/). Also, to make
# things easier, you can use an online CSP header generator such as:
# http://cspisawesome.com/.
# <IfModule mod_headers.c>
# Header set Content-Security-Policy "script-src 'self'; object-src 'self'"
# # `mod_headers` cannot match based on the content-type, however,
# # the `Content-Security-Policy` response header should be send
# # only for HTML documents and not for the other resources.
# <FilesMatch "\.(appcache|atom|bbaw|bmp|crx|css|cur|eot|f4[abpv]|flv|geojson|gif|htc|ico|jpe?g|js|json(ld)?|m4[av]|manifest|map|mp4|oex|og[agv]|opus|otf|pdf|png|rdf|rss|safariextz|svgz?|swf|topojson|tt[cf]|txt|vcard|vcf|vtt|webapp|web[mp]|woff2?|xloc|xml|xpi)$">
# Header unset Content-Security-Policy
# </FilesMatch>
# </IfModule>
# ----------------------------------------------------------------------
# | File access |
# ----------------------------------------------------------------------
# Block access to directories without a default document.
#
# You should leave the following uncommented, as you shouldn't allow
# anyone to surf through every directory on your server (which may
# includes rather private places such as the CMS's directories).
<IfModule mod_autoindex.c>
Options -Indexes
</IfModule>
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Block access to all hidden files and directories with the exception of
# the visible content from within the `/.well-known/` hidden directory.
#
# These types of files usually contain user preferences or the preserved
# state of an utility, and can include rather private places like, for
# example, the `.git` or `.svn` directories.
#
# The `/.well-known/` directory represents the standard (RFC 5785) path
# prefix for "well-known locations" (e.g.: `/.well-known/manifest.json`,
# `/.well-known/keybase.txt`), and therefore, access to its visible
# content should not be blocked.
#
# https://www.mnot.net/blog/2010/04/07/well-known
# https://tools.ietf.org/html/rfc5785
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_URI} "!(^|/)\.well-known/([^./]+./?)+$" [NC]
RewriteCond %{SCRIPT_FILENAME} -d [OR]
RewriteCond %{SCRIPT_FILENAME} -f
RewriteRule "(^|/)\." - [F]
</IfModule>
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Block access to files that can expose sensitive information.
#
# By default, block access to backup and source files that may be
# left by some text editors and can pose a security risk when anyone
# has access to them.
#
# http://feross.org/cmsploit/
#
# (!) Update the `<FilesMatch>` regular expression from below to
# include any files that might end up on your production server and
# can expose sensitive information about your website. These files may
# include: configuration files, files that contain metadata about the
# project (e.g.: project dependencies), build scripts, etc..
<FilesMatch "(^#.*#|\.(bak|conf|dist|fla|in[ci]|log|psd|sh|sql|sw[op])|~)$">
# Apache < 2.3
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
Satisfy All
</IfModule>
# Apache ≥ 2.3
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
</FilesMatch>
# ----------------------------------------------------------------------
# | HTTP Strict Transport Security (HSTS) |
# ----------------------------------------------------------------------
# Force client-side SSL redirection.
#
# If a user types `example.com` in their browser, even if the server
# redirects them to the secure version of the website, that still leaves
# a window of opportunity (the initial HTTP connection) for an attacker
# to downgrade or redirect the request.
#
# The following header ensures that browser will ONLY connect to your
# server via HTTPS, regardless of what the users type in the browser's
# address bar.
#
# (!) Remove the `includeSubDomains` optional directive if the website's
# subdomains are not using HTTPS.
#
# http://www.html5rocks.com/en/tutorials/security/transport-layer-security/
# https://tools.ietf.org/html/draft-ietf-websec-strict-transport-sec-14#section-6.1
# http://blogs.msdn.com/b/ieinternals/archive/2014/08/18/hsts-strict-transport-security-attacks-mitigations-deployment-https.aspx
# <IfModule mod_headers.c>
# Header set Strict-Transport-Security "max-age=16070400; includeSubDomains"
# </IfModule>
# ----------------------------------------------------------------------
# | Reducing MIME type security risks |
# ----------------------------------------------------------------------
# Prevent some browsers from MIME-sniffing the response.
#
# This reduces exposure to drive-by download attacks and cross-origin
# data leaks, and should be left uncommented, especially if the server
# is serving user-uploaded content or content that could potentially be
# treated as executable by the browser.
#
# http://www.slideshare.net/hasegawayosuke/owasp-hasegawa
# http://blogs.msdn.com/b/ie/archive/2008/07/02/ie8-security-part-v-comprehensive-protection.aspx
# http://msdn.microsoft.com/en-us/library/ie/gg622941.aspx
# https://mimesniff.spec.whatwg.org/
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
</IfModule>
# ----------------------------------------------------------------------
# | Reflected Cross-Site Scripting (XSS) attacks |
# ----------------------------------------------------------------------
# (1) Try to re-enable the cross-site scripting (XSS) filter built
# into most web browsers.
#
# The filter is usually enabled by default, but in some cases it
# may be disabled by the user. However, in Internet Explorer for
# example, it can be re-enabled just by sending the
# `X-XSS-Protection` header with the value of `1`.
#
# (2) Prevent web browsers from rendering the web page if a potential
# reflected (a.k.a non-persistent) XSS attack is detected by the
# filter.
#
# By default, if the filter is enabled and browsers detect a
# reflected XSS attack, they will attempt to block the attack
# by making the smallest possible modifications to the returned
# web page.
#
# Unfortunately, in some browsers (e.g.: Internet Explorer),
# this default behavior may allow the XSS filter to be exploited,
# thereby, it's better to inform browsers to prevent the rendering
# of the page altogether, instead of attempting to modify it.
#
# http://hackademix.net/2009/11/21/ies-xss-filter-creates-xss-vulnerabilities
#
# (!) Do not rely on the XSS filter to prevent XSS attacks! Ensure that
# you are taking all possible measures to prevent XSS attacks, the
# most obvious being: validating and sanitizing your website's inputs.
#
# http://blogs.msdn.com/b/ie/archive/2008/07/02/ie8-security-part-iv-the-xss-filter.aspx
# http://blogs.msdn.com/b/ieinternals/archive/2011/01/31/controlling-the-internet-explorer-xss-filter-with-the-x-xss-protection-http-header.aspx
# https://www.owasp.org/index.php/Cross-site_Scripting_%28XSS%29
# <IfModule mod_headers.c>
# # (1) (2)
# Header set X-XSS-Protection "1; mode=block"
# # `mod_headers` cannot match based on the content-type, however,
# # the `X-XSS-Protection` response header should be send only for
# # HTML documents and not for the other resources.
# <FilesMatch "\.(appcache|atom|bbaw|bmp|crx|css|cur|eot|f4[abpv]|flv|geojson|gif|htc|ico|jpe?g|js|json(ld)?|m4[av]|manifest|map|mp4|oex|og[agv]|opus|otf|pdf|png|rdf|rss|safariextz|svgz?|swf|topojson|tt[cf]|txt|vcard|vcf|vtt|webapp|web[mp]|woff2?|xloc|xml|xpi)$">
# Header unset X-XSS-Protection
# </FilesMatch>
# </IfModule>
# ----------------------------------------------------------------------
# | Server software information |
# ----------------------------------------------------------------------
# Prevent Apache from sending in the `Server` response header its
# exact version number, the description of the generic OS-type or
# information about its compiled-in modules.
#
# (!) The `ServerTokens` directive will only work in the main server
# configuration file, so don't try to enable it in the `.htaccess` file!
#
# https://httpd.apache.org/docs/current/mod/core.html#servertokens
# ServerTokens Prod
# ######################################################################
# # WEB PERFORMANCE #
# ######################################################################
# ----------------------------------------------------------------------
# | Compression |
# ----------------------------------------------------------------------
<IfModule mod_deflate.c>
# Force compression for mangled `Accept-Encoding` request headers
# https://developer.yahoo.com/blogs/ydn/pushing-beyond-gzipping-25601.html
<IfModule mod_setenvif.c>
<IfModule mod_headers.c>
SetEnvIfNoCase ^(Accept-EncodXng|X-cept-Encoding|X{15}|~{15}|-{15})$ ^((gzip|deflate)\s*,?\s*)+|[X~-]{4,13}$ HAVE_Accept-Encoding
RequestHeader append Accept-Encoding "gzip,deflate" env=HAVE_Accept-Encoding
</IfModule>
</IfModule>
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Compress all output labeled with one of the following media types.
#
# (!) For Apache versions below version 2.3.7 you don't need to
# enable `mod_filter` and can remove the `<IfModule mod_filter.c>`
# and `</IfModule>` lines as `AddOutputFilterByType` is still in
# the core directives.
#
# https://httpd.apache.org/docs/current/mod/mod_filter.html#addoutputfilterbytype
<IfModule mod_filter.c>
AddOutputFilterByType DEFLATE "application/atom+xml" \
"application/javascript" \
"application/json" \
"application/ld+json" \
"application/manifest+json" \
"application/rdf+xml" \
"application/rss+xml" \
"application/schema+json" \
"application/vnd.geo+json" \
"application/vnd.ms-fontobject" \
"application/x-font-ttf" \
"application/x-javascript" \
"application/x-web-app-manifest+json" \
"application/xhtml+xml" \
"application/xml" \
"font/eot" \
"font/opentype" \
"image/bmp" \
"image/svg+xml" \
"image/vnd.microsoft.icon" \
"image/x-icon" \
"text/cache-manifest" \
"text/css" \
"text/html" \
"text/javascript" \
"text/plain" \
"text/vcard" \
"text/vnd.rim.location.xloc" \
"text/vtt" \
"text/x-component" \
"text/x-cross-domain-policy" \
"text/xml"
</IfModule>
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Map the following filename extensions to the specified
# encoding type in order to make Apache serve the file types
# with the appropriate `Content-Encoding` response header
# (do note that this will NOT make Apache compress them!).
#
# If these files types would be served without an appropriate
# `Content-Enable` response header, client applications (e.g.:
# browsers) wouldn't know that they first need to uncompress
# the response, and thus, wouldn't be able to understand the
# content.
#
# https://httpd.apache.org/docs/current/mod/mod_mime.html#addencoding
<IfModule mod_mime.c>
AddEncoding gzip svgz
</IfModule>
</IfModule>
# ----------------------------------------------------------------------
# | Content transformation |
# ----------------------------------------------------------------------
# Prevent intermediate caches or proxies (e.g.: such as the ones
# used by mobile network providers) from modifying the website's
# content.
#
# https://tools.ietf.org/html/rfc2616#section-14.9.5
#
# (!) If you are using `mod_pagespeed`, please note that setting
# the `Cache-Control: no-transform` response header will prevent
# `PageSpeed` from rewriting `HTML` files, and, if the
# `ModPagespeedDisableRewriteOnNoTransform` directive isn't set
# to `off`, also from rewriting other resources.
#
# https://developers.google.com/speed/pagespeed/module/configuration#notransform
# <IfModule mod_headers.c>
# Header merge Cache-Control "no-transform"
# </IfModule>
# ----------------------------------------------------------------------
# | ETags |
# ----------------------------------------------------------------------
# Remove `ETags` as resources are sent with far-future expires headers.
#
# https://developer.yahoo.com/performance/rules.html#etags
# https://tools.ietf.org/html/rfc7232#section-2.3
# `FileETag None` doesn't work in all cases.
<IfModule mod_headers.c>
Header unset ETag
</IfModule>
FileETag None
# ----------------------------------------------------------------------
# | Expires headers |
# ----------------------------------------------------------------------
# Serve resources with far-future expires headers.
#
# (!) If you don't control versioning with filename-based
# cache busting, you should consider lowering the cache times
# to something like one week.
#
# https://httpd.apache.org/docs/current/mod/mod_expires.html
<IfModule mod_expires.c>
ExpiresActive on
ExpiresDefault "access plus 1 month"
# CSS
ExpiresByType text/css "access plus 1 year"
# Data interchange
ExpiresByType application/atom+xml "access plus 1 hour"
ExpiresByType application/rdf+xml "access plus 1 hour"
ExpiresByType application/rss+xml "access plus 1 hour"
ExpiresByType application/json "access plus 0 seconds"
ExpiresByType application/ld+json "access plus 0 seconds"
ExpiresByType application/schema+json "access plus 0 seconds"
ExpiresByType application/vnd.geo+json "access plus 0 seconds"
ExpiresByType application/xml "access plus 0 seconds"
ExpiresByType text/xml "access plus 0 seconds"
# Favicon (cannot be renamed!) and cursor images
ExpiresByType image/vnd.microsoft.icon "access plus 1 week"
ExpiresByType image/x-icon "access plus 1 week"
# HTML
ExpiresByType text/html "access plus 0 seconds"
# JavaScript
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType application/x-javascript "access plus 1 year"
ExpiresByType text/javascript "access plus 1 year"
# Manifest files
ExpiresByType application/manifest+json "access plus 1 year"
ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds"
ExpiresByType text/cache-manifest "access plus 0 seconds"
# Media files
ExpiresByType audio/ogg "access plus 1 month"
ExpiresByType image/bmp "access plus 1 month"
ExpiresByType image/gif "access plus 1 month"
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/svg+xml "access plus 1 month"
ExpiresByType video/mp4 "access plus 1 month"
ExpiresByType video/ogg "access plus 1 month"
ExpiresByType video/webm "access plus 1 month"
# Web fonts
# Embedded OpenType (EOT)
ExpiresByType application/vnd.ms-fontobject "access plus 1 month"
ExpiresByType font/eot "access plus 1 month"
# OpenType
ExpiresByType font/opentype "access plus 1 month"
# TrueType
ExpiresByType application/x-font-ttf "access plus 1 month"
# Web Open Font Format (WOFF) 1.0
ExpiresByType application/font-woff "access plus 1 month"
ExpiresByType application/x-font-woff "access plus 1 month"
ExpiresByType font/woff "access plus 1 month"
# Web Open Font Format (WOFF) 2.0
ExpiresByType application/font-woff2 "access plus 1 month"
# Other
ExpiresByType text/x-cross-domain-policy "access plus 1 week"
</IfModule>
# ----------------------------------------------------------------------
# | File concatenation |
# ----------------------------------------------------------------------
# Allow concatenation from within specific files.
#
# e.g.:
#
# If you have the following lines in a file called, for
# example, `main.combined.js`:
#
# <!--#include file="js/jquery.js" -->
# <!--#include file="js/jquery.timer.js" -->
#
# Apache will replace those lines with the content of the
# specified files.
# <IfModule mod_include.c>
# <FilesMatch "\.combined\.js$">
# Options +Includes
# AddOutputFilterByType INCLUDES application/javascript \
# application/x-javascript \
# text/javascript
# SetOutputFilter INCLUDES
# </FilesMatch>
# <FilesMatch "\.combined\.css$">
# Options +Includes
# AddOutputFilterByType INCLUDES text/css
# SetOutputFilter INCLUDES
# </FilesMatch>
# </IfModule>
# ----------------------------------------------------------------------
# | Filename-based cache busting |
# ----------------------------------------------------------------------
# If you're not using a build process to manage your filename version
# revving, you might want to consider enabling the following directives
# to route all requests such as `/style.12345.css` to `/style.css`.
#
# To understand why this is important and even a better solution than
# using something like `*.css?v231`, please see:
# http://www.stevesouders.com/blog/2008/08/23/revving-filenames-dont-use-querystring/
# <IfModule mod_rewrite.c>
# RewriteEngine On
# RewriteCond %{REQUEST_FILENAME} !-f
# RewriteRule ^(.+)\.(\d+)\.(bmp|css|cur|gif|ico|jpe?g|js|png|svgz?|webp)$ $1.$3 [L]
# </IfModule>

View File

@ -1,60 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Page Not Found</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* {
line-height: 1.2;
margin: 0;
}
html {
color: #888;
display: table;
font-family: sans-serif;
height: 100%;
text-align: center;
width: 100%;
}
body {
display: table-cell;
vertical-align: middle;
margin: 2em auto;
}
h1 {
color: #555;
font-size: 2em;
font-weight: 400;
}
p {
margin: 0 auto;
width: 280px;
}
@media only screen and (max-width: 280px) {
body, p {
width: 95%;
}
h1 {
font-size: 1.5em;
margin: 0 0 0.3em;
}
}
</style>
</head>
<body>
<h1>Page Not Found</h1>
<p>Sorry, but the page you were trying to view does not exist.</p>
</body>
</html>
<!-- IE needs 512+ bytes: http://blogs.msdn.com/b/ieinternals/archive/2010/08/19/http-error-pages-in-internet-explorer.aspx -->

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Please read: http://msdn.microsoft.com/en-us/library/ie/dn455106.aspx -->
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="tile.png"/>
<square150x150logo src="tile.png"/>
<wide310x150logo src="tile-wide.png"/>
<square310x310logo src="tile.png"/>
</tile>
</msapplication>
</browserconfig>

View File

@ -1,15 +0,0 @@
<?xml version="1.0"?>
<!DOCTYPE cross-domain-policy SYSTEM "http://www.adobe.com/xml/dtds/cross-domain-policy.dtd">
<cross-domain-policy>
<!-- Read this: www.adobe.com/devnet/articles/crossdomain_policy_file_spec.html -->
<!-- Most restrictive policy: -->
<site-control permitted-cross-domain-policies="none"/>
<!-- Least restrictive policy: -->
<!--
<site-control permitted-cross-domain-policies="all"/>
<allow-access-from domain="*" to-ports="*" secure="false"/>
<allow-http-request-headers-from domain="*" headers="*" secure="false"/>
-->
</cross-domain-policy>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,77 +0,0 @@
html {
font-size: 16px;
}
#head {
height:160px;
line-height: 160px;
font-size:200%;
}
/* Login */
#loginForm{
width:600px;
}
#invalidCredentials {
color: red;
}
/* Budgets */
.budget-item {
width: 11.3em;
height: 11.3em;
border: 1px solid dimgray;
border-radius: 0.707em;
display: inline-block;
margin: 1em;
}
.budget-item a {
margin: 8em auto 0.5em;
text-align: center;
display: block;
}
.budget-item .time {
display: block;
text-align: center;
font-size: 70.7%;
}
#wrapper {
display: grid;
grid-template-columns: 300px auto;
}
#sidebar {
margin: 1em;
font-size: 180%;
}
#sidebar ul {
padding: 0;
list-style: none;
font-size: 75%;
}
.two-valued {
display: table;
}
.two-valued * {
display: table-row;
}
.two-valued * * {
display: table-cell;
}
.right {
text-align: right;
}
/* Highlights */
.negative {
color: #d50000;
}
.zero {
color: #888888;
}
.future {
background-color: #cccccc;
}

Some files were not shown because too many files have changed in this diff Show More