1
0
Fork 0
forked from Kispi/Core

Compare commits

..

52 commits

Author SHA1 Message Date
2430243028 feat(webapp): change wage to daily in wording, add max value for bank input and allow admin to get realtime updates in bank view again
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-18 19:08:21 +02:00
d13e7f664f fix(webapp): currency input fields
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-17 21:30:08 +02:00
6d6418858b feat(webapp): show min and max wage and fix permission issues
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-17 21:14:48 +02:00
dd59408c82 fix(webapp): allow companyTransactions to be viewed by everyone
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-17 20:36:57 +02:00
36638fbf74 feat(webapp): add banker login
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-17 20:21:59 +02:00
0b087a38bf feat(webapp): allow wage to be set freely
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-17 19:40:07 +02:00
dc3c065d1a feat(webapp): support old iPadOS version
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-17 10:23:26 +02:00
7089d0397f feat(webapp): bump version
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-16 13:32:14 +02:00
dd23862e9d feat(webapp): add missing students export
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-14 12:12:05 +02:00
d92696e2ff fix(webapp): servertime on production
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-13 10:56:48 +02:00
c98b51c22a feat(webapp): bump version and upgrade dependencies
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-13 00:46:21 +02:00
54d60744d0 feat(webapp): change wording of min wage to daily
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-12 16:45:16 +02:00
f1e7c0b514 feat(webapp): add seed capital to state portal
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-12 16:00:52 +02:00
cd5c7d1569 feat(webapp): add server time to admin portal
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-12 15:36:33 +02:00
019d0ae566 feat(core): add wage functionality to the employer portal
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-12 15:30:25 +02:00
97785d17ad feat(webapp): add company overview in admin settings
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-11 17:14:28 +02:00
a2d4d89776 feat(core): add company bank accounts
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-11 13:38:58 +02:00
5aea42d33a feat(webapp): add option to modify the account number
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-11 12:08:38 +02:00
fe2eabaffc fix(webapp): logo in navbar on safari
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-10 21:46:10 +02:00
212e0bbcc3 fix(webapp): small fixes
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-09 22:45:42 +02:00
c70dba5ab3 feat(core): add employer portal
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-09 21:27:45 +02:00
a2994e50e6 fix(webapp): remove padding on smaller screens 2023-07-09 20:57:29 +02:00
8f544f224b fix(webapp): footer
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-07 20:31:58 +02:00
4f80b7e895 feat(core): add companies and csv data import
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-07 00:10:50 +02:00
9a092e9324 fix(webapp): remove max width in navigation bar
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-03 12:05:39 +02:00
be3f71eb12 feat(core): add spa handling, add radio, add account import from csv file and use new dialog elements
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-03 11:38:34 +02:00
3e1437300f feat(core): add employer portal with mock data
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-30 16:18:07 +02:00
c94bf147f5 feat(core): update dependencies and some visual improvements
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-30 14:59:53 +02:00
23aad9b667 feat(webapp): add admin settings
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-25 15:34:09 +02:00
c2207e337a feat(core): change branding
All checks were successful
continuous-integration/drone Build is passing
2023-06-05 18:05:25 +02:00
4944890510 docs(readme): update docs 2023-06-04 11:57:22 +02:00
bc7a295a06 fix(webapp): responsive errors 2023-06-03 23:51:32 +02:00
8a6a8bd1dc feat(webapp): ui refinements 2023-06-03 23:42:07 +02:00
364ee76159 feat(core): migrate to pocketbase and update ui 2023-06-01 18:34:41 +02:00
84bb25a4fa fix(webapp): use new ip for appwrite 2022-08-08 10:20:11 +02:00
10399aa812 fix(webapp): filter in lower case 2022-08-04 14:02:26 +02:00
86ef3d989f fix(webapp): add pin to data view again 2022-08-04 14:01:45 +02:00
b5247ea3ad feat(webapp): add filter to data view 2022-08-04 13:57:18 +02:00
cfb41b2b32 feat(webbapp): add account migration 2022-08-04 12:08:09 +02:00
8c1878d4aa feat(webapp): protect data view with pin 2022-08-04 11:48:29 +02:00
46d0ad8770 feat(webapp): add minimum and maximum amount for currency input 2022-08-04 11:48:09 +02:00
f255ffcd94 feat(webapp): disable data view by default 2022-08-01 09:08:10 +02:00
971b760792 fix(webapp): anonymous session not working 2022-07-30 23:13:03 +02:00
64f7c7393f feat(webapp): add check in functionality 2022-07-28 17:46:29 +02:00
eab98a13b7 feat(webapp): get account details from input 2022-07-28 17:30:10 +02:00
dda5a1ac7d feat(webapp): add account modal functionality 2022-07-28 16:46:07 +02:00
6425af1ffb style(webapp): reorder imports 2022-07-26 23:43:30 +02:00
5ba74d198a feat(webapp): add backend for data view 2022-07-26 23:38:22 +02:00
4915be99b8 feat(webapp): add backend to bank view 2022-07-26 16:28:45 +02:00
069da4fc5e fix(webapp): overflow menu has no color 2022-07-26 16:27:54 +02:00
ebdfa33c37 feat(webapp): add appwrite 2022-07-26 16:24:22 +02:00
f172a2c338 feat(webapp): remove cents as this is impossbile with this currency 2022-07-18 12:18:07 +02:00
121 changed files with 10847 additions and 3203 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 398 KiB

View file

@ -2,21 +2,35 @@ 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.6 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.6 lint
- name: version-webapp
image: node:lts-alpine
commands:
- cd webapp
- corepack pnpm@8.6.6 generate-version
- name: build-webapp
image: node:lts-alpine
commands:
- cd webapp
- corepack pnpm@8.6.6 build
- name: deploy - name: deploy
image: plugins/docker image: plugins/docker
settings: settings:
registry: registry.cliffbreak.de registry: registry.cliffbreak.de
repo: registry.cliffbreak.de/kispi-core repo: registry.cliffbreak.de/hgoe-sas
username: username:
from_secret: docker_username from_secret: docker_username
password: password:

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
# Paths
WEBAPP="../webapp/dist"

9
.gitignore vendored
View file

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

20
.vscode/settings.json vendored
View file

@ -1,20 +1,32 @@
{ {
"cSpell.words": [ "cSpell.words": [
"appwrite",
"camelcase", "camelcase",
"checkin", "checkin",
"classname",
"cliffbreak", "cliffbreak",
"corepack", "corepack",
"daisyui", "daisyui",
"esnext",
"Gitea", "Gitea",
"github",
"godotenv",
"heroicons", "heroicons",
"kispi", "hgoe",
"mopidy", "HGOE",
"Mopidy", "middlewares",
"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

@ -1,11 +1,11 @@
<h1 align="center">🔥 Core - Kinderspielstadt Öhringen 🔥</h1> <h1 align="center">🔥 Schule als Staat - Hohenlohe Gymnasium Öhringen 🔥</h1>
<h3 align="center">Verwaltung + Bank + Radio = ❤️</h1> <h3 align="center">User administration, user check and customs, banking system, employer portal and an admin portal</h1>
<p align="center"> <p align="center">
<img height="600px" src=".docs/screenshot.png" alt="screenshot"/> <img height="600px" src=".docs/screenshot.png" alt="screenshot"/>
</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 "Hohenlohe Gymnasium Öhringen - Schule als Staat" project.
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,32 +15,70 @@ 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
At first clone this repository to your local machine by using At first clone this repository to your local machine by using
``` ```
git clone https://git.cliffbreak.de/Kontast/Core.git git clone https://git.cliffbreak.de/SimGie/HGOE-SaS.git
``` ```
Change to the cloned repository Change to the cloned repository
``` ```
cd Core cd HGOE-Sas
``` ```
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,37 +0,0 @@
{
"name": "kispi-core",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite",
"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",
"daisyui": "^2.19.0",
"events": "^3.3.0",
"mopidy": "^1.3.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

99
server/go.mod Normal file
View file

@ -0,0 +1,99 @@
module cliffbreak.de/hgoe-sas-server
go 1.20
require (
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
github.com/pocketbase/pocketbase v0.16.8
github.com/pterm/pterm v0.12.62
)
require (
atomicgo.dev/cursor v0.1.2 // indirect
atomicgo.dev/keyboard v0.2.9 // indirect
atomicgo.dev/schedule v0.0.2 // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go v1.44.299 // indirect
github.com/aws/aws-sdk-go-v2 v1.18.1 // 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.27 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.26 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.71 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.26 // 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.29 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.28 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.3 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.36.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.19.2 // 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.12.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/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.30.0 // indirect
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/image v0.9.0 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.12.0 // indirect
golang.org/x/oauth2 v0.10.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/term v0.10.0 // indirect
golang.org/x/text v0.11.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.11.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.130.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect
google.golang.org/grpc v1.56.2 // indirect
google.golang.org/protobuf v1.31.0 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.14 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.6.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/sqlite v1.24.0 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.1.0 // indirect
)

3398
server/go.sum Normal file

File diff suppressed because it is too large Load diff

40
server/main.go Normal file
View file

@ -0,0 +1,40 @@
package main
import (
"net/http"
"time"
"cliffbreak.de/hgoe-sas-server/pkg/middlewares"
"cliffbreak.de/hgoe-sas-server/pkg/utils"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pterm/pterm"
)
func main() {
pterm.Info.Println("Loading environment variables...")
utils.LoadEnv()
pterm.Info.Println("Starting PocketBase server...")
app := pocketbase.New()
app.OnBeforeServe().Add(middlewares.ServeSPA(utils.GetEnv("WEBAPP", "./webapp")))
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/time",
Handler: func(c echo.Context) error {
return c.String(http.StatusOK, time.Now().Format(time.RFC3339))
},
})
return nil
})
if err := app.Start(); err != nil {
pterm.Fatal.Println(err)
}
}

458
server/pb_schema.json Normal file
View file

@ -0,0 +1,458 @@
[
{
"id": "s854d2w72fvyl54",
"name": "companies",
"type": "auth",
"system": false,
"schema": [
{
"id": "h5ogbj93",
"name": "accountNumber",
"type": "text",
"system": false,
"required": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "cvcgnf4x",
"name": "name",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "ujqd3vik",
"name": "earlyShiftPayed",
"type": "date",
"system": false,
"required": false,
"options": {
"min": "",
"max": ""
}
},
{
"id": "t1kakf3f",
"name": "lateShiftPayed",
"type": "date",
"system": false,
"required": false,
"options": {
"min": "",
"max": ""
}
}
],
"indexes": [
"CREATE INDEX `idx_eX1Vqqz` ON `companies` (`accountNumber`)"
],
"listRule": "",
"viewRule": null,
"createRule": null,
"updateRule": "(@request.data.username = null && @request.data.email = null && @request.data.accountNumber = null && @request.data.created = null && @request.data.updated = null && @request.data.name = null && @request.data.emailVisibility = null && @request.data.verified = null) && id = @request.auth.id",
"deleteRule": null,
"options": {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
"onlyEmailDomains": null,
"requireEmail": false
}
},
{
"id": "c3zz98wpbn7m6zw",
"name": "accountsList",
"type": "view",
"system": false,
"schema": [
{
"id": "ne8rkvlr",
"name": "accountNumber",
"type": "text",
"system": false,
"required": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "paqieqwu",
"name": "name",
"type": "json",
"system": false,
"required": false,
"options": {}
},
{
"id": "7sij0xcm",
"name": "grade",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "txpycyit",
"name": "lastCheckIn",
"type": "date",
"system": false,
"required": false,
"options": {
"min": "",
"max": ""
}
},
{
"id": "og7zs0ox",
"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.grade AS grade,\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\nORDER BY\n a.grade, a.lastName"
}
},
{
"id": "t4gewf713jqhz3i",
"name": "settings",
"type": "base",
"system": false,
"schema": [
{
"id": "z7pmr7wm",
"name": "minWage",
"type": "number",
"system": false,
"required": true,
"options": {
"min": 0,
"max": null
}
},
{
"id": "7wzv9qum",
"name": "incomeTax",
"type": "number",
"system": false,
"required": true,
"options": {
"min": 0,
"max": null
}
},
{
"id": "3pgjcfai",
"name": "incomeTaxRecipient",
"type": "relation",
"system": false,
"required": true,
"options": {
"collectionId": "s854d2w72fvyl54",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": []
}
},
{
"id": "dawtvakx",
"name": "maxWage",
"type": "number",
"system": false,
"required": true,
"options": {
"min": 0,
"max": null
}
},
{
"id": "ftzmu1na",
"name": "radioUrl",
"type": "url",
"system": false,
"required": true,
"options": {
"exceptDomains": [],
"onlyDomains": []
}
}
],
"indexes": [],
"listRule": "",
"viewRule": "",
"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": false,
"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": "4lw6gmtc",
"name": "grade",
"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": "gwrzizpu",
"name": "company",
"type": "relation",
"system": false,
"required": false,
"options": {
"collectionId": "s854d2w72fvyl54",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": []
}
},
{
"id": "rtvjzpxw",
"name": "shift",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "ggx1gyeq",
"name": "wage",
"type": "number",
"system": false,
"required": false,
"options": {
"min": null,
"max": null
}
}
],
"indexes": [
"CREATE 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.grade = null && @request.data.created = null && @request.data.updated = null && @request.data.company = null && @request.data.shift = null && @request.data.wage = null) || (@request.data.id = null && @request.data.accountNumber = null && @request.data.firstName = null && @request.data.lastName = null && @request.data.created = null && @request.data.updated = null && @request.data.company = null && @request.auth.id = company.id)",
"deleteRule": null,
"options": {}
},
{
"id": "w1au07idupp27qv",
"name": "bankers",
"type": "auth",
"system": false,
"schema": [],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
"onlyEmailDomains": null,
"requireEmail": false
}
},
{
"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": "@request.auth.id != \"\" && @collection.bankers.id ?= @request.auth.id",
"viewRule": "@request.auth.id != \"\" && @collection.bankers.id ?= @request.auth.id",
"createRule": "(@request.auth.id != \"\" && @collection.bankers.id ?= @request.auth.id) || (@request.auth.id != \"\" && @collection.companies.id ?= @request.auth.id && amount > 0)",
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "5msnfxat1sc2c1r",
"name": "companyTransactions",
"type": "base",
"system": false,
"schema": [
{
"id": "7rnyupog",
"name": "account",
"type": "relation",
"system": false,
"required": true,
"options": {
"collectionId": "s854d2w72fvyl54",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": []
}
},
{
"id": "ruby9fp9",
"name": "label",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "4hiu46ib",
"name": "amount",
"type": "number",
"system": false,
"required": false,
"options": {
"min": null,
"max": null
}
}
],
"indexes": [],
"listRule": "",
"viewRule": "",
"createRule": "(@request.auth.id != \"\" && @collection.bankers.id ?= @request.auth.id) || (@request.auth.id != \"\" && @collection.companies.id ?= @request.auth.id)",
"updateRule": null,
"deleteRule": null,
"options": {}
}
]

View file

@ -0,0 +1,24 @@
package middlewares
import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pterm/pterm"
)
func ServeSPA(webAppPath string) func(e *core.ServeEvent) error {
return func(e *core.ServeEvent) error {
pterm.Info.Print("Serving SPA from ", webAppPath)
subFs := echo.MustSubFS(e.Router.Filesystem, webAppPath)
e.Router.GET("/*", apis.StaticDirectoryHandler(subFs, false))
originalErrorHandler := e.Router.HTTPErrorHandler
e.Router.HTTPErrorHandler = func(c echo.Context, err error) {
if c.Path() == "/*" && err == echo.ErrNotFound {
err = c.FileFS("index.html", subFs)
}
originalErrorHandler(c, err)
}
return nil
}
}

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])
}
}
}

View file

@ -1,62 +0,0 @@
<template>
<MoleculeNavigationDrawer
:drawer-id="drawerId"
:navigation-entries="navigationEntries"
>
<RouterView />
</MoleculeNavigationDrawer>
</template>
<script setup lang="ts">
import {
IdentificationIcon,
LibraryIcon,
LogoutIcon,
MusicNoteIcon,
TrendingUpIcon,
UserIcon,
} from '@heroicons/vue/outline';
import { INavigationEntry } from './interfaces/navigation-entry.interface';
import MoleculeNavigationDrawer from './components/molecules/MoleculeNavigationDrawer.vue';
const drawerId = 'default-drawer';
const navigationEntries: INavigationEntry[] = [
{
name: 'Stammdaten',
icon: UserIcon,
to: '/data',
},
{
name: 'Einstempeln',
icon: IdentificationIcon,
to: '/checkin',
},
{
name: 'Bank',
icon: LibraryIcon,
to: '/bank',
},
{
name: 'Börse',
icon: TrendingUpIcon,
to: '/stocks',
disabled: true,
},
{
name: 'Radio',
icon: MusicNoteIcon,
to: '/radio',
},
{
name: 'divider',
},
{
name: 'Abmelden',
icon: LogoutIcon,
to: '/logout',
},
];
</script>
<style lang="scss" scoped>
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View file

@ -1,20 +0,0 @@
<template>
<div>
<AtomInput
ref="input"
type="number"
class="text-right pr-16"
step="1"
/>
<span class="text-sm opacity-50 absolute -ml-16 top-2/4 -mt-8 pointer-events-none">,00 Öro</span>
</div>
</template>
<script lang="ts" setup>
import AtomInput from './AtomInput.vue';
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,40 +0,0 @@
<template>
<input
ref="input"
:type="type"
:placeholder="placeholder"
:value="modelValue"
class="input input-bordered w-full"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
<script lang="ts" setup>
defineProps({
type: {
type: String,
default: 'text',
},
placeholder: {
type: String,
default: '',
},
modelValue: {
type: String,
default: '',
},
});
defineEmits(['update:modelValue']);
</script>
<style lang="scss" scoped>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
}
</style>

View file

@ -1,18 +0,0 @@
<template>
<img
v-if="isDark"
src="../../assets/logo_white.png"
/>
<img
v-else
src="../../assets/logo.png"
/>
</template>
<script lang="ts" setup>
import { isDark } from '../../utils/darkMode';
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,38 +0,0 @@
<template>
<Teleport to="body">
<input
:id="id"
type="checkbox"
class="modal-toggle"
@change="$emit('open', {id, open: ($event.target as HTMLInputElement).checked})"
/>
<div class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">{{ title }}</h3>
<p class="py-4"><slot /></p>
<div class="modal-action">
<slot name="action" />
</div>
</div>
</div>
</Teleport>
</template>
<script lang="ts" setup>
defineProps({
id: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
});
defineEmits(['open']);
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,91 +0,0 @@
<template>
<AtomModal
:id="id"
title="Account hinzufügen"
>
<div class="flex flex-col gap-4">
<h4 class="text-md">Daten</h4>
<div class="flex gap-4">
<AtomInput
v-model="firstName"
placeholder="Vorname"
/>
<AtomInput placeholder="Nachname" />
</div>
<AtomInput
placeholder="Kontonummer"
type="number"
/>
<AtomInput
placeholder="Geburtsdatum"
type="number"
/>
<div class="flex gap-4">
<AtomInput placeholder="Straße" />
<AtomInput
placeholder="Hnr."
class="w-3/12"
/>
</div>
<div class="flex gap-4">
<AtomInput
placeholder="Postleitzahl"
class="w-6/12"
/>
<AtomInput placeholder="Stadt" />
</div>
<h4 class="text-md">Kontakt</h4>
<div class="flex gap-4">
<AtomSelect
placeholder="Label"
:options="['Festnetz', 'Mobil', 'Arbeit']"
class="w-4/12"
/>
<AtomInput placeholder="Telefonnummer" />
</div>
</div>
<template #action>
<label
ref="abortButton"
class="btn gap-2"
:for="id"
>
<XCircleIcon class="w-6 h-6" />
Abbrechen
</label>
<button
class="btn gap-2"
@click="handleSubmit()"
>
<CheckCircleIcon class="w-6 h-6" />
Speichern
</button>
</template>
</AtomModal>
</template>
<script lang="ts" setup>
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/outline';
import AtomModal from '../atoms/AtomModal.vue';
import AtomInput from '../atoms/AtomInput.vue';
import AtomSelect from '../atoms/AtomSelect.vue';
import { ref } from 'vue';
const firstName = ref('');
defineProps({
id: {
type: String,
required: true,
},
});
function handleSubmit() {
console.log('TODO: Submit');
console.log(firstName.value);
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,52 +0,0 @@
<template>
<div class="overflow-x-auto">
<table class="table w-full table-zebra">
<thead class="sticky top-0">
<tr>
<th v-for="(_, key) of data[0]">{{ key }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entries in data">
<td
v-for="(entry, _, key) of entries"
:class="key >= 6 ? 'text-center' : null"
>
<span v-if="key < 6">{{ entry }}</span>
<button
v-if="key == 6"
class="btn btn-sm btn-ghost p-1"
>
<DocumentTextIcon
class="h-6 w-6"
/>
</button>
<button
v-if="key == 7"
class="btn btn-sm btn-ghost p-1"
>
<CurrencyDollarIcon
class="h-6 w-6"
/>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script lang="ts" setup>import { PropType } from 'vue';
import { CurrencyDollarIcon, DocumentTextIcon } from '@heroicons/vue/outline';
defineProps({
data: {
type: Array as PropType<object[]>,
required: true,
},
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,97 +0,0 @@
<template>
<div class="drawer drawer-mobile">
<input
:id="drawerId"
type="checkbox"
class="drawer-toggle"
/>
<div class="drawer-content flex flex-col bg-base-300 h-screen">
<div
class="navbar shadow-lg bg-neutral-focus text-neutral-content lg:hidden sticky top-0 z-50 max-w-7xl place-self-center"
>
<div class="flex-none">
<label
:for="drawerId"
class="btn btn-ghost rounded-btn lg:hidden"
>
<MenuIcon class="h-7 w-7" />
</label>
<label
tabindex="0"
class="btn btn-ghost avatar"
>
<AtomLogo class="w-12" />
</label>
</div>
</div>
<slot />
</div>
<div class="drawer-side">
<label
:for="drawerId"
class="drawer-overlay"
/>
<ul class="menu p-4 overflow-y-auto w-64">
<li class="pointer-events-none">
<a>
<AtomLogo class="w-28" />
</a>
</li>
<li
v-for="navigationEntry of navigationEntries"
:class="getNavigationEntryClass(navigationEntry)"
>
<component
:is="!navigationEntry.to || navigationEntry.disabled ? 'span' : 'router-link'"
v-if="navigationEntry.name !== 'divider'"
:to="navigationEntry.to"
active-class="active"
>
<component
:is="navigationEntry.icon"
class="h-6 w-6"
/>{{ navigationEntry.name }}
</component>
</li>
<AtomSwap
class="mt-auto place-self-end"
:on-icon="SunIcon"
:off-icon="MoonIcon"
:checked="isDark"
@change="toggleDark()"
/>
</ul>
</div>
</div>
</template>
<script lang="ts" setup>
import { PropType } from 'vue';
import { isDark, toggleDark } from '../../utils/darkMode';
import { INavigationEntry } from '../../interfaces/navigation-entry.interface';
import { MenuIcon, MoonIcon, SunIcon } from '@heroicons/vue/outline';
import AtomLogo from '../atoms/AtomLogo.vue';
import AtomSwap from '../atoms/AtomSwap.vue';
defineProps({
drawerId: {
type: String,
required: true,
},
navigationEntries: {
type: Array as PropType<INavigationEntry[]>,
required: true,
},
});
function getNavigationEntryClass(navigationEntry: INavigationEntry) {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
'menu-title': navigationEntry.name === 'divider',
disabled: navigationEntry.disabled,
};
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,11 +0,0 @@
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',
});
}
}

View file

@ -1,161 +0,0 @@
<template>
<div class="hero-content flex-col text-center lg:p-6 pt-0">
<h1 class="text-8xl mt-5">{{ CurrencyService.toString(balance) }}</h1>
<h3 class="text-5xl font-bold mt-10">Kontoinhaber: John Doe</h3>
<h4 class="text-4xl mb-10">Kontonummer: 0000123456</h4>
<MoleculeTransactionTable
class="w-[42rem]"
:balance="balance"
:transactions="transactions"
/>
</div>
<div class="sticky flex place-content-center lg:gap-32 gap-16 bg-base-100 bottom-0 w-100 p-5">
<MoleculeInputModal
:id="depositModalId"
title="Einzahlung"
action-label="Einzahlen"
input-label="Einzuzahlender Betrag:"
@submit="handleDeposit"
/>
<label
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"
/>
<label
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"
/>
<label
class="btn gap-2"
:for="withdrawModalId"
>
<ChevronDownIcon class="w-6 h-6 text-error" />
Auszahlen
</label>
</div>
</template>
<script lang="ts" setup>
import { ITransaction } from '../../interfaces/transaction.interface';
import { CurrencyService } from '../services/currency.service';
import { CurrencyDollarIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/outline';
import MoleculeTransactionTable from '../molecules/MoleculeTransactionTable.vue';
import MoleculeInputModal from '../molecules/MoleculeInputModal.vue';
import { onKeyStroke } from '@vueuse/core';
const depositModalId = 'deposit-modal';
const salaryModalId = 'salary-modal';
const withdrawModalId = 'withdraw-modal';
const balance = 13500;
const transactions: ITransaction[] = [
{
type: 'Kontoeröffnung',
date: new Date(2022, 7, 12, 17, 30, 0),
amount: 500,
},
{
type: 'Bargeldeinzahlung',
date: new Date(2022, 7, 12, 17, 30, 10),
amount: 10000,
},
{
type: 'Gehalt',
date: new Date(2022, 7, 12, 17, 34, 10),
amount: 5000,
},
{
type: 'Gehalt',
date: new Date(2022, 7, 12, 17, 34, 10),
amount: 5000,
},
{
type: 'Gehalt',
date: new Date(2022, 7, 12, 17, 34, 10),
amount: 5000,
},
{
type: 'Gehalt',
date: new Date(2022, 7, 12, 17, 34, 10),
amount: 5000,
},
{
type: 'Gehalt',
date: new Date(2022, 7, 12, 17, 34, 10),
amount: 5000,
},
{
type: 'Gehalt',
date: new Date(2022, 7, 12, 17, 34, 10),
amount: 5000,
},
{
type: 'Gehalt',
date: new Date(2022, 7, 12, 17, 34, 10),
amount: 5000,
},
{
type: 'Gehalt',
date: new Date(2022, 7, 12, 17, 34, 10),
amount: 5000,
},
{
type: 'Gehalt',
date: new Date(2022, 7, 12, 17, 34, 10),
amount: 5000,
},
{
type: 'Gehalt',
date: new Date(2022, 7, 12, 17, 34, 10),
amount: 5000,
},
{
type: 'Bargeldauszahlung',
date: new Date(2022, 7, 15, 18, 26, 0),
amount: -2000,
},
].sort((a, b) => b.date.getTime() - a.date.getTime());
onKeyStroke('e', (e) => {
e.preventDefault();
console.log('e pressed');
// TODO: open deposit modal
});
function handleDeposit(amount: number) {
console.log('Deposit:', amount);
}
function handleSalary(amount: number) {
console.log('Salary:', amount);
}
function handleWithdraw(amount: number) {
console.log('Withdraw:', amount);
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,199 +0,0 @@
<template>
<div class="lg:p-6">
<MoleculeDataTable :data="data" />
<MoleculeAddAccountModal :id="addAccountModalId" />
<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 MoleculeDataTable from '../molecules/MoleculeDataTable.vue';
import { PlusIcon } from '@heroicons/vue/outline';
import MoleculeAddAccountModal from '../molecules/MoleculeAddAccountModal.vue';
const addAccountModalId = 'add-account-modal';
/* eslint-disable @typescript-eslint/naming-convention */
const data = [
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
{
'Kontonummer': '0000123456',
'Name': 'John Doe',
'Geburtsdatum': '13.11.1998',
'Kontostand': '150,00 Öro',
'Adresse': 'Test-Straße 1, 74613 Öhringen',
'Letzter': '12.07.2022 17:39',
'Kontakt': null,
'Trans.': null,
},
/* eslint-enable @typescript-eslint/naming-convention */
];
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,59 +0,0 @@
<template>
<button @click="getTrack()">GetTrack</button>
<button @click="setPlayPause()">Play/Pause</button>
<div>Currently Playing: {{ `${artist} - ${track}` }}</div>
</template>
<script lang="ts" setup>
import Mopidy from 'mopidy';
import { onBeforeUnmount, ref } from 'vue';
const artist = ref('');
const track = ref('');
const mopidy = new Mopidy({
webSocketUrl: 'ws://localhost:6680/mopidy/ws/',
});
mopidy.on('state', console.log);
mopidy.on('event', console.log);
mopidy.on('state:online', () => {
getTrack();
});
mopidy.on('event:trackPlaybackStarted', (event) => {
track.value = event.tl_track.track.name;
artist.value = event.tl_track.track.artists.map((artist) => artist.name).join(', ');
});
async function getTrack() {
if(mopidy.playback) {
const currentTrack = await mopidy.playback.getCurrentTrack();
console.log(await mopidy.playback.getTimePosition());
if(currentTrack) {
track.value = currentTrack.name;
artist.value = currentTrack.artists.map((artist) => artist.name).join(', ');
}
}
}
async function setPlayPause() {
const currentState = await mopidy.playback?.getState();
if(currentState === 'playing') {
await mopidy.playback?.pause();
} else {
await mopidy.playback?.resume();
}
}
onBeforeUnmount(() => {
mopidy.close();
mopidy.off();
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

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

View file

@ -1,36 +0,0 @@
import * as VueRouter from 'vue-router';
import DataView from '../components/views/DataView.vue';
import CheckInView from '../components/views/CheckInView.vue';
import BankView from '../components/views/BankView.vue';
import StocksView from '../components/views/StocksView.vue';
import RadioView from '../components/views/RadioView.vue';
const routes = [
{
path: '/data',
component: DataView,
},
{
path: '/checkin',
component: CheckInView,
},
{
path: '/bank',
component: BankView,
},
{
path: '/stocks',
component: StocksView,
},
{
path: '/radio',
component: RadioView,
},
];
const router = VueRouter.createRouter({
history: VueRouter.createWebHistory(),
routes,
});
export default router;

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: {
@ -52,7 +53,7 @@ module.exports = {
'asyncArrow': 'always', 'asyncArrow': 'always',
}], }],
'space-in-parens': ['error', 'never'], 'space-in-parens': ['error', 'never'],
'spaced-comment': ['error', 'always'], 'spaced-comment': ['error', 'always', { 'markers': ['/'] }],
}, },
overrides: [ overrides: [
{ {
@ -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',
}, },
}, },
], ],

View file

@ -8,10 +8,10 @@
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest"> <link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5"> <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#ffc40d"> <meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kinderspielstadt Öhringen</title> <title>Schule als Staat | Hohenlohe Gymnasium Öhringen</title>
</head> </head>
<body> <body>

47
webapp/package.json Normal file
View file

@ -0,0 +1,47 @@
{
"name": "hgoe-sas",
"private": true,
"author": "Simon Giesel",
"version": "1.3.6",
"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",
"optimize-svg": "svgo -f ./src/assets/svg/ --config=./src/assets/svg/svgo.config.js",
"generate-version": "node ./scripts/generate-version.js"
},
"dependencies": {
"@heroicons/vue": "^2.0.18",
"@vueuse/core": "^10.2.1",
"canvas-confetti": "^1.6.0",
"daisyui": "^3.2.1",
"date-fns": "^2.30.0",
"pocketbase": "^0.15.3",
"vue": "^3.3.4",
"vue-router": "4.2.4"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.9",
"@types/canvas-confetti": "^1.6.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-vue": "^4.2.3",
"autoprefixer": "^10.4.14",
"eslint": "^8.44.0",
"eslint-plugin-tailwindcss": "^3.13.0",
"eslint-plugin-vue": "^9.15.1",
"eslint-plugin-vue-scoped-css": "^2.5.0",
"pocketbase-typegen": "^1.1.11",
"postcss": "^8.4.25",
"sass": "^1.63.6",
"svgo": "^3.0.2",
"tailwindcss": "^3.3.2",
"typescript": "^5.1.6",
"vite": "^4.4.3",
"vue-eslint-parser": "^9.3.1",
"vue-tsc": "^1.8.4"
}
}

3096
webapp/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 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: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 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: 13 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: /

View file

@ -0,0 +1,94 @@
<?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="3407.000000pt" height="3407.000000pt" viewBox="0 0 3407.000000 3407.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,3407.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M4410 33513 c-380 -51 -730 -280 -929 -609 -138 -227 -304 -646 -428
-1079 -35 -124 -98 -371 -106 -415 -3 -14 -10 -43 -16 -65 -15 -58 -98 -472
-106 -529 -3 -27 -8 -52 -9 -55 -2 -3 -6 -28 -10 -56 -3 -27 -8 -61 -11 -75
-3 -14 -10 -57 -16 -97 -5 -39 -12 -80 -14 -91 -2 -10 -6 -43 -10 -73 -3 -30
-8 -65 -10 -79 -10 -70 -15 -109 -20 -145 -10 -82 -24 -212 -30 -275 -3 -36
-8 -81 -10 -100 -2 -19 -7 -62 -10 -95 -3 -33 -7 -85 -10 -115 -12 -132 -15
-170 -20 -255 -3 -49 -8 -108 -10 -130 -2 -22 -7 -98 -10 -170 -4 -71 -8 -161
-10 -200 -26 -490 -26 -1706 0 -2155 2 -30 6 -122 10 -205 3 -82 8 -175 10
-205 21 -356 32 -516 40 -620 9 -107 14 -176 20 -255 9 -116 13 -173 20 -235
3 -30 8 -80 10 -110 3 -30 7 -77 10 -105 3 -27 7 -77 10 -110 12 -129 14 -151
20 -195 5 -46 9 -88 20 -195 3 -33 8 -73 10 -90 2 -16 7 -57 10 -90 3 -33 10
-94 15 -135 5 -41 12 -97 15 -125 3 -27 7 -66 10 -85 2 -19 7 -60 10 -90 3
-30 8 -66 10 -80 2 -14 7 -49 10 -79 4 -30 8 -61 10 -70 1 -9 6 -43 10 -76 3
-33 8 -76 11 -95 5 -41 23 -174 29 -210 3 -14 7 -50 11 -80 5 -55 7 -63 18
-130 3 -19 8 -53 11 -75 3 -22 7 -53 10 -70 3 -16 7 -48 10 -70 3 -22 7 -53
10 -70 3 -16 7 -48 10 -70 3 -22 12 -80 20 -130 8 -49 17 -108 20 -130 3 -22
10 -65 15 -95 5 -30 11 -68 14 -85 10 -67 19 -121 22 -140 2 -11 6 -36 9 -55
3 -19 7 -46 9 -60 2 -14 10 -59 16 -100 7 -41 14 -84 16 -95 4 -22 24 -138 29
-170 6 -34 16 -97 21 -119 2 -11 18 -100 34 -196 31 -179 57 -318 90 -495 11
-55 22 -115 25 -134 3 -19 7 -37 9 -40 2 -3 7 -28 10 -56 4 -27 11 -68 16 -90
5 -22 12 -56 15 -75 3 -19 17 -91 31 -160 14 -69 28 -136 30 -150 3 -14 13
-68 24 -120 20 -97 26 -128 35 -177 3 -16 10 -48 15 -73 13 -57 18 -82 30
-145 6 -27 14 -68 19 -90 18 -84 43 -201 47 -224 3 -12 21 -91 40 -175 20 -83
41 -178 49 -211 12 -55 74 -298 140 -550 14 -55 42 -154 61 -220 19 -66 37
-127 39 -135 16 -68 180 -596 260 -835 68 -202 201 -582 245 -695 11 -30 27
-71 34 -90 41 -110 109 -288 116 -305 5 -11 47 -114 94 -230 98 -243 277 -666
331 -780 34 -73 95 -207 95 -210 0 -2 25 -57 56 -122 30 -65 79 -167 107 -228
61 -130 433 -870 502 -1000 26 -49 70 -129 96 -177 27 -48 49 -89 49 -92 0 -5
269 -490 320 -576 133 -227 186 -319 200 -345 16 -29 294 -488 369 -608 20
-32 54 -87 76 -123 130 -210 627 -964 818 -1239 107 -156 301 -430 306 -435 4
-3 26 -34 51 -70 24 -36 50 -72 57 -80 6 -8 74 -100 150 -205 161 -222 635
-849 653 -865 3 -3 21 -25 40 -50 19 -25 40 -52 47 -60 7 -8 67 -85 135 -170
67 -85 128 -162 135 -170 7 -8 47 -58 89 -110 85 -105 105 -130 226 -276 45
-55 98 -119 116 -142 19 -23 42 -50 52 -62 10 -12 33 -39 52 -62 18 -23 38
-46 43 -52 6 -6 35 -40 65 -76 30 -36 57 -67 60 -70 3 -3 27 -32 54 -65 28
-33 68 -80 90 -105 23 -25 64 -72 92 -105 103 -120 136 -158 147 -170 7 -7 37
-41 67 -76 74 -86 220 -250 239 -270 9 -9 41 -44 71 -79 30 -34 66 -75 80 -90
72 -79 209 -228 235 -256 17 -17 47 -51 69 -75 43 -50 253 -273 396 -424 52
-55 106 -112 120 -126 224 -237 866 -885 1149 -1158 131 -127 410 -394 461
-441 19 -18 67 -62 106 -99 154 -145 372 -346 420 -388 12 -10 62 -55 112
-101 50 -45 109 -99 133 -120 23 -20 54 -48 69 -62 15 -14 56 -50 91 -80 35
-30 70 -62 79 -70 9 -8 47 -42 85 -75 39 -33 72 -63 75 -66 9 -9 411 -351 535
-454 66 -55 125 -104 131 -110 6 -5 28 -23 49 -40 21 -16 66 -53 99 -81 81
-67 432 -343 476 -374 19 -13 44 -33 55 -43 11 -10 55 -44 99 -75 43 -31 117
-85 165 -119 396 -286 662 -386 1006 -377 71 2 141 5 155 8 79 14 97 17 112
22 9 3 25 6 35 8 38 8 172 56 229 83 102 48 305 172 420 258 22 16 53 39 68
50 105 75 526 399 656 506 19 16 82 67 140 115 116 95 125 102 325 269 77 64
156 131 176 148 20 18 62 55 94 82 32 28 63 55 69 60 6 6 43 37 81 70 39 33
75 64 81 70 6 5 39 35 74 65 35 30 74 64 86 75 12 11 55 49 95 85 40 36 77 70
83 75 6 6 56 51 111 100 198 178 279 252 340 310 18 17 83 77 145 135 62 58
127 119 145 135 653 619 1260 1225 1825 1825 149 158 277 295 292 313 20 22
183 200 204 221 5 6 55 61 109 121 114 126 100 112 186 207 37 40 88 98 114
128 26 30 74 84 105 120 31 36 108 124 170 195 62 72 125 144 139 160 14 17
76 89 136 160 60 72 142 168 181 215 75 88 323 388 336 405 4 6 22 28 40 50
26 31 163 202 245 305 7 8 50 62 96 120 200 252 769 1001 857 1130 8 12 32 45
54 73 40 53 234 324 273 380 12 18 69 100 127 182 141 201 605 895 751 1125
202 317 566 913 677 1110 14 25 60 104 102 177 42 73 76 137 76 143 0 5 5 10
10 10 6 0 10 4 10 9 0 5 17 40 39 78 21 37 53 95 71 128 18 33 41 74 50 90 33
59 179 332 244 455 79 150 423 840 496 995 28 61 85 184 127 275 41 91 86 189
99 218 59 126 300 697 387 917 211 533 407 1083 552 1550 20 66 43 140 52 165
28 85 186 640 219 770 87 344 164 661 178 733 2 12 16 75 30 139 31 143 40
188 50 238 2 14 16 81 31 150 26 123 70 341 79 390 2 14 7 36 10 50 3 14 8 36
10 50 3 14 12 61 21 105 9 44 18 95 21 114 3 18 8 43 10 55 4 19 26 131 39
201 3 17 8 41 10 55 9 43 13 66 39 213 15 78 30 165 36 192 5 28 12 66 15 85
3 19 8 42 10 50 3 8 9 44 14 80 5 36 22 133 36 215 15 83 28 159 30 170 1 11
6 36 9 55 4 19 9 46 11 60 1 14 8 57 15 95 13 82 17 106 25 160 3 22 10 63 15
90 5 28 15 84 21 125 16 116 28 195 54 365 8 50 17 110 20 135 6 41 20 140 30
200 2 14 9 63 15 110 6 47 13 101 16 120 21 150 31 220 79 610 9 69 18 141 20
160 2 19 7 58 10 85 3 28 8 66 10 85 3 19 7 60 10 90 6 63 15 143 20 180 2 14
7 59 11 100 10 116 11 121 18 185 3 33 8 85 11 115 3 30 8 75 10 100 2 25 7
72 10 105 3 33 7 83 10 111 7 97 16 209 20 244 3 19 7 76 10 125 4 50 8 110
10 135 4 50 14 181 20 280 2 36 7 106 10 155 3 50 8 128 10 175 11 233 16 341
20 400 19 327 26 1432 12 1850 -12 349 -24 590 -42 815 -2 33 -7 94 -10 135
-3 41 -7 95 -10 120 -2 25 -7 74 -10 110 -5 65 -13 135 -20 200 -3 19 -7 62
-10 95 -3 33 -7 69 -9 80 -2 11 -7 49 -11 85 -4 36 -9 74 -11 85 -2 11 -6 43
-9 70 -3 28 -7 59 -9 70 -2 11 -9 61 -15 110 -7 50 -13 97 -15 105 -12 61 -18
101 -37 214 -11 72 -22 132 -24 135 -2 3 -6 31 -10 61 -4 30 -9 58 -11 61 -1
3 -17 74 -34 158 -34 165 -124 535 -160 652 -160 525 -301 869 -449 1094 -70
107 -221 268 -306 326 -27 19 -55 39 -62 46 -7 6 -68 39 -135 72 -122 60 -246
102 -343 116 -27 4 -61 9 -75 12 -14 3 -5675 6 -12581 7 -10052 1 -12574 -1
-12649 -11z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.4 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

@ -0,0 +1,9 @@
const fs = require('fs');
const packageJson = require('../package.json');
const version = `${packageJson.version}+${fs.readFileSync('../.git/refs/heads/master').toString().trim().substring(0, 7)}`;
// eslint-disable-next-line no-console
console.log(`Writing version ${version} to local .env file.`);
fs.writeFileSync('.env', `VITE_APP_VERSION=${version}`);

67
webapp/src/App.vue Normal file
View file

@ -0,0 +1,67 @@
<template>
<MoleculeNavigationDrawer
:drawer-id="drawerId"
:navigation-entries="navigationEntries"
>
<div class="flex min-h-screen flex-col items-stretch">
<RouterView
v-if="route.name != 'radio'"
class="shrink-0 grow"
/>
<ViewRadio
v-show="route.name == 'radio'"
class="shrink-0 grow"
/>
</div>
<AtomFooter class="shrink-0" />
</MoleculeNavigationDrawer>
</template>
<script setup lang="ts">
import { INavigationEntry } from './interfaces/navigation-entry.interface';
import { useRoute } from 'vue-router';
import {
IdentificationIcon,
BuildingLibraryIcon,
MusicalNoteIcon,
AdjustmentsHorizontalIcon,
BriefcaseIcon,
} from '@heroicons/vue/24/outline';
import MoleculeNavigationDrawer from './components/molecules/MoleculeNavigationDrawer.vue';
import ViewRadio from './components/views/ViewRadio.vue';
import AtomFooter from './components/atoms/AtomFooter.vue';
const route = useRoute();
const drawerId = 'default-drawer';
const navigationEntries: INavigationEntry[] = [
{
name: 'Arbeitgeberportal',
icon: BriefcaseIcon,
to: '/employer',
},
{
name: 'Zoll',
icon: IdentificationIcon,
to: '/checkin',
},
{
name: 'Bank',
icon: BuildingLibraryIcon,
to: '/bank',
},
{
name: 'Radio',
icon: MusicalNoteIcon,
to: '/radio',
},
{
name: 'Staatsportal',
icon: AdjustmentsHorizontalIcon,
to: '/settings',
},
];
</script>
<style lang="scss" scoped>
</style>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: [
'preset-default',
'prefixIds',
],
};

View file

@ -0,0 +1,18 @@
<template>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title"><slot name="title" /></h2>
<p>
<slot />
</p>
</div>
</div>
</template>
<script lang="ts" setup>
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,83 @@
<template>
<div class="relative">
<input
v-model="modelValue"
type="number"
class="input w-full border-primary text-right"
:class="{
'pr-[5.5rem]': !perDay,
'pr-[8rem]': perDay,
'!border-error': error,
}"
step="1"
:min="min"
:max="max"
@keyup="onKeyUp"
/>
<span
class="pointer-events-none absolute top-2/4 text-[1rem] opacity-50"
:class="{
'-mt-3': !suffixMargin,
'mt-[-0.625rem]': suffixMargin,
'ml-[-5.5rem]': !perDay,
'ml-[-8rem]': perDay,
}"
>,00 Batzen{{ perDay ? ' / Tag' : '' }}</span>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
const input = ref<InstanceType<typeof HTMLInputElement>>();
const error = ref(false);
const props = withDefaults(defineProps<{
suffixMargin?: boolean,
perDay?: boolean,
min?: number,
max?: number,
value?: number,
}>(), {
suffixMargin: false,
perHour: false,
min: 1,
max: 1_000_000,
value: undefined,
});
/* global defineModel */
const modelValue = defineModel();
function onKeyUp(event: KeyboardEvent) {
error.value = false;
// eslint-disable-next-line vue/no-mutating-props
const value = parseInt((event.target as HTMLInputElement).value);
if(isNaN(value)) {
error.value = true;
} else if(value < props.min) {
error.value = true;
} else if(value > props.max) {
error.value = true;
} else {
emit('change', value);
}
}
function reset() {
if(input.value) {
input.value.value = '';
}
}
const emit = defineEmits<{
change: [value: number],
}>();
defineExpose({
reset,
});
</script>
<style lang="scss" scoped></style>

View file

@ -0,0 +1,23 @@
<template>
<div class="relative">
<AtomInput
v-model="modelValue"
type="number"
class="pr-7 text-right"
step="1"
min="1"
/>
<span class="pointer-events-none absolute top-2/4 -ml-6 -mt-3 opacity-50">x</span>
</div>
</template>
<script lang="ts" setup>
import AtomInput from './AtomInput.vue';
/* global defineModel */
const modelValue = defineModel();
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,14 @@
<template>
<input
type="file"
class="file-input-bordered file-input-primary file-input"
/>
</template>
<script lang="ts" setup>
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,19 @@
<template>
<footer class="footer footer-center bg-base-100 p-4 text-base-content">
<div>
<p>Version: {{ version }}</p>
<p>Made with by Simon Giesel</p>
<p>Copyright © 2023 - All rights reserved</p>
</div>
</footer>
</template>
<script lang="ts" setup>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const version = (import.meta as any).env.VITE_APP_VERSION as string ?? 'local+dev';
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,8 +1,8 @@
<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">John Doe um 17:39:33</h1> <h1 class="text-6xl font-bold"><slot /></h1>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,39 @@
<template>
<input
v-model="modelValue"
:type="type"
:required="required"
:placeholder="placeholder"
class="input w-full border-primary"
:class="{
'!border-error': error,
}"
/>
</template>
<script lang="ts" setup>
defineProps({
type: {
type: String,
default: 'text',
},
required: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
error: {
type: Boolean,
default: false,
},
});
/* global defineModel */
const modelValue = defineModel();
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,92 @@
<template>
<Teleport to="body">
<input
:id="id"
type="checkbox"
class="modal-toggle"
@change="($event.target as HTMLInputElement).checked ? $emit('close') : null"
/>
<div class="modal">
<form
method="dialog"
class="modal-box"
:class="{
'bg-base-300': darker,
'w-auto': wAuto,
}"
>
<button
v-if="closeButton"
class="btn-ghost btn-sm btn-circle btn absolute right-2 top-2"
>
</button>
<h3
v-if="title"
class="text-lg font-bold"
>
{{ title }}
</h3>
<p class="py-4"><slot /></p>
<div class="modal-action">
<slot name="action" />
</div>
</form>
<form
method="dialog"
class="modal-backdrop"
>
<button>close</button>
</form>
</div>
</Teleport>
</template>
<script lang="ts" setup>
const props = defineProps({
id: {
type: String,
required: true,
},
title: {
type: String,
default: '',
},
darker: {
type: Boolean,
default: false,
},
closeButton: {
type: Boolean,
default: false,
},
wAuto: {
type: Boolean,
default: false,
},
});
function show(): void {
(document.getElementById(props.id) as HTMLInputElement).checked = true;
}
function close(): void {
(document.getElementById(props.id) as HTMLInputElement).checked = false;
}
function isOpen(): boolean {
return (document.getElementById(props.id) as HTMLInputElement).checked;
}
defineEmits(['close']);
defineExpose({
show,
close,
isOpen,
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,14 @@
<template>
<img
src="../../assets/svg/logo.svg"
/>
</template>
<script lang="ts" setup>
// import { isDark } from '../../utils/darkMode';
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,97 @@
<template>
<Teleport to="body">
<dialog
:id="id"
class="modal"
>
<form
method="dialog"
class="modal-box"
:class="{
'bg-base-300': darker,
'w-auto': wAuto,
}"
>
<button
v-if="closeButton"
class="btn-ghost btn-sm btn-circle btn absolute right-2 top-2"
>
</button>
<h3
v-if="title"
class="text-lg font-bold"
>
{{ title }}
</h3>
<p class="py-4"><slot /></p>
<div class="modal-action">
<slot name="action" />
</div>
</form>
<form
method="dialog"
class="modal-backdrop"
>
<button>close</button>
</form>
</dialog>
</Teleport>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
const props = defineProps({
id: {
type: String,
required: true,
},
title: {
type: String,
default: '',
},
darker: {
type: Boolean,
default: false,
},
closeButton: {
type: Boolean,
default: false,
},
wAuto: {
type: Boolean,
default: false,
},
});
function show(): void {
(document.getElementById(props.id) as HTMLDialogElement)?.showModal();
}
function close(): void {
(document.getElementById(props.id) as HTMLDialogElement)?.close();
}
function isOpen(): boolean {
return (document.getElementById(props.id) as HTMLDialogElement)?.open;
}
onMounted(() => {
(document.getElementById(props.id) as HTMLDialogElement).addEventListener('close', () => {
emit('close');
});
});
const emit = defineEmits(['close']);
defineExpose({
show,
close,
isOpen,
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,23 @@
<template>
<div class="relative">
<AtomInput
v-model="modelValue"
type="number"
class="pr-14 text-right"
step="1"
min="1"
/>
<span class="pointer-events-none absolute top-2/4 -ml-14 -mt-3 opacity-50">,00 %</span>
</div>
</template>
<script lang="ts" setup>
import AtomInput from './AtomInput.vue';
/* global defineModel */
const modelValue = defineModel();
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,5 +1,8 @@
<template> <template>
<select class="select select-bordered w-full"> <select
class="select-bordered select w-full"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
>
<option <option
disabled disabled
selected selected
@ -22,7 +25,13 @@ defineProps({
type: Array as PropType<string[]>, type: Array as PropType<string[]>,
required: true, required: true,
}, },
modelValue: {
type: String,
default: '',
},
}); });
defineEmits(['update:modelValue']);
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

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

@ -0,0 +1,16 @@
<template>
<textarea
v-model="modelValue"
class="textarea-primary textarea h-auto"
/>
</template>
<script lang="ts" setup>
/* global defineModel */
const modelValue = defineModel();
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,44 @@
<template>
<div class="hero min-h-full">
<form
class="hero-content mx-auto 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-primary 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,63 @@
<template>
<div class="hero min-h-full">
<form
class="hero-content mx-auto flex-col text-center"
@submit.prevent="handleAuth"
>
<AtomInput
v-model="username"
placeholder="Nutzername"
type="text"
:error="error"
required
/>
<AtomInput
v-model="password"
placeholder="Passwort"
type="password"
:error="error"
required
/>
<div
v-if="error"
class="alert alert-error flex w-[211px] items-center gap-2"
>
<XCircleIcon class="h-12 w-12" />
<span class="text-base font-normal">Nutzername oder Passwort falsch.</span>
</div>
<button class="btn-primary btn gap-2 self-end">
<ArrowRightOnRectangleIcon class="h-6 w-6" />
Login
</button>
</form>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { AuthService } from '../../services/auth.service';
import { ArrowRightOnRectangleIcon, XCircleIcon } from '@heroicons/vue/24/outline';
import AtomInput from '../atoms/AtomInput.vue';
const username = ref('');
const password = ref('');
const error = ref(false);
async function handleAuth() {
try {
await AuthService.loginAsBanker(username.value, password.value);
} catch (e) {
error.value = true;
console.error(e);
}
}
watch([username, password], () => {
error.value = false;
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,84 @@
<template>
<AtomModal
:id="id"
ref="modal"
:title="title"
@close="handleClose"
>
<div class="flex place-items-center justify-between">
<AtomInput
v-model="accountNumber"
class="w-4/12"
@keyup.esc="modal?.close()"
/>
</div>
<template #action>
<label
class="btn gap-2"
:for="id"
@click="modal?.close()"
>
<XCircleIcon class="h-6 w-6" />
Abbrechen
</label>
<button
class="btn gap-2"
@click="handleSubmit()"
>
<CheckCircleIcon class="h-6 w-6" />
Speichern
</button>
</template>
</AtomModal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/outline';
import AtomModal from '../atoms/AtomModal.vue';
import AtomInput from '../atoms/AtomInput.vue';
const modal = ref<InstanceType<typeof AtomModal>>();
const accountNumber = ref('');
const accountId = ref('');
defineProps({
id: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
});
const emit = defineEmits(['submit']);
function handleClose() {
accountNumber.value = '';
}
function handleSubmit() {
if(!accountNumber.value) {
return;
}
emit('submit', {
id: accountId.value,
accountNumber: accountNumber.value,
});
}
defineExpose({
show: (id: string) => {
accountId.value = id;
modal.value?.show();
},
close: () => modal.value?.close(),
isOpen: () => modal.value?.isOpen(),
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,166 @@
<template>
<div
v-if="settings"
class="rounded-lg"
>
<table class="table-pin-rows table-zebra table">
<thead
v-if="!hideHeader"
class="uppercase"
>
<tr>
<th v-for="header in tableHeaders">{{ header.title }}</th>
</tr>
</thead>
<tbody class="bg-base-100">
<tr
v-for="entry in data"
@click="$emit('click', entry.id)"
>
<td
v-for="header in tableHeaders"
:class="{
'text-center': [TableHeaderType.BUTTON_ACCOUNT, TableHeaderType.BUTTON_CHANGE_ACCOUNT_NUMBER].includes(header.type),
}"
>
<span v-if="header.type === TableHeaderType.STRING">
{{ entry[header.key] }}
</span>
<span v-else-if="header.type === TableHeaderType.DATE">
{{ DateService.toShortString(entry[header.key]) }}
</span>
<span v-else-if="header.type === TableHeaderType.DATETIME">
{{ entry[header.key] ? DateService.toString(parseISO(entry[header.key])) : 'N/A' }}
</span>
<span v-else-if="header.type === TableHeaderType.CURRENCY">
{{ CurrencyService.toString(entry[header.key]) }}
</span>
<span v-else-if="header.type === TableHeaderType.SHIFT">
<div class="dropdown-bottom dropdown">
<label
tabindex="0"
class="btn bg-base-300 normal-case"
>{{ entry[header.key] }}
<ChevronDownIcon class="h-4 w-4" />
</label>
<ul
tabindex="0"
class="dropdown-content menu rounded-box z-[1] w-52 bg-base-100 p-2 shadow"
>
<li @click="$emit('selectShift', { id: entry.id, shift: Shifts.NONE }); removeFocus();"><a>{{
Shifts.NONE
}}</a></li>
<li><a @click="$emit('selectShift', { id: entry.id, shift: Shifts.EARLY }); removeFocus();">{{
Shifts.EARLY }}</a></li>
<li><a @click="$emit('selectShift', { id: entry.id, shift: Shifts.LATE }); removeFocus();">{{
Shifts.LATE
}}</a></li>
</ul>
</div>
</span>
<RouterLink
v-if="header.type === TableHeaderType.BUTTON_ACCOUNT"
:to="`/bank?accountNumber=${entry.accountNumber}`"
class="btn-ghost btn-sm btn p-1"
>
<div
class="tooltip normal-case"
data-tip="Transaktionen anzeigen"
>
<CurrencyDollarIcon class="h-6 w-6" />
</div>
</RouterLink>
<span
v-if="header.type === TableHeaderType.BUTTON_CHANGE_ACCOUNT_NUMBER"
class="btn-ghost btn-sm btn p-1"
@click="$emit('changeAccountNumber', entry.id)"
>
<div
class="tooltip normal-case"
data-tip="Kontonummer ändern"
>
<CreditCardIcon class="h-6 w-6" />
</div>
</span>
<span
v-if="header.type === TableHeaderType.WAGE"
class="flex items-center gap-2"
>
<AtomCurrencyInput
v-model="entry.wage"
suffix-margin
per-day
class="min-w-[11rem]"
:min="settings.minWage / 100"
:max="settings.maxWage / 100"
@change="$emit('selectWage', { id: entry.id, wage: $event })"
/>
</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script lang="ts" setup>
import { PropType, onMounted, ref } from 'vue';
import { parseISO } from 'date-fns';
import { SettingsResponse } from '../../types/pocketbase.types';
import { DateService } from '../../services/date.service';
import { CurrencyService } from '../../services/currency.service';
import { SettingsService } from '../../services/settings.service';
import { Shifts } from '../../enums/shift.enum';
import { ChevronDownIcon, CreditCardIcon, CurrencyDollarIcon } from '@heroicons/vue/24/outline';
import AtomCurrencyInput from '../atoms/AtomCurrencyInput.vue';
const settings = ref<SettingsResponse>();
defineProps({
tableHeaders: {
type: Array as PropType<{ title: string, key: string, type: TableHeaderType }[]>,
required: true,
},
data: {
// Explicit allow any types as this is a dynamic list of data
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: Array as PropType<any[]>,
required: true,
},
hideHeader: {
type: Boolean,
required: false,
default: false,
},
});
onMounted(async () => {
settings.value = await SettingsService.getSettings();
});
defineEmits<{
click: [id: string],
selectShift: [{ id: string, shift: Shifts }],
selectWage: [{ id: string, wage: number }],
changeAccountNumber: [id: string],
}>();
function removeFocus() {
(document.activeElement as HTMLElement).blur();
}
</script>
<script lang="ts">
export enum TableHeaderType {
STRING,
DATE,
CURRENCY,
DATETIME,
BUTTON_ACCOUNT,
BUTTON_CHANGE_ACCOUNT_NUMBER,
WAGE,
SHIFT,
}
</script>
<style lang="scss" scoped></style>

View file

@ -0,0 +1,54 @@
<template>
<AtomModal
:id="id"
ref="modal"
:title="title"
>
<div class="flex flex-col gap-2">
<AtomTextarea v-model="modelValue" />
</div>
<template #action>
<label
class="btn gap-2"
:for="id"
@click="modal?.close()"
>
<XCircleIcon class="h-6 w-6" />
Schließen
</label>
</template>
</AtomModal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { XCircleIcon } from '@heroicons/vue/24/outline';
import AtomModal from '../atoms/AtomModal.vue';
import AtomTextarea from '../atoms/AtomTextarea.vue';
const modal = ref<InstanceType<typeof AtomModal>>();
defineProps({
id: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
});
defineExpose({
show: () => modal.value?.show(),
close: () => modal.value?.close(),
isOpen: () => modal.value?.isOpen(),
});
/* global defineModel */
const modelValue = defineModel();
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,73 @@
<template>
<AtomModal
:id="id"
ref="modal"
:title="title"
>
<div class="flex flex-col gap-2">
<AtomFileInput @change="file = ($event.target as HTMLInputElement)?.files?.[0]" />
<p>Datei für den Import der Stammdaten im CSV-Format auswählen.<br /><b>! Achtung !</b> Die Datei muss eine Kopfzeile besitzen.</p>
</div>
<template #action>
<label
class="btn gap-2"
:for="id"
@click="modal?.close()"
>
<XCircleIcon class="h-6 w-6" />
Abbrechen
</label>
<button
class="btn gap-2"
@click="handleSubmit()"
>
<CheckCircleIcon class="h-6 w-6" />
{{ actionLabel }}
</button>
</template>
</AtomModal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/outline';
import AtomModal from '../atoms/AtomModal.vue';
import AtomFileInput from '../atoms/AtomFileInput.vue';
const modal = ref<InstanceType<typeof AtomModal>>();
const file = ref<File>();
defineProps({
id: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
actionLabel: {
type: String,
required: true,
},
});
const emit = defineEmits(['submit']);
function handleSubmit() {
if(!file.value) {
return;
}
emit('submit', file.value);
}
defineExpose({
show: () => modal.value?.show(),
close: () => modal.value?.close(),
isOpen: () => modal.value?.isOpen(),
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,32 +1,33 @@
<template> <template>
<AtomModal <AtomModal
:id="id" :id="id"
ref="modal"
:title="title" :title="title"
@open="handleOpen" @close="handleClose"
> >
<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" v-model="amount"
class="w-4/12" class="w-4/12"
@keyup.enter="handleSubmit()" :max="1000"
@keyup.esc="abortButton.click()" @keyup.esc="modal?.close()"
/> />
</div> </div>
<template #action> <template #action>
<label <label
ref="abortButton"
class="btn gap-2" class="btn gap-2"
:for="id" :for="id"
@click="modal?.close()"
> >
<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>
@ -34,13 +35,13 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/outline';
import AtomModal from '../atoms/AtomModal.vue';
import AtomCurrencyInput from '../atoms/AtomCurrencyInput.vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/outline';
import AtomCurrencyInput from '../atoms/AtomCurrencyInput.vue';
import AtomModal from '../atoms/AtomModal.vue';
const input = ref(); const modal = ref<InstanceType<typeof AtomModal>>();
const abortButton = ref(); const amount = ref();
defineProps({ defineProps({
id: { id: {
@ -63,18 +64,22 @@ defineProps({
const emit = defineEmits(['submit']); const emit = defineEmits(['submit']);
function handleOpen(event: {id: string, open: boolean}) { function handleClose() {
if(event.open) { amount.value = '';
// This is (currently) the only method to focus a input field inside a custom component.
input.value.$refs.input.$refs.input.value = '';
input.value.$refs.input.$refs.input.focus();
}
} }
function handleSubmit() { function handleSubmit() {
emit('submit', input.value.$refs.input.$refs.input.value); if(amount.value <= 0) {
abortButton.value.click(); return;
}
emit('submit', amount.value);
} }
defineExpose({
show: () => modal.value?.show(),
close: () => modal.value?.close(),
isOpen: () => modal.value?.isOpen(),
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -0,0 +1,86 @@
<template>
<AtomModal
:id="id"
ref="modal"
:title="title"
>
<div class="flex flex-col gap-4">
<p>{{ description }}</p>
<AtomInput
v-model="modelValue"
placeholder="Passwort"
type="password"
@keydown.prevent.enter="$emit('login')"
/>
<p
v-if="error"
class="alert alert-error"
>
{{ error }}
</p>
</div>
<template #action>
<button
class="btn gap-2"
:for="id"
@click="modal?.close()"
>
<XCircleIcon class="h-6 w-6" />
Schließen
</button>
<button
class="btn gap-2"
:for="id"
@click.prevent="$emit('login')"
>
<ArrowRightOnRectangleIcon class="h-6 w-6" />
Login
</button>
</template>
</AtomModal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ArrowRightOnRectangleIcon, XCircleIcon } from '@heroicons/vue/24/outline';
import AtomModal from '../atoms/AtomLegacyModal.vue';
import AtomInput from '../atoms/AtomInput.vue';
const modal = ref<InstanceType<typeof AtomModal>>();
defineProps({
id: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
error: {
type: String,
default: '',
},
});
defineExpose({
show: () => modal.value?.show(),
close: () => modal.value?.close(),
isOpen: () => modal.value?.isOpen(),
});
/* global defineModel */
const modelValue = defineModel();
defineEmits<{
login: [],
}>();
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,157 @@
<template>
<div
class="drawer"
:class="{
'lg:drawer-open': !hideNavigation,
}"
>
<input
:id="drawerId"
ref="drawerCheckbox"
type="checkbox"
class="drawer-toggle"
/>
<div
class="drawer-content flex flex-col bg-base-300"
:class="{'lg:pl-64': !hideNavigation}"
>
<div
class="navbar top-0 place-self-center shadow-lg"
:class="{
'lg:hidden': !hideNavigation,
}"
>
<div class="flex-none">
<label
:for="drawerId"
class="btn-ghost rounded-btn btn"
:class="{
'lg:hidden': !hideNavigation,
}"
>
<Bars3Icon class="h-7 w-7" />
</label>
<AtomLogo class="h-10 px-4" />
</div>
</div>
<slot />
</div>
<div class="drawer-side !fixed z-50">
<label
:for="drawerId"
class="drawer-overlay"
/>
<ul class="menu h-full w-64 overflow-y-auto bg-base-100 p-4">
<li class="pointer-events-none">
<AtomLogo class="w-full" />
</li>
<template v-if="isAdmin">
<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 !p-0" />
</template>
<template v-if="isBanker">
<li class="pointer-events-none rounded-md bg-info text-warning-content">
<span>
Angemeldet als Banker
<BuildingLibraryIcon 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 !p-0" />
</template>
<li
v-for="navigationEntry of navigationEntries"
:class="getNavigationEntryClass(navigationEntry)"
>
<component
:is="!navigationEntry.to || navigationEntry.disabled ? 'span' : 'router-link'"
v-if="navigationEntry.name !== 'divider'"
:to="navigationEntry.to"
active-class="active"
>
<component
:is="navigationEntry.icon"
class="h-6 w-6"
/>{{ navigationEntry.name }}
</component>
</li>
<AtomSwap
class="mt-auto place-self-end"
:on-icon="SunIcon"
:off-icon="MoonIcon"
:checked="isDark"
@change="toggleDark()"
/>
</ul>
</div>
</div>
</template>
<script lang="ts" setup>
import { PropType, onMounted, ref } from 'vue';
import { useEventBus } from '@vueuse/core';
import { useRouter } from 'vue-router';
import { INavigationEntry } from '../../interfaces/navigation-entry.interface';
import { AuthService } from '../../services/auth.service';
import { isDark, toggleDark } from '../../utils/darkMode';
import { ArrowRightOnRectangleIcon, Bars3Icon, BuildingLibraryIcon, ExclamationTriangleIcon, MoonIcon, SunIcon } from '@heroicons/vue/24/outline';
import AtomLogo from '../atoms/AtomLogo.vue';
import AtomSwap from '../atoms/AtomSwap.vue';
const isAdmin = ref(false);
const isBanker = ref(false);
const hideNavigation = ref(false);
const router = useRouter();
const drawerCheckbox = ref();
defineProps({
drawerId: {
type: String,
required: true,
},
navigationEntries: {
type: Array as PropType<INavigationEntry[]>,
required: true,
},
});
function getNavigationEntryClass(navigationEntry: INavigationEntry) {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
'menu-title !p-0': navigationEntry.name === 'divider',
disabled: navigationEntry.disabled,
};
}
useEventBus<boolean>('isAdmin').on(state => (isAdmin.value = state));
useEventBus<boolean>('isBanker').on(state => (isBanker.value = state));
useEventBus<boolean>('hideNavigation').on(state => (hideNavigation.value = state));
onMounted(() => {
isAdmin.value = AuthService.isAdmin();
isBanker.value = AuthService.isBanker();
});
router.afterEach(() => {
drawerCheckbox.value.checked = false;
});
</script>
<style lang="scss" scoped></style>

View file

@ -1,18 +1,18 @@
<template> <template>
<table class="table table-zebra"> <table class="table-zebra table overflow-hidden rounded-lg">
<thead> <thead class="bg-base-200">
<tr> <tr>
<th /> <th />
<th class="normal-case text-lg text-right">{{ CurrencyService.toString(balance) }}</th> <th class="text-right text-lg normal-case text-base-content">{{ CurrencyService.toString(balance) }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="bg-base-100">
<tr v-for="transaction in transactions"> <tr v-for="transaction in transactions">
<td> <td>
<div class="flex items-center"> <div class="flex items-center">
<div> <div>
<div class="font-bold">{{ transaction.type }}</div> <div class="font-bold">{{ transaction.label }}</div>
<div>{{ DateService.toString(transaction.date) }}</div> <div>{{ DateService.toString(parseISO(transaction.created)) }}</div>
</div> </div>
</div> </div>
</td> </td>
@ -35,13 +35,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PropType } from 'vue'; import { PropType } from 'vue';
import { ITransaction } from '../../interfaces/transaction.interface'; import { parseISO } from 'date-fns';
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 { 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,61 @@
<template>
<AtomModal
:id="id"
ref="modal"
:title="title"
>
<div class="flex flex-col gap-4">
<slot />
</div>
<template #action>
<button
class="btn gap-2"
:for="id"
@click="modal?.close()"
>
<XCircleIcon class="h-6 w-6" />
Abbrechen
</button>
<button
class="btn gap-2"
@click.prevent="$emit('confirm')"
>
<CheckCircleIcon class="h-6 w-6" />
Bestätigen
</button>
</template>
</AtomModal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/outline';
import AtomModal from '../atoms/AtomLegacyModal.vue';
const modal = ref<InstanceType<typeof AtomModal>>();
defineProps({
id: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
});
defineExpose({
show: () => modal.value?.show(),
close: () => modal.value?.close(),
isOpen: () => modal.value?.isOpen(),
});
defineEmits<{
confirm: [],
}>();
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,37 @@
<template>
<MoleculeAuthDialog v-if="!isAdmin" />
<div v-else>
<slot />
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useEventBus } from '@vueuse/core';
import { AuthService } from '../../services/auth.service';
import MoleculeAuthDialog from '../molecules/MoleculeAuthDialog.vue';
const isAdmin = ref(false);
useEventBus<boolean>('isAdmin').on((state: boolean) => {
isAdmin.value = state;
if(isAdmin.value) {
emit('authChange', true);
} else {
emit('authChange', false);
}
});
onMounted(() => {
isAdmin.value = AuthService.isAdmin();
if(isAdmin.value) {
emit('authChange', true);
}
});
const emit = defineEmits(['authChange']);
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,5 +1,9 @@
<template> <template>
#Stocks <iframe
src="/_"
frameborder="0"
class="h-full w-full"
/>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>

View file

@ -0,0 +1,291 @@
<template>
<MoleculeBankerAuthDialog
v-if="!isBanker"
/>
<div v-else>
<div
v-if="account || company"
class="hero-content flex-col pt-0 text-center lg:p-6"
>
<h1 class="mt-5 text-8xl">{{ CurrencyService.toString(getBalance()) }}</h1>
<h3
v-if="account"
class="mt-10 text-5xl font-bold"
>
Kontoinhaber: {{ `${account.firstName} ${account.lastName}` }}
</h3>
<h3
v-if="company && company.id == settings?.incomeTaxRecipient"
class="mt-10 text-5xl font-bold"
>
<div class="flex items-center gap-4">
<AtomLogo class="h-14 w-14" />
{{ company.name }}
</div>
</h3>
<h3
v-else-if="company"
class="mt-10 text-5xl font-bold"
>
Firma: {{ company.name }}
</h3>
<h4 class="mb-10 text-4xl">Kontonummer: {{ (account ? account : company ? company : null)?.accountNumber }}</h4>
<MoleculeTransactionTable
v-if="transactions"
class="mb-24 max-w-[42rem]"
:balance="getBalance()"
:transactions="transactions"
/>
</div>
<div
v-if="account || company"
class="fixed bottom-0 flex w-full place-content-center gap-16 bg-base-100 p-5 lg:w-[calc(100%-16rem)] lg:gap-32"
>
<MoleculeInputModal
id="deposit-modal"
ref="depositModal"
title="Einzahlung"
action-label="Einzahlen"
input-label="Einzuzahlender Betrag"
@submit="handleDeposit"
/>
<button
class="btn gap-2"
@click="() => depositModal?.show()"
>
<ChevronUpIcon class="h-6 w-6 text-success" />
Einzahlen
</button>
<MoleculeInputModal
id="withdraw-modal"
ref="withdrawModal"
title="Auszahlung"
action-label="Auszahlen"
input-label="Auszuzahlender Betrag"
@submit="handleWithdraw"
/>
<button
class="btn gap-2"
@click="() => withdrawModal?.show()"
>
<ChevronDownIcon class="h-6 w-6 text-error" />
Auszahlen
</button>
</div>
<AtomHeroText
v-else
class="h-full"
>
Bitte Karte scannen...
<div
v-if="error"
class="alert alert-error mt-6 shadow-lg"
>
<XCircleIcon class="h-6 w-6" />
<span class="text-base font-normal"><b>Fehler: </b>{{ error }}</span>
</div>
</AtomHeroText>
<div
v-if="depositError"
class="toast z-10"
>
<div class="alert alert-error">
<span>Transaktion nicht möglich. Konto ist nicht ausreichend gedeckt.</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { onKeyStroke, promiseTimeout, useEventBus } from '@vueuse/core';
import { RecordSubscription, UnsubscribeFunc } from 'pocketbase';
import { AccountsResponse, CompaniesResponse, SettingsResponse, TransactionsResponse } from '../../types/pocketbase.types';
import { AccountService } from '../../services/account.service';
import { AccountType, BankService, TransactionType } from '../../services/bank.service';
import { AuthService } from '../../services/auth.service';
import { CompanyService } from '../../services/company.service';
import { CurrencyService } from '../../services/currency.service';
import { SettingsService } from '../../services/settings.service';
import { 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';
import AtomLogo from '../atoms/AtomLogo.vue';
import MoleculeBankerAuthDialog from '../molecules/MoleculeBankerAuthDialog.vue';
const router = useRouter();
const subscription = ref<UnsubscribeFunc>();
const companySubscription = ref<UnsubscribeFunc>();
const isBanker = ref(false);
const account = ref<AccountsResponse|null>();
const company = ref<CompaniesResponse|null>();
const transactions = ref<TransactionsResponse[]>();
const depositModal = ref<InstanceType<typeof MoleculeInputModal>>();
const withdrawModal = ref<InstanceType<typeof MoleculeInputModal>>();
const accountId = ref<string>('');
const inputBuffer = ref<string>('');
const error = ref('');
const depositError = ref(false);
const settings = ref<SettingsResponse>();
if(router.currentRoute.value.query.accountNumber) {
accountId.value = router.currentRoute.value.query.accountNumber as string;
getAccount();
router.replace({ path: '/bank' });
}
onKeyStroke(['e', 'a'], (e) => {
if(!isBanker.value) { return; }
e.preventDefault();
if(depositModal.value?.isOpen() || withdrawModal.value?.isOpen()) {
return;
}
switch (e.key) {
case 'e':
depositModal.value?.show();
break;
case 'a':
withdrawModal.value?.show();
break;
}
});
onKeyStroke(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Enter'], (e) => {
if(!isBanker.value) { return; }
if(depositModal.value?.isOpen() || withdrawModal.value?.isOpen()) {
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) {
try {
company.value = await CompanyService.getCompanyByAccountNumber(accountId.value);
transactions.value = await BankService.getCompanyTransactions(company.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(amountNumber > 1000) { return; }
if(!isNaN(amountNumber) && account.value) {
await BankService.addTransaction(account.value.id, amountNumber, TransactionType.DEPOSIT);
} else if(!isNaN(amountNumber) && company.value) {
await BankService.addTransaction(company.value.id, amountNumber, TransactionType.DEPOSIT, AccountType.COMPANY);
}
}
async function handleWithdraw(amount: string) {
const amountNumber = parseInt(amount);
if(amountNumber > 1000) { return; }
if(amountNumber * 100 <= getBalance()) {
if(!isNaN(amountNumber) && account.value) {
// TODO: add max amount to input field and display custom error message
await BankService.addTransaction(account.value.id, -amountNumber, TransactionType.WITHDRAW);
} else if(!isNaN(amountNumber) && company.value) {
await BankService.addTransaction(company.value.id, -amountNumber, TransactionType.WITHDRAW, AccountType.COMPANY);
}
} else {
depositError.value = true;
const timeout = setTimeout(() => {
depositError.value = false;
clearTimeout(timeout);
}, 5000);
}
}
function handleRealtimeUpdates(transactionSubscription: RecordSubscription<TransactionsResponse>) {
if(
transactions.value &&
(transactionSubscription.record.account === account.value?.id || transactionSubscription.record.account === company.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;
}
}
}
onMounted(async () => {
isBanker.value = AuthService.isBanker();
isBanker.value = AuthService.isAdmin();
});
useEventBus<boolean>('isBanker').on(state => (isBanker.value = state));
useEventBus<boolean>('isAdmin').on(state => (isBanker.value = state));
onUnmounted(destroyRealtime);
async function init() {
if(!subscription.value) {
subscription.value = await BankService.subscribeToTransactionChanges(handleRealtimeUpdates);
}
if(!companySubscription.value) {
companySubscription.value = await BankService.subscribeToCompanyTransactionChanges(handleRealtimeUpdates);
}
settings.value = await SettingsService.getSettings();
}
function destroyRealtime() {
subscription.value?.();
companySubscription.value?.();
}
watch(isBanker, async (newValue) => {
if(newValue) {
await init();
} else {
destroyRealtime();
}
});
</script>
<style lang="scss" scoped>
</style>

View file

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

View file

@ -0,0 +1,117 @@
<template>
<MoleculeAuthDialog v-if="!isAdmin" />
<div
v-else
class="flex h-full flex-col p-6"
>
<AtomInput
v-model="searchQuery"
placeholder="Nach Firmenname suchen"
class="mb-4"
/>
<MoleculeDataTable
v-if="companies && changeAccountNumberModal"
:table-headers="tableHeaders"
:data="companies"
@change-account-number="changeAccountNumberModal.show($event);"
/>
<MoleculeChanceAccountNumberModal
id="changeAccountNumber"
ref="changeAccountNumberModal"
title="Kontonummer ändern"
@submit="changeAccountNumber"
/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useEventBus } from '@vueuse/core';
import { UnsubscribeFunc } from 'pocketbase';
import { CompaniesResponse } from '../../types/pocketbase.types';
import { AuthService } from '../../services/auth.service';
import AtomInput from '../atoms/AtomInput.vue';
import MoleculeDataTable, { TableHeaderType } from '../molecules/MoleculeDataTable.vue';
import MoleculeAuthDialog from '../molecules/MoleculeAuthDialog.vue';
import MoleculeChanceAccountNumberModal from '../molecules/MoleculeChanceAccountNumberModal.vue';
import { CompanyService } from '../../services/company.service';
const isAdmin = ref(false);
const companiesSubscription = ref<UnsubscribeFunc>();
const changeAccountNumberModal = ref<InstanceType<typeof MoleculeChanceAccountNumberModal>>();
const companies = ref<CompaniesResponse[]>([]);
const initCompanies = ref<CompaniesResponse[]>([]);
const searchQuery = ref('');
const tableHeaders = [
{
title: 'Kontonummer',
key: 'accountNumber',
type: TableHeaderType.STRING,
},
{
title: 'Name',
key: 'name',
type: TableHeaderType.STRING,
},
{
title: 'Trans.',
key: '',
type: TableHeaderType.BUTTON_ACCOUNT,
},
{
title: 'Knr. änd.',
key: '',
type: TableHeaderType.BUTTON_CHANGE_ACCOUNT_NUMBER,
},
];
useEventBus<boolean>('isAdmin').on(state => {
isAdmin.value = state;
if(isAdmin.value) {
getData();
}
});
async function getData() {
initCompanies.value = await CompanyService.getCompanies();
companies.value = initCompanies.value;
}
watch(
searchQuery,
(value) => {
search(value);
},
);
function search(value: string) {
if(initCompanies.value) {
companies.value = initCompanies.value.filter((company) =>
company.name?.toLocaleLowerCase().includes(value.toLocaleLowerCase()),
);
}
}
function changeAccountNumber(account: {id: string, accountNumber: string}) {
CompanyService.updateAccountNumber(account.id, account.accountNumber);
}
onMounted(async () => {
isAdmin.value = AuthService.isAdmin();
if(isAdmin.value) {
getData();
}
companiesSubscription.value = await CompanyService.subscribeToCompanyChanges(async () => {
await getData();
search(searchQuery.value);
});
});
onUnmounted(() => {
companiesSubscription.value?.();
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,140 @@
<template>
<MoleculeAuthDialog v-if="!isAdmin" />
<div
v-else
class="flex h-full flex-col p-6"
>
<AtomInput
v-model="searchQuery"
placeholder="Nach Vor-/Nachname oder Klasse suchen"
class="mb-4"
/>
<MoleculeDataTable
v-if="accounts && changeAccountNumberModal"
:table-headers="tableHeaders"
:data="accounts"
@change-account-number="changeAccountNumberModal.show($event);"
/>
<MoleculeChanceAccountNumberModal
id="changeAccountNumber"
ref="changeAccountNumberModal"
title="Kontonummer ändern"
@submit="changeAccountNumber"
/>
</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 AtomInput from '../atoms/AtomInput.vue';
import MoleculeDataTable, { TableHeaderType } from '../molecules/MoleculeDataTable.vue';
import MoleculeAuthDialog from '../molecules/MoleculeAuthDialog.vue';
import MoleculeChanceAccountNumberModal from '../molecules/MoleculeChanceAccountNumberModal.vue';
const isAdmin = ref(false);
const accountsSubscription = ref<UnsubscribeFunc>();
const transactionsSubscription = ref<UnsubscribeFunc>();
const changeAccountNumberModal = ref<InstanceType<typeof MoleculeChanceAccountNumberModal>>();
const accounts = ref<AccountsListResponse<number, string>[]>([]);
const initAccounts = ref<AccountsListResponse<number, string>[]>([]);
const searchQuery = ref('');
const tableHeaders = [
{
title: 'Kontonummer',
key: 'accountNumber',
type: TableHeaderType.STRING,
},
{
title: 'Name',
key: 'name',
type: TableHeaderType.STRING,
},
{
title: 'Klasse',
key: 'grade',
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: 'Knr. änd.',
key: '',
type: TableHeaderType.BUTTON_CHANGE_ACCOUNT_NUMBER,
},
];
useEventBus<boolean>('isAdmin').on(state => {
isAdmin.value = state;
if(isAdmin.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())
|| account.grade?.toLocaleLowerCase().includes(value.toLocaleLowerCase()),
);
}
}
function changeAccountNumber(account: {id: string, accountNumber: string}) {
AccountService.updateAccountNumber(account.id, account.accountNumber);
}
onMounted(async () => {
isAdmin.value = AuthService.isAdmin();
if(isAdmin.value) {
getData();
}
accountsSubscription.value = await AccountService.subscribeToAccountChanges(async () => {
await getData();
search(searchQuery.value);
});
transactionsSubscription.value = await BankService.subscribeToTransactionChanges(async () => {
await getData();
search(searchQuery.value);
});
});
onUnmounted(() => {
accountsSubscription.value?.();
transactionsSubscription.value?.();
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,364 @@
<template>
<div
v-if="!isAuthenticated"
class="flex min-h-full flex-col p-6"
>
<AtomInput
v-model="searchQuery"
placeholder="Firmenname eingeben"
class="mb-4"
/>
<MoleculeDataTable
v-if="companies"
class="cursor-pointer"
:table-headers="companyTableHeaders"
:data="companies"
hide-header
@click="showLoginDialog"
/>
<MoleculeLoginModal
id="login-modal"
ref="loginModal"
v-model="password"
:title="selectedCompany?.name ?? ''"
description="Bitte gib dein Passwort ein, um das Firmenportal anzuzeigen."
:error="error"
@login="login"
@close="password = ''; error = ''"
/>
</div>
<div
v-else-if="company"
class="flex min-h-full flex-col p-6"
>
<div class="mb-4 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h1 class="text-4xl">{{ company.name }}</h1>
<button
class="btn-primary btn w-48"
@click="logout()"
>
<ArrowLeftOnRectangleIcon class="h-6 w-6" />
Abmelden
</button>
</div>
<!-- <div class="flex items-center justify-between">
<h2 class="text-2xl">Kontostand: {{ CurrencyService.toString(getBalance()) }}</h2>
<button class="btn-primary btn-disabled btn w-48">
<BanknotesIcon class="h-6 w-6" />
Transaktionen
</button>
</div> -->
<div class="stats shadow">
<div class="stat place-items-center">
<div class="stat-title">Kontostand</div>
<div class="stat-value">{{ CurrencyService.toString(getBalance(), false) }}</div>
<div class="stat-desc">Batzen</div>
</div>
<div class="stat place-items-center">
<div class="stat-title">Arbeitnehmer</div>
<div class="stat-value">{{ accounts.filter(account => (account.shift != Shifts.EARLY && account.shift != Shifts.LATE)).length }}</div>
<div class="stat-desc">ohne Schicht</div>
</div>
<div class="stat place-items-center">
<div class="stat-title">Arbeitnehmer</div>
<div class="stat-value">{{ accounts.filter(account => account.shift == Shifts.EARLY).length }}</div>
<div class="stat-desc">in Frühschicht</div>
</div>
<div class="stat place-items-center">
<div class="stat-title">Arbeitnehmer</div>
<div class="stat-value">{{ accounts.filter(account => account.shift == Shifts.LATE).length }}</div>
<div class="stat-desc">in Spätschicht</div>
</div>
</div>
<div
v-if="settings"
class="stats shadow"
>
<div class="stat place-items-center">
<div class="stat-title">Aktueller Mindestlohn</div>
<div class="stat-value">{{ CurrencyService.toString(settings.minWage, false) }} / Tag</div>
<div class="stat-desc">Batzen</div>
</div>
<div class="stat place-items-center">
<div class="stat-title">Aktueller Maximallohn</div>
<div class="stat-value">{{ CurrencyService.toString(settings.maxWage, false) }} / Tag</div>
<div class="stat-desc">Batzen</div>
</div>
</div>
<div
v-if="company"
class="flex items-center justify-center gap-4"
>
<button
class="btn-primary btn"
:class="{'btn-disabled': (getBalance() < getTotalCostsByShifts(Shifts.EARLY) || DateService.isToday(parseISO(company.earlyShiftPayed)))}"
@click="wageType = Shifts.EARLY; verifyModal?.show()"
>
<SunIcon class="h-6 w-6" />
Frühschicht bezahlen ({{ CurrencyService.toString(getTotalCostsByShifts(Shifts.EARLY)) }})
</button>
<button
class="btn-primary btn"
:class="{'btn-disabled': (getBalance() < getTotalCostsByShifts(Shifts.LATE) || DateService.isToday(parseISO(company.lateShiftPayed)))}"
@click="wageType = Shifts.LATE; verifyModal?.show()"
>
<MoonIcon class="h-6 w-6" />
Spätschicht bezahlen ({{ CurrencyService.toString(getTotalCostsByShifts(Shifts.LATE)) }})
</button>
</div>
<div class="flex flex-col gap-2">
<div
v-if="DateService.isToday(parseISO(company.earlyShiftPayed))"
class="alert alert-info"
>
<InformationCircleIcon class="h-6 w-6" />
Die Frühschicht wurde heute bereits bezahlt.
</div>
<div
v-if="DateService.isToday(parseISO(company.lateShiftPayed))"
class="alert alert-info"
>
<InformationCircleIcon class="h-6 w-6" />
Die Spätschicht wurde heute bereits bezahlt.
</div>
<div
v-if="getBalance() < getTotalCostsByShifts(Shifts.EARLY)"
class="alert alert-warning"
>
<ExclamationTriangleIcon class="h-6 w-6" />
Konto nicht ausreichend gedeckt um die Frühschicht zu bezahlen.
</div>
<div
v-if="getBalance() < getTotalCostsByShifts(Shifts.LATE)"
class="alert alert-warning"
>
<ExclamationTriangleIcon class="h-6 w-6" />
Konto nicht ausreichend gedeckt um die Spätschicht zu bezahlen.
</div>
</div>
<MoleculeDataTable
v-if="accounts"
:table-headers="accountTableHeaders"
:data="accounts"
@select-shift="selectShift"
@select-wage="updateWage"
/>
</div>
<MoleculeVerifyModal
id="verify-modal"
ref="verifyModal"
title="Lohnauszahlung bestätigen"
@confirm="confirmWage"
>
<div class="flex flex-col gap-1">
<p>Lohn wirklich für die {{ wageType == Shifts.EARLY ? 'Frühschicht' : 'Spätschicht' }} auszahlen?</p>
<p>
Das Konto wird mit <b>{{
CurrencyService.toString(
wageType == Shifts.EARLY ?
getTotalCostsByShifts(Shifts.EARLY) : getTotalCostsByShifts(Shifts.LATE)
)
}}</b> belastet.<br />
Dies beinhaltet die Lohnsteuer von {{ settings?.incomeTax }}% ({{
CurrencyService.toString(
wageType == Shifts.EARLY ?
getTotalCostsByShifts(Shifts.EARLY) * (settings?.incomeTax ?? 0) / 100 :
getTotalCostsByShifts(Shifts.LATE) * (settings?.incomeTax ?? 0) / 100
)
}}).
</p>
<p><b>Hinweis:</b> Diese Aktion kann nur 1x am Tag ausgeführt werden.</p>
</div>
</MoleculeVerifyModal>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { RecordSubscription, UnsubscribeFunc } from 'pocketbase';
import { parseISO } from 'date-fns';
import { AccountsResponse, CompaniesResponse, SettingsResponse, TransactionsResponse } from '../../types/pocketbase.types';
import { AccountService } from '../../services/account.service';
import { BankService } from '../../services/bank.service';
import { CompanyService } from '../../services/company.service';
import { CurrencyService } from '../../services/currency.service';
import { DateService } from '../../services/date.service';
import { SettingsService } from '../../services/settings.service';
import { Shifts } from '../../enums/shift.enum';
import { ArrowLeftOnRectangleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
MoonIcon,
SunIcon,
} from '@heroicons/vue/24/outline';
import AtomInput from '../atoms/AtomInput.vue';
import MoleculeDataTable, { TableHeaderType } from '../molecules/MoleculeDataTable.vue';
import MoleculeLoginModal from '../molecules/MoleculeLoginModal.vue';
import MoleculeVerifyModal from '../molecules/MoleculeVerifyModal.vue';
const loginModal = ref<InstanceType<typeof MoleculeLoginModal>>();
const searchQuery = ref('');
const password = ref('');
const companies = ref<CompaniesResponse[]>([]);
const initCompanies = ref<CompaniesResponse[]>([]);
const selectedCompany = ref<CompaniesResponse>();
const company = ref<CompaniesResponse>();
const accounts = ref<AccountsResponse[]>([]);
const transactions = ref<TransactionsResponse[]>([]);
const isAuthenticated = ref(false);
const accountsSubscription = ref<UnsubscribeFunc>();
const settings = ref<SettingsResponse>();
const error = ref('');
const verifyModal = ref<InstanceType<typeof MoleculeVerifyModal>>();
const wageType = ref<Shifts|undefined>();
const companyTableHeaders = [
{
title: '',
key: 'name',
type: TableHeaderType.STRING,
},
];
const accountTableHeaders = [
{
title: 'Vorname',
key: 'firstName',
type: TableHeaderType.STRING,
},
{
title: 'Nachname',
key: 'lastName',
type: TableHeaderType.STRING,
},
{
title: 'Schicht',
key: 'shift',
type: TableHeaderType.SHIFT,
},
{
title: 'Lohn',
key: 'wage',
type: TableHeaderType.WAGE,
},
];
async function getData() {
initCompanies.value = await CompanyService.getCompanies();
companies.value = initCompanies.value;
}
watch(
searchQuery,
(value) => {
search(value);
},
);
function search(value: string): void {
if(initCompanies.value) {
companies.value = initCompanies.value.filter((company) =>
company.name?.toLocaleLowerCase().includes(value.toLocaleLowerCase()),
);
}
}
onMounted(async () => {
isAuthenticated.value = CompanyService.isAuthenticated();
settings.value = await SettingsService.getSettings();
if(isAuthenticated.value) {
init();
} else {
getData();
}
});
async function init() {
company.value = await CompanyService.getCompany();
accounts.value = await AccountService.getAccountsByCompanyId(company.value.id);
transactions.value = await BankService.getCompanyTransactions(company.value.id);
accounts.value.forEach((account) => {
account.wage = account.wage / 100;
});
accountsSubscription.value = await AccountService.subscribeToAccountChanges(async (data: RecordSubscription<AccountsResponse>) => {
if(data.action === 'update') {
accounts.value = accounts.value.map((account) => {
if(account.id === data.record.id) {
data.record.wage = data.record.wage / 100;
return data.record;
}
return account;
});
}
});
}
function getBalance(): number {
return transactions.value?.reduce((acc, transaction) => acc + transaction.amount, 0) ?? 0;
}
function getTotalCostsByShifts(shift: Shifts): number {
return accounts.value?.filter(
(account) => account.shift === shift).reduce((acc, account) => acc + account.wage * 100, 0,
) ?? 0;
}
function showLoginDialog(id: string): void {
selectedCompany.value = companies.value.find((company) => company.id === id);
loginModal.value?.show();
}
async function login(): Promise<void> {
if(selectedCompany.value) {
try {
await CompanyService.login(selectedCompany.value?.username, password.value);
} catch {
error.value = 'Fehler beim Login. Bitte Passwort überprüfen.';
return;
}
isAuthenticated.value = true;
await init();
loginModal.value?.close();
}
}
async function selectShift(shiftChange: {id: string, shift: Shifts}): Promise<void> {
await AccountService.updateShift(shiftChange.id, shiftChange.shift);
}
async function updateWage(wageChange: {id: string, wage: number}): Promise<void> {
if(wageChange.wage * 100 < (settings.value?.minWage ?? 0)) {
return;
}
if(wageChange.wage * 100 > (settings.value?.maxWage ?? 0)) {
return;
}
await AccountService.updateWage(wageChange.id, wageChange.wage);
}
function logout() {
CompanyService.logout();
isAuthenticated.value = false;
selectedCompany.value = undefined;
company.value = undefined;
accounts.value = [];
getData();
}
async function confirmWage() {
verifyModal.value?.close();
if(wageType.value) {
await CompanyService.payWage(wageType.value);
}
wageType.value = undefined;
init();
}
onUnmounted(() => {
accountsSubscription.value?.();
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,26 @@
<template>
<iframe
v-if="settings?.radioUrl"
:src="settings?.radioUrl"
frameborder="0"
class="h-full w-full"
/>
<span v-else>Fehler: Keine Radio URL festgelegt.</span>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { SettingsResponse } from '../../types/pocketbase.types';
import { SettingsService } from '../../services/settings.service';
const settings = ref<SettingsResponse>();
onMounted(async () => {
settings.value = await SettingsService.getSettings();
});
</script>
<style lang="scss" scoped>
</style>

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