6 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
38 changed files with 1069 additions and 314 deletions

7
backend/env/env.go vendored
View File

@@ -17,12 +17,15 @@ const (
PrivKey EnvKey = "PRIVKEY" PrivKey EnvKey = "PRIVKEY"
Fullchain EnvKey = "FULLCHAIN" Fullchain EnvKey = "FULLCHAIN"
Https EnvKey = "HTTPS" Https EnvKey = "HTTPS"
Url EnvKey = "URL" HostUrl EnvKey = "HOST_URL"
Port EnvKey = "PORT" HostPort EnvKey = "HOST_PORT"
WorkingDir EnvKey = "WORKING_DIR" WorkingDir EnvKey = "WORKING_DIR"
Spa EnvKey = "SPA" Spa EnvKey = "SPA"
AccessSecret EnvKey = "ACCESS_SECRET" AccessSecret EnvKey = "ACCESS_SECRET"
RefreshSecret EnvKey = "REFRESH_SECRET" RefreshSecret EnvKey = "REFRESH_SECRET"
Organization EnvKey = "ORGANIZATION"
DOMAIN EnvKey = "DOMAIN"
AllowOrigin EnvKey = "ALLOWORIGIN"
) )
const ( const (

View File

@@ -3,10 +3,12 @@ GIN_MODE=release
DEBUG=false DEBUG=false
SPA=directory_of_spa_files SPA=directory_of_spa_files
WORKING_DIR=. WORKING_DIR=.
URL=your_local_url HOST_URL=your_local_url
PORT=your_local_port HOST_PORT=your_local_port
HTTPS=true HTTPS=true
PRIVKEY=your_certificate_key_file PRIVKEY=your_certificate_key_file
FULLCHAIN=your_certificate_fullchain_file FULLCHAIN=your_certificate_fullchain_file
ACCESS_SECRET=your_32bit_long_access_secret ACCESS_SECRET=your_32bit_long_access_secret
REFRESH_SECRET=your_32bit_long_referesh_secret REFRESH_SECRET=your_32bit_long_referesh_secret
ALLOWORIGIN=all_allowed_urls
DOMAIN=your_domain

View File

@@ -3,8 +3,8 @@ module backend
go 1.24.5 go 1.24.5
require ( require (
gitea.tecamino.com/paadi/access-handler v1.0.22 gitea.tecamino.com/paadi/access-handler v1.0.25
gitea.tecamino.com/paadi/memberDB v1.1.2 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

View File

@@ -1,9 +1,9 @@
gitea.tecamino.com/paadi/access-handler v1.0.22 h1:XFi2PQ1gWqe9YuSye4Ti1o5TpdV0AFpt4Fb58MFMagk= gitea.tecamino.com/paadi/access-handler v1.0.25 h1:GiMnkEM0/fo2B1uCzGVyjpAhM2S58LG22N6+BdtdpgQ=
gitea.tecamino.com/paadi/access-handler v1.0.22/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.1.2 h1:j/Tsr7JnzAkdOvgjG77TzTVBWd4vBrmEFzPXNpW7GYk= gitea.tecamino.com/paadi/memberDB v1.1.3 h1:ZwSA+TNL1ZvL8bMnJ5a2odc44bQBa31gVxD2fBA6o0I=
gitea.tecamino.com/paadi/memberDB v1.1.2/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=

View File

@@ -24,12 +24,8 @@ import (
) )
func main() { func main() {
var allowOrigins models.StringSlice // set cli flage
flag.Var(&allowOrigins, "allowOrigin", "Allowed origin (can repeat this flag)")
envFile := flag.String("env", ".env", "enviroment file") envFile := flag.String("env", ".env", "enviroment file")
organization := flag.String("organization", "", "self signed ciertificate organization")
flag.Parse() flag.Parse()
// load enviroment file if exists // load enviroment file if exists
@@ -51,6 +47,16 @@ func main() {
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()
if err != nil { if err != nil {
log.Fatalf("Could not get working directory: %v", err) log.Fatalf("Could not get working directory: %v", err)
@@ -103,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{"*"},
@@ -130,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)
@@ -146,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)
@@ -156,6 +169,7 @@ 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)
@@ -181,7 +195,7 @@ func main() {
go func() { go func() {
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
if err := utils.OpenBrowser(fmt.Sprintf("%slocalhost:%s", httpString, env.Port.GetValue()), 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))
} }
}() }()
@@ -197,16 +211,16 @@ func main() {
} }
// start https server // start https server
logger.Info("main", fmt.Sprintf("https listen on ip: %s port: %s", env.Url.GetValue(), env.Port.GetValue())) logger.Info("main", fmt.Sprintf("https listen on ip: %s port: %s", env.HostUrl.GetValue(), env.HostPort.GetValue()))
if err := s.ServeHttps(env.Url.GetValue(), env.Port.GetUIntValue(), cert.Cert{Organization: *organization, CertFile: env.Fullchain.GetValue(), KeyFile: env.PrivKey.GetValue()}); 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: %s", env.Url.GetValue(), env.Port.GetValue())) logger.Info("main", fmt.Sprintf("http listen on ip: %s port: %s", env.HostUrl.GetValue(), env.HostPort.GetValue()))
if err := s.ServeHttp(env.Url.GetValue(), env.Port.GetUIntValue()); 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

@@ -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,6 +1,6 @@
{ {
"name": "lightcontrol", "name": "lightcontrol",
"version": "1.1.0", "version": "1.1.1",
"description": "A Tecamino App", "description": "A Tecamino App",
"productName": "Attendence Records", "productName": "Attendence Records",
"author": "A. Zuercher", "author": "A. Zuercher",

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
@@ -118,3 +120,14 @@ responsibles: Verantwortliche
comment: Bemerkung comment: Bemerkung
dark_mode: Dunkel-Modus dark_mode: Dunkel-Modus
light_mode: Hell-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
@@ -118,3 +120,14 @@ responsibles: Verantwortliche
comment: Bemerkung comment: Bemerkung
dark_mode: Dunkel-Modus dark_mode: Dunkel-Modus
light_mode: Hell-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
@@ -118,3 +120,14 @@ responsibles: Responsibles
comment: Comment comment: Comment
dark_mode: Dark-Mode dark_mode: Dark-Mode
light_mode: Light-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', {
@@ -39,7 +39,7 @@ function resolveLocale(desiredLocale) {
return messages['en'] ? 'en' : Object.keys(messages)[0]; return messages['en'] ? 'en' : Object.keys(messages)[0];
} }
const selectedLocale = resolveLocale(savedLang || systemLocale); const selectedLocale = resolveLocale(getLocalLanguage() || systemLocale);
const i18n = createI18n({ const i18n = createI18n({
legacy: false, // Composition API mode legacy: false, // Composition API mode

View File

@@ -3,44 +3,29 @@ import { setQuasarInstance } from 'src/vueLib/utils/globalQ';
import { setRouterInstance } from 'src/vueLib/utils/globalRouter'; import { setRouterInstance } from 'src/vueLib/utils/globalRouter';
import { databaseName, logo, appName } from 'src/vueLib/models/settings'; import { databaseName, logo, appName } from 'src/vueLib/models/settings';
import { Dark } from 'quasar'; 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);
Dark.set(localStorage.getItem('mode') === 'true'); Dark.set(getLocalDarkMode());
logo.value = localStorage.getItem('icon') ?? logo.value; const settings = getLocalSettings();
appName.value = localStorage.getItem('appName') ?? appName.value;
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'); logo.value = settings.icon ?? logo.value;
document.documentElement.style.setProperty('--q-primary-text', primaryColorText ?? '#ffffff'); appName.value = settings.appName ?? appName.value;
document.documentElement.style.setProperty('--q-secondary', secondaryColor ?? '#26a69a'); databaseName.value = settings.databaseName ?? databaseName.value;
document.documentElement.style.setProperty('--q-secondary-text', secondaryColorText ?? '#ffffff');
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,6 +1,7 @@
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 { 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();
@@ -14,7 +15,7 @@ export default boot(async ({ router }) => {
// 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:
@@ -34,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

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

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

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,8 +1,8 @@
<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>
@@ -151,6 +151,7 @@ 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);
@@ -190,13 +191,7 @@ function save() {
document.documentElement.style.setProperty('--q-secondary-text', settings.secondaryColorText); document.documentElement.style.setProperty('--q-secondary-text', settings.secondaryColorText);
appName.value = settings.appName; appName.value = settings.appName;
logo.value = settings.icon; logo.value = settings.icon;
localStorage.setItem('icon', settings.icon); setLocalSettings(settings);
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);
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

@@ -54,6 +54,7 @@ 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();
@@ -81,7 +82,7 @@ const loginText = computed(() => {
//switch between dark and light mode and save it in localStorage //switch between dark and light mode and save it in localStorage
function toggleDarkMode() { function toggleDarkMode() {
Dark.toggle(); Dark.toggle();
localStorage.setItem('mode', String(Dark.mode)); setLocalDarkMode();
} }
// opens login page if no user is logged in otherwise it serves as logout // opens login page if no user is logged in otherwise it serves as logout
@@ -99,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

@@ -3,6 +3,7 @@ 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 { appName, logo } from '../models/settings'; 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;
@@ -22,12 +23,7 @@ export function useLogin() {
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');
@@ -49,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;
} }
}; };
}, },

View File

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

View File

@@ -1,5 +1,4 @@
<template> <template>
<DialogFrame ref="dialog" :header-title="$t('attendees')" :width="600" :height="600">
<div class="q-pa-md"> <div class="q-pa-md">
<q-table <q-table
flat flat
@@ -113,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';
@@ -133,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);
@@ -149,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() {
@@ -177,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;
} }
@@ -197,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')"
@@ -155,7 +155,7 @@ import { useNotify } from 'src/vueLib/general/useNotify';
import { useEventTable } from './EventsTable'; import { useEventTable } from './EventsTable';
import { databaseName } from 'src/vueLib/models/settings'; 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'; import { i18n } from 'src/boot/lang';

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,10 +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';
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',
@@ -201,20 +211,20 @@ export function useMemberTable() {
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;
} }
@@ -232,7 +242,7 @@ export function useMemberTable() {
loading.value = false; loading.value = false;
//filter same members out so list is shorter //filter same members out so list is shorter
if (filter) { if (filter) {
members.value = members.value.filter( filteredMembers.value = allMembers.value.filter(
(m1) => (m1) =>
!filter.some((m2) => { !filter.some((m2) => {
if (filterbyName) { if (filterbyName) {
@@ -242,9 +252,45 @@ export function useMemberTable() {
}), }),
); );
} }
//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) {
@@ -253,15 +299,60 @@ 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
@@ -204,6 +263,7 @@ import AddToEvent from 'src/components/AddToEvent.vue';
import { databaseName } from 'src/vueLib/models/settings'; 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 },
@@ -229,21 +289,30 @@ 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(
@@ -255,15 +324,21 @@ onMounted(() => {
'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(() => {
@@ -310,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[] = [];
@@ -381,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();
@@ -406,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>

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