1
0
Fork 0
forked from Kispi/Core

feat(core): migrate to pocketbase and update ui

This commit is contained in:
Simon Giesel 2023-06-01 18:34:41 +02:00
parent 84bb25a4fa
commit 364ee76159
98 changed files with 8668 additions and 3150 deletions

View file

@ -2,15 +2,23 @@ kind: pipeline
name: default name: default
steps: steps:
- name: install - name: install-webapp
image: node:18-alpine image: node:lts-alpine
commands: commands:
- corepack pnpm@7.5.1 install - cd webapp
- corepack pnpm@8.6.0 install
- name: build - name: lint-webapp
image: node:18-alpine image: node:lts-alpine
commands: commands:
- corepack pnpm@7.5.1 build - cd webapp
- corepack pnpm@8.6.0 lint
- name: build-webapp
image: node:lts-alpine
commands:
- cd webapp
- corepack pnpm@8.6.0 build
- name: deploy - name: deploy
image: plugins/docker image: plugins/docker

6
.gitignore vendored
View file

@ -20,3 +20,9 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Pocketbase-Data
pb_data
# Environment variables
.env

13
.vscode/settings.json vendored
View file

@ -1,18 +1,29 @@
{ {
"cSpell.words": [ "cSpell.words": [
"appwrite",
"camelcase", "camelcase",
"checkin", "checkin",
"classname",
"cliffbreak", "cliffbreak",
"corepack", "corepack",
"daisyui", "daisyui",
"esnext",
"Gitea", "Gitea",
"godotenv",
"heroicons", "heroicons",
"kispi", "kispi",
"pnpm", "pnpm",
"pocketbase",
"tailwindcss", "tailwindcss",
"typegen",
"vite", "vite",
"vitejs", "vitejs",
"vueuse" "vueuse"
],
"eslint.format.enable": true,
"eslint.workingDirectories": [
{
"directory": "webapp",
"changeProcessCWD": true
}
] ]
} }

View file

@ -26,7 +26,7 @@ docs(changelog): update change log to beta.5
style(webapp): reorder imports style(webapp): reorder imports
``` ```
``` ```
fix(appwrite): need to depend on latest rxjs and zone.js fix(server): need to depend on latest rxjs and zone.js
The version in our package.json gets copied to the one we publish, and users need the latest of these. The version in our package.json gets copied to the one we publish, and users need the latest of these.
``` ```
@ -53,7 +53,7 @@ The following is the list of supported scopes (more to come):
* **core** used for changes to the whole project * **core** used for changes to the whole project
* **webapp** used for changes made in the webapp * **webapp** used for changes made in the webapp
* **appwrite** used for changes made in the Appwrite cloud functions * **server** used for changes made in the server
* **changelog**: used for updating the release notes in CHANGELOG.md * **changelog**: used for updating the release notes in CHANGELOG.md
* none/empty string: useful for `style`, `test` and `refactor` changes that are done across all packages (e.g. `style: add missing semicolons`) * none/empty string: useful for `style`, `test` and `refactor` changes that are done across all packages (e.g. `style: add missing semicolons`)

View file

@ -1,7 +1,25 @@
FROM steebchen/nginx-spa:stable ## Build
FROM golang:1.20-buster as build
COPY dist/ /app WORKDIR /app
EXPOSE 80 COPY server/go.mod ./
COPY server/go.sum ./
COPY server/pkg ./pkg
RUN go mod download
CMD ["nginx"] COPY server/*.go ./
RUN CGO_ENABLED=1 go build -o /server
## Deploy
FROM gcr.io/distroless/base-debian10
WORKDIR /
COPY --from=build /server /server
COPY webapp/dist /webapp
EXPOSE 8090
ENTRYPOINT ["/server", "serve", "--http=0.0.0.0:8090"]

View file

@ -5,7 +5,7 @@
</p> </p>
This is our repository containing all required resources to run the "Core" WebApp from the "Kinderspielstadt Öhringen". This is our repository containing all required resources to run the "Core" WebApp from the "Kinderspielstadt Öhringen".
This repository also contains all the required Appwrite cloud functions if any exist. This repository also contains all the required Pocketbase files to run the backend server.
## 🚀 Getting Started ## 🚀 Getting Started
@ -15,7 +15,7 @@ See deployment for notes on how to deploy the project on a live system.
### 🍽️ Prerequisites ### 🍽️ Prerequisites
`NodeJS (including PNPM)` is required to run this project. `NodeJS (including PNPM)` is required to run this project.
Also a hosted instance of `Appwrite` is required. Also `Go` is required to run the built in server.
### 📦 Installing ### 📦 Installing
@ -31,16 +31,54 @@ Change to the cloned repository
cd Core cd Core
``` ```
To install all required packages run Copy the example environment file
``` ```
pnpm install cp .env.example .env
```
To install all required webapp packages run
```
cd webapp && pnpm install
``` ```
To start the webapp in development mode run the following npm script To start the webapp in development mode run the following npm script
``` ```
pnpm run dev pnpm dev
```
To build the webapp run the following npm script
```
pnpm build
```
To install all required server packages run
```
cd ../server && go mod download
```
To generate unique VAPID keys run the following Go command copy this keys to the `.env` file
```
go run main.go generate-vapid-keys
```
To start the server run the following Go command
```
go run main.go serve
```
If you do changes to the database schema keep in mind to `Export collections` via the Pocketbase UI.
Paste the exported JSON into the `pb_schema.json` file and run the following npm script to update the frontend schema types.
```
cd ../webapp && pnpm typegen
``` ```
## 🧑‍💻 Configure Visual Studio Code ## 🧑‍💻 Configure Visual Studio Code
@ -58,7 +96,9 @@ Please refer to our **[COMMIT_CONVENTION](COMMIT_CONVENTION.md)**
* [PNPM](https://pnpm.io/) - Faster alternative to npm for managing dependencies * [PNPM](https://pnpm.io/) - Faster alternative to npm for managing dependencies
* [Vue.js](https://vuejs.org/) - The Frontend Web Framework * [Vue.js](https://vuejs.org/) - The Frontend Web Framework
* [Vite](https://vitejs.dev/) - Used Frontend Tooling * [Vite](https://vitejs.dev/) - Used Frontend Tooling
* [Appwrite](https://docs.mongodb.com/) - The hosted Backend used for this application * [Go](https://go.dev/) - The Backend Programming Language
* [Pocketbase](https://pocketbase.io/) - The Backend Framework
## 🤵 Authors ## 🤵 Authors

View file

@ -1,36 +0,0 @@
{
"name": "kispi-core",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite --host",
"build": "vue-tsc --noEmit && vite build",
"lint": "eslint -c .eslintrc.js src/",
"preview": "vite preview"
},
"dependencies": {
"@heroicons/vue": "^1.0.6",
"@vueuse/core": "^8.9.2",
"appwrite": "^9.0.1",
"daisyui": "^2.19.0",
"vue": "^3.2.37",
"vue-router": "4"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.3",
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
"@vitejs/plugin-vue": "^2.3.3",
"autoprefixer": "^10.4.7",
"eslint": "^8.19.0",
"eslint-plugin-vue": "^9.2.0",
"eslint-plugin-vue-scoped-css": "^2.2.0",
"postcss": "^8.4.14",
"sass": "^1.53.0",
"tailwindcss": "^3.1.6",
"typescript": "^4.7.4",
"vite": "^2.9.14",
"vue-eslint-parser": "^9.0.3",
"vue-tsc": "^0.38.5"
}
}

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -1,162 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1181.000000pt" height="1181.000000pt" viewBox="0 0 1181.000000 1181.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1181.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M8929 10966 c-19 -7 -39 -24 -49 -42 -15 -30 -16 -266 -16 -3380 l1
-3349 310 0 310 0 1 3335 c1 1959 -2 3350 -8 3372 -16 67 -39 73 -295 74 -133
0 -235 -4 -254 -10z"/>
<path d="M414 9835 l0 -855 81 0 80 0 0 445 c0 245 3 445 6 445 5 0 541 -544
769 -781 l105 -109 117 0 117 0 -156 158 c-85 86 -290 293 -454 460 -164 166
-299 306 -299 310 0 4 42 44 93 89 50 45 112 101 137 124 50 46 175 159 211
190 13 11 42 37 64 58 22 20 111 101 198 179 l157 142 -112 0 -113 -1 -100
-91 c-55 -50 -140 -128 -189 -172 -49 -45 -100 -90 -112 -101 -12 -11 -54 -49
-94 -85 -40 -36 -82 -74 -94 -85 -13 -11 -60 -55 -107 -97 -110 -100 -131
-118 -138 -118 -3 0 -6 168 -6 373 l0 372 -80 3 -81 3 0 -856z"/>
<path d="M1825 9835 l0 -855 80 0 80 0 0 855 0 855 -80 0 -80 0 0 -855z"/>
<path d="M2394 9835 l0 -855 81 0 80 0 0 745 c0 410 2 745 5 745 3 -1 18 -19
33 -41 16 -23 132 -190 259 -373 127 -182 348 -499 490 -703 l260 -373 104 0
104 0 0 855 0 855 -80 0 -80 0 -1 -37 c0 -21 0 -356 -1 -745 0 -390 -3 -708
-7 -708 -3 0 -55 72 -116 160 -113 165 -169 244 -643 925 l-278 400 -105 3
-105 3 0 -856z"/>
<path d="M4192 9835 l-1 -857 362 5 c199 2 373 7 387 10 14 4 39 9 55 12 216
42 424 183 527 359 202 343 155 818 -107 1079 -71 72 -126 110 -228 157 -164
76 -252 88 -664 89 l-331 2 0 -856z m678 689 c316 -48 523 -219 596 -489 20
-74 26 -258 12 -343 -24 -143 -84 -257 -184 -354 -102 -98 -214 -151 -419
-194 -16 -3 -141 -8 -276 -11 l-245 -5 -1 706 0 707 216 -2 c119 -1 254 -8
301 -15z"/>
<path d="M5958 10683 c0 -5 -2 -389 -3 -855 l-1 -848 542 0 542 0 3 43 c2 23
3 57 1 74 l-3 33 -462 2 -462 3 0 328 0 327 417 0 417 0 -1 73 -1 72 -416 5
-416 5 -1 285 c0 157 2 291 4 298 3 9 101 12 443 12 344 0 439 3 440 13 1 15
1 119 0 130 -1 4 -235 7 -521 7 -286 0 -521 -3 -522 -7z"/>
<path d="M7325 9836 l0 -856 80 0 80 0 0 403 0 402 149 0 150 0 71 -120 c300
-502 356 -595 378 -635 l25 -45 91 -3 c50 -1 91 -1 91 2 0 2 -20 37 -45 78
-24 40 -57 96 -74 123 -16 28 -106 176 -200 330 l-170 280 86 22 c191 51 305
157 337 315 26 126 6 254 -54 346 -63 98 -135 146 -275 183 -72 20 -113 22
-400 26 l-320 5 0 -856z m630 692 c184 -39 289 -169 265 -325 -17 -106 -71
-177 -166 -219 -82 -36 -162 -45 -374 -42 l-195 3 0 285 c-1 157 1 291 3 298
6 16 391 16 467 0z"/>
<path d="M9650 8955 l0 -1601 930 1 930 0 0 230 0 230 -150 -1 c-119 -1 -150
2 -151 13 0 7 -1 105 -1 218 0 113 1 210 1 215 1 6 58 10 151 10 l150 0 0 228
0 229 -137 -2 c-76 0 -145 1 -153 3 -13 3 -15 35 -13 225 1 122 2 224 2 227 1
3 69 5 151 5 l150 -1 0 228 0 228 -32 1 c-18 0 -87 1 -153 2 l-119 2 -1 215
c0 118 1 221 3 228 3 9 43 12 153 12 l149 0 0 228 0 229 -930 0 -930 0 0
-1602z m991 476 c1 -3 2 -60 3 -126 l2 -120 126 0 127 0 -2 -230 -2 -230 -122
1 c-82 0 -123 -3 -124 -10 -1 -6 -2 -63 -3 -126 l-1 -115 -227 -1 c-162 -1
-228 2 -228 10 -1 6 -2 63 -3 126 l-2 115 -123 0 c-67 0 -123 2 -123 5 -1 3
-2 105 -3 227 -1 148 2 224 9 226 5 2 57 3 115 2 58 -1 111 0 118 3 9 3 13 21
12 56 -1 28 -1 83 -1 123 l1 71 225 0 c124 -1 226 -4 226 -7z"/>
<path d="M8316 8691 c-26 -10 -30 -45 -8 -64 16 -15 20 -15 40 -1 24 17 29 43
10 62 -13 13 -15 14 -42 3z"/>
<path d="M7785 8654 c3 -35 7 -37 120 -78 55 -20 165 -60 244 -89 136 -51 142
-55 115 -64 -130 -47 -425 -157 -451 -169 -30 -14 -35 -25 -24 -61 1 -2 31 9
69 23 37 14 74 27 82 29 23 6 412 154 417 158 10 11 10 47 0 55 -7 5 -41 19
-77 32 -36 13 -83 31 -105 40 -37 16 -104 41 -317 120 l-76 29 3 -25z"/>
<path d="M995 8394 c-11 -2 -45 -9 -75 -15 -246 -49 -425 -214 -469 -436 -14
-70 -7 -225 13 -294 38 -128 130 -235 264 -307 42 -22 177 -75 300 -116 239
-82 281 -100 355 -153 89 -66 130 -150 129 -273 -1 -110 -41 -200 -125 -277
-92 -84 -209 -123 -360 -119 -191 6 -333 78 -427 216 -13 19 -25 37 -26 38 -2
3 -159 -96 -183 -114 -13 -10 91 -124 162 -177 78 -59 228 -118 341 -135 94
-13 296 -7 366 12 316 85 490 348 441 666 -27 172 -122 291 -303 379 -57 28
-146 62 -198 77 -324 89 -478 175 -529 294 -50 116 -44 257 15 357 72 124 202
190 389 198 165 7 271 -31 375 -135 33 -33 62 -60 65 -60 3 0 41 26 85 59 l80
59 -58 60 c-87 90 -181 145 -307 178 -58 15 -279 28 -320 18z"/>
<path d="M2074 7311 l0 -1041 98 0 98 0 0 495 0 495 263 0 c276 1 307 3 399
26 268 68 422 267 412 536 -6 164 -53 271 -163 371 -76 68 -160 105 -326 143
-16 4 -199 9 -405 11 l-376 4 0 -1040z m751 844 c98 -19 152 -44 216 -103 82
-75 104 -129 103 -257 0 -88 -3 -106 -26 -150 -34 -65 -89 -119 -152 -150 -93
-45 -169 -55 -443 -55 l-253 0 0 365 0 366 243 -1 c158 -1 266 -6 312 -15z"/>
<path d="M3685 7313 l0 -1038 98 -3 97 -3 0 1041 0 1040 -98 0 -97 0 0 -1037z"/>
<path d="M4374 7310 l1 -1040 660 0 660 0 0 90 1 90 -563 0 -563 0 0 405 0
405 505 0 505 0 0 90 0 90 -505 0 -505 0 0 365 0 365 333 0 c182 0 425 0 540
0 l207 0 0 90 0 90 -638 0 -638 0 0 -1040z"/>
<path d="M6045 7313 l0 -1038 583 -3 582 -2 0 90 0 90 -485 0 -485 0 0 950 0
950 -98 0 -97 0 0 -1037z"/>
<path d="M8307 8138 c-29 -22 -10 -68 28 -68 35 0 46 37 19 64 -19 19 -25 20
-47 4z"/>
<path d="M8319 7975 c-1 -3 -2 -74 -3 -157 l-1 -153 -112 0 -113 -1 0 143 0
143 -25 0 -24 0 -3 -142 -3 -143 -97 0 c-59 0 -99 4 -99 10 -1 5 -3 69 -4 140
-3 163 -2 154 -22 150 -10 -2 -20 -4 -23 -4 -3 -1 -5 -80 -5 -176 l0 -175 290
0 290 0 -1 178 c-1 97 -2 180 -3 185 -1 8 -40 10 -42 2z"/>
<path d="M7789 7267 c0 -7 -2 -20 -4 -29 -3 -16 15 -17 213 -17 120 0 235 0
257 0 l40 -1 -50 -33 c-120 -80 -451 -312 -457 -321 -3 -5 -5 -21 -4 -35 l3
-26 289 0 c308 0 293 -2 285 43 0 4 -113 7 -251 7 -184 0 -246 3 -237 11 6 6
50 37 97 69 399 273 397 271 395 307 l-2 33 -286 2 c-211 1 -287 -2 -288 -10z"/>
<path d="M8318 6703 c-3 -4 -4 -74 -4 -155 1 -94 -2 -148 -9 -149 -5 -1 -56
-2 -112 -3 l-103 -1 0 142 0 143 -25 0 -25 0 0 -143 0 -143 -97 2 c-54 1 -100
2 -103 3 -3 0 -5 64 -5 141 0 78 -2 145 -5 150 -3 5 -14 8 -25 6 -19 -3 -20
-12 -20 -170 0 -92 2 -172 6 -177 3 -6 121 -8 290 -7 l284 3 0 180 c0 177 0
180 -22 183 -11 2 -23 0 -25 -5z"/>
<path d="M8055 6188 c-3 -7 -4 -51 -2 -98 2 -83 3 -85 26 -85 23 0 24 3 23 67
l-1 66 74 4 c114 5 127 0 141 -50 19 -65 12 -179 -14 -229 -88 -168 -369 -164
-457 7 -22 42 -28 144 -11 191 7 21 23 49 35 65 l22 28 -21 20 c-21 21 -21 21
-36 1 -42 -55 -57 -101 -60 -181 -3 -71 0 -87 25 -140 38 -80 112 -144 190
-162 60 -14 165 -6 219 18 70 30 133 101 157 175 22 70 16 186 -13 261 l-20
49 -136 3 c-103 2 -138 -1 -141 -10z"/>
<path d="M7786 5563 c-16 -40 -4 -42 250 -41 135 0 247 -2 249 -3 1 -2 -65
-50 -149 -107 -330 -225 -349 -239 -352 -264 -5 -49 -5 -48 296 -46 273 3 283
4 286 23 2 11 -1 22 -6 25 -5 3 -119 5 -254 5 -135 0 -246 1 -246 2 0 2 242
170 442 307 63 43 67 50 59 105 0 5 -123 9 -286 8 -228 0 -285 -3 -289 -14z"/>
<path d="M860 5324 c-113 -25 -231 -91 -297 -164 -33 -36 -91 -158 -98 -205
-18 -121 -2 -236 47 -328 63 -118 165 -182 431 -271 268 -90 338 -131 389
-229 32 -62 31 -193 -2 -262 -113 -232 -488 -277 -685 -81 -28 28 -58 61 -66
74 l-14 22 -77 -51 -78 -51 20 -28 c64 -89 191 -173 320 -210 100 -29 278 -36
375 -14 238 54 395 234 397 458 1 102 -9 153 -42 221 -59 123 -176 203 -390
268 -300 92 -385 140 -441 249 -31 61 -34 183 -6 256 50 131 194 211 375 209
126 -1 208 -35 293 -121 l46 -47 63 44 c35 24 65 48 67 53 6 17 -64 90 -120
128 -90 59 -193 88 -327 92 -77 2 -136 -2 -180 -12z"/>
<path d="M1620 5225 l0 -75 298 -2 297 -3 1 -795 0 -795 82 -3 82 -3 0 801 0
800 300 0 300 0 0 75 0 75 -680 0 -680 0 0 -75z"/>
<path d="M3381 4923 c-91 -208 -175 -400 -187 -428 -12 -27 -108 -248 -214
-490 -105 -242 -194 -443 -197 -447 -2 -5 35 -8 84 -8 l88 0 97 228 96 227
465 3 464 2 94 -230 94 -230 93 0 92 0 -16 38 c-8 20 -38 91 -66 157 -48 116
-70 168 -89 210 -5 11 -46 110 -92 220 -46 110 -171 408 -278 662 l-194 463
-85 0 -85 0 -164 -377z m444 -298 c43 -104 102 -248 132 -318 29 -71 53 -133
53 -138 0 -5 -160 -9 -395 -9 -217 0 -395 3 -395 6 0 6 292 695 331 781 10 23
19 44 19 46 0 3 12 31 26 63 l26 59 63 -150 c34 -82 97 -235 140 -340z"/>
<path d="M4619 5278 c-2 -12 -4 -405 -3 -873 l1 -850 344 0 c401 0 447 4 589
53 130 45 229 107 322 204 170 174 255 455 224 733 -22 195 -127 412 -253 526
-39 35 -157 119 -167 119 -3 0 -29 11 -58 25 -47 21 -140 50 -233 71 -16 4
-195 9 -396 11 l-366 5 -4 -24z m669 -140 c110 -17 188 -40 277 -83 150 -73
244 -166 310 -310 45 -96 62 -183 63 -311 2 -326 -140 -548 -426 -664 -127
-51 -204 -62 -479 -68 l-253 -4 0 727 0 726 226 -2 c124 -1 251 -6 282 -11z"/>
<path d="M6200 5225 l0 -75 298 0 298 0 0 -797 0 -798 82 -3 82 -3 0 158 c0
87 0 447 0 801 l0 642 300 0 300 0 0 75 0 75 -680 0 -680 0 0 -75z"/>
<path d="M7785 4938 l-1 -28 292 0 291 0 -2 27 -1 28 -290 0 -289 0 0 -27z"/>
<path d="M8235 4740 c-71 -43 -136 -79 -142 -80 -7 0 -13 8 -13 18 0 59 -69
122 -133 122 -75 0 -130 -39 -148 -104 -12 -41 -20 -220 -11 -243 3 -10 71
-13 293 -13 269 0 289 1 286 18 -5 35 -15 38 -146 37 l-131 -1 0 51 0 51 123
72 c67 40 128 76 136 81 10 6 26 71 17 71 0 0 -59 -36 -131 -80z m-243 -11
c32 -17 49 -76 46 -159 l-3 -75 -90 -1 c-49 0 -95 2 -101 3 -14 5 -11 147 4
183 24 61 84 81 144 49z"/>
<path d="M7790 4300 c-14 -9 -5 -43 13 -49 9 -3 66 -6 127 -5 l110 1 -2 -161
-3 -161 -115 -1 c-146 -2 -136 0 -136 -29 l0 -25 291 0 292 0 -2 28 -1 27
-137 0 -137 -1 0 162 0 161 128 1 c70 0 132 1 138 1 5 1 10 12 10 26 0 24 -3
25 -60 26 -365 3 -509 3 -516 -1z"/>
<path d="M8620 4053 c-58 -29 -60 -36 -60 -313 0 -184 4 -262 13 -280 27 -55
26 -55 608 -55 497 0 537 2 563 18 15 10 31 29 36 43 13 33 13 505 0 538 -5
14 -21 33 -36 43 -25 16 -67 18 -564 18 -396 0 -542 -3 -560 -12z"/>
<path d="M7994 3760 c-32 -9 -70 -22 -84 -30 -39 -21 -91 -81 -114 -130 -23
-52 -31 -176 -14 -218 31 -76 41 -92 79 -127 61 -55 120 -78 204 -78 83 -1
109 4 162 31 108 56 164 173 148 312 -21 177 -200 290 -381 240z m202 -70 c93
-46 141 -136 131 -246 -13 -137 -126 -223 -279 -212 -88 7 -150 43 -193 112
-26 44 -30 58 -30 125 0 88 16 129 69 180 71 69 206 87 302 41z"/>
<path d="M7646 3571 c-17 -18 -17 -20 3 -40 26 -26 33 -26 53 -3 11 12 13 24
7 40 -10 27 -40 29 -63 3z"/>
<path d="M7652 3418 c-16 -16 -15 -43 3 -58 32 -26 78 25 49 54 -19 19 -36 20
-52 4z"/>
<path d="M8719 3271 c-22 -7 -1217 -1209 -1233 -1238 -14 -28 -30 -93 -30
-123 1 -27 16 -84 32 -115 8 -16 358 -373 777 -792 579 -580 772 -767 806
-782 59 -27 149 -27 208 -1 34 16 230 206 807 783 628 628 767 771 785 812 27
60 27 140 0 200 -14 32 -172 196 -628 651 l-608 609 -452 0 c-249 0 -458 -2
-464 -4z m892 -895 c239 -238 441 -441 448 -450 11 -13 -44 -72 -435 -463
l-449 -448 -442 442 c-244 244 -444 448 -445 454 -3 9 868 894 883 898 3 0
201 -194 440 -433z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

101
server/go.mod Normal file
View file

@ -0,0 +1,101 @@
module kinderspielstadt.de/core-server
go 1.20
require (
github.com/joho/godotenv v1.5.1
github.com/pocketbase/pocketbase v0.16.3
github.com/pterm/pterm v0.12.62
)
require (
atomicgo.dev/cursor v0.1.1 // indirect
atomicgo.dev/keyboard v0.2.9 // indirect
atomicgo.dev/schedule v0.0.2 // indirect
cloud.google.com/go v0.110.0 // indirect
cloud.google.com/go/iam v0.13.0 // indirect
github.com/AlecAivazis/survey/v2 v2.3.6 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go v1.44.273 // indirect
github.com/aws/aws-sdk-go-v2 v1.18.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.25 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.24 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.67 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.28 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.2 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.33.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.10 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.19.0 // indirect
github.com/aws/smithy-go v1.13.5 // indirect
github.com/containerd/console v1.0.3 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/ganigeorgiev/fexpr v0.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/wire v0.5.0 // indirect
github.com/googleapis/gax-go/v2 v2.10.0 // indirect
github.com/gookit/color v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/pocketbase/dbx v1.10.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/cobra v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.29.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/image v0.7.0 // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/oauth2 v0.8.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/term v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.9.2 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.125.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect
google.golang.org/grpc v1.55.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.22.6 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/sqlite v1.22.1 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.1.0 // indirect
)

2984
server/go.sum Normal file

File diff suppressed because it is too large Load diff

19
server/main.go Normal file
View file

@ -0,0 +1,19 @@
package main
import (
"github.com/pocketbase/pocketbase"
"github.com/pterm/pterm"
"kinderspielstadt.de/core-server/pkg/utils"
)
func main() {
pterm.Info.Println("Loading environment variables...")
utils.LoadEnv()
pterm.Info.Println("Starting PocketBase server...")
app := pocketbase.New()
if err := app.Start(); err != nil {
pterm.Fatal.Println(err)
}
}

303
server/pb_schema.json Normal file
View file

@ -0,0 +1,303 @@
[
{
"id": "w85pgrtrmovf916",
"name": "transactions",
"type": "base",
"system": false,
"schema": [
{
"id": "5aeh5giq",
"name": "account",
"type": "relation",
"system": false,
"required": true,
"options": {
"collectionId": "74ftooxenpeq14b",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": [
"firstName",
"lastName"
]
}
},
{
"id": "2bzqyeuk",
"name": "label",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "zsx9i8w7",
"name": "amount",
"type": "number",
"system": false,
"required": false,
"options": {
"min": null,
"max": null
}
}
],
"indexes": [],
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "p0gixtx6jwria0d",
"name": "accountsData",
"type": "base",
"system": false,
"schema": [
{
"id": "6ubgbgeq",
"name": "birthday",
"type": "date",
"system": false,
"required": true,
"options": {
"min": "",
"max": ""
}
},
{
"id": "r8rs5y2b",
"name": "email",
"type": "email",
"system": false,
"required": true,
"options": {
"exceptDomains": null,
"onlyDomains": null
}
},
{
"id": "uu9474bx",
"name": "firstNameParent",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "xmqazgjl",
"name": "lastNameParent",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "atylx3pk",
"name": "street",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "1ocem7g7",
"name": "zipCode",
"type": "number",
"system": false,
"required": true,
"options": {
"min": null,
"max": null
}
},
{
"id": "73do7uq3",
"name": "city",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "ktjkhpmz",
"name": "phone",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "74ftooxenpeq14b",
"name": "accounts",
"type": "base",
"system": false,
"schema": [
{
"id": "as1gvc1r",
"name": "accountNumber",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "vxeq20lk",
"name": "firstName",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "bsinpdk7",
"name": "lastName",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "syfivtki",
"name": "lastCheckIn",
"type": "date",
"system": false,
"required": false,
"options": {
"min": "",
"max": ""
}
},
{
"id": "pkoynx7h",
"name": "personalData",
"type": "relation",
"system": false,
"required": false,
"options": {
"collectionId": "p0gixtx6jwria0d",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": [
"firstNameParent",
"lastNameParent",
"email"
]
}
}
],
"indexes": [
"CREATE UNIQUE INDEX `idx_mPdvrAQ` ON `accounts` (`accountNumber`)"
],
"listRule": "",
"viewRule": null,
"createRule": null,
"updateRule": "@request.data.id = null && @request.data.accountNumber = null && @request.data.firstName = null && @request.data.lastName = null && @request.data.created = null && @request.data.updated = null",
"deleteRule": null,
"options": {}
},
{
"id": "c3zz98wpbn7m6zw",
"name": "accountsList",
"type": "view",
"system": false,
"schema": [
{
"id": "gw7zo5sb",
"name": "accountNumber",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "44jwqwg4",
"name": "name",
"type": "json",
"system": false,
"required": false,
"options": {}
},
{
"id": "dwqo9wuf",
"name": "lastCheckIn",
"type": "date",
"system": false,
"required": false,
"options": {
"min": "",
"max": ""
}
},
{
"id": "etoowqoa",
"name": "balance",
"type": "json",
"system": false,
"required": false,
"options": {}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {
"query": "SELECT\n a.id AS id,\n a.accountNumber AS accountNumber,\n (a.firstName || ' ' || a.lastName) AS name,\n a.lastCheckIn AS lastCheckIn,\n SUM(t.amount) AS balance\nFROM\n accounts AS a\nLEFT JOIN\n transactions AS t\nON\n a.id = t.account\nGROUP BY\n a.id"
}
}
]

View file

@ -0,0 +1,33 @@
package utils
import (
"os"
"strings"
"github.com/joho/godotenv"
"github.com/pterm/pterm"
)
// GetEnv returns the value of the environment variable named by the key.
// If the env key is not present, it returns the default value.
func GetEnv(key, def string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return def
}
func LoadEnv() {
err := godotenv.Load("../.env")
if err != nil {
pterm.Info.Println("No .env file found. Proceeding...")
}
// Add env variables without "VITE_" prefix.
for _, e := range os.Environ() {
pair := strings.Split(e, "=")
if strings.HasPrefix(pair[0], "VITE_") {
os.Setenv(pair[0][5:], pair[1])
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View file

@ -1,53 +0,0 @@
<template>
<AtomModal
:id="id"
:title="`Kontaktdaten von ${name}`"
darker
close-button
>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<tbody>
<tr v-for="entry of contact">
<td>{{ JSON.parse(entry).label }}</td>
<td>{{ JSON.parse(entry).phone }}</td>
</tr>
</tbody>
</table>
<div
v-if="contact.length == 0"
class="alert alert-info shadow-lg"
>
<div>
<InformationCircleIcon class="h-6 w-6" />
<span>Keine Kontaktdaten vorhanden.</span>
</div>
</div>
</div>
</AtomModal>
</template>
<script lang="ts" setup>
import { PropType } from 'vue';
import { InformationCircleIcon } from '@heroicons/vue/outline';
import AtomModal from '../atoms/AtomModal.vue';
defineProps({
id: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
contact: {
type: Array as PropType<string[]>,
required: true,
},
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,66 +0,0 @@
import { Databases, Models, Query } from 'appwrite';
import { AppwriteService } from './appwrite.service';
import { IAccount } from '../../interfaces/account.interface';
const DATABASE_ID = '62dfd5787755e7ed9fcd';
const ACCOUNTS_COLLECTION_ID = '62dfd6ca06197f9baa86';
const sdk = AppwriteService.getSDK();
const database = new Databases(sdk, DATABASE_ID);
export const AccountService = {
async getAccount(accountNumber: string): Promise<IAccount & Models.Document> {
const accountDocument = await database.listDocuments<IAccount & Models.Document>(
ACCOUNTS_COLLECTION_ID, [Query.equal('accountNumber', accountNumber)], 1);
return accountDocument.documents[0];
},
async updateBalance(accountNumber: string, balance: number): Promise<IAccount & Models.Document> {
const account = await this.getAccount(accountNumber);
await database.updateDocument<IAccount & Models.Document>(ACCOUNTS_COLLECTION_ID, account.$id, {
balance: account.balance + balance,
});
return account;
},
async getAllAccounts(): Promise<IAccount[]> {
let accountDocuments: Models.DocumentList<IAccount & Models.Document>,
offset = 0;
const accounts: IAccount[] = [];
do {
accountDocuments = await database.listDocuments<IAccount & Models.Document>(
ACCOUNTS_COLLECTION_ID, [], 100, offset);
accounts.push(...accountDocuments.documents.map(account => ({
accountNumber: account.accountNumber,
firstName: account.firstName,
lastName: account.lastName,
balance: account.balance,
transactions: [],
lastCheckIn: account.lastCheckIn,
})));
offset += 100;
} while(accountDocuments.total > offset);
return accounts;
},
async checkIn(accountNumber: string): Promise<IAccount> {
const account = await this.getAccount(accountNumber);
if(!account) {
throw new Error('Account not found');
}
const accountDocument = await database.updateDocument<IAccount & Models.Document>(ACCOUNTS_COLLECTION_ID, account.$id, {
lastCheckIn: Math.floor(+new Date() / 1000),
});
return accountDocument;
},
async addAccount(account: IAccount): Promise<IAccount> {
const accountDocument = await database.createDocument<IAccount & Models.Document>(ACCOUNTS_COLLECTION_ID, 'unique()', account);
return accountDocument;
},
async migrateAccount(oldAccountNumber: string, newAccountNumber: string): Promise<IAccount> {
const account = await this.getAccount(oldAccountNumber);
if(!account) {
throw new Error('Account not found');
}
await database.updateDocument<IAccount & Models.Document>(ACCOUNTS_COLLECTION_ID, account.$id, {
accountNumber: newAccountNumber,
});
return account;
},
};

View file

@ -1,16 +0,0 @@
import { Client, Account } from 'appwrite';
const sdk = new Client();
sdk
.setEndpoint('http://192.168.1.115/v1')
.setProject('62dfc94a1637dc23acbc');
export const AppwriteService = {
getSDK(): Client {
// TODO Authenticate
const account = new Account(sdk);
account.createAnonymousSession();
return sdk;
},
};

View file

@ -1,78 +0,0 @@
import { Databases, Models, Query } from 'appwrite';
import { AccountService } from './account.service';
import { AppwriteService } from './appwrite.service';
import { IAccount } from '../../interfaces/account.interface';
import { ITransaction } from '../../interfaces/transaction.interface';
const DATABASE_ID = '62dfd5787755e7ed9fcd';
const TRANSACTIONS_COLLECTION_ID = '62dfd81b7671727b27cd';
const DEPOSIT_LABEL = 'Bargeldeinzahlung';
const SALARY_LABEL = 'Gehalt';
const WITHDRAW_LABEL = 'Bargeldauszahlung';
const OPEN_ACCOUNT_LABEL = 'Kontoeröffnung';
const sdk = AppwriteService.getSDK();
const database = new Databases(sdk, DATABASE_ID);
export const BankService = {
async getAccountDetails(accountNumber: string): Promise<IAccount> {
const account = await AccountService.getAccount(accountNumber);
if(!account) {
throw new Error('Account not found');
}
const transactionDocuments = await database.listDocuments<ITransaction & Models.Document>(
TRANSACTIONS_COLLECTION_ID, [Query.equal('accountNumber', accountNumber)], 100, 0, undefined, undefined, ['$createdAt'], ['DESC']);
return {
accountNumber: account.accountNumber,
firstName: account.firstName,
lastName: account.lastName,
balance: account.balance,
transactions: transactionDocuments.documents.map(transaction => ({
label: transaction.label,
amount: transaction.amount,
date: new Date(transaction.$createdAt * 1000),
})),
lastCheckIn: account.lastCheckIn,
};
},
async addTransaction(accountNumber: string, amount: number, type: TransactionType): Promise<ITransaction> {
let label = '';
switch (type) {
case TransactionType.DEPOSIT:
label = DEPOSIT_LABEL;
break;
case TransactionType.SALARY:
label = SALARY_LABEL;
break;
case TransactionType.WITHDRAW:
label = WITHDRAW_LABEL;
break;
case TransactionType.OPEN_ACCOUNT:
label = OPEN_ACCOUNT_LABEL;
break;
}
const transactionDocument = await database.createDocument<ITransaction & Models.Document>(TRANSACTIONS_COLLECTION_ID, 'unique()', {
accountNumber: accountNumber,
label,
amount: (type === TransactionType.WITHDRAW ? -amount : amount),
});
await AccountService.updateBalance(accountNumber, type === TransactionType.WITHDRAW ? -amount : amount);
return { label: transactionDocument.label, amount: transactionDocument.amount, date: new Date(transactionDocument.$createdAt * 1000) };
},
async migrateTransactions(oldAccountNumber: string, newAccountNumber: string): Promise<boolean> {
const transactionDocuments = await database.listDocuments<ITransaction & Models.Document>(
TRANSACTIONS_COLLECTION_ID, [Query.equal('accountNumber', oldAccountNumber)], 100, 0, undefined, undefined, ['$createdAt'], ['DESC']);
transactionDocuments.documents.forEach(async transaction => {
await database.updateDocument(TRANSACTIONS_COLLECTION_ID, transaction.$id, {
accountNumber: newAccountNumber,
});
});
return true;
},
};
export enum TransactionType {
DEPOSIT,
SALARY,
WITHDRAW,
OPEN_ACCOUNT,
}

View file

@ -1,73 +0,0 @@
import { Databases, Models } from 'appwrite';
import { AccountService } from './account.service';
import { AppwriteService } from './appwrite.service';
import { IAccountData } from '../../interfaces/account-data.interface';
import { IPersonalInformation } from '../../interfaces/personal-information.interface';
import { ICreateAccount } from '../../interfaces/create-account.interface';
import { IAccount } from '../../interfaces/account.interface';
import { BankService, TransactionType } from './bank.service';
const OPEN_ACCOUNT_BALANCE = 5;
const DATABASE_ID = '62dfd5787755e7ed9fcd';
const PERSONAL_INFORMATION_COLLECTION_ID = '62e004abb0cdae53b483';
const sdk = AppwriteService.getSDK();
const database = new Databases(sdk, DATABASE_ID);
export const DataService = {
async getAllData(): Promise<IAccountData[]> {
const accounts = (await AccountService.getAllAccounts()) as IAccountData[];
accounts.forEach(account => {
account.name = `${account.firstName} ${account.lastName}`;
});
// let offset = 0,
// personalInformationDocuments: Models.DocumentList<IPersonalInformation & Models.Document>;
// do {
// personalInformationDocuments = await database.listDocuments<IPersonalInformation & Models.Document>(
// PERSONAL_INFORMATION_COLLECTION_ID, [], 100, offset);
// personalInformationDocuments.documents.forEach(personalInformation => {
// const account = accounts.find(account => account.accountNumber === personalInformation.accountNumber);
// if(account) {
// account.name = `${account.firstName} ${account.lastName}`;
// account.birthday = personalInformation.birthday;
// account.address = personalInformation.address;
// account.contact = personalInformation.contact;
// }
// },
// );
// offset += 100;
// } while(personalInformationDocuments.total > offset);
return accounts;
},
async addAccount(account: ICreateAccount): Promise<IAccountData> {
if(await AccountService.getAccount(account.accountNumber)) {
throw new Error('Account already exists');
}
const accountDocument = await AccountService.addAccount({
accountNumber: account.accountNumber,
firstName: account.firstName,
lastName: account.lastName,
balance: 0,
} as IAccount) as IAccountData;
await BankService.addTransaction(account.accountNumber, OPEN_ACCOUNT_BALANCE, TransactionType.OPEN_ACCOUNT);
const personalInformation = await database.createDocument<IAccountData & Models.Document>(
PERSONAL_INFORMATION_COLLECTION_ID, 'unique()', {
accountNumber: account.accountNumber,
birthday: account.birthday,
address: account.address,
contact: account.contact,
} as IPersonalInformation);
if(accountDocument) {
accountDocument.name = `${accountDocument.firstName} ${accountDocument.lastName}`;
accountDocument.birthday = personalInformation.birthday;
accountDocument.address = personalInformation.address;
}
// manually edit balance as the TransactionService updates the value after a transaction was created
accountDocument.balance = OPEN_ACCOUNT_BALANCE;
return accountDocument;
},
async migrateAccount(oldAccountNumber: string, newAccountNumber: string): Promise<boolean> {
await AccountService.migrateAccount(oldAccountNumber, newAccountNumber);
await BankService.migrateTransactions(oldAccountNumber, newAccountNumber);
return true;
},
};

View file

@ -1,44 +0,0 @@
export class DateService {
public static toString(date: Date | number): string {
if(typeof date === 'number') {
date = new Date(date * 1000);
}
if(!date) {
return 'n/a';
}
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
public static toShortString(date: Date | number): string {
if(typeof date === 'number') {
date = new Date(date * 1000);
}
if(!date) {
return 'n/a';
}
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
public static toTime(date: Date | number): string {
if(typeof date === 'number') {
date = new Date(date * 1000);
}
if(!date) {
return 'n/a';
}
return date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
}

View file

@ -1,196 +0,0 @@
<template>
<div
v-if="accountDetails"
class="hero-content flex-col text-center lg:p-6 pt-0"
>
<h1 class="text-8xl mt-5">{{ CurrencyService.toString(accountDetails.balance) }}</h1>
<h3 class="text-5xl font-bold mt-10">Kontoinhaber: {{ `${accountDetails.firstName} ${accountDetails.lastName}` }}</h3>
<h4 class="text-4xl mb-10">Kontonummer: {{ accountDetails.accountNumber }}</h4>
<MoleculeTransactionTable
class="w-[42rem] mb-24"
:balance="accountDetails.balance"
:transactions="accountDetails.transactions"
/>
</div>
<div
v-if="accountDetails"
class="fixed flex place-content-center lg:gap-32 gap-16 bg-base-100 bottom-0 lg:w-[calc(100%-16rem)] p-5"
>
<MoleculeInputModal
:id="depositModalId"
title="Einzahlung"
action-label="Einzahlen"
input-label="Einzuzahlender Betrag:"
@submit="handleDeposit"
@open="depositModalOpen = $event.open"
/>
<label
ref="depositModalLabel"
class="btn gap-2"
:for="depositModalId"
>
<ChevronUpIcon class="w-6 h-6 text-success" />
Einzahlen
</label>
<MoleculeInputModal
:id="salaryModalId"
title="Gehalt"
action-label="Gehalt hinzufügen"
input-label="Gehalt Betrag:"
@submit="handleSalary"
@open="salaryModalOpen = $event.open"
/>
<label
ref="salaryModalLabel"
class="btn gap-2"
:for="salaryModalId"
>
<CurrencyDollarIcon class="w-6 h-6 text-warning" />
Gehalt
</label>
<MoleculeInputModal
:id="withdrawModalId"
title="Auszahlung"
action-label="Auszahlen"
input-label="Auszuzahlender Betrag:"
@submit="handleWithdraw"
@open="withdrawModalOpen = $event.open"
/>
<label
ref="withdrawModalLabel"
class="btn gap-2"
:for="withdrawModalId"
>
<ChevronDownIcon class="w-6 h-6 text-error" />
Auszahlen
</label>
</div>
<AtomHeroText v-else>
Bitte Karte scannen...
<div
v-if="accountNotFound"
class="alert alert-error shadow-lg mt-6"
>
<div>
<XCircleIcon class="w-6 h-6" />
<span class="text-base font-normal">Fehler: Der Account wurde nicht gefunden.</span>
</div>
</div>
</AtomHeroText>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { onKeyStroke, promiseTimeout } from '@vueuse/core';
import { BankService, TransactionType } from '../services/bank.service';
import { CurrencyService } from '../services/currency.service';
import { IAccount } from '../../interfaces/account.interface';
import { ITransaction } from '../../interfaces/transaction.interface';
import { CurrencyDollarIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from '@heroicons/vue/outline';
import AtomHeroText from '../atoms/AtomHeroText.vue';
import MoleculeInputModal from '../molecules/MoleculeInputModal.vue';
import MoleculeTransactionTable from '../molecules/MoleculeTransactionTable.vue';
const route = useRoute();
const router = useRouter();
const accountDetails = ref<IAccount>();
const depositModalLabel = ref<HTMLLabelElement>();
const salaryModalLabel = ref<HTMLLabelElement>();
const withdrawModalLabel = ref<HTMLLabelElement>();
const accountId = ref<string>('');
const inputBuffer = ref<string>('');
const depositModalOpen = ref<boolean>(false);
const salaryModalOpen = ref<boolean>(false);
const withdrawModalOpen = ref<boolean>(false);
const accountNotFound = ref<boolean>(false);
const depositModalId = 'deposit-modal';
const salaryModalId = 'salary-modal';
const withdrawModalId = 'withdraw-modal';
if(route.query.accountNumber) {
accountId.value = route.query.accountNumber as string;
getAccountDetails();
router.replace({ path: '/bank' });
}
onKeyStroke(['e', 'g', 'a'], (e) => {
e.preventDefault();
switch (e.key) {
case 'e':
depositModalLabel.value?.click();
break;
case 'g':
salaryModalLabel.value?.click();
break;
case 'a':
withdrawModalLabel.value?.click();
break;
}
});
onKeyStroke(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Enter'], (e) => {
if(depositModalOpen.value || salaryModalOpen.value || withdrawModalOpen.value) {
return;
}
accountNotFound.value = false;
e.preventDefault();
if(e.key === 'Enter') {
accountId.value = inputBuffer.value;
inputBuffer.value = '';
getAccountDetails();
} else {
inputBuffer.value += e.key;
}
});
async function getAccountDetails() {
try {
accountDetails.value = await BankService.getAccountDetails(accountId.value);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if(error.message == 'Account not found') {
accountNotFound.value = true;
await promiseTimeout(3000);
accountNotFound.value = false;
} else {
// eslint-disable-next-line no-console
console.error(error);
}
}
}
async function handleDeposit(amount: string) {
const amountNumber = parseInt(amount);
if(!isNaN(amountNumber)) {
updateTransactions(await BankService.addTransaction(accountId.value, amountNumber, TransactionType.DEPOSIT));
}
}
async function handleSalary(amount: string) {
const amountNumber = parseInt(amount);
if(!isNaN(amountNumber)) {
updateTransactions(await BankService.addTransaction(accountId.value, amountNumber, TransactionType.SALARY));
}
}
async function handleWithdraw(amount: string) {
const amountNumber = parseInt(amount);
if(!isNaN(amountNumber)) {
updateTransactions(await BankService.addTransaction(accountId.value, amountNumber, TransactionType.WITHDRAW));
}
}
function updateTransactions(transaction: ITransaction) {
if(accountDetails.value) {
accountDetails.value.balance += transaction.amount;
accountDetails.value.transactions.unshift(transaction);
}
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,154 +0,0 @@
<template>
<div
v-show="!pinCorrect"
class="hero min-h-full"
>
<div class="hero-content flex-col text-center">
<AtomInput
placeholder="Bitte PIN eingeben"
type="password"
@input="inputEvent"
/>
</div>
</div>
<div
v-show="pinCorrect"
class="lg:p-6"
>
<AtomInput
placeholder="Nach Vor-/Nachname suchen"
class="mb-4"
@input="filterEvent"
/>
<MoleculeDataTable
v-if="data"
:table-headers="tableHeaders"
:data="data"
:contact-modal-id="contactModalId"
@open-contact-modal="openContactModal"
/>
<MoleculeContactModal
:id="contactModalId"
:name="contactName"
:contact="contact"
/>
<MoleculeMigrateAccountModal
:id="migrateAccountModalId"
/>
<MoleculeAddAccountModal
:id="addAccountModalId"
@submit="addAccount"
/>
<label
class="btn btn-primary btn-lg btn-circle shadow-2xl fixed bottom-8 right-28"
:for="migrateAccountModalId"
>
<SupportIcon class="w-7 h-7" />
</label>
<label
class="btn btn-primary btn-lg btn-circle shadow-2xl fixed bottom-8 right-8"
:for="addAccountModalId"
>
<PlusIcon class="w-7 h-7" />
</label>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { DataService } from '../services/data.service';
import { IAccountData } from '../../interfaces/account-data.interface';
import { PlusIcon, SupportIcon } from '@heroicons/vue/outline';
import MoleculeAddAccountModal from '../molecules/MoleculeAddAccountModal.vue';
import MoleculeContactModal from '../molecules/MoleculeContactModal.vue';
import MoleculeDataTable, { TableHeaderType } from '../molecules/MoleculeDataTable.vue';
import AtomInput from '../atoms/AtomInput.vue';
import MoleculeMigrateAccountModal from '../molecules/MoleculeMigrateAccountModal.vue';
const ACCESS_PIN_KEY = '1337';
const pinCorrect = ref(false);
const data = ref<IAccountData[]>([]);
const initialData = ref<IAccountData[]>([]);
const contactName = ref('');
const contact = ref<string[]>([]);
const contactModalId = 'contact-modal';
const addAccountModalId = 'add-account-modal';
const migrateAccountModalId = 'migrate-account-modal';
const tableHeaders = [
{
title: 'Kontonummer',
key: 'accountNumber',
type: TableHeaderType.STRING,
},
{
title: 'Name',
key: 'name',
type: TableHeaderType.STRING,
},
// {
// title: 'Geburtsdatum',
// key: 'birthday',
// type: TableHeaderType.STRING,
// },
{
title: 'Kontostand',
key: 'balance',
type: TableHeaderType.CURRENCY,
},
// {
// title: 'Adresse',
// key: 'address',
// type: TableHeaderType.STRING,
// },
{
title: 'Letzter Login',
key: 'lastCheckIn',
type: TableHeaderType.DATETIME,
},
// {
// title: 'Kontakt',
// key: '',
// type: TableHeaderType.BUTTON_CONTACT,
// },
{
title: 'Trans.',
key: '',
type: TableHeaderType.BUTTON_ACCOUNT,
},
];
onMounted(async () => {
data.value = await DataService.getAllData();
initialData.value = data.value;
});
function openContactModal(data: { name: string, contact: string[] }) {
contactName.value = data.name;
contact.value = data.contact;
}
function addAccount(account: IAccountData) {
data.value.push(account);
initialData.value = data.value;
}
function inputEvent(event: Event) {
if((event.target as HTMLInputElement).value == ACCESS_PIN_KEY) {
pinCorrect.value = true;
}
}
function filterEvent(event: Event) {
const input = (event.target as HTMLInputElement).value;
data.value = initialData.value;
if(input === '') {
return;
}
data.value = data.value.filter((account: IAccountData) => {
return `${account.firstName} ${account.lastName}`.toLocaleLowerCase().includes(input.toLocaleLowerCase());
});
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,8 +0,0 @@
import { IAccount } from './account.interface';
export interface IAccountData extends IAccount {
name: string;
birthday: string;
address: string;
contact: string[];
}

View file

@ -1,10 +0,0 @@
import { ITransaction } from './transaction.interface';
export interface IAccount {
accountNumber: string;
firstName: string;
lastName: string;
balance: number;
transactions: ITransaction[];
lastCheckIn: number;
}

View file

@ -1,8 +0,0 @@
export interface ICreateAccount {
accountNumber: string;
firstName: string;
lastName: string;
birthday: string;
address: string;
contact: string[];
}

View file

@ -1,6 +0,0 @@
export interface IPersonalInformation {
accountNumber: string;
birthday: string;
address: string;
contact: string[];
}

View file

@ -1,5 +0,0 @@
export interface ITransaction {
label: string;
amount: number;
date: Date;
}

View file

@ -1,10 +0,0 @@
module.exports = {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/typography'), require('daisyui')],
}

1
webapp/.eslintignore Normal file
View file

@ -0,0 +1 @@
src/types/pocketbase.types.ts

View file

@ -4,6 +4,7 @@ module.exports = {
'eslint:recommended', 'eslint:recommended',
'plugin:vue/vue3-recommended', 'plugin:vue/vue3-recommended',
'plugin:vue-scoped-css/vue3-recommended', 'plugin:vue-scoped-css/vue3-recommended',
'plugin:tailwindcss/recommended',
], ],
parser: 'vue-eslint-parser', parser: 'vue-eslint-parser',
env: { env: {
@ -70,7 +71,8 @@ module.exports = {
parserOptions: { parserOptions: {
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
sourceType: 'module', sourceType: 'module',
'project': 'tsconfig.json', project: 'tsconfig.json',
tsconfigRootDir: __dirname,
extraFileExtensions: ['.vue'], extraFileExtensions: ['.vue'],
}, },
plugins: [ plugins: [
@ -138,6 +140,7 @@ module.exports = {
'svg': 'always', 'svg': 'always',
'math': 'always', 'math': 'always',
}], }],
'tailwindcss/no-custom-classname': 'off',
}, },
}, },
], ],

42
webapp/package.json Normal file
View file

@ -0,0 +1,42 @@
{
"name": "kispi-core",
"private": true,
"author": "Simon Giesel",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"lint": "eslint -c .eslintrc.js src/",
"preview": "vite preview",
"typegen": "pocketbase-typegen --json ../server/pb_schema.json --out ./src/types/pocketbase.types.ts"
},
"dependencies": {
"@heroicons/vue": "^2.0.18",
"@vueuse/core": "^10.1.2",
"canvas-confetti": "^1.6.0",
"daisyui": "^2.52.0",
"pocketbase": "^0.15.1",
"vue": "^3.3.4",
"vue-router": "4.2.2"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@types/canvas-confetti": "^1.6.0",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.59.8",
"@vitejs/plugin-vue": "^4.2.3",
"autoprefixer": "^10.4.14",
"eslint": "^8.41.0",
"eslint-plugin-tailwindcss": "^3.12.1",
"eslint-plugin-vue": "^9.14.1",
"eslint-plugin-vue-scoped-css": "^2.4.0",
"pocketbase-typegen": "^1.1.9",
"postcss": "^8.4.24",
"sass": "^1.62.1",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.4",
"vite": "^4.3.9",
"vue-eslint-parser": "^9.3.0",
"vue-tsc": "^1.6.5"
}
}

2987
webapp/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -3,7 +3,7 @@
<msapplication> <msapplication>
<tile> <tile>
<square150x150logo src="/mstile-150x150.png"/> <square150x150logo src="/mstile-150x150.png"/>
<TileColor>#b91d47</TileColor> <TileColor>#da532c</TileColor>
</tile> </tile>
</msapplication> </msapplication>
</browserconfig> </browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
webapp/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

2
webapp/public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-Agent: *
Disallow: /

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 77 KiB

View file

@ -1,6 +1,6 @@
{ {
"name": "Kinderspielstadt Öhringen", "name": "",
"short_name": "KiSpi Öhringen", "short_name": "",
"icons": [ "icons": [
{ {
"src": "/android-chrome-192x192.png", "src": "/android-chrome-192x192.png",

View file

@ -11,12 +11,12 @@
import { INavigationEntry } from './interfaces/navigation-entry.interface'; import { INavigationEntry } from './interfaces/navigation-entry.interface';
import { import {
IdentificationIcon, IdentificationIcon,
LibraryIcon, BuildingLibraryIcon,
LogoutIcon, ArrowRightOnRectangleIcon,
MusicNoteIcon, MusicalNoteIcon,
TrendingUpIcon, ArrowTrendingUpIcon,
UserIcon, UserIcon,
} from '@heroicons/vue/outline'; } from '@heroicons/vue/24/outline';
import MoleculeNavigationDrawer from './components/molecules/MoleculeNavigationDrawer.vue'; import MoleculeNavigationDrawer from './components/molecules/MoleculeNavigationDrawer.vue';
const drawerId = 'default-drawer'; const drawerId = 'default-drawer';
@ -33,28 +33,29 @@ const navigationEntries: INavigationEntry[] = [
}, },
{ {
name: 'Bank', name: 'Bank',
icon: LibraryIcon, icon: BuildingLibraryIcon,
to: '/bank', to: '/bank',
}, },
{ {
name: 'Börse', name: 'Börse',
icon: TrendingUpIcon, icon: ArrowTrendingUpIcon,
to: '/stocks', to: '/stocks',
disabled: true, disabled: true,
}, },
{ {
name: 'Radio', name: 'Radio',
icon: MusicNoteIcon, icon: MusicalNoteIcon,
to: '/radio', to: '/radio',
disabled: true,
}, },
{ // {
name: 'divider', // name: 'divider',
}, // },
{ // {
name: 'Abmelden', // name: 'Abmelden',
icon: LogoutIcon, // icon: ArrowRightOnRectangleIcon,
to: '/logout', // to: '/logout',
}, // },
]; ];
</script> </script>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -3,12 +3,12 @@
<AtomInput <AtomInput
ref="input" ref="input"
type="number" type="number"
class="text-right pr-16" class="pr-16 text-right"
step="1" step="1"
min="1" min="1"
max="100" max="100"
/> />
<span class="text-sm opacity-50 absolute -ml-16 top-2/4 -mt-8 pointer-events-none">,00 Öro</span> <span class="pointer-events-none absolute top-2/4 -ml-16 -mt-8 text-sm opacity-50">,00 Öro</span>
</div> </div>
</template> </template>

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="min-h-full lg:p-6"> <div class="min-h-full lg:p-6">
<div class="hero min-h-full bg-base-200 rounded-lg"> <div class="hero min-h-full rounded-lg bg-base-200">
<div class="hero-content flex-col text-center"> <div class="hero-content flex-col text-center">
<h1 class="text-6xl font-bold"><slot /></h1> <h1 class="text-6xl font-bold"><slot /></h1>
</div> </div>

View file

@ -2,9 +2,10 @@
<input <input
ref="input" ref="input"
:type="type" :type="type"
:required="required"
:placeholder="placeholder" :placeholder="placeholder"
:value="modelValue" :value="modelValue"
class="input input-bordered w-full" class="input-bordered input w-full"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)" @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/> />
</template> </template>
@ -15,6 +16,10 @@ defineProps({
type: String, type: String,
default: 'text', default: 'text',
}, },
required: {
type: Boolean,
default: false,
},
placeholder: { placeholder: {
type: String, type: String,
default: '', default: '',

View file

@ -1,11 +1,13 @@
<template> <template>
<img <img
v-if="isDark" v-if="isDark"
src="../../assets/logo_white.png" src="../../assets/logo_white.svg"
class="w-48 scale-[1.4]"
/> />
<img <img
v-else v-else
src="../../assets/logo.png" src="../../assets/logo_black.svg"
class="w-48 scale-[1.4]"
/> />
</template> </template>

View file

@ -11,15 +11,22 @@
class="modal cursor-pointer" class="modal cursor-pointer"
> >
<label <label
:class="`modal-box${darker ? ' bg-base-300' : ''}`" class="modal-box"
:class="{
'bg-base-300': darker,
'w-auto': wAuto,
}"
for="" for=""
> >
<label <label
v-if="closeButton" v-if="closeButton"
:for="id" :for="id"
class="btn btn-sm btn-circle absolute right-2 top-2" class="btn-sm btn-circle btn sticky left-full top-2"
></label> ></label>
<h3 class="font-bold text-lg">{{ title }}</h3> <h3
v-if="title"
class="text-lg font-bold"
>{{ title }}</h3>
<p class="py-4"><slot /></p> <p class="py-4"><slot /></p>
<div class="modal-action"> <div class="modal-action">
<slot name="action" /> <slot name="action" />
@ -37,7 +44,7 @@ defineProps({
}, },
title: { title: {
type: String, type: String,
required: true, default: '',
}, },
darker: { darker: {
type: Boolean, type: Boolean,
@ -47,6 +54,10 @@ defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
wAuto: {
type: Boolean,
default: false,
},
}); });
defineEmits(['open']); defineEmits(['open']);

View file

@ -1,6 +1,6 @@
<template> <template>
<select <select
class="select select-bordered w-full" class="select-bordered select w-full"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)" @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
> >
<option <option

View file

@ -20,11 +20,11 @@ import { FunctionalComponent, PropType } from 'vue';
defineProps({ defineProps({
onIcon: { onIcon: {
type: Object as PropType<FunctionalComponent>, type: Function as PropType<FunctionalComponent>,
required: true, required: true,
}, },
offIcon: { offIcon: {
type: Object as PropType<FunctionalComponent>, type: Function as PropType<FunctionalComponent>,
required: true, required: true,
}, },
checked: { checked: {

View file

@ -71,14 +71,14 @@
:for="id" :for="id"
class="btn gap-2" class="btn gap-2"
> >
<XCircleIcon class="w-6 h-6" /> <XCircleIcon class="h-6 w-6" />
Abbrechen Abbrechen
</label> </label>
<button <button
class="btn gap-2" class="btn gap-2"
@click="handleSubmit" @click="handleSubmit"
> >
<CheckCircleIcon class="w-6 h-6" /> <CheckCircleIcon class="h-6 w-6" />
Speichern Speichern
</button> </button>
</template> </template>
@ -87,8 +87,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { DataService } from '../services/data.service'; import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/outline';
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/outline';
import AtomInput from '../atoms/AtomInput.vue'; import AtomInput from '../atoms/AtomInput.vue';
import AtomModal from '../atoms/AtomModal.vue'; import AtomModal from '../atoms/AtomModal.vue';
import AtomSelect from '../atoms/AtomSelect.vue'; import AtomSelect from '../atoms/AtomSelect.vue';
@ -114,43 +113,44 @@ defineProps({
}); });
async function handleSubmit() { async function handleSubmit() {
try { alert('TODO: Implement handleSubmit');
const account = await DataService.addAccount( // try {
{ // const account = await DataService.addAccount(
accountNumber: accountNumber.value, // {
firstName: firstName.value, // accountNumber: accountNumber.value,
lastName: lastName.value, // firstName: firstName.value,
birthday: birthday.value, // lastName: lastName.value,
address: `${street.value} ${houseNumber.value}, ${zipCode.value} ${city.value}`, // birthday: birthday.value,
contact: contactLabel.value.map((label, index) => JSON.stringify({ // address: `${street.value} ${houseNumber.value}, ${zipCode.value} ${city.value}`,
label, // contact: contactLabel.value.map((label, index) => JSON.stringify({
phone: contactPhone.value[index], // label,
})), // phone: contactPhone.value[index],
}, // })),
); // },
emit('submit', account); // );
firstName.value = ''; // emit('submit', account);
lastName.value = ''; // firstName.value = '';
accountNumber.value = ''; // lastName.value = '';
birthday.value = ''; // accountNumber.value = '';
street.value = ''; // birthday.value = '';
houseNumber.value = ''; // street.value = '';
zipCode.value = ''; // houseNumber.value = '';
city.value = ''; // zipCode.value = '';
contactLabel.value = []; // city.value = '';
contactPhone.value = []; // contactLabel.value = [];
contactCount.value = 1; // contactPhone.value = [];
abortButton.value.click(); // contactCount.value = 1;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // abortButton.value.click();
} catch (error: any) { // // eslint-disable-next-line @typescript-eslint/no-explicit-any
if(error.message == 'Account already exists') { // } catch (error: any) {
alert('Konto existiert bereits'); // if(error.message == 'Account already exists') {
} else { // alert('Konto existiert bereits');
alert('Fehler beim Speichern'); // } else {
// eslint-disable-next-line no-console // alert('Fehler beim Speichern');
console.error(error); // // eslint-disable-next-line no-console
} // console.error(error);
} // }
// }
} }
function handleContactChange(index: number) { function handleContactChange(index: number) {

View file

@ -0,0 +1,44 @@
<template>
<div class="hero min-h-full">
<form
class="hero-content flex-col text-center"
@submit.prevent="handleAuth"
>
<AtomInput
v-model="email"
placeholder="Admin-Email"
type="email"
required
/>
<AtomInput
v-model="password"
placeholder="Passwort"
type="password"
required
/>
<button class="btn gap-2 self-end">
<ArrowRightOnRectangleIcon class="h-6 w-6" />
Login
</button>
</form>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { AuthService } from '../../services/auth.service';
import { ArrowRightOnRectangleIcon } from '@heroicons/vue/24/outline';
import AtomInput from '../atoms/AtomInput.vue';
const email = ref('');
const password = ref('');
async function handleAuth() {
await AuthService.loginAsAdmin(email.value, password.value);
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,120 @@
<template>
<AtomModal
:id="id"
close-button
w-auto
>
<div class="-my-16 overflow-x-auto">
<template v-if="accountDetails">
<div class="stats stats-vertical">
<div class="stat">
<div class="stat-title">Name des Kindes</div>
<div class="stat-value">{{ accountDetails.firstName }} {{ accountDetails.lastName }}</div>
<div class="stat-desc">Vorname Nachname</div>
</div>
<div class="stat">
<div class="stat-title">Kontonummer</div>
<div class="stat-value">{{ accountDetails.accountNumber }}</div>
<div class="stat-desc">ID: {{ accountDetails.id }}</div>
</div>
<div class="stat">
<div class="stat-title">Letzter Check-In</div>
<div class="stat-value">{{ accountDetails.lastCheckIn ? DateService.toTime(new Date(accountDetails.lastCheckIn)) : 'N/A' }}</div>
<div
v-if="accountDetails.lastCheckIn"
class="stat-desc"
>
{{ DateService.toShortString(new Date(accountDetails.lastCheckIn)) }}
</div>
</div>
<template v-if="accountDetails.expand?.personalData">
<div class="stat">
<div class="stat-title">Geburtstag</div>
<div class="stat-value">{{ DateService.toShortString(new Date(accountDetails.expand.personalData.birthday)) }}</div>
</div>
<div class="stat">
<div class="stat-title">Name der Eltern</div>
<div class="stat-value">
{{ accountDetails.expand.personalData.firstNameParent }}
{{ accountDetails.expand.personalData.lastNameParent }}
</div>
<div class="stat-desc">Vorname Nachname</div>
</div>
<div class="stat">
<div class="stat-title">Adresse</div>
<div class="stat-value">{{ accountDetails.expand.personalData.street }}</div>
<div class="stat-desc">{{ accountDetails.expand.personalData.zipCode }} {{ accountDetails.expand.personalData.city }}</div>
</div>
<div
class="stat cursor-pointer"
@click="truncateEmail = !truncateEmail"
>
<div class="stat-title">E-Mail-Adresse</div>
<div
class="stat-value"
:class="{'truncate': truncateEmail}"
>
{{ accountDetails.expand.personalData.email }}
</div>
<div class="stat-desc">Um die E-Mail-Adresse komplett anzuzeigen, bitte drücken und scrollen.</div>
</div>
<div
class="stat cursor-pointer"
@click="truncatePhone = !truncatePhone"
>
<div class="stat-title">Telefonnummer</div>
<div
class="stat-value"
:class="{'truncate': truncatePhone}"
>
{{ accountDetails.expand.personalData.phone }}
</div>
<div class="stat-desc">Um die Telefonnummer komplett anzuzeigen, bitte drücken und scrollen.</div>
</div>
</template>
<template
v-else
>
<div class="stat">
<div class="stat-title">Keine Persönlichen Daten vorhanden</div>
<div class="stat-desc">Dies ist vermutlich ein technischer Fehler.</div>
</div>
</template>
</div>
</template>
</div>
</AtomModal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { AccountsDataResponse, AccountsResponse } from '../../types/pocketbase.types';
import { AccountService } from '../../services/account.service';
import { DateService } from '../../services/date.service';
import AtomModal from '../atoms/AtomModal.vue';
const accountDetails = ref<AccountsResponse<{personalData: AccountsDataResponse}>>();
const truncateEmail = ref(true);
const truncatePhone = ref(true);
const props = defineProps({
id: {
type: String,
required: true,
},
contactId: {
type: String,
required: true,
},
});
watch(() => props.contactId, async (contactId) => {
if(contactId) {
accountDetails.value = await AccountService.getAccountDetails(contactId);
}
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table w-full table-zebra"> <table class="table-zebra table w-full">
<thead class="sticky top-0"> <thead class="sticky top-0">
<tr> <tr>
<th v-for="header in tableHeaders">{{ header.title }}</th> <th v-for="header in tableHeaders">{{ header.title }}</th>
@ -19,7 +19,7 @@
{{ DateService.toShortString(entry[header.key]) }} {{ DateService.toShortString(entry[header.key]) }}
</span> </span>
<span v-else-if="header.type === TableHeaderType.DATETIME"> <span v-else-if="header.type === TableHeaderType.DATETIME">
{{ DateService.toString(entry[header.key]) }} {{ entry[header.key] ? DateService.toString(new Date(entry[header.key])): 'N/A' }}
</span> </span>
<span v-else-if="header.type === TableHeaderType.CURRENCY"> <span v-else-if="header.type === TableHeaderType.CURRENCY">
{{ CurrencyService.toString(entry[header.key]) }} {{ CurrencyService.toString(entry[header.key]) }}
@ -27,15 +27,15 @@
<label <label
v-if="header.type === TableHeaderType.BUTTON_CONTACT" v-if="header.type === TableHeaderType.BUTTON_CONTACT"
:for="contactModalId" :for="contactModalId"
class="btn btn-sm btn-ghost p-1" class="btn-ghost btn-sm btn p-1"
@click="$emit('open-contact-modal', { name: entry.name, contact: entry.contact })" @click="$emit('open-contact-modal', entry.id)"
> >
<DocumentTextIcon class="h-6 w-6" /> <DocumentTextIcon class="h-6 w-6" />
</label> </label>
<RouterLink <RouterLink
v-if="header.type === TableHeaderType.BUTTON_ACCOUNT" v-if="header.type === TableHeaderType.BUTTON_ACCOUNT"
:to="`/bank?accountNumber=${entry.accountNumber}`" :to="`/bank?accountNumber=${entry.accountNumber}`"
class="btn btn-sm btn-ghost p-1" class="btn-ghost btn-sm btn p-1"
> >
<CurrencyDollarIcon class="h-6 w-6" /> <CurrencyDollarIcon class="h-6 w-6" />
</RouterLink> </RouterLink>
@ -48,9 +48,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PropType } from 'vue'; import { PropType } from 'vue';
import { CurrencyService } from '../services/currency.service'; import { DateService } from '../../services/date.service';
import { DateService } from '../services/date.service'; import { CurrencyService } from '../../services/currency.service';
import { CurrencyDollarIcon, DocumentTextIcon } from '@heroicons/vue/outline'; import { CurrencyDollarIcon, DocumentTextIcon } from '@heroicons/vue/24/outline';
defineProps({ defineProps({
tableHeaders: { tableHeaders: {

View file

@ -4,7 +4,7 @@
:title="title" :title="title"
@open="handleOpen" @open="handleOpen"
> >
<div class="flex justify-between place-items-center"> <div class="flex place-items-center justify-between">
<span>{{ inputLabel }}</span> <span>{{ inputLabel }}</span>
<AtomCurrencyInput <AtomCurrencyInput
ref="input" ref="input"
@ -19,14 +19,14 @@
class="btn gap-2" class="btn gap-2"
:for="id" :for="id"
> >
<XCircleIcon class="w-6 h-6" /> <XCircleIcon class="h-6 w-6" />
Abbrechen Abbrechen
</label> </label>
<button <button
class="btn gap-2" class="btn gap-2"
@click="handleSubmit()" @click="handleSubmit()"
> >
<CheckCircleIcon class="w-6 h-6" /> <CheckCircleIcon class="h-6 w-6" />
{{ actionLabel }} {{ actionLabel }}
</button> </button>
</template> </template>
@ -35,7 +35,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/outline'; import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/outline';
import AtomCurrencyInput from '../atoms/AtomCurrencyInput.vue'; import AtomCurrencyInput from '../atoms/AtomCurrencyInput.vue';
import AtomModal from '../atoms/AtomModal.vue'; import AtomModal from '../atoms/AtomModal.vue';

View file

@ -21,14 +21,14 @@
:for="id" :for="id"
class="btn gap-2" class="btn gap-2"
> >
<XCircleIcon class="w-6 h-6" /> <XCircleIcon class="h-6 w-6" />
Abbrechen Abbrechen
</label> </label>
<button <button
class="btn gap-2" class="btn gap-2"
@click="handleSubmit" @click="handleSubmit"
> >
<CheckCircleIcon class="w-6 h-6" /> <CheckCircleIcon class="h-6 w-6" />
Speichern Speichern
</button> </button>
</template> </template>
@ -37,8 +37,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { DataService } from '../services/data.service'; import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/outline';
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/outline';
import AtomInput from '../atoms/AtomInput.vue'; import AtomInput from '../atoms/AtomInput.vue';
import AtomModal from '../atoms/AtomModal.vue'; import AtomModal from '../atoms/AtomModal.vue';
@ -54,17 +53,18 @@ defineProps({
}); });
async function handleSubmit() { async function handleSubmit() {
try { alert('TODO: Implement handleSubmit');
const isSuccess = await DataService.migrateAccount(oldAccountNumber.value, newAccountNumber.value); // try {
emit('submit', isSuccess); // const isSuccess = await DataService.migrateAccount(oldAccountNumber.value, newAccountNumber.value);
abortButton.value.click(); // emit('submit', isSuccess);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // abortButton.value.click();
} catch (error: any) { // // eslint-disable-next-line @typescript-eslint/no-explicit-any
alert('Fehler beim Migrieren'); // } catch (error: any) {
// eslint-disable-next-line no-console // alert('Fehler beim Migrieren');
console.error(error); // // eslint-disable-next-line no-console
// console.error(error);
} // }
} }
const emit = defineEmits(['submit']); const emit = defineEmits(['submit']);

View file

@ -1,24 +1,24 @@
<template> <template>
<div class="drawer drawer-mobile"> <div class="drawer-mobile drawer">
<input <input
:id="drawerId" :id="drawerId"
type="checkbox" type="checkbox"
class="drawer-toggle" class="drawer-toggle"
/> />
<div class="drawer-content flex flex-col bg-base-300 h-screen"> <div class="drawer-content flex h-screen flex-col bg-base-300">
<div <div
class="navbar shadow-lg bg-neutral-focus text-neutral-content lg:hidden sticky top-0 z-50 max-w-7xl place-self-center" class="navbar sticky top-0 z-50 max-w-7xl place-self-center bg-neutral-focus text-neutral-content shadow-lg lg:hidden"
> >
<div class="flex-none"> <div class="flex-none">
<label <label
:for="drawerId" :for="drawerId"
class="btn btn-ghost rounded-btn lg:hidden" class="btn-ghost rounded-btn btn lg:hidden"
> >
<MenuIcon class="h-7 w-7" /> <Bars3Icon class="h-7 w-7" />
</label> </label>
<label <label
tabindex="0" tabindex="0"
class="btn btn-ghost avatar" class="btn-ghost avatar btn"
> >
<AtomLogo class="w-12" /> <AtomLogo class="w-12" />
</label> </label>
@ -31,12 +31,28 @@
:for="drawerId" :for="drawerId"
class="drawer-overlay" class="drawer-overlay"
/> />
<ul class="menu p-4 overflow-y-auto w-64 bg-base-100"> <ul class="menu w-64 overflow-y-auto bg-base-100 p-4">
<li class="pointer-events-none"> <li class="pointer-events-none">
<a> <a>
<AtomLogo class="w-28" /> <AtomLogo class="w-28" />
</a> </a>
</li> </li>
<template v-if="isAuthenticated">
<li class="pointer-events-none rounded-md bg-warning uppercase text-warning-content">
<span>
<ExclamationTriangleIcon class="h-6 w-6" />
Admin-Modus
<ExclamationTriangleIcon class="h-6 w-6" />
</span>
</li>
<li>
<span @click="AuthService.logout()">
<ArrowRightOnRectangleIcon class="h-6 w-6" />
Abmelden
</span>
</li>
<li class="menu-title" />
</template>
<li <li
v-for="navigationEntry of navigationEntries" v-for="navigationEntry of navigationEntries"
:class="getNavigationEntryClass(navigationEntry)" :class="getNavigationEntryClass(navigationEntry)"
@ -66,12 +82,16 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { PropType } from 'vue'; import { PropType, onMounted, ref } from 'vue';
import { isDark, toggleDark } from '../../utils/darkMode'; import { isDark, toggleDark } from '../../utils/darkMode';
import { INavigationEntry } from '../../interfaces/navigation-entry.interface'; import { INavigationEntry } from '../../interfaces/navigation-entry.interface';
import { MenuIcon, MoonIcon, SunIcon } from '@heroicons/vue/outline'; import { ArrowRightOnRectangleIcon, Bars3Icon, ExclamationTriangleIcon, MoonIcon, SunIcon } from '@heroicons/vue/24/outline';
import AtomLogo from '../atoms/AtomLogo.vue'; import AtomLogo from '../atoms/AtomLogo.vue';
import AtomSwap from '../atoms/AtomSwap.vue'; import AtomSwap from '../atoms/AtomSwap.vue';
import { useEventBus } from '@vueuse/core';
import { AuthService } from '../../services/auth.service';
const isAuthenticated = ref(false);
defineProps({ defineProps({
drawerId: { drawerId: {
@ -91,7 +111,12 @@ function getNavigationEntryClass(navigationEntry: INavigationEntry) {
disabled: navigationEntry.disabled, disabled: navigationEntry.disabled,
}; };
} }
useEventBus<boolean>('isAuthenticated').on(state => (isAuthenticated.value = state));
onMounted(() => {
isAuthenticated.value = AuthService.isAuthenticated();
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped></style>
</style>

View file

@ -1,9 +1,9 @@
<template> <template>
<table class="table table-zebra"> <table class="table-zebra table">
<thead> <thead>
<tr> <tr>
<th /> <th />
<th class="normal-case text-lg text-right">{{ CurrencyService.toString(balance) }}</th> <th class="text-right text-lg normal-case">{{ CurrencyService.toString(balance) }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -12,7 +12,7 @@
<div class="flex items-center"> <div class="flex items-center">
<div> <div>
<div class="font-bold">{{ transaction.label }}</div> <div class="font-bold">{{ transaction.label }}</div>
<div>{{ DateService.toString(transaction.date) }}</div> <div>{{ DateService.toString(new Date(transaction.created)) }}</div>
</div> </div>
</div> </div>
</td> </td>
@ -35,13 +35,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PropType } from 'vue'; import { PropType } from 'vue';
import { CurrencyService } from '../services/currency.service'; import { CurrencyService } from '../../services/currency.service';
import { DateService } from '../services/date.service'; import { DateService } from '../../services/date.service';
import { ITransaction } from '../../interfaces/transaction.interface'; import { TransactionsResponse } from '../../types/pocketbase.types';
defineProps({ defineProps({
transactions: { transactions: {
type: Array as PropType<ITransaction[]>, type: Array as PropType<TransactionsResponse[]>,
required: true, required: true,
}, },
balance: { balance: {

View file

@ -0,0 +1,227 @@
<template>
<div
v-if="account"
class="hero-content flex-col pt-0 text-center lg:p-6"
>
<h1 class="mt-5 text-8xl">{{ CurrencyService.toString(getBalance()) }}</h1>
<h3 class="mt-10 text-5xl font-bold">Kontoinhaber: {{ `${account.firstName} ${account.lastName}` }}</h3>
<h4 class="mb-10 text-4xl">Kontonummer: {{ account.accountNumber }}</h4>
<MoleculeTransactionTable
v-if="transactions"
class="mb-24 w-[42rem]"
:balance="getBalance()"
:transactions="transactions"
/>
</div>
<div
v-if="account"
class="fixed bottom-0 flex place-content-center gap-16 bg-base-100 p-5 lg:w-[calc(100%-16rem)] lg:gap-32"
>
<MoleculeInputModal
:id="depositModalId"
title="Einzahlung"
action-label="Einzahlen"
input-label="Einzuzahlender Betrag:"
@submit="handleDeposit"
@open="depositModalOpen = $event.open"
/>
<label
ref="depositModalLabel"
class="btn gap-2"
:for="depositModalId"
>
<ChevronUpIcon class="h-6 w-6 text-success" />
Einzahlen
</label>
<MoleculeInputModal
:id="salaryModalId"
title="Gehalt"
action-label="Gehalt hinzufügen"
input-label="Gehalt Betrag:"
@submit="handleSalary"
@open="salaryModalOpen = $event.open"
/>
<label
ref="salaryModalLabel"
class="btn gap-2"
:for="salaryModalId"
>
<CurrencyDollarIcon class="h-6 w-6 text-warning" />
Gehalt
</label>
<MoleculeInputModal
:id="withdrawModalId"
title="Auszahlung"
action-label="Auszahlen"
input-label="Auszuzahlender Betrag:"
@submit="handleWithdraw"
@open="withdrawModalOpen = $event.open"
/>
<label
ref="withdrawModalLabel"
class="btn gap-2"
:for="withdrawModalId"
>
<ChevronDownIcon class="h-6 w-6 text-error" />
Auszahlen
</label>
</div>
<AtomHeroText v-else>
Bitte Karte scannen...
<div
v-if="error"
class="alert alert-error mt-6 shadow-lg"
>
<div>
<XCircleIcon class="h-6 w-6" />
<span class="text-base font-normal"><b>Fehler: </b>{{ error }}</span>
</div>
</div>
</AtomHeroText>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { onKeyStroke, promiseTimeout } from '@vueuse/core';
import { UnsubscribeFunc } from 'pocketbase';
import { AccountsResponse, TransactionsResponse } from '../../types/pocketbase.types';
import { AccountService } from '../../services/account.service';
import { BankService, TransactionType } from '../../services/bank.service';
import { CurrencyService } from '../../services/currency.service';
import { CurrencyDollarIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from '@heroicons/vue/24/outline';
import AtomHeroText from '../atoms/AtomHeroText.vue';
import MoleculeInputModal from '../molecules/MoleculeInputModal.vue';
import MoleculeTransactionTable from '../molecules/MoleculeTransactionTable.vue';
const router = useRouter();
const subscription = ref<UnsubscribeFunc>();
const account = ref<AccountsResponse|null>();
const transactions = ref<TransactionsResponse[]>();
const depositModalLabel = ref<HTMLLabelElement>();
const salaryModalLabel = ref<HTMLLabelElement>();
const withdrawModalLabel = ref<HTMLLabelElement>();
const accountId = ref<string>('');
const inputBuffer = ref<string>('');
const depositModalOpen = ref<boolean>(false);
const salaryModalOpen = ref<boolean>(false);
const withdrawModalOpen = ref<boolean>(false);
const error = ref('');
const depositModalId = 'deposit-modal';
const salaryModalId = 'salary-modal';
const withdrawModalId = 'withdraw-modal';
if(router.currentRoute.value.query.accountNumber) {
accountId.value = router.currentRoute.value.query.accountNumber as string;
getAccount();
router.replace({ path: '/bank' });
}
onKeyStroke(['e', 'g', 'a'], (e) => {
e.preventDefault();
switch (e.key) {
case 'e':
depositModalLabel.value?.click();
break;
case 'g':
salaryModalLabel.value?.click();
break;
case 'a':
withdrawModalLabel.value?.click();
break;
}
});
onKeyStroke(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Enter'], (e) => {
if(depositModalOpen.value || salaryModalOpen.value || withdrawModalOpen.value) {
return;
}
error.value = '';
e.preventDefault();
if(e.key === 'Enter') {
accountId.value = inputBuffer.value;
inputBuffer.value = '';
getAccount();
} else {
inputBuffer.value += e.key;
}
});
async function getAccount() {
account.value = null;
transactions.value = [];
try {
account.value = await AccountService.getAccount(accountId.value);
transactions.value = await BankService.getTransactions(account.value.id);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
if(e.status == 404) {
error.value = 'Der Account wurde nicht gefunden.';
await promiseTimeout(4000);
error.value = '';
} else {
error.value = 'Ein unerwarteter Fehler ist aufgetreten. Bitte kontaktiere einen Administrator.';
// eslint-disable-next-line no-console
console.error(e);
}
}
}
function getBalance(): number {
return transactions.value?.reduce((acc, transaction) => acc + transaction.amount, 0) ?? 0;
}
async function handleDeposit(amount: string) {
const amountNumber = parseInt(amount);
if(!isNaN(amountNumber) && account.value) {
await BankService.addTransaction(account.value.id, amountNumber, TransactionType.DEPOSIT);
}
}
async function handleSalary(amount: string) {
const amountNumber = parseInt(amount);
if(!isNaN(amountNumber) && account.value) {
await BankService.addTransaction(account.value.id, amountNumber, TransactionType.SALARY);
}
}
async function handleWithdraw(amount: string) {
const amountNumber = parseInt(amount);
if(!isNaN(amountNumber) && account.value) {
await BankService.addTransaction(account.value.id, -amountNumber, TransactionType.WITHDRAW);
}
}
onMounted(async () => {
subscription.value = await BankService.subscribeToTransactionChanges((transactionSubscription) => {
if(transactions.value && transactionSubscription.record.account === account.value?.id) {
switch (transactionSubscription.action) {
case 'create':
transactions.value.unshift(transactionSubscription.record);
break;
case 'update':
transactions.value = transactions.value.map((transaction) => {
if(transaction.id == transactionSubscription.record.id) {
transaction = transactionSubscription.record;
}
return transaction;
});
break;
case 'delete':
transactions.value = transactions.value.filter((transaction) => transaction.id !== transactionSubscription.record.id);
break;
}
}
});
});
onUnmounted(() => {
subscription.value?.();
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -2,12 +2,12 @@
<AtomHeroText> <AtomHeroText>
{{ message }} {{ message }}
<div <div
v-if="accountNotFound" v-if="error"
class="alert alert-error shadow-lg mt-6" class="alert alert-error mt-6 shadow-lg"
> >
<div> <div>
<XCircleIcon class="w-6 h-6" /> <XCircleIcon class="h-6 w-6" />
<span class="text-base font-normal">Fehler: Der Account wurde nicht gefunden.</span> <span class="text-base font-normal"><b>Fehler:</b> {{ error }}</span>
</div> </div>
</div> </div>
</AtomHeroText> </AtomHeroText>
@ -16,35 +16,37 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { onKeyStroke, promiseTimeout } from '@vueuse/core'; import { onKeyStroke, promiseTimeout } from '@vueuse/core';
import { AccountService } from '../services/account.service'; import { AccountService } from '../../services/account.service';
import { DateService } from '../services/date.service'; import { DateService } from '../../services/date.service';
import { XCircleIcon } from '@heroicons/vue/24/outline';
import AtomHeroText from '../atoms/AtomHeroText.vue'; import AtomHeroText from '../atoms/AtomHeroText.vue';
const DEFAULT_MESSAGE = 'Bitte Karte scannen...'; const DEFAULT_MESSAGE = 'Bitte Karte scannen...';
const accountId = ref<string>(''); const accountId = ref<string>('');
const message = ref<string>(DEFAULT_MESSAGE); const message = ref<string>(DEFAULT_MESSAGE);
const accountNotFound = ref<boolean>(false); const error = ref('');
onKeyStroke(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Enter'], async (e) => { onKeyStroke(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Enter'], async (e) => {
e.preventDefault(); e.preventDefault();
accountNotFound.value = false; error.value = '';
if(e.key === 'Enter') { if(e.key === 'Enter') {
try { try {
const account = await AccountService.checkIn(accountId.value); const account = await AccountService.checkIn(accountId.value);
accountId.value = ''; accountId.value = '';
message.value = `${account.firstName} ${account.lastName} um ${DateService.toTime(account.lastCheckIn)}`; message.value = `${account.firstName} ${account.lastName} um ${DateService.toTime(new Date(account.lastCheckIn))}`;
await promiseTimeout(5000); await promiseTimeout(5000);
message.value = DEFAULT_MESSAGE; message.value = DEFAULT_MESSAGE;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (e: any) {
if(error.message == 'Account not found') { if(e.status == 404) {
accountId.value = ''; accountId.value = '';
accountNotFound.value = true; error.value = 'Der Account wurde nicht gefunden.';
await promiseTimeout(3000); await promiseTimeout(3000);
accountNotFound.value = false; error.value = '';
} else { } else {
error.value = 'Ein unerwarteter Fehler ist aufgetreten. Bitte kontaktiere einen Administrator.';
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(error); console.error(e);
} }
} }
} else { } else {

View file

@ -0,0 +1,165 @@
<template>
<MoleculeAuthDialog v-if="!isAuthenticated" />
<div
v-else
class="lg:p-6"
>
<AtomInput
v-model="searchQuery"
placeholder="Nach Vor-/Nachname suchen"
class="mb-4"
/>
<MoleculeDataTable
v-if="accounts"
:table-headers="tableHeaders"
:data="accounts"
:contact-modal-id="contactModalId"
@open-contact-modal="openContactModal"
/>
<MoleculeContactModal
:id="contactModalId"
:contact-id="contactId"
/>
<!-- <MoleculeMigrateAccountModal
:id="migrateAccountModalId"
/>
<MoleculeAddAccountModal
:id="addAccountModalId"
@submit="addAccount"
/> -->
<label
class="btn-primary btn-circle btn-lg btn fixed bottom-8 right-28 shadow-2xl"
:for="migrateAccountModalId"
>
<LifebuoyIcon class="h-7 w-7" />
</label>
<label
class="btn-primary btn-circle btn-lg btn fixed bottom-8 right-8 shadow-2xl"
:for="addAccountModalId"
>
<PlusIcon class="h-7 w-7" />
</label>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useEventBus } from '@vueuse/core';
import { UnsubscribeFunc } from 'pocketbase';
import { BankService } from '../../services/bank.service';
import { AccountsListResponse } from '../../types/pocketbase.types';
import { AccountService } from '../../services/account.service';
import { AuthService } from '../../services/auth.service';
import { LifebuoyIcon, PlusIcon } from '@heroicons/vue/24/outline';
import AtomInput from '../atoms/AtomInput.vue';
import MoleculeAddAccountModal from '../molecules/MoleculeAddAccountModal.vue';
import MoleculeContactModal from '../molecules/MoleculeContactModal.vue';
import MoleculeDataTable, { TableHeaderType } from '../molecules/MoleculeDataTable.vue';
import MoleculeMigrateAccountModal from '../molecules/MoleculeMigrateAccountModal.vue';
import MoleculeAuthDialog from '../molecules/MoleculeAuthDialog.vue';
const isAuthenticated = ref(false);
const accountsSubscription = ref<UnsubscribeFunc>();
const transactionsSubscription = ref<UnsubscribeFunc>();
const accounts = ref<AccountsListResponse<number, string>[]>([]);
const initAccounts = ref<AccountsListResponse<number, string>[]>([]);
const searchQuery = ref('');
const contactId = ref('');
const contactModalId = 'contact-modal';
const addAccountModalId = 'add-account-modal';
const migrateAccountModalId = 'migrate-account-modal';
const tableHeaders = [
{
title: 'Kontonummer',
key: 'accountNumber',
type: TableHeaderType.STRING,
},
{
title: 'Name',
key: 'name',
type: TableHeaderType.STRING,
},
{
title: 'Letzter Check-In',
key: 'lastCheckIn',
type: TableHeaderType.DATETIME,
},
{
title: 'Kontostand',
key: 'balance',
type: TableHeaderType.CURRENCY,
},
{
title: 'Trans.',
key: '',
type: TableHeaderType.BUTTON_ACCOUNT,
},
{
title: 'Pers. Daten',
key: '',
type: TableHeaderType.BUTTON_CONTACT,
},
];
function openContactModal(id: string) {
contactId.value = id;
}
// function addAccount(account: IAccountData) {
// data.value.push(account);
// initialData.value = data.value;
// }
useEventBus<boolean>('isAuthenticated').on(state => {
isAuthenticated.value = state;
if(isAuthenticated.value) {
getData();
}
});
async function getData() {
initAccounts.value = await AccountService.getAccounts();
accounts.value = initAccounts.value;
}
watch(
searchQuery,
(value) => {
search(value);
},
);
function search(value: string) {
if(initAccounts.value) {
accounts.value = initAccounts.value.filter((account) =>
account.name?.toLocaleLowerCase().includes(value.toLocaleLowerCase()),
);
}
}
onMounted(async () => {
isAuthenticated.value = AuthService.isAuthenticated();
if(isAuthenticated.value) {
getData();
}
accountsSubscription.value = await AccountService.subscribeToAccountChanges(async () => {
console.log('accounts changed');
await getData();
search(searchQuery.value);
});
transactionsSubscription.value = await BankService.subscribeToTransactionChanges(async () => {
console.log('transactions changed');
await getData();
search(searchQuery.value);
});
});
onUnmounted(() => {
accountsSubscription.value?.();
transactionsSubscription.value?.();
});
</script>
<style lang="scss" scoped>
</style>

View file

View file

@ -0,0 +1,30 @@
import { RecordSubscription, UnsubscribeFunc } from 'pocketbase';
import { AccountsDataResponse, AccountsListResponse, AccountsResponse, Collections } from '../types/pocketbase.types';
import { PocketbaseService } from './pocketbase.service';
const COLLECTION = PocketbaseService.getApi().collection(Collections.Accounts);
const VIEW = PocketbaseService.getApi().collection(Collections.AccountsList);
export class AccountService {
public static async getAccount(accountNumber: string): Promise<AccountsResponse> {
return COLLECTION.getFirstListItem(`accountNumber = "${accountNumber}"`);
}
public static async checkIn(accountNumber: string): Promise<AccountsResponse> {
const account = await this.getAccount(accountNumber);
return COLLECTION.update(account.id, { lastCheckIn: new Date() });
}
public static async getAccounts(): Promise<AccountsListResponse<number, string>[]> {
return VIEW.getFullList();
}
public static getAccountDetails(id: string): Promise<AccountsResponse<{personalData: AccountsDataResponse}>> {
return COLLECTION.getOne(id, { expand: 'personalData' });
}
public static async subscribeToAccountChanges(
callback: (data: RecordSubscription<AccountsResponse>)=> void,
): Promise<UnsubscribeFunc> {
return await COLLECTION.subscribe<AccountsResponse>('*', (data) => {
callback(data);
});
}
}

View file

@ -0,0 +1,33 @@
import { Admin } from 'pocketbase';
import { PocketbaseService } from './pocketbase.service';
import { useEventBus } from '@vueuse/core';
const API = PocketbaseService.getApi();
export class AuthService {
public static isAuthenticated(): boolean {
if(API.authStore.isValid) {
return true;
}
this.logout();
return false;
}
public static getAdmin(): Admin {
if(this.isAuthenticated()) {
return API.authStore.model as Admin;
}
throw new Error('Not authenticated');
}
public static async loginAsAdmin(email: string, password: string): Promise<Admin> {
const response = await API.admins.authWithPassword(email, password);
if(response) {
useEventBus<boolean>('isAuthenticated').emit(true);
return response.admin;
}
throw new Error('Invalid credentials');
}
public static logout(): void {
API.authStore.clear();
useEventBus<boolean>('isAuthenticated').emit(false);
}
}

View file

@ -0,0 +1,46 @@
import { RecordSubscription, UnsubscribeFunc } from 'pocketbase';
import { Collections, TransactionsResponse } from '../types/pocketbase.types';
import { PocketbaseService } from './pocketbase.service';
const COLLECTION = PocketbaseService.getApi().collection(Collections.Transactions);
const DEPOSIT_LABEL = 'Bargeldeinzahlung';
const SALARY_LABEL = 'Gehalt';
const WITHDRAW_LABEL = 'Bargeldauszahlung';
const OPEN_ACCOUNT_LABEL = 'Kontoeröffnung';
export class BankService {
public static async getTransactions(accountId: string): Promise<TransactionsResponse[]> {
return COLLECTION.getFullList({ filter: `account = "${accountId}"`, sort: '-created' });
}
public static async addTransaction(accountId: string, amount: number, type: TransactionType): Promise<TransactionsResponse> {
let label = '';
switch (type) {
case TransactionType.DEPOSIT:
label = DEPOSIT_LABEL;
break;
case TransactionType.SALARY:
label = SALARY_LABEL;
break;
case TransactionType.WITHDRAW:
label = WITHDRAW_LABEL;
break;
case TransactionType.OPEN_ACCOUNT:
label = OPEN_ACCOUNT_LABEL;
break;
}
return COLLECTION.create({ account: accountId, label, amount: amount * 100 });
}
public static async subscribeToTransactionChanges(callback: (data: RecordSubscription<TransactionsResponse>)=> void): Promise<UnsubscribeFunc> {
return await COLLECTION.subscribe<TransactionsResponse>('*', (data) => {
callback(data);
});
}
}
export enum TransactionType {
DEPOSIT,
SALARY,
WITHDRAW,
OPEN_ACCOUNT,
}

View file

@ -1,12 +1,12 @@
export class CurrencyService { export class CurrencyService {
public static toString(value: number): string { public static toString(value: number): string {
return `${value.toLocaleString('de-DE', { return `${(value / 100).toLocaleString('de-DE', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
})} Öro`; })} Öro`;
} }
public static toSignedString(value: number): string { public static toSignedString(value: number): string {
return `${value > 0 ? '+' : ''}${value.toLocaleString('de', { return `${(value / 100) > 0 ? '+' : ''}${(value / 100).toLocaleString('de', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
})} Öro`; })} Öro`;

View file

@ -0,0 +1,26 @@
export class DateService {
public static toString(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
public static toShortString(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
public static toTime(date: Date): string {
return date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
}

View file

@ -0,0 +1,13 @@
import PocketBase from 'pocketbase';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pb = new PocketBase((import.meta as any).env.MODE === 'development' ? 'http://127.0.0.1:8090' : '/');
export class PocketbaseService {
public static getApi() {
return pb;
}
public static getHealth() {
return pb.health.check();
}
}

View file

@ -0,0 +1,88 @@
/**
* This file was @generated using pocketbase-typegen
*/
export enum Collections {
Accounts = "accounts",
AccountsData = "accountsData",
AccountsList = "accountsList",
Transactions = "transactions",
}
// Alias types for improved usability
export type IsoDateString = string
export type RecordIdString = string
export type HTMLString = string
// System fields
export type BaseSystemFields<T = never> = {
id: RecordIdString
created: IsoDateString
updated: IsoDateString
collectionId: string
collectionName: Collections
expand?: T
}
export type AuthSystemFields<T = never> = {
email: string
emailVisibility: boolean
username: string
verified: boolean
} & BaseSystemFields<T>
// Record types for each collection
export type AccountsRecord = {
accountNumber: string
firstName: string
lastName: string
lastCheckIn?: IsoDateString
personalData: RecordIdString
}
export type AccountsDataRecord = {
birthday: IsoDateString
email: string
firstNameParent: string
lastNameParent: string
street: string
zipCode: number
city: string
phone: string
}
export type AccountsListRecord<Tbalance = unknown, Tname = unknown> = {
accountNumber: string
name?: null | Tname
lastCheckIn?: IsoDateString
balance?: null | Tbalance
}
export type TransactionsRecord = {
account: RecordIdString
label: string
amount?: number
}
// Response types include system fields and match responses from the PocketBase API
export type AccountsResponse<Texpand = unknown> = Required<AccountsRecord> & BaseSystemFields<Texpand>
export type AccountsDataResponse = Required<AccountsDataRecord> & BaseSystemFields
export type AccountsListResponse<Tbalance = unknown, Tname = unknown> = Required<AccountsListRecord<Tbalance, Tname>> & BaseSystemFields
export type TransactionsResponse<Texpand = unknown> = Required<TransactionsRecord> & BaseSystemFields<Texpand>
// Types containing all Records and Responses, useful for creating typing helper functions
export type CollectionRecords = {
accounts: AccountsRecord
accountsData: AccountsDataRecord
accountsList: AccountsListRecord
transactions: TransactionsRecord
}
export type CollectionResponses = {
accounts: AccountsResponse
accountsData: AccountsDataResponse
accountsList: AccountsListResponse
transactions: TransactionsResponse
}

41
webapp/tailwind.config.js Normal file
View file

@ -0,0 +1,41 @@
module.exports = {
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/typography'), require('daisyui')],
daisyui: {
themes: [
'light',
'night',
{
// light: {
// 'primary': '#EE8039',
// 'secondary': '#BFB0D6',
// 'accent': '#F9F6F0',
// 'neutral': '#1E293B',
// 'base-100': '#F9F6F0',
// 'info': '#CBE0DE',
// 'success': '#9DC278',
// 'warning': '#F6B64B',
// 'error': '#DD513C',
// },
// night: {
// 'primary': '#EE8039',
// 'secondary': '#BFB0D6',
// 'accent': '#F9F6F0',
// 'neutral': '#1E293B',
// 'base-100': '#4E4E4E',
// 'info': '#CBE0DE',
// 'success': '#9DC278',
// 'warning': '#F6B64B',
// 'error': '#DD513C',
// },
},
],
darkTheme: 'night',
},
};