first commit
This commit is contained in:
49
src/vueLib/db/db.ts
Normal file
49
src/vueLib/db/db.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite';
|
||||
import type { Settings } from '../models/settings';
|
||||
|
||||
const sqlite = new SQLiteConnection(CapacitorSQLite);
|
||||
|
||||
export async function initDB() {
|
||||
const db = await sqlite.createConnection('membersDB', true, 'secreto_passwordo', 1, false);
|
||||
await db.open();
|
||||
await db.execute(`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
settings TEXT NOT NULL
|
||||
);`);
|
||||
|
||||
const result = await db.query(`SELECT * FROM users WHERE username = ?`, ['admin']);
|
||||
if (result.values?.length === 0) {
|
||||
await db.run(`INSERT INTO users (username, role, password, settings) VALUES (?, ?, ?, ?)`, [
|
||||
'admin',
|
||||
'admin',
|
||||
'tecamino@2023',
|
||||
{},
|
||||
]);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function addUser(user: string, role: string, password: string, settings: Settings) {
|
||||
const db = await initDB();
|
||||
await db.run(`INSERT INTO users (username, role, password, settings) VALUES (?, ?, ?, ?)`, [
|
||||
user,
|
||||
role,
|
||||
password,
|
||||
settings,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getUsers() {
|
||||
const db = await initDB();
|
||||
const resp = await db.query(`SELECT * FROM users`);
|
||||
return resp.values;
|
||||
}
|
||||
|
||||
export async function getUser(user: string) {
|
||||
const db = await initDB();
|
||||
const resp = await db.query(`SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)`, [user]);
|
||||
return resp.values;
|
||||
}
|
||||
172
src/vueLib/dialog/DialogFrame.vue
Normal file
172
src/vueLib/dialog/DialogFrame.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<q-dialog
|
||||
ref="dialogRef"
|
||||
:maximized="minMaxState"
|
||||
:full-width="minMaxState"
|
||||
:no-focus="!minMaxState"
|
||||
:no-refocus="!minMaxState"
|
||||
:seamless="!minMaxState"
|
||||
>
|
||||
<q-card class="layout" :style="cardStyle">
|
||||
<!-- Draggable Header -->
|
||||
<div
|
||||
class="dialog-header row items-center justify-between bg-grey-1"
|
||||
v-touch-pan.mouse.prevent.stop="handlePan"
|
||||
>
|
||||
<div v-if="headerTitle" class="text-left text-bold text-caption q-mx-sm">
|
||||
{{ headerTitle }}
|
||||
</div>
|
||||
<div class="row justify-end q-mx-sm">
|
||||
<q-btn dense flat :icon="minMaxIcon" size="md" @click="minMax" />
|
||||
<q-btn dense flat icon="close" size="md" v-close-popup />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator color="black" />
|
||||
|
||||
<!-- Content Slot -->
|
||||
<div class="scrollArea">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Resize Handle -->
|
||||
<div v-if="!minMaxState" class="resize-handle" @mousedown.prevent="startResizing" />
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import type { TouchPanValue } from 'quasar';
|
||||
|
||||
const dialogRef = ref();
|
||||
const open = () => dialogRef.value?.show();
|
||||
const close = () => dialogRef.value?.hide();
|
||||
defineExpose({ open, close });
|
||||
|
||||
const props = defineProps({
|
||||
headerTitle: { type: String, default: '' },
|
||||
width: { type: Number, default: 400 },
|
||||
height: { type: Number, default: 250 },
|
||||
});
|
||||
|
||||
// Fullscreen toggle
|
||||
const minMaxIcon = ref('fullscreen');
|
||||
const minMaxState = ref(false);
|
||||
function minMax() {
|
||||
minMaxState.value = !minMaxState.value;
|
||||
minMaxIcon.value = minMaxState.value ? 'fullscreen_exit' : 'fullscreen';
|
||||
}
|
||||
|
||||
// Position and Size
|
||||
const position = ref({ x: 0, y: 0 });
|
||||
const width = ref(props.width || 400);
|
||||
const height = ref(props.height || 250);
|
||||
|
||||
// Watch prop changes and sync local ref
|
||||
watch(
|
||||
() => props.width,
|
||||
(newWidth) => {
|
||||
if (newWidth !== undefined && newWidth !== width.value) {
|
||||
width.value = newWidth;
|
||||
}
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.height,
|
||||
(newHeight) => {
|
||||
if (newHeight !== undefined && newHeight !== height.value) {
|
||||
height.value = newHeight;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Dragging (only from header)
|
||||
const handlePan: TouchPanValue = (details) => {
|
||||
if (!minMaxState.value && details.delta) {
|
||||
position.value.x += details.delta.x || 0;
|
||||
position.value.y += details.delta.y || 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Resizing
|
||||
const isResizing = ref(false);
|
||||
function startResizing(e: MouseEvent) {
|
||||
isResizing.value = true;
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startWidth = width.value;
|
||||
const startHeight = height.value;
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
width.value = Math.max(200, startWidth + e.clientX - startX);
|
||||
height.value = Math.max(200, startHeight + e.clientY - startY);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isResizing.value = false;
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
// Styles
|
||||
const cardStyle = computed(() => {
|
||||
if (minMaxState.value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
width:
|
||||
typeof width.value === 'number' || /^\d+$/.test(width.value)
|
||||
? `${width.value}px`
|
||||
: width.value,
|
||||
height:
|
||||
typeof height.value === 'number' || /^\d+$/.test(height.value)
|
||||
? `${height.value}px`
|
||||
: height.value,
|
||||
transform: `translate(${position.value.x}px, ${position.value.y}px)`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 10px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* Draggable header */
|
||||
.dialog-header {
|
||||
padding: 8px 0;
|
||||
background: #f5f5f5;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Scrollable content */
|
||||
.scrollArea {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Resize handle in bottom right */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
cursor: nwse-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
83
src/vueLib/general/useNotify.ts
Normal file
83
src/vueLib/general/useNotify.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { getCurrentInstance } from 'vue';
|
||||
import type { Response } from '../models/Response';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
export function useNotify() {
|
||||
const instance = getCurrentInstance();
|
||||
const $q = instance?.appContext.config.globalProperties.$q;
|
||||
|
||||
function NotifyResponse(
|
||||
response: Response | string | AxiosError | undefined,
|
||||
type?: 'warning' | 'error',
|
||||
timeout: number = 5000,
|
||||
) {
|
||||
let color = 'green';
|
||||
let icon = 'check_circle';
|
||||
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
color = 'orange';
|
||||
icon = 'warning';
|
||||
break;
|
||||
case 'error':
|
||||
color = 'red';
|
||||
icon = 'error';
|
||||
break;
|
||||
}
|
||||
|
||||
if (response) {
|
||||
let message = '';
|
||||
if (response instanceof AxiosError && response.response) {
|
||||
if (response.response.data) {
|
||||
const data = response.response.data as Response;
|
||||
message = data.message;
|
||||
}
|
||||
} else {
|
||||
message = typeof response === 'string' ? response : (response.message ?? '');
|
||||
}
|
||||
if (message === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
color = typeof response === 'string' ? color : type === 'error' ? 'red' : color;
|
||||
icon = typeof response === 'string' ? icon : type === 'error' ? 'error' : icon;
|
||||
if (!$q) {
|
||||
console.error(message);
|
||||
return;
|
||||
}
|
||||
$q?.notify({
|
||||
message: message,
|
||||
color: color,
|
||||
position: 'bottom-right',
|
||||
icon: icon,
|
||||
timeout: timeout,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function NotifyDialog(title: string, text: string, okText?: string, cancelText?: string) {
|
||||
return new Promise((resolve) => {
|
||||
$q
|
||||
?.dialog({
|
||||
title: title,
|
||||
message: text,
|
||||
persistent: true,
|
||||
ok: okText ?? 'OK',
|
||||
cancel: cancelText ?? 'CANCEL',
|
||||
})
|
||||
.onOk(() => {
|
||||
resolve(true);
|
||||
})
|
||||
.onCancel(() => {
|
||||
resolve(false);
|
||||
})
|
||||
.onDismiss(() => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
return {
|
||||
NotifyDialog,
|
||||
NotifyResponse,
|
||||
};
|
||||
}
|
||||
57
src/vueLib/login/LoginDialog.vue
Normal file
57
src/vueLib/login/LoginDialog.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<DialogFrame ref="Dialog" :width="300" :height="380" header-title="Login">
|
||||
<LoginForm v-on:update-close="close" />
|
||||
</DialogFrame>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue';
|
||||
import DialogFrame from '../dialog/DialogFrame.vue';
|
||||
import { useNotify } from '../general/useNotify';
|
||||
import LoginForm from './LoginForm.vue';
|
||||
|
||||
const { NotifyResponse } = useNotify();
|
||||
const Dialog = ref();
|
||||
const refUserInput = ref();
|
||||
|
||||
const open = () => {
|
||||
Dialog.value?.open();
|
||||
nextTick(() => {
|
||||
refUserInput.value?.focus();
|
||||
}).catch((err) => NotifyResponse(err, 'error'));
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
Dialog.value.close();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
20% {
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.shake {
|
||||
animation: shake 0.4s ease;
|
||||
border: 2px solid #f44336;
|
||||
}
|
||||
</style>
|
||||
110
src/vueLib/login/LoginForm.vue
Normal file
110
src/vueLib/login/LoginForm.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="text-black"></div>
|
||||
<q-form ref="refForm">
|
||||
<q-item-section class="q-gutter-md q-pa-md">
|
||||
<q-card :class="['q-gutter-xs q-items-center q-pa-lg', { shake: shake }]">
|
||||
<div class="text-h5 text-primary text-center">{{ productName }}</div>
|
||||
<q-input
|
||||
ref="refUserInput"
|
||||
dense
|
||||
filled
|
||||
type="text"
|
||||
:label="$t('user')"
|
||||
v-model="user"
|
||||
:rules="[(val) => !!val || $t('user') + ' ' + $t('isRequired')]"
|
||||
></q-input>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:label="$t('password')"
|
||||
v-model="password"
|
||||
@keyup.enter="onSubmit"
|
||||
:rules="[(val) => !!val || $t('password') + ' ' + $t('isRequired')]"
|
||||
>
|
||||
<template #append>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
:icon="showPassword ? 'visibility_off' : 'visibility'"
|
||||
@mousedown.left="showPassword = true"
|
||||
@mouseup.left="showPassword = false"
|
||||
@mouseleave="showPassword = false"
|
||||
></q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
<div class="q-pt-sm q-mr-md row justify-end">
|
||||
<q-btn color="primary" :label="$t('login')" @click="onSubmit"></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-item-section>
|
||||
</q-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { productName } from '../../../package.json';
|
||||
import { ref } from 'vue';
|
||||
import { useNotify } from '../general/useNotify';
|
||||
import { useLogin } from './useLogin';
|
||||
|
||||
const refForm = ref();
|
||||
const refUserInput = ref();
|
||||
const user = ref('');
|
||||
const password = ref('');
|
||||
const showPassword = ref(false);
|
||||
const shake = ref(false);
|
||||
|
||||
const { NotifyResponse } = useNotify();
|
||||
const { login } = useLogin();
|
||||
|
||||
const emit = defineEmits(['update-close']);
|
||||
|
||||
const onSubmit = () => {
|
||||
refForm.value?.validate().then((success: boolean) => {
|
||||
if (success) {
|
||||
login(user.value, password.value)
|
||||
.then(() => {
|
||||
NotifyResponse("logged in as '" + user.value + "'");
|
||||
emit('update-close');
|
||||
})
|
||||
.catch((err) => {
|
||||
NotifyResponse(err, 'error');
|
||||
shake.value = true;
|
||||
setTimeout(() => {
|
||||
shake.value = false;
|
||||
}, 500);
|
||||
});
|
||||
} else {
|
||||
NotifyResponse('error submitting login form', 'error');
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
20% {
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.shake {
|
||||
animation: shake 0.4s ease;
|
||||
border: 2px solid #f44336;
|
||||
}
|
||||
</style>
|
||||
77
src/vueLib/login/LoginMenu.vue
Normal file
77
src/vueLib/login/LoginMenu.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="q-gutter-md">
|
||||
<q-btn dense flat round icon="person" :color="currentUser ? 'green' : ''">
|
||||
<q-menu ref="refLoginMenu">
|
||||
<q-list style="min-width: 120px">
|
||||
<q-item v-if="userLogin.getUser()" class="text-primary">{{
|
||||
currentUser?.username
|
||||
}}</q-item>
|
||||
<q-item v-if="showLogin" clickable v-close-popup @click="openLogin">
|
||||
<q-item-section class="text-primary">{{ loginText }}</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-select
|
||||
:label="$t('language')"
|
||||
borderless
|
||||
color="primary"
|
||||
dense
|
||||
v-model="langSelected"
|
||||
:options="langSelection"
|
||||
></q-select>
|
||||
</q-item>
|
||||
<q-item v-if="autorized">
|
||||
<q-btn flat color="secondary" icon="settings" to="/settings"></q-btn>
|
||||
</q-item>
|
||||
<q-item v-if="autorized">
|
||||
<q-btn flat color="secondary" icon="group" to="/usersSettings"></q-btn>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
<LoginDialog ref="refLoginDialog"></LoginDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LoginDialog from './LoginDialog.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useLogin } from './useLogin';
|
||||
import { useNotify } from '../general/useNotify';
|
||||
import { lang, i18n } from 'src/boot/lang';
|
||||
import { useUserStore } from './userStore';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
const showLogin = computed(
|
||||
() => (route.path !== '/' && route.path !== '/login') || currentUser.value?.username === '',
|
||||
);
|
||||
|
||||
const userLogin = useLogin();
|
||||
const user = useUserStore();
|
||||
const autorized = computed(() => !!user.isAuthorizedAs(['admin']));
|
||||
const { NotifyResponse } = useNotify();
|
||||
const currentUser = computed(() => userLogin.getUser());
|
||||
|
||||
const loginText = computed(() => {
|
||||
return currentUser.value ? 'Logout' : 'Login';
|
||||
});
|
||||
|
||||
const refLoginDialog = ref();
|
||||
|
||||
function openLogin() {
|
||||
if (currentUser.value) {
|
||||
userLogin.logout().catch((err) => NotifyResponse(err, 'error'));
|
||||
return;
|
||||
}
|
||||
refLoginDialog.value?.open();
|
||||
}
|
||||
|
||||
const langSelected = ref(i18n.global.locale);
|
||||
const langSelection = ref(lang);
|
||||
|
||||
// Watch for changes and update i18n locale
|
||||
watch(langSelected, (newLang) => {
|
||||
i18n.global.locale = newLang;
|
||||
localStorage.setItem('lang', newLang);
|
||||
});
|
||||
</script>
|
||||
86
src/vueLib/login/useLogin.ts
Normal file
86
src/vueLib/login/useLogin.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { appApi } from 'src/boot/axios';
|
||||
import { useUserStore } from './userStore';
|
||||
import { useNotify } from '../general/useNotify';
|
||||
import type { Settings } from '../models/settings';
|
||||
import { Logo } from '../models/logo';
|
||||
|
||||
const refreshTime = 10000;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function useLogin() {
|
||||
const userStore = useUserStore();
|
||||
const { NotifyResponse } = useNotify();
|
||||
|
||||
async function login(user: string, password: string) {
|
||||
try {
|
||||
await appApi.post('/login', { user, password }).then((resp) => {
|
||||
const sets = resp.data.settings as Settings;
|
||||
|
||||
Logo.value = sets.icon;
|
||||
document.documentElement.style.setProperty('--q-primary', sets.primaryColor);
|
||||
document.documentElement.style.setProperty('--q-secondary', sets.secondaryColor);
|
||||
localStorage.setItem('icon', sets.icon);
|
||||
localStorage.setItem('databaseName', sets.databaseName);
|
||||
localStorage.setItem('primaryColor', sets.primaryColor);
|
||||
localStorage.setItem('secondaryColor', sets.secondaryColor);
|
||||
});
|
||||
|
||||
const resp = await appApi.get('/login/me');
|
||||
userStore.setUser({ username: resp.data.user, role: resp.data.role });
|
||||
|
||||
startRefreshInterval();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await appApi.get('/logout').catch((err) => {
|
||||
NotifyResponse(err, 'error');
|
||||
});
|
||||
|
||||
userStore.clearUser();
|
||||
stopRefreshInterval();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await appApi
|
||||
.post('secure/login/refresh', {}, { withCredentials: true })
|
||||
.then(() => {
|
||||
appApi
|
||||
.get('/login/me')
|
||||
.then((resp) => {
|
||||
userStore.setUser({ username: resp.data.user, role: resp.data.role });
|
||||
if (!intervalId) {
|
||||
startRefreshInterval();
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.catch(() => {});
|
||||
})
|
||||
.catch(() => {
|
||||
userStore.clearUser();
|
||||
});
|
||||
stopRefreshInterval();
|
||||
return false;
|
||||
}
|
||||
function getUser() {
|
||||
return userStore.getUser();
|
||||
}
|
||||
|
||||
function startRefreshInterval() {
|
||||
intervalId = setInterval(() => {
|
||||
refresh().catch((err) => NotifyResponse(err, 'error'));
|
||||
}, refreshTime);
|
||||
}
|
||||
function stopRefreshInterval() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { login, logout, refresh, getUser };
|
||||
}
|
||||
67
src/vueLib/login/userStore.ts
Normal file
67
src/vueLib/login/userStore.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useGlobalRouter } from 'src/utils/globalRouter';
|
||||
import { useGlobalQ } from 'src/utils/globalQ';
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: (): UserState => ({
|
||||
user: null,
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state): boolean => !!state.user,
|
||||
},
|
||||
actions: {
|
||||
setUser(user: User) {
|
||||
this.user = user;
|
||||
},
|
||||
getUser() {
|
||||
return this.user;
|
||||
},
|
||||
clearUser() {
|
||||
const $q = useGlobalQ();
|
||||
|
||||
if (!this.user) return;
|
||||
if ($q) {
|
||||
$q?.notify({
|
||||
message: "user '" + this.user?.username + "' logged out",
|
||||
color: 'orange',
|
||||
position: 'bottom-right',
|
||||
icon: 'warning',
|
||||
timeout: 5000,
|
||||
});
|
||||
} else {
|
||||
console.error("user '" + this.user?.username + "' logged out");
|
||||
}
|
||||
|
||||
this.user = null;
|
||||
|
||||
const router = useGlobalRouter();
|
||||
|
||||
router?.push('/').catch((err) => {
|
||||
if ($q) {
|
||||
$q?.notify({
|
||||
message: err,
|
||||
color: 'orange',
|
||||
position: 'bottom-right',
|
||||
icon: 'warning',
|
||||
timeout: 5000,
|
||||
});
|
||||
} else {
|
||||
console.error("user '" + this.user?.username + "' logged out");
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
isAuthorizedAs(roles: string[]) {
|
||||
return this.user !== null && roles.includes(this.user.role);
|
||||
},
|
||||
},
|
||||
});
|
||||
3
src/vueLib/models/Response.ts
Normal file
3
src/vueLib/models/Response.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface Response {
|
||||
message: string;
|
||||
}
|
||||
3
src/vueLib/models/logo.ts
Normal file
3
src/vueLib/models/logo.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const Logo = ref('');
|
||||
18
src/vueLib/models/member.ts
Normal file
18
src/vueLib/models/member.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface Member {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
birthday: string;
|
||||
age: string;
|
||||
address: string;
|
||||
town: string;
|
||||
zip: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
group: string;
|
||||
responsiblePerson: string;
|
||||
firstVisit: string;
|
||||
lastVisit: string;
|
||||
}
|
||||
|
||||
export type Members = Member[];
|
||||
12
src/vueLib/models/metaData.ts
Normal file
12
src/vueLib/models/metaData.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface MetaData {
|
||||
rowIndex: number;
|
||||
seperator: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
birthday: string;
|
||||
address: string;
|
||||
town: string;
|
||||
zip: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
}
|
||||
6
src/vueLib/models/settings.ts
Normal file
6
src/vueLib/models/settings.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type Settings = {
|
||||
icon: string;
|
||||
databaseName: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
};
|
||||
8
src/vueLib/models/users.ts
Normal file
8
src/vueLib/models/users.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
role: string;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
export type Users = User[];
|
||||
208
src/vueLib/tables/members/MembersTable.ts
Normal file
208
src/vueLib/tables/members/MembersTable.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { appApi } from 'src/boot/axios';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Member, Members } from 'src/vueLib/models/member';
|
||||
import { useNotify } from 'src/vueLib/general/useNotify';
|
||||
import { i18n } from 'boot/lang';
|
||||
|
||||
export const databaseName = ref('members.dba');
|
||||
|
||||
export function useMemberTable() {
|
||||
const members = ref<Members>([]);
|
||||
|
||||
const pagination = ref({
|
||||
sortBy: 'firstName',
|
||||
descending: false,
|
||||
page: 1,
|
||||
rowsPerPage: 10,
|
||||
});
|
||||
|
||||
const columns = computed(() => [
|
||||
{ name: 'cake', align: 'center' as const, label: '', field: 'cake', icon: 'cake' },
|
||||
{
|
||||
name: 'firstName',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('prename'),
|
||||
field: 'firstName',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('lastname'),
|
||||
field: 'lastName',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'birthday',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('birthday'),
|
||||
field: 'birthday',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'age',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('age'),
|
||||
field: 'age',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('address'),
|
||||
field: 'address',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'town',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('town'),
|
||||
field: 'town',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'zip',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('zipCode'),
|
||||
field: 'zip',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('phone'),
|
||||
field: 'phone',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('email'),
|
||||
field: 'email',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('group'),
|
||||
field: 'group',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'responsiblePerson',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('responsible'),
|
||||
field: 'responsiblePerson',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'firstVisit',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('firstVisit'),
|
||||
field: 'firstVisit',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'lastVisit',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('lastVisit'),
|
||||
field: 'lastVisit',
|
||||
sortable: true,
|
||||
},
|
||||
{ name: 'option', align: 'center' as const, label: '', field: 'option', icon: 'option' },
|
||||
]);
|
||||
|
||||
const { NotifyResponse } = useNotify();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
function calculateAge(birthDateString: string) {
|
||||
if (birthDateString === undefined) return 0;
|
||||
const [day, month, year] = birthDateString.split('.').map(Number); // for format "1.2.2000"
|
||||
if (year === undefined || month === undefined || day === undefined) return 0;
|
||||
const birthDate = new Date(year, month - 1, day); // month is 0-based
|
||||
|
||||
const today = new Date();
|
||||
|
||||
let age = today.getFullYear() - birthDate.getFullYear();
|
||||
|
||||
// Check if birthday has occurred yet this year
|
||||
const hasBirthdayPassed =
|
||||
today.getMonth() > birthDate.getMonth() ||
|
||||
(today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate());
|
||||
|
||||
if (!hasBirthdayPassed) {
|
||||
age--;
|
||||
}
|
||||
|
||||
return age;
|
||||
}
|
||||
|
||||
//isThreeDaysBeforeAnnualDate check if upcoming date is less than 3 days from now
|
||||
function isXDaysBeforeAnnualDate(dateString: string, before: number) {
|
||||
if (dateString === undefined) return;
|
||||
const [day, month] = dateString.split('.').map(Number);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0);
|
||||
|
||||
const annualDate = new Date(today.getFullYear(), month ? month - 1 : 0, day);
|
||||
annualDate.setHours(0, 0, 1);
|
||||
|
||||
const xDaysBefore = new Date(annualDate);
|
||||
xDaysBefore.setDate(annualDate.getDate() - before);
|
||||
|
||||
return today >= xDaysBefore && today <= annualDate;
|
||||
}
|
||||
|
||||
function getRowClass(row: Member) {
|
||||
if (isXDaysBeforeAnnualDate(row.birthday, 1)) {
|
||||
return 'bg-red-2 text-red-10';
|
||||
} else if (isXDaysBeforeAnnualDate(row.birthday, 4)) {
|
||||
return 'bg-green-2 text-green-10';
|
||||
} else if (isXDaysBeforeAnnualDate(row.birthday, 8)) {
|
||||
return 'bg-amber-2 text-amber-10';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
//updates member list from database
|
||||
function updateMembers() {
|
||||
loading.value = true;
|
||||
|
||||
appApi
|
||||
.get('secure/members')
|
||||
.then((resp) => {
|
||||
if (resp.data === null) {
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
members.value = resp.data as Members;
|
||||
if (members.value === null) {
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
members.value.forEach((member) => {
|
||||
if (member.birthday !== undefined) {
|
||||
member.age = String(calculateAge(member.birthday));
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
.catch((err) => {
|
||||
NotifyResponse(err, 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
return {
|
||||
members,
|
||||
pagination,
|
||||
columns,
|
||||
loading,
|
||||
getRowClass,
|
||||
updateMembers,
|
||||
isXDaysBeforeAnnualDate,
|
||||
};
|
||||
}
|
||||
272
src/vueLib/tables/members/MembersTable.vue
Normal file
272
src/vueLib/tables/members/MembersTable.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="q-pa-md">
|
||||
<q-table
|
||||
flat
|
||||
bordered
|
||||
ref="tableRef"
|
||||
title="Members"
|
||||
title-class="text-bold text-blue-9"
|
||||
:no-data-label="$t('noDataAvailable')"
|
||||
:loading-label="$t('loading')"
|
||||
:rows-per-page-label="$t('recordsPerPage')"
|
||||
:selected-rows-label="(val) => val + $t('recordSelected')"
|
||||
:rows="members"
|
||||
: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 dense flat icon="add" @click="openAllValueDialog(null)">
|
||||
<q-tooltip>{{ $t('addNewMember') }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
style="color: grey"
|
||||
:icon="selectOption ? 'check_box' : 'check_box_outline_blank'"
|
||||
@click="selectOption = !selectOption"
|
||||
>
|
||||
<q-tooltip>{{ $t('selectMemberOptions') }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn dense flat icon="upload" @click="openUploadDialog">
|
||||
<q-tooltip>{{ $t('importCSV') }}</q-tooltip>
|
||||
</q-btn>
|
||||
</q-btn-group>
|
||||
<div v-if="selectOption && selected.length > 0">
|
||||
<q-btn flat dense icon="more_vert" @click="openSubmenu = true" />
|
||||
<q-menu v-if="openSubmenu" anchor="bottom middle" self="top middle">
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="openRemoveDialog(...selected)"
|
||||
class="text-negative"
|
||||
>{{ $t('delete') }}</q-item
|
||||
>
|
||||
</q-menu>
|
||||
</div>
|
||||
<div v-if="selectOption && selected.length > 0" class="q-ml-md text-weight-bold">
|
||||
{{ $t('selected') }}: {{ selected.length }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:top-right>
|
||||
<q-input filled dense debounce="300" v-model="filter" :placeholder="$t('search')">
|
||||
<template v-slot:append>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
<template v-slot:body-cell="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="getRowClass(props.row)"
|
||||
@click="openSingleValueDialog(props.col.label, props.col.name, props.row)"
|
||||
>
|
||||
{{ props.value }}
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-cake="props">
|
||||
<q-td :props="props" :class="getRowClass(props.row)">
|
||||
<q-icon
|
||||
:name="isXDaysBeforeAnnualDate(props.row.birthday, 1) ? 'cake' : ''"
|
||||
:color="'red'"
|
||||
size="md"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-option="props">
|
||||
<q-td :props="props">
|
||||
<q-btn flat dense icon="more_vert" @click="openSubmenu = true" />
|
||||
<q-menu v-if="openSubmenu" anchor="top right" self="top left">
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="openAllValueDialog(props.row)"
|
||||
class="text-primary"
|
||||
>{{ $t('edit') }}</q-item
|
||||
>
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="openRemoveDialog(props.row)"
|
||||
class="text-negative"
|
||||
title="zu"
|
||||
>{{ $t('delete') }}</q-item
|
||||
>
|
||||
</q-menu>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
<EditOneDialog ref="editOneDialog" v-on:update-member="updateMembers"></EditOneDialog>
|
||||
<EditAllDialog ref="editAllDialog" v-on:update-member="updateMembers"></EditAllDialog>
|
||||
<OkDialog
|
||||
ref="okDialog"
|
||||
:dialog-label="$t('delete')"
|
||||
:text="$t('doYouWantToDelete') + ' ' + deleteText"
|
||||
label-color="red"
|
||||
:button-cancel-label="$t('cancel')"
|
||||
:button-ok-label="$t('confirm')"
|
||||
:button-ok-flat="false"
|
||||
button-ok-color="red"
|
||||
v-on:update-confirm="(val) => removeMember(...val)"
|
||||
></OkDialog>
|
||||
<UploadDialog ref="uploadDialog" @update-upload="updateMembers"> </UploadDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { appApi } from 'src/boot/axios';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import type { Member, Members } from 'src/vueLib/models/member';
|
||||
import EditOneDialog from 'src/components/EditOneDialog.vue';
|
||||
import EditAllDialog from 'src/components/EditAllDialog.vue';
|
||||
import OkDialog from 'src/components/dialog/OkDialog.vue';
|
||||
import { useNotify } from 'src/vueLib/general/useNotify';
|
||||
import { useMemberTable } from './MembersTable';
|
||||
import UploadDialog from 'src/components/UploadDialog.vue';
|
||||
import { databaseName } from './MembersTable';
|
||||
|
||||
export interface MemberDialog {
|
||||
getSelected: () => Members;
|
||||
}
|
||||
|
||||
const { NotifyResponse } = useNotify();
|
||||
const editOneDialog = ref();
|
||||
const editAllDialog = ref();
|
||||
const uploadDialog = ref();
|
||||
const okDialog = ref();
|
||||
const deleteText = ref('');
|
||||
const selectOption = ref(false);
|
||||
const selected = ref<Members>([]);
|
||||
const openSubmenu = ref(false);
|
||||
const filter = ref('');
|
||||
|
||||
const {
|
||||
members,
|
||||
pagination,
|
||||
loading,
|
||||
columns,
|
||||
getRowClass,
|
||||
updateMembers,
|
||||
isXDaysBeforeAnnualDate,
|
||||
} = useMemberTable();
|
||||
|
||||
//load on mounting page
|
||||
onMounted(() => {
|
||||
loading.value = true;
|
||||
|
||||
appApi
|
||||
.post('secure/database/open', { dbPath: databaseName.value, create: true })
|
||||
.then(() => {
|
||||
updateMembers();
|
||||
})
|
||||
.catch((err) => NotifyResponse(err, 'error'))
|
||||
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
// opens dialog for all member values
|
||||
function openSingleValueDialog(label: string, field: string, member: Member) {
|
||||
editOneDialog.value?.open(label, field, member);
|
||||
}
|
||||
|
||||
//opens dialog for one value
|
||||
function openAllValueDialog(member: Member | null) {
|
||||
editAllDialog.value?.open(member);
|
||||
}
|
||||
|
||||
//opens remove dialog
|
||||
function openRemoveDialog(...members: Members) {
|
||||
if (members.length === 1) {
|
||||
deleteText.value = "'";
|
||||
if (members[0]?.firstName !== undefined) {
|
||||
deleteText.value += members[0]?.firstName + ' ';
|
||||
}
|
||||
if (members[0]?.lastName !== undefined) {
|
||||
deleteText.value += members[0]?.lastName;
|
||||
}
|
||||
deleteText.value += "'";
|
||||
} else {
|
||||
deleteText.value = String(members.length) + ' members';
|
||||
}
|
||||
okDialog.value?.open(members);
|
||||
}
|
||||
|
||||
//opens uploader dialog
|
||||
function openUploadDialog() {
|
||||
uploadDialog.value?.open();
|
||||
}
|
||||
|
||||
//remove member from database
|
||||
function removeMember(...removeMembers: Members) {
|
||||
const memberIds: number[] = [];
|
||||
|
||||
removeMembers.forEach((member: Member) => {
|
||||
memberIds.push(member.id);
|
||||
});
|
||||
|
||||
appApi
|
||||
.post('secure/members/delete', { ids: memberIds })
|
||||
.then(() => {
|
||||
updateMembers();
|
||||
selected.value = [];
|
||||
})
|
||||
.catch((err) => NotifyResponse(err, 'error'))
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
//const blinkingId = ref<number | null>(null);
|
||||
|
||||
// function triggerBlink(id: number) {
|
||||
// blinkingId.value = id;
|
||||
|
||||
// // Optional: stop blinking after 3 seconds
|
||||
// setTimeout(() => {
|
||||
// blinkingId.value = null;
|
||||
// }, 3000);
|
||||
// }
|
||||
|
||||
function getSelected(): Members {
|
||||
if (selected.value.length === 0) return [];
|
||||
return selected.value;
|
||||
}
|
||||
defineExpose({
|
||||
getSelected,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes blink-yellow {
|
||||
0%,
|
||||
100% {
|
||||
background-color: yellow;
|
||||
}
|
||||
50% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.blink-yellow {
|
||||
animation: blink-yellow 1.5s step-start 6 !important;
|
||||
}
|
||||
|
||||
.bigger-table-text .q-table__middle td {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.bigger-table-text .q-table__top,
|
||||
.bigger-table-text .q-table__bottom,
|
||||
.bigger-table-text th {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
149
src/vueLib/tables/users/UserTable.ts
Normal file
149
src/vueLib/tables/users/UserTable.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { appApi } from 'src/boot/axios';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useNotify } from 'src/vueLib/general/useNotify';
|
||||
import { i18n } from 'boot/lang';
|
||||
import type { Users } from 'src/vueLib/models/users';
|
||||
|
||||
export function useUserTable() {
|
||||
const users = ref<Users>([]);
|
||||
|
||||
const pagination = ref({
|
||||
sortBy: 'firstName',
|
||||
descending: false,
|
||||
page: 1,
|
||||
rowsPerPage: 10,
|
||||
});
|
||||
|
||||
const columns = computed(() => [
|
||||
{ name: 'cake', align: 'center' as const, label: '', field: 'cake', icon: 'cake' },
|
||||
{
|
||||
name: 'firstName',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('prename'),
|
||||
field: 'firstName',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('lastname'),
|
||||
field: 'lastName',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'birthday',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('birthday'),
|
||||
field: 'birthday',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'age',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('age'),
|
||||
field: 'age',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('address'),
|
||||
field: 'address',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'town',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('town'),
|
||||
field: 'town',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'zip',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('zipCode'),
|
||||
field: 'zip',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('phone'),
|
||||
field: 'phone',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('email'),
|
||||
field: 'email',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('group'),
|
||||
field: 'group',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'responsiblePerson',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('responsible'),
|
||||
field: 'responsiblePerson',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'firstVisit',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('firstVisit'),
|
||||
field: 'firstVisit',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'lastVisit',
|
||||
align: 'left' as const,
|
||||
label: i18n.global.t('lastVisit'),
|
||||
field: 'lastVisit',
|
||||
sortable: true,
|
||||
},
|
||||
{ name: 'option', align: 'center' as const, label: '', field: 'option', icon: 'option' },
|
||||
]);
|
||||
|
||||
const { NotifyResponse } = useNotify();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
//updates user list from database
|
||||
function updateUsers() {
|
||||
loading.value = true;
|
||||
|
||||
appApi
|
||||
.get('users')
|
||||
.then((resp) => {
|
||||
if (resp.data === null) {
|
||||
users.value = [];
|
||||
return;
|
||||
}
|
||||
users.value = resp.data as Users;
|
||||
if (users.value === null) {
|
||||
users.value = [];
|
||||
return;
|
||||
}
|
||||
})
|
||||
|
||||
.catch((err) => {
|
||||
NotifyResponse(err, 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
return {
|
||||
users,
|
||||
pagination,
|
||||
columns,
|
||||
loading,
|
||||
updateUsers,
|
||||
};
|
||||
}
|
||||
243
src/vueLib/tables/users/VueTable.vue
Normal file
243
src/vueLib/tables/users/VueTable.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div class="q-pa-md">
|
||||
<q-table
|
||||
flat
|
||||
bordered
|
||||
ref="tableRef"
|
||||
title="Users"
|
||||
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="users"
|
||||
: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 dense flat icon="add" @click="openAllValueDialog(null)">
|
||||
<q-tooltip>{{ $t('addNewUser') }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
style="color: grey"
|
||||
:icon="selectOption ? 'check_box' : 'check_box_outline_blank'"
|
||||
@click="selectOption = !selectOption"
|
||||
>
|
||||
<q-tooltip>{{ $t('selectUserOptions') }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn dense flat icon="upload" @click="openUploadDialog">
|
||||
<q-tooltip>{{ $t('importCSV') }}</q-tooltip>
|
||||
</q-btn>
|
||||
</q-btn-group>
|
||||
<div v-if="selectOption && selected.length > 0">
|
||||
<q-btn flat dense icon="more_vert" @click="openSubmenu = true" />
|
||||
<q-menu v-if="openSubmenu" anchor="bottom middle" self="top middle">
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="openRemoveDialog(...selected)"
|
||||
class="text-negative"
|
||||
>{{ $t('delete') }}</q-item
|
||||
>
|
||||
</q-menu>
|
||||
</div>
|
||||
<div v-if="selectOption && selected.length > 0" class="q-ml-md text-weight-bold">
|
||||
{{ $t('selected') }}: {{ selected.length }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:top-right>
|
||||
<q-input filled dense debounce="300" v-model="filter" :placeholder="$t('search')">
|
||||
<template v-slot:append>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
<template v-slot:body-cell="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
@click="openSingleValueDialog(props.col.label, props.col.name, props.row)"
|
||||
>
|
||||
{{ props.value }}
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-option="props">
|
||||
<q-td :props="props">
|
||||
<q-btn flat dense icon="more_vert" @click="openSubmenu = true" />
|
||||
<q-menu v-if="openSubmenu" anchor="top right" self="top left">
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="openAllValueDialog(props.row)"
|
||||
class="text-primary"
|
||||
>{{ $t('edit') }}</q-item
|
||||
>
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="openRemoveDialog(props.row)"
|
||||
class="text-negative"
|
||||
title="zu"
|
||||
>{{ $t('delete') }}</q-item
|
||||
>
|
||||
</q-menu>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
<EditOneDialog ref="editOneDialog" v-on:update-member="updateUsers"></EditOneDialog>
|
||||
<EditAllDialog ref="editAllDialog" v-on:update-member="updateUsers"></EditAllDialog>
|
||||
<OkDialog
|
||||
ref="okDialog"
|
||||
:dialog-label="$t('delete')"
|
||||
:text="$t('doYouWantToDelete') + ' ' + deleteText"
|
||||
label-color="red"
|
||||
:button-cancel-label="$t('cancel')"
|
||||
:button-ok-label="$t('confirm')"
|
||||
:button-ok-flat="false"
|
||||
button-ok-color="red"
|
||||
v-on:update-confirm="(val) => removeUser(...val)"
|
||||
></OkDialog>
|
||||
<UploadDialog ref="uploadDialog" @update-upload="updateUsers"> </UploadDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { appApi } from 'src/boot/axios';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import type { Users, User } from 'src/vueLib/models/users';
|
||||
import EditOneDialog from 'src/components/EditOneDialog.vue';
|
||||
import EditAllDialog from 'src/components/EditAllDialog.vue';
|
||||
import OkDialog from 'src/components/dialog/OkDialog.vue';
|
||||
import { useNotify } from 'src/vueLib/general/useNotify';
|
||||
import UploadDialog from 'src/components/UploadDialog.vue';
|
||||
import { databaseName } from '../members/MembersTable';
|
||||
import { useUserTable } from './UserTable';
|
||||
|
||||
const { NotifyResponse } = useNotify();
|
||||
const editOneDialog = ref();
|
||||
const editAllDialog = ref();
|
||||
const uploadDialog = ref();
|
||||
const okDialog = ref();
|
||||
const deleteText = ref('');
|
||||
const selectOption = ref(false);
|
||||
const selected = ref<Users>([]);
|
||||
const openSubmenu = ref(false);
|
||||
const filter = ref('');
|
||||
|
||||
const { users, pagination, loading, columns, updateUsers } = useUserTable();
|
||||
|
||||
//load on mounting page
|
||||
onMounted(() => {
|
||||
loading.value = true;
|
||||
|
||||
appApi
|
||||
.post('secure/database/open', { dbPath: databaseName.value, create: true })
|
||||
.then(() => {
|
||||
updateUsers();
|
||||
})
|
||||
.catch((err) => NotifyResponse(err, 'error'))
|
||||
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
// opens dialog for all user values
|
||||
function openSingleValueDialog(label: string, field: string, user: User) {
|
||||
editOneDialog.value?.open(label, field, user);
|
||||
}
|
||||
|
||||
//opens dialog for one value
|
||||
function openAllValueDialog(user: User | null) {
|
||||
editAllDialog.value?.open(user);
|
||||
}
|
||||
|
||||
//opens remove dialog
|
||||
function openRemoveDialog(...users: Users) {
|
||||
if (users.length === 1) {
|
||||
deleteText.value = "'" + users[0]?.name + "'";
|
||||
} else {
|
||||
deleteText.value = String(users.length) + ' users';
|
||||
}
|
||||
okDialog.value?.open(users);
|
||||
}
|
||||
|
||||
//opens uploader dialog
|
||||
function openUploadDialog() {
|
||||
uploadDialog.value?.open();
|
||||
}
|
||||
|
||||
//remove user from database
|
||||
function removeUser(...removeUsers: Users) {
|
||||
const userIds: number[] = [];
|
||||
|
||||
removeUsers.forEach((user: User) => {
|
||||
userIds.push(user.id);
|
||||
});
|
||||
|
||||
appApi
|
||||
.post('users/delete', { ids: userIds })
|
||||
.then(() => {
|
||||
updateUsers();
|
||||
selected.value = [];
|
||||
})
|
||||
.catch((err) => NotifyResponse(err, 'error'))
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
//const blinkingId = ref<number | null>(null);
|
||||
|
||||
// function triggerBlink(id: number) {
|
||||
// blinkingId.value = id;
|
||||
|
||||
// // Optional: stop blinking after 3 seconds
|
||||
// setTimeout(() => {
|
||||
// blinkingId.value = null;
|
||||
// }, 3000);
|
||||
// }
|
||||
|
||||
function getSelected(): Users {
|
||||
if (selected.value.length === 0) return [];
|
||||
return selected.value;
|
||||
}
|
||||
defineExpose({
|
||||
getSelected,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes blink-yellow {
|
||||
0%,
|
||||
100% {
|
||||
background-color: yellow;
|
||||
}
|
||||
50% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.blink-yellow {
|
||||
animation: blink-yellow 1.5s step-start 6 !important;
|
||||
}
|
||||
|
||||
.bigger-table-text .q-table__middle td {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.bigger-table-text .q-table__top,
|
||||
.bigger-table-text .q-table__bottom,
|
||||
.bigger-table-text th {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user