ADD: Notificaciones en tiempo real (#3)

* ADD: Avances
* ADD: Usuarios conectados en tiempo real
This commit is contained in:
Moisés de Jesús Cortés Castellanos 2024-12-27 12:10:10 -06:00 committed by GitHub
parent d7d83b69a0
commit 24edbfebb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 833 additions and 201 deletions

View File

@ -2,7 +2,7 @@
"name": "notsoweb.frontend", "name": "notsoweb.frontend",
"copyright": "Notsoweb Software Inc.", "copyright": "Notsoweb Software Inc.",
"private": true, "private": true,
"version": "0.0.1", "version": "0.9.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -1,9 +1,9 @@
<script setup> <script setup>
import { onBeforeMount, onMounted } from 'vue'; import { onMounted } from 'vue';
import { bootPermissions } from '@Plugins/RolePermission.js';
import useDarkMode from '@Stores/DarkMode' import useDarkMode from '@Stores/DarkMode'
import useLeftSidebar from '@Stores/LeftSidebar' import useLeftSidebar from '@Stores/LeftSidebar'
import useNotificationSidebar from '@Stores/NotificationSidebar' import useNotificationSidebar from '@Stores/NotificationSidebar'
import useNotifier from '@Stores/Notifier'
import Header from '../Skeleton/Header.vue'; import Header from '../Skeleton/Header.vue';
import LeftSidebar from '../Skeleton/Sidebar/Left.vue'; import LeftSidebar from '../Skeleton/Sidebar/Left.vue';
@ -13,6 +13,7 @@ import NotificationSidebar from '../Skeleton/Sidebar/Notification.vue';
const darkMode = useDarkMode(); const darkMode = useDarkMode();
const leftSidebar = useLeftSidebar(); const leftSidebar = useLeftSidebar();
const notificationSidebar = useNotificationSidebar(); const notificationSidebar = useNotificationSidebar();
const notifier = useNotifier();
/** Propiedades */ /** Propiedades */
defineProps({ defineProps({
@ -20,13 +21,11 @@ defineProps({
}); });
/** Ciclos */ /** Ciclos */
onBeforeMount(() => {
bootPermissions()
})
onMounted(()=> { onMounted(() => {
leftSidebar.boot() leftSidebar.boot();
darkMode.boot() darkMode.boot();
notifier.boot();
}); });
</script> </script>

View File

@ -13,7 +13,7 @@ defineProps({
{{ title }} {{ title }}
</p> </p>
<p v-if="subtitle" <p v-if="subtitle"
class="text-sm text-primary-on dark:text-primary-dark-on" class="text-sm text-gray-50"
> >
{{ subtitle }} {{ subtitle }}
</p> </p>

View File

@ -13,10 +13,7 @@ const emit = defineEmits([
const props = defineProps({ const props = defineProps({
editable: Boolean, editable: Boolean,
show: Boolean, show: Boolean,
title: { title: String
default: Lang('details'),
type: String
}
}); });
</script> </script>
@ -25,7 +22,7 @@ const props = defineProps({
<template #title> <template #title>
<p <p
class="font-bold text-xl" class="font-bold text-xl"
v-text="title" v-text="title ?? $t('details')"
/> />
</template> </template>
<template #content> <template #content>

View File

@ -24,6 +24,12 @@ const query = ref('');
const search = () => { const search = () => {
emit('search', query.value); emit('search', query.value);
} }
const clear = () => {
query.value = '';
search();
}
</script> </script>
<template> <template>
<div v-if="title" class="flex w-full justify-center"> <div v-if="title" class="flex w-full justify-center">
@ -35,12 +41,19 @@ const search = () => {
<div class="flex w-full justify-between items-center border-y-2 border-page-t dark:border-page-dt"> <div class="flex w-full justify-between items-center border-y-2 border-page-t dark:border-page-dt">
<div> <div>
<div class="relative py-1 z-0"> <div class="relative py-1 z-0">
<div @click="search" class="absolute inset-y-0 right-2 flex items-center pl-3 cursor-pointer text-gray-700 hover:scale-110 hover:text-danger"> <div @click="search" class="absolute inset-y-0 right-2 flex items-center pl-3 cursor-pointer">
<GoogleIcon <GoogleIcon
:title="$t('search')" :title="$t('search')"
class="text-xl" class="text-xl text-gray-700 hover:scale-110 hover:text-danger"
name="search" name="search"
/> />
<GoogleIcon
v-show="query"
:title="$t('clear')"
class="text-xl text-gray-700 hover:scale-110 hover:text-danger"
name="close"
@click="clear"
/>
</div> </div>
<input <input
id="search" id="search"
@ -54,7 +67,7 @@ const search = () => {
/> />
</div> </div>
</div> </div>
<div class="flex items-center space-x-2 text-sm" id="buttons"> <div class="flex items-center space-x-1 text-sm" id="buttons">
<slot /> <slot />
<RouterLink :to="$view({name:'index'})"> <RouterLink :to="$view({name:'index'})">
<IconButton <IconButton

View File

@ -1,10 +1,14 @@
<script setup> <script setup>
import { users } from '@Plugins/AuthUsers'
import { hasPermission } from '@Plugins/RolePermission'
import { logout } from '@Services/Page'; import { logout } from '@Services/Page';
import useDarkMode from '@Stores/DarkMode' import useDarkMode from '@Stores/DarkMode'
import useLeftSidebar from '@Stores/LeftSidebar' import useLeftSidebar from '@Stores/LeftSidebar'
import useNotificationSidebar from '@Stores/NotificationSidebar' import useNotificationSidebar from '@Stores/NotificationSidebar'
import useNotifier from '@Stores/Notifier' import useNotifier from '@Stores/Notifier'
import useLoader from '@Stores/Loader';
import Loader from '@Shared/Loader.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue'; import GoogleIcon from '@Shared/GoogleIcon.vue';
import Dropdown from '../Dropdown.vue'; import Dropdown from '../Dropdown.vue';
import DropdownLink from '../DropdownLink.vue'; import DropdownLink from '../DropdownLink.vue';
@ -19,6 +23,8 @@ const darkMode = useDarkMode()
const leftSidebar = useLeftSidebar() const leftSidebar = useLeftSidebar()
const notificationSidebar = useNotificationSidebar() const notificationSidebar = useNotificationSidebar()
const notifier = useNotifier() const notifier = useNotifier()
const loader = useLoader()
</script> </script>
<template> <template>
@ -36,6 +42,19 @@ const notifier = useNotifier()
/> />
<div class="flex w-fit justify-end items-center h-14 header-right"> <div class="flex w-fit justify-end items-center h-14 header-right">
<ul class="flex items-center space-x-2"> <ul class="flex items-center space-x-2">
<li v-if="loader.isProcessing" class="flex items-center">
<Loader />
</li>
<li v-if="hasPermission('users.online')">
<RouterLink :to="{ name: 'admin.users.online' }" class="flex items-center">
<GoogleIcon
class="text-xl mt-1"
name="connect_without_contact"
:title="$t('notifications.title')"
/>
<span class="text-xs">{{ users.length - 1 }}</span>
</RouterLink>
</li>
<li class="flex items-center"> <li class="flex items-center">
<GoogleIcon <GoogleIcon
class="text-xl mt-1" class="text-xl mt-1"

View File

@ -1,15 +1,23 @@
<script setup> <script setup>
import { onMounted } from 'vue'; import { ref } from 'vue';
import { RouterLink } from 'vue-router';
import useNotificationSidebar from '@Stores/NotificationSidebar' import useNotificationSidebar from '@Stores/NotificationSidebar'
import useNotifier from '@Stores/Notifier' import useNotifier from '@Stores/Notifier'
import GoogleIcon from '@Shared/GoogleIcon.vue'; import ModalController from '@Controllers/ModalController.js';
import Item from './Notification/Item.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Item from './Notification/Item.vue';
import PrimaryButton from '@Holos/Button/Primary.vue';
import ShowView from '@Holos/Skeleton/Sidebar/Notification/Show.vue';
/** Definidores */ /** Definidores */
const notifier = useNotifier(); const notifier = useNotifier();
const notificationSidebar = useNotificationSidebar() const notificationSidebar = useNotificationSidebar()
/** Controladores */
const Modal = new ModalController();
/** Eventos */ /** Eventos */
const emit = defineEmits(['open']); const emit = defineEmits(['open']);
@ -18,10 +26,9 @@ const props = defineProps({
sidebar: Boolean sidebar: Boolean
}); });
/** Ciclos */ const showModal = ref(Modal.showModal);
onMounted(() => { const modelModal = ref(Modal.modelModal);
notifier.boot();
});
</script> </script>
<template> <template>
@ -37,9 +44,14 @@ onMounted(() => {
<div class="flex flex-col h-full p-2 md:w-64"> <div class="flex flex-col h-full p-2 md:w-64">
<div class="flex h-full flex-col w-[15.5rem] justify-between rounded-lg overflow-y-auto overflow-x-hidden bg-primary/70 text-primary-t dark:bg-primary-d/70 dark:text-primary-dt"> <div class="flex h-full flex-col w-[15.5rem] justify-between rounded-lg overflow-y-auto overflow-x-hidden bg-primary/70 text-primary-t dark:bg-primary-d/70 dark:text-primary-dt">
<div class="flex justify-between px-2 items-center"> <div class="flex justify-between px-2 items-center">
<h4 class="text-md text-center py-1 font-semibold"> <div class="py-1">
{{ $t('notifications.title') }} <span class="text-xs">({{ notifier.counter }})</span> <h4 class="text-md font-semibold">
</h4> {{ $t('notifications.title') }} <span class="text-xs">({{ notifier.counter }})</span>
</h4>
<h4 class="text-xs font-semibold" v-if="notifier.unreadClosedCounter > 0">
{{ $t('notifications.unreadClosed') }} <span class="text-xs"> ({{ notifier.unreadClosedCounter }})</span>
</h4>
</div>
<GoogleIcon <GoogleIcon
name="close" name="close"
class="text-primary-t dark:text-primary-dt cursor-pointer" class="text-primary-t dark:text-primary-dt cursor-pointer"
@ -47,15 +59,35 @@ onMounted(() => {
/> />
</div> </div>
<div class="flex h-full flex-col space-y-1"> <div class="flex h-full flex-col space-y-1">
<ul class="px-2 space-y-2 h-[calc(100vh-6.5rem)] overflow-y-auto"> <ul class="px-2 space-y-1 overflow-y-auto"
:class="{
'h-[calc(100vh-10rem)]': notifier.unreadClosedCounter > 0,
'h-[calc(100vh-9rem)]': notifier.unreadClosedCounter === 0
}"
>
<Item v-for="notification in notifier.notifications" <Item v-for="notification in notifier.notifications"
:key="notification.id" :key="notification.id"
:notification="notification" :notification="notification"
@openModal="Modal.switchShowModal(notification)"
/> />
</ul> </ul>
</div> </div>
<div class="flex justify-center items-center pb-1">
<RouterLink :to="$view({ name: 'profile.notifications.index' })">
<PrimaryButton type="button" @click="notificationSidebar.close()">
{{ $t('notifications.seeAll') }}
</PrimaryButton>
</RouterLink>
</div>
</div> </div>
</div> </div>
</section> </section>
<ShowView
:show="showModal"
:model="modelModal"
@close="Modal.switchShowModal"
@reload="notifier.getUpdates()"
/>
</div> </div>
</template> </template>

View File

@ -6,6 +6,11 @@ import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Definidores */ /** Definidores */
const notifier = useNotifier(); const notifier = useNotifier();
/** Eventos */
const emit = defineEmits([
'openModal'
]);
/** Propiedades */ /** Propiedades */
defineProps({ defineProps({
notification: Object, notification: Object,
@ -22,12 +27,12 @@ defineProps({
<GoogleIcon <GoogleIcon
name="close" name="close"
class="text-xs text-white cursor-pointer" class="text-xs text-white cursor-pointer"
@click="notifier.readNotification(notification.id)" @click="notifier.closeNotification(notification.id)"
/> />
</div> </div>
</div> </div>
<div class="flex w-full"> <div class="flex w-full cursor-pointer">
<div class="w-10 space-y-0"> <div class="w-10 space-y-0" @click="emit('openModal', notification)">
<template v-if="notification.user"> <template v-if="notification.user">
<div class="w-10 h-10 bg-transparent rounded-full flex items-center justify-center"> <div class="w-10 h-10 bg-transparent rounded-full flex items-center justify-center">
<img v-if="notification.user" <img v-if="notification.user"
@ -56,6 +61,10 @@ defineProps({
v-text="notification.data.title" v-text="notification.data.title"
class="text-sm font-medium truncate" class="text-sm font-medium truncate"
/> />
<div
v-text="notification.data.description"
class="text-xs w-40 font-thin truncate"
/>
<div v-if="notification.user" <div v-if="notification.user"
v-text="`~ ${notification.user.name} ${notification.user.paternal}`" v-text="`~ ${notification.user.name} ${notification.user.paternal}`"
class="text-xs text-gray-400 truncate" class="text-xs text-gray-400 truncate"

View File

@ -0,0 +1,74 @@
<script setup>
import { computed, nextTick, onUpdated } from 'vue';
import { getDateTime } from '@Controllers/DateController';
import useNotifier from '@Stores/Notifier';
import Header from '@Holos/Modal/Elements/Header.vue';
import ShowModal from '@Holos/Modal/Show.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Definidores */
const notifier = useNotifier();
/** Eventos */
const emit = defineEmits([
'close',
'reload'
]);
/** Propiedades */
const props = defineProps({
show: Boolean,
model: Object
});
onUpdated(() => {
if(!props.model.read_at && props.show) {
notifier.readNotification(props.model.id);
}
if(!props.model.read_at && !props.show) {
emit('reload');
}
});
</script>
<template>
<ShowModal
:show="show"
@close="$emit('close')"
>
<Header
:title="model.data.title"
>
</Header>
<div class="py-2 border-b">
<div class="flex w-full px-4 py-2">
<GoogleIcon
class="text-xl text-success"
name="contact_mail"
/>
<div class="pl-3">
<p class="font-bold text-lg leading-none pb-2">
{{ $t('details') }}
</p>
<div class="flex flex-col">
<b>{{ $t('description') }}: </b>
{{ model.data.description }}
</div>
<div v-if="model.data.message" class="flex flex-col">
<b>{{ $t('message') }}: </b>
{{ model.data.message }}
</div>
<p>
<b>{{ $t('created_at') }}: </b>
{{ getDateTime(model.created_at) }}
</p>
<p v-if="model.read_at">
<b>{{ $t('read_at') }}: </b>
{{ getDateTime(model.read_at) }}
</p>
</div>
</div>
</div>
</ShowModal>
</template>

View File

@ -1,5 +1,6 @@
<script setup> <script setup>
import GoogleIcon from '../Shared/GoogleIcon.vue'; import GoogleIcon from '../Shared/GoogleIcon.vue';
import Loader from '../Shared/Loader.vue';
/** Eventos */ /** Eventos */
const emit = defineEmits([ const emit = defineEmits([
@ -9,6 +10,7 @@ const emit = defineEmits([
/** Propiedades */ /** Propiedades */
const props = defineProps({ const props = defineProps({
items: Object, items: Object,
processing: Boolean
}); });
</script> </script>
@ -16,13 +18,13 @@ const props = defineProps({
<section class="pb-2"> <section class="pb-2">
<div class="w-full overflow-hidden rounded-md shadow-lg"> <div class="w-full overflow-hidden rounded-md shadow-lg">
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
<table class="w-full"> <table v-if="!processing" class="w-full">
<thead> <thead>
<tr> <tr>
<slot name="head" /> <slot name="head" />
</tr> </tr>
</thead> </thead>
<tbody class=""> <tbody>
<template v-if="items?.total > 0"> <template v-if="items?.total > 0">
<slot <slot
name="body" name="body"
@ -36,6 +38,24 @@ const props = defineProps({
</template> </template>
</tbody> </tbody>
</table> </table>
<table v-else class="animate-pulse w-full">
<thead>
<tr>
<th colspan="100%" class="h-8 text-center">
<div class="flex items-center justify-center">
<Loader />
</div>
</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="100%" class="table-item h-7 text-center">
<div class="w-full h-4 bg-secondary/50 rounded-md"></div>
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</section> </section>

View File

@ -25,9 +25,6 @@ const props = defineProps({
name="body" name="body"
:items="items" :items="items"
/> />
<tr>
<slot name="empty" />
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -0,0 +1,6 @@
<template>
<svg class="animate-spin -ml-1 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</template>

View File

@ -1,90 +0,0 @@
import { ref } from 'vue';
import { api } from '@Services/Api.js';
/**
* Controlador simple de las bandejas
*/
class SearcherController
{
route = '';
params = {};
query = ref('');
constructor({ route, model, params = {} }) {
this.route = route;
this.model = ref(model);
this.params = params;
}
/**
* Búsqueda simple
*/
search = (q = '', params) => {
this.query.value = q;
api.get(this._getRoute(), {
params: {
q: this.query.value,
...params
},
onSuccess: (r) => {
this.model.value = r.users;
}
});
};
/**
* Paginación simple
*/
withPagination = (page, params) => {
api.get(this._getRoute(), {
params: {
page,
...params
},
onSuccess: (r) => {
this.model.value = r.users;
}
});
}
/**
* Búsqueda con Paginación en tablas
*/
searchWithPagination = (page, params) => {
api.get(page, {
params: {
q: this.query.value,
...params
},
onSuccess: (r) => {
this.model.value = r.users;
}
});
}
/**
* Búsqueda con Paginación en bandejas
*/
searchWithInboxPagination = (page, params) => {
api.get(page, {
params: {
q: this.query.value,
...params
},
onSuccess: (r) => {
this.model.value = r.users;
}
});
}
/**
* Obtiene la ruta según los parámetros
*/
_getRoute = () => {
return (this.params)
? route(this.route, this.params)
: route(this.route);
}
}
export default SearcherController;

View File

@ -4,50 +4,63 @@ import axios from 'axios';
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { createApp } from 'vue' import { createApp } from 'vue'
import { useRoute, ZiggyVue } from 'ziggy-js'; import { useRoute, ZiggyVue } from 'ziggy-js';
import { i18n, lang } from '@/lang/i18n.js'; import { i18n, lang } from '@Lang/i18n.js';
import router from '@Router/Index' import router from '@Router/Index'
import Notify from '@Plugins/Notify' import Notify from '@Plugins/Notify'
import { bootPermissions } from '@Plugins/RolePermission';
import TailwindScreen from '@Plugins/TailwindScreen' import TailwindScreen from '@Plugins/TailwindScreen'
import { pagePlugin } from '@Services/Page'; import { pagePlugin } from '@Services/Page';
import { reloadApp,view } from '@Services/Page'; import { reloadApp, view } from '@Services/Page';
import App from '@Layouts/AppLayout.vue' import App from '@Layouts/AppLayout.vue'
import Error503 from '@Pages/Errors/503.vue'
// Configurar axios // Configurar axios
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
// Crear instancias globales // Elementos globales
window.axios = axios; window.axios = axios;
window.Lang = lang; window.Lang = lang;
window.Notify = new Notify(); window.Notify = new Notify();
window.TwScreen = new TailwindScreen(); window.TwScreen = new TailwindScreen();
async function boot() { async function boot() {
let initRoutes = false;
// Iniciar rutas
try { try {
const routes = await axios.get(import.meta.env.VITE_API_URL + '/api/resources/routes'); const routes = await axios.get(import.meta.env.VITE_API_URL + '/api/resources/routes');
// Iniciar rutas
window.Ziggy = routes.data; window.Ziggy = routes.data;
window.route = useRoute(); window.route = useRoute();
window.view = view; window.view = view;
initRoutes = true;
} catch (error) { } catch (error) {
console.error(error); window.Notify.error(window.Lang('server.api.noAvailable'));
alert('Failed to load routes');
} }
if(import.meta.env.VITE_REVERB_ACTIVE === 'true') { if(initRoutes) {
await import('@Services/Broadcast') // Iniciar permisos
await bootPermissions();
// Iniciar broadcast
if(import.meta.env.VITE_REVERB_ACTIVE === 'true') {
await import('@Services/Broadcast')
}
reloadApp();
createApp(App)
.use(createPinia())
.use(i18n)
.use(pagePlugin)
.use(router)
.use(ZiggyVue)
.mount('#app');
} else {
createApp(Error503)
.mount('#app');
} }
reloadApp();
createApp(App)
.use(createPinia())
.use(i18n)
.use(pagePlugin)
.use(router)
.use(ZiggyVue)
.mount('#app');
} }
// Iniciar aplicación // Iniciar aplicación

View File

@ -202,17 +202,21 @@ export default {
import: 'Importar', import: 'Importar',
items: 'Elementos', items: 'Elementos',
maternal:'Apellido materno', maternal:'Apellido materno',
message:'Mensaje',
menu:'Menú', menu:'Menú',
name:'Nombre', name:'Nombre',
noRecords:'Sin registros', noRecords:'Sin registros',
notification:'Notificación', notification:'Notificación',
notifications: { notifications: {
unreadClosed:'Ocultas',
readed:'Marcar como leído', readed:'Marcar como leído',
deleted:'Notificación eliminada', deleted:'Notificación eliminada',
description:'Notificaciones del usuario', description:'Notificaciones del usuario',
notFound:'Notificación no encontrada', notFound:'Notificación no encontrada',
title:'Notificaciones', title:'Notificaciones',
seeAll:'Ver todas',
}, },
omitted:'Omitida',
password:'Contraseña', password:'Contraseña',
passwordConfirmation:'Confirmar contraseña', passwordConfirmation:'Confirmar contraseña',
passwordCurrent:'Contraseña actual', passwordCurrent:'Contraseña actual',
@ -246,6 +250,7 @@ export default {
}, },
profile:'Perfil', profile:'Perfil',
readed:'Leído', readed:'Leído',
read_at:'Fecha leído',
register: { register: {
create: { create: {
onError: 'Error al crear el registro', onError: 'Error al crear el registro',
@ -300,6 +305,11 @@ export default {
search:'Buscar', search:'Buscar',
selected: 'Seleccionado', selected: 'Seleccionado',
select: 'Seleccionar', select: 'Seleccionar',
server: {
api: {
noAvailable: 'No se encontró el servidor API.'
}
},
session: { session: {
closed: 'Sesión cerrada', closed: 'Sesión cerrada',
}, },
@ -354,6 +364,7 @@ export default {
start: 'Hora inicial', start: 'Hora inicial',
end: 'Hora final', end: 'Hora final',
}, },
title: 'Título',
total: 'Total', total: 'Total',
unknown:'Desconocido', unknown:'Desconocido',
update:'Actualizar', update:'Actualizar',
@ -390,6 +401,11 @@ export default {
}, },
title:'Roles de usuario', title:'Roles de usuario',
}, },
online: {
description: 'Lista de usuarios conectados al sistema.',
title: 'Usuarios conectados',
count: 'Usuarios conectados.',
},
menu:'Menú de usuario', menu:'Menú de usuario',
select:'Seleccionar un usuario', select:'Seleccionar un usuario',
settings:'Ajustes del usuario', settings:'Ajustes del usuario',

View File

@ -1,13 +1,25 @@
<script setup> <script setup>
import { onMounted } from 'vue';
import useLoader from '@Stores/Loader';
import { hasPermission } from '@Plugins/RolePermission';
import Layout from '@Holos/Layout/App.vue'; import Layout from '@Holos/Layout/App.vue';
import Link from '@Holos/Skeleton/Sidebar/Link.vue'; import Link from '@Holos/Skeleton/Sidebar/Link.vue';
import Section from '@Holos/Skeleton/Sidebar/Section.vue'; import Section from '@Holos/Skeleton/Sidebar/Section.vue';
/** Definidores */
const loader = useLoader()
/** Propiedades */ /** Propiedades */
defineProps({ defineProps({
title: String, title: String,
}); });
/** Ciclos */
onMounted(() => {
loader.boot()
})
</script> </script>
<template> <template>
@ -28,8 +40,12 @@ defineProps({
to="profile.show" to="profile.show"
/> />
</Section> </Section>
<Section :name="$t('admin.title')"> <Section
v-if="hasPermission('users.index')"
:name="$t('admin.title')"
>
<Link <Link
v-if="hasPermission('users.index')"
icon="people" icon="people"
name="users.title" name="users.title"
to="admin.users.index" to="admin.users.index"

View File

@ -1,10 +1,10 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { can, apiTo, viewTo } from './Module' import { can, apiTo, viewTo } from './Module'
import { api } from '@Services/Api'; import { useSearcher } from '@Services/Api';
import { users } from '@Plugins/AuthUsers'
import ModalController from '@Controllers/ModalController.js'; import ModalController from '@Controllers/ModalController.js';
import SearcherController from '@Controllers/SearcherController.js';
import IconButton from '@Holos/Button/Icon.vue' import IconButton from '@Holos/Button/Icon.vue'
import DestroyView from '@Holos/Modal/Template/Destroy.vue'; import DestroyView from '@Holos/Modal/Template/Destroy.vue';
@ -23,27 +23,23 @@ const modelModal = ref(Modal.modelModal);
const models = ref([]); const models = ref([]);
const Searcher = new SearcherController({ const searcher = useSearcher({
route: 'users.index', url: route('users.index'),
model: models onSuccess: (r) => models.value = r.models,
onError: () => models.value = []
}); });
/** Métodos */
function load() {
api.get(apiTo('index'), {
onSuccess: (r) => models.value = r.users
});
}
/** Ciclos */ /** Ciclos */
onMounted(() => load()); onMounted(() => {
searcher.search();
});
</script> </script>
<template> <template>
<div> <div>
<SearcherHead <SearcherHead
:title="$t('users.title')" :title="$t('users.title')"
@search="Searcher.search" @search="(x) => searcher.search(x)"
> >
<RouterLink <RouterLink
v-if="can('create')" v-if="can('create')"
@ -60,7 +56,8 @@ onMounted(() => load());
<div class="pt-2 w-full"> <div class="pt-2 w-full">
<Table <Table
:items="models" :items="models"
@send-pagination="Searcher.searchWithPagination" @send-pagination="searcher.pagination"
:processing="searcher.processing"
> >
<template #head> <template #head>
<th v-text="$t('user')" /> <th v-text="$t('user')" />
@ -166,7 +163,7 @@ onMounted(() => load());
:show="destroyModal" :show="destroyModal"
:to="(user) => apiTo('destroy', { user })" :to="(user) => apiTo('destroy', { user })"
@close="Modal.switchDestroyModal" @close="Modal.switchDestroyModal"
@update="load" @update="searcher.search()"
/> />
</div> </div>
</template> </template>

View File

@ -0,0 +1,150 @@
<script setup>
import { ref } from 'vue';
import { can, apiTo, viewTo } from './Module'
import { users } from '@Plugins/AuthUsers'
import ModalController from '@Controllers/ModalController.js';
import IconButton from '@Holos/Button/Icon.vue'
import DestroyView from '@Holos/Modal/Template/Destroy.vue';
import Header from '@Holos/PageHeader.vue';
import Table from '@Holos/TableSimple.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import ShowView from './Modals/Show.vue';
/** Controladores */
const Modal = new ModalController();
/** Propiedades */
const destroyModal = ref(Modal.destroyModal);
const showModal = ref(Modal.showModal);
const modelModal = ref(Modal.modelModal);
</script>
<template>
<div>
<Header
:title="$t('users.online.title')"
>
<RouterLink
v-if="can('create')"
:to="viewTo({ name: 'create' })"
>
<IconButton
class="text-white"
icon="add"
:title="$t('crud.create')"
filled
/>
</RouterLink>
</Header>
<p class="mt-2">{{ $t('users.online.description') }} {{ users.length - 1 }} {{ $t('users.online.count') }}</p>
<div class="w-full -mt-2">
<Table
:items="users"
>
<template #head>
<th v-text="$t('profile')" class="w-10" />
<th v-text="$t('name')" />
<th v-text="$t('contact')" />
<th
v-text="$t('actions')"
class="w-32 text-center"
/>
</template>
<template #body="{items}">
<template v-for="model in items">
<tr v-if="model.id != 1">
<td class="table-item border">
<img :src="model.profile_photo_url" alt="Profile photo" class="w-10 h-10 rounded-full">
</td>
<td class="table-item border">
{{ `${model.full_name}` }}
</td>
<td class="table-item border">
<p>
<a
class="hover:underline"
target="_blank"
:href="`mailto:${model.email}`"
>
{{ model.email }}
</a>
</p>
<p v-if="model.phone" class="font-semibold text-xs">
<b>Teléfono: </b>
<a
class="hover:underline"
target="_blank"
:href="`tel:${model.phone}`"
>
{{ model.phone }}
</a>
</p>
</td>
<td class="table-item">
<div class="table-actions">
<GoogleIcon
class="btn-icon"
name="visibility"
:title="$t('crud.show')"
@click="Modal.switchShowModal(model)"
outline
/>
<RouterLink
v-if="can('edit')"
class="h-fit"
:to="viewTo({ name: 'edit', params: { id: model.id } })"
>
<GoogleIcon
class="btn-icon"
name="edit"
:title="$t('crud.edit')"
outline
/>
</RouterLink>
<GoogleIcon
v-if="can('destroy')"
class="btn-icon"
name="delete"
:title="$t('crud.destroy')"
@click="Modal.switchDestroyModal(model)"
outline
/>
<RouterLink
v-if="can('settings')"
class="h-fit"
:to="viewTo({ name: 'settings', params: { id: model.id } })"
>
<GoogleIcon
class="btn-icon"
name="settings"
:title="$t('setting')"
/>
</RouterLink>
</div>
</td>
</tr>
</template>
</template>
</Table>
</div>
<ShowView
v-if="can('index')"
:show="showModal"
:model="modelModal"
@close="Modal.switchShowModal"
/>
<DestroyView
v-if="can('destroy')"
:model="modelModal"
:show="destroyModal"
:to="(user) => apiTo('destroy', { user })"
@close="Modal.switchDestroyModal"
@update="searcher.search()"
/>
</div>
</template>

14
src/pages/Errors/404.vue Normal file
View File

@ -0,0 +1,14 @@
<template>
<div class="relative flex items-top justify-center min-h-[calc(100vh-5rem)] sm:items-center sm:pt-0">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="flex items-center pt-8 sm:justify-start sm:pt-0">
<div class="px-4 text-lg text-gray-500 border-r border-gray-400 tracking-wider">
404
</div>
<div class="ml-4 text-lg text-gray-500 uppercase tracking-wider">
Not Found
</div>
</div>
</div>
</div>
</template>

14
src/pages/Errors/502.vue Normal file
View File

@ -0,0 +1,14 @@
<template>
<div class="relative flex items-top justify-center min-h-[calc(100vh-5rem)] sm:items-center sm:pt-0">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="flex items-center pt-8 sm:justify-start sm:pt-0">
<div class="px-4 text-lg text-gray-500 border-r border-gray-400 tracking-wider">
502
</div>
<div class="ml-4 text-lg text-gray-500 uppercase tracking-wider">
Bad Gateway
</div>
</div>
</div>
</div>
</template>

14
src/pages/Errors/503.vue Normal file
View File

@ -0,0 +1,14 @@
<template>
<div class="relative flex items-top justify-center min-h-[calc(100vh-5rem)] sm:items-center sm:pt-0">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="flex items-center pt-8 sm:justify-start sm:pt-0">
<div class="px-4 text-lg text-gray-500 border-r border-gray-400 tracking-wider">
503
</div>
<div class="ml-4 text-lg text-gray-500 uppercase tracking-wider">
Service Unavailable
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,140 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useSearcher } from '@Services/Api';
import ModalController from '@Controllers/ModalController.js';
import { getDateTime } from '@Controllers/DateController.js';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import IconButton from '@Holos/Button/Icon.vue';
import ShowView from '@Holos/Skeleton/Sidebar/Notification/Show.vue';
/** Controladores */
const Modal = new ModalController();
/** Propiedades */
// const destroyModal = ref(Modal.destroyModal);
const showModal = ref(Modal.showModal);
const modelModal = ref(Modal.modelModal);
const models = ref([]);
const searcher = useSearcher({
url: route('system.notifications.all'),
onSuccess: (r) => models.value = r.models,
onError: () => models.value = []
});
/** Ciclos */
onMounted(() => {
searcher.search();
});
</script>
<template>
<div>
<SearcherHead
:title="$t('notifications.title')"
@search="(x) => searcher.search(x)"
>
<IconButton
icon="refresh"
:title="$t('notifications.unreadClosed')"
@click="searcher.search()"
/>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="models"
@send-pagination="searcher.pagination"
:processing="searcher.processing"
>
<template #head>
<th v-text="$t('title')" />
<th v-text="$t('description')" />
<th
v-text="$t('date')"
class="w-40 text-center"
/>
<th
v-text="$t('status')"
class="w-32 text-center"
/>
<th
v-text="$t('actions')"
class="w-32 text-center"
/>
</template>
<template #body="{items}">
<tr v-for="model in items">
<td class="table-item border">
{{ model.data.title }}
</td>
<td class="table-item border">
{{ model.data.description }}
</td>
<td class="table-item border">
{{ getDateTime(model.created_at) }}
</td>
<td class="table-item border">
<div class="flex items-center justify-center">
<div class="w-2 h-2 rounded-full" :class="model.read_at ? 'bg-success' : 'bg-danger'"></div>
<span class="ml-2">{{ model.read_at ? $t('readed') : (model.is_closed ? $t('omitted') : $t('unreaded')) }}</span>
</div>
</td>
<td class="table-item">
<div class="table-actions">
<GoogleIcon
class="btn-icon"
name="visibility"
:title="$t('crud.show')"
@click="Modal.switchShowModal(model)"
outline
/>
<!-- <GoogleIcon
v-if="can('destroy')"
class="btn-icon"
name="delete"
:title="$t('crud.destroy')"
@click="Modal.switchDestroyModal(model)"
outline
/> -->
</div>
</td>
</tr>
</template>
<template #empty>
<td class="table-item border">
<div class="flex items-center text-sm">
<p class="font-semibold">
{{ $t('registers.empty') }}
</p>
</div>
</td>
<td class="table-item border">-</td>
<td class="table-item border">-</td>
<td class="table-item border">-</td>
<td class="table-item border">-</td>
</template>
</Table>
</div>
<ShowView
:show="showModal"
:model="modelModal"
@close="Modal.switchShowModal"
@reload="searcher.search()"
/>
<!-- <DestroyView
v-if="can('destroy')"
:model="modelModal"
:show="destroyModal"
:to="(user) => apiTo('destroy', { user })"
@close="Modal.switchDestroyModal"
@update="getNotifications"
/> -->
</div>
</template>

View File

@ -0,0 +1,21 @@
import { lang } from '@Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`users.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `admin.users.${name}`, params, query })
// Obtener traducción del componente
const transl = (str) => lang(`users.${str}`)
// Determina si un usuario puede hacer algo no en base a los permisos
const can = (permission) => hasPermission(`users.${permission}`)
export {
can,
viewTo,
apiTo,
transl
}

26
src/plugins/AuthUsers.js Normal file
View File

@ -0,0 +1,26 @@
/**
* Usuarios autenticados
*/
import { ref } from 'vue';
const users = ref([]);
function boot(x) {
users.value = x;
}
function addUser(user) {
users.value.push(user);
}
function removeUser(user) {
users.value = users.value.filter(u => u.id !== user.id);
}
export {
users,
boot,
addUser,
removeUser
};

View File

@ -1,4 +1,3 @@
import { lang } from '@Lang/i18n';
import toastr from 'toastr'; import toastr from 'toastr';
class Notify { class Notify {

View File

@ -22,16 +22,23 @@ const hasPermission = (can) => {
} }
const bootPermissions = () => { const bootPermissions = () => {
if (!permissionsInit.value) { return new Promise((resolve, reject) => {
api.get(route('user.permissions'), { if (!permissionsInit.value) {
onSuccess: (res) => { api.get(route('user.permissions'), {
loadPermissions(res.permissions) onSuccess: (res) => {
}, loadPermissions(res.permissions)
onFinish: () => {
permissionsInit.value = true; resolve(true)
} },
}) onFinish: () => {
} permissionsInit.value = true;
},
onError: () => {
reject(false)
}
})
}
})
} }
const resetPermissions = () => { const resetPermissions = () => {
@ -47,8 +54,13 @@ const loadPermissions = (permissionList = []) => {
} }
} }
const getAllPermissions = () => {
return allPermissions.value;
}
export { export {
bootPermissions, bootPermissions,
hasPermission, hasPermission,
resetPermissions resetPermissions,
getAllPermissions
}; };

View File

@ -1,4 +1,13 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import { hasPermission } from '@Plugins/RolePermission';
function can(next, can) {
if (!hasPermission(can)) {
next({ name: '404' });
} else {
next();
}
}
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
@ -9,8 +18,23 @@ const router = createRouter({
component: () => import('@Pages/Dashboard/Index.vue') component: () => import('@Pages/Dashboard/Index.vue')
}, { }, {
path: '/profile', path: '/profile',
name: 'profile.show', children: [
component: () => import('@Pages/Profile/Show.vue') {
path: '',
name: 'profile.show',
component: () => import('@Pages/Profile/Show.vue')
},
{
path: 'notifications',
children: [
{
path: '',
name: 'profile.notifications.index',
component: () => import('@Pages/Profile/Notifications/Index.vue')
}
]
},
]
}, { }, {
path: '/admin', path: '/admin',
children: [ children: [
@ -20,24 +44,39 @@ const router = createRouter({
{ {
path: '', path: '',
name: 'admin.users.index', name: 'admin.users.index',
beforeEnter: (to, from, next) => can(next, 'users.index'),
component: () => import('@Pages/Admin/Users/Index.vue') component: () => import('@Pages/Admin/Users/Index.vue')
}, },
{
path: 'online',
name: 'admin.users.online',
beforeEnter: (to, from, next) => can(next, 'users.online'),
component: () => import('@Pages/Admin/Users/Online.vue')
},
{ {
path: 'create', path: 'create',
name: 'admin.users.create', name: 'admin.users.create',
beforeEnter: (to, from, next) => can(next, 'users.create'),
component: () => import('@Pages/Admin/Users/Create.vue') component: () => import('@Pages/Admin/Users/Create.vue')
}, { }, {
path: ':id/edit', path: ':id/edit',
name: 'admin.users.edit', name: 'admin.users.edit',
beforeEnter: (to, from, next) => can(next, 'users.edit'),
component: () => import('@Pages/Admin/Users/Edit.vue') component: () => import('@Pages/Admin/Users/Edit.vue')
}, { }, {
path: ':id/settings', path: ':id/settings',
name: 'admin.users.settings', name: 'admin.users.settings',
beforeEnter: (to, from, next) => can(next, 'users.settings'),
component: () => import('@Pages/Admin/Users/Settings.vue') component: () => import('@Pages/Admin/Users/Settings.vue')
} }
] ]
}, },
] ]
},
{
path: '/:pathMatch(.*)*',
name: '404',
component: () => import('@Pages/Errors/404.vue')
} }
] ]
}) })

View File

@ -244,6 +244,11 @@ const api = {
}, },
} }
/**
* Instancia de la API
*/
const useApi = () => reactive(api);
/** /**
* Instancia de la API para formularios * Instancia de la API para formularios
*/ */
@ -462,7 +467,7 @@ const useSearcher = (options = {
async load({ async load({
url, url,
apiToken = token.value, apiToken = token.value,
filters = {} filters,
}) { }) {
this.errors = {}; this.errors = {};
this.processing = true; this.processing = true;
@ -478,7 +483,7 @@ const useSearcher = (options = {
method: 'get', method: 'get',
url, url,
params: { params: {
query: this.query, q: this.query,
...filters ...filters
}, },
headers: { headers: {
@ -531,35 +536,46 @@ const useSearcher = (options = {
this.processing = false; this.processing = false;
}, },
pagination(url, filters = {}) { pagination(url, filter = {}) {
console.log(url, filter)
this.load({ this.load({
url, url,
filters filters : {
...options.filters,
...filter,
}
}) })
}, },
search(q, filters = {}) { search(q = '', filter = {}) {
this.query = q this.query = q
this.load({ this.load({
url, url: options.url,
filters filters : {
...options.filters,
...filter,
}
}) })
}, },
refresh(filters = {}) { refresh(filter = {}) {
this.load({ this.load({
url, url: options.url,
filters filters : {
...options.filters,
...filter,
}
}) })
}, }
}) })
export { export {
api, api,
token, token,
closeSession, closeSession,
hasToken,
useForm,
useSearcher,
defineApiToken,
defineCsrfToken, defineCsrfToken,
resetApiToken defineApiToken,
hasToken,
resetApiToken,
useApi,
useForm,
useSearcher
} }

35
src/stores/Loader.js Normal file
View File

@ -0,0 +1,35 @@
import axios from 'axios';
import { defineStore } from 'pinia'
// Almacén del modo oscuro
const useLoader = defineStore('loader', {
state: () => ({
processing: false
}),
getters: {
isProcessing(state) {
return state.processing
}
},
actions: {
boot() {
axios.interceptors.request.use((config) => {
this.processing = true
return config;
}, (error) => {
this.processing = false
return Promise.reject(error);
});
axios.interceptors.response.use((response) => {
this.processing = false
return response;
}, (error) => {
this.processing = false
return Promise.reject(error);
});
}
},
})
export default useLoader

View File

@ -1,6 +1,8 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { api } from '@Services/Api' import { api } from '@Services/Api'
import { page } from '@Services/Page' import { page } from '@Services/Page'
import { hasPermission } from '@Plugins/RolePermission'
import { boot as bootAuthUsers, addUser, removeUser } from '@Plugins/AuthUsers'
/** Propiedades */ /** Propiedades */
const hasNotifications = import.meta.env.VITE_REVERB_ACTIVE === 'true'; const hasNotifications = import.meta.env.VITE_REVERB_ACTIVE === 'true';
@ -9,6 +11,7 @@ const hasNotifications = import.meta.env.VITE_REVERB_ACTIVE === 'true';
const useNotifier = defineStore('notifier', { const useNotifier = defineStore('notifier', {
state: () => ({ state: () => ({
counter: 0, counter: 0,
unreadClosedCounter: 0,
notifications: [], notifications: [],
isStarted: false, isStarted: false,
user_id: 0, user_id: 0,
@ -21,7 +24,7 @@ const useNotifier = defineStore('notifier', {
this.subscribeGLobalNotifications(); this.subscribeGLobalNotifications();
this.subscribeUserNotifications(); this.subscribeUserNotifications();
this.suscribeAuthUsers();
this.isStarted = true; this.isStarted = true;
this.getUpdates(); this.getUpdates();
@ -48,6 +51,23 @@ const useNotifier = defineStore('notifier', {
unsubscribeGlobalNotifications() { unsubscribeGlobalNotifications() {
Echo.leave('Global'); Echo.leave('Global');
}, },
// Usuarios logueados
suscribeAuthUsers() {
if(hasPermission('users.index')) {
Echo.join('online')
.here((users) => {
bootAuthUsers(users);
})
.joining((user) => {
addUser(user);
})
.leaving((user) => {
removeUser(user);
});
} else {
Echo.join('online');
}
},
// Notificaciones del usuario // Notificaciones del usuario
subscribeUserNotifications() { subscribeUserNotifications() {
Echo.private(`App.Models.User.${this.user_id}`) Echo.private(`App.Models.User.${this.user_id}`)
@ -61,13 +81,26 @@ const useNotifier = defineStore('notifier', {
}, },
readNotification(id) { readNotification(id) {
api.post(route('system.notifications.read'), { api.post(route('system.notifications.read'), {
id, data: {
id
},
onSuccess: res => {
this.getUpdates();
},
onFailed: res => {
this.getUpdates();
}
})
},
closeNotification(id) {
api.post(route('system.notifications.close'), {
data: {
id
},
onSuccess: res => { onSuccess: res => {
Notify.success(Lang('notifications.readed'))
this.getUpdates(); this.getUpdates();
}, },
onFailed: res => { onFailed: res => {
Notify.error(Lang('error'))
this.getUpdates(); this.getUpdates();
} }
}) })
@ -75,8 +108,9 @@ const useNotifier = defineStore('notifier', {
getUpdates() { getUpdates() {
api.get(route('system.notifications.all-unread'), { api.get(route('system.notifications.all-unread'), {
onSuccess: res => { onSuccess: res => {
this.counter = res.data.total; this.counter = res.total;
this.notifications = res.data.notifications; this.unreadClosedCounter = res.unread_closed;
this.notifications = res.notifications;
}, },
onFailed: res => { onFailed: res => {
console.log('error', res) console.log('error', res)