51 Commits

Author SHA1 Message Date
Adrian Zürcher
a6f4b47d92 release new version with new group table and member filter function
All checks were successful
Build Quasar SPA and Go Backend for memberApp / build-spa (push) Successful in 2m28s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, .exe, windows) (push) Successful in 5m50s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, , linux) (push) Successful in 5m51s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm, 6, , linux) (push) Successful in 5m26s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm64, , linux) (push) Successful in 5m28s
2025-11-15 14:21:27 +01:00
Adrian Zürcher
8d9220ae2c add title to every page 2025-11-15 14:20:03 +01:00
Adrian Zürcher
7b17cd99fe make new localstorage function file 2025-11-15 14:19:45 +01:00
Adrian Zürcher
44f355a5ea add new group table and filter for member table 2025-11-15 14:17:30 +01:00
Adrian Zürcher
fb27e9c026 add new export option with permissions close #13 2025-11-12 17:17:43 +01:00
Adrian Zürcher
14d2270260 add new enviroment variables and remove cli flags 2025-11-12 16:07:10 +01:00
Adrian Zürcher
7d4d2e4c54 new relase with enviroment variable
All checks were successful
Build Quasar SPA and Go Backend for memberApp / build-spa (push) Successful in 2m25s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, .exe, windows) (push) Successful in 5m29s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, , linux) (push) Successful in 5m37s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm64, , linux) (push) Successful in 5m29s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm, 6, , linux) (push) Successful in 5m35s
2025-11-12 12:18:24 +01:00
Adrian Zürcher
352d0aa4c6 add translation to appName name 2025-11-12 12:16:47 +01:00
Adrian Zürcher
76269e7358 add enviroment variable for golang beckend close #6 2025-11-12 12:16:27 +01:00
Adrian Zürcher
87297a3b97 add tooltip 2025-11-12 10:22:22 +01:00
Adrian Zürcher
b12574994c make drawer size change for smaller screens 2025-11-12 10:22:12 +01:00
Adrian Zürcher
66bb7c1942 fix darkmode for dialog and add tooltip translation close #20 2025-11-12 10:21:42 +01:00
Adrian Zürcher
f8b79de6a2 make app name changable and move settings parameter to settings close #19 2025-11-12 10:20:44 +01:00
Adrian Zürcher
c7fe7490f1 change to new logo with icongenie form quasar close #17 2025-11-12 10:18:29 +01:00
Adrian Zürcher
334c14a307 add login logout translation 2025-11-11 16:05:25 +01:00
Adrian Zürcher
b9f009162d remove console logs 2025-11-11 16:05:07 +01:00
Adrian Zürcher
b844f487bc make darkmode persistent close #15 2025-11-11 16:04:55 +01:00
Adrian Zürcher
829dc074e2 change to personal favicon (could be improved) close #14 2025-11-11 16:04:22 +01:00
Adrian Zürcher
63c7e89dd4 new release
All checks were successful
Build Quasar SPA and Go Backend for memberApp / build-spa (push) Successful in 2m22s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, .exe, windows) (push) Successful in 5m26s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, , linux) (push) Successful in 5m40s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm, 6, , linux) (push) Successful in 5m22s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm64, , linux) (push) Successful in 5m15s
2025-11-11 08:13:54 +01:00
Adrian Zürcher
53a6408466 change birthay as non required 2025-11-11 08:13:01 +01:00
Adrian Zürcher
0793bb5e31 fix missing translation 2025-11-11 08:12:44 +01:00
Adrian Zürcher
8d243302f0 add filter function to exclude existing records 2025-11-11 08:12:23 +01:00
Adrian Zürcher
2cce310fc4 add fallback language to first match if language not avaiable 2025-11-11 08:10:52 +01:00
Adrian Zürcher
a9707dc799 change notification from top left to top center 2025-11-11 08:10:24 +01:00
Adrian Zürcher
67dee7a746 add new column comment close #13 2025-11-11 08:09:41 +01:00
Adrian Zürcher
c7c1b6c7c6 move notification with new marging and close button close #7 2025-11-10 08:09:09 +01:00
Adrian Zürcher
f7d5d9d019 fix localstorage not working on phone close #8 2025-11-09 22:32:22 +01:00
Adrian Zürcher
f1d7d3617d fix column address missing close #10 2025-11-09 22:31:49 +01:00
Adrian Zürcher
df580d98c0 new release
All checks were successful
Build Quasar SPA and Go Backend for memberApp / build-spa (push) Successful in 2m19s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, .exe, windows) (push) Successful in 5m32s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, , linux) (push) Successful in 5m34s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm, 6, , linux) (push) Successful in 5m31s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm64, , linux) (push) Successful in 5m33s
2025-11-08 12:14:05 +01:00
Adrian Zürcher
06afdf4349 add feature to set user defined expiration on every user close #4 2025-11-08 12:09:56 +01:00
Adrian Zürcher
db96732a62 fix functions existing and delete 2025-11-07 08:34:53 +01:00
Adrian Zürcher
aba4bafb65 add new table feature for responsible person and assignment close #2
All checks were successful
Build Quasar SPA and Go Backend for memberApp / build-spa (push) Successful in 2m23s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, .exe, windows) (push) Successful in 5m37s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, , linux) (push) Successful in 5m49s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm, 6, , linux) (push) Successful in 5m37s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm64, , linux) (push) Successful in 5m34s
2025-11-06 17:43:20 +01:00
Adrian Zürcher
d57ee4c1e7 fix event not working issue close #1 2025-11-06 09:08:01 +01:00
Adrian Zürcher
9866cc3ffd fix event edit not working closes #3 2025-11-05 11:30:56 +01:00
6ec42c6c75 database removed 2025-11-05 11:26:51 +01:00
Adrian Zürcher
7b760afeb3 remove permission object 2025-11-04 15:32:02 +01:00
Adrian Zürcher
21dcf1a476 remove id from table 2025-11-04 15:31:36 +01:00
Adrian Zürcher
35d1a0f734 change wrong permissions 2025-11-04 15:31:21 +01:00
Adrian Zürcher
effbb61707 fix reference problem so value changes only after saving 2025-11-04 15:30:58 +01:00
Adrian Zürcher
7eb5ab9ab2 add env files for dev and production 2025-11-04 15:30:17 +01:00
Adrian Zürcher
aec741f094 add new event and attendance table with automatic now timestamp
All checks were successful
Build Quasar SPA and Go Backend for memberApp / build-spa (push) Successful in 2m34s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, .exe, windows) (push) Successful in 5m39s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, , linux) (push) Successful in 5m46s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm, 6, , linux) (push) Successful in 5m32s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm64, , linux) (push) Successful in 5m35s
2025-11-04 10:59:56 +01:00
Adrian Zürcher
632163d751 fix csv import endpoint and cert flags 2025-10-31 15:27:05 +01:00
Adrian Zürcher
a93f063009 Merge branch 'main' of https://gitea.tecamino.com/paadi/memberApp 2025-10-31 14:54:30 +01:00
Adrian Zürcher
cc3a547961 new package dbHandler accesscontrol memeberdb and login with rights
All checks were successful
Build Quasar SPA and Go Backend for memberApp / build-spa (push) Successful in 2m20s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, .exe, windows) (push) Successful in 5m27s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, , linux) (push) Successful in 5m32s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm, 6, , linux) (push) Successful in 5m28s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm64, , linux) (push) Successful in 5m29s
2025-10-31 14:54:05 +01:00
fdc8525abe backend/main.go aktualisiert
fix wrong order and description of ssl certificate
2025-10-14 21:29:42 +02:00
8075d1fe1f src/components/UploadDialog.vue aktualisiert 2025-10-14 21:23:31 +02:00
8709989fbe src/components/UploadDialog.vue aktualisiert
Fix absolute url to relative url
2025-10-14 21:22:16 +02:00
Adrian Zürcher
b0d6bb5512 fix binary name
All checks were successful
Build Quasar SPA and Go Backend for memberApp / build-spa (push) Successful in 2m18s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, .exe, windows) (push) Successful in 5m15s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, , linux) (push) Successful in 5m18s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm64, , linux) (push) Successful in 5m18s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm, 6, , linux) (push) Successful in 5m20s
2025-10-14 16:52:04 +02:00
Adrian Zürcher
690b7f4034 add new user and role table in app (in progress)
All checks were successful
Build Quasar SPA and Go Backend for memberApp / build-spa (push) Successful in 2m12s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, , linux) (push) Successful in 5m8s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, .exe, windows) (push) Successful in 5m8s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm, 6, , linux) (push) Successful in 4m57s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm64, , linux) (push) Successful in 5m7s
2025-10-14 16:41:20 +02:00
Adrian Zürcher
fdf56a4c0b lift version
All checks were successful
Build Quasar SPA and Go Backend for memberApp / build-spa (push) Successful in 2m21s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, , linux) (push) Successful in 3m15s
Build Quasar SPA and Go Backend for memberApp / build-backend (amd64, .exe, windows) (push) Successful in 3m8s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm, 6, , linux) (push) Successful in 3m7s
Build Quasar SPA and Go Backend for memberApp / build-backend (arm64, , linux) (push) Successful in 3m8s
2025-10-12 14:57:19 +02:00
Adrian Zürcher
a908db4f38 first commit 2025-10-12 14:56:18 +02:00
112 changed files with 16514 additions and 0 deletions

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

1
.env.development Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL= https://localhost:9500/api

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL= https://members.tecamino.com/api

120
.gitea/workflows/build.yml Normal file
View File

@@ -0,0 +1,120 @@
name: Build Quasar SPA and Go Backend for memberApp
on:
push:
tags:
- '*'
jobs:
build-spa:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Install Quasar CLI
run: |
npm cache clean --force
npm install -g @quasar/cli --force
- name: Build Quasar SPA
run: quasar build
- name: Upload SPA artifact
uses: actions/upload-artifact@v3
with:
name: quasar-dist
path: ./dist/spa
build-backend:
needs: build-spa
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: windows
arch: amd64
ext: .exe
- goos: linux
arch: amd64
ext: ""
- goos: linux
arch: arm64
ext: ""
- goos: linux
arch: arm
arm_version: 6
ext: ""
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download SPA artifact
uses: actions/download-artifact@v3
with:
name: quasar-dist
path: ./dist/spa
- name: Ensure latest Go is installed in /data/go
run: |
export GOROOT=/data/go/go
export PATH=$GOROOT/bin:$PATH
export GOCACHE=/data/gocache
export GOMODCACHE=/data/gomodcache
mkdir -p $GOCACHE $GOMODCACHE
if [ ! -x "$GOROOT/bin/go" ]; then
echo "Go not found in $GOROOT, downloading latest stable..."
GO_VERSION=$(curl -s https://go.dev/VERSION?m=text | head -n1)
echo "Latest version is $GO_VERSION"
mkdir -p /data/go
curl -sSL "https://go.dev/dl/${GO_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tar.gz
tar -C /data/go -xzf /tmp/go.tar.gz
else
echo "Using cached Go from $GOROOT"
fi
go version
- name: Go Mod Tidy & Download
working-directory: ./backend
run: |
export GOROOT=/data/go/go
export PATH=$GOROOT/bin:$PATH
export GOCACHE=/data/gocache
export GOMODCACHE=/data/gomodcache
mkdir -p $GOCACHE $GOMODCACHE
go mod tidy -v
go mod download
- name: Build Go backend binary
working-directory: ./backend
run: |
export GOROOT=/data/go/go
export PATH=$GOROOT/bin:$PATH
export GOCACHE=/data/gocache
export GOMODCACHE=/data/gomodcache
mkdir -p $GOCACHE $GOMODCACHE
OUTPUT="../memberApp-${{ matrix.goos }}-${{ matrix.arch }}${{ matrix.ext }}"
if [ "${{ matrix.arch }}" = "arm" ]; then
GOARM=${{ matrix.arm_version }}
fi
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.arch }} go build -ldflags="-s -w" -trimpath -o "$OUTPUT" main.go
- name: Upload combined package
uses: actions/upload-artifact@v3
with:
name: memberApp-${{ matrix.goos }}-${{ matrix.arch }}
path: |
./dist/spa
memberApp-${{ matrix.goos }}-${{ matrix.arch }}${{ matrix.ext }}

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
.DS_Store
.thumbs.db
node_modules
# Quasar core related directories
.quasar
/dist
/quasar.config.*.temporary.compiled*
# Cordova related directories and files
/src-cordova/node_modules
/src-cordova/platforms
/src-cordova/plugins
/src-cordova/www
# Capacitor related directories and files
/src-capacitor
/src-capacitor/www
/src-capacitor/node_modules
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.scene
# local .env files
.env.local*
# local .db files
*.db
*.dba
# local .log files
*.log
# golang quasar websever executable
backend/server-linux-arm64
backend/cert

5
.npmrc Normal file
View File

@@ -0,0 +1,5 @@
# pnpm-related options
shamefully-hoist=true
strict-peer-dependencies=false
# to get the latest compatible packages when creating the project https://github.com/pnpm/pnpm/issues/6463
resolution-mode=highest

5
.prettierrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"singleQuote": true,
"printWidth": 100
}

15
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"editorconfig.editorconfig",
"vue.volar",
"wayou.vscode-todo-highlight"
],
"unwantedRecommendations": [
"octref.vetur",
"hookyqr.beautify",
"dbaeumer.jshint",
"ms-vscode.vscode-typescript-tslint-plugin"
]
}

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

@@ -0,0 +1,16 @@
{
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": [
"source.fixAll.eslint"
],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"vue"
],
"typescript.tsdk": "node_modules/typescript/lib"
}

1
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

62
backend/env/env.go vendored Normal file
View File

@@ -0,0 +1,62 @@
package env
import (
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
type EnvKey string
const (
Env EnvKey = "ENV"
GinMode EnvKey = "GIN_MODE"
Debug EnvKey = "DEBUG"
PrivKey EnvKey = "PRIVKEY"
Fullchain EnvKey = "FULLCHAIN"
Https EnvKey = "HTTPS"
HostUrl EnvKey = "HOST_URL"
HostPort EnvKey = "HOST_PORT"
WorkingDir EnvKey = "WORKING_DIR"
Spa EnvKey = "SPA"
AccessSecret EnvKey = "ACCESS_SECRET"
RefreshSecret EnvKey = "REFRESH_SECRET"
Organization EnvKey = "ORGANIZATION"
DOMAIN EnvKey = "DOMAIN"
AllowOrigin EnvKey = "ALLOWORIGIN"
)
const (
EnvDevelopment = "development"
EnvProduction = "Prodction"
)
func (key EnvKey) GetValue() string {
return os.Getenv(string(key))
}
func (key EnvKey) GetBoolValue() bool {
return strings.ToLower(os.Getenv(string(key))) == "true"
}
func (key EnvKey) GetUIntValue() uint {
value, err := strconv.ParseUint(os.Getenv(string(key)), 10, 32)
if err != nil {
return 0
}
return uint(value)
}
func Load(file string) error {
return godotenv.Load(file)
}
func InDevelopmentMode() bool {
return Env.GetValue() == EnvDevelopment
}
func InDebugMode() bool {
return strings.ToLower(Debug.GetValue()) == "true"
}

View File

@@ -0,0 +1,14 @@
ENV=development
GIN_MODE=release
DEBUG=false
SPA=directory_of_spa_files
WORKING_DIR=.
HOST_URL=your_local_url
HOST_PORT=your_local_port
HTTPS=true
PRIVKEY=your_certificate_key_file
FULLCHAIN=your_certificate_fullchain_file
ACCESS_SECRET=your_32bit_long_access_secret
REFRESH_SECRET=your_32bit_long_referesh_secret
ALLOWORIGIN=all_allowed_urls
DOMAIN=your_domain

67
backend/go.mod Normal file
View File

@@ -0,0 +1,67 @@
module backend
go 1.24.5
require (
gitea.tecamino.com/paadi/access-handler v1.0.25
gitea.tecamino.com/paadi/memberDB v1.1.3
gitea.tecamino.com/paadi/tecamino-dbm v0.1.1
gitea.tecamino.com/paadi/tecamino-logger v0.2.1
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.43.0
)
require (
gitea.tecamino.com/paadi/dbHandler v1.0.8 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gorm.io/gorm v1.31.0 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.39.0 // indirect
)

163
backend/go.sum Normal file
View File

@@ -0,0 +1,163 @@
gitea.tecamino.com/paadi/access-handler v1.0.25 h1:GiMnkEM0/fo2B1uCzGVyjpAhM2S58LG22N6+BdtdpgQ=
gitea.tecamino.com/paadi/access-handler v1.0.25/go.mod h1:wKsB5/Rvaj580gdg3+GbUf5V/0N00XN6cID+C/8135M=
gitea.tecamino.com/paadi/dbHandler v1.0.8 h1:ZWSBM/KFtLwTv2cBqwK1mOxWAxAfL0BcWEC3kJ9JALU=
gitea.tecamino.com/paadi/dbHandler v1.0.8/go.mod h1:y/xn/POJg1DO++67uKvnO23lJQgh+XFQq7HZCS9Getw=
gitea.tecamino.com/paadi/memberDB v1.1.3 h1:ZwSA+TNL1ZvL8bMnJ5a2odc44bQBa31gVxD2fBA6o0I=
gitea.tecamino.com/paadi/memberDB v1.1.3/go.mod h1:/Af2OeJIHq+8kE5L5DlJxhSJjB75eWBcKRpkxi+n9bU=
gitea.tecamino.com/paadi/tecamino-dbm v0.1.1 h1:vAq7mwUxlxJuLzCQSDMrZCwo8ky5usWi9Qz+UP+WnkI=
gitea.tecamino.com/paadi/tecamino-dbm v0.1.1/go.mod h1:+tmf1rjPaKEoNeUcr1vdtoFIFweNG3aUGevDAl3NMBk=
gitea.tecamino.com/paadi/tecamino-logger v0.2.1 h1:sQTBKYPdzn9mmWX2JXZBtGBvNQH7cuXIwsl4TD0aMgE=
gitea.tecamino.com/paadi/tecamino-logger v0.2.1/go.mod h1:FkzRTldUBBOd/iy2upycArDftSZ5trbsX5Ira5OzJgM=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

226
backend/main.go Normal file
View File

@@ -0,0 +1,226 @@
package main
import (
"backend/env"
"backend/models"
"backend/server"
"backend/utils"
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
AccessHandler "gitea.tecamino.com/paadi/access-handler"
dbApi "gitea.tecamino.com/paadi/memberDB/api"
"gitea.tecamino.com/paadi/tecamino-dbm/cert"
"gitea.tecamino.com/paadi/tecamino-logger/logging"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func main() {
// set cli flage
envFile := flag.String("env", ".env", "enviroment file")
flag.Parse()
// load enviroment file if exists
if err := env.Load(*envFile); err != nil {
fmt.Println("no .env found path: ", *envFile)
}
devMode := env.InDevelopmentMode()
// set gin mode
if !devMode {
gin.SetMode(env.GinMode.GetValue())
}
workingDir := env.WorkingDir.GetValue()
spa := env.Spa.GetValue()
//change working directory only if value is given
if workingDir != "." && workingDir != "" {
os.Chdir(workingDir)
}
//set allowed origins
var allowOrigins models.StringSlice
if strings.Contains(env.DOMAIN.GetValue(), "http") {
allowOrigins.Set(env.DOMAIN.GetValue())
}
if env.AllowOrigin.GetValue() != "" {
allowOrigins.Set(env.AllowOrigin.GetValue())
}
wd, err := os.Getwd()
if err != nil {
log.Fatalf("Could not get working directory: %v", err)
}
folderName := filepath.Base(wd)
logFileName := folderName + ".log"
logger, err := logging.NewLogger(logFileName, &logging.Config{
MaxSize: 1,
MaxBackup: 3,
MaxAge: 28,
Debug: env.InDebugMode(),
TerminalOut: true,
})
if err != nil {
logger.Error("main new logger", err)
panic(err)
}
//new login manager
accessHandler, err := AccessHandler.NewAccessHandler(".", logger)
if err != nil {
logger.Error("main login manager", err)
panic(err)
}
// new server
s := server.NewServer()
// initiate Database handler
dbHandler, err := dbApi.NewAPIHandler(logger)
if err != nil {
logger.Error("main login manager", err)
panic(err)
}
//get local ip
httpString := "http://"
if env.Https.GetBoolValue() {
httpString = "https://"
}
allowOrigins = append(allowOrigins, httpString+"localhost:9000", httpString+"localhost:9500", httpString+"127.0.0.1:9000", httpString+"0.0.0.0:9500")
localIP, err := utils.GetLocalIP()
if err != nil {
logger.Error("main", fmt.Sprintf("get local ip : %s", err))
} else {
allowOrigins = append(allowOrigins, fmt.Sprintf("%s%s:9000", httpString, localIP), fmt.Sprintf("%s%s:9500", httpString, localIP))
}
fmt.Println(100, allowOrigins)
s.Routes.Use(cors.New(cors.Config{
AllowOrigins: allowOrigins,
//AllowOrigins: []string{"*"},
AllowMethods: []string{"POST", "GET", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
//set logger for AuthMiddleware
accessHandler.SetMiddlewareLogger(s.Routes)
api := s.Routes.Group("/api")
//set routes
//public
api.GET("/logout", accessHandler.Logout)
api.GET("/login/me", accessHandler.Me)
api.POST("/login", accessHandler.Login)
//private
auth := api.Group("", accessHandler.AuthMiddleware())
role := auth.Group("", accessHandler.AuthorizeRole("/api"))
role.GET("/members", dbHandler.GetMember)
auth.GET("/events", dbHandler.GetEvent)
auth.GET("/groups", dbHandler.GetGroup)
auth.GET("/users", accessHandler.GetUser)
auth.GET("/roles", accessHandler.GetRole)
auth.POST("database/open", dbHandler.OpenDatabase)
auth.POST("/members/add", dbHandler.AddNewMember)
auth.POST("/members/edit", dbHandler.UpdateMember)
auth.POST("/members/delete", dbHandler.DeleteMember)
auth.POST("/members/import/csv", dbHandler.ImportCSV)
auth.POST("/events/add", dbHandler.StartNewEvent)
auth.POST("/events/edit", dbHandler.UpdateEvent)
auth.POST("/events/add/attendees", dbHandler.AddNewAttendees)
auth.POST("/events/delete/attendees", dbHandler.DeleteAttendee)
auth.POST("/events/delete", dbHandler.DeleteEvent)
auth.POST("/groups/add", dbHandler.NewGroup)
auth.POST("/groups/edit", dbHandler.UpdateGroup)
auth.POST("/groups/delete", dbHandler.DeleteGroup)
auth.GET("/responsible", dbHandler.GetResponsible)
auth.POST("/responsible/add", dbHandler.AddNewResponsible)
auth.POST("/responsible/delete", dbHandler.DeleteResponsible)
auth.POST("/roles/add", accessHandler.AddRole)
auth.POST("/roles/update", accessHandler.UpdateRole)
auth.POST("/roles/delete", accessHandler.DeleteRole)
auth.POST("/users/add", accessHandler.AddUser)
auth.POST("/users/update", accessHandler.UpdateUser)
auth.POST("/users/new/password", accessHandler.ChangePassword)
auth.POST("/users/delete", accessHandler.DeleteUser)
api.POST("/login/refresh", accessHandler.Refresh)
// Serve static files
s.Routes.StaticFS("/assets", gin.Dir(filepath.Join(spa, "assets"), true))
s.Routes.NoRoute(func(c *gin.Context) {
// Disallow fallback for /api paths
if strings.HasPrefix(c.Request.URL.Path, "/api") {
c.JSON(http.StatusNotFound, models.NewJsonMessageResponse("API endpoint not found"))
return
}
// Try to serve file from SPA directory
filePath := filepath.Join(spa, c.Request.URL.Path)
if _, err := os.Stat(filePath); err == nil {
c.File(filePath)
return
}
// Fallback to index.html for SPA routing
c.File(filepath.Join(spa, "index.html"))
})
go func() {
time.Sleep(500 * time.Millisecond)
if err := utils.OpenBrowser(fmt.Sprintf("%slocalhost:%s", httpString, env.HostPort.GetValue()), logger); err != nil {
logger.Error("main", fmt.Sprintf("starting browser error : %s", err))
}
}()
if env.Https.GetBoolValue() {
if env.Fullchain.GetValue() == "" {
logger.Error("ssl certificate", "-cert flag not given for https server")
log.Fatal("-cert flag not given for https server")
}
if env.PrivKey.GetValue() == "" {
logger.Error("ssl key", "-chain flag not given for https server")
log.Fatal("-chain flag not given for https server")
}
// start https server
logger.Info("main", fmt.Sprintf("https listen on ip: %s port: %s", env.HostUrl.GetValue(), env.HostPort.GetValue()))
if err := s.ServeHttps(env.HostUrl.GetValue(), env.HostPort.GetUIntValue(), cert.Cert{Organization: env.Organization.GetValue(), CertFile: env.Fullchain.GetValue(), KeyFile: env.PrivKey.GetValue()}); err != nil {
logger.Error("main", "error https server "+err.Error())
}
return
}
// start http server
logger.Info("main", fmt.Sprintf("http listen on ip: %s port: %s", env.HostUrl.GetValue(), env.HostPort.GetValue()))
if err := s.ServeHttp(env.HostUrl.GetValue(), env.HostPort.GetUIntValue()); err != nil {
logger.Error("main", "error http server "+err.Error())
}
}

View File

@@ -0,0 +1,18 @@
package models
type JsonResponse struct {
Error bool `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
func NewJsonMessageResponse(msg string) JsonResponse {
return JsonResponse{
Message: msg,
}
}
func NewJsonErrorResponse(err error) JsonResponse {
return JsonResponse{
Message: err.Error(),
}
}

View File

@@ -0,0 +1,13 @@
package models
import "strings"
type StringSlice []string
func (s *StringSlice) Set(value string) {
if strings.Contains(value, ",") {
*s = append(*s, strings.Split(value, ",")...)
return
}
*s = append(*s, value)
}

38
backend/server/server.go Normal file
View File

@@ -0,0 +1,38 @@
package server
import (
"fmt"
"sync"
"gitea.tecamino.com/paadi/tecamino-dbm/cert"
"gitea.tecamino.com/paadi/tecamino-logger/logging"
"github.com/gin-gonic/gin"
)
// server model for database manager websocket
type Server struct {
Routes *gin.Engine
sync.RWMutex
Logger *logging.Logger
}
// initalizes new dbm server
func NewServer() *Server {
return &Server{
Routes: gin.Default(),
}
}
// serve dbm as http
func (s *Server) ServeHttp(ip string, port uint) error {
return s.Routes.Run(fmt.Sprintf("%s:%d", ip, port))
}
// serve dbm as http
func (s *Server) ServeHttps(url string, port uint, cert cert.Cert) error {
// generate self signed tls certificate
if err := cert.GenerateSelfSignedCert(); err != nil {
return err
}
return s.Routes.RunTLS(fmt.Sprintf("%s:%d", url, port), cert.CertFile, cert.KeyFile)
}

14
backend/utils/hash.go Normal file
View File

@@ -0,0 +1,14 @@
package utils
import "golang.org/x/crypto/bcrypt"
// Hash password
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(b), err
}
// Check password
func CheckPassword(password, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

22
backend/utils/ip.go Normal file
View File

@@ -0,0 +1,22 @@
package utils
import (
"fmt"
"net"
)
func GetLocalIP() (string, error) {
addrs, err := net.InterfaceAddrs()
if err != nil {
return "", err
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
if ipNet.IP.To4() != nil {
return ipNet.IP.String(), nil
}
}
}
return "", fmt.Errorf("no local IP address found")
}

14
backend/utils/secret.go Normal file
View File

@@ -0,0 +1,14 @@
package utils
import (
"crypto/rand"
)
func GenerateJWTSecret(length int) ([]byte, error) {
bytes := make([]byte, length)
_, err := rand.Read(bytes)
if err != nil {
return nil, err
}
return bytes, nil
}

65
backend/utils/utils.go Normal file
View File

@@ -0,0 +1,65 @@
package utils
import (
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"gitea.tecamino.com/paadi/tecamino-logger/logging"
)
func OpenBrowser(url string, logger *logging.Logger) error {
var commands [][]string
switch runtime.GOOS {
case "windows":
// Try with Chrome in kiosk mode
commands = [][]string{
{`C:\Program Files\Google\Chrome\Application\chrome.exe`, "--kiosk", url},
{"rundll32", "url.dll,FileProtocolHandler", url}, // fallback
}
case "darwin":
// macOS: open with Chrome in kiosk
commands = [][]string{
{"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "--kiosk", url},
{"open", url}, // fallback
}
default: // Linux
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("XDG_SESSION_TYPE") != "wayland" {
return fmt.Errorf("os is running i headless mode do not start browser")
}
commands = [][]string{
{"chromium-browser", "--kiosk", url},
{"google-chrome", "--kiosk", url},
{"firefox", "--kiosk", url},
{"xdg-open", url}, // fallback
}
}
for _, cmd := range commands {
execCmd := exec.Command(cmd[0], cmd[1:]...)
if err := execCmd.Start(); err == nil {
return nil
} else {
logger.Error("utils.OpenBrowser", err)
}
}
return fmt.Errorf("could not open browser")
}
func FindAllFiles(rootDir, fileExtention string) (files []string, err error) {
err = filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
if d.IsDir() {
return nil
} else if filepath.Ext(d.Name()) == fileExtention {
files = append(files, path)
}
return err
})
return
}

83
eslint.config.js Normal file
View File

@@ -0,0 +1,83 @@
import js from '@eslint/js';
import globals from 'globals';
import pluginVue from 'eslint-plugin-vue';
import pluginQuasar from '@quasar/app-vite/eslint';
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript';
import prettierSkipFormatting from '@vue/eslint-config-prettier/skip-formatting';
export default defineConfigWithVueTs(
{
/**
* Ignore the following files.
* Please note that pluginQuasar.configs.recommended() already ignores
* the "node_modules" folder for you (and all other Quasar project
* relevant folders and files).
*
* ESLint requires "ignores" key to be the only one in this object
*/
// ignores: []
},
pluginQuasar.configs.recommended(),
js.configs.recommended,
/**
* https://eslint.vuejs.org
*
* pluginVue.configs.base
* -> Settings and rules to enable correct ESLint parsing.
* pluginVue.configs[ 'flat/essential']
* -> base, plus rules to prevent errors or unintended behavior.
* pluginVue.configs["flat/strongly-recommended"]
* -> Above, plus rules to considerably improve code readability and/or dev experience.
* pluginVue.configs["flat/recommended"]
* -> Above, plus rules to enforce subjective community defaults to ensure consistency.
*/
pluginVue.configs['flat/essential'],
{
files: ['**/*.ts', '**/*.d.ts', '**/*.vue'],
rules: {
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
},
},
// https://github.com/vuejs/eslint-config-typescript
vueTsConfigs.recommendedTypeChecked,
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node, // SSR, Electron, config files
process: 'readonly', // process.env.*
ga: 'readonly', // Google Analytics
cordova: 'readonly',
Capacitor: 'readonly',
chrome: 'readonly', // BEX related
browser: 'readonly', // BEX related
},
},
// add your custom rules here
rules: {
'prefer-promise-reject-errors': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
},
},
{
files: ['src-pwa/custom-service-worker.ts'],
languageOptions: {
globals: {
...globals.serviceworker,
},
},
},
prettierSkipFormatting,
);

24
index.html Normal file
View File

@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<title><%= productName %></title>
<meta charset="utf-8" />
<meta name="description" content="<%= productDescription %>" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta
name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"
/>
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png" />
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png" />
<link rel="icon" type="image/ico" href="favicon.ico" />
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

8445
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "lightcontrol",
"version": "1.1.1",
"description": "A Tecamino App",
"productName": "Attendence Records",
"author": "A. Zuercher",
"type": "module",
"private": true,
"scripts": {
"lint": "eslint -c ./eslint.config.js \"./src*/**/*.{ts,js,cjs,mjs,vue}\"",
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0",
"dev": "quasar dev",
"build": "quasar build",
"postinstall": "quasar prepare"
},
"dependencies": {
"@capacitor-community/sqlite": "^7.0.1",
"@quasar/extras": "^1.16.4",
"axios": "^1.10.0",
"js-yaml": "^4.1.0",
"jwt-decode": "^4.0.0",
"pinia": "^3.0.3",
"quasar": "^2.16.0",
"vue": "^3.4.18",
"vue-i18n": "^11.1.12",
"vue-router": "^4.0.12",
"vue3-touch-events": "^5.0.13",
"vuedraggable": "^4.1.0",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"@quasar/app-vite": "^2.1.0",
"@types/node": "^20.5.9",
"@types/zxcvbn": "^4.4.5",
"@vue/eslint-config-prettier": "^10.1.0",
"@vue/eslint-config-typescript": "^14.4.0",
"autoprefixer": "^10.4.2",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"globals": "^15.12.0",
"prettier": "^3.3.3",
"typescript": "~5.5.3",
"vite-plugin-checker": "^0.9.0",
"vue-tsc": "^2.0.29"
},
"engines": {
"node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1"
}
}

29
postcss.config.js Normal file
View File

@@ -0,0 +1,29 @@
// https://github.com/michael-ciniawsky/postcss-load-config
import autoprefixer from 'autoprefixer'
// import rtlcss from 'postcss-rtlcss'
export default {
plugins: [
// https://github.com/postcss/autoprefixer
autoprefixer({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions'
]
}),
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/pnpm/bun/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line (and its import statement above):
// rtlcss()
]
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Adobe Illustrator 24.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="246px" height="246px" viewBox="0 0 246 246" style="enable-background:new 0 0 246 246;" xml:space="preserve">
<style type="text/css">
.st0{fill:#113A68;}
.st1{fill:#9E2D29;}
.st2{fill:#9B1515;}
.st3{fill:#FFFFFF;}
</style>
<g>
<path class="st0" d="M166.6,210.9c-11.6,5-24.4,7.8-37.8,7.8c-52.8,0-95.6-42.8-95.6-95.6c0-2.3,0.1-4.6,0.2-6.8
c2.4-1.8,10.8-5.2,31.7-5.2s46.4,3.4,71.6,9.4c-13,22.2-15.9,44.7-7.8,62.2C133.6,192.6,143.9,205.2,166.6,210.9"/>
<path class="st0" d="M146.9,103.8C102.4,90.7,61.4,88.5,39.7,88.4c0-0.1,0.1-0.2,0.1-0.2c2.5-1.4,8.9-3.3,22.1-3.3
c23.7,0,55.7,5.9,86.9,15.8C148.2,101.7,147.5,102.7,146.9,103.8"/>
<path class="st0" d="M168.4,49.9c0.7,7.9-2.7,18.9-10.6,34.4C117.3,69.2,78.9,63.7,55.4,61.7c17.5-21,43.9-34.3,73.4-34.3
c7.8,0,15.5,0.9,22.8,2.7l3.8,2.5C164.2,38.3,167.8,43.1,168.4,49.9"/>
<path class="st0" d="M209.6,71.8c8.7,13.7,14,29.8,14.7,47c-3.9-2.6-8.2-5.3-13.1-8.3C214,95.5,212.6,82.5,209.6,71.8"/>
<path class="st0" d="M206.8,144.8c4.5,2.1,9,4.5,13.2,6.7c-2.2,6.9-5.1,13.5-8.7,19.6c-0.6-4-2.2-7.4-3.5-10.3
c-2.5-5.6-4.2-9.3-1.2-15.7C206.8,145,206.8,144.9,206.8,144.8"/>
<path class="st0" d="M207.2,125.1c5.4,2.9,11.3,6.2,16.6,9.2c-0.1,1.2-0.3,2.4-0.5,3.7c-5.7-3.5-11.8-7-17.2-9.9
C206.4,127.1,206.8,126.1,207.2,125.1"/>
<path class="st1" d="M185.2,205.6c41.5-36.8,4.9-39.5,15.9-63.1c-14,12.6-44,47.2-18,64.1C164.7,168.9,207.4,157.8,185.2,205.6"/>
<path class="st2" d="M198.1,130.8c9.6,5,24.5,13.5,33.5,20c-9.4-5.4-24.7-13-34.9-17.4c-2,3.9-4.3,7.8-7,11.8
c-10.3,15-36.7,44.6-12.5,61.4c-51.2-7.1-57.1-49.2-33.1-86.7c0.8-1.2,1.5-2.4,2.3-3.6c-62.2-16.2-119.6-14.7-119.8,0.3
c-14.3-6.8-26.2-20.1-2.1-21.8c9.6-0.7,65.2-2.4,125.1,16.2c3-4.9,5.8-9.6,8.2-13.9c-63.3-21.6-124.4-24-125.5-8.6
C18.4,81,7.4,67,31.6,66.7c9.9-0.1,68.5,1.5,129.1,25.3c21.2-39.5,16.4-52.7-2-64.6c21.6,1.5,57.2,37,45.6,86.1
c8.8,5.4,22.8,14.1,31,20.7c-8.4-5.5-22.6-13.3-31.7-17.9C202.2,121.1,200.4,125.9,198.1,130.8"/>
<path class="st2" d="M185.2,205.6c41.5-36.8,4.9-39.5,15.9-63.1c-14,12.6-44,47.2-18,64.1C164.7,168.9,207.4,157.8,185.2,205.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

231
quasar.config.ts Normal file
View File

@@ -0,0 +1,231 @@
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file
import { defineConfig } from '#q-app/wrappers';
export default defineConfig((/* ctx */) => {
return {
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true,
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: ['auth', 'axios', 'lang', 'quasar-global', 'restore-route'],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
css: ['app.scss'],
// https://github.com/quasarframework/quasar/tree/dev/extras
extras: [
// 'ionicons-v4',
// 'mdi-v7',
// 'fontawesome-v6',
// 'eva-icons',
// 'themify',
// 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it
],
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
build: {
target: {
browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
node: 'node20',
publicPath: '/',
},
typescript: {
strict: true,
vueShim: true,
// extendTsConfig (tsConfig) {}
},
vueRouterMode: 'hash', // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// vueOptionsAPI: false,
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
// publicPath: '/',
// analyze: true,
// env: {},
// rawDefine: {}
// ignorePublicFolder: true,
// minify: false,
// polyfillModulePreload: true,
// distDir
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {},
vitePlugins: [
[
'vite-plugin-checker',
{
vueTsc: true,
eslint: {
lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{ts,js,mjs,cjs,vue}"',
useFlatConfig: true,
},
},
{ server: false },
],
],
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
devServer: {
open: true, // opens browser window automatically
https: true, // or true if you want HTTPS
port: 9000, // your custom port
// host: '0.0.0.0', // allows external access (not just localhost)
// allowedHosts: ['members.tecamino.com'],
// headers: {
// 'Access-Control-Allow-Origin': '*',
// },
// proxy: {
// '/v1': {
// target: 'http://0.0.0.0:4040/',
// changeOrigin: true,
// secure: false,
// },
// },
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
framework: {
config: {},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack
// For special cases outside of where the auto-import strategy can have an impact
// (like functional components as one of the examples),
// you can manually specify Quasar components/directives to be available everywhere:
//
// components: [],
//directives: [],
// Quasar plugins
plugins: ['Notify', 'Dialog'],
},
// animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations
animations: [],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#sourcefiles
// sourceFiles: {
// rootComponent: 'src/App.vue',
// router: 'src/router/index',
// store: 'src/store/index',
// pwaRegisterServiceWorker: 'src-pwa/register-service-worker',
// pwaServiceWorker: 'src-pwa/custom-service-worker',
// pwaManifestFile: 'src-pwa/manifest.json',
// electronMain: 'src-electron/electron-main',
// electronPreload: 'src-electron/electron-preload'
// bexManifestFile: 'src-bex/manifest.json
// },
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: {
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
middlewares: [
'render', // keep this as last one
],
// extendPackageJson (json) {},
// extendSSRWebserverConf (esbuildConf) {},
// manualStoreSerialization: true,
// manualStoreSsrContextInjection: true,
// manualStoreHydration: true,
// manualPostHydrationTrigger: true,
pwa: false,
// pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name!
// pwaExtendGenerateSWOptions (cfg) {},
// pwaExtendInjectManifestOptions (cfg) {}
},
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: {
workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
// swFilename: 'sw.js',
// manifestFilename: 'manifest.json',
// extendManifestJson (json) {},
// useCredentialsForManifestTag: true,
// injectPwaMetaTags: false,
// extendPWACustomSWConf (esbuildConf) {},
// extendGenerateSWOptions (cfg) {},
// extendInjectManifestOptions (cfg) {}
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
cordova: {
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
capacitor: {
hideSplashscreen: true,
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (esbuildConf) {},
// extendElectronPreloadConf (esbuildConf) {},
// extendPackageJson (json) {},
// Electron preload scripts (if any) from /src-electron, WITHOUT file extension
preloadScripts: ['electron-preload'],
// specify the debugging port to use for the Electron app when running in development mode
inspectPort: 5858,
bundler: 'packager', // 'packager' or 'builder'
packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store
// appBundleId: '',
// appCategoryType: '',
// osxSign: '',
// protocol: 'myapp://path',
// Windows only
// win32metadata: { ... }
},
builder: {
// https://www.electron.build/configuration/configuration
appId: 'lightcontrol',
},
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: {
// extendBexScriptsConf (esbuildConf) {},
// extendBexManifestJson (json) {},
/**
* The list of extra scripts (js/ts) not in your bex manifest that you want to
* compile and use in your browser extension. Maybe dynamic use them?
*
* Each entry in the list should be a relative filename to /src-bex/
*
* @example [ 'my-script.ts', 'sub-folder/my-other-script.js' ]
*/
extraScripts: [],
},
};
});

7
src/App.vue Normal file
View File

@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup lang="ts">
//
</script>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Adobe Illustrator 24.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="246px" height="246px" viewBox="0 0 246 246" style="enable-background:new 0 0 246 246;" xml:space="preserve">
<style type="text/css">
.st0{fill:#113A68;}
.st1{fill:#9E2D29;}
.st2{fill:#9B1515;}
.st3{fill:#FFFFFF;}
</style>
<g>
<path class="st0" d="M166.6,210.9c-11.6,5-24.4,7.8-37.8,7.8c-52.8,0-95.6-42.8-95.6-95.6c0-2.3,0.1-4.6,0.2-6.8
c2.4-1.8,10.8-5.2,31.7-5.2s46.4,3.4,71.6,9.4c-13,22.2-15.9,44.7-7.8,62.2C133.6,192.6,143.9,205.2,166.6,210.9"/>
<path class="st0" d="M146.9,103.8C102.4,90.7,61.4,88.5,39.7,88.4c0-0.1,0.1-0.2,0.1-0.2c2.5-1.4,8.9-3.3,22.1-3.3
c23.7,0,55.7,5.9,86.9,15.8C148.2,101.7,147.5,102.7,146.9,103.8"/>
<path class="st0" d="M168.4,49.9c0.7,7.9-2.7,18.9-10.6,34.4C117.3,69.2,78.9,63.7,55.4,61.7c17.5-21,43.9-34.3,73.4-34.3
c7.8,0,15.5,0.9,22.8,2.7l3.8,2.5C164.2,38.3,167.8,43.1,168.4,49.9"/>
<path class="st0" d="M209.6,71.8c8.7,13.7,14,29.8,14.7,47c-3.9-2.6-8.2-5.3-13.1-8.3C214,95.5,212.6,82.5,209.6,71.8"/>
<path class="st0" d="M206.8,144.8c4.5,2.1,9,4.5,13.2,6.7c-2.2,6.9-5.1,13.5-8.7,19.6c-0.6-4-2.2-7.4-3.5-10.3
c-2.5-5.6-4.2-9.3-1.2-15.7C206.8,145,206.8,144.9,206.8,144.8"/>
<path class="st0" d="M207.2,125.1c5.4,2.9,11.3,6.2,16.6,9.2c-0.1,1.2-0.3,2.4-0.5,3.7c-5.7-3.5-11.8-7-17.2-9.9
C206.4,127.1,206.8,126.1,207.2,125.1"/>
<path class="st1" d="M185.2,205.6c41.5-36.8,4.9-39.5,15.9-63.1c-14,12.6-44,47.2-18,64.1C164.7,168.9,207.4,157.8,185.2,205.6"/>
<path class="st2" d="M198.1,130.8c9.6,5,24.5,13.5,33.5,20c-9.4-5.4-24.7-13-34.9-17.4c-2,3.9-4.3,7.8-7,11.8
c-10.3,15-36.7,44.6-12.5,61.4c-51.2-7.1-57.1-49.2-33.1-86.7c0.8-1.2,1.5-2.4,2.3-3.6c-62.2-16.2-119.6-14.7-119.8,0.3
c-14.3-6.8-26.2-20.1-2.1-21.8c9.6-0.7,65.2-2.4,125.1,16.2c3-4.9,5.8-9.6,8.2-13.9c-63.3-21.6-124.4-24-125.5-8.6
C18.4,81,7.4,67,31.6,66.7c9.9-0.1,68.5,1.5,129.1,25.3c21.2-39.5,16.4-52.7-2-64.6c21.6,1.5,57.2,37,45.6,86.1
c8.8,5.4,22.8,14.1,31,20.7c-8.4-5.5-22.6-13.3-31.7-17.9C202.2,121.1,200.4,125.9,198.1,130.8"/>
<path class="st2" d="M185.2,205.6c41.5-36.8,4.9-39.5,15.9-63.1c-14,12.6-44,47.2-18,64.1C164.7,168.9,207.4,157.8,185.2,205.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

133
src/assets/lang/de-CH.yaml Normal file
View File

@@ -0,0 +1,133 @@
language: Sprach
prename: Vorname
lastName: Nachname
birthday: Geburtstag
email: Email
group: Gruppe
groups: Gruppen
age: Auter
address: Adresse
town: Ort
zipCode: Postleitzau
phone: Telefon
responsible: Verantwortlech
firstVisit: Erste Bsuech
lastVisit: Letscht Bsuech
search: Suechi
noDataAvailable: Keni Date
importCSV: importier CSV
exportCSV: exportier CSV
selectMemberOptions: Wähle Mitglieder Optione
addNewMember: Neues Mitglied
csvOptions: CSV Optionen
delete: Lösche
edit: Editiere
confirm: Bestätige
cancel: Abbreche
doYouWantToDelete: Wosch dä Iitrag lösche
close: Zu tue
loading: lade
recordsPerPage: 'Iiträg pro Site:'
recordSelected: Iiträg usgwäut
selected: Usgwäut
settings: Iistellige
databaseName: Datebank Name
token: Schlüssu
login: Amäude
logout: Abmäude
user: Benutzer
password: Passwort
isRequired: isch erforderlich
colors: Farbe
primaryColor: Primär Farb
primaryColorText: Primär Text Farb
secondaryColor: Sekondär Farb
secondaryColorText: Sekondär Text Farb
database: Datebank
general: Augemein
setColors: Setz Farbe
icon: Ikon
resetColors: Farbe zrügsetze
save: Spichere
users: Benutzer
roles: Rollen
name: Name
role: Rolle
addNewUser: Füeg neue Benutzer hinzue
expires: Ablouf
selectUserOptions: Wähle Benutzer Optione
prenameIsRequired: Vorname isch erforderlich
lastNameIsRequired: Nachname isch erforderlich
birthdayIsRequired: Geburtstag isch erforderlich
userIsRequired: Benutzer isch erforderlich
emailIsRequired: Email isch erforderlich
roleIsRequired: Rolle isch erforderlich
permissions: Recht
selectRoleOptions: Wähle Roue Optione
selectEventOptions: Wähle Verastautigs Optione
addNewRole: Füeg neui Roue hinzue
addNewEvent: Füeg neui Verastautig hinzue
veryWeak: sehr Schwach
weak: Schwach
fair: So so
good: Guet
strong: Stark
passwordIsRequired: Password isch erforderlich
passwordTooShort: Ds Passwort mues mindestens 8 Zeiche läng si
passwordNeedsUppercase: Ds Passwort mues mindestens ei Grossbuechstabe enthaute
passwordNeedsLowercase: Ds Passwort mues mindestens ei Chlibuechstabe enthaute
passwordNeedsNumber: Ds Passwort mues mindestens ei Zau enthaute
passwordNeedsSpecial: Ds Passwort mues mindestens eis Sonderzeiche enthaute
passwordDoNotMatch: Passwörter stimme nid überei
read: Lese
write: Schribe
userSettings: Benutzer Istellige
members: Mitglider
attendanceTable: Anweseheits Tabelle
excursionTable: Usflugs Tabelle
updated: aktualisiert
events: Veranstalige
eventNameIsRequired: Verastatigsname isch erforderlich
eventName: Verastatigsname
attendees: Teilnähmer
now: Jetzt
addToEvent: Füge zu Veranstautig
add: Hinzuefüege
event: Verastautig
dateAndTime: Datum und Zyt
count: Anzau
selectAttendeesOptions: Wähle Teilnehmer Optionen
addNewAttendees: Füeg neue Teilnehmer hinzue
notAllRequiredFieldsFilled: Nid aui erforderliche Felder usgfüet
memberUpdated: Mitglied aktualisiert
membersUpdated: Mitglieder aktualisiert
deleteAttendee: Teilnehmer entfernt
deleteAttendees: Teilnehmer entfernt
deleteRoles: Rolen entfernt
attendeeAdded: Teilnämer hinzuegfüegt
attendeesAdded: Teilnämer hinzuegfüegt
eventAdded: Verastautig hinzuegfüegt
userUpdated: Benutzer aktualisiert
selectResponsibleOptions: Wähle Verantwortliche Optionen
addNewResponsible: Füeg neue Verantwortliche hinzue
responsibleAdded: Veratwortläche hinzuegfüegt
responsiblesAdded: Veratwortläche hinzuegfüegt
deleteResponsible: Veratwortläche entfernt
deleteResponsibles: Veratwortläche entfernt
expiration: Ablauf
never: Nie
responsibles: Verantwortliche
comment: Bemerkung
dark_mode: Dunkel-Modus
light_mode: Hell-Modus
import: Import
export: Export
changePassword: Passwort ändere
noneAttendees: Fählendi Telnähmer
addNewgroup: Neui Gruppe
selectgroupOptions: Wähle Gruppe Optionen
groupNameIsRequired: Gruppename isch erforderlich
groupName: Gruppename
filterByColumn: Spaltenfilter
filterByColumnValue: Spaltenwerte
saveAsDefault: Aus Standard spichere

133
src/assets/lang/de-DE.yaml Normal file
View File

@@ -0,0 +1,133 @@
language: Sprache
prename: Vorname
lastName: Nachname
birthday: Geburtstag
email: Email
group: Gruppe
groups: Gruppen
age: Alter
address: Adresse
town: Ort
zipCode: Postleitzahl
phone: Telefon
responsible: Verantwortlich
firstVisit: Erster Besuch
lastVisit: Letzter Besuch
search: Suche
noDataAvailable: Keine Daten
importCSV: importiere CSV
exportCSV: exportiere CSV
selectMemberOptions: Wähle Mitglieder Optionen
addNewMember: Neues Mitglied
csvOptions: CSV Optionen
delete: Löschen
edit: Editieren
confirm: Bestätigen
cancel: Abbrechen
doYouWantToDelete: Willst du folgenden Eintrag löschen
close: Schliessen
loading: laden
recordsPerPage: 'Einträge pro Seite:'
recordSelected: Eintrag ausgewählt
selected: Ausgewählt
settings: Einstellungen
databaseName: Datenbank Name
token: Schlüssel
login: Anmelden
logout: Abmelden
user: Benutzer
password: Passwort
isRequired: ist erforderlich
colors: Farben
primaryColor: Primär Farbe
primaryColorText: Primär Text Farbe
secondaryColor: Sekondär Farbe
secondaryColorText: Sekondär Text Farbe
database: Datenbank
general: Allgemein
setColors: Setze Farben
icon: Ikon
resetColors: Farben zurücksetzen
save: Speichern
users: Benutzer
roles: Rollen
name: Name
role: Rolle
addNewUser: Füge neuen Benutzer hinzu
expires: Ablauf
selectUserOptions: Wähle Benutzer Optionen
prenameIsRequired: Vorname ist erforderlich
lastNameIsRequired: Nachname ist erforderlich
birthdayIsRequired: Geburtstag ist erforderlich
userIsRequired: Benutzer ist erforderlich
emailIsRequired: Email ist erforderlich
roleIsRequired: Rolle ist erforderlich
permissions: Rechte
selectRoleOptions: Wähle Rollen Optionen
selectEventOptions: Wähle Veranstaltungs Optionen
addNewRole: Füge neue Rolle hinzu
addNewEvent: Füeg neue Veranstaltung hinzu
veryWeak: sehr Schwach
weak: Schwach
fair: Ausreichend
good: Gut
strong:
passwordIsRequired: Password ist erforderlich
passwordTooShort: Das Passwort muss mindestens 8 Zeichen lang sein
passwordNeedsUppercase: Das Passwort muss mindestens einen Großbuchstaben enthalten
passwordNeedsLowercase: Das Passwort muss mindestens einen Kleinbuchstaben enthalten
passwordNeedsNumber: Das Passwort muss mindestens eine Zahl enthalten
passwordNeedsSpecial: Das Passwort muss mindestens ein Sonderzeichen enthalten
passwordDoNotMatch: Passwörter stimmen nicht überein
read: Lesen
write: Schreiben
userSettings: Benutzer Einstellungen
members: Mitglieder
attendanceTable: Anwesenheits Tabelle
excursionTable: Ausflugs Tabelle
updated: aktualisiert
events: Veranstaltungen
eventNameIsRequired: Veranstaltungsname ist erforderlich
eventName: Veranstaltungsname
attendees: Teilnehmer
now: Jetzt
addToEvent: Füge zu Veranstaltung
add: Hinzufügen
event: Veranstaltung
dateAndTime: Datum und Zeit
count: Anzahl
selectAttendeesOptions: Wähle Teilnehmer Optionen
addNewAttendees: Füge neuen Teilnehmer hinzu
notAllRequiredFieldsFilled: Nicht alle erforderlichen Felder ausgefüllt
memberUpdated: Mitglied aktualisiert
membersUpdated: Mitglieder aktualisiert
deleteAttendee: Teilnehmer entfernt
deleteAttendees: Teilnehmer entfernt
deleteRoles: Rolen entfernt
attendeeAdded: Teilnehmer hinzugefügt
attendeesAdded: Teilnehmer hinzugefügt
eventAdded: Veranstaltung hinzugefügt
userUpdated: Benutzer aktualisiert
selectResponsibleOptions: Wähle Verantwortliche Optionen
addNewResponsible: Füge neuen Verantwortlichen hinzu
responsibleAdded: Veratwortläche hinzuegfüegt
responsiblesAdded: Verantwortliche hinzuegfüegt
deleteResponsible: Verantwortliche entfernt
deleteResponsibles: Verantwortliche entfernt
expiration: Ablauf
never: Nie
responsibles: Verantwortliche
comment: Bemerkung
dark_mode: Dunkel-Modus
light_mode: Hell-Modus
import: Import
export: Export
changePassword: Passwort ändern
noneAttendees: Fehlende Teilnehmer
addNewgroup: Neue Gruppe
selectgroupOptions: Wähle Gruppen Optionen
groupNameIsRequired: Gruppenname ist erforderlich
groupName: Gruppenname
filterByColumn: Spaltenfilter
filterByColumnValue: Spaltenwerte
saveAsDefault: Als Standard speichern

133
src/assets/lang/en-US.yaml Normal file
View File

@@ -0,0 +1,133 @@
language: Language
prename: Prename
lastName: Name
birthday: Birthday
email: Email
group: Group
groups: Groups
age: Age
address: Address
town: Town
zipCode: Zip Code
phone: Phone
responsible: Responsible
firstVisit: First Visit
lastVisit: Last Visit
search: Search
noDataAvailable: No data available
importCSV: Import CSV
exportCSV: Export CSV
selectMemberOptions: Select Member Options
addNewMember: Add new Member
csvOptions: CSV Options
delete: Delete
edit: Edit
confirm: Confirm
cancel: Cancel
doYouWantToDelete: Do you want to delete
close: Close
loading: loading
recordsPerPage: 'Records per page:'
recordSelected: record selected
selected: Selected
settings: Settings
databaseName: Database Name
token: Token
login: Login
logout: Logout
user: User
password: Password
isRequired: is required
colors: Colors
primaryColor: Primary Color
primaryColorText: Primary Text Color
secondaryColor: Secondary Color
secondaryColorText: Secondary Text Color
database: Database
general: General
setColors: Set Colors
icon: Icon
resetColors: Reset Colors
save: Save
users: Users
roles: Roles
name: Name
role: Role
addNewUser: Add new User
expires: Expires
selectUserOptions: Select User Options
prenameIsRequired: Fist Name is required
lastNameIsRequired: Last Name is required
birthdayIsRequired: Birthday is required
userIsRequired: User is required
emailIsRequired: Email is required
roleIsRequired: Role is required
permissions: Permissions
selectRoleOptions: Select Role Options
selectEventOptions: Select Event Options
addNewRole: Add new Role
addNewEvent: Add new Event
veryWeak: very Weak
weak: Weak
fair: Fair
good: Good
strong: Strong
passwordIsRequired: Password is required
passwordTooShort: Password must be at least 8 characters long
passwordNeedsUppercase: Password must contain at least one uppercase letter
passwordNeedsLowercase: Password must contain at least one lowercase letter
passwordNeedsNumber: Password must contain at least one number
passwordNeedsSpecial: Password must contain at least one special character
passwordDoNotMatch: Password do not match
read: Read
write: Write
userSettings: User Settings
members: Members
attendanceTable: Attendance Table
excursionTable: Excursion Table
updated: updated
events: Events
eventNameIsRequired: Eventname is required
eventName: Eventname
attendees: Attendees
now: Now
addToEvent: Add to event
add: Add
event: Event
dateAndTime: Date and Time
count: Count
selectAttendeesOptions: Select Attendees Options
addNewAttendees: Add new Attendee
notAllRequiredFieldsFilled: Not all required fields are filled in
memberUpdated: Member updated
membersUpdated: Members updated
deleteAttendee: Attendee deleted
deleteAttendees: Attendees deleted
deleteRoles: Roles deleted
attendeeAdded: Attendee added
attendeesAdded: Attendees added
eventAdded: Event added
userUpdated: User updated
selectResponsibleOptions: Select Responsible Options
addNewResponsible: Add Responsible
responsibleAdded: Responsible hinzuegfüegt
responsiblesAdded: Responsibles hinzuegfüegt
deleteResponsible: Responsible deleted
deleteResponsibles: Responsibles deleted
expiration: Expiration
never: Never
responsibles: Responsibles
comment: Comment
dark_mode: Dark-Mode
light_mode: Light-Mode
import: Import
export: Export
changePassword: change Password
noneAttendees: Missing Attendees
addNewgroup: New Group
selectgroupOptions: Select Group Options
groupNameIsRequired: Groupname is required
groupName: Groupname
filterByColumn: Columnfilter
filterByColumnValue: Columnvalues
saveAsDefault: Save a Default

View File

@@ -0,0 +1,3 @@
hello: hola
goodbye: adios
language: Lengua

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 233 KiB

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
<path
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
<path fill="#050A14"
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
<path fill="#00B4FF"
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
<path fill="#00B4FF"
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
<path fill="#050A14"
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
<path fill="#00B4FF"
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

0
src/boot/.gitkeep Normal file
View File

25
src/boot/auth.ts Normal file
View File

@@ -0,0 +1,25 @@
import { boot } from 'quasar/wrappers';
import { appApi } from './axios';
import { createPinia } from 'pinia';
import { useUserStore } from 'src/vueLib/login/userStore';
import { useLogin } from 'src/vueLib/login/useLogin';
const pinia = createPinia();
export default boot(async ({ app }) => {
app.use(pinia);
const useStore = useUserStore();
const login = useLogin();
await appApi
.get('/login/me')
.then((resp) => {
useStore
.setUser({ id: resp.data.id, username: resp.data.username, role: resp.data.role })
.catch((err) => console.error(err));
login.refresh().catch((err) => console.error(err));
})
.catch(() => {
login.logout().catch((err) => console.error(err));
});
});

139
src/boot/axios.ts Normal file
View File

@@ -0,0 +1,139 @@
import { boot } from 'quasar/wrappers';
import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import axios from 'axios';
import { useLogin } from 'src/vueLib/login/useLogin';
const host = import.meta.env.VITE_API_URL;
// Create axios instance
export const appApi: AxiosInstance = axios.create({
baseURL: host,
timeout: 10000,
withCredentials: true,
});
interface RetryRequestConfig extends AxiosRequestConfig {
_retry?: boolean;
}
const noRefreshEndpoints = ['/login', '/login/refresh', '/logout'];
// ========= Refresh Queue Handling ========= //
let isRefreshing = false;
interface FailedRequest {
resolve: (value?: unknown) => void;
reject: (reason?: Error) => void;
}
let failedQueue: FailedRequest[] = [];
const processQueue = (error: Error | null): void => {
failedQueue.forEach((prom) => {
if (error) prom.reject(error);
else prom.resolve();
});
failedQueue = [];
};
// ========================================= //
appApi.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError<unknown, RetryRequestConfig>): Promise<AxiosResponse> => {
const { refresh, logout } = useLogin();
const originalRequest = error.config as RetryRequestConfig | undefined;
// Skip refresh for login/logout endpoints
if (
!originalRequest ||
noRefreshEndpoints.some((url) => originalRequest.url?.includes(url ?? ''))
) {
const message = error instanceof Error ? error.message : JSON.stringify(error);
throw new Error(message);
}
// Handle unauthorized responses
if (error.response?.status === 401 && !originalRequest._retry) {
const data = error.response?.data;
const serverMessage =
typeof data === 'object' && data !== null && 'message' in data
? (data as { message: string }).message
: undefined;
if (['no refresh token', 'is expired'].some((msg) => serverMessage?.includes(msg))) {
console.warn('[Axios] No refresh token — logging out');
try {
await logout();
} catch (logoutErr) {
console.error('[Axios] Logout failed:', logoutErr);
}
throw new Error('Session expired: no refresh token');
}
if (isRefreshing) {
// Wait until refresh completes
return new Promise<AxiosResponse>((resolve, reject) => {
failedQueue.push({
resolve: () => {
void appApi(originalRequest).then(resolve).catch(reject);
},
reject,
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshed = await refresh().catch(() => false);
processQueue(null);
if (refreshed) {
// Token refreshed successfully → retry request
return appApi(originalRequest);
}
// Refresh returned false → logout
console.warn('[Axios] Refresh returned false, logging out');
await logout();
throw new Error('Token refresh failed');
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
console.error('[Axios] Token refresh failed:', e.message);
// Always logout, even if refresh throws
try {
await logout();
} catch (logoutErr) {
console.error('[Axios] Logout failed after token refresh error:', logoutErr);
}
processQueue(e);
throw e;
} finally {
isRefreshing = false;
}
}
// Not a 401 — rethrow as Error
let msg = '';
if (error && (error as AxiosError).isAxiosError) {
const axiosError = error as AxiosError<{ message?: string }>;
msg = axiosError.response?.data?.message ?? axiosError.message;
} else {
msg = error instanceof Error ? error.message : JSON.stringify(error);
}
throw new Error(msg);
},
);
// ======== Boot registration for Quasar ======== //
export default boot(({ app }) => {
app.config.globalProperties.$axios = axios;
app.config.globalProperties.$appApi = appApi;
});
export { axios };

55
src/boot/lang.js Normal file
View File

@@ -0,0 +1,55 @@
import { createI18n } from 'vue-i18n';
import yaml from 'js-yaml';
import { getLocalLanguage } from 'src/localstorage/localStorage';
export const lang = [];
const systemLocale = navigator.language || 'en-US';
const messages = {};
const modules = import.meta.glob('src/assets/lang/*.yaml', {
eager: true,
import: 'default',
query: '?raw',
});
for (const path in modules) {
const raw = modules[path];
const parsed = yaml.load(raw);
// Extract the file name (e.g., "en.yaml" → "en")
const locale = path.split('/').pop().replace('.yaml', '');
lang.push(locale);
messages[locale] = parsed;
}
function resolveLocale(desiredLocale) {
if (messages[desiredLocale]) return desiredLocale;
const baseLang = desiredLocale.split('-')[0];
// exact base match (e.g. en)
if (messages[baseLang]) return baseLang;
// first locale starting with that base (e.g. en-US, en-GB)
const partialMatch = Object.keys(messages).find((l) => l.startsWith(baseLang));
if (partialMatch) return partialMatch;
// fallback to English or the first available
return messages['en'] ? 'en' : Object.keys(messages)[0];
}
const selectedLocale = resolveLocale(getLocalLanguage() || systemLocale);
const i18n = createI18n({
legacy: false, // Composition API mode
locale: selectedLocale,
fallbackLocale: resolveLocale(selectedLocale),
messages,
});
export default ({ app }) => {
app.use(i18n);
};
export { i18n };

31
src/boot/quasar-global.ts Normal file
View File

@@ -0,0 +1,31 @@
import { boot } from 'quasar/wrappers';
import { setQuasarInstance } from 'src/vueLib/utils/globalQ';
import { setRouterInstance } from 'src/vueLib/utils/globalRouter';
import { databaseName, logo, appName } from 'src/vueLib/models/settings';
import { Dark } from 'quasar';
import { getLocalDarkMode, getLocalSettings } from 'src/localstorage/localStorage';
export default boot(({ app, router }) => {
setRouterInstance(router); // store router for global access
const $q = app.config.globalProperties.$q;
setQuasarInstance($q);
Dark.set(getLocalDarkMode());
const settings = getLocalSettings();
logo.value = settings.icon ?? logo.value;
appName.value = settings.appName ?? appName.value;
databaseName.value = settings.databaseName ?? databaseName.value;
document.documentElement.style.setProperty('--q-primary', settings.primaryColor ?? '#1976d2');
document.documentElement.style.setProperty(
'--q-primary-text',
settings.primaryColorText ?? '#ffffff',
);
document.documentElement.style.setProperty('--q-secondary', settings.secondaryColor ?? '#26a69a');
document.documentElement.style.setProperty(
'--q-secondary-text',
settings.secondaryColorText ?? '#ffffff',
);
});

41
src/boot/restore-route.js Normal file
View File

@@ -0,0 +1,41 @@
import { boot } from 'quasar/wrappers';
import { useUserStore } from 'src/vueLib/login/userStore';
import { appApi } from './axios';
import { getLocalLastRoute, setLocalLastRoute } from 'src/localstorage/localStorage';
export default boot(async ({ router }) => {
const userStore = useUserStore();
// load user
try {
const { data } = await appApi.get('/login/me');
await userStore.setUser(data);
} catch {
/* ignore error */
}
// Restore logic after router is ready but before navigation
router.isReady().then(() => {
const lastRoute = getLocalLastRoute();
const currentPath = router.currentRoute.value.fullPath;
// Restore only if:
// - were on root ("/" or "/#/"), and
// - a last route exists, and
// - the user is authenticated
if (
lastRoute &&
['/', '/#/', '/#/index.html'].includes(currentPath) &&
userStore.isAuthenticated
) {
router.replace(lastRoute).catch(() => {});
}
});
// Save the route after every successful navigation
router.afterEach((to) => {
// Don't save login page as "last route"
if (to.path !== '/login' && to.path !== '/') {
setLocalLastRoute(to.fullPath);
}
});
});

View File

@@ -0,0 +1,121 @@
<template>
<DialogFrame ref="dialog" :header-title="localTitle">
<div class="row justify-center">
<q-select
autofocus
:label="$t('event')"
filled
:options="events"
option-label="name"
option-value="id"
v-model="selected"
@keyup.enter="addAttendees"
map-options
emit-value
></q-select>
</div>
<div class="row justify-center">
<q-btn class="q-ma-md" color="primary" no-caps @click="addAttendees">{{ localTitle }}</q-btn>
</div>
</DialogFrame>
<EditAllDialog
ref="newEventRef"
v-on:update="
(val) => {
resolveNewEvent(val);
NotifyResponse($t('memberUpdated'));
}
"
></EditAllDialog>
</template>
<script setup lang="ts">
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { ref } from 'vue';
import { appApi } from 'src/boot/axios';
import { useNotify } from 'src/vueLib/general/useNotify';
import { i18n } from 'src/boot/lang';
import type { Event, Events } from 'src/vueLib/models/event';
import type { Members } from 'src/vueLib/models/member';
import EditAllDialog from 'src/components/EventEditAllDialog.vue';
import { useAttendeesTable } from 'src/vueLib/tables/attendees/AttendeesTable';
import { useEventTable } from 'src/vueLib/tables/events/EventsTable';
const dialog = ref();
const newEventRef = ref();
const localTitle = ref('');
const events = ref<Events>([{ id: -1, name: i18n.global.t('addNewEvent'), attendees: [] }]);
const selected = ref<Event>({ id: -1, name: i18n.global.t('addNewEvent'), attendees: [] });
const localMembers = ref<Members>([]);
const { updateAttendees } = useAttendeesTable();
const { updateEvents } = useEventTable();
const props = defineProps({
endpoint: {
type: String,
required: true,
},
queryId: {
type: Boolean,
},
});
const emit = defineEmits(['update-event']);
const { NotifyResponse } = useNotify();
function open(title: string, members: Members) {
localTitle.value = title;
localMembers.value = members;
events.value = [{ id: -1, name: i18n.global.t('addNewEvent'), attendees: [] }];
appApi
.get('events')
.then((resp) => {
events.value.push(...resp.data);
})
.catch((err) => {
NotifyResponse(err, 'error');
});
dialog.value?.open();
}
async function addAttendees() {
const payload = {
id: Number(selected.value),
attendees: localMembers.value,
};
if (selected.value.id == -1) {
const event = await addNewEvent();
NotifyResponse(i18n.global.t('eventAdded') + ': ' + event.name);
payload.id = event.id;
}
await appApi
.post(props.endpoint, payload)
.then(() => {
emit('update-event', localMembers.value);
if (localMembers.value.length > 1) {
NotifyResponse(i18n.global.t('attendeesAdded'));
} else {
NotifyResponse(i18n.global.t('attendeeAdded'));
}
dialog.value.close();
})
.catch((err) => {
NotifyResponse(err, 'error');
});
await updateAttendees();
updateEvents();
}
let resolveNewEvent!: (value: Event) => void;
function addNewEvent(): Promise<Event> {
newEventRef.value?.open(null);
return new Promise((resolve) => {
resolveNewEvent = resolve;
});
}
defineExpose({ open });
</script>

View File

@@ -0,0 +1,135 @@
<template>
<DialogFrame ref="dialog" :header-title="'Edit ' + localTitle">
<div class="row justify-center">
<q-input autofocus :label="localTitle" filled v-model="value" @keyup.enter="save">
<template
v-if="['firstVisit', 'lastVisit', 'date', 'expiration'].includes(localField)"
v-slot:prepend
>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date v-model="value" mask="YYYY-MM-DD HH:mm:ss">
<div class="row items-center justify-end">
<q-btn :label="$t('now')" color="primary" no-caps flat @click="setTimeNow" />
<q-btn
v-if="localField"
:label="$t('never')"
color="primary"
no-caps
flat
@click="value = 'never'"
/>
<q-btn no-caps v-close-popup :label="$t('close')" color="primary" flat />
</div>
</q-date>
</q-popup-proxy>
</q-icon>
</template>
<template
v-if="['firstVisit', 'lastVisit', 'date', 'expiration'].includes(localField)"
v-slot:append
>
<q-icon name="access_time" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-time with-seconds v-model="value" mask="YYYY-MM-DD HH:mm:ss" format24h>
<div class="row items-center justify-end">
<q-btn :label="$t('now')" color="primary" no-caps flat @click="setTimeNow" />
<q-btn
v-if="localField"
:label="$t('never')"
color="primary"
no-caps
flat
@click="value = 'never'"
/>
<q-btn no-caps v-close-popup :label="$t('close')" color="primary" flat />
</div>
</q-time>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
</div>
<div class="row justify-center">
<q-btn class="q-ma-md" color="primary" no-caps @click="save">{{ $t('save') }}</q-btn>
</div>
</DialogFrame>
</template>
<script setup lang="ts">
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { ref } from 'vue';
import { appApi } from 'src/boot/axios';
import type { Member } from 'src/vueLib/models/member';
import { useNotify } from 'src/vueLib/general/useNotify';
const dialog = ref();
const localMember = ref();
const localTitle = ref('');
const localField = ref('');
const value = ref('');
const props = defineProps({
endpoint: {
type: String,
},
});
const emit = defineEmits(['update']);
const { NotifyResponse } = useNotify();
function open(label: string, field: string, member: Member) {
localTitle.value = label;
localField.value = field;
localMember.value = member;
value.value = localMember.value[field];
dialog.value?.open();
}
function save() {
let payload = {};
if (value.value === localMember.value[localField.value]) {
dialog.value.close();
return;
}
if (!props.endpoint) {
localMember.value[localField.value] = value.value;
emit('update', localMember.value);
return;
}
payload = [
{
id: localMember.value.id,
[localField.value]: value.value,
},
];
appApi
.post(props.endpoint, payload)
.then(() => {
emit('update');
dialog.value.close();
})
.catch((err) => {
NotifyResponse(err, 'error');
});
}
function setTimeNow() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0'); // Months are 0-based
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
value.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
defineExpose({ open });
</script>

View File

@@ -0,0 +1,95 @@
<template>
<DialogFrame
ref="dialog"
:header-title="newEvent ? $t('addNewEvent') : 'Edit ' + localEvent.name"
:height="250"
:width="500"
>
<q-form ref="form">
<div class="row justify-center q-gutter-md">
<q-input
class="q-ml-md col-5 required"
:label="$t('eventName')"
filled
:rules="[(val) => !!val || $t('eventNameIsRequired')]"
v-model="localEvent.name"
autofocus
@keyup.enter="save"
></q-input>
</div>
</q-form>
<div class="row justify-center">
<q-btn class="q-ma-md" color="primary" no-caps @click="save">{{ $t('save') }}</q-btn>
</div>
</DialogFrame>
</template>
<script setup lang="ts">
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { ref } from 'vue';
import { appApi } from 'src/boot/axios';
import type { Event } from 'src/vueLib/models/event';
import { useNotify } from 'src/vueLib/general/useNotify';
const { NotifyResponse } = useNotify();
const dialog = ref();
const form = ref();
const newEvent = ref(false);
const localEvent = ref<Event>({
id: 0,
name: '',
attendees: [],
});
const emit = defineEmits(['update']);
function open(Event: Event | null) {
if (Event === undefined) {
return;
}
if (Event !== null) {
localEvent.value = { ...Event };
newEvent.value = Event.id === 0;
} else {
localEvent.value = {
id: 0,
name: '',
attendees: [],
};
newEvent.value = true;
}
dialog.value?.open();
}
async function save() {
const valid = await form.value.validate();
if (!valid) return;
let query = 'events/edit';
let payload = JSON.stringify([localEvent.value]);
if (newEvent.value) {
query = 'events/add?name=' + localEvent.value.name;
payload = JSON.stringify(localEvent.value);
}
appApi
.post(query, payload)
.then((resp) => {
emit('update', resp.data.data);
dialog.value.close();
})
.catch((err) => NotifyResponse(err, 'error'));
}
defineExpose({ open });
</script>
<style>
.required .q-field__label::after {
content: ' *';
color: red;
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<DialogFrame
ref="dialog"
:header-title="
newMember ? $t('addNewMember') : 'Edit ' + localMember.firstName + ' ' + localMember.lastName
"
:height="600"
:width="500"
>
<q-form ref="form">
<div class="row justify-center q-gutter-md">
<q-input
class="q-ml-md col-5 required"
:label="$t('prename')"
filled
:rules="[(val) => !!val || $t('prenameIsRequired')]"
v-model="localMember.firstName"
autofocus
></q-input>
<q-input
class="q-ml-md col-5 required"
:label="$t('lastName')"
filled
:rules="[(val) => !!val || $t('lastNameIsRequired')]"
v-model="localMember.lastName"
></q-input>
<q-input
class="q-ml-md col-5"
:label="$t('birthday')"
filled
v-model="localMember.birthday"
></q-input>
<q-input
class="q-ml-md col-5"
:label="$t('address')"
filled
v-model="localMember.address"
></q-input>
<q-input
class="q-ml-md col-5"
:label="$t('town')"
filled
v-model="localMember.town"
></q-input>
<q-input
class="q-ml-md col-5"
:label="$t('zipCode')"
filled
v-model="localMember.zip"
></q-input>
<q-input
class="q-ml-md col-5"
:label="$t('phone')"
filled
v-model="localMember.phone"
></q-input>
<q-input
class="q-ml-md col-5"
:label="$t('email')"
filled
v-model="localMember.email"
></q-input>
<q-input
class="q-ml-md col-5"
:label="$t('group')"
filled
v-model="localMember.group"
></q-input>
<q-select
class="q-ml-md col-5"
:label="$t('responsible')"
filled
:options="props.responsibles"
:option-label="(opt) => opt.firstName + ' ' + opt.lastName"
v-model="localMember.responsiblePerson"
></q-select>
<q-input
v-if="!newMember"
class="q-ml-md col-11"
:label="$t('firstVisit')"
filled
v-model="localMember.firstVisit"
></q-input>
<q-input
v-if="!newMember"
class="q-ml-md col-11"
:label="$t('lastVisit')"
filled
v-model="localMember.lastVisit"
></q-input>
</div>
</q-form>
<div class="row justify-center">
<q-btn class="q-ma-md" color="primary" no-caps @click="save">{{ $t('save') }}</q-btn>
</div>
</DialogFrame>
</template>
<script setup lang="ts">
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { type PropType, ref } from 'vue';
import { appApi } from 'src/boot/axios';
import type { Member, Members } from 'src/vueLib/models/member';
import { useNotify } from 'src/vueLib/general/useNotify';
import { i18n } from 'src/boot/lang';
const { NotifyResponse } = useNotify();
const dialog = ref();
const form = ref();
const newMember = ref(false);
const localMember = ref<Member>({
id: 0,
firstName: '',
lastName: '',
});
const props = defineProps({
responsibles: {
type: Object as PropType<Members>,
},
});
const emit = defineEmits(['update']);
function open(member: Member | null) {
if (member === undefined) {
return;
}
if (member !== null) {
localMember.value = { ...member };
newMember.value = false;
} else {
localMember.value = {
id: 0,
firstName: '',
lastName: '',
};
newMember.value = true;
}
dialog.value?.open();
}
async function save() {
const valid = await form.value.validate();
if (!valid) {
NotifyResponse(i18n.global.t('notAllRequiredFieldsFilled'), 'error');
return;
}
let query = 'members/edit';
let payload = JSON.stringify([localMember.value]);
if (newMember.value) {
query = 'members/add';
payload = JSON.stringify(localMember.value);
}
await appApi
.post(query, payload)
.then(() => {
emit('update');
NotifyResponse(i18n.global.t('memberUpdated'));
dialog.value.close();
})
.catch((err) => NotifyResponse(err, 'error'));
}
defineExpose({ open });
</script>
<style>
.required .q-field__label::after {
content: ' *';
color: red;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<DialogFrame
ref="dialog"
:header-title="newRole ? $t('addNewRole') : 'Edit ' + localRole.role"
:height="700"
:width="700"
>
<div class="row justify-center">
<q-input
v-if="showRoleField"
class="q-my-lg col-5 required"
:label="$t('role')"
filled
:rules="[(val) => !!val || $t('roleIsRequired')]"
v-model="localRole.role"
autofocus
></q-input>
<q-card>
<q-card-section class="text-h5 text-bold text-primary flex justify-center">{{
$t('permissions')
}}</q-card-section>
<q-separator color="black" />
<PermissionsCheckBoxGroup
:permissions="localRole.permissions || []"
v-on:update="(val) => (localRole.permissions = val)"
/>
</q-card>
</div>
<div class="row justify-center">
<q-btn class="q-ma-md" color="primary" no-caps @click="save">{{ $t('save') }}</q-btn>
</div>
</DialogFrame>
</template>
<script setup lang="ts">
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { ref } from 'vue';
import { appApi } from 'src/boot/axios';
import type { Role } from 'src/vueLib/models/roles';
import { useNotify } from 'src/vueLib/general/useNotify';
import PermissionsCheckBoxGroup from 'src/vueLib/checkboxes/CheckBoxGroupPermissions.vue';
import { defaultPermissions } from 'src/vueLib/checkboxes/permissions';
import { i18n } from 'src/boot/lang';
const { NotifyResponse } = useNotify();
const dialog = ref();
const newRole = ref(false);
const showRoleField = ref(true);
const localRole = ref<Role>({
role: '',
permissions: [],
});
const emit = defineEmits(['update']);
function open(role: Role | null, typ?: 'permissions') {
if (role === undefined) {
return;
}
showRoleField.value = typ !== 'permissions';
if (role !== null) {
localRole.value = { ...role };
localRole.value.permissions = role.permissions || defaultPermissions;
newRole.value = false;
} else {
localRole.value = {
role: '',
permissions: defaultPermissions,
};
newRole.value = true;
}
dialog.value?.open();
}
function save() {
let query = 'roles/update?id=' + localRole.value.id;
let update = true;
if (newRole.value) {
query = 'roles/add';
update = false;
localRole.value.permissions = localRole.value.permissions ?? defaultPermissions;
}
appApi
.post(query, JSON.stringify(localRole.value))
.then(() => {
if (update) {
NotifyResponse(
i18n.global.t('role') + " '" + localRole.value.role + "' " + i18n.global.t('updated'),
);
}
emit('update');
dialog.value.close();
})
.catch((err) => NotifyResponse(err, 'error'));
}
defineExpose({ open });
</script>
<style>
.required .q-field__label::after {
content: ' *';
color: red;
}
</style>

View File

@@ -0,0 +1,218 @@
<template>
<DialogFrame ref="dialog" header-title="Import CSV" :height="dialogHeight" :width="dialogWidth">
<div class="column q-gutter-xs">
<div class="row">
<q-uploader
style="max-width: 300px"
url="/api/members/import/csv"
label="Import CSV"
multiple
:with-credentials="true"
accept=".csv"
field-name="file"
method="POST"
:form-fields="metaData"
v-on:failed="onImportFail"
:headers="[{ name: 'Accept', value: 'application/json' }]"
@uploaded="onUploaded"
/>
</div>
<q-card :flat="!showOptions">
<div class="row q-ma-sm">
<div class="text-bold q-ma-sm">
<q-btn
dense
flat
size="sm"
:icon="showOptions ? 'remove' : 'add'"
@click="showOptions = !showOptions"
></q-btn>
CSV options
</div>
<div class="row justify-center q-gutter-md">
<q-input
v-if="showOptions"
class="col-4"
label="Row Index of Column titles"
dense
borderless
type="number"
v-model.number="data.rowIndex"
></q-input>
<q-input
v-if="showOptions"
class="col-4"
label="Seperator"
dense
borderless
v-model="data.seperator"
></q-input>
<q-space class="col-1"></q-space>
<q-input
v-if="showOptions"
class="col-2"
label="First Name"
dense
borderless
v-model="data.firstName"
autofocus
></q-input>
<q-input
v-if="showOptions"
class="col-2"
label="Last Name"
dense
borderless
v-model="data.lastName"
></q-input>
<q-input
v-if="showOptions"
class="col-2"
label="Birthday"
dense
borderless
v-model="data.birthday"
></q-input>
<q-input
v-if="showOptions"
class="col-2"
label="Address"
dense
borderless
v-model="data.address"
></q-input>
<q-input
v-if="showOptions"
class="col-2"
label="Town"
dense
borderless
v-model="data.town"
></q-input>
<q-input
v-if="showOptions"
class="col-2"
label="Zip Code"
dense
borderless
v-model="data.zip"
></q-input>
<q-input
v-if="showOptions"
class="col-2"
label="Phone"
dense
borderless
v-model="data.phone"
></q-input>
<q-input
v-if="showOptions"
class="col-2"
label="E-Mail"
dense
borderless
v-model="data.email"
></q-input>
</div>
</div>
</q-card>
<div class="row justify-end q-ma-sm">
<q-btn no-caps color="primary" class="col-3" v-close-popup>{{ $t('close') }}</q-btn>
</div>
</div>
</DialogFrame>
</template>
<script setup lang="ts">
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { ref, watch } from 'vue';
import type { MetaData } from 'src/vueLib/models/metaData';
import { useNotify } from 'src/vueLib/general/useNotify';
const dialogHeight = ref(300);
const dialogWidth = ref(500);
const { NotifyResponse } = useNotify();
const emit = defineEmits(['update-upload']);
const dialog = ref();
const showOptions = ref(true);
const data = ref<MetaData>({
rowIndex: 0,
seperator: ';',
firstName: 'firstName',
lastName: 'lastName',
birthday: 'birthday',
address: 'address',
town: 'town',
zip: 'zip',
phone: 'phone',
email: 'email',
});
const metaData = ref([
{ name: 'rowIndex', value: String(data.value.rowIndex) },
{ name: 'seperator', value: data.value.seperator },
{ name: 'firstName', value: data.value.firstName },
{ name: 'lastName', value: data.value.lastName },
{ name: 'birthday', value: data.value.birthday },
{ name: 'address', value: data.value.address },
{ name: 'town', value: data.value.town },
{ name: 'zip', value: data.value.zip },
{ name: 'phone', value: data.value.phone },
{ name: 'email', value: data.value.email },
]);
function open() {
showOptions.value = false;
dialog.value?.open();
}
watch(showOptions, () => {
if (dialogWidth.value === 500) {
dialogWidth.value = 325;
dialogHeight.value = 325;
return;
}
dialogHeight.value = 500;
dialogWidth.value = 500;
});
watch(
data,
() => {
metaData.value = [
{ name: 'rowIndex', value: String(data.value.rowIndex) },
{ name: 'seperator', value: data.value.seperator },
{ name: 'firstName', value: data.value.firstName },
{ name: 'lastName', value: data.value.lastName },
{ name: 'birthday', value: data.value.birthday },
{ name: 'address', value: data.value.address },
{ name: 'town', value: data.value.town },
{ name: 'zip', value: data.value.zip },
{ name: 'phone', value: data.value.phone },
{ name: 'email', value: data.value.email },
];
},
{ deep: true },
);
function onImportFail(info: { files: readonly File[]; xhr: XMLHttpRequest }) {
const response = JSON.parse(info.xhr.responseText);
if (response.message) {
NotifyResponse(response.message, 'error', 15000);
}
emit('update-upload');
}
function onUploaded(info: { files: readonly File[]; xhr: XMLHttpRequest }) {
const response = JSON.parse(info.xhr.responseText);
if (response.message) {
NotifyResponse(response.message);
}
emit('update-upload');
}
defineExpose({ open });
</script>

View File

@@ -0,0 +1,236 @@
<template>
<DialogFrame
ref="dialog"
:header-title="newUser ? $t('addNewUser') : 'Edit ' + localUser.user"
:height="600"
:width="500"
>
<q-form ref="form">
<div class="row justify-center q-gutter-md">
<q-input
class="col-5 required"
:label="$t('user')"
filled
:lazy-rules="false"
:rules="[(val) => !!val || $t('userIsRequired')]"
v-model="localUser.user"
autofocus
></q-input>
<q-input
class="col-5 required"
:label="$t('email')"
filled
:lazy-rules="false"
:rules="[(val) => !!val || $t('emailIsRequired')]"
v-model="localUser.email"
></q-input>
<div class="col-5">
<q-input
class="col-5 required"
:label="$t('password')"
filled
:lazy-rules="false"
:rules="[validatePassword]"
v-model="localUser.password"
@update:model-value="checkStrength"
:type="showPassword1 ? 'text' : 'password'"
>
<template v-slot:append>
<q-btn
flat
dense
:icon="showPassword1 ? 'visibility_off' : 'visibility'"
@mousedown.prevent="showPassword1 = true"
@mouseup.prevent="showPassword1 = false"
@mouseleave.prevent="showPassword1 = false"
@touchstart.prevent="showPassword1 = true"
@touchend.prevent="showPassword1 = false"
@touchcancel.prevent="showPassword1 = false"
></q-btn>
<q-icon :name="strengthIcon" :color="strengthColor"></q-icon>
</template>
</q-input>
<div class="q-mt-md q-px-xl">
<q-linear-progress :value="strengthValue" :color="strengthColor" size="8px" rounded />
<div class="text-caption text-center q-mt-xs">
{{ strengthLabel }}
</div>
</div>
<q-input
class="col-5 required"
:label="$t('password')"
filled
:type="showPassword2 ? 'text' : 'password'"
:rules="[checkSamePassword]"
v-model="passwordCheck"
>
<template v-slot:append>
<q-btn
flat
dense
:icon="showPassword2 ? 'visibility_off' : 'visibility'"
@mousedown.prevent="showPassword2 = true"
@mouseup.prevent="showPassword2 = false"
@mouseleave.prevent="showPassword2 = false"
@touchstart.prevent="showPassword2 = true"
@touchend.prevent="showPassword2 = false"
@touchcancel.prevent="showPassword2 = false"
></q-btn>
</template>
</q-input>
</div>
<div class="col-5">
<q-select
class="col-5 required"
:label="$t('role')"
filled
:options="props.roles"
:rules="[(val) => !!val || $t('roleIsRequired')]"
v-model="localUser.role"
></q-select>
<q-input
class="col-5 q-mt-xl"
:label="$t('expires')"
filled
type="datetime-local"
v-model="localUser.expiration"
></q-input>
</div>
</div>
</q-form>
<div class="row justify-center">
<q-btn class="q-ma-md" color="primary" no-caps @click="save">{{ $t('save') }}</q-btn>
</div>
</DialogFrame>
</template>
<script setup lang="ts">
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { ref } from 'vue';
import zxcvbn from 'zxcvbn';
import { appApi } from 'src/boot/axios';
import type { User } from 'src/vueLib/models/users';
import { useNotify } from 'src/vueLib/general/useNotify';
import { validateQForm } from 'src/vueLib/utils/validation';
import { i18n } from 'src/boot/lang';
import { DefaultSettings } from 'src/vueLib/models/settings';
const { NotifyResponse } = useNotify();
const dialog = ref();
const form = ref();
const newUser = ref(false);
const showPassword1 = ref(false);
const showPassword2 = ref(false);
const passwordCheck = ref('');
const strengthValue = ref(0);
const strengthLabel = ref('Enter a password');
const strengthColor = ref('grey');
const strengthIcon = ref('lock');
const localUser = ref<User>({
user: '',
email: '',
role: '',
});
const props = defineProps({
roles: {
type: Array,
required: true,
},
});
const emit = defineEmits(['update']);
async function open(user: User | null) {
if (user === undefined) {
return;
}
if (user !== null) {
localUser.value = { ...user };
newUser.value = false;
} else {
localUser.value = {
user: '',
email: '',
role: '',
};
newUser.value = true;
}
await dialog.value?.open();
await validateQForm(form.value);
}
function checkStrength() {
const result = zxcvbn(localUser.value.password || '');
strengthValue.value = (result.score + 1) / 5;
const levels = [
i18n.global.t('veryWeak'),
i18n.global.t('weak'),
i18n.global.t('fair'),
i18n.global.t('good'),
i18n.global.t('strong'),
];
const colors = ['red', 'orange', 'yellow', 'light-green', 'green'];
const icon = ['lock', 'warning', 'error_outline', 'check_circle_outline', 'verified_user'];
strengthLabel.value = levels[result.score] || i18n.global.t('veryWeak');
strengthColor.value = colors[result.score] || 'grey';
strengthIcon.value = icon[result.score] || 'lock';
}
function validatePassword(): string | boolean {
if (!localUser.value.password) return i18n.global.t('passwordIsRequired');
if (localUser.value.password.length < 8) {
return i18n.global.t('passwordTooShort');
} else if (!/[A-Z]/.test(localUser.value.password)) {
return i18n.global.t('passwordNeedsUppercase');
} else if (!/[a-z]/.test(localUser.value.password)) {
return i18n.global.t('passwordNeedsLowercase');
} else if (!/[0-9]/.test(localUser.value.password)) {
return i18n.global.t('passwordNeedsNumber');
} else if (!/[!@#$%^&*(),.?":{}|<>]/.test(localUser.value.password)) {
return i18n.global.t('passwordNeedsSpecial');
}
return true;
}
function checkSamePassword(): string | boolean {
if (localUser.value.password === passwordCheck.value) return true;
return i18n.global.t('passwordDoNotMatch');
}
async function save() {
if (!(await validateQForm(form.value))) {
NotifyResponse(i18n.global.t('notAllRequiredFieldsFilled'), 'error');
return;
}
let query = 'users/edit?id=' + localUser.value.id;
if (newUser.value) {
query = 'users/add';
localUser.value.settings = DefaultSettings();
}
appApi
.post(query, JSON.stringify(localUser.value))
.then(() => {
emit('update');
dialog.value.close();
})
.catch((err) => NotifyResponse(err, 'error'));
}
defineExpose({ open });
</script>
<style>
.required .q-field__label::after {
content: ' *';
color: red;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<q-dialog ref="dialog">
<q-card :style="'width:' + props.width">
<q-card-section
v-if="props.dialogLabel"
class="text-h6 text-center"
:class="'text-' + props.labelColor"
>{{ props.dialogLabel }}</q-card-section
>
<q-card-section v-if="props.text" class="text-center" style="white-space: pre-line">{{
props.text
}}</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn
v-if="props.buttonCancelLabel"
:flat="props.buttonCancelFlat"
:label="props.buttonCancelLabel"
v-close-popup
>
</q-btn>
<q-btn
v-if="props.buttonOkLabel"
:flat="props.buttonOkFlat"
:label="props.buttonOkLabel"
:color="props.buttonOkColor"
v-close-popup
@click="confirm"
>
</q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps({
buttonOkLabel: {
type: String,
default: 'OK',
},
buttonOkColor: {
type: String,
default: '',
},
buttonOkFlat: {
type: Boolean,
default: true,
},
labelColor: {
type: String,
default: 'primary',
},
dialogLabel: {
type: String,
default: '',
},
text: {
type: String,
default: '',
},
buttonCancelLabel: {
type: String,
default: '',
},
buttonCancelFlat: {
type: Boolean,
default: true,
},
width: {
type: String,
default: '300px',
},
});
const emit = defineEmits(['update-confirm']);
const dialog = ref();
const localInput = ref();
const open = (input?: unknown) => {
localInput.value = input;
dialog.value?.show();
};
const confirm = () => {
emit('update-confirm', localInput.value);
};
defineExpose({ open });
</script>

1
src/css/app.scss Normal file
View File

@@ -0,0 +1 @@
// app global css in SCSS form

View File

@@ -0,0 +1,40 @@
// Quasar SCSS (& Sass) Variables
// --------------------------------------------------
// To customize the look and feel of this app, you can override
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
// Check documentation for full list of Quasar variables
// Your own variables (that are declared here) and Quasar's own
// ones will be available out of the box in your .vue/.scss/.sass files
// It's highly recommended to change the default colors
// to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary: #1976d2;
$primary-text: #ffffff;
$secondary: #26a69a;
$secondary-text: #ffffff;
$accent: #9c27b0;
$dark: #1d1d1d;
$dark-page: #121212;
$positive: #21ba45;
$negative: #c10015;
$info: #31ccec;
$warning: #f2c037;
:root {
--q-primary-text: #ffffff;
--q-secondary-text: #ffffff;
}
.text-primary-text {
color: var(--q-primary-text) !important;
}
.text-secondary-text {
color: var(--q-secondary-text) !important;
}

7
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: string;
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
VUE_ROUTER_BASE: string | undefined;
}
}

107
src/layouts/MainLayout.vue Normal file
View File

@@ -0,0 +1,107 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated>
<q-toolbar>
<q-img
:src="localLogo"
alt="Logo"
style="width: 40px; height: 40px; background-color: var(--q-primary)"
class="q-mr-sm"
/>
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleLeftDrawer" />
<q-toolbar-title class="text-primary-text"> {{ $t(appName) }} </q-toolbar-title>
<div>Version {{ version }}</div>
<q-btn dense icon="refresh" square class="q-px-md q-ml-md" @click="refresh" />
<LoginMenu />
</q-toolbar>
</q-header>
<q-drawer v-model="leftDrawerOpen" bordered :width="drawerWidth" :overlay="$q.screen.lt.sm">
<q-list>
<q-item v-if="!autorized" to="/login" exact clickable v-ripple @click="closeDrawer">
<q-item-section>{{ $t('login') }}</q-item-section>
</q-item>
<q-item
v-if="autorized || user.isPermittedTo('members', 'read')"
to="/members"
exact
clickable
v-ripple
@click="closeDrawer"
>
<q-item-section> {{ $t('members') }}</q-item-section>
</q-item>
<q-item
v-if="autorized || user.isPermittedTo('events', 'read')"
to="/events"
exact
clickable
v-ripple
@click="closeDrawer"
>
<q-item-section>{{ $t('events') }}</q-item-section>
</q-item>
<q-item
v-if="autorized || user.isPermittedTo('responsible', 'read')"
to="/responsible"
exact
clickable
v-ripple
@click="closeDrawer"
>
<q-item-section>{{ $t('responsible') }}</q-item-section>
</q-item>
<q-item
v-if="autorized || user.isPermittedTo('group', 'read')"
to="/group"
exact
clickable
v-ripple
@click="closeDrawer"
>
<q-item-section>{{ $t('groups') }}</q-item-section>
</q-item>
</q-list>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { version } from '../../package.json';
import LoginMenu from 'src/vueLib/login/LoginMenu.vue';
import { useUserStore } from 'src/vueLib/login/userStore';
import { logo, appName } from 'src/vueLib/models/settings';
import { useQuasar } from 'quasar';
const localLogo = ref(logo);
const leftDrawerOpen = ref(false);
const user = useUserStore();
const autorized = computed(
() => user.isAuthorizedAs(['admin']) || user.isPermittedTo('settings', 'read'),
);
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
function closeDrawer() {
leftDrawerOpen.value = false;
}
function refresh() {
window.location.reload();
}
// Change width based on screen size
const drawerWidth = computed(() => {
const $q = useQuasar();
return $q.screen.lt.sm ? 220 : 300; // phone: 220px, desktop: 300px
});
</script>

View File

@@ -0,0 +1,93 @@
import { Dark } from 'quasar';
import type { Settings } from 'src/vueLib/models/settings';
import { updateOrAddObject } from 'src/vueLib/utils/utils';
import { ref } from 'vue';
export function setLocalSettings(settings: Settings) {
localStorage.setItem('icon', settings.icon);
localStorage.setItem('appName', settings.appName);
localStorage.setItem('databaseName', settings.databaseName);
localStorage.setItem('primaryColor', settings.primaryColor);
localStorage.setItem('primaryColorText', settings.primaryColorText);
localStorage.setItem('secondaryColor', settings.secondaryColor);
localStorage.setItem('secondaryColorText', settings.secondaryColorText);
}
export function getLocalSettings(): Settings {
return <Settings>{
icon: localStorage.getItem('icon'),
appName: localStorage.getItem('appName'),
databaseName: localStorage.getItem('databaseName'),
primaryColor: localStorage.getItem('primaryColor'),
primaryColorText: localStorage.getItem('primaryColorText'),
secondaryColor: localStorage.getItem('secondaryColor'),
secondaryColorText: localStorage.getItem('secondaryColorText'),
};
}
export function clearLocalStorage() {
localStorage.removeItem('icon');
localStorage.removeItem('appName');
localStorage.removeItem('databaseName');
localStorage.removeItem('primaryColor');
localStorage.removeItem('primaryColorText');
localStorage.removeItem('secondaryColor');
localStorage.removeItem('secondaryColorText');
localStorage.removeItem('lastRoute');
localStorage.removeItem('mode');
localStorage.removeItem('lang');
}
export function setLocalDarkMode() {
localStorage.setItem('mode', String(Dark.mode));
}
export function getLocalDarkMode(): boolean {
return localStorage.getItem('mode') === 'true';
}
export function setLocalLastRoute(route: string) {
localStorage.setItem('lastRoute', route);
}
export function getLocalLastRoute(): string {
return localStorage.getItem('lastRoute') || '/members';
}
export function setLocalLanguage(language: string) {
localStorage.setItem('lang', language);
}
export function getLocalLanguage(): string | null {
return localStorage.getItem('lang');
}
type pageDefault = {
page: string;
filteredColumn: string;
filteredValue: string[];
};
type pageDefaults = pageDefault[];
const pageDefaults = ref<pageDefaults>([]);
export function setLocalPageDefaults(
page: string,
filteredColumn?: string,
filteredValue?: string[],
) {
updateOrAddObject(
pageDefaults.value,
{ page: page, filteredColumn: filteredColumn, filteredValue: filteredValue },
'page',
);
localStorage.setItem('pageDefaults', JSON.stringify(pageDefaults.value));
}
export function getLocalPageDefaults(page: string): pageDefault | null {
const defaults = localStorage.getItem('pageDefaults');
if (!defaults) return null;
pageDefaults.value = JSON.parse(defaults);
return pageDefaults.value.find((e) => e.page === page) || null;
}

View File

@@ -0,0 +1,27 @@
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">
404
</div>
<div class="text-h2" style="opacity:.4">
Oops. Nothing here...
</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps
/>
</div>
</div>
</template>
<script setup lang="ts">
//
</script>

11
src/pages/EventsTable.vue Normal file
View File

@@ -0,0 +1,11 @@
<template>
<q-page>
<h4 class="text-primary text-bold text-center">{{ $t('events') }}</h4>
<EventsTable />
</q-page>
</template>
<script setup lang="ts">
import EventsTable from 'src/vueLib/tables/events/EventsTable.vue';
</script>

10
src/pages/GroupTable.vue Normal file
View File

@@ -0,0 +1,10 @@
<template>
<q-page>
<h4 class="text-primary text-bold text-center">{{ $t('groups') }}</h4>
<GroupTable />
</q-page>
</template>
<script setup lang="ts">
import GroupTable from 'src/vueLib/tables/group/GroupTable.vue';
</script>

27
src/pages/LoginPage.vue Normal file
View File

@@ -0,0 +1,27 @@
<template>
<div class="flex flex-center">
<LoginForm v-on:update-close="forwardToPage" />
</div>
</template>
<script setup lang="ts">
import { getLocalLastRoute } from 'src/localstorage/localStorage';
import LoginForm from 'src/vueLib/login/LoginForm.vue';
import { useUserStore } from 'src/vueLib/login/userStore';
import { nextTick, onMounted } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const userStore = useUserStore();
onMounted(() => {
if (userStore.user?.username !== '' && userStore.user?.role !== '') {
forwardToPage().catch((err) => console.error(err));
}
});
const forwardToPage = async () => {
await nextTick();
await router.push(getLocalLastRoute());
};
</script>

View File

@@ -0,0 +1,10 @@
<template>
<q-page>
<h4 class="text-primary text-bold text-center">{{ $t('members') }}</h4>
<MembersTable />
</q-page>
</template>
<script setup lang="ts">
import MembersTable from 'src/vueLib/tables/members/MembersTable.vue';
</script>

View File

@@ -0,0 +1,11 @@
<template>
<q-page>
<h4 class="text-primary text-bold text-center">{{ $t('responsibles') }}</h4>
<ResponsibleTable />
</q-page>
</template>
<script setup lang="ts">
import ResponsibleTable from 'src/vueLib/tables/responsible/ResponsibleTable.vue';
</script>

205
src/pages/SettingsPage.vue Normal file
View File

@@ -0,0 +1,205 @@
<template>
<h4 class="text-primary text-bold text-center">{{ $t('userSettings') }}</h4>
<div class="text-h2 flex flex-center">
<q-card class="q-gutter-md">
<div>
<q-card class="q-ma-lg">
<p class="text-bold text-h6 text-primary q-pa-md">{{ $t('general') }}</p>
<div class="row">
<q-input
:readonly="!user.isPermittedTo('settings', 'write')"
:class="[
colorGroup ? 'col-md-4' : 'col-md-3',
colorGroup ? 'col-md-6' : 'col-md-6',
colorGroup ? 'col-md-4' : 'col-md-12',
'q-pa-md',
]"
filled
:label="$t('appName')"
v-model="settings.appName"
></q-input>
<q-input
:readonly="!user.isPermittedTo('settings', 'write')"
:class="[
colorGroup ? 'col-md-4' : 'col-md-3',
colorGroup ? 'col-md-6' : 'col-md-6',
colorGroup ? 'col-md-4' : 'col-md-12',
'q-pa-md',
]"
filled
:label="$t('icon')"
v-model="settings.icon"
></q-input>
</div>
</q-card>
<q-card class="q-ma-lg">
<p class="text-bold text-h6 text-primary q-pa-md">{{ $t('database') }}</p>
<div class="row">
<q-input
:readonly="!user.isPermittedTo('settings', 'write')"
:class="[
colorGroup ? 'col-md-4' : 'col-md-3',
colorGroup ? 'col-md-6' : 'col-md-6',
colorGroup ? 'col-md-4' : 'col-md-12',
'q-pa-md',
]"
filled
:label="$t('databaseName')"
v-model="settings.databaseName"
></q-input>
</div>
</q-card>
<q-card class="q-ma-lg">
<q-btn
class="q-pa-md"
:icon="colorGroup ? 'remove' : 'add'"
size="md"
dense
no-caps
color="primary"
flat
@click="colorGroup = !colorGroup"
>{{ $t('colors') }}</q-btn
>
<div v-if="colorGroup" class="colum">
<div class="row">
<div class="col-12 col-sm-6 col-md-3 q-px-md">
<p class="text-center text-bold text-h6 text-primary">{{ $t('primaryColor') }}</p>
<q-color
:disable="!user.isPermittedTo('settings', 'write')"
bordered
class="q-mx-md"
v-model="settings.primaryColor"
></q-color>
</div>
<div class="col-12 col-sm-6 col-md-3 q-px-md">
<p
:class="[
'text-center',
'text-bold',
'text-h6',
'text-primary-text',
settings.primaryColorText === '#ffffff' ? 'bg-black' : '',
]"
:style="settings.primaryColorText === '#ffffff' ? 'opacity: 0.2;' : ''"
>
{{ $t('primaryColorText') }}
</p>
<q-color
:disable="!user.isPermittedTo('settings', 'write')"
bordered
class="q-mx-md"
v-model="settings.primaryColorText"
></q-color>
</div>
<div class="col-12 col-sm-6 col-md-3 q-px-md">
<p class="text-center text-bold text-h6 text-secondary">
{{ $t('secondaryColor') }}
</p>
<q-color
:disable="!user.isPermittedTo('settings', 'write')"
class="q-mx-md"
v-model="settings.secondaryColor"
></q-color>
</div>
<div class="col-12 col-sm-6 col-md-3 q-px-md">
<p
:class="[
'text-center',
'text-bold',
'text-h6',
'text-secondary-text',
settings.secondaryColorText === '#ffffff' ? 'bg-black' : '',
]"
:style="settings.secondaryColorText === '#ffffff' ? 'opacity: 0.2;' : ''"
>
{{ $t('secondaryColorText') }}
</p>
<q-color
:disable="!user.isPermittedTo('settings', 'write')"
class="q-mx-md"
v-model="settings.secondaryColorText"
></q-color>
</div>
</div>
<q-btn
:disable="!user.isPermittedTo('settings', 'write')"
class="q-my-md q-mx-md"
color="secondary"
dense
no-caps
@click="resetColors"
>{{ $t('resetColors') }}</q-btn
>
</div>
</q-card>
<div class="row justify-end">
<div class="q-my-lg q-mr-xl">
<q-btn no-caps color="primary" @click="save">{{ $t('save') }}</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</template>
<script setup lang="ts">
import { logo, appName, databaseName } from 'src/vueLib/models/settings';
import { reactive, ref, watch } from 'vue';
import { appApi } from 'src/boot/axios';
import { useNotify } from 'src/vueLib/general/useNotify';
import { type Settings } from 'src/vueLib/models/settings';
import { useUserStore } from 'src/vueLib/login/userStore';
import { setLocalSettings } from 'src/localstorage/localStorage';
const { NotifyResponse } = useNotify();
const colorGroup = ref(false);
const user = useUserStore();
const settings = reactive<Settings>({
appName: appName.value,
icon: logo.value,
databaseName: databaseName.value,
primaryColor: document.documentElement.style.getPropertyValue('--q-primary'),
primaryColorText: document.documentElement.style.getPropertyValue('--q-primary-text'),
secondaryColor: document.documentElement.style.getPropertyValue('--q-secondary'),
secondaryColorText: document.documentElement.style.getPropertyValue('--q-secondary-text'),
});
watch(settings, (newSettings) => {
logo.value = newSettings.icon;
appName.value = newSettings.appName;
databaseName.value = newSettings.databaseName;
});
function resetColors() {
document.documentElement.style.setProperty('--q-primary', '#1976d2');
settings.primaryColor = '#1976d2';
document.documentElement.style.setProperty('--q-primary-text', '#ffffff');
settings.primaryColorText = '#ffffff';
document.documentElement.style.setProperty('--q-secondary', '#26a69a');
settings.secondaryColor = '#26a69a';
document.documentElement.style.setProperty('--q-secondary-text', '#ffffff');
settings.secondaryColorText = '#ffffff';
}
function save() {
document.documentElement.style.setProperty('--q-primary', settings.primaryColor);
document.documentElement.style.setProperty('--q-primary-text', settings.primaryColorText);
document.documentElement.style.setProperty('--q-secondary', settings.secondaryColor);
document.documentElement.style.setProperty('--q-secondary-text', settings.secondaryColorText);
appName.value = settings.appName;
logo.value = settings.icon;
setLocalSettings(settings);
const tempuser = user.user;
if (tempuser) {
tempuser.settings = settings;
}
appApi
.post('users/update', tempuser)
.then((resp) => NotifyResponse(resp.data.message))
.catch((err) => NotifyResponse(err, 'error'));
}
</script>

View File

@@ -0,0 +1,40 @@
<template>
<h4 class="text-primary text-bold text-center">{{ $t('userSettings') }}</h4>
<div class="q-pa-md">
<div class="q-gutter-y-md">
<q-card>
<q-tabs
v-model="tab"
dense
class="taxt-grey"
active-color="primary"
indicator-color="primary"
align="justify"
narrow-indicator
>
<q-tab name="users" no-caps :label="$t('users')" />
<q-tab name="roles" no-caps :label="$t('roles')" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="tab" animated>
<q-tab-panel name="users">
<UserTable />
</q-tab-panel>
<q-tab-panel name="roles">
<RoleTable />
</q-tab-panel>
</q-tab-panels>
</q-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import UserTable from 'src/vueLib/tables/users/UserTable.vue';
import RoleTable from 'src/vueLib/tables/roles/RoleTable.vue';
const tab = ref('users');
</script>

53
src/router/index.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineRouter } from '#q-app/wrappers';
import {
createMemoryHistory,
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router';
import routes from './routes';
import { useUserStore } from 'src/vueLib/login/userStore';
/*
* If not building with SSR mode, you can
* directly export the Router instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Router instance.
*/
export default defineRouter(function (/* { store, ssrContext } */) {
const createHistory = process.env.SERVER
? createMemoryHistory
: process.env.VUE_ROUTER_MODE === 'history'
? createWebHistory
: createWebHashHistory;
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE),
});
Router.beforeEach((to, from, next) => {
const userStore = useUserStore();
const isLoggedIn = userStore.isAuthenticated;
if (to.meta.requiresAuth && !isLoggedIn) {
next('/login');
} else if (
to.meta.requiresAdmin &&
!userStore.isPermittedTo(to.path.replace('/', ''), 'read')
) {
next('/');
} else {
next();
}
});
return Router;
});

57
src/router/routes.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('layouts/MainLayout.vue'),
children: [
{
path: '',
component: () => import('pages/LoginPage.vue'),
},
{
path: 'login',
component: () => import('pages/LoginPage.vue'),
},
{
path: 'members',
component: () => import('pages/MembersTable.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: 'events',
component: () => import('pages/EventsTable.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: 'responsible',
component: () => import('pages/ResponsibleTable.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: 'group',
component: () => import('pages/GroupTable.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: 'settings',
component: () => import('pages/SettingsPage.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: 'userSettings',
component: () => import('pages/UserSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
],
},
// Always leave this as last one,
// but you can also remove it
{
path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue'),
},
];
export default routes;

View File

@@ -0,0 +1,82 @@
<template>
<q-card>
<q-card bordered v-for="(permission, index) in localPermission" v-bind:key="index">
<q-card-section class="text-center">
<div class="text-h7 text-bold text-primary">{{ $t(permission.name) }}</div>
</q-card-section>
<q-separator></q-separator>
<div class="flex justify-center">
<q-checkbox
class="q-mx-md"
:model-value="isFlagSet(permission.permission, 1 << 0)"
@update:model-value="(val) => toggleBit(index, 0, val)"
>{{ i18n.global.t('read') }}</q-checkbox
>
<q-checkbox
class="q-mx-md"
:model-value="isFlagSet(permission.permission, 1 << 1)"
@update:model-value="(val) => toggleBit(index, 1, val)"
>{{ i18n.global.t('write') }}</q-checkbox
>
<q-checkbox
class="q-mx-md"
:model-value="isFlagSet(permission.permission, 1 << 2)"
@update:model-value="(val) => toggleBit(index, 2, val)"
>{{ i18n.global.t('delete') }}</q-checkbox
>
<q-checkbox
v-if="permission.permissionNumber > 3"
class="q-mx-md"
:model-value="isFlagSet(permission.permission, 1 << 3)"
@update:model-value="(val) => toggleBit(index, 3, val)"
>{{ i18n.global.t('import') }}</q-checkbox
>
<q-checkbox
v-if="permission.permissionNumber > 4"
class="q-mx-md"
:model-value="isFlagSet(permission.permission, 1 << 4)"
@update:model-value="(val) => toggleBit(index, 4, val)"
>{{ i18n.global.t('export') }}</q-checkbox
>
</div>
</q-card>
</q-card>
</template>
<script setup lang="ts">
import { ref, type PropType } from 'vue';
import type { Permissions } from './permissions';
import { i18n } from 'src/boot/lang';
const props = defineProps({
permissions: {
type: Object as PropType<Permissions>,
required: true,
},
});
const emit = defineEmits(['update']);
const localPermission = ref(
props.permissions.map((e) => ({
name: e.name,
permission: e.permission ?? 0,
permissionNumber: e.name === 'members' ? 5 : 3,
})),
);
function isFlagSet(mask: number, flag: number) {
return (mask & flag) !== 0;
}
function toggleBit(index: number, bit: number, value: boolean) {
const item = localPermission.value[index];
if (!item) return; // guard against undefined index
const mask = 1 << bit;
const current = item.permission ?? 0;
item.permission = value ? current | mask : current & ~mask;
emit('update', localPermission.value);
}
</script>

View File

@@ -0,0 +1,50 @@
import { i18n } from 'src/boot/lang';
import { ref } from 'vue';
export interface Permission {
name: string;
label: string;
permission: number;
}
export type Permissions = Permission[];
export const defaultPermissions = [
{
name: 'settings',
label: i18n.global.t('settings'),
permission: 0,
},
{
name: 'userSettings',
label: i18n.global.t('userSettings'),
permission: 0,
},
{
name: 'members',
label: i18n.global.t('members'),
permission: 0,
},
{
name: 'events',
label: i18n.global.t('events'),
permission: 0,
},
{
name: 'responsible',
label: i18n.global.t('responsible'),
permission: 0,
},
{
name: 'group',
label: i18n.global.t('group'),
permission: 0,
},
{
name: 'excursionTable',
label: i18n.global.t('excursionTable'),
permission: 0,
},
];
export const permissions = ref<Permissions>(defaultPermissions);

View File

@@ -0,0 +1,170 @@
<template>
<q-dialog
ref="dialogRef"
:maximized="minMaxState"
:full-width="minMaxState"
:no-focus="!minMaxState"
:no-refocus="!minMaxState"
:seamless="!minMaxState"
>
<q-card class="layout bg-surface text-on-surface" :style="cardStyle">
<!-- Draggable Header -->
<div
class="dialog-header row items-center justify-between bg-transparent"
v-touch-pan.mouse.prevent.stop="handlePan"
>
<div v-if="headerTitle" class="text-left text-bold text-caption q-mx-sm">
{{ headerTitle }}
</div>
<div class="row justify-end q-mx-sm">
<q-btn dense flat :icon="minMaxIcon" size="md" @click="minMax" />
<q-btn dense flat icon="close" size="md" v-close-popup />
</div>
</div>
<q-separator color="black" />
<!-- Content Slot -->
<div class="scrollArea">
<slot />
</div>
<!-- Resize Handle -->
<div v-if="!minMaxState" class="resize-handle" @mousedown.prevent="startResizing" />
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import type { TouchPanValue } from 'quasar';
const dialogRef = ref();
const open = () => dialogRef.value?.show();
const close = () => dialogRef.value?.hide();
defineExpose({ open, close });
const props = defineProps({
headerTitle: { type: String, default: '' },
width: { type: Number, default: 400 },
height: { type: Number, default: 250 },
});
// Fullscreen toggle
const minMaxIcon = ref('fullscreen');
const minMaxState = ref(false);
function minMax() {
minMaxState.value = !minMaxState.value;
minMaxIcon.value = minMaxState.value ? 'fullscreen_exit' : 'fullscreen';
}
// Position and Size
const position = ref({ x: 0, y: 0 });
const width = ref(props.width || 400);
const height = ref(props.height || 250);
// Watch prop changes and sync local ref
watch(
() => props.width,
(newWidth) => {
if (newWidth !== undefined && newWidth !== width.value) {
width.value = newWidth;
}
},
);
watch(
() => props.height,
(newHeight) => {
if (newHeight !== undefined && newHeight !== height.value) {
height.value = newHeight;
}
},
);
// Dragging (only from header)
const handlePan: TouchPanValue = (details) => {
if (!minMaxState.value && details.delta) {
position.value.x += details.delta.x || 0;
position.value.y += details.delta.y || 0;
}
};
// Resizing
const isResizing = ref(false);
function startResizing(e: MouseEvent) {
isResizing.value = true;
const startX = e.clientX;
const startY = e.clientY;
const startWidth = width.value;
const startHeight = height.value;
function onMouseMove(e: MouseEvent) {
width.value = Math.max(200, startWidth + e.clientX - startX);
height.value = Math.max(200, startHeight + e.clientY - startY);
}
function onMouseUp() {
isResizing.value = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
// Styles
const cardStyle = computed(() => {
if (minMaxState.value) {
return {};
}
return {
width:
typeof width.value === 'number' || /^\d+$/.test(width.value)
? `${width.value}px`
: width.value,
height:
typeof height.value === 'number' || /^\d+$/.test(height.value)
? `${height.value}px`
: height.value,
transform: `translate(${position.value.x}px, ${position.value.y}px)`,
};
});
</script>
<style scoped>
.layout {
position: relative;
display: flex;
flex-direction: column;
border-radius: 10px;
}
/* Draggable header */
.dialog-header {
padding: 8px 0;
cursor: move;
user-select: none;
}
/* Scrollable content */
.scrollArea {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
/* Resize handle in bottom right */
.resize-handle {
position: absolute;
width: 16px;
height: 16px;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.1);
cursor: nwse-resize;
z-index: 10;
}
</style>

View File

@@ -0,0 +1,94 @@
import { getCurrentInstance } from 'vue';
import type { Response } from '../models/Response';
import { AxiosError } from 'axios';
export function useNotify() {
const instance = getCurrentInstance();
const $q = instance?.appContext.config.globalProperties.$q;
function NotifyResponse(
response: Response | string | AxiosError | undefined,
type?: 'warning' | 'error',
timeout: number = 5000,
) {
let color = 'green';
let icon = 'check_circle';
switch (type) {
case 'warning':
color = 'orange';
icon = 'warning';
break;
case 'error':
color = 'red';
icon = 'error';
break;
}
if (response) {
let message = '';
if (response instanceof AxiosError && response.response) {
if (response.response.data) {
const data = response.response.data as Response;
message = data.message;
}
} else {
message = typeof response === 'string' ? response : (response.message ?? '');
}
if (message === '') {
return;
}
color = typeof response === 'string' ? color : type === 'error' ? 'red' : color;
icon = typeof response === 'string' ? icon : type === 'error' ? 'error' : icon;
if (!$q) {
console.error(message);
return;
}
$q?.notify({
message: message,
color: color,
position: 'top',
icon: icon,
timeout: timeout,
actions: [
{
icon: 'close',
color: 'white',
dense: true,
round: true,
handler: () => {
/* just closes */
},
},
],
});
}
}
function NotifyDialog(title: string, text: string, okText?: string, cancelText?: string) {
return new Promise((resolve) => {
$q
?.dialog({
title: title,
message: text,
persistent: true,
ok: okText ?? 'OK',
cancel: cancelText ?? 'CANCEL',
})
.onOk(() => {
resolve(true);
})
.onCancel(() => {
resolve(false);
})
.onDismiss(() => {
resolve(false);
});
});
}
return {
NotifyDialog,
NotifyResponse,
};
}

View File

@@ -0,0 +1,57 @@
<template>
<DialogFrame ref="Dialog" :width="300" :height="380" header-title="Login">
<LoginForm v-on:update-close="close" />
</DialogFrame>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue';
import DialogFrame from '../dialog/DialogFrame.vue';
import { useNotify } from '../general/useNotify';
import LoginForm from './LoginForm.vue';
const { NotifyResponse } = useNotify();
const Dialog = ref();
const refUserInput = ref();
const open = () => {
Dialog.value?.open();
nextTick(() => {
refUserInput.value?.focus();
}).catch((err) => NotifyResponse(err, 'error'));
};
const close = () => {
Dialog.value.close();
};
defineExpose({ open });
</script>
<style scoped>
@keyframes shake {
0% {
transform: translateX(0);
}
20% {
transform: translateX(-8px);
}
40% {
transform: translateX(8px);
}
60% {
transform: translateX(-6px);
}
80% {
transform: translateX(6px);
}
100% {
transform: translateX(0);
}
}
.shake {
animation: shake 0.4s ease;
border: 2px solid #f44336;
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<div class="text-black"></div>
<q-form ref="refForm">
<q-item-section class="q-gutter-md q-pa-md">
<q-card :class="['q-gutter-xs q-items-center q-pa-lg', { shake: shake }]">
<div class="text-h5 text-primary text-center">{{ $t(appName) }}</div>
<q-input
ref="refUserInput"
dense
filled
autocomplete="username"
type="text"
:label="$t('user')"
v-model="user"
:rules="[(val) => !!val || $t('user') + ' ' + $t('isRequired')]"
></q-input>
<q-input
dense
filled
autocomplete="current-password"
:type="showPassword ? 'text' : 'password'"
:label="$t('password')"
v-model="password"
@keyup.enter="onSubmit"
:rules="[(val) => !!val || $t('password') + ' ' + $t('isRequired')]"
>
<template #append>
<q-btn
flat
dense
:icon="showPassword ? 'visibility_off' : 'visibility'"
@mousedown.prevent="showPassword = true"
@mouseup.prevent="showPassword = false"
@mouseleave.prevent="showPassword = false"
@touchstart.prevent="showPassword = true"
@touchend.prevent="showPassword = false"
@touchcancel.prevent="showPassword = false"
></q-btn>
</template>
</q-input>
<div class="q-pt-sm q-mr-md row justify-end">
<q-btn no-caps color="primary" :label="$t('login')" @click="onSubmit"></q-btn>
</div>
</q-card>
</q-item-section>
</q-form>
</template>
<script setup lang="ts">
import { appName } from '../models/settings';
import { ref } from 'vue';
import { useNotify } from '../general/useNotify';
import { useLogin } from './useLogin';
const refForm = ref();
const refUserInput = ref();
const user = ref('');
const password = ref('');
const showPassword = ref(false);
const shake = ref(false);
const { NotifyResponse } = useNotify();
const { login } = useLogin();
const emit = defineEmits(['update-close']);
const onSubmit = async () => {
const valid = refForm.value?.validate();
if (!valid) {
NotifyResponse('error submitting login form', 'error');
return;
}
await login(user.value, password.value)
.then(() => {
NotifyResponse("logged in as '" + user.value + "'");
})
.catch((err) => {
NotifyResponse(err, 'error');
shake.value = true;
setTimeout(() => {
shake.value = false;
}, 500);
});
emit('update-close');
};
</script>
<style scoped>
@keyframes shake {
0% {
transform: translateX(0);
}
20% {
transform: translateX(-8px);
}
40% {
transform: translateX(8px);
}
60% {
transform: translateX(-6px);
}
80% {
transform: translateX(6px);
}
100% {
transform: translateX(0);
}
}
.shake {
animation: shake 0.4s ease;
border: 2px solid #f44336;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div class="q-gutter-md">
<q-btn dense flat round icon="person" :color="currentUser ? 'green' : ''">
<q-menu ref="refLoginMenu">
<q-list style="min-width: 120px">
<q-item v-if="user.user" class="text-primary">{{ currentUser?.username }}</q-item>
<q-item v-if="showLogin" clickable v-close-popup @click="openLogin">
<q-item-section class="text-primary">{{ loginText }}</q-item-section>
</q-item>
<q-item>
<q-btn flat :icon="darkMode" @click="toggleDarkMode"
><q-tooltip>{{ $t(darkMode) }}</q-tooltip></q-btn
>
</q-item>
<q-item>
<q-select
:label="$t('language')"
borderless
color="primary"
dense
v-model="langSelected"
:options="langSelection"
><q-tooltip>{{ $t('language') }}</q-tooltip></q-select
>
</q-item>
<q-item
v-if="
(autorized || user.isPermittedTo('settings', 'read')) && route.path !== '/settings'
"
>
<q-btn flat color="secondary" icon="settings" to="/settings"></q-btn>
</q-item>
<q-item
v-if="
(autorized || user.isPermittedTo('userSettings', 'read')) &&
route.path !== '/userSettings'
"
>
<q-btn flat color="secondary" icon="group" to="/userSettings"></q-btn>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<LoginDialog ref="refLoginDialog"></LoginDialog>
</template>
<script setup lang="ts">
import LoginDialog from './LoginDialog.vue';
import { computed, ref, watch } from 'vue';
import { useNotify } from '../general/useNotify';
import { lang, i18n } from 'src/boot/lang';
import { useUserStore } from './userStore';
import { useRoute } from 'vue-router';
import { Dark } from 'quasar';
import { useLogin } from './useLogin';
import { setLocalDarkMode, setLocalLanguage } from 'src/localstorage/localStorage';
const userLogin = useLogin();
const route = useRoute();
const refLoginDialog = ref();
const user = useUserStore();
const { NotifyResponse } = useNotify();
const currentUser = computed(() => user.user);
const darkMode = computed(() => {
if (Dark.mode) {
return 'light_mode';
}
return 'dark_mode';
});
const showLogin = computed(
() => (route.path !== '/' && route.path !== '/login') || currentUser.value?.username === '',
);
const autorized = computed(() => !!user.isAuthorizedAs(['admin']));
// switch between logged in or logged out text
const loginText = computed(() => {
return currentUser.value ? i18n.global.t('logout') : i18n.global.t('login');
});
//switch between dark and light mode and save it in localStorage
function toggleDarkMode() {
Dark.toggle();
setLocalDarkMode();
}
// opens login page if no user is logged in otherwise it serves as logout
function openLogin() {
if (currentUser.value) {
userLogin.logout().catch((err) => NotifyResponse(err, 'error'));
return;
}
refLoginDialog.value?.open();
}
const langSelected = ref(i18n.global.locale);
const langSelection = ref(lang);
// Watch for changes and update i18n locale
watch(langSelected, (newLang) => {
i18n.global.locale = newLang;
setLocalLanguage(newLang);
});
</script>

View File

@@ -0,0 +1,89 @@
import { appApi } from 'src/boot/axios';
import { useUserStore } from './userStore';
import { useNotify } from '../general/useNotify';
import type { Settings } from '../models/settings';
import { appName, logo } from '../models/settings';
import { clearLocalStorage, setLocalSettings } from 'src/localstorage/localStorage';
const refreshTime = 10000;
let intervalId: ReturnType<typeof setInterval> | null = null;
export function useLogin() {
const userStore = useUserStore();
const { NotifyResponse } = useNotify();
async function login(user: string, password: string) {
try {
await appApi.post('/login', { user, password }).then((resp) => {
const sets = resp.data.settings as Settings;
logo.value = sets.icon;
appName.value = sets.appName;
document.documentElement.style.setProperty('--q-primary', sets.primaryColor);
document.documentElement.style.setProperty('--q-primary-text', sets.primaryColorText);
document.documentElement.style.setProperty('--q-secondary', sets.secondaryColor);
document.documentElement.style.setProperty('--q-secondary-text', sets.secondaryColorText);
setLocalSettings(sets);
});
const resp = await appApi.get('/login/me');
await userStore
.setUser({ id: resp.data.id, username: resp.data.user, role: resp.data.role })
.catch((err) => NotifyResponse(err, 'error'));
startRefreshInterval();
return true;
} catch (err) {
console.error('Login error:', err);
throw err;
}
}
async function logout() {
await appApi.get('/logout').catch((err) => {
NotifyResponse(err, 'error');
});
userStore.clearUser();
clearLocalStorage();
stopRefreshInterval();
}
async function refresh() {
await appApi
.post('login/refresh', {}, { withCredentials: true })
.then(() => {
appApi
.get('/login/me')
.then((resp) => {
userStore
.setUser({ id: resp.data.id, username: resp.data.user, role: resp.data.role })
.catch((err) => NotifyResponse(err, 'error'));
if (!intervalId) {
startRefreshInterval();
}
return true;
})
.catch(() => {});
})
.catch(() => {
userStore.clearUser();
});
stopRefreshInterval();
return false;
}
function startRefreshInterval() {
intervalId = setInterval(() => {
refresh().catch((err) => NotifyResponse(err, 'error'));
}, refreshTime);
}
function stopRefreshInterval() {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
}
return { login, logout, refresh };
}

View File

@@ -0,0 +1,112 @@
import { defineStore } from 'pinia';
import { useGlobalRouter } from 'src/vueLib/utils/globalRouter';
import { useGlobalQ } from 'src/vueLib/utils/globalQ';
import { appApi } from 'src/boot/axios';
import { useNotify } from '../general/useNotify';
import type { Role } from '../models/roles';
import type { UserState, User } from '../models/user';
import type { Permission } from '../checkboxes/permissions';
const { NotifyResponse } = useNotify();
export const useUserStore = defineStore('user', {
state: (): UserState => ({
user: null,
}),
getters: {
isAuthenticated: (state: UserState): boolean => {
return !!state.user;
},
isAuthorizedAs: (state: UserState) => {
return (roles: string[]) => {
return state.user !== null && roles.includes(state.user.role);
};
},
isPermittedTo: (state: UserState) => {
return (name: string, type: 'read' | 'write' | 'delete' | 'import' | 'export'): boolean => {
const permission = state.user?.permissions?.find((r: Permission) => r.name === name);
switch (type) {
case 'read':
return permission?.permission ? (permission.permission & (1 << 0)) === 1 : false;
case 'write':
return permission?.permission ? (permission.permission & (1 << 1)) === 2 : false;
case 'delete':
return permission?.permission ? (permission.permission & (1 << 2)) === 4 : false;
case 'import':
return permission?.permission ? (permission.permission & (1 << 3)) === 8 : false;
case 'export':
return permission?.permission ? (permission.permission & (1 << 4)) === 16 : false;
}
};
},
},
actions: {
async setUser(user: User) {
await appApi
.get('roles?role=' + user.role)
.then((resp) => {
const roleData = resp.data.find((role: Role) => role.role === user.role);
user.permissions = roleData?.permissions || [];
this.user = user;
})
.catch((err) => NotifyResponse(err, 'error'));
},
clearUser() {
const $q = useGlobalQ();
if (!this.user) return;
if ($q) {
$q?.notify({
message: "user '" + this.user?.username + "' logged out",
color: 'orange',
position: 'top',
icon: 'warning',
timeout: 5000,
actions: [
{
icon: 'close',
color: 'white',
dense: true,
round: true,
handler: () => {
/* just closes */
},
},
],
});
} else {
console.error("user '" + this.user?.username + "' logged out");
}
this.user = null;
const router = useGlobalRouter();
router?.push('/').catch((err) => {
if ($q) {
$q?.notify({
message: err,
color: 'orange',
position: 'top',
icon: 'warning',
timeout: 5000,
actions: [
{
icon: 'close',
color: 'white',
dense: true,
round: true,
handler: () => {
/* just closes */
},
},
],
});
} else {
console.error("user '" + this.user?.username + "' logged out");
}
});
},
},
});

View File

@@ -0,0 +1,3 @@
export interface Response {
message: string;
}

View File

@@ -0,0 +1,9 @@
import type { Members } from './member';
export interface Event {
id: number;
name: string;
attendees: Members;
}
export type Events = Event[];

View File

@@ -0,0 +1,6 @@
export interface Group {
id: number;
name: string;
}
export type Groups = Group[];

View File

@@ -0,0 +1,19 @@
export interface Member {
id: number;
firstName: string;
lastName: string;
birthday?: string;
age?: string;
comment?: string;
address?: string;
town?: string;
zip?: string;
phone?: string;
email?: string;
group?: string;
responsiblePerson?: Member;
firstVisit?: string;
lastVisit?: string;
}
export type Members = Member[];

View File

@@ -0,0 +1,12 @@
export interface MetaData {
rowIndex: number;
seperator: string;
firstName: string;
lastName: string;
birthday: string;
address: string;
town: string;
zip: string;
phone: string;
email: string;
}

View File

@@ -0,0 +1,9 @@
import type { Permissions } from '../checkboxes/permissions';
export interface Role {
id?: number;
role: string;
permissions: Permissions;
}
export type Roles = Role[];

View File

@@ -0,0 +1,27 @@
import { ref } from 'vue';
export const logo = ref('');
export const appName = ref('Attendance Records');
export const databaseName = ref('members.dba');
export type Settings = {
appName: string;
icon: string;
databaseName: string;
primaryColor: string;
primaryColorText: string;
secondaryColor: string;
secondaryColorText: string;
};
export function DefaultSettings(): Settings {
return {
appName: 'Attendance Records',
icon: '',
databaseName: 'members.dba',
primaryColor: document.documentElement.style.getPropertyValue('--q-primary-text'),
primaryColorText: document.documentElement.style.getPropertyValue('--q-primary'),
secondaryColor: document.documentElement.style.getPropertyValue('--q-secondary'),
secondaryColorText: document.documentElement.style.getPropertyValue('--q-secondary-text'),
};
}

14
src/vueLib/models/user.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Permissions } from '../checkboxes/permissions';
import type { Settings } from './settings';
export interface User {
id: number;
username: string;
role: string;
permissions?: Permissions;
settings?: Settings;
}
export interface UserState {
user: User | null;
}

View File

@@ -0,0 +1,13 @@
import type { Settings } from './settings';
export interface User {
id?: number;
user: string;
email: string;
role: string;
expiration?: string;
password?: string;
settings?: Settings;
}
export type Users = User[];

View File

@@ -0,0 +1,77 @@
import { appApi } from 'src/boot/axios';
import { ref, computed } from 'vue';
import type { Members } from 'src/vueLib/models/member';
import { useNotify } from 'src/vueLib/general/useNotify';
import { i18n } from 'boot/lang';
import type { Events } from 'src/vueLib/models/event';
export function useAttendeesTable() {
const attendees = ref<Members>([]);
const pagination = ref({
sortBy: 'firstName',
descending: false,
page: 1,
rowsPerPage: 20,
});
const columns = computed(() => [
{
name: 'firstName',
align: 'left' as const,
label: i18n.global.t('prename'),
field: 'firstName',
sortable: true,
},
{
name: 'lastName',
align: 'left' as const,
label: i18n.global.t('lastName'),
field: 'lastName',
sortable: true,
},
{ name: 'option', align: 'center' as const, label: '', field: 'option', icon: 'option' },
]);
const { NotifyResponse } = useNotify();
const loading = ref(false);
//updates Attendees list from database
async function updateAttendees() {
loading.value = true;
let events: Events | undefined;
await appApi
.get('events')
.then((resp) => {
if (resp.data === null) {
attendees.value = [];
return;
}
events = resp.data as Events;
})
.catch((err) => {
NotifyResponse(err, 'error');
})
.finally(() => {
loading.value = false;
});
if (!events || events.length === 0 || !events[0]?.attendees || events[0].attendees === null) {
attendees.value = [];
return;
}
attendees.value = events[0].attendees ?? [];
}
return {
attendees,
pagination,
columns,
loading,
updateAttendees,
};
}

View File

@@ -0,0 +1,233 @@
<template>
<div class="q-pa-md">
<q-table
flat
bordered
ref="tableRef"
:title="$t('attendees')"
title-class="text-bold text-blue-9"
:no-data-label="$t('noDataAvailable')"
:loading-label="$t('loading')"
:rows-per-page-label="$t('recordsPerPage')"
:selected-rows-label="(val) => val + ' ' + $t('recordSelected')"
:rows="attendees"
:columns="columns"
row-key="id"
v-model:pagination="pagination"
:loading="loading"
:filter="filter"
:selection="selectOption ? 'multiple' : 'none'"
v-model:selected="selected"
binary-state-sort
class="bigger-table-text"
>
<template v-slot:top-left>
<q-btn-group push flat style="color: grey">
<q-btn
v-if="user.isPermittedTo('events', 'write')"
dense
flat
icon="person"
@click="openAllValueDialog"
><q-badge floating transparent color="primary" text-color="primary-text">+</q-badge>
<q-tooltip>{{ $t('addNewAttendees') }}</q-tooltip>
</q-btn>
<q-btn
v-if="user.isPermittedTo('events', 'write')"
dense
flat
style="color: grey"
:icon="selectOption ? 'check_box' : 'check_box_outline_blank'"
@click="selectOption = !selectOption"
>
<q-tooltip>{{ $t('selectAttendeesOptions') }}</q-tooltip>
</q-btn>
</q-btn-group>
<div v-if="selectOption && selected.length > 0">
<q-btn flat dense icon="more_vert" @click="openSubmenu = true" />
<q-menu v-if="openSubmenu" anchor="bottom middle" self="top middle">
<q-item
clickable
v-close-popup
@click="openRemoveDialog(...selected)"
class="text-negative"
>{{ $t('delete') }}</q-item
>
</q-menu>
</div>
<div v-if="selectOption && selected.length > 0" class="q-ml-md text-weight-bold">
{{ $t('selected') }}: {{ selected.length }}
</div>
</template>
<template v-slot:top-right>
<q-input filled dense debounce="300" v-model="filter" :placeholder="$t('search')">
<template v-slot:append>
<q-icon name="search" />
</template>
</q-input>
</template>
<template v-slot:body-cell="props">
<q-td v-if="props.col.field === 'attendees'" :props="props">
<q-btn v-if="props.value !== null && props.value.length > 0" dense flat icon="people"
><q-badge color="primary" text-color="primary-text" floating transparent>{{
props.row.count
}}</q-badge></q-btn
>
</q-td>
<q-td v-else :props="props">
{{ props.value }}
</q-td>
</template>
<template v-slot:body-cell-option="props">
<q-td :props="props">
<q-btn
v-if="user.isPermittedTo('events', 'delete')"
flat
dense
@click="openRemoveDialog(props.row)"
color="negative"
icon="delete"
/>
</q-td>
</template>
</q-table>
</div>
<DialogFrame ref="memberTableDialog" :header-title="$t('members')" :width="700" :height="500">
<MembersTable
add-attendees
:compare-members="attendees"
v-on:update-event="updateTable"
:event-id="localEvent?.id ?? 0"
/>
</DialogFrame>
<OkDialog
ref="okDialog"
:dialog-label="$t('delete')"
:text="$t('doYouWantToDelete') + ' ' + deleteText"
label-color="red"
:button-cancel-label="$t('cancel')"
:button-ok-label="$t('confirm')"
:button-ok-flat="false"
button-ok-color="red"
v-on:update-confirm="(val) => removeAttendees(...val)"
></OkDialog>
</template>
<script setup lang="ts">
import { appApi } from 'src/boot/axios';
import { onMounted, type PropType, ref } from 'vue';
import type { Members } from 'src/vueLib/models/member';
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import OkDialog from 'src/components/dialog/OkDialog.vue';
import { useNotify } from 'src/vueLib/general/useNotify';
import { useAttendeesTable } from '../attendees/AttendeesTable';
import { useUserStore } from 'src/vueLib/login/userStore';
import type { Event } from 'src/vueLib/models/event';
import MembersTable from '../members/MembersTable.vue';
import { i18n } from 'src/boot/lang';
export interface AttendeesDialog {
getSelected: () => Members;
}
const props = defineProps({
event: {
type: Object as PropType<Event>,
required: true,
},
});
const emit = defineEmits(['update']);
const { NotifyResponse } = useNotify();
const memberTableDialog = ref();
const okDialog = ref();
const deleteText = ref('');
const selectOption = ref(false);
const localEvent = ref<Event>();
const selected = ref<Members>([]);
const openSubmenu = ref(false);
const filter = ref('');
const user = useUserStore();
const { attendees, pagination, loading, columns, updateAttendees } = useAttendeesTable();
onMounted(() => {
localEvent.value = props.event;
attendees.value = props.event.attendees ?? [];
});
//opens dialog for one value
function openAllValueDialog() {
memberTableDialog.value?.open();
}
//opens remove dialog
function openRemoveDialog(...attendees: Members) {
if (attendees.length === 1) {
deleteText.value = "'";
if (attendees[0]?.firstName && attendees[0]?.lastName) {
deleteText.value += attendees[0]?.firstName + ' ' + attendees[0]?.lastName;
}
deleteText.value += "'";
} else {
deleteText.value = String(attendees.length) + ' ' + i18n.global.t('attendees');
}
okDialog.value?.open(attendees);
}
//remove Attendees from database
async function removeAttendees(...removeAttendees: Members) {
if (!localEvent.value) {
NotifyResponse('event is empty', 'error');
return;
}
localEvent.value.attendees = removeAttendees;
await appApi
.post('events/delete/attendees', localEvent.value)
.then(() => {
selected.value = [];
if (localEvent.value?.attendees !== undefined && localEvent.value?.attendees.length > 1) {
NotifyResponse(i18n.global.t('deleteAttendees'), 'warning');
} else {
NotifyResponse(i18n.global.t('deleteAttendee'), 'warning');
}
})
.catch((err) => NotifyResponse(err, 'error'))
.finally(() => {
loading.value = false;
});
await updateAttendees();
emit('update');
}
async function updateTable() {
await updateAttendees();
emit('update');
}
</script>
<style>
@keyframes blink-yellow {
0%,
100% {
background-color: yellow;
}
50% {
background-color: transparent;
}
}
.bigger-table-text .q-table__middle td {
font-size: 14px;
}
.bigger-table-text .q-table__top,
.bigger-table-text .q-table__bottom,
.bigger-table-text th {
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<DialogFrame ref="dialog" :header-title="$t('attendees')" :width="700" :height="600">
<q-card>
<q-tabs
v-model="tab"
dense
class="text-grey"
active-color="primary"
indicator-color="primary"
align="justify"
narrow-indicator
>
<q-tab no-caps name="attendance" :label="$t('attendees')" />
<q-tab no-caps name="noneAttendees" :label="$t('noneAttendees')" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="tab" animated>
<q-tab-panel name="attendance">
<AttendeesTable :event="localEvent!" v-on:update="updateTable" />
</q-tab-panel>
<q-tab-panel name="noneAttendees">
<MembersTable
add-attendees
:compare-members="attendees"
v-on:update-event="updateTable"
:event-id="localEvent?.id ?? 0"
/>
</q-tab-panel>
</q-tab-panels>
</q-card>
</DialogFrame>
</template>
<script setup lang="ts">
import AttendeesTable from './AttendeesTable.vue';
import MembersTable from '../members/MembersTable.vue';
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import type { Event } from 'src/vueLib/models/event';
import { ref } from 'vue';
import { useAttendeesTable } from './AttendeesTable';
const emit = defineEmits(['update']);
const dialog = ref();
const localEvent = ref<Event>();
const tab = ref('attendance');
const { attendees, updateAttendees } = useAttendeesTable();
const open = (event: Event) => {
localEvent.value = event;
attendees.value = event.attendees ?? [];
dialog.value.open();
};
async function updateTable() {
await updateAttendees();
emit('update');
}
defineExpose({ open });
</script>

View File

@@ -0,0 +1,79 @@
import { appApi } from 'src/boot/axios';
import { ref, computed } from 'vue';
import type { Events } from 'src/vueLib/models/event';
import { useNotify } from 'src/vueLib/general/useNotify';
import { i18n } from 'boot/lang';
export function useEventTable() {
const Events = ref<Events>([]);
const pagination = ref({
sortBy: 'firstName',
descending: false,
page: 1,
rowsPerPage: 20,
});
const columns = computed(() => [
{
name: 'name',
align: 'left' as const,
label: i18n.global.t('name'),
field: 'name',
sortable: true,
},
{
name: 'attendees',
align: 'center' as const,
label: i18n.global.t('attendees'),
field: 'attendees',
sortable: true,
},
{
name: 'date',
align: 'left' as const,
label: i18n.global.t('dateAndTime'),
field: 'date',
sortable: true,
},
{ name: 'option', align: 'center' as const, label: '', field: 'option', icon: 'option' },
]);
const { NotifyResponse } = useNotify();
const loading = ref(false);
//updates Event list from database
function updateEvents() {
loading.value = true;
appApi
.get('events')
.then((resp) => {
if (resp.data === null) {
Events.value = [];
return;
}
Events.value = resp.data as Events;
if (Events.value === null) {
Events.value = [];
return;
}
})
.catch((err) => {
NotifyResponse(err, 'error');
})
.finally(() => {
loading.value = false;
});
}
return {
Events,
pagination,
columns,
loading,
updateEvents,
};
}

View File

@@ -0,0 +1,269 @@
<template>
<div class="q-pa-md">
<q-table
flat
bordered
ref="tableRef"
title="Events"
title-class="text-bold text-blue-9"
:no-data-label="$t('noDataAvailable')"
:loading-label="$t('loading')"
:rows-per-page-label="$t('recordsPerPage')"
:selected-rows-label="(val) => val + ' ' + $t('recordSelected')"
:rows="Events"
:columns="columns"
row-key="id"
v-model:pagination="pagination"
:loading="loading"
:filter="filter"
:selection="selectOption ? 'multiple' : 'none'"
v-model:selected="selected"
binary-state-sort
class="bigger-table-text"
>
<template v-slot:top-left>
<q-btn-group push flat style="color: grey">
<q-btn
v-if="user.isPermittedTo('events', 'write')"
dense
flat
icon="add"
@click="openAllValueDialog(null)"
>
<q-tooltip>{{ $t('addNewEvent') }}</q-tooltip>
</q-btn>
<q-btn
v-if="user.isPermittedTo('events', 'write')"
dense
flat
style="color: grey"
:icon="selectOption ? 'check_box' : 'check_box_outline_blank'"
@click="selectOption = !selectOption"
>
<q-tooltip>{{ $t('selectEventOptions') }}</q-tooltip>
</q-btn>
</q-btn-group>
<div v-if="selectOption && selected.length > 0">
<q-btn flat dense icon="more_vert" @click="openSubmenu = true" />
<q-menu v-if="openSubmenu" anchor="bottom middle" self="top middle">
<q-item
clickable
v-close-popup
@click="openRemoveDialog(...selected)"
class="text-negative"
>{{ $t('delete') }}</q-item
>
</q-menu>
</div>
<div v-if="selectOption && selected.length > 0" class="q-ml-md text-weight-bold">
{{ $t('selected') }}: {{ selected.length }}
</div>
</template>
<template v-slot:top-right>
<q-input filled dense debounce="300" v-model="filter" :placeholder="$t('search')">
<template v-slot:append>
<q-icon name="search" />
</template>
</q-input>
</template>
<template v-slot:body-cell="props">
<q-td v-if="props.col.field === 'attendees'" :props="props">
<q-btn dense flat icon="people" @click="openAttendees(props.row)"
><q-badge color="primary" text-color="primary-text" floating transparent>{{
props.row.count
}}</q-badge></q-btn
>
</q-td>
<q-td
v-else
:props="props"
:style="
user.isPermittedTo('events', 'write') && props.col.field !== 'count'
? 'cursor: pointer'
: ''
"
@click="
user.isPermittedTo('events', 'write') &&
props.col.field !== 'count' &&
openSingleValueDialog(props.col.label, props.col.name, props.row)
"
>
{{ props.value }}
</q-td>
</template>
<template v-slot:body-cell-option="props">
<q-td :props="props">
<q-btn
v-if="user.isPermittedTo('events', 'write') || user.isPermittedTo('events', 'delete')"
flat
dense
icon="more_vert"
@click="openSubmenu = true"
/>
<q-menu v-if="openSubmenu" anchor="top right" self="top left">
<q-item
v-if="user.isPermittedTo('events', 'write')"
clickable
v-close-popup
@click="openAllValueDialog(props.row)"
class="text-primary"
>{{ $t('edit') }}</q-item
>
<q-item
v-if="user.isPermittedTo('events', 'delete')"
clickable
v-close-popup
@click="openRemoveDialog(props.row)"
class="text-negative"
title="zu"
>{{ $t('delete') }}</q-item
>
</q-menu>
</q-td>
</template>
</q-table>
</div>
<EditOneDialog
ref="editOneDialog"
endpoint="events/edit"
query-id
v-on:update="updateEvents"
></EditOneDialog>
<EditAllDialog ref="editAllDialog" v-on:update="updateEvents"></EditAllDialog>
<AttendeesTableDialog ref="attendeesDialog" v-on:update="updateEvents" />
<OkDialog
ref="okDialog"
:dialog-label="$t('delete')"
:text="$t('doYouWantToDelete') + ' ' + deleteText"
label-color="red"
:button-cancel-label="$t('cancel')"
:button-ok-label="$t('confirm')"
:button-ok-flat="false"
button-ok-color="red"
v-on:update-confirm="(val) => removeEvent(...val)"
></OkDialog>
</template>
<script setup lang="ts">
import { appApi } from 'src/boot/axios';
import { ref, onMounted } from 'vue';
import type { Event, Events } from 'src/vueLib/models/event';
import EditOneDialog from 'src/components/EditOneDialog.vue';
import EditAllDialog from 'src/components/EventEditAllDialog.vue';
import OkDialog from 'src/components/dialog/OkDialog.vue';
import { useNotify } from 'src/vueLib/general/useNotify';
import { useEventTable } from './EventsTable';
import { databaseName } from 'src/vueLib/models/settings';
import { useUserStore } from 'src/vueLib/login/userStore';
import AttendeesTableDialog from '../attendees/AttendeesTableDialog.vue';
import type { Members } from 'src/vueLib/models/member';
import { i18n } from 'src/boot/lang';
export interface EventDialog {
getSelected: () => Events;
}
const { NotifyResponse } = useNotify();
const editOneDialog = ref();
const editAllDialog = ref();
const attendeesDialog = ref();
const okDialog = ref();
const deleteText = ref('');
const selectOption = ref(false);
const selected = ref<Events>([]);
const openSubmenu = ref(false);
const filter = ref('');
const user = useUserStore();
const { Events, pagination, loading, columns, updateEvents } = useEventTable();
//load on mounting page
onMounted(() => {
loading.value = true;
appApi
.post('database/open', { dbPath: databaseName.value, create: true })
.then(() => {
updateEvents();
})
.catch((err) => NotifyResponse(err, 'error'))
.finally(() => {
loading.value = false;
});
});
// opens dialog for all Event values
function openSingleValueDialog(label: string, field: string, Event: Event) {
editOneDialog.value?.open(label, field, Event);
}
//opens dialog for one value
function openAllValueDialog(Event: Event | null) {
editAllDialog.value?.open(Event);
}
//opens remove dialog
function openRemoveDialog(...Events: Events) {
if (Events.length === 1) {
deleteText.value = "'";
if (Events[0]?.name !== undefined) {
deleteText.value += Events[0]?.name + ' ';
}
deleteText.value += "'";
} else {
deleteText.value = String(Events.length) + ' ' + i18n.global.t('events');
}
okDialog.value?.open(Events);
}
function openAttendees(attendees: Members | null) {
attendeesDialog.value.open(attendees);
}
//remove Event from database
function removeEvent(...removeEvents: Events) {
const EventIds: number[] = [];
removeEvents.forEach((Event: Event) => {
EventIds.push(Event.id);
});
appApi
.post('events/delete', { ids: EventIds })
.then(() => {
updateEvents();
selected.value = [];
})
.catch((err) => NotifyResponse(err, 'error'))
.finally(() => {
loading.value = false;
});
}
</script>
<style>
@keyframes blink-yellow {
0%,
100% {
background-color: yellow;
}
50% {
background-color: transparent;
}
}
.blink-yellow {
animation: blink-yellow 1.5s step-start 6 !important;
}
.bigger-table-text .q-table__middle td {
font-size: 14px;
}
.bigger-table-text .q-table__top,
.bigger-table-text .q-table__bottom,
.bigger-table-text th {
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,61 @@
import { appApi } from 'src/boot/axios';
import { ref, computed } from 'vue';
import type { Members } from 'src/vueLib/models/member';
import { useNotify } from 'src/vueLib/general/useNotify';
import { i18n } from 'boot/lang';
export function useGroupTable() {
const groups = ref<Members>([]);
const pagination = ref({
sortBy: 'firstName',
descending: false,
page: 1,
rowsPerPage: 20,
});
const columns = computed(() => [
{
name: 'name',
align: 'left' as const,
label: i18n.global.t('name'),
field: 'name',
sortable: true,
},
{ name: 'option', align: 'center' as const, label: '', field: 'option', icon: 'option' },
]);
const { NotifyResponse } = useNotify();
const loading = ref(false);
//updates group list from database
async function updateGroups() {
loading.value = true;
await appApi
.get('groups')
.then((resp) => {
if (resp.data === null) {
groups.value = [];
return;
}
groups.value = resp.data as Members;
})
.catch((err) => {
NotifyResponse(err, 'error');
})
.finally(() => {
loading.value = false;
});
}
return {
groups,
pagination,
columns,
loading,
updateGroups,
};
}

View File

@@ -0,0 +1,267 @@
<template>
<div class="q-pa-md">
<q-table
flat
bordered
ref="tableRef"
title="groups"
title-class="text-bold text-blue-9"
:no-data-label="$t('noDataAvailable')"
:loading-label="$t('loading')"
:rows-per-page-label="$t('recordsPerPage')"
:selected-rows-label="(val) => val + ' ' + $t('recordSelected')"
:rows="groups"
:columns="columns"
row-key="id"
v-model:pagination="pagination"
:loading="loading"
:filter="filter"
:selection="selectOption ? 'multiple' : 'none'"
v-model:selected="selected"
binary-state-sort
class="bigger-table-text"
>
<template v-slot:top-left>
<q-btn-group push flat style="color: grey">
<q-btn
v-if="user.isPermittedTo('group', 'write')"
dense
flat
icon="add"
@click="openGroupDialog()"
>
<q-tooltip>{{ $t('addNewgroup') }}</q-tooltip>
</q-btn>
<q-btn
v-if="user.isPermittedTo('group', 'write') || user.isPermittedTo('group', 'delete')"
dense
flat
style="color: grey"
:icon="selectOption ? 'check_box' : 'check_box_outline_blank'"
@click="selectOption = !selectOption"
>
<q-tooltip>{{ $t('selectgroupOptions') }}</q-tooltip>
</q-btn>
</q-btn-group>
<div v-if="selectOption && selected.length > 0">
<q-btn flat dense icon="more_vert" @click="openSubmenu = true" />
<q-menu v-if="openSubmenu" anchor="bottom middle" self="top middle">
<q-item
v-if="user.isPermittedTo('group', 'delete')"
clickable
v-close-popup
@click="openRemoveDialog(...selected)"
class="text-negative"
>{{ $t('delete') }}</q-item
>
</q-menu>
</div>
<div v-if="selectOption && selected.length > 0" class="text-weight-bold">
{{ $t('selected') }}: {{ selected.length }}
</div>
</template>
<template v-slot:top-right>
<q-input filled dense debounce="300" v-model="filter" :placeholder="$t('search')">
<template v-slot:append>
<q-icon name="search" />
</template>
</q-input>
</template>
<template v-slot:body-cell="props">
<q-td
:props="props"
:style="user.isPermittedTo('group', 'write') ? 'cursor: pointer' : ''"
@click="
user.isPermittedTo('group', 'write') && openGroupDialog(props.col.label, props.row)
"
>
{{ props.value }}
</q-td>
</template>
<template v-slot:body-cell-option="props">
<q-td :props="props">
<q-btn
v-if="user.isPermittedTo('group', 'write') || user.isPermittedTo('group', 'delete')"
flat
dense
icon="more_vert"
@click="openSubmenu = true"
/>
<q-menu v-if="openSubmenu" anchor="top right" self="top left">
<q-item
v-if="user.isPermittedTo('group', 'delete')"
clickable
v-close-popup
@click="openRemoveDialog(props.row)"
class="text-negative"
title="zu"
>{{ $t('delete') }}</q-item
>
</q-menu>
</q-td>
</template>
</q-table>
</div>
<DialogFrame ref="groupDialog" :header-title="$t('addNewgroup')" :height="600" :width="500">
<q-form ref="form">
<div class="row justify-center q-gutter-md">
<q-input
class="q-ml-md col-5 required"
:label="$t('groupName')"
filled
:rules="[(val) => !!val || $t('groupNameIsRequired')]"
v-model="localGroup.name"
autofocus
@keyup.enter="save()"
></q-input>
</div>
</q-form>
<div class="row justify-center">
<q-btn class="q-ma-md" color="primary" no-caps @click="save()">{{ $t('save') }}</q-btn>
</div>
</DialogFrame>
<OkDialog
ref="okDialog"
:dialog-label="$t('delete')"
:text="$t('doYouWantToDelete') + ' ' + deleteText"
label-color="red"
:button-cancel-label="$t('cancel')"
:button-ok-label="$t('confirm')"
:button-ok-flat="false"
button-ok-color="red"
v-on:update-confirm="(val) => removegroup(...val)"
></OkDialog>
</template>
<script setup lang="ts">
import { appApi } from 'src/boot/axios';
import { ref, onMounted } from 'vue';
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import OkDialog from 'src/components/dialog/OkDialog.vue';
import { useNotify } from 'src/vueLib/general/useNotify';
import { useGroupTable } from './GroupTable';
import { databaseName } from 'src/vueLib/models/settings';
import { useUserStore } from 'src/vueLib/login/userStore';
import { i18n } from 'src/boot/lang';
import type { Group, Groups } from 'src/vueLib/models/group';
export interface groupDialog {
getSelected: () => Groups;
}
const { NotifyResponse } = useNotify();
const groupDialog = ref();
const form = ref();
const localGroup = ref<Group>({} as Group);
const localLabel = ref('');
const okDialog = ref();
const deleteText = ref('');
const selectOption = ref(false);
const selected = ref<Groups>([]);
const openSubmenu = ref(false);
const filter = ref('');
const user = useUserStore();
const { groups, pagination, loading, columns, updateGroups } = useGroupTable();
//load on mounting page
onMounted(() => {
loading.value = true;
appApi
.post('database/open', { dbPath: databaseName.value, create: true })
.then(() => {
updateGroups().catch((err) => {
NotifyResponse(err, 'error');
});
})
.catch((err) => NotifyResponse(err, 'error'))
.finally(() => {
loading.value = false;
});
});
//opens dialog for one value
function openGroupDialog(label?: string, group?: Group) {
localLabel.value = label!;
localGroup.value = group ? group : <Group>{ name: '' };
groupDialog.value?.open();
}
//opens remove dialog
function openRemoveDialog(...groups: Groups) {
if (groups.length === 1) {
deleteText.value = "'" + localGroup.value.name + "''";
} else {
deleteText.value = String(groups.length) + ' ' + i18n.global.t('groups');
}
okDialog.value?.open(groups);
}
//remove group from database
function removegroup(...removegroups: Groups) {
const groupIds: number[] = [];
removegroups.forEach((group: Group) => {
groupIds.push(group.id);
});
appApi
.post('groups/delete', { ids: groupIds })
.then(() => {
updateGroups().catch((err) => {
NotifyResponse(err, 'error');
});
selected.value = [];
})
.catch((err) => NotifyResponse(err, 'error'))
.finally(() => {
loading.value = false;
});
}
async function save() {
const valid = await form.value.validate();
if (!valid) return;
let query = 'groups/edit';
let payload = JSON.stringify([localGroup.value]);
if (!localGroup.value.id) {
query = 'groups/add';
payload = JSON.stringify(localGroup.value);
}
appApi
.post(query, payload)
.then(() => {
updateGroups().catch((err) => NotifyResponse(err, 'error'));
groupDialog.value.close();
})
.catch((err) => NotifyResponse(err, 'error'));
}
</script>
<style>
@keyframes blink-yellow {
0%,
100% {
background-color: yellow;
}
50% {
background-color: transparent;
}
}
.bigger-table-text .q-table__middle td {
font-size: 14px;
}
.bigger-table-text .q-table__top,
.bigger-table-text .q-table__bottom,
.bigger-table-text th {
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,358 @@
import { appApi } from 'src/boot/axios';
import { ref, computed } from 'vue';
import type { Member, Members } from 'src/vueLib/models/member';
import { useNotify } from 'src/vueLib/general/useNotify';
import { i18n } from 'boot/lang';
import { useResponsibleTable } from '../responsible/ResponsibleTable';
import { appName } from 'src/vueLib/models/settings';
import { useGroupTable } from '../group/GroupTable';
export function useMemberTable() {
const allMembers = ref<Members>([]);
const filteredMembers = ref<Members>([]);
const filterList = ref<
{
field: keyof Member;
keys: string[];
}[]
>();
const { responsibles, updateResponsibles } = useResponsibleTable();
const { groups, updateGroups } = useGroupTable();
const pagination = ref({
sortBy: 'firstName',
descending: false,
page: 1,
rowsPerPage: 20,
});
//add enabling of each columns
const enabledColumns = ref<Record<string, boolean>>({
cake: true,
firstName: true,
lastName: true,
birthday: true,
age: true,
address: true,
comment: true,
town: true,
zip: true,
phone: true,
email: true,
group: true,
responsiblePerson: true,
firstVisit: true,
lastVisit: true,
option: true,
});
const columns = computed(() =>
[
{ name: 'cake', align: 'center' as const, label: '', field: 'cake', icon: 'cake' },
{
name: 'firstName',
align: 'left' as const,
label: i18n.global.t('prename'),
field: 'firstName',
sortable: true,
},
{
name: 'lastName',
align: 'left' as const,
label: i18n.global.t('lastName'),
field: 'lastName',
sortable: true,
},
{
name: 'birthday',
align: 'left' as const,
label: i18n.global.t('birthday'),
field: 'birthday',
sortable: true,
},
{
name: 'age',
align: 'left' as const,
label: i18n.global.t('age'),
field: 'age',
sortable: true,
},
{
name: 'comment',
align: 'left' as const,
label: i18n.global.t('comment'),
field: 'comment',
sortable: true,
},
{
name: 'address',
align: 'left' as const,
label: i18n.global.t('address'),
field: 'address',
sortable: true,
},
{
name: 'town',
align: 'left' as const,
label: i18n.global.t('town'),
field: 'town',
sortable: true,
},
{
name: 'zip',
align: 'left' as const,
label: i18n.global.t('zipCode'),
field: 'zip',
sortable: true,
},
{
name: 'phone',
align: 'left' as const,
label: i18n.global.t('phone'),
field: 'phone',
sortable: true,
},
{
name: 'email',
align: 'left' as const,
label: i18n.global.t('email'),
field: 'email',
sortable: true,
},
{
name: 'group',
align: 'left' as const,
label: i18n.global.t('group'),
field: 'group',
sortable: true,
},
{
name: 'responsiblePerson',
align: 'left' as const,
label: i18n.global.t('responsible'),
field: 'responsiblePerson',
sortable: true,
},
{
name: 'firstVisit',
align: 'left' as const,
label: i18n.global.t('firstVisit'),
field: 'firstVisit',
sortable: true,
},
{
name: 'lastVisit',
align: 'left' as const,
label: i18n.global.t('lastVisit'),
field: 'lastVisit',
sortable: true,
},
{ name: 'option', align: 'center' as const, label: '', field: 'option', icon: 'option' },
].filter((c) => enabledColumns.value[c.name]),
);
const { NotifyResponse } = useNotify();
const loading = ref(false);
function calculateAge(birthDateString: string) {
if (birthDateString === undefined) return 0;
const [day, month, year] = birthDateString.split('.').map(Number); // for format "1.2.2000"
if (year === undefined || month === undefined || day === undefined) return 0;
const birthDate = new Date(year, month - 1, day); // month is 0-based
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
// Check if birthday has occurred yet this year
const hasBirthdayPassed =
today.getMonth() > birthDate.getMonth() ||
(today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate());
if (!hasBirthdayPassed) {
age--;
}
return age;
}
//isThreeDaysBeforeAnnualDate check if upcoming date is less than 3 days from now
function isXDaysBeforeAnnualDate(dateString: string, before: number) {
if (dateString === undefined) return;
const [day, month] = dateString.split('.').map(Number);
const today = new Date();
today.setHours(0, 0, 0);
const annualDate = new Date(today.getFullYear(), month ? month - 1 : 0, day);
annualDate.setHours(0, 0, 1);
const xDaysBefore = new Date(annualDate);
xDaysBefore.setDate(annualDate.getDate() - before);
return today >= xDaysBefore && today <= annualDate;
}
function getRowClass(row: Member) {
if (!row.birthday) return '';
if (isXDaysBeforeAnnualDate(row.birthday, 1)) {
return 'bg-red-2 text-red-10';
} else if (isXDaysBeforeAnnualDate(row.birthday, 4)) {
return 'bg-green-2 text-green-10';
} else if (isXDaysBeforeAnnualDate(row.birthday, 8)) {
return 'bg-amber-2 text-amber-10';
}
return '';
}
//updates member list from database
async function updateMembers(filter?: Members, filterbyName?: boolean) {
loading.value = true;
await updateResponsibles().catch((err) => NotifyResponse(err, 'error'));
await updateGroups().catch((err) => NotifyResponse(err, 'error'));
await appApi
.get('members')
.then((resp) => {
if (resp.data === null) {
allMembers.value = [];
return;
}
allMembers.value = resp.data as Members;
if (allMembers.value === null) {
allMembers.value = [];
return;
}
allMembers.value.forEach((member) => {
if (!responsibles.value.some((r) => r.id === member.responsiblePerson?.id)) {
delete member.responsiblePerson;
}
if (member.birthday !== undefined) {
member.age = String(calculateAge(member.birthday));
}
});
})
.catch((err) => {
NotifyResponse(err, 'error');
})
.finally(() => {
loading.value = false;
//filter same members out so list is shorter
if (filter) {
filteredMembers.value = allMembers.value.filter(
(m1) =>
!filter.some((m2) => {
if (filterbyName) {
return m1.firstName === m2.firstName && m1.lastName === m2.lastName;
}
return m1.id === m2.id;
}),
);
}
//second filter
const list = filterList.value ?? [];
if (filterList.value && filterList.value.length > 0) {
filteredMembers.value = allMembers.value.filter((member) =>
list.every((filterItem) => {
const keys = filterItem.keys ?? [];
if (keys.includes('null')) return true;
if (keys.length === 0) return true;
const value = member[filterItem.field];
if (value === undefined || value === null) {
return keys.includes('None');
}
if (typeof value === 'number') {
return keys.includes(value.toString());
}
if (typeof value === 'string') {
return keys.includes(value);
}
return false;
}),
);
return;
}
filteredMembers.value = allMembers.value;
});
}
function setNewFilter(field: string, ...keys: string[]) {
filterList.value = [
{
field: field as keyof Member,
keys: keys.flat().map((k) => String(k)),
},
];
}
function disableColumns(...columns: string[]) {
columns.forEach((col) => {
if (col in enabledColumns.value) {
enabledColumns.value[col] = false;
}
});
}
function exportCsv() {
const comma = ';';
// Extract only columns that have a field (not icons/options)
const exportableColumns = columns.value.filter(
(col) => typeof col.field === 'string' && col.field !== 'cake' && col.field !== 'option',
) as { field: keyof Member; label: string }[];
// Build CSV header row
const header = exportableColumns.map((col) => col.field).join(comma);
// Build CSV rows
const data = allMembers.value.map((member) =>
exportableColumns
.map((col) => {
const value = member[col.field];
// handle nested objects (e.g. responsiblePerson)
if (typeof value === 'object' && value !== null) {
if ('firstName' in value && 'lastName' in value)
return `"${value.firstName} ${value.lastName}"`;
return `"${JSON.stringify(value)}"`;
}
return `"${value ?? ''}"`;
})
.join(comma),
);
// Combine into CSV string
const csv = [header, ...data].join('\n');
// Create blob and trigger download
const BOM = '\uFEFF';
const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', i18n.global.t(appName.value) + '.csv');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
return {
allMembers,
filteredMembers,
responsibles,
groups,
pagination,
columns,
loading,
getRowClass,
updateMembers,
setNewFilter,
isXDaysBeforeAnnualDate,
disableColumns,
exportCsv,
};
}

View File

@@ -0,0 +1,538 @@
<template>
<div class="q-pa-md">
<q-table
flat
bordered
ref="tableRef"
title="Members"
title-class="text-bold text-blue-9"
:no-data-label="$t('noDataAvailable')"
:loading-label="$t('loading')"
:rows-per-page-label="$t('recordsPerPage')"
:selected-rows-label="(val) => val + ' ' + $t('recordSelected')"
:rows="filteredMembers"
:columns="columns"
row-key="id"
v-model:pagination="pagination"
:loading="loading"
:filter="filter"
:selection="selectOption ? 'multiple' : 'none'"
v-model:selected="selected"
binary-state-sort
class="bigger-table-text"
>
<template v-slot:top-left>
<div>
<q-btn-group push flat style="color: grey">
<q-btn
v-if="user.isPermittedTo('members', 'write')"
dense
flat
icon="add"
@click="openAllValueDialog(null)"
>
<q-tooltip>{{ $t('addNewMember') }}</q-tooltip>
</q-btn>
<q-btn
v-if="
user.isPermittedTo('members', 'write') || user.isPermittedTo('members', 'delete')
"
dense
flat
style="color: grey"
:icon="selectOption ? 'check_box' : 'check_box_outline_blank'"
@click="selectOption = !selectOption"
>
<q-tooltip>{{ $t('selectMemberOptions') }}</q-tooltip>
</q-btn>
<q-btn
v-if="user.isPermittedTo('members', 'import')"
dense
flat
icon="upload"
@click="openUploadDialog"
>
<q-tooltip>{{ $t('importCSV') }}</q-tooltip>
</q-btn>
<q-btn
v-if="user.isPermittedTo('members', 'export')"
dense
flat
icon="download"
@click="exportCsv"
>
<q-tooltip>{{ $t('exportCSV') }}</q-tooltip>
</q-btn>
</q-btn-group>
<div v-if="selectOption && selected.length > 0">
<q-btn
v-if="inProps.addAttendees || inProps.addResponsible"
dense
color="grey-7"
flat
icon="person"
@click="addMemberTo"
>
<q-badge floating transparent color="primary" text-color="primary-text">+</q-badge>
</q-btn>
<q-btn v-else flat dense icon="more_vert" @click="openSubmenu = true" />
<q-menu v-if="openSubmenu" anchor="bottom middle" self="top middle">
<q-item clickable v-close-popup @click="addToEvent" class="text-primary">{{
$t('addToEvent')
}}</q-item>
<q-item
v-if="user.isPermittedTo('members', 'delete')"
clickable
v-close-popup
@click="openRemoveDialog(...selected)"
class="text-negative"
>{{ $t('delete') }}</q-item
>
</q-menu>
</div>
<q-card flat class="q-pa-sm">
<q-select
:label="$t('filterByColumn')"
dense
v-model="selectedColumnFilter"
option-label="label"
option-value="name"
map-options
emit-value
clearable
:options="columns.filter((col) => col.label !== '')"
v-on:clear="selectedColumnOptions = []"
@update:model-value="
filterMembers(selectedColumnFilter, ...(selectedColumnOptions || []))
"
class="q-mt-xs"
/>
<q-select
v-if="selectedColumnFilter"
:label="$t('filterByColumnValue')"
dense
v-model="selectedColumnOptions"
:options="setColumnOptions(selectedColumnFilter)"
class="q-mt-xs"
multiple
clearable
@update:model-value="
filterMembers(selectedColumnFilter, ...(selectedColumnOptions || []))
"
/>
</q-card>
</div>
<div v-if="selectOption && selected.length > 0" class="text-weight-bold">
{{ $t('selected') }}: {{ selected.length }}
</div>
</template>
<template v-slot:top-right>
<q-input filled dense debounce="300" v-model="filter" :placeholder="$t('search')">
<template v-slot:append>
<q-icon name="search" />
</template>
</q-input>
</template>
<template v-slot:body-cell="props">
<q-td
:props="props"
:class="getRowClass(props.row)"
:style="user.isPermittedTo('members', 'write') ? 'cursor: pointer' : ''"
@click="
user.isPermittedTo('members', 'write') &&
openSingleValueDialog(props.col.label, props.col.name, props.row)
"
>
{{ props.value }}
</q-td>
</template>
<template v-slot:body-cell-cake="props">
<q-td :props="props" :class="getRowClass(props.row)">
<q-icon
:name="isXDaysBeforeAnnualDate(props.row.birthday, 1) ? 'cake' : ''"
:color="'red'"
size="md"
/>
</q-td>
</template>
<template v-slot:body-cell-group="props">
<q-td :props="props">
<q-select
v-if="groups.length > 0"
:readonly="!user.isPermittedTo('members', 'write')"
:options="groups"
emit-value
map-options
option-value="name"
option-label="name"
v-model="props.row.group"
@update:model-value="updateMember(props.row)"
></q-select>
</q-td>
</template>
<template v-slot:body-cell-responsiblePerson="props">
<q-td :props="props">
<q-select
v-if="responsibles.length > 0"
:readonly="!user.isPermittedTo('members', 'write')"
:options="responsibles"
:option-label="(opt) => opt.firstName + ' ' + opt.lastName"
v-model="props.row.responsiblePerson"
@update:model-value="updateMember(props.row)"
></q-select>
</q-td>
</template>
<template v-slot:body-cell-option="props">
<q-td :props="props">
<q-btn
v-if="user.isPermittedTo('members', 'write') || user.isPermittedTo('members', 'delete')"
flat
dense
icon="more_vert"
@click="openSubmenu = true"
/>
<q-menu v-if="openSubmenu" anchor="top right" self="top left">
<q-item
v-if="user.isPermittedTo('members', 'write')"
clickable
v-close-popup
@click="openAllValueDialog(props.row)"
class="text-primary"
>{{ $t('edit') }}</q-item
>
<q-item
v-if="
user.isPermittedTo('members', 'delete') &&
!inProps.addAttendees &&
!inProps.addResponsible
"
clickable
v-close-popup
@click="openRemoveDialog(props.row)"
class="text-negative"
title="zu"
>{{ $t('delete') }}</q-item
>
</q-menu>
</q-td>
</template>
</q-table>
</div>
<EditOneDialog
ref="editOneDialog"
endpoint="members/edit"
query-id
v-on:update="updateMember"
></EditOneDialog>
<EditAllDialog
ref="editAllDialog"
:responsibles="responsibles"
v-on:update="updateMember"
></EditAllDialog>
<OkDialog
ref="okDialog"
:dialog-label="$t('delete')"
:text="$t('doYouWantToDelete') + ' ' + deleteText"
label-color="red"
:button-cancel-label="$t('cancel')"
:button-ok-label="$t('confirm')"
:button-ok-flat="false"
button-ok-color="red"
v-on:update-confirm="(val) => removeMember(...val)"
></OkDialog>
<UploadDialog ref="uploadDialog" @update-upload="updateMembers"> </UploadDialog>
<AddToEvent
ref="addToEventDialog"
endpoint="events/add/attendees"
v-on:update-event="(val) => updateMemberLastVisit(val)"
/>
</template>
<script setup lang="ts">
import { appApi } from 'src/boot/axios';
import { ref, onMounted, type PropType } from 'vue';
import type { Member, Members } from 'src/vueLib/models/member';
import EditOneDialog from 'src/components/EditOneDialog.vue';
import EditAllDialog from 'src/components/MemberEditAllDialog.vue';
import OkDialog from 'src/components/dialog/OkDialog.vue';
import { useNotify } from 'src/vueLib/general/useNotify';
import { useMemberTable } from './MembersTable';
import UploadDialog from 'src/components/UploadDialog.vue';
import AddToEvent from 'src/components/AddToEvent.vue';
import { databaseName } from 'src/vueLib/models/settings';
import { useUserStore } from 'src/vueLib/login/userStore';
import { i18n } from 'src/boot/lang';
import { getLocalPageDefaults, setLocalPageDefaults } from 'src/localstorage/localStorage';
const inProps = defineProps({
addAttendees: { type: Boolean },
addResponsible: { type: Boolean },
eventId: { type: Number },
compareMembers: { type: Object as PropType<Members> },
});
export interface MemberDialog {
getSelected: () => Members;
}
const emit = defineEmits(['update-event']);
const { NotifyResponse } = useNotify();
const editOneDialog = ref();
const editAllDialog = ref();
const addToEventDialog = ref();
const uploadDialog = ref();
const okDialog = ref();
const deleteText = ref('');
const selectOption = ref(false);
const selected = ref<Members>([]);
const openSubmenu = ref(false);
const filter = ref('');
const user = useUserStore();
const localCompareMembers = ref<Members>();
const selectedColumnFilter = ref<string>('');
const selectedColumnOptions = ref<string[]>([]);
const page = ref<string>('members');
const {
allMembers,
filteredMembers,
responsibles,
groups,
pagination,
loading,
columns,
getRowClass,
updateMembers,
setNewFilter,
isXDaysBeforeAnnualDate,
disableColumns,
exportCsv,
} = useMemberTable();
//load on mounting page
onMounted(() => {
page.value = 'members';
if (inProps.addAttendees || inProps.addResponsible) {
selectOption.value = true;
disableColumns(
'birthday',
'age',
'comment',
'town',
'zip',
'email',
'address',
'phone',
'responsiblePerson',
'firstVisit',
'lastVisit',
);
page.value = 'attendance';
}
loading.value = true;
localCompareMembers.value = inProps.compareMembers;
const defaults = getLocalPageDefaults(page.value);
selectedColumnFilter.value = defaults?.filteredColumn || '';
selectedColumnOptions.value = defaults?.filteredValue ?? [];
setNewFilter(selectedColumnFilter.value, ...selectedColumnOptions.value);
appApi
.post('database/open', { dbPath: databaseName.value, create: true })
.then(() => {
updateMembers(inProps.compareMembers, inProps.addResponsible).catch((err) =>
NotifyResponse(err, 'error'),
);
})
.catch((err) => NotifyResponse(err, 'error'))
.finally(() => {
loading.value = false;
});
});
// opens dialog for all member values
function openSingleValueDialog(label: string, field: string, member: Member) {
editOneDialog.value?.open(label, field, member);
}
//opens dialog for one value
function openAllValueDialog(member: Member | null) {
editAllDialog.value?.open(member);
}
//opens remove dialog
function openRemoveDialog(...members: Members) {
if (members.length === 1) {
deleteText.value = "'";
if (members[0]?.firstName !== undefined) {
deleteText.value += members[0]?.firstName + ' ';
}
if (members[0]?.lastName !== undefined) {
deleteText.value += members[0]?.lastName;
}
deleteText.value += "'";
} else {
deleteText.value = String(members.length) + ' ' + i18n.global.t('members');
}
okDialog.value?.open(members);
}
//opens uploader dialog
function openUploadDialog() {
uploadDialog.value?.open();
}
function setColumnOptions(columnName: string) {
const values = allMembers.value
.map((e) => e[columnName as keyof Member]) // could be undefined
.filter((v): v is string | number => v !== null && v !== undefined)
.map((v) => String(v));
const selection = [...new Set(values)];
// Add special option for missing/null/empty values
if (allMembers.value.some((e) => !e[columnName as keyof Member])) {
selection.unshift('None');
}
return selection;
}
async function filterMembers(field: string, ...keys: string[]) {
setNewFilter(field, ...keys);
setLocalPageDefaults(page.value, field, keys);
await updateMembers();
}
//remove member from database
function removeMember(...removeMembers: Members) {
const memberIds: number[] = [];
removeMembers.forEach((member: Member) => {
memberIds.push(member.id);
});
appApi
.post('members/delete', { ids: memberIds })
.then(() => {
updateMembers().catch((err) => NotifyResponse(err, 'error'));
selected.value = [];
})
.catch((err) => NotifyResponse(err, 'error'))
.finally(() => {
loading.value = false;
});
}
function updateMember(member: Member | null) {
if (!member) NotifyResponse(i18n.global.t('memberUpdated'));
appApi
.post('/members/edit', [member])
.then(() => NotifyResponse(i18n.global.t('memberUpdated')))
.catch((err) => NotifyResponse(err, 'error'));
updateMembers().catch((err) => NotifyResponse(err, 'error'));
}
function addToEvent() {
addToEventDialog.value?.open(i18n.global.t('addToEvent'), selected.value);
}
async function addMemberTo() {
let query = '';
let payload = {};
let notificationSingular = '';
let notificationPlural = '';
if (inProps.addAttendees) {
query = 'events/add/attendees';
payload = {
id: inProps.eventId,
attendees: [...selected.value],
};
notificationSingular = 'attendeeAdded';
notificationPlural = 'attendeesAdded';
} else if (inProps.addResponsible) {
query = 'responsible/add';
payload = selected.value;
notificationSingular = 'responsibleAdded';
notificationPlural = 'responsiblesAdded';
}
await appApi
.post(query, payload)
.then(() => {
if (selected.value.length > 1) {
NotifyResponse(i18n.global.t(notificationSingular));
} else {
NotifyResponse(i18n.global.t(notificationPlural));
}
})
.catch((err) => {
NotifyResponse(err, 'error');
});
if (inProps.addAttendees) {
await updateMemberLastVisit(selected.value);
}
emit('update-event');
}
async function updateMemberLastVisit(members: Members) {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0'); // Months are 0-based
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const dateTimeNow = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
members.forEach((mem) => {
mem.lastVisit = dateTimeNow;
});
await appApi
.post('members/edit', members)
.then(() => {
if (members.length > 1) {
NotifyResponse(i18n.global.t('membersUpdated'));
} else {
NotifyResponse(i18n.global.t('memberUpdated'));
}
})
.catch((err) => NotifyResponse(err, 'error'));
await updateMembers(localCompareMembers.value, inProps.addResponsible)
.then(() => localCompareMembers.value?.push(...members))
.catch((err) => NotifyResponse(err, 'error'));
emit('update-event');
}
</script>
<style>
@keyframes blink-yellow {
0%,
100% {
background-color: yellow;
}
50% {
background-color: transparent;
}
}
.blink-yellow {
animation: blink-yellow 1.5s step-start 6 !important;
}
.bigger-table-text .q-table__middle td {
font-size: 14px;
}
.bigger-table-text .q-table__top,
.bigger-table-text .q-table__bottom,
.bigger-table-text th {
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,68 @@
import { appApi } from 'src/boot/axios';
import { ref, computed } from 'vue';
import type { Members } from 'src/vueLib/models/member';
import { useNotify } from 'src/vueLib/general/useNotify';
import { i18n } from 'boot/lang';
export function useResponsibleTable() {
const responsibles = ref<Members>([]);
const pagination = ref({
sortBy: 'firstName',
descending: false,
page: 1,
rowsPerPage: 20,
});
const columns = computed(() => [
{
name: 'firstName',
align: 'left' as const,
label: i18n.global.t('prename'),
field: 'firstName',
sortable: true,
},
{
name: 'lastName',
align: 'left' as const,
label: i18n.global.t('lastName'),
field: 'lastName',
sortable: true,
},
{ name: 'option', align: 'center' as const, label: '', field: 'option', icon: 'option' },
]);
const { NotifyResponse } = useNotify();
const loading = ref(false);
//updates responsible list from database
async function updateResponsibles() {
loading.value = true;
await appApi
.get('responsible')
.then((resp) => {
if (resp.data === null) {
responsibles.value = [];
return;
}
responsibles.value = resp.data as Members;
})
.catch((err) => {
NotifyResponse(err, 'error');
})
.finally(() => {
loading.value = false;
});
}
return {
responsibles,
pagination,
columns,
loading,
updateResponsibles,
};
}

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