28 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
60 changed files with 1388 additions and 529 deletions

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,12 +3,13 @@ module backend
go 1.24.5 go 1.24.5
require ( require (
gitea.tecamino.com/paadi/access-handler v1.0.19 gitea.tecamino.com/paadi/access-handler v1.0.25
gitea.tecamino.com/paadi/memberDB v1.0.21 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
) )

View File

@@ -1,9 +1,9 @@
gitea.tecamino.com/paadi/access-handler v1.0.19 h1:L51Qg5RNjdIGeQsHwGUTV+ADRpUqPt3qdnu7A7UQbsw= gitea.tecamino.com/paadi/access-handler v1.0.25 h1:GiMnkEM0/fo2B1uCzGVyjpAhM2S58LG22N6+BdtdpgQ=
gitea.tecamino.com/paadi/access-handler v1.0.19/go.mod h1:wKsB5/Rvaj580gdg3+GbUf5V/0N00XN6cID+C/8135M= gitea.tecamino.com/paadi/access-handler v1.0.25/go.mod h1:wKsB5/Rvaj580gdg3+GbUf5V/0N00XN6cID+C/8135M=
gitea.tecamino.com/paadi/dbHandler v1.0.8 h1:ZWSBM/KFtLwTv2cBqwK1mOxWAxAfL0BcWEC3kJ9JALU= gitea.tecamino.com/paadi/dbHandler v1.0.8 h1:ZWSBM/KFtLwTv2cBqwK1mOxWAxAfL0BcWEC3kJ9JALU=
gitea.tecamino.com/paadi/dbHandler v1.0.8/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.21 h1:kGQe5fUOc50oQj8caWcjnmmxJaSPuQEjeSG5qDT9Iz4= gitea.tecamino.com/paadi/memberDB v1.1.3 h1:ZwSA+TNL1ZvL8bMnJ5a2odc44bQBa31gVxD2fBA6o0I=
gitea.tecamino.com/paadi/memberDB v1.0.21/go.mod h1:/Af2OeJIHq+8kE5L5DlJxhSJjB75eWBcKRpkxi+n9bU= 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")
sslKey := flag.String("privkey", "", "ssl private key path")
sslFullchain := flag.String("fullchain", "", "ssl fullchain 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{"*"},
@@ -122,6 +138,7 @@ 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("/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)
@@ -138,6 +155,10 @@ func main() {
auth.POST("/events/delete/attendees", dbHandler.DeleteAttendee) auth.POST("/events/delete/attendees", dbHandler.DeleteAttendee)
auth.POST("/events/delete", dbHandler.DeleteEvent) 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.GET("/responsible", dbHandler.GetResponsible)
auth.POST("/responsible/add", dbHandler.AddNewResponsible) auth.POST("/responsible/add", dbHandler.AddNewResponsible)
auth.POST("/responsible/delete", dbHandler.DeleteResponsible) auth.POST("/responsible/delete", dbHandler.DeleteResponsible)
@@ -148,12 +169,13 @@ func main() {
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") {
@@ -161,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 *sslFullchain == "" { 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 *sslKey == "" { 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: *sslFullchain, KeyFile: *sslKey}); 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())
} }
} }

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.7", "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

@@ -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
@@ -113,3 +116,18 @@ deleteResponsible: Veratwortläche entfernt
deleteResponsibles: Veratwortläche entfernt deleteResponsibles: Veratwortläche entfernt
expiration: Ablauf expiration: Ablauf
never: Nie 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
@@ -113,3 +116,18 @@ deleteResponsible: Verantwortliche entfernt
deleteResponsibles: Verantwortliche entfernt deleteResponsibles: Verantwortliche entfernt
expiration: Ablauf expiration: Ablauf
never: Nie 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
@@ -113,3 +116,18 @@ deleteResponsible: Responsible deleted
deleteResponsibles: Responsibles deleted deleteResponsibles: Responsibles deleted
expiration: Expiration expiration: Expiration
never: Never 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

@@ -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',
);
}); });

View File

@@ -1,12 +1,21 @@
import { boot } from 'quasar/wrappers'; import { boot } from 'quasar/wrappers';
import { useUserStore } from 'src/vueLib/login/userStore'; import { useUserStore } from 'src/vueLib/login/userStore';
import { appApi } from './axios';
import { getLocalLastRoute, setLocalLastRoute } from 'src/localstorage/localStorage';
export default boot(async ({ router }) => { export default boot(async ({ router }) => {
const userStore = useUserStore(); 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 // Restore logic after router is ready but before navigation
router.isReady().then(() => { router.isReady().then(() => {
const lastRoute = localStorage.getItem('lastRoute'); const lastRoute = getLocalLastRoute();
const currentPath = router.currentRoute.value.fullPath; const currentPath = router.currentRoute.value.fullPath;
// Restore only if: // Restore only if:
@@ -26,7 +35,7 @@ export default boot(async ({ router }) => {
router.afterEach((to) => { router.afterEach((to) => {
// Don't save login page as "last route" // Don't save login page as "last route"
if (to.path !== '/login' && to.path !== '/') { if (to.path !== '/login' && to.path !== '/') {
localStorage.setItem('lastRoute', to.fullPath); setLocalLastRoute(to.fullPath);
} }
}); });
}); });

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>

View File

@@ -1,63 +0,0 @@
<template>
<DialogFrame
ref="dialog"
:header-title="
newMember
? $t('addNewResponsible')
: 'Edit ' + localMember.firstName + ' ' + localMember.lastName
"
:height="600"
:width="500"
>
<q-form ref="form">
<MembersTable add-responsible v-on:update-event="emit('updated')" />
</q-form>
</DialogFrame>
</template>
<script setup lang="ts">
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import MembersTable from 'src/vueLib/tables/members/MembersTable.vue';
import { ref } from 'vue';
import type { Member } from 'src/vueLib/models/member';
const dialog = ref();
const form = ref();
const newMember = ref(false);
const localMember = ref<Member>({
id: 0,
firstName: '',
lastName: '',
});
const emit = defineEmits(['updated']);
function open(member: Member | null) {
if (member === undefined) {
return;
}
if (member !== null) {
localMember.value = { ...member };
newMember.value = false;
} else {
localMember.value = {
id: 0,
firstName: '',
lastName: '',
};
newMember.value = true;
}
dialog.value?.open();
}
defineExpose({ open });
</script>
<style>
.required .q-field__label::after {
content: ' *';
color: red;
}
</style>

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

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>
@@ -53,6 +53,16 @@
> >
<q-item-section>{{ $t('responsible') }}</q-item-section> <q-item-section>{{ $t('responsible') }}</q-item-section>
</q-item> </q-item>
<q-item
v-if="autorized || user.isPermittedTo('group', 'read')"
to="/group"
exact
clickable
v-ripple
@click="closeDrawer"
>
<q-item-section>{{ $t('groups') }}</q-item-section>
</q-item>
</q-list> </q-list>
</q-drawer> </q-drawer>
<q-page-container> <q-page-container>
@@ -63,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();
@@ -87,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;
}

View File

@@ -1,21 +1,11 @@
<template> <template>
<q-page> <q-page>
<h4 class="text-primary text-bold text-center">{{ $t('events') }}</h4>
<EventsTable /> <EventsTable />
<DialogFrame ref="dialog" header-title="Test Frame">
<EventsTable ref="EventDialog" />
</DialogFrame>
<DialogFrame ref="uploadDialog" header-title="Test Frame">
<EventsTable ref="EventDialog" />
</DialogFrame>
</q-page> </q-page>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import EventsTable from 'src/vueLib/tables/events/EventsTable.vue'; import EventsTable from 'src/vueLib/tables/events/EventsTable.vue';
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { ref } from 'vue';
import type { EventDialog } from 'src/vueLib/tables/events/EventsTable.vue';
const dialog = ref();
const EventDialog = ref<EventDialog>();
</script> </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,7 +22,6 @@ onMounted(() => {
const forwardToPage = async () => { const forwardToPage = async () => {
await nextTick(); await nextTick();
const lastRoute = localStorage.getItem('lastRoute') || '/members'; await router.push(getLocalLastRoute());
await router.push(lastRoute);
}; };
</script> </script>

View File

@@ -1,5 +1,6 @@
<template> <template>
<q-page> <q-page>
<h4 class="text-primary text-bold text-center">{{ $t('members') }}</h4>
<MembersTable /> <MembersTable />
</q-page> </q-page>
</template> </template>

View File

@@ -1,28 +1,11 @@
<template> <template>
<q-page> <q-page>
<h4 class="text-primary text-bold text-center">{{ $t('responsibles') }}</h4>
<ResponsibleTable /> <ResponsibleTable />
<DialogFrame ref="dialog" header-title="Test Frame">
<ResponsibleTable ref="memberDialog" />
<q-btn @click="getSelection">Get Selected</q-btn>
</DialogFrame>
<DialogFrame ref="uploadDialog" header-title="Test Frame">
<ResponsibleTable ref="memberDialog" />
<q-btn @click="getSelection">Get Selected</q-btn>
</DialogFrame>
</q-page> </q-page>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ResponsibleTable from 'src/vueLib/tables/responsible/ResponsibleTable.vue'; import ResponsibleTable from 'src/vueLib/tables/responsible/ResponsibleTable.vue';
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import { ref } from 'vue';
import type { MemberDialog } from 'src/vueLib/tables/members/MembersTable.vue';
import type { Members } from 'src/vueLib/models/member';
const dialog = ref();
const memberDialog = ref<MemberDialog>();
function getSelection(): Members {
return memberDialog.value?.getSelected() || [];
}
</script> </script>

View File

@@ -1,12 +1,24 @@
<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
: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 <q-input
:readonly="!user.isPermittedTo('settings', 'write')" :readonly="!user.isPermittedTo('settings', 'write')"
:class="[ :class="[
@@ -133,20 +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 { 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 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'),
@@ -155,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;
}); });
@@ -175,13 +189,9 @@ 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; const tempuser = user.user;
if (tempuser) { if (tempuser) {

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

@@ -28,6 +28,11 @@ const routes: RouteRecordRaw[] = [
component: () => import('pages/ResponsibleTable.vue'), component: () => import('pages/ResponsibleTable.vue'),
meta: { requiresAuth: true, requiresAdmin: true }, 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

@@ -35,6 +35,11 @@ export const defaultPermissions = [
label: i18n.global.t('responsible'), label: i18n.global.t('responsible'),
permission: 0, permission: 0,
}, },
{
name: 'group',
label: i18n.global.t('group'),
permission: 0,
},
{ {
name: 'excursionTable', name: 'excursionTable',
label: i18n.global.t('excursionTable'), label: i18n.global.t('excursionTable'),

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

@@ -8,7 +8,9 @@
<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
@@ -18,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="
@@ -51,24 +54,38 @@ 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 { useLogin } from './useLogin';
import { setLocalDarkMode, setLocalLanguage } from 'src/localstorage/localStorage';
const userLogin = useLogin(); 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 user = useUserStore();
const autorized = computed(() => !!user.isAuthorizedAs(['admin'])); const autorized = computed(() => !!user.isAuthorizedAs(['admin']));
const { NotifyResponse } = useNotify();
const currentUser = computed(() => user.user);
// 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'));
@@ -83,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,17 +17,13 @@ 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');
@@ -48,7 +45,7 @@ export function useLogin() {
}); });
userStore.clearUser(); userStore.clearUser();
localStorage.clear(); clearLocalStorage();
stopRefreshInterval(); stopRefreshInterval();
} }

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,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

@@ -4,6 +4,7 @@ export interface Member {
lastName: string; lastName: string;
birthday?: string; birthday?: string;
age?: string; age?: string;
comment?: string;
address?: string; address?: string;
town?: string; town?: string;
zip?: string; zip?: string;

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,5 +1,4 @@
<template> <template>
<DialogFrame ref="dialog" :header-title="$t('attendees')" :width="600">
<div class="q-pa-md"> <div class="q-pa-md">
<q-table <q-table
flat flat
@@ -93,8 +92,13 @@
</template> </template>
</q-table> </q-table>
</div> </div>
<DialogFrame ref="memberTableDialog" :header-title="$t('members')" :width="700"> <DialogFrame ref="memberTableDialog" :header-title="$t('members')" :width="700" :height="500">
<MembersTable add-attendees v-on:update-event="updateTable" :event-id="localEvent?.id ?? 0" /> <MembersTable
add-attendees
:compare-members="attendees"
v-on:update-event="updateTable"
:event-id="localEvent?.id ?? 0"
/>
</DialogFrame> </DialogFrame>
<OkDialog <OkDialog
@@ -108,12 +112,11 @@
button-ok-color="red" button-ok-color="red"
v-on:update-confirm="(val) => removeAttendees(...val)" v-on:update-confirm="(val) => removeAttendees(...val)"
></OkDialog> ></OkDialog>
</DialogFrame>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { appApi } from 'src/boot/axios'; import { appApi } from 'src/boot/axios';
import { ref } from 'vue'; import { onMounted, type PropType, ref } from 'vue';
import type { Members } from 'src/vueLib/models/member'; import type { Members } from 'src/vueLib/models/member';
import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue'; import DialogFrame from 'src/vueLib/dialog/DialogFrame.vue';
import OkDialog from 'src/components/dialog/OkDialog.vue'; import OkDialog from 'src/components/dialog/OkDialog.vue';
@@ -128,11 +131,17 @@ export interface AttendeesDialog {
getSelected: () => Members; getSelected: () => Members;
} }
const props = defineProps({
event: {
type: Object as PropType<Event>,
required: true,
},
});
const emit = defineEmits(['update']); const emit = defineEmits(['update']);
const { NotifyResponse } = useNotify(); const { NotifyResponse } = useNotify();
const memberTableDialog = ref(); const memberTableDialog = ref();
const dialog = ref();
const okDialog = ref(); const okDialog = ref();
const deleteText = ref(''); const deleteText = ref('');
const selectOption = ref(false); const selectOption = ref(false);
@@ -144,11 +153,10 @@ const user = useUserStore();
const { attendees, pagination, loading, columns, updateAttendees } = useAttendeesTable(); const { attendees, pagination, loading, columns, updateAttendees } = useAttendeesTable();
const open = (event: Event) => { onMounted(() => {
localEvent.value = event; localEvent.value = props.event;
attendees.value = event.attendees ?? []; attendees.value = props.event.attendees ?? [];
dialog.value.open(); });
};
//opens dialog for one value //opens dialog for one value
function openAllValueDialog() { function openAllValueDialog() {
@@ -164,7 +172,7 @@ function openRemoveDialog(...attendees: Members) {
} }
deleteText.value += "'"; deleteText.value += "'";
} else { } else {
deleteText.value = String(attendees.length) + ' attendees'; deleteText.value = String(attendees.length) + ' ' + i18n.global.t('attendees');
} }
okDialog.value?.open(attendees); okDialog.value?.open(attendees);
} }
@@ -172,7 +180,7 @@ function openRemoveDialog(...attendees: Members) {
//remove Attendees from database //remove Attendees from database
async function removeAttendees(...removeAttendees: Members) { async function removeAttendees(...removeAttendees: Members) {
if (!localEvent.value) { if (!localEvent.value) {
console.error('event is empty'); NotifyResponse('event is empty', 'error');
return; return;
} }
@@ -192,18 +200,14 @@ async function removeAttendees(...removeAttendees: Members) {
.finally(() => { .finally(() => {
loading.value = false; loading.value = false;
}); });
emit('update');
await updateAttendees(); await updateAttendees();
emit('update');
} }
async function updateTable() { async function updateTable() {
await updateAttendees(); await updateAttendees();
emit('update'); emit('update');
} }
defineExpose({
open,
});
</script> </script>
<style> <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

@@ -130,7 +130,7 @@
v-on:update="updateEvents" v-on:update="updateEvents"
></EditOneDialog> ></EditOneDialog>
<EditAllDialog ref="editAllDialog" v-on:update="updateEvents"></EditAllDialog> <EditAllDialog ref="editAllDialog" v-on:update="updateEvents"></EditAllDialog>
<AttendeesTable ref="attendeesDialog" v-on:update="updateEvents" /> <AttendeesTableDialog ref="attendeesDialog" v-on:update="updateEvents" />
<OkDialog <OkDialog
ref="okDialog" ref="okDialog"
:dialog-label="$t('delete')" :dialog-label="$t('delete')"
@@ -153,10 +153,11 @@ import EditAllDialog from 'src/components/EventEditAllDialog.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 { useEventTable } from './EventsTable'; import { useEventTable } from './EventsTable';
import { databaseName } from '../members/MembersTable'; import { databaseName } from 'src/vueLib/models/settings';
import { useUserStore } from 'src/vueLib/login/userStore'; import { useUserStore } from 'src/vueLib/login/userStore';
import AttendeesTable from '../attendees/AttendeesTable.vue'; import AttendeesTableDialog from '../attendees/AttendeesTableDialog.vue';
import type { Members } from 'src/vueLib/models/member'; import type { Members } from 'src/vueLib/models/member';
import { i18n } from 'src/boot/lang';
export interface EventDialog { export interface EventDialog {
getSelected: () => Events; getSelected: () => Events;
@@ -211,7 +212,7 @@ function openRemoveDialog(...Events: Events) {
} }
deleteText.value += "'"; deleteText.value += "'";
} else { } else {
deleteText.value = String(Events.length) + ' Events'; deleteText.value = String(Events.length) + ' ' + i18n.global.t('events');
} }
okDialog.value?.open(Events); okDialog.value?.open(Events);
} }

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

@@ -4,12 +4,20 @@ 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'; import { useResponsibleTable } from '../responsible/ResponsibleTable';
import { appName } from 'src/vueLib/models/settings';
export const databaseName = ref('members.dba'); 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 { responsibles, updateResponsibles } = useResponsibleTable();
const { groups, updateGroups } = useGroupTable();
const pagination = ref({ const pagination = ref({
sortBy: 'firstName', sortBy: 'firstName',
@@ -25,7 +33,8 @@ export function useMemberTable() {
lastName: true, lastName: true,
birthday: true, birthday: true,
age: true, age: true,
address: false, address: true,
comment: true,
town: true, town: true,
zip: true, zip: true,
phone: true, phone: true,
@@ -68,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,
@@ -191,24 +207,24 @@ export function useMemberTable() {
} }
//updates member list from database //updates member list from database
async function updateMembers() { async function updateMembers(filter?: Members, filterbyName?: boolean) {
loading.value = true; loading.value = true;
await updateResponsibles().catch((err) => NotifyResponse(err, 'error')); await updateResponsibles().catch((err) => NotifyResponse(err, 'error'));
await updateGroups().catch((err) => NotifyResponse(err, 'error'));
appApi 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)) { if (!responsibles.value.some((r) => r.id === member.responsiblePerson?.id)) {
delete member.responsiblePerson; delete member.responsiblePerson;
} }
@@ -224,9 +240,57 @@ 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[]) { function disableColumns(...columns: string[]) {
columns.forEach((col) => { columns.forEach((col) => {
if (col in enabledColumns.value) { if (col in enabledColumns.value) {
@@ -234,15 +298,61 @@ export function useMemberTable() {
} }
}); });
} }
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, responsibles,
groups,
pagination, pagination,
columns, columns,
loading, loading,
getRowClass, getRowClass,
updateMembers, updateMembers,
setNewFilter,
isXDaysBeforeAnnualDate, isXDaysBeforeAnnualDate,
disableColumns, disableColumns,
exportCsv,
}; };
} }

View File

@@ -10,7 +10,7 @@
: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,6 +22,7 @@
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('members', 'write')" v-if="user.isPermittedTo('members', 'write')"
@@ -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('members', 'write') || user.isPermittedTo('members', 'delete')" 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('members', 'write')" v-if="user.isPermittedTo('members', 'import')"
dense dense
flat flat
icon="upload" icon="upload"
@@ -51,6 +54,15 @@
> >
<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 <q-btn
@@ -79,6 +91,38 @@
> >
</q-menu> </q-menu>
</div> </div>
<q-card flat class="q-pa-sm">
<q-select
:label="$t('filterByColumn')"
dense
v-model="selectedColumnFilter"
option-label="label"
option-value="name"
map-options
emit-value
clearable
:options="columns.filter((col) => col.label !== '')"
v-on:clear="selectedColumnOptions = []"
@update:model-value="
filterMembers(selectedColumnFilter, ...(selectedColumnOptions || []))
"
class="q-mt-xs"
/>
<q-select
v-if="selectedColumnFilter"
:label="$t('filterByColumnValue')"
dense
v-model="selectedColumnOptions"
:options="setColumnOptions(selectedColumnFilter)"
class="q-mt-xs"
multiple
clearable
@update:model-value="
filterMembers(selectedColumnFilter, ...(selectedColumnOptions || []))
"
/>
</q-card>
</div>
<div v-if="selectOption && selected.length > 0" class="text-weight-bold"> <div v-if="selectOption && selected.length > 0" class="text-weight-bold">
{{ $t('selected') }}: {{ selected.length }} {{ $t('selected') }}: {{ selected.length }}
</div> </div>
@@ -112,6 +156,21 @@
/> />
</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"> <template v-slot:body-cell-responsiblePerson="props">
<q-td :props="props"> <q-td :props="props">
<q-select <q-select
@@ -192,7 +251,7 @@
<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';
@@ -201,14 +260,16 @@ 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 AddToEvent from 'src/components/AddToEvent.vue'; import AddToEvent from 'src/components/AddToEvent.vue';
import { databaseName } from './MembersTable'; 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 { i18n } from 'src/boot/lang';
import { getLocalPageDefaults, setLocalPageDefaults } from 'src/localstorage/localStorage';
const inProps = defineProps({ const inProps = defineProps({
addAttendees: { type: Boolean }, addAttendees: { type: Boolean },
addResponsible: { type: Boolean }, addResponsible: { type: Boolean },
eventId: { type: Number }, eventId: { type: Number },
compareMembers: { type: Object as PropType<Members> },
}); });
export interface MemberDialog { export interface MemberDialog {
getSelected: () => Members; getSelected: () => Members;
@@ -228,44 +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, responsibles,
groups,
pagination, pagination,
loading, loading,
columns, columns,
getRowClass, getRowClass,
updateMembers, updateMembers,
setNewFilter,
isXDaysBeforeAnnualDate, isXDaysBeforeAnnualDate,
disableColumns, disableColumns,
exportCsv,
} = useMemberTable(); } = useMemberTable();
//load on mounting page //load on mounting page
onMounted(() => { onMounted(() => {
page.value = 'members';
if (inProps.addAttendees || inProps.addResponsible) { if (inProps.addAttendees || inProps.addResponsible) {
selectOption.value = true; selectOption.value = true;
disableColumns( disableColumns(
'birthday', 'birthday',
'age', 'age',
'comment',
'town', 'town',
'zip', 'zip',
'email', 'email',
'address', 'address',
'phone', 'phone',
'group',
'responsiblePerson', 'responsiblePerson',
'firstVisit', 'firstVisit',
'lastVisit', 'lastVisit',
); );
page.value = 'attendance';
} }
loading.value = true; 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().catch((err) => NotifyResponse(err, 'error')); updateMembers(inProps.compareMembers, inProps.addResponsible).catch((err) =>
NotifyResponse(err, 'error'),
);
}) })
.catch((err) => NotifyResponse(err, 'error')) .catch((err) => NotifyResponse(err, 'error'))
@@ -296,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);
} }
@@ -306,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[] = [];
@@ -377,6 +478,7 @@ async function addMemberTo() {
} }
emit('update-event'); emit('update-event');
} }
async function updateMemberLastVisit(members: Members) { async function updateMemberLastVisit(members: Members) {
const now = new Date(); const now = new Date();
@@ -402,6 +504,10 @@ async function updateMemberLastVisit(members: Members) {
} }
}) })
.catch((err) => NotifyResponse(err, 'error')); .catch((err) => NotifyResponse(err, 'error'));
await updateMembers(localCompareMembers.value, inProps.addResponsible)
.then(() => localCompareMembers.value?.push(...members))
.catch((err) => NotifyResponse(err, 'error'));
emit('update-event');
} }
</script> </script>

View File

@@ -28,7 +28,7 @@
dense dense
flat flat
icon="add" icon="add"
@click="openAllValueDialog(null)" @click="openResponsibleDialog"
> >
<q-tooltip>{{ $t('addNewResponsible') }}</q-tooltip> <q-tooltip>{{ $t('addNewResponsible') }}</q-tooltip>
</q-btn> </q-btn>
@@ -49,9 +49,6 @@
<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 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('responsible', 'delete')" v-if="user.isPermittedTo('responsible', 'delete')"
clickable clickable
@@ -74,14 +71,7 @@
</q-input> </q-input>
</template> </template>
<template v-slot:body-cell="props"> <template v-slot:body-cell="props">
<q-td <q-td :props="props">
:props="props"
:style="user.isPermittedTo('responsible', 'write') ? 'cursor: pointer' : ''"
@click="
user.isPermittedTo('responsible', 'write') &&
openSingleValueDialog(props.col.label, props.col.name, props.row)
"
>
{{ props.value }} {{ props.value }}
</q-td> </q-td>
</template> </template>
@@ -98,14 +88,6 @@
@click="openSubmenu = true" @click="openSubmenu = true"
/> />
<q-menu v-if="openSubmenu" anchor="top right" self="top left"> <q-menu v-if="openSubmenu" anchor="top right" self="top left">
<q-item
v-if="user.isPermittedTo('responsible', 'write')"
clickable
v-close-popup
@click="openAllValueDialog(props.row)"
class="text-primary"
>{{ $t('edit') }}</q-item
>
<q-item <q-item
v-if="user.isPermittedTo('responsible', 'delete')" v-if="user.isPermittedTo('responsible', 'delete')"
clickable clickable
@@ -120,13 +102,18 @@
</template> </template>
</q-table> </q-table>
</div> </div>
<EditOneDialog <DialogFrame
ref="editOneDialog" ref="responsibleDialog"
endpoint="Responsibles/edit" :header-title="$t('addNewResponsible')"
query-id :height="600"
v-on:update="updateResponsibles" :width="500"
></EditOneDialog> >
<EditAllDialog ref="editAllDialog" v-on:updated="updateResponsibles"></EditAllDialog> <MembersTable
add-responsible
:compare-members="responsibles"
v-on:update-event="updateResponsibles"
/>
</DialogFrame>
<OkDialog <OkDialog
ref="okDialog" ref="okDialog"
:dialog-label="$t('delete')" :dialog-label="$t('delete')"
@@ -143,13 +130,13 @@
<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 } 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 type { Member, Members } from 'src/vueLib/models/member';
import EditOneDialog from 'src/components/EditOneDialog.vue';
import EditAllDialog from 'src/components/ResponsibleEditAllDialog.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 { useResponsibleTable } from './ResponsibleTable'; import { useResponsibleTable } from './ResponsibleTable';
import { databaseName } from '../members/MembersTable'; 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 { i18n } from 'src/boot/lang';
@@ -158,9 +145,7 @@ export interface ResponsibleDialog {
} }
const { NotifyResponse } = useNotify(); const { NotifyResponse } = useNotify();
const editOneDialog = ref(); const responsibleDialog = ref();
const editAllDialog = ref();
const addToEventDialog = ref();
const okDialog = ref(); const okDialog = ref();
const deleteText = ref(''); const deleteText = ref('');
const selectOption = ref(false); const selectOption = ref(false);
@@ -189,14 +174,9 @@ onMounted(() => {
}); });
}); });
// opens dialog for all Responsible values
function openSingleValueDialog(label: string, field: string, Responsible: Member) {
editOneDialog.value?.open(label, field, Responsible);
}
//opens dialog for one value //opens dialog for one value
function openAllValueDialog(Responsible: Member | null) { function openResponsibleDialog() {
editAllDialog.value?.open(Responsible); responsibleDialog.value?.open();
} }
//opens remove dialog //opens remove dialog
@@ -211,7 +191,7 @@ function openRemoveDialog(...Responsibles: Members) {
} }
deleteText.value += "'"; deleteText.value += "'";
} else { } else {
deleteText.value = String(Responsibles.length) + ' Responsibles'; deleteText.value = String(Responsibles.length) + ' ' + i18n.global.t('responsibles');
} }
okDialog.value?.open(Responsibles); okDialog.value?.open(Responsibles);
} }
@@ -237,10 +217,6 @@ function removeResponsible(...removeResponsibles: Members) {
loading.value = false; loading.value = false;
}); });
} }
function addToEvent() {
addToEventDialog.value?.open(i18n.global.t('addToEvent'), selected.value);
}
</script> </script>
<style> <style>

View File

@@ -196,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);
} }

View File

@@ -206,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);
} }

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