ADD: Historial de acciones (#5)

This commit is contained in:
Moisés de Jesús Cortés Castellanos 2025-01-03 12:55:25 -06:00 committed by GitHub
parent e42af7db7e
commit b68bd0c27b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 559 additions and 17 deletions

View File

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

View File

@ -0,0 +1,87 @@
<script setup>
import GoogleIcon from '../Shared/GoogleIcon.vue';
import Loader from '../Shared/Loader.vue';
/** Eventos */
const emit = defineEmits([
'send-pagination'
]);
/** Propiedades */
const props = defineProps({
items: Object,
processing: Boolean
});
</script>
<template>
<section class="pb-2">
<div class="w-full overflow-hidden rounded-md shadow-lg">
<div v-if="!processing" class="w-full overflow-x-auto">
<template v-if="items?.total > 0">
<slot
name="body"
:items="items?.data"
/>
</template>
<template v-else>
<template v-if="$slots.empty">
<slot name="empty" />
</template>
<template v-else>
<div class="flex p-2 items-center justify-center">
<p class="text-center text-page-t dark:text-page-dt">{{ $t('noRecords') }}</p>
</div>
</template>
</template>
</div>
<div v-else class="flex items-center justify-center">
<Loader />
</div>
</div>
</section>
<template v-if="items?.links">
<div v-if="items.links.length > 3" class="flex w-full justify-end">
<div class="flex w-full justify-end flex-wrap space-x-1 -mb-1">
<template v-for="(link, k) in items.links" :key="k">
<div v-if="link.url === null && k == 0"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_back"
/>
</div>
<button v-else-if="k === 0" class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
@click="$emit('send-pagination', link.url)"
>
<GoogleIcon
name="arrow_back"
/>
</button>
<div v-else-if="link.url === null && k == (items.links.length - 1)"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_forward"
/>
</div>
<button v-else-if="k === (items.links.length - 1)" class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
@click="$emit('send-pagination', link.url)"
>
<GoogleIcon
name="arrow_forward"
/>
</button>
<button v-else class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
v-html="link.label"
@click="$emit('send-pagination', link.url)"
></button>
</template>
</div>
</div>
</template>
</template>

View File

@ -70,7 +70,7 @@ defineProps({
class="text-xs text-gray-400 truncate"
/>
<div v-else
v-text="$t('system')"
v-text="$t('system.title')"
class="text-xs text-gray-400 truncate"
/>
</div>

View File

@ -0,0 +1,91 @@
<script setup>
import { computed } from 'vue';
import { getDate, getTime } from '@Controllers/DateController';
import PrimaryButton from '@Holos/Button/Primary.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
const emit = defineEmits([
'show',
]);
const props = defineProps({
event: Object,
});
const icons = {
created: 'add',
updated: 'edit',
deleted: 'delete',
restored: 'restore',
};
const colors = {
created: 'primary',
updated: 'primary',
deleted: 'danger',
restored: 'primary',
};
const eventType = computed(() => {
return props.event.event.split('.')[1];
});
const bgColor = computed(() => {
return `bg-${colors[eventType.value]} dark:bg-${colors[eventType.value]}-d text-${colors[eventType.value]}-t dark:text-${colors[eventType.value]}-t`;
});
const borderColor = computed(() => {
return `border-${colors[eventType.value]} dark:border-${colors[eventType.value]}-d`;
});
</script>
<template>
<li class="border-l-2" :class="borderColor">
<div class="relative flex w-full">
<div class="absolute -left-3.5 top-7 h-0.5 w-8" :class="bgColor"></div>
<div
class="absolute -mt-3 -left-3.5 top-7 w-6 h-6 flex items-center justify-center rounded-full"
:class="bgColor"
@click="emit('show', event.data)"
>
<GoogleIcon
:name="icons[eventType]"
/>
</div>
<div class="w-full rounded-lg shadow-xl dark:shadow-page-dt dark:shadow-sm my-2 mx-4">
<div class="flex justify-between p-2 rounded-t-lg" :class="bgColor">
<span
class="font-medium text-sm cursor-pointer"
@click="emit('show', event.data)"
>
{{ $t('event')}}: <i class="underline">{{ event.event }}</i>
</span>
<span class="font-medium text-sm">
{{ getDate(event.created_at) }}, {{ getTime(event.created_at) }}
</span>
</div>
<div class="p-2">
<div class="flex flex-col justify-center items-center md:flex-row md:justify-start space-x-4">
<div v-if="event.user" class="w-32">
<div class="flex flex-col w-full justify-center items-center space-y-2">
<img :src="event.user?.profile_photo_url" alt="Photo" class="w-24 h-24 rounded-full">
</div>
</div>
<div class="flex flex-col space-y-2">
<div>
<h4 class="font-semibold">{{ $t('description') }}:</h4>
<p>{{ event.description }}.</p>
</div>
<div>
<h4 class="font-semibold">{{ $t('author') }}:</h4>
<p>{{ event.user?.full_name ?? $t('system.title') }} <span v-if="event.user?.deleted_at" class="text-xs text-gray-500">({{ $t('deleted') }})</span></p>
</div>
</div>
</div>
</div>
</div>
</div>
</li>
</template>

View File

@ -69,9 +69,14 @@ export default {
title: 'Cuenta',
},
actions:'Acciones',
activity:'Actividad',
add: 'Agregar',
admin: {
title: 'Administración',
activity: {
title: 'Historial de acciones',
description: 'Historial de acciones realizadas por los usuarios en orden cronológico.'
}
},
app: {
theme: {
@ -115,6 +120,7 @@ export default {
notifySendVerification: 'Se ha enviado un nuevo enlace de verificación a su dirección de correo electrónico.',
},
},
author:'Autor',
code:'Código',
contracted_at: 'Fecha contratación',
cancel:'Cancelar',
@ -347,7 +353,9 @@ export default {
},
startDate:'Fecha de inicio',
status:'Estado',
system:'Sistema',
system:{
title:'Núcleo de Holos',
},
target: {
title: 'Meta',
total: 'Meta total'
@ -381,6 +389,10 @@ export default {
unreaded:'No leído',
user:'Usuario',
users:{
activity: {
title: 'Actividad del usuario',
description: 'Historial de acciones realizadas por el usuario.',
},
create:{
title:'Crear usuario',
description:'Permite crear nuevos usuarios. No olvides otorgarle roles para que pueda acceder a las partes del sistema deseados.',
@ -388,6 +400,7 @@ export default {
onError:'Ocurrió un error al crear el usuario'
},
deleted:'Usuario eliminado',
remove: 'Remover usuario',
edit: {
title: 'Editar usuario'
},
@ -398,7 +411,7 @@ export default {
},
notFount:'Usuario no encontrado',
password: {
description:'Permite actualizar las contraseñas de los usuarios sobre escribiendola.',
description:'Permite actualizar las contraseñas de los usuarios sobre escribiéndola.',
title:'Actualizar contraseña',
},
roles: {
@ -420,7 +433,7 @@ export default {
title:'Usuarios',
},
version:'Versión',
welcome: '<b>Bienvenido</b> {name}.',
welcome: 'Bienvenido',
workstation: 'Puesto de trabajo',
workstations: {
create: {

View File

@ -32,7 +32,7 @@ onMounted(() => {
<Link
icon="monitoring"
name="dashboard"
to="index"
to="dashboard.index"
/>
<Link
icon="person"
@ -56,6 +56,12 @@ onMounted(() => {
name="roles.title"
to="admin.roles.index"
/>
<Link
v-if="hasPermission('activities.index')"
icon="event"
name="history.title"
to="admin.activities.index"
/>
</Section>
</template>
<!-- Contenido -->

View File

@ -0,0 +1,151 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { hasPermission } from '@Plugins/RolePermission';
import { useSearcher } from '@Services/Api';
import { apiTo } from './Module';
import ModalController from '@Controllers/ModalController.js';
import IconButton from '@Holos/Button/Icon.vue'
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/Input.vue';
import Header from '@Holos/PageHeader.vue';
import Paginable from '@Holos/Paginable.vue';
import Item from '@Holos/Timeline/Item.vue';
import ShowView from './Modals/Event.vue';
/** Definidores */
const vroute = useRoute();
const router = useRouter();
/** Controladores */
const Modal = new ModalController();
/** Propiedades */
const showModal = ref(Modal.showModal);
const modelModal = ref(Modal.modelModal);
const models = ref([]);
const filters = reactive({
search: '',
start_date: '',
end_date: '',
user: ''
});
/** Métodos */
const searcher = useSearcher({
url: apiTo('index'),
filters,
onSuccess: (r) => models.value = r.models,
onError: () => models.value = []
});
const clearFilters = () => {
filters.search = '';
filters.start_date = '';
filters.end_date = '';
searcher.search();
};
const clearUser = () => {
router.replace({ query: {} });
filters.user = '';
searcher.search();
};
/** Ciclos */
onMounted(() => {
if(vroute.query?.user) {
filters.user = vroute.query.user;
}
searcher.search('');
});
</script>
<template>
<div>
<Header :title="$t('admin.activity.title')">
<RouterLink v-if="filters.user && hasPermission('users.index')" :to="$view({ name: 'admin.users.index' })">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
filled
/>
</RouterLink>
</Header>
<p class="mt-2">{{ $t('admin.activity.description') }}</p>
<div id="filters" class="grid gap-2 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mt-4">
<Input
v-model="filters.search"
title="event"
@keyup.enter="searcher.search()"
/>
<Input
v-model="filters.start_date"
type="date"
title="dates.start"
/>
<Input
v-model="filters.end_date"
title="dates.end"
type="date"
/>
<div class="flex space-x-2 items-end">
<PrimaryButton
class="!w-full h-12"
@click="searcher.search()"
>
{{ $t('search') }}
</PrimaryButton>
<PrimaryButton
class="!w-full h-12"
@click="clearFilters()"
>
{{ $t('clear') }}
</PrimaryButton>
<PrimaryButton
v-if="filters.user"
class="!w-full h-12"
@click="clearUser()"
>
{{ $t('users.remove') }}
</PrimaryButton>
</div>
</div>
<div class="pb-4">
<Paginable
:items="models"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #body="{ items }">
<ol class="ml-4">
<template v-for="event in items">
<Item
:event="event"
@show="Modal.switchShowModal(event)"
/>
</template>
</ol>
</template>
</Paginable>
</div>
<ShowView
:show="showModal"
:model="modelModal"
@close="Modal.switchShowModal"
/>
</div>
</template>

View File

@ -0,0 +1,62 @@
<script setup>
import { getDateTime } from '@Controllers/DateController';
import Header from '@Holos/Modal/Elements/Header.vue';
import ShowModal from '@Holos/Modal/Show.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
defineEmits([
'close',
]);
/** Propiedades */
defineProps({
show: Boolean,
model: Object
});
</script>
<template>
<ShowModal
:show="show"
@close="$emit('close')"
>
<Header
:title="model.name"
:subtitle="model.last_name"
/>
<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 w-full">
<p class="font-bold text-lg leading-none pb-2">
{{ $t('details') }}
</p>
<div class="text-sm">
<h4 class="font-semibold">{{ $t('event') }}:</h4>
<p>{{ model.event }}</p>
</div>
<div class="text-sm">
<h4 class="font-semibold">{{ $t('description') }}:</h4>
<p>{{ model.description }}</p>
</div>
<div class="flex w-full flex-col">
<p class="font-semibold">
{{ $t('changes') }}:
</p>
<div class="w-full text-xs p-2 border rounded-md">
<pre>{{ model.data }}</pre>
</div>
</div>
<div class="text-sm">
<h4 class="font-semibold">{{ $t('created_at') }}:</h4>
<p>{{ getDateTime(model.created_at) }}</p>
</div>
</div>
</div>
</div>
</ShowModal>
</template>

View File

@ -0,0 +1,71 @@
<script setup>
import { getDateTime } from '@Controllers/DateController';
import Header from '@Holos/Modal/Elements/Header.vue';
import ShowModal from '@Holos/Modal/Show.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
defineEmits([
'close',
]);
/** Propiedades */
defineProps({
show: Boolean,
model: Object
});
</script>
<template>
<ShowModal
:show="show"
@close="$emit('close')"
>
<Header
:title="model.name"
:subtitle="model.last_name"
>
<div class="flex w-full flex-col">
<div class="flex w-full justify-center items-center">
<img :src="model.profile_photo_url" alt="Photo" class="w-24 h-24 rounded-full">
</div>
</div>
</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>
<p>
<b>{{ $t('name') }}: </b>
{{ model.full_name }}
</p>
<p>
<b>{{ $t('phone') }}: </b>
<a :href="`tel:${model.phone}`" target="_blank" class="hover:text-danger">
{{ model.phone ?? '-' }}
</a>
</p>
<p>
<b>{{ $t('email.title') }}: </b>
<a :href="`mailto:${model.email}`" target="_blank" class="hover:text-danger">
{{ model.email }}
</a>
</p>
<p>
<b>{{ $t('created_at') }}: </b>
{{ getDateTime(model.created_at) }}
</p>
<p>
<b>{{ $t('updated_at') }}: </b>
{{ getDateTime(model.updated_at) }}
</p>
</div>
</div>
</div>
</ShowModal>
</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(`admin.activities.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `admin.activities.${name}`, params, query })
// Obtener traducción del componente
const transl = (str) => lang(`activities.${str}`)
// Determina si un usuario puede hacer algo no en base a los permisos
const can = (permission) => hasPermission(`activities.${permission}`)
export {
can,
viewTo,
apiTo,
transl
}

View File

@ -22,7 +22,7 @@ const modelModal = ref(Modal.modelModal);
const models = ref([]);
const searcher = useSearcher({
url: route('roles.index'),
url: apiTo('index'),
onSuccess: (r) => models.value = r.models,
onError: () => models.value = []
});
@ -59,7 +59,7 @@ onMounted(() => {
<div class="pt-2 w-full">
<Table
:items="models"
@send-pagination="searcher.pagination"
@send-pagination="(page) => searcher.pagination(page)"
:processing="searcher.processing"
>
<template #head>

View File

@ -1,6 +1,7 @@
<script setup>
import { onUpdated, ref } from 'vue';
import { api } from '@Services/Api';
import { apiTo } from '../Module';
import Header from '@Holos/Modal/Elements/Header.vue';
import EditModal from '@Holos/Modal/Edit.vue';
@ -22,7 +23,7 @@ const permissions = ref([]);
/** Métodos */
function update() {
api.put(route('roles.permissions', { role: props.model.id }), {
api.put(apiTo('permissions', { role: props.model.id }), {
data: {
permissions: permissions.value
},
@ -40,7 +41,7 @@ onUpdated(() => {
onSuccess: (r) => permissionTypes.value = r.models
});
api.get(route('roles.permissions', { role: props.model.id }), {
api.get(apiTo('permissions', { role: props.model.id }), {
onSuccess: (r) => {
if(r.permissions) {
permissions.value = r.permissions.map(p => p.id);

View File

@ -2,7 +2,7 @@ import { lang } from '@Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`roles.${name}`, params)
const apiTo = (name, params = {}) => route(`admin.roles.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `admin.roles.${name}`, params, query })

View File

@ -2,6 +2,7 @@
import { onMounted, ref } from 'vue';
import { can, apiTo, viewTo } from './Module'
import { useSearcher } from '@Services/Api';
import { hasPermission } from '@Plugins/RolePermission';
import ModalController from '@Controllers/ModalController.js';
@ -22,7 +23,7 @@ const modelModal = ref(Modal.modelModal);
const models = ref([]);
const searcher = useSearcher({
url: route('users.index'),
url: apiTo('index'),
onSuccess: (r) => models.value = r.models,
onError: () => models.value = []
});
@ -31,7 +32,7 @@ const searcher = useSearcher({
onMounted(() => {
searcher.search();
});
</script>
</script>
<template>
<div>
@ -59,8 +60,8 @@ onMounted(() => {
<div class="pt-2 w-full">
<Table
:items="models"
@send-pagination="searcher.pagination"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th v-text="$t('user')" />
@ -132,6 +133,16 @@ onMounted(() => {
:title="$t('setting')"
/>
</RouterLink>
<RouterLink
v-if="hasPermission('activities.index')"
class="h-fit"
:to="$view({ name: 'admin.activities.index', query: { user: model.id } })"
>
<IconButton
icon="timeline"
:title="$t('activity')"
/>
</RouterLink>
</div>
</td>
</tr>

View File

@ -2,7 +2,7 @@ import { lang } from '@Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`users.${name}`, params)
const apiTo = (name, params = {}) => route(`admin.users.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `admin.users.${name}`, params, query })

View File

@ -44,6 +44,18 @@ const changelogs = [
'FIX: Tooltip de botones en tablas.'
],
date: '2024-12-28'
},
{
version: '0.9.5',
details: [
'ADD: Historial de acciones general.',
'ADD: Historial de acciones por usuario.',
'FIX: Paginación con filtros.',
'UPDATE: La ruta / ahora redirige a /dashboard en caso de que se desarrolle un frontend publico.',
'UPDATE: Se agregaron los elementos administrados en /admin',
'UPDATE: Redirección a dashboard al iniciar sesión.'
],
date: '2025-01-03'
}
]
</script>

View File

@ -5,5 +5,5 @@ import PageHeader from '@Holos/PageHeader.vue';
<template>
<PageHeader title="Dashboard" />
<p v-html="$t('welcome', { name: $page.user.name })"></p>
<p><b>{{ $t('welcome') }}</b>, {{ $page.user.name }}.</p>
</template>

View File

@ -9,4 +9,5 @@
<p class="bg-secondary"></p>
<p class="hover:bg-primary/80 dark:hover:bg-primary-d/80"></p>
<p class="bg-secondary-d"></p>
<p class="bg-danger border-danger"></p>
</template>

View File

@ -15,6 +15,11 @@ const router = createRouter({
{
path: '/',
name: 'index',
redirect: '/dashboard'
},
{
path: '/dashboard',
name: 'dashboard.index',
component: () => import('@Pages/Dashboard/Index.vue')
}, {
path: '/profile',
@ -89,6 +94,17 @@ const router = createRouter({
component: () => import('@Pages/Admin/Roles/Edit.vue')
}
]
},
{
path: 'activities',
children: [
{
path: '',
name: 'admin.activities.index',
beforeEnter: (to, from, next) => can(next, 'activities.index'),
component: () => import('@Pages/Admin/Activities/Index.vue')
}
]
}
]
},

View File

@ -537,7 +537,6 @@ const useSearcher = (options = {
this.processing = false;
},
pagination(url, filter = {}) {
console.log(url, filter)
this.load({
url,
filters : {