46 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
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
79 changed files with 3119 additions and 576 deletions

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

1
.gitignore vendored
View File

@@ -36,6 +36,7 @@ yarn-error.log*
# local .db files # local .db files
*.db *.db
*.dba
# local .log files # local .log files
*.log *.log

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

View File

@@ -3,17 +3,18 @@ module backend
go 1.24.5 go 1.24.5
require ( require (
gitea.tecamino.com/paadi/access-handler v1.0.12 gitea.tecamino.com/paadi/access-handler v1.0.25
gitea.tecamino.com/paadi/memberDB v1.0.4 gitea.tecamino.com/paadi/memberDB v1.1.3
gitea.tecamino.com/paadi/tecamino-dbm v0.1.1 gitea.tecamino.com/paadi/tecamino-dbm v0.1.1
gitea.tecamino.com/paadi/tecamino-logger v0.2.1 gitea.tecamino.com/paadi/tecamino-logger v0.2.1
github.com/gin-contrib/cors v1.7.6 github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.43.0 golang.org/x/crypto v0.43.0
) )
require ( require (
gitea.tecamino.com/paadi/dbHandler v1.0.4 // indirect gitea.tecamino.com/paadi/dbHandler v1.0.8 // indirect
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect

View File

@@ -1,9 +1,9 @@
gitea.tecamino.com/paadi/access-handler v1.0.12 h1:lSmW0YrBJJvCqCg0ukTJHlFUNwd7q6hFYtNd2rfztrE= gitea.tecamino.com/paadi/access-handler v1.0.25 h1:GiMnkEM0/fo2B1uCzGVyjpAhM2S58LG22N6+BdtdpgQ=
gitea.tecamino.com/paadi/access-handler v1.0.12/go.mod h1:w71lpnuu5MgAWG3oiI9vsY2dWi4njF/iPrM/xV/dbBQ= 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.4 h1:ctnaec0GDdtw3gRQdUISVDYLJ9x+vt50VW41OemfhD4= gitea.tecamino.com/paadi/dbHandler v1.0.8 h1:ZWSBM/KFtLwTv2cBqwK1mOxWAxAfL0BcWEC3kJ9JALU=
gitea.tecamino.com/paadi/dbHandler v1.0.4/go.mod h1:y/xn/POJg1DO++67uKvnO23lJQgh+XFQq7HZCS9Getw= gitea.tecamino.com/paadi/dbHandler v1.0.8/go.mod h1:y/xn/POJg1DO++67uKvnO23lJQgh+XFQq7HZCS9Getw=
gitea.tecamino.com/paadi/memberDB v1.0.4 h1:2H7obSoMq4dW+VN8PsDOv1UK75hCtlF+NymU5NBGuw4= gitea.tecamino.com/paadi/memberDB v1.1.3 h1:ZwSA+TNL1ZvL8bMnJ5a2odc44bQBa31gVxD2fBA6o0I=
gitea.tecamino.com/paadi/memberDB v1.0.4/go.mod h1:iLm7nunVRzqJK8CV4PJVuWIhgPlQjNIaeOkmtfK5fMg= 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 h1:vAq7mwUxlxJuLzCQSDMrZCwo8ky5usWi9Qz+UP+WnkI=
gitea.tecamino.com/paadi/tecamino-dbm v0.1.1/go.mod h1:+tmf1rjPaKEoNeUcr1vdtoFIFweNG3aUGevDAl3NMBk= 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 h1:sQTBKYPdzn9mmWX2JXZBtGBvNQH7cuXIwsl4TD0aMgE=
@@ -56,6 +56,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"backend/env"
"backend/models" "backend/models"
"backend/server" "backend/server"
"backend/utils" "backend/utils"
@@ -23,24 +24,37 @@ import (
) )
func main() { func main() {
var allowOrigins models.StringSlice // set cli flage
envFile := flag.String("env", ".env", "enviroment file")
flag.Var(&allowOrigins, "allowOrigin", "Allowed origin (can repeat this flag)")
spa := flag.String("spa", "./dist/spa", "quasar spa files")
workingDir := flag.String("workingDirectory", ".", "quasar spa files")
ip := flag.String("ip", "0.0.0.0", "server listening ip")
organization := flag.String("organization", "", "self signed ciertificate organization")
port := flag.Uint("port", 9500, "server listening port")
https := flag.Bool("https", false, "serves as https needs flag -cert and -chain")
sslCert := flag.String("cert", "", "ssl certificate path")
sslChain := flag.String("chain", "", "ssl chain path")
debug := flag.Bool("debug", false, "log debug")
flag.Parse() 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 //change working directory only if value is given
if *workingDir != "." && *workingDir != "" { if workingDir != "." && workingDir != "" {
os.Chdir(*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() wd, err := os.Getwd()
@@ -55,7 +69,7 @@ func main() {
MaxSize: 1, MaxSize: 1,
MaxBackup: 3, MaxBackup: 3,
MaxAge: 28, MaxAge: 28,
Debug: *debug, Debug: env.InDebugMode(),
TerminalOut: true, TerminalOut: true,
}) })
if err != nil { if err != nil {
@@ -82,11 +96,11 @@ func main() {
//get local ip //get local ip
httpString := "http://" httpString := "http://"
if *https { if env.Https.GetBoolValue() {
httpString = "https://" httpString = "https://"
} }
allowOrigins = append(allowOrigins, httpString+"localhost:9000", httpString+"localhost:9500", httpString+"127.0.0.1:9500", httpString+"0.0.0.0:9500") 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() localIP, err := utils.GetLocalIP()
if err != nil { if err != nil {
@@ -95,6 +109,8 @@ func main() {
allowOrigins = append(allowOrigins, fmt.Sprintf("%s%s:9000", httpString, localIP), fmt.Sprintf("%s%s:9500", httpString, localIP)) 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{ s.Routes.Use(cors.New(cors.Config{
AllowOrigins: allowOrigins, AllowOrigins: allowOrigins,
//AllowOrigins: []string{"*"}, //AllowOrigins: []string{"*"},
@@ -121,28 +137,45 @@ func main() {
role := auth.Group("", accessHandler.AuthorizeRole("/api")) role := auth.Group("", accessHandler.AuthorizeRole("/api"))
role.GET("/members", dbHandler.GetMember) role.GET("/members", dbHandler.GetMember)
auth.GET("/events", dbHandler.GetEvent)
auth.GET("/groups", dbHandler.GetGroup)
auth.GET("/users", accessHandler.GetUser) auth.GET("/users", accessHandler.GetUser)
auth.GET("/roles", accessHandler.GetRole) auth.GET("/roles", accessHandler.GetRole)
auth.POST("database/open", dbHandler.OpenDatabase) auth.POST("database/open", dbHandler.OpenDatabase)
auth.POST("/members/add", dbHandler.AddNewMember) auth.POST("/members/add", dbHandler.AddNewMember)
auth.POST("/members/edit", dbHandler.EditMember) auth.POST("/members/edit", dbHandler.UpdateMember)
auth.POST("/members/delete", dbHandler.DeleteMember) auth.POST("/members/delete", dbHandler.DeleteMember)
auth.POST("/members/import/csv", dbHandler.ImportCSV) 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/add", accessHandler.AddRole)
auth.POST("/roles/update", accessHandler.UpdateRole) auth.POST("/roles/update", accessHandler.UpdateRole)
auth.POST("/roles/delete", accessHandler.DeleteRole) auth.POST("/roles/delete", accessHandler.DeleteRole)
auth.POST("/users/add", accessHandler.AddUser) auth.POST("/users/add", accessHandler.AddUser)
auth.POST("/users/update", accessHandler.UpdateUser) auth.POST("/users/update", accessHandler.UpdateUser)
auth.POST("/users/new/password", accessHandler.ChangePassword)
auth.POST("/users/delete", accessHandler.DeleteUser) auth.POST("/users/delete", accessHandler.DeleteUser)
api.POST("/login/refresh", accessHandler.Refresh) api.POST("/login/refresh", accessHandler.Refresh)
// Serve static files // Serve static files
s.Routes.StaticFS("/assets", gin.Dir(filepath.Join(*spa, "assets"), true)) s.Routes.StaticFS("/assets", gin.Dir(filepath.Join(spa, "assets"), true))
s.Routes.NoRoute(func(c *gin.Context) { s.Routes.NoRoute(func(c *gin.Context) {
// Disallow fallback for /api paths // Disallow fallback for /api paths
if strings.HasPrefix(c.Request.URL.Path, "/api") { if strings.HasPrefix(c.Request.URL.Path, "/api") {
@@ -150,44 +183,44 @@ func main() {
return return
} }
// Try to serve file from SPA directory // Try to serve file from SPA directory
filePath := filepath.Join(*spa, c.Request.URL.Path) filePath := filepath.Join(spa, c.Request.URL.Path)
if _, err := os.Stat(filePath); err == nil { if _, err := os.Stat(filePath); err == nil {
c.File(filePath) c.File(filePath)
return return
} }
// Fallback to index.html for SPA routing // Fallback to index.html for SPA routing
c.File(filepath.Join(*spa, "index.html")) c.File(filepath.Join(spa, "index.html"))
}) })
go func() { go func() {
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
if err := utils.OpenBrowser(fmt.Sprintf("%slocalhost:%d", httpString, *port), logger); err != nil { 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)) logger.Error("main", fmt.Sprintf("starting browser error : %s", err))
} }
}() }()
if *https { if env.Https.GetBoolValue() {
if *sslCert == "" { if env.Fullchain.GetValue() == "" {
logger.Error("ssl certificate", "-cert flag not given for https server") logger.Error("ssl certificate", "-cert flag not given for https server")
log.Fatal("-cert flag not given for https server") log.Fatal("-cert flag not given for https server")
} }
if *sslChain == "" { if env.PrivKey.GetValue() == "" {
logger.Error("ssl key", "-chain flag not given for https server") logger.Error("ssl key", "-chain flag not given for https server")
log.Fatal("-chain flag not given for https server") log.Fatal("-chain flag not given for https server")
} }
// start https server // start https server
logger.Info("main", fmt.Sprintf("https listen on ip: %s port: %d", *ip, *port)) logger.Info("main", fmt.Sprintf("https listen on ip: %s port: %s", env.HostUrl.GetValue(), env.HostPort.GetValue()))
if err := s.ServeHttps(*ip, *port, cert.Cert{Organization: *organization, CertFile: *sslCert, KeyFile: *sslChain}); err != nil { 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()) logger.Error("main", "error https server "+err.Error())
} }
return return
} }
// start http server // start http server
logger.Info("main", fmt.Sprintf("http listen on ip: %s port: %d", *ip, *port)) logger.Info("main", fmt.Sprintf("http listen on ip: %s port: %s", env.HostUrl.GetValue(), env.HostPort.GetValue()))
if err := s.ServeHttp(*ip, *port); err != nil { if err := s.ServeHttp(env.HostUrl.GetValue(), env.HostPort.GetUIntValue()); err != nil {
logger.Error("main", "error http server "+err.Error()) logger.Error("main", "error http server "+err.Error())
} }
} }

Binary file not shown.

View File

@@ -1,6 +0,0 @@
package models
// type Rights struct {
// Name string `json:"name"`
// Rights int `json:"rights"`
// }

View File

@@ -1,11 +0,0 @@
package models
// type Role struct {
// Id int `json:"id"`
// Role string `json:"role"`
// Rights []Rights `json:"rights"`
// }
// func (r *Role) IsValid() bool {
// return r.Role != ""
// }

View File

@@ -1,11 +0,0 @@
package models
// type Settings struct {
// PrimaryColor string `json:"primaryColor,omitempty"`
// PrimaryColorText string `json:"primaryColorText,omitempty"`
// SecondaryColor string `json:"secondaryColor,omitempty"`
// SecondaryColorText string `json:"secondaryColorText,omitempty"`
// Icon string `json:"icon,omitempty"`
// DatabaseName string `json:"databaseName,omitempty"`
// DatabaseToken string `json:"databaseToken,omitempty"`
// }

View File

@@ -4,11 +4,10 @@ import "strings"
type StringSlice []string type StringSlice []string
func (s *StringSlice) String() string { func (s *StringSlice) Set(value string) {
return strings.Join(*s, ",") if strings.Contains(value, ",") {
} *s = append(*s, strings.Split(value, ",")...)
return
func (s *StringSlice) Set(value string) error { }
*s = append(*s, value) *s = append(*s, value)
return nil
} }

View File

@@ -1,14 +0,0 @@
package models
// type User struct {
// Id int `json:"id"`
// Name string `json:"user"`
// Email string `json:"email"`
// Role string `json:"role"`
// Password string `json:"password,omitempty"`
// Settings Settings `json:"settings"`
// }
// func (u *User) IsValid() bool {
// return u.Name != ""
// }

View File

@@ -1,19 +1,22 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<title><%= productName %></title> <title><%= productName %></title>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="description" content="<%= productDescription %>"> <meta name="description" content="<%= productDescription %>" />
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="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<% } %>"> <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="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="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="32x32" href="icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png" />
<link rel="icon" type="image/ico" href="favicon.ico"> <link rel="icon" type="image/ico" href="favicon.ico" />
</head> </head>
<body> <body>
<!-- quasar:entry-point --> <!-- quasar:entry-point -->

View File

@@ -1,8 +1,8 @@
{ {
"name": "lightcontrol", "name": "lightcontrol",
"version": "1.0.4", "version": "1.1.1",
"description": "A Tecamino App", "description": "A Tecamino App",
"productName": "Member Database", "productName": "Attendence Records",
"author": "A. Zuercher", "author": "A. Zuercher",
"type": "module", "type": "module",
"private": true, "private": true,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -11,7 +11,7 @@ export default defineConfig((/* ctx */) => {
// app boot file (/src/boot) // app boot file (/src/boot)
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files // https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: ['auth', 'axios', 'lang', 'quasar-global'], boot: ['auth', 'axios', 'lang', 'quasar-global', 'restore-route'],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
css: ['app.scss'], css: ['app.scss'],

View File

@@ -4,6 +4,7 @@ lastName: Nachname
birthday: Geburtstag birthday: Geburtstag
email: Email email: Email
group: Gruppe group: Gruppe
groups: Gruppen
age: Auter age: Auter
address: Adresse address: Adresse
town: Ort town: Ort
@@ -15,6 +16,7 @@ lastVisit: Letscht Bsuech
search: Suechi search: Suechi
noDataAvailable: Keni Date noDataAvailable: Keni Date
importCSV: importier CSV importCSV: importier CSV
exportCSV: exportier CSV
selectMemberOptions: Wähle Mitglieder Optione selectMemberOptions: Wähle Mitglieder Optione
addNewMember: Neues Mitglied addNewMember: Neues Mitglied
csvOptions: CSV Optionen csvOptions: CSV Optionen
@@ -32,6 +34,7 @@ settings: Iistellige
databaseName: Datebank Name databaseName: Datebank Name
token: Schlüssu token: Schlüssu
login: Amäude login: Amäude
logout: Abmäude
user: Benutzer user: Benutzer
password: Passwort password: Passwort
isRequired: isch erforderlich isRequired: isch erforderlich
@@ -61,7 +64,9 @@ emailIsRequired: Email isch erforderlich
roleIsRequired: Rolle isch erforderlich roleIsRequired: Rolle isch erforderlich
permissions: Recht permissions: Recht
selectRoleOptions: Wähle Roue Optione selectRoleOptions: Wähle Roue Optione
selectEventOptions: Wähle Verastautigs Optione
addNewRole: Füeg neui Roue hinzue addNewRole: Füeg neui Roue hinzue
addNewEvent: Füeg neui Verastautig hinzue
veryWeak: sehr Schwach veryWeak: sehr Schwach
weak: Schwach weak: Schwach
fair: So so fair: So so
@@ -81,3 +86,48 @@ members: Mitglider
attendanceTable: Anweseheits Tabelle attendanceTable: Anweseheits Tabelle
excursionTable: Usflugs Tabelle excursionTable: Usflugs Tabelle
updated: aktualisiert 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

View File

@@ -4,6 +4,7 @@ lastName: Nachname
birthday: Geburtstag birthday: Geburtstag
email: Email email: Email
group: Gruppe group: Gruppe
groups: Gruppen
age: Alter age: Alter
address: Adresse address: Adresse
town: Ort town: Ort
@@ -15,6 +16,7 @@ lastVisit: Letzter Besuch
search: Suche search: Suche
noDataAvailable: Keine Daten noDataAvailable: Keine Daten
importCSV: importiere CSV importCSV: importiere CSV
exportCSV: exportiere CSV
selectMemberOptions: Wähle Mitglieder Optionen selectMemberOptions: Wähle Mitglieder Optionen
addNewMember: Neues Mitglied addNewMember: Neues Mitglied
csvOptions: CSV Optionen csvOptions: CSV Optionen
@@ -32,6 +34,7 @@ settings: Einstellungen
databaseName: Datenbank Name databaseName: Datenbank Name
token: Schlüssel token: Schlüssel
login: Anmelden login: Anmelden
logout: Abmelden
user: Benutzer user: Benutzer
password: Passwort password: Passwort
isRequired: ist erforderlich isRequired: ist erforderlich
@@ -60,8 +63,10 @@ userIsRequired: Benutzer ist erforderlich
emailIsRequired: Email ist erforderlich emailIsRequired: Email ist erforderlich
roleIsRequired: Rolle ist erforderlich roleIsRequired: Rolle ist erforderlich
permissions: Rechte permissions: Rechte
selectRoleOptions: Wähle Rollen Option selectRoleOptions: Wähle Rollen Optionen
selectEventOptions: Wähle Veranstaltungs Optionen
addNewRole: Füge neue Rolle hinzu addNewRole: Füge neue Rolle hinzu
addNewEvent: Füeg neue Veranstaltung hinzu
veryWeak: sehr Schwach veryWeak: sehr Schwach
weak: Schwach weak: Schwach
fair: Ausreichend fair: Ausreichend
@@ -81,3 +86,48 @@ members: Mitglieder
attendanceTable: Anwesenheits Tabelle attendanceTable: Anwesenheits Tabelle
excursionTable: Ausflugs Tabelle excursionTable: Ausflugs Tabelle
updated: aktualisiert 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

View File

@@ -4,6 +4,7 @@ lastName: Name
birthday: Birthday birthday: Birthday
email: Email email: Email
group: Group group: Group
groups: Groups
age: Age age: Age
address: Address address: Address
town: Town town: Town
@@ -12,9 +13,10 @@ phone: Phone
responsible: Responsible responsible: Responsible
firstVisit: First Visit firstVisit: First Visit
lastVisit: Last Visit lastVisit: Last Visit
search: search search: Search
noDataAvailable: No data available noDataAvailable: No data available
importCSV: Import CSV importCSV: Import CSV
exportCSV: Export CSV
selectMemberOptions: Select Member Options selectMemberOptions: Select Member Options
addNewMember: Add new Member addNewMember: Add new Member
csvOptions: CSV Options csvOptions: CSV Options
@@ -32,6 +34,7 @@ settings: Settings
databaseName: Database Name databaseName: Database Name
token: Token token: Token
login: Login login: Login
logout: Logout
user: User user: User
password: Password password: Password
isRequired: is required isRequired: is required
@@ -61,7 +64,9 @@ emailIsRequired: Email is required
roleIsRequired: Role is required roleIsRequired: Role is required
permissions: Permissions permissions: Permissions
selectRoleOptions: Select Role Options selectRoleOptions: Select Role Options
selectEventOptions: Select Event Options
addNewRole: Add new Role addNewRole: Add new Role
addNewEvent: Add new Event
veryWeak: very Weak veryWeak: very Weak
weak: Weak weak: Weak
fair: Fair fair: Fair
@@ -81,3 +86,48 @@ members: Members
attendanceTable: Attendance Table attendanceTable: Attendance Table
excursionTable: Excursion Table excursionTable: Excursion Table
updated: updated 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

@@ -16,7 +16,7 @@ export default boot(async ({ app }) => {
.then((resp) => { .then((resp) => {
useStore useStore
.setUser({ id: resp.data.id, username: resp.data.username, role: resp.data.role }) .setUser({ id: resp.data.id, username: resp.data.username, role: resp.data.role })
.catch((err) => console.log(err)); .catch((err) => console.error(err));
login.refresh().catch((err) => console.error(err)); login.refresh().catch((err) => console.error(err));
}) })
.catch(() => { .catch(() => {

View File

@@ -3,12 +3,11 @@ import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } fro
import axios from 'axios'; import axios from 'axios';
import { useLogin } from 'src/vueLib/login/useLogin'; import { useLogin } from 'src/vueLib/login/useLogin';
const host = window.location.hostname; const host = import.meta.env.VITE_API_URL;
export const portApp = 9500;
// Create axios instance // Create axios instance
export const appApi: AxiosInstance = axios.create({ export const appApi: AxiosInstance = axios.create({
baseURL: `https://${host}:${portApp}/api`, baseURL: host,
timeout: 10000, timeout: 10000,
withCredentials: true, withCredentials: true,
}); });
@@ -56,6 +55,21 @@ appApi.interceptors.response.use(
// Handle unauthorized responses // Handle unauthorized responses
if (error.response?.status === 401 && !originalRequest._retry) { 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) { if (isRefreshing) {
// Wait until refresh completes // Wait until refresh completes
return new Promise<AxiosResponse>((resolve, reject) => { return new Promise<AxiosResponse>((resolve, reject) => {

View File

@@ -1,10 +1,10 @@
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import { getLocalLanguage } from 'src/localstorage/localStorage';
export const lang = []; export const lang = [];
const systemLocale = navigator.language || 'en-US'; const systemLocale = navigator.language || 'en-US';
const savedLang = localStorage.getItem('lang');
const messages = {}; const messages = {};
const modules = import.meta.glob('src/assets/lang/*.yaml', { const modules = import.meta.glob('src/assets/lang/*.yaml', {
@@ -24,10 +24,27 @@ for (const path in modules) {
messages[locale] = parsed; 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({ const i18n = createI18n({
legacy: false, // Composition API mode legacy: false, // Composition API mode
locale: savedLang || systemLocale, locale: selectedLocale,
fallbackLocale: systemLocale, fallbackLocale: resolveLocale(selectedLocale),
messages, messages,
}); });

View File

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

@@ -1,17 +1,57 @@
<template> <template>
<DialogFrame ref="dialog" :header-title="'Edit ' + localTitle"> <DialogFrame ref="dialog" :header-title="'Edit ' + localTitle">
<div class="row justify-center"> <div class="row justify-center">
<q-input <q-input autofocus :label="localTitle" filled v-model="value" @keyup.enter="save">
autofocus <template
class="q-ml-md col-6" v-if="['firstVisit', 'lastVisit', 'date', 'expiration'].includes(localField)"
:label="localTitle" v-slot:prepend
filled >
v-model="value" <q-icon name="event" class="cursor-pointer">
@keyup.enter="save" <q-popup-proxy cover transition-show="scale" transition-hide="scale">
></q-input> <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>
<div class="row justify-center"> <div class="row justify-center">
<q-btn class="q-ma-md" color="primary" no-caps @click="save">Save</q-btn> <q-btn class="q-ma-md" color="primary" no-caps @click="save">{{ $t('save') }}</q-btn>
</div> </div>
</DialogFrame> </DialogFrame>
</template> </template>
@@ -32,10 +72,6 @@ const value = ref('');
const props = defineProps({ const props = defineProps({
endpoint: { endpoint: {
type: String, type: String,
required: true,
},
queryId: {
type: Boolean,
}, },
}); });
@@ -52,26 +88,29 @@ function open(label: string, field: string, member: Member) {
} }
function save() { function save() {
let query = props.endpoint;
if (props.queryId) {
query += '?id=' + localMember.value.id;
}
let payload = {}; let payload = {};
if (value.value === localMember.value[localField.value]) { if (value.value === localMember.value[localField.value]) {
dialog.value.close(); dialog.value.close();
return; return;
} }
payload = {
if (!props.endpoint) {
localMember.value[localField.value] = value.value;
emit('update', localMember.value);
return;
}
payload = [
{
id: localMember.value.id, id: localMember.value.id,
[localField.value]: value.value, [localField.value]: value.value,
}; },
];
appApi appApi
.post(query, payload) .post(props.endpoint, payload)
.then((resp) => { .then(() => {
emit('update'); emit('update');
NotifyResponse(resp.data);
dialog.value.close(); dialog.value.close();
}) })
.catch((err) => { .catch((err) => {
@@ -79,5 +118,18 @@ function save() {
}); });
} }
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 }); defineExpose({ open });
</script> </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

@@ -25,9 +25,8 @@
v-model="localMember.lastName" v-model="localMember.lastName"
></q-input> ></q-input>
<q-input <q-input
class="q-ml-md col-5 required" class="q-ml-md col-5"
:label="$t('birthday')" :label="$t('birthday')"
:rules="[(val) => !!val || $t('birthdayIsRequired')]"
filled filled
v-model="localMember.birthday" v-model="localMember.birthday"
></q-input> ></q-input>
@@ -67,12 +66,14 @@
filled filled
v-model="localMember.group" v-model="localMember.group"
></q-input> ></q-input>
<q-input <q-select
class="q-ml-md col-5" class="q-ml-md col-5"
:label="$t('responsible')" :label="$t('responsible')"
filled filled
:options="props.responsibles"
:option-label="(opt) => opt.firstName + ' ' + opt.lastName"
v-model="localMember.responsiblePerson" v-model="localMember.responsiblePerson"
></q-input> ></q-select>
<q-input <q-input
v-if="!newMember" v-if="!newMember"
class="q-ml-md col-11" class="q-ml-md col-11"
@@ -90,17 +91,18 @@
</div> </div>
</q-form> </q-form>
<div class="row justify-center"> <div class="row justify-center">
<q-btn class="q-ma-md" color="primary" no-caps @click="save">Save</q-btn> <q-btn class="q-ma-md" color="primary" no-caps @click="save">{{ $t('save') }}</q-btn>
</div> </div>
</DialogFrame> </DialogFrame>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue'; import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { ref } from 'vue'; import { type PropType, ref } from 'vue';
import { appApi } from 'src/boot/axios'; import { appApi } from 'src/boot/axios';
import type { Member } from 'src/vueLib/models/member'; import type { Member, Members } from 'src/vueLib/models/member';
import { useNotify } from 'src/vueLib/general/useNotify'; import { useNotify } from 'src/vueLib/general/useNotify';
import { i18n } from 'src/boot/lang';
const { NotifyResponse } = useNotify(); const { NotifyResponse } = useNotify();
const dialog = ref(); const dialog = ref();
@@ -110,20 +112,15 @@ const localMember = ref<Member>({
id: 0, id: 0,
firstName: '', firstName: '',
lastName: '', lastName: '',
birthday: '',
age: '',
address: '',
town: '',
zip: '',
phone: '',
email: '',
group: '',
responsiblePerson: '',
firstVisit: '',
lastVisit: '',
}); });
const emit = defineEmits(['update-member']); const props = defineProps({
responsibles: {
type: Object as PropType<Members>,
},
});
const emit = defineEmits(['update']);
function open(member: Member | null) { function open(member: Member | null) {
if (member === undefined) { if (member === undefined) {
@@ -131,24 +128,13 @@ function open(member: Member | null) {
} }
if (member !== null) { if (member !== null) {
localMember.value = member; localMember.value = { ...member };
newMember.value = false; newMember.value = false;
} else { } else {
localMember.value = { localMember.value = {
id: 0, id: 0,
firstName: '', firstName: '',
lastName: '', lastName: '',
birthday: '',
age: '',
address: '',
town: '',
zip: '',
phone: '',
email: '',
group: '',
responsiblePerson: '',
firstVisit: '',
lastVisit: '',
}; };
newMember.value = true; newMember.value = true;
} }
@@ -158,18 +144,23 @@ function open(member: Member | null) {
async function save() { async function save() {
const valid = await form.value.validate(); const valid = await form.value.validate();
if (!valid) {
if (!valid) return; NotifyResponse(i18n.global.t('notAllRequiredFieldsFilled'), 'error');
return;
let query = 'members/edit?id=' + localMember.value.id;
if (newMember.value) {
query = 'members/add';
} }
appApi let query = 'members/edit';
.post(query, JSON.stringify(localMember.value)) let payload = JSON.stringify([localMember.value]);
if (newMember.value) {
query = 'members/add';
payload = JSON.stringify(localMember.value);
}
await appApi
.post(query, payload)
.then(() => { .then(() => {
emit('update-member'); emit('update');
NotifyResponse(i18n.global.t('memberUpdated'));
dialog.value.close(); dialog.value.close();
}) })
.catch((err) => NotifyResponse(err, 'error')); .catch((err) => NotifyResponse(err, 'error'));

View File

@@ -3,7 +3,7 @@
ref="dialog" ref="dialog"
:header-title="newRole ? $t('addNewRole') : 'Edit ' + localRole.role" :header-title="newRole ? $t('addNewRole') : 'Edit ' + localRole.role"
:height="700" :height="700"
:width="500" :width="700"
> >
<div class="row justify-center"> <div class="row justify-center">
<q-input <q-input
@@ -27,7 +27,7 @@
</q-card> </q-card>
</div> </div>
<div class="row justify-center"> <div class="row justify-center">
<q-btn class="q-ma-md" color="primary" no-caps @click="save">Save</q-btn> <q-btn class="q-ma-md" color="primary" no-caps @click="save">{{ $t('save') }}</q-btn>
</div> </div>
</DialogFrame> </DialogFrame>
</template> </template>
@@ -60,7 +60,7 @@ function open(role: Role | null, typ?: 'permissions') {
showRoleField.value = typ !== 'permissions'; showRoleField.value = typ !== 'permissions';
if (role !== null) { if (role !== null) {
localRole.value = role; localRole.value = { ...role };
localRole.value.permissions = role.permissions || defaultPermissions; localRole.value.permissions = role.permissions || defaultPermissions;
newRole.value = false; newRole.value = false;
} else { } else {
@@ -80,7 +80,7 @@ function save() {
if (newRole.value) { if (newRole.value) {
query = 'roles/add'; query = 'roles/add';
update = false; update = false;
localRole.value.permissions = defaultPermissions; localRole.value.permissions = localRole.value.permissions ?? defaultPermissions;
} }
appApi appApi

View File

@@ -4,7 +4,7 @@
<div class="row"> <div class="row">
<q-uploader <q-uploader
style="max-width: 300px" style="max-width: 300px"
:url="`https://0.0.0.0:` + portApp + `/api/members/import/csv`" url="/api/members/import/csv"
label="Import CSV" label="Import CSV"
multiple multiple
:with-credentials="true" :with-credentials="true"
@@ -128,7 +128,6 @@ import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import type { MetaData } from 'src/vueLib/models/metaData'; import type { MetaData } from 'src/vueLib/models/metaData';
import { useNotify } from 'src/vueLib/general/useNotify'; import { useNotify } from 'src/vueLib/general/useNotify';
import { portApp } from 'src/boot/axios';
const dialogHeight = ref(300); const dialogHeight = ref(300);
const dialogWidth = ref(500); const dialogWidth = ref(500);

View File

@@ -91,16 +91,17 @@
v-model="localUser.role" v-model="localUser.role"
></q-select> ></q-select>
<q-input <q-input
class="col-5" class="col-5 q-mt-xl"
:label="$t('expires')" :label="$t('expires')"
filled filled
v-model="localUser.expires" type="datetime-local"
v-model="localUser.expiration"
></q-input> ></q-input>
</div> </div>
</div> </div>
</q-form> </q-form>
<div class="row justify-center"> <div class="row justify-center">
<q-btn class="q-ma-md" color="primary" no-caps @click="save">Save</q-btn> <q-btn class="q-ma-md" color="primary" no-caps @click="save">{{ $t('save') }}</q-btn>
</div> </div>
</DialogFrame> </DialogFrame>
</template> </template>
@@ -131,7 +132,6 @@ const localUser = ref<User>({
user: '', user: '',
email: '', email: '',
role: '', role: '',
expires: '',
}); });
const props = defineProps({ const props = defineProps({
@@ -149,14 +149,13 @@ async function open(user: User | null) {
} }
if (user !== null) { if (user !== null) {
localUser.value = user; localUser.value = { ...user };
newUser.value = false; newUser.value = false;
} else { } else {
localUser.value = { localUser.value = {
user: '', user: '',
email: '', email: '',
role: '', role: '',
expires: '',
}; };
newUser.value = true; newUser.value = true;
} }

View File

@@ -10,7 +10,7 @@
/> />
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleLeftDrawer" /> <q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleLeftDrawer" />
<q-toolbar-title class="text-primary-text"> {{ productName }} </q-toolbar-title> <q-toolbar-title class="text-primary-text"> {{ $t(appName) }} </q-toolbar-title>
<div>Version {{ version }}</div> <div>Version {{ version }}</div>
<q-btn dense icon="refresh" square class="q-px-md q-ml-md" @click="refresh" /> <q-btn dense icon="refresh" square class="q-px-md q-ml-md" @click="refresh" />
@@ -18,7 +18,7 @@
</q-toolbar> </q-toolbar>
</q-header> </q-header>
<q-drawer v-model="leftDrawerOpen" bordered> <q-drawer v-model="leftDrawerOpen" bordered :width="drawerWidth" :overlay="$q.screen.lt.sm">
<q-list> <q-list>
<q-item v-if="!autorized" to="/login" exact clickable v-ripple @click="closeDrawer"> <q-item v-if="!autorized" to="/login" exact clickable v-ripple @click="closeDrawer">
<q-item-section>{{ $t('login') }}</q-item-section> <q-item-section>{{ $t('login') }}</q-item-section>
@@ -31,7 +31,37 @@
v-ripple v-ripple
@click="closeDrawer" @click="closeDrawer"
> >
<q-item-section>Members</q-item-section> <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-item>
</q-list> </q-list>
</q-drawer> </q-drawer>
@@ -43,12 +73,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { version, productName } from '../../package.json'; import { version } from '../../package.json';
import LoginMenu from 'src/vueLib/login/LoginMenu.vue'; import LoginMenu from 'src/vueLib/login/LoginMenu.vue';
import { useUserStore } from 'src/vueLib/login/userStore'; import { useUserStore } from 'src/vueLib/login/userStore';
import { Logo } from 'src/vueLib/models/logo'; import { logo, appName } from 'src/vueLib/models/settings';
import { useQuasar } from 'quasar';
const localLogo = ref(Logo); const localLogo = ref(logo);
const leftDrawerOpen = ref(false); const leftDrawerOpen = ref(false);
const user = useUserStore(); const user = useUserStore();
@@ -67,4 +98,10 @@ function closeDrawer() {
function refresh() { function refresh() {
window.location.reload(); 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> </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;
}

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>

View File

@@ -5,6 +5,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getLocalLastRoute } from 'src/localstorage/localStorage';
import LoginForm from 'src/vueLib/login/LoginForm.vue'; import LoginForm from 'src/vueLib/login/LoginForm.vue';
import { useUserStore } from 'src/vueLib/login/userStore'; import { useUserStore } from 'src/vueLib/login/userStore';
import { nextTick, onMounted } from 'vue'; import { nextTick, onMounted } from 'vue';
@@ -21,6 +22,6 @@ onMounted(() => {
const forwardToPage = async () => { const forwardToPage = async () => {
await nextTick(); await nextTick();
await router.push('/members'); await router.push(getLocalLastRoute());
}; };
</script> </script>

View File

@@ -1,34 +1,10 @@
<template> <template>
<q-page> <q-page>
<h4 class="text-primary text-bold text-center">{{ $t('members') }}</h4>
<MembersTable /> <MembersTable />
<DialogFrame ref="dialog" header-title="Test Frame">
<MembersTable ref="memberDialog" />
<q-btn @click="getSelection">Get Selected</q-btn>
</DialogFrame>
<DialogFrame ref="uploadDialog" header-title="Test Frame">
<MembersTable ref="memberDialog" />
<q-btn @click="getSelection">Get Selected</q-btn>
</DialogFrame>
<div class="row">
<q-btn @click="open">Click Me</q-btn>
</div>
</q-page> </q-page>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import MembersTable from 'src/vueLib/tables/members/MembersTable.vue'; import MembersTable from 'src/vueLib/tables/members/MembersTable.vue';
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { ref } from 'vue';
import type { MemberDialog } from 'src/vueLib/tables/members/MembersTable.vue';
const dialog = ref();
const memberDialog = ref<MemberDialog>();
const open = () => dialog.value?.open();
function getSelection() {
const selected = memberDialog.value?.getSelected();
if (selected === undefined) return;
console.log(65, selected[0]?.id);
}
</script> </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>

View File

@@ -1,14 +1,26 @@
<template> <template>
<h4 class="text-primary text-bold text-center">{{ $t('userSettings') }}</h4>
<div class="text-h2 flex flex-center"> <div class="text-h2 flex flex-center">
<q-card class="q-gutter-md"> <q-card class="q-gutter-md">
<p class="text-center text-bold text-h3 text-primary q-pt-md">{{ $t('settings') }}</p>
<div> <div>
<q-card class="q-ma-lg"> <q-card class="q-ma-lg">
<p class="text-bold text-h6 text-primary q-pa-md">{{ $t('general') }}</p> <p class="text-bold text-h6 text-primary q-pa-md">{{ $t('general') }}</p>
<div class="row"> <div class="row">
<q-input <q-input
:readonly="!user.isPermittedTo('userSettings', 'write')" :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="[ :class="[
colorGroup ? 'col-md-4' : 'col-md-3', colorGroup ? 'col-md-4' : 'col-md-3',
colorGroup ? 'col-md-6' : 'col-md-6', colorGroup ? 'col-md-6' : 'col-md-6',
@@ -25,7 +37,7 @@
<p class="text-bold text-h6 text-primary q-pa-md">{{ $t('database') }}</p> <p class="text-bold text-h6 text-primary q-pa-md">{{ $t('database') }}</p>
<div class="row"> <div class="row">
<q-input <q-input
:readonly="!user.isPermittedTo('userSettings', 'write')" :readonly="!user.isPermittedTo('settings', 'write')"
:class="[ :class="[
colorGroup ? 'col-md-4' : 'col-md-3', colorGroup ? 'col-md-4' : 'col-md-3',
colorGroup ? 'col-md-6' : 'col-md-6', colorGroup ? 'col-md-6' : 'col-md-6',
@@ -55,7 +67,7 @@
<div class="col-12 col-sm-6 col-md-3 q-px-md"> <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> <p class="text-center text-bold text-h6 text-primary">{{ $t('primaryColor') }}</p>
<q-color <q-color
:disable="!user.isPermittedTo('userSettings', 'write')" :disable="!user.isPermittedTo('settings', 'write')"
bordered bordered
class="q-mx-md" class="q-mx-md"
v-model="settings.primaryColor" v-model="settings.primaryColor"
@@ -75,7 +87,7 @@
{{ $t('primaryColorText') }} {{ $t('primaryColorText') }}
</p> </p>
<q-color <q-color
:disable="!user.isPermittedTo('userSettings', 'write')" :disable="!user.isPermittedTo('settings', 'write')"
bordered bordered
class="q-mx-md" class="q-mx-md"
v-model="settings.primaryColorText" v-model="settings.primaryColorText"
@@ -86,7 +98,7 @@
{{ $t('secondaryColor') }} {{ $t('secondaryColor') }}
</p> </p>
<q-color <q-color
:disable="!user.isPermittedTo('userSettings', 'write')" :disable="!user.isPermittedTo('settings', 'write')"
class="q-mx-md" class="q-mx-md"
v-model="settings.secondaryColor" v-model="settings.secondaryColor"
></q-color> ></q-color>
@@ -105,14 +117,14 @@
{{ $t('secondaryColorText') }} {{ $t('secondaryColorText') }}
</p> </p>
<q-color <q-color
:disable="!user.isPermittedTo('userSettings', 'write')" :disable="!user.isPermittedTo('settings', 'write')"
class="q-mx-md" class="q-mx-md"
v-model="settings.secondaryColorText" v-model="settings.secondaryColorText"
></q-color> ></q-color>
</div> </div>
</div> </div>
<q-btn <q-btn
:disable="!user.isPermittedTo('userSettings', 'write')" :disable="!user.isPermittedTo('settings', 'write')"
class="q-my-md q-mx-md" class="q-my-md q-mx-md"
color="secondary" color="secondary"
dense dense
@@ -133,22 +145,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { databaseName } from 'src/vueLib/tables/members/MembersTable'; import { logo, appName, databaseName } from 'src/vueLib/models/settings';
import { Logo } from 'src/vueLib/models/logo';
import { reactive, ref, watch } from 'vue'; import { reactive, ref, watch } from 'vue';
import { appApi } from 'src/boot/axios'; import { appApi } from 'src/boot/axios';
import { useNotify } from 'src/vueLib/general/useNotify'; import { useNotify } from 'src/vueLib/general/useNotify';
import { type Settings } from 'src/vueLib/models/settings'; import { type Settings } from 'src/vueLib/models/settings';
import { useLogin } from 'src/vueLib/login/useLogin';
import { useUserStore } from 'src/vueLib/login/userStore'; import { useUserStore } from 'src/vueLib/login/userStore';
import { setLocalSettings } from 'src/localstorage/localStorage';
const { NotifyResponse } = useNotify(); const { NotifyResponse } = useNotify();
const { getUser } = useLogin();
const colorGroup = ref(false); const colorGroup = ref(false);
const user = useUserStore(); const user = useUserStore();
const settings = reactive<Settings>({ const settings = reactive<Settings>({
icon: Logo.value, appName: appName.value,
icon: logo.value,
databaseName: databaseName.value, databaseName: databaseName.value,
primaryColor: document.documentElement.style.getPropertyValue('--q-primary'), primaryColor: document.documentElement.style.getPropertyValue('--q-primary'),
primaryColorText: document.documentElement.style.getPropertyValue('--q-primary-text'), primaryColorText: document.documentElement.style.getPropertyValue('--q-primary-text'),
@@ -157,7 +168,8 @@ const settings = reactive<Settings>({
}); });
watch(settings, (newSettings) => { watch(settings, (newSettings) => {
Logo.value = newSettings.icon; logo.value = newSettings.icon;
appName.value = newSettings.appName;
databaseName.value = newSettings.databaseName; databaseName.value = newSettings.databaseName;
}); });
@@ -177,16 +189,16 @@ function save() {
document.documentElement.style.setProperty('--q-primary-text', settings.primaryColorText); document.documentElement.style.setProperty('--q-primary-text', settings.primaryColorText);
document.documentElement.style.setProperty('--q-secondary', settings.secondaryColor); document.documentElement.style.setProperty('--q-secondary', settings.secondaryColor);
document.documentElement.style.setProperty('--q-secondary-text', settings.secondaryColorText); document.documentElement.style.setProperty('--q-secondary-text', settings.secondaryColorText);
Logo.value = settings.icon; appName.value = settings.appName;
localStorage.setItem('icon', settings.icon); logo.value = settings.icon;
localStorage.setItem('databaseName', settings.databaseName); setLocalSettings(settings);
localStorage.setItem('primaryColor', settings.primaryColor);
localStorage.setItem('primaryColorText', settings.primaryColorText);
localStorage.setItem('secondaryColor', settings.secondaryColor);
localStorage.setItem('secondaryColorText', settings.secondaryColorText);
const tempuser = user.user;
if (tempuser) {
tempuser.settings = settings;
}
appApi appApi
.post('settings/update', { user: getUser()?.username, settings }) .post('users/update', tempuser)
.then((resp) => NotifyResponse(resp.data.message)) .then((resp) => NotifyResponse(resp.data.message))
.catch((err) => NotifyResponse(err, 'error')); .catch((err) => NotifyResponse(err, 'error'));
} }

View File

@@ -1,4 +1,6 @@
<template> <template>
<h4 class="text-primary text-bold text-center">{{ $t('userSettings') }}</h4>
<div class="q-pa-md"> <div class="q-pa-md">
<div class="q-gutter-y-md"> <div class="q-gutter-y-md">
<q-card> <q-card>

View File

@@ -18,6 +18,21 @@ const routes: RouteRecordRaw[] = [
component: () => import('pages/MembersTable.vue'), component: () => import('pages/MembersTable.vue'),
meta: { requiresAuth: true, requiresAdmin: true }, 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', path: 'settings',
component: () => import('pages/SettingsPage.vue'), component: () => import('pages/SettingsPage.vue'),

View File

@@ -24,6 +24,20 @@
@update:model-value="(val) => toggleBit(index, 2, val)" @update:model-value="(val) => toggleBit(index, 2, val)"
>{{ i18n.global.t('delete') }}</q-checkbox >{{ 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> </div>
</q-card> </q-card>
</q-card> </q-card>
@@ -47,6 +61,7 @@ const localPermission = ref(
props.permissions.map((e) => ({ props.permissions.map((e) => ({
name: e.name, name: e.name,
permission: e.permission ?? 0, permission: e.permission ?? 0,
permissionNumber: e.name === 'members' ? 5 : 3,
})), })),
); );

View File

@@ -26,8 +26,18 @@ export const defaultPermissions = [
permission: 0, permission: 0,
}, },
{ {
name: 'attendanceTable', name: 'events',
label: i18n.global.t('attendanceTable'), 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, permission: 0,
}, },
{ {

View File

@@ -7,10 +7,10 @@
:no-refocus="!minMaxState" :no-refocus="!minMaxState"
:seamless="!minMaxState" :seamless="!minMaxState"
> >
<q-card class="layout" :style="cardStyle"> <q-card class="layout bg-surface text-on-surface" :style="cardStyle">
<!-- Draggable Header --> <!-- Draggable Header -->
<div <div
class="dialog-header row items-center justify-between bg-grey-1" class="dialog-header row items-center justify-between bg-transparent"
v-touch-pan.mouse.prevent.stop="handlePan" v-touch-pan.mouse.prevent.stop="handlePan"
> >
<div v-if="headerTitle" class="text-left text-bold text-caption q-mx-sm"> <div v-if="headerTitle" class="text-left text-bold text-caption q-mx-sm">
@@ -139,13 +139,11 @@ const cardStyle = computed(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: 10px; border-radius: 10px;
background-color: white;
} }
/* Draggable header */ /* Draggable header */
.dialog-header { .dialog-header {
padding: 8px 0; padding: 8px 0;
background: #f5f5f5;
cursor: move; cursor: move;
user-select: none; user-select: none;
} }

View File

@@ -48,9 +48,20 @@ export function useNotify() {
$q?.notify({ $q?.notify({
message: message, message: message,
color: color, color: color,
position: 'bottom-right', position: 'top',
icon: icon, icon: icon,
timeout: timeout, timeout: timeout,
actions: [
{
icon: 'close',
color: 'white',
dense: true,
round: true,
handler: () => {
/* just closes */
},
},
],
}); });
} }
} }

View File

@@ -3,7 +3,7 @@
<q-form ref="refForm"> <q-form ref="refForm">
<q-item-section class="q-gutter-md q-pa-md"> <q-item-section class="q-gutter-md q-pa-md">
<q-card :class="['q-gutter-xs q-items-center q-pa-lg', { shake: shake }]"> <q-card :class="['q-gutter-xs q-items-center q-pa-lg', { shake: shake }]">
<div class="text-h5 text-primary text-center">{{ productName }}</div> <div class="text-h5 text-primary text-center">{{ $t(appName) }}</div>
<q-input <q-input
ref="refUserInput" ref="refUserInput"
dense dense
@@ -47,7 +47,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { productName } from '../../../package.json'; import { appName } from '../models/settings';
import { ref } from 'vue'; import { ref } from 'vue';
import { useNotify } from '../general/useNotify'; import { useNotify } from '../general/useNotify';
import { useLogin } from './useLogin'; import { useLogin } from './useLogin';

View File

@@ -3,14 +3,14 @@
<q-btn dense flat round icon="person" :color="currentUser ? 'green' : ''"> <q-btn dense flat round icon="person" :color="currentUser ? 'green' : ''">
<q-menu ref="refLoginMenu"> <q-menu ref="refLoginMenu">
<q-list style="min-width: 120px"> <q-list style="min-width: 120px">
<q-item v-if="userLogin.getUser()" class="text-primary">{{ <q-item v-if="user.user" class="text-primary">{{ currentUser?.username }}</q-item>
currentUser?.username
}}</q-item>
<q-item v-if="showLogin" clickable v-close-popup @click="openLogin"> <q-item v-if="showLogin" clickable v-close-popup @click="openLogin">
<q-item-section class="text-primary">{{ loginText }}</q-item-section> <q-item-section class="text-primary">{{ loginText }}</q-item-section>
</q-item> </q-item>
<q-item> <q-item>
<q-btn flat :icon="Dark.mode ? 'light_mode' : 'dark_mode'" @click="Dark.toggle"></q-btn> <q-btn flat :icon="darkMode" @click="toggleDarkMode"
><q-tooltip>{{ $t(darkMode) }}</q-tooltip></q-btn
>
</q-item> </q-item>
<q-item> <q-item>
<q-select <q-select
@@ -20,7 +20,8 @@
dense dense
v-model="langSelected" v-model="langSelected"
:options="langSelection" :options="langSelection"
></q-select> ><q-tooltip>{{ $t('language') }}</q-tooltip></q-select
>
</q-item> </q-item>
<q-item <q-item
v-if=" v-if="
@@ -47,30 +48,44 @@
<script setup lang="ts"> <script setup lang="ts">
import LoginDialog from './LoginDialog.vue'; import LoginDialog from './LoginDialog.vue';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useLogin } from './useLogin';
import { useNotify } from '../general/useNotify'; import { useNotify } from '../general/useNotify';
import { lang, i18n } from 'src/boot/lang'; import { lang, i18n } from 'src/boot/lang';
import { useUserStore } from './userStore'; import { useUserStore } from './userStore';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { Dark } from 'quasar'; import { Dark } from 'quasar';
import { useLogin } from './useLogin';
import { setLocalDarkMode, setLocalLanguage } from 'src/localstorage/localStorage';
const userLogin = useLogin();
const route = useRoute(); 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( const showLogin = computed(
() => (route.path !== '/' && route.path !== '/login') || currentUser.value?.username === '', () => (route.path !== '/' && route.path !== '/login') || currentUser.value?.username === '',
); );
const userLogin = useLogin();
const user = useUserStore();
const autorized = computed(() => !!user.isAuthorizedAs(['admin'])); const autorized = computed(() => !!user.isAuthorizedAs(['admin']));
const { NotifyResponse } = useNotify();
const currentUser = computed(() => userLogin.getUser());
// switch between logged in or logged out text
const loginText = computed(() => { const loginText = computed(() => {
return currentUser.value ? 'Logout' : 'Login'; return currentUser.value ? i18n.global.t('logout') : i18n.global.t('login');
}); });
const refLoginDialog = ref(); //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() { function openLogin() {
if (currentUser.value) { if (currentUser.value) {
userLogin.logout().catch((err) => NotifyResponse(err, 'error')); userLogin.logout().catch((err) => NotifyResponse(err, 'error'));
@@ -85,6 +100,7 @@ const langSelection = ref(lang);
// Watch for changes and update i18n locale // Watch for changes and update i18n locale
watch(langSelected, (newLang) => { watch(langSelected, (newLang) => {
i18n.global.locale = newLang; i18n.global.locale = newLang;
localStorage.setItem('lang', newLang);
setLocalLanguage(newLang);
}); });
</script> </script>

View File

@@ -2,7 +2,8 @@ import { appApi } from 'src/boot/axios';
import { useUserStore } from './userStore'; import { useUserStore } from './userStore';
import { useNotify } from '../general/useNotify'; import { useNotify } from '../general/useNotify';
import type { Settings } from '../models/settings'; import type { Settings } from '../models/settings';
import { Logo } from '../models/logo'; import { appName, logo } from '../models/settings';
import { clearLocalStorage, setLocalSettings } from 'src/localstorage/localStorage';
const refreshTime = 10000; const refreshTime = 10000;
let intervalId: ReturnType<typeof setInterval> | null = null; let intervalId: ReturnType<typeof setInterval> | null = null;
@@ -16,23 +17,19 @@ export function useLogin() {
await appApi.post('/login', { user, password }).then((resp) => { await appApi.post('/login', { user, password }).then((resp) => {
const sets = resp.data.settings as Settings; const sets = resp.data.settings as Settings;
Logo.value = sets.icon; logo.value = sets.icon;
appName.value = sets.appName;
document.documentElement.style.setProperty('--q-primary', sets.primaryColor); document.documentElement.style.setProperty('--q-primary', sets.primaryColor);
document.documentElement.style.setProperty('--q-primary-text', sets.primaryColorText); document.documentElement.style.setProperty('--q-primary-text', sets.primaryColorText);
document.documentElement.style.setProperty('--q-secondary', sets.secondaryColor); document.documentElement.style.setProperty('--q-secondary', sets.secondaryColor);
document.documentElement.style.setProperty('--q-secondary-text', sets.secondaryColorText); document.documentElement.style.setProperty('--q-secondary-text', sets.secondaryColorText);
localStorage.setItem('icon', sets.icon); setLocalSettings(sets);
localStorage.setItem('databaseName', sets.databaseName);
localStorage.setItem('primaryColor', sets.primaryColor);
localStorage.setItem('primaryColorText', sets.primaryColorText);
localStorage.setItem('secondaryColor', sets.secondaryColor);
localStorage.setItem('secondaryColorText', sets.secondaryColorText);
}); });
const resp = await appApi.get('/login/me'); const resp = await appApi.get('/login/me');
await userStore await userStore
.setUser({ id: resp.data.id, username: resp.data.user, role: resp.data.role }) .setUser({ id: resp.data.id, username: resp.data.user, role: resp.data.role })
.catch((err) => console.log(err)); .catch((err) => NotifyResponse(err, 'error'));
startRefreshInterval(); startRefreshInterval();
return true; return true;
@@ -48,6 +45,7 @@ export function useLogin() {
}); });
userStore.clearUser(); userStore.clearUser();
clearLocalStorage();
stopRefreshInterval(); stopRefreshInterval();
} }
@@ -60,7 +58,7 @@ export function useLogin() {
.then((resp) => { .then((resp) => {
userStore userStore
.setUser({ id: resp.data.id, username: resp.data.user, role: resp.data.role }) .setUser({ id: resp.data.id, username: resp.data.user, role: resp.data.role })
.catch((err) => console.error(err)); .catch((err) => NotifyResponse(err, 'error'));
if (!intervalId) { if (!intervalId) {
startRefreshInterval(); startRefreshInterval();
} }
@@ -74,9 +72,6 @@ export function useLogin() {
stopRefreshInterval(); stopRefreshInterval();
return false; return false;
} }
function getUser() {
return userStore.user;
}
function startRefreshInterval() { function startRefreshInterval() {
intervalId = setInterval(() => { intervalId = setInterval(() => {
@@ -90,5 +85,5 @@ export function useLogin() {
} }
} }
return { login, logout, refresh, getUser }; return { login, logout, refresh };
} }

View File

@@ -24,7 +24,7 @@ export const useUserStore = defineStore('user', {
}; };
}, },
isPermittedTo: (state: UserState) => { isPermittedTo: (state: UserState) => {
return (name: string, type: 'read' | 'write' | 'delete'): boolean => { return (name: string, type: 'read' | 'write' | 'delete' | 'import' | 'export'): boolean => {
const permission = state.user?.permissions?.find((r: Permission) => r.name === name); const permission = state.user?.permissions?.find((r: Permission) => r.name === name);
switch (type) { switch (type) {
case 'read': case 'read':
@@ -33,6 +33,10 @@ export const useUserStore = defineStore('user', {
return permission?.permission ? (permission.permission & (1 << 1)) === 2 : false; return permission?.permission ? (permission.permission & (1 << 1)) === 2 : false;
case 'delete': case 'delete':
return permission?.permission ? (permission.permission & (1 << 2)) === 4 : false; 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;
} }
}; };
}, },
@@ -56,9 +60,20 @@ export const useUserStore = defineStore('user', {
$q?.notify({ $q?.notify({
message: "user '" + this.user?.username + "' logged out", message: "user '" + this.user?.username + "' logged out",
color: 'orange', color: 'orange',
position: 'bottom-right', position: 'top',
icon: 'warning', icon: 'warning',
timeout: 5000, timeout: 5000,
actions: [
{
icon: 'close',
color: 'white',
dense: true,
round: true,
handler: () => {
/* just closes */
},
},
],
}); });
} else { } else {
console.error("user '" + this.user?.username + "' logged out"); console.error("user '" + this.user?.username + "' logged out");
@@ -73,9 +88,20 @@ export const useUserStore = defineStore('user', {
$q?.notify({ $q?.notify({
message: err, message: err,
color: 'orange', color: 'orange',
position: 'bottom-right', position: 'top',
icon: 'warning', icon: 'warning',
timeout: 5000, timeout: 5000,
actions: [
{
icon: 'close',
color: 'white',
dense: true,
round: true,
handler: () => {
/* just closes */
},
},
],
}); });
} else { } else {
console.error("user '" + this.user?.username + "' logged out"); console.error("user '" + this.user?.username + "' logged out");

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

@@ -1,3 +0,0 @@
import { ref } from 'vue';
export const Logo = ref('');

View File

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

View File

@@ -1,4 +1,11 @@
import { ref } from 'vue';
export const logo = ref('');
export const appName = ref('Attendance Records');
export const databaseName = ref('members.dba');
export type Settings = { export type Settings = {
appName: string;
icon: string; icon: string;
databaseName: string; databaseName: string;
primaryColor: string; primaryColor: string;
@@ -9,6 +16,7 @@ export type Settings = {
export function DefaultSettings(): Settings { export function DefaultSettings(): Settings {
return { return {
appName: 'Attendance Records',
icon: '', icon: '',
databaseName: 'members.dba', databaseName: 'members.dba',
primaryColor: document.documentElement.style.getPropertyValue('--q-primary-text'), primaryColor: document.documentElement.style.getPropertyValue('--q-primary-text'),

View File

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

View File

@@ -5,7 +5,7 @@ export interface User {
user: string; user: string;
email: string; email: string;
role: string; role: string;
expires: string; expiration?: string;
password?: string; password?: string;
settings?: Settings; settings?: Settings;
} }

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

@@ -3,11 +3,21 @@ import { ref, computed } from 'vue';
import type { Member, Members } from 'src/vueLib/models/member'; import type { Member, Members } from 'src/vueLib/models/member';
import { useNotify } from 'src/vueLib/general/useNotify'; import { useNotify } from 'src/vueLib/general/useNotify';
import { i18n } from 'boot/lang'; import { i18n } from 'boot/lang';
import { useResponsibleTable } from '../responsible/ResponsibleTable';
export const databaseName = ref('members.dba'); import { appName } from 'src/vueLib/models/settings';
import { useGroupTable } from '../group/GroupTable';
export function useMemberTable() { export function useMemberTable() {
const members = ref<Members>([]); 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({ const pagination = ref({
sortBy: 'firstName', sortBy: 'firstName',
@@ -16,7 +26,28 @@ export function useMemberTable() {
rowsPerPage: 20, rowsPerPage: 20,
}); });
const columns = computed(() => [ //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: 'cake', align: 'center' as const, label: '', field: 'cake', icon: 'cake' },
{ {
name: 'firstName', name: 'firstName',
@@ -46,6 +77,13 @@ export function useMemberTable() {
field: 'age', field: 'age',
sortable: true, sortable: true,
}, },
{
name: 'comment',
align: 'left' as const,
label: i18n.global.t('comment'),
field: 'comment',
sortable: true,
},
{ {
name: 'address', name: 'address',
align: 'left' as const, align: 'left' as const,
@@ -110,7 +148,8 @@ export function useMemberTable() {
sortable: true, sortable: true,
}, },
{ name: 'option', align: 'center' as const, label: '', field: 'option', icon: 'option' }, { name: 'option', align: 'center' as const, label: '', field: 'option', icon: 'option' },
]); ].filter((c) => enabledColumns.value[c.name]),
);
const { NotifyResponse } = useNotify(); const { NotifyResponse } = useNotify();
@@ -156,6 +195,7 @@ export function useMemberTable() {
} }
function getRowClass(row: Member) { function getRowClass(row: Member) {
if (!row.birthday) return '';
if (isXDaysBeforeAnnualDate(row.birthday, 1)) { if (isXDaysBeforeAnnualDate(row.birthday, 1)) {
return 'bg-red-2 text-red-10'; return 'bg-red-2 text-red-10';
} else if (isXDaysBeforeAnnualDate(row.birthday, 4)) { } else if (isXDaysBeforeAnnualDate(row.birthday, 4)) {
@@ -167,22 +207,28 @@ export function useMemberTable() {
} }
//updates member list from database //updates member list from database
function updateMembers() { async function updateMembers(filter?: Members, filterbyName?: boolean) {
loading.value = true; loading.value = true;
appApi await updateResponsibles().catch((err) => NotifyResponse(err, 'error'));
await updateGroups().catch((err) => NotifyResponse(err, 'error'));
await appApi
.get('members') .get('members')
.then((resp) => { .then((resp) => {
if (resp.data === null) { if (resp.data === null) {
members.value = []; allMembers.value = [];
return; return;
} }
members.value = resp.data as Members; allMembers.value = resp.data as Members;
if (members.value === null) { if (allMembers.value === null) {
members.value = []; allMembers.value = [];
return; return;
} }
members.value.forEach((member) => { allMembers.value.forEach((member) => {
if (!responsibles.value.some((r) => r.id === member.responsiblePerson?.id)) {
delete member.responsiblePerson;
}
if (member.birthday !== undefined) { if (member.birthday !== undefined) {
member.age = String(calculateAge(member.birthday)); member.age = String(calculateAge(member.birthday));
} }
@@ -194,16 +240,119 @@ export function useMemberTable() {
}) })
.finally(() => { .finally(() => {
loading.value = false; 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 { return {
members, allMembers,
filteredMembers,
responsibles,
groups,
pagination, pagination,
columns, columns,
loading, loading,
getRowClass, getRowClass,
updateMembers, updateMembers,
setNewFilter,
isXDaysBeforeAnnualDate, isXDaysBeforeAnnualDate,
disableColumns,
exportCsv,
}; };
} }

View File

@@ -9,8 +9,8 @@
:no-data-label="$t('noDataAvailable')" :no-data-label="$t('noDataAvailable')"
:loading-label="$t('loading')" :loading-label="$t('loading')"
:rows-per-page-label="$t('recordsPerPage')" :rows-per-page-label="$t('recordsPerPage')"
:selected-rows-label="(val) => val + $t('recordSelected')" :selected-rows-label="(val) => val + ' ' + $t('recordSelected')"
:rows="members" :rows="filteredMembers"
:columns="columns" :columns="columns"
row-key="id" row-key="id"
v-model:pagination="pagination" v-model:pagination="pagination"
@@ -22,9 +22,10 @@
class="bigger-table-text" class="bigger-table-text"
> >
<template v-slot:top-left> <template v-slot:top-left>
<div>
<q-btn-group push flat style="color: grey"> <q-btn-group push flat style="color: grey">
<q-btn <q-btn
v-if="user.isPermittedTo('userSettings', 'write')" v-if="user.isPermittedTo('members', 'write')"
dense dense
flat flat
icon="add" icon="add"
@@ -33,7 +34,9 @@
<q-tooltip>{{ $t('addNewMember') }}</q-tooltip> <q-tooltip>{{ $t('addNewMember') }}</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
v-if="user.isPermittedTo('userSettings', 'write')" v-if="
user.isPermittedTo('members', 'write') || user.isPermittedTo('members', 'delete')
"
dense dense
flat flat
style="color: grey" style="color: grey"
@@ -43,7 +46,7 @@
<q-tooltip>{{ $t('selectMemberOptions') }}</q-tooltip> <q-tooltip>{{ $t('selectMemberOptions') }}</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
v-if="user.isPermittedTo('userSettings', 'write')" v-if="user.isPermittedTo('members', 'import')"
dense dense
flat flat
icon="upload" icon="upload"
@@ -51,11 +54,35 @@
> >
<q-tooltip>{{ $t('importCSV') }}</q-tooltip> <q-tooltip>{{ $t('importCSV') }}</q-tooltip>
</q-btn> </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> </q-btn-group>
<div v-if="selectOption && selected.length > 0"> <div v-if="selectOption && selected.length > 0">
<q-btn flat dense icon="more_vert" @click="openSubmenu = true" /> <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-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 <q-item
v-if="user.isPermittedTo('members', 'delete')"
clickable clickable
v-close-popup v-close-popup
@click="openRemoveDialog(...selected)" @click="openRemoveDialog(...selected)"
@@ -64,7 +91,39 @@
> >
</q-menu> </q-menu>
</div> </div>
<div v-if="selectOption && selected.length > 0" class="q-ml-md text-weight-bold"> <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 }} {{ $t('selected') }}: {{ selected.length }}
</div> </div>
</template> </template>
@@ -79,9 +138,9 @@
<q-td <q-td
:props="props" :props="props"
:class="getRowClass(props.row)" :class="getRowClass(props.row)"
:style="user.isPermittedTo('userSettings', 'write') ? 'cursor: pointer' : ''" :style="user.isPermittedTo('members', 'write') ? 'cursor: pointer' : ''"
@click=" @click="
user.isPermittedTo('userSettings', 'write') && user.isPermittedTo('members', 'write') &&
openSingleValueDialog(props.col.label, props.col.name, props.row) openSingleValueDialog(props.col.label, props.col.name, props.row)
" "
> >
@@ -97,13 +156,37 @@
/> />
</q-td> </q-td>
</template> </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"> <template v-slot:body-cell-option="props">
<q-td :props="props"> <q-td :props="props">
<q-btn <q-btn
v-if=" v-if="user.isPermittedTo('members', 'write') || user.isPermittedTo('members', 'delete')"
user.isPermittedTo('userSettings', 'write') ||
user.isPermittedTo('userSettings', 'delete')
"
flat flat
dense dense
icon="more_vert" icon="more_vert"
@@ -111,7 +194,7 @@
/> />
<q-menu v-if="openSubmenu" anchor="top right" self="top left"> <q-menu v-if="openSubmenu" anchor="top right" self="top left">
<q-item <q-item
v-if="user.isPermittedTo('userSettings', 'write')" v-if="user.isPermittedTo('members', 'write')"
clickable clickable
v-close-popup v-close-popup
@click="openAllValueDialog(props.row)" @click="openAllValueDialog(props.row)"
@@ -119,7 +202,11 @@
>{{ $t('edit') }}</q-item >{{ $t('edit') }}</q-item
> >
<q-item <q-item
v-if="user.isPermittedTo('userSettings', 'delete')" v-if="
user.isPermittedTo('members', 'delete') &&
!inProps.addAttendees &&
!inProps.addResponsible
"
clickable clickable
v-close-popup v-close-popup
@click="openRemoveDialog(props.row)" @click="openRemoveDialog(props.row)"
@@ -136,9 +223,13 @@
ref="editOneDialog" ref="editOneDialog"
endpoint="members/edit" endpoint="members/edit"
query-id query-id
v-on:update="updateMembers" v-on:update="updateMember"
></EditOneDialog> ></EditOneDialog>
<EditAllDialog ref="editAllDialog" v-on:update="updateMembers"></EditAllDialog> <EditAllDialog
ref="editAllDialog"
:responsibles="responsibles"
v-on:update="updateMember"
></EditAllDialog>
<OkDialog <OkDialog
ref="okDialog" ref="okDialog"
:dialog-label="$t('delete')" :dialog-label="$t('delete')"
@@ -151,11 +242,16 @@
v-on:update-confirm="(val) => removeMember(...val)" v-on:update-confirm="(val) => removeMember(...val)"
></OkDialog> ></OkDialog>
<UploadDialog ref="uploadDialog" @update-upload="updateMembers"> </UploadDialog> <UploadDialog ref="uploadDialog" @update-upload="updateMembers"> </UploadDialog>
<AddToEvent
ref="addToEventDialog"
endpoint="events/add/attendees"
v-on:update-event="(val) => updateMemberLastVisit(val)"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { appApi } from 'src/boot/axios'; import { appApi } from 'src/boot/axios';
import { ref, onMounted } from 'vue'; import { ref, onMounted, type PropType } from 'vue';
import type { Member, Members } from 'src/vueLib/models/member'; import type { Member, Members } from 'src/vueLib/models/member';
import EditOneDialog from 'src/components/EditOneDialog.vue'; import EditOneDialog from 'src/components/EditOneDialog.vue';
import EditAllDialog from 'src/components/MemberEditAllDialog.vue'; import EditAllDialog from 'src/components/MemberEditAllDialog.vue';
@@ -163,16 +259,28 @@ import OkDialog from 'src/components/dialog/OkDialog.vue';
import { useNotify } from 'src/vueLib/general/useNotify'; import { useNotify } from 'src/vueLib/general/useNotify';
import { useMemberTable } from './MembersTable'; import { useMemberTable } from './MembersTable';
import UploadDialog from 'src/components/UploadDialog.vue'; import UploadDialog from 'src/components/UploadDialog.vue';
import { databaseName } from './MembersTable'; import AddToEvent from 'src/components/AddToEvent.vue';
import { databaseName } from 'src/vueLib/models/settings';
import { useUserStore } from 'src/vueLib/login/userStore'; 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 { export interface MemberDialog {
getSelected: () => Members; getSelected: () => Members;
} }
const emit = defineEmits(['update-event']);
const { NotifyResponse } = useNotify(); const { NotifyResponse } = useNotify();
const editOneDialog = ref(); const editOneDialog = ref();
const editAllDialog = ref(); const editAllDialog = ref();
const addToEventDialog = ref();
const uploadDialog = ref(); const uploadDialog = ref();
const okDialog = ref(); const okDialog = ref();
const deleteText = ref(''); const deleteText = ref('');
@@ -181,25 +289,62 @@ const selected = ref<Members>([]);
const openSubmenu = ref(false); const openSubmenu = ref(false);
const filter = ref(''); const filter = ref('');
const user = useUserStore(); const user = useUserStore();
const localCompareMembers = ref<Members>();
const selectedColumnFilter = ref<string>('');
const selectedColumnOptions = ref<string[]>([]);
const page = ref<string>('members');
const { const {
members, allMembers,
filteredMembers,
responsibles,
groups,
pagination, pagination,
loading, loading,
columns, columns,
getRowClass, getRowClass,
updateMembers, updateMembers,
setNewFilter,
isXDaysBeforeAnnualDate, isXDaysBeforeAnnualDate,
disableColumns,
exportCsv,
} = useMemberTable(); } = useMemberTable();
//load on mounting page //load on mounting page
onMounted(() => { onMounted(() => {
loading.value = true; 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 appApi
.post('database/open', { dbPath: databaseName.value, create: true }) .post('database/open', { dbPath: databaseName.value, create: true })
.then(() => { .then(() => {
updateMembers(); updateMembers(inProps.compareMembers, inProps.addResponsible).catch((err) =>
NotifyResponse(err, 'error'),
);
}) })
.catch((err) => NotifyResponse(err, 'error')) .catch((err) => NotifyResponse(err, 'error'))
@@ -230,7 +375,7 @@ function openRemoveDialog(...members: Members) {
} }
deleteText.value += "'"; deleteText.value += "'";
} else { } else {
deleteText.value = String(members.length) + ' members'; deleteText.value = String(members.length) + ' ' + i18n.global.t('members');
} }
okDialog.value?.open(members); okDialog.value?.open(members);
} }
@@ -240,6 +385,28 @@ function openUploadDialog() {
uploadDialog.value?.open(); 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 //remove member from database
function removeMember(...removeMembers: Members) { function removeMember(...removeMembers: Members) {
const memberIds: number[] = []; const memberIds: number[] = [];
@@ -251,7 +418,7 @@ function removeMember(...removeMembers: Members) {
appApi appApi
.post('members/delete', { ids: memberIds }) .post('members/delete', { ids: memberIds })
.then(() => { .then(() => {
updateMembers(); updateMembers().catch((err) => NotifyResponse(err, 'error'));
selected.value = []; selected.value = [];
}) })
.catch((err) => NotifyResponse(err, 'error')) .catch((err) => NotifyResponse(err, 'error'))
@@ -260,24 +427,88 @@ function removeMember(...removeMembers: Members) {
}); });
} }
//const blinkingId = ref<number | null>(null); function updateMember(member: Member | null) {
if (!member) NotifyResponse(i18n.global.t('memberUpdated'));
// function triggerBlink(id: number) { appApi
// blinkingId.value = id; .post('/members/edit', [member])
.then(() => NotifyResponse(i18n.global.t('memberUpdated')))
// // Optional: stop blinking after 3 seconds .catch((err) => NotifyResponse(err, 'error'));
// setTimeout(() => { updateMembers().catch((err) => NotifyResponse(err, 'error'));
// blinkingId.value = null; }
// }, 3000);
// } function addToEvent() {
addToEventDialog.value?.open(i18n.global.t('addToEvent'), selected.value);
function getSelected(): Members { }
if (selected.value.length === 0) return [];
return 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');
} }
defineExpose({
getSelected,
});
</script> </script>
<style> <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,
};
}

View File

@@ -0,0 +1,242 @@
<template>
<div class="q-pa-md">
<q-table
flat
bordered
ref="tableRef"
title="Responsibles"
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="responsibles"
: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('responsible', 'write')"
dense
flat
icon="add"
@click="openResponsibleDialog"
>
<q-tooltip>{{ $t('addNewResponsible') }}</q-tooltip>
</q-btn>
<q-btn
v-if="
user.isPermittedTo('responsible', 'write') ||
user.isPermittedTo('responsible', 'delete')
"
dense
flat
style="color: grey"
:icon="selectOption ? 'check_box' : 'check_box_outline_blank'"
@click="selectOption = !selectOption"
>
<q-tooltip>{{ $t('selectResponsibleOptions') }}</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('responsible', '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">
{{ props.value }}
</q-td>
</template>
<template v-slot:body-cell-option="props">
<q-td :props="props">
<q-btn
v-if="
user.isPermittedTo('responsible', 'write') ||
user.isPermittedTo('responsible', '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('responsible', '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="responsibleDialog"
:header-title="$t('addNewResponsible')"
:height="600"
:width="500"
>
<MembersTable
add-responsible
:compare-members="responsibles"
v-on:update-event="updateResponsibles"
/>
</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) => removeResponsible(...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 MembersTable from '../members/MembersTable.vue';
import type { Member, Members } from 'src/vueLib/models/member';
import OkDialog from 'src/components/dialog/OkDialog.vue';
import { useNotify } from 'src/vueLib/general/useNotify';
import { useResponsibleTable } from './ResponsibleTable';
import { databaseName } from 'src/vueLib/models/settings';
import { useUserStore } from 'src/vueLib/login/userStore';
import { i18n } from 'src/boot/lang';
export interface ResponsibleDialog {
getSelected: () => Members;
}
const { NotifyResponse } = useNotify();
const responsibleDialog = 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 { responsibles, pagination, loading, columns, updateResponsibles } = useResponsibleTable();
//load on mounting page
onMounted(() => {
loading.value = true;
appApi
.post('database/open', { dbPath: databaseName.value, create: true })
.then(() => {
updateResponsibles().catch((err) => {
NotifyResponse(err, 'error');
});
})
.catch((err) => NotifyResponse(err, 'error'))
.finally(() => {
loading.value = false;
});
});
//opens dialog for one value
function openResponsibleDialog() {
responsibleDialog.value?.open();
}
//opens remove dialog
function openRemoveDialog(...Responsibles: Members) {
if (Responsibles.length === 1) {
deleteText.value = "'";
if (Responsibles[0]?.firstName !== undefined) {
deleteText.value += Responsibles[0]?.firstName + ' ';
}
if (Responsibles[0]?.lastName !== undefined) {
deleteText.value += Responsibles[0]?.lastName;
}
deleteText.value += "'";
} else {
deleteText.value = String(Responsibles.length) + ' ' + i18n.global.t('responsibles');
}
okDialog.value?.open(Responsibles);
}
//remove Responsible from database
function removeResponsible(...removeResponsibles: Members) {
const ResponsibleIds: number[] = [];
removeResponsibles.forEach((Responsible: Member) => {
ResponsibleIds.push(Responsible.id);
});
appApi
.post('responsible/delete', { ids: ResponsibleIds })
.then(() => {
updateResponsibles().catch((err) => {
NotifyResponse(err, 'error');
});
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;
}
}
.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

@@ -17,14 +17,6 @@ export function useRoleTable() {
}); });
const columns = computed(() => [ const columns = computed(() => [
{
name: 'id',
align: 'left' as const,
label: 'Id',
field: 'id',
sortable: true,
style: 'width: 50px; max-width: 50px;',
},
{ {
name: 'role', name: 'role',
align: 'left' as const, align: 'left' as const,
@@ -77,11 +69,11 @@ export function useRoleTable() {
.then((resp) => { .then((resp) => {
userStore userStore
.setUser({ id: resp.data.id, username: resp.data.username, role: resp.data.role }) .setUser({ id: resp.data.id, username: resp.data.username, role: resp.data.role })
.catch((err) => console.log(err)); .catch((err) => NotifyResponse(err, 'error'));
login.refresh().catch((err) => console.error(err)); login.refresh().catch((err) => NotifyResponse(err, 'error'));
}) })
.catch(() => { .catch(() => {
login.logout().catch((err) => console.error(err)); login.logout().catch((err) => NotifyResponse(err, 'error'));
}); });
} }
return { return {

View File

@@ -9,7 +9,7 @@
:no-data-label="$t('noDataAvailable')" :no-data-label="$t('noDataAvailable')"
:loading-label="$t('loading')" :loading-label="$t('loading')"
:rows-per-page-label="$t('recordsPerPage')" :rows-per-page-label="$t('recordsPerPage')"
:selected-rows-label="(val) => val + $t('recordSelected')" :selected-rows-label="(val) => val + ' ' + $t('recordSelected')"
:rows="roles" :rows="roles"
:columns="columns" :columns="columns"
row-key="id" row-key="id"
@@ -151,7 +151,6 @@ import EditAllDialog from 'src/components/RoleEditAllDialog.vue';
import OkDialog from 'src/components/dialog/OkDialog.vue'; import OkDialog from 'src/components/dialog/OkDialog.vue';
import { useNotify } from 'src/vueLib/general/useNotify'; import { useNotify } from 'src/vueLib/general/useNotify';
import { useRoleTable } from './RoleTable'; import { useRoleTable } from './RoleTable';
import { useLogin } from 'src/vueLib/login/useLogin';
import { i18n } from 'src/boot/lang'; import { i18n } from 'src/boot/lang';
import { QTable } from 'quasar'; import { QTable } from 'quasar';
import { useUserStore } from 'src/vueLib/login/userStore'; import { useUserStore } from 'src/vueLib/login/userStore';
@@ -173,9 +172,8 @@ const { roles, pagination, loading, columns, updateRoles } = useRoleTable();
//load on mounting page //load on mounting page
onMounted(() => { onMounted(() => {
loading.value = true; loading.value = true;
const login = useLogin(); currentUser.value = user.user;
currentUser.value = login.getUser(); updateRoles().catch((err) => NotifyResponse(err, 'error'));
updateRoles().catch((err) => console.error(err));
}); });
function autorized(role: Role): boolean { function autorized(role: Role): boolean {
@@ -198,7 +196,7 @@ function openRemoveDialog(...roles: Roles) {
if (roles.length === 1) { if (roles.length === 1) {
deleteText.value = "'" + roles[0]?.role + "'"; deleteText.value = "'" + roles[0]?.role + "'";
} else { } else {
deleteText.value = String(roles.length) + ' roles'; deleteText.value = String(roles.length) + ' ' + i18n.global.t('roles');
} }
okDialog.value?.open(roles); okDialog.value?.open(roles);
} }
@@ -217,12 +215,12 @@ function removeRole(...removeRoles: Roles) {
appApi appApi
.post('roles/delete?role=' + currentUser.value.role, { roles: roles }) .post('roles/delete?role=' + currentUser.value.role, { roles: roles })
.then((resp) => { .then(() => {
updateRoles().catch((err) => console.error(err)); updateRoles().catch((err) => NotifyResponse(err, 'error'));
if (roles.length === 1) { if (roles.length === 1) {
NotifyResponse("'" + roles[0] + "' " + i18n.global.t('deleted'), 'warning'); NotifyResponse("'" + roles[0] + "' " + i18n.global.t('deleted'), 'warning');
} else { } else {
NotifyResponse(resp.data, 'warning'); NotifyResponse(i18n.global.t('deleteRoles'), 'warning');
} }
selected.value = []; selected.value = [];
}) })
@@ -231,15 +229,6 @@ function removeRole(...removeRoles: Roles) {
loading.value = false; loading.value = false;
}); });
} }
function getSelected(): Roles {
if (selected.value.length === 0) return [];
return selected.value;
}
defineExpose({
getSelected,
});
</script> </script>
<style> <style>

View File

@@ -15,14 +15,6 @@ export function useUserTable() {
}); });
const columns = computed(() => [ const columns = computed(() => [
{
name: 'id',
align: 'left' as const,
label: 'Id',
field: 'id',
sortable: true,
style: 'width: 50px; max-width: 50px;',
},
{ {
name: 'user', name: 'user',
align: 'left' as const, align: 'left' as const,
@@ -45,6 +37,14 @@ export function useUserTable() {
sortable: true, sortable: true,
style: 'width: 120px; max-width: 120px;', style: 'width: 120px; max-width: 120px;',
}, },
{
name: 'expiration',
align: 'left' as const,
label: i18n.global.t('expiration'),
field: 'expiration',
sortable: true,
style: 'width: 120px; max-width: 120px;',
},
{ name: 'option', align: 'center' as const, label: '', field: 'option', icon: 'option' }, { name: 'option', align: 'center' as const, label: '', field: 'option', icon: 'option' },
]); ]);

View File

@@ -9,7 +9,7 @@
:no-data-label="$t('noDataAvailable')" :no-data-label="$t('noDataAvailable')"
:loading-label="$t('loading')" :loading-label="$t('loading')"
:rows-per-page-label="$t('recordsPerPage')" :rows-per-page-label="$t('recordsPerPage')"
:selected-rows-label="(val) => val + $t('recordSelected')" :selected-rows-label="(val) => val + ' ' + $t('recordSelected')"
:rows="users" :rows="users"
:columns="columns" :columns="columns"
row-key="id" row-key="id"
@@ -67,17 +67,7 @@
</q-input> </q-input>
</template> </template>
<template v-slot:body-cell="props"> <template v-slot:body-cell="props">
<q-td v-if="props.col.name === 'role'" :props="props">
<q-select
:readonly="!user.isPermittedTo('userSettings', 'write') || !autorized(props.row)"
dense
v-model="props.row.role"
:options="localRoles"
@update:model-value="updateUser(props.row)"
></q-select>
</q-td>
<q-td <q-td
v-else
:props="props" :props="props"
:style=" :style="
autorized(props.row) && user.isPermittedTo('userSettings', 'write') autorized(props.row) && user.isPermittedTo('userSettings', 'write')
@@ -93,6 +83,34 @@
{{ props.value }} {{ props.value }}
</q-td> </q-td>
</template> </template>
<template v-slot:body-cell-role="props">
<q-td :props="props">
<q-select
:readonly="!user.isPermittedTo('userSettings', 'write') || !autorized(props.row)"
dense
v-model="props.row.role"
:options="localRoles"
@update:model-value="updateUser(props.row)"
></q-select>
</q-td>
</template>
<template v-slot:body-cell-expiration="props">
<q-td
:props="props"
:style="
autorized(props.row) && user.isPermittedTo('userSettings', 'write')
? 'cursor: pointer'
: ''
"
@click="
autorized(props.row) && user.isPermittedTo('userSettings', 'write')
? openSingleValueDialog(props.col.label, props.col.name, props.row)
: ''
"
>
{{ props.value === 'never' ? $t('never') : props.value }}
</q-td>
</template>
<template v-slot:body-cell-option="props"> <template v-slot:body-cell-option="props">
<q-td :props="props"> <q-td :props="props">
<q-btn <q-btn
@@ -112,9 +130,8 @@
</div> </div>
<EditOneDialog <EditOneDialog
ref="editOneDialog" ref="editOneDialog"
endpoint="users/edit"
query-id query-id
v-on:update="updateUsers" v-on:update="(val) => updateUser(val)"
></EditOneDialog> ></EditOneDialog>
<EditAllDialog ref="editAllDialog" :roles="localRoles" v-on:update="updateUsers"></EditAllDialog> <EditAllDialog ref="editAllDialog" :roles="localRoles" v-on:update="updateUsers"></EditAllDialog>
<OkDialog <OkDialog
@@ -139,7 +156,6 @@ import EditAllDialog from 'src/components/UserEditAllDialog.vue';
import OkDialog from 'src/components/dialog/OkDialog.vue'; import OkDialog from 'src/components/dialog/OkDialog.vue';
import { useNotify } from 'src/vueLib/general/useNotify'; import { useNotify } from 'src/vueLib/general/useNotify';
import { useUserTable } from './UserTable'; import { useUserTable } from './UserTable';
import { useLogin } from 'src/vueLib/login/useLogin';
import { roles, useRoleTable } from '../roles/RoleTable'; import { roles, useRoleTable } from '../roles/RoleTable';
import { i18n } from 'src/boot/lang'; import { i18n } from 'src/boot/lang';
import { useUserStore } from 'src/vueLib/login/userStore'; import { useUserStore } from 'src/vueLib/login/userStore';
@@ -164,10 +180,9 @@ const user = useUserStore();
//load on mounting page //load on mounting page
onMounted(() => { onMounted(() => {
loading.value = true; loading.value = true;
const login = useLogin(); currentUser.value = user.user;
currentUser.value = login.getUser();
updateUsers(); updateUsers();
updateRoles().catch((err) => console.error(err)); updateRoles().catch((err) => NotifyResponse(err, 'error'));
}); });
//check authorization //check authorization
@@ -191,7 +206,7 @@ function openRemoveDialog(...users: Users) {
if (users.length === 1) { if (users.length === 1) {
deleteText.value = "'" + users[0]?.user + "'"; deleteText.value = "'" + users[0]?.user + "'";
} else { } else {
deleteText.value = String(users.length) + ' users'; deleteText.value = String(users.length) + ' ' + i18n.global.t('users');
} }
okDialog.value?.open(users); okDialog.value?.open(users);
} }
@@ -224,18 +239,9 @@ function removeUser(...removeUsers: Users) {
function updateUser(user: User) { function updateUser(user: User) {
appApi appApi
.post('/users/update', user) .post('/users/update', user)
.then((resp) => console.log(100, resp)) .then(() => NotifyResponse(i18n.global.t('userUpdated')))
.catch((err) => console.log(101, err)); .catch((err) => NotifyResponse(err, 'error'));
} }
function getSelected(): Users {
if (selected.value.length === 0) return [];
return selected.value;
}
defineExpose({
getSelected,
});
</script> </script>
<style> <style>

12
src/vueLib/utils/utils.ts Normal file
View File

@@ -0,0 +1,12 @@
export function updateOrAddObject<T extends Record<K, unknown>, K extends keyof T>(
arr: T[],
obj: T,
key: K,
) {
const i = arr.findIndex((o) => o[key] === obj[key]);
if (i === -1) {
arr.push(obj);
return;
}
arr.splice(i, 1, obj);
}