feat(ui): add realtime feed view

This commit is contained in:
josedario87
2025-06-09 19:40:29 -06:00
parent f3f2f30da9
commit 5dae4a20d3
7 changed files with 134 additions and 6 deletions

View File

@@ -147,6 +147,8 @@ source.onmessage = (e) => {
};
```
La interfaz incluye una vista **Feed** accesible desde la barra lateral. Allí se muestran en tiempo real las tarjetas de cada módulo conforme llegan estos eventos.
#### Card Animation
The data cards implemented in `ui/src/components/ui/NucleoDataCard.vue` now feature a subtle growing animation when hovered over. This animation is implemented purely with CSS using keyframes and transitions defined within the component's `<style scoped>` section, ensuring the styles are encapsulated and don't affect other elements.

View File

@@ -103,3 +103,7 @@ const handleEditAsistencia = (asistenciaId) => {
```
The specific prop name matches the module (e.g., `asistencia` for `cardAsistencia`, `employee` for `cardEmpleado`, `planilla` for `cardPlanilla`, `tarea` for `cardTarea`).
## Realtime Feed
The UI provides a *Feed* view that consumes the realtime events generated by the backend. You can access it via the sidebar link "Feed". Each incoming event is rendered using these standardized cards so you can monitor inserts, updates and deletes as they happen.

View File

@@ -0,0 +1,60 @@
<template>
<div class="realtime-event">
<p class="text-xs text-gray-500 mb-1">
{{ event.operation }} · {{ event.table }} {{ formatTimestamp(event.receivedAt) }}
</p>
<component v-if="component" :is="component" v-bind="componentProps" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import CardPlanilla from '@/components/planillas/cardPlanilla.vue'
import CardEmpleado from '@/components/empleados/cardEmpleado.vue'
import CardTarea from '@/components/tareas/cardTarea.vue'
import CardAsistencia from '@/components/asistencias/cardAsistencia.vue'
const props = defineProps({
event: { type: Object, required: true }
})
const componentMap = {
Planilla: CardPlanilla,
Cliente: CardEmpleado,
TareaRealizada: CardTarea,
Asistencia: CardAsistencia,
}
const component = computed(() => componentMap[props.event.table] || null)
const itemData = computed(() =>
props.event.operation === 'DELETE' ? props.event.old : props.event.new
)
const componentProps = computed(() => {
const data = itemData.value || {}
switch (props.event.table) {
case 'Planilla':
return { planilla: data }
case 'Cliente':
return { employee: data }
case 'TareaRealizada':
return { tarea: data }
case 'Asistencia':
return { asistencia: data }
default:
return {}
}
})
const formatTimestamp = (ts) => {
if (!ts) return ''
return new Date(ts).toLocaleString('es-HN', { hour12: true })
}
</script>
<style scoped>
.realtime-event {
margin-bottom: 1rem;
}
</style>

View File

@@ -12,6 +12,7 @@ const links = [
{ to: '/tareas', label: 'Tareas', icon: '📋' },
{ to: '/planillas', label: 'Planillas', icon: '📂' },
{ to: '/asistencias', label: 'Asistencias', icon: '⏰' },
{ to: '/feed', label: 'Feed', icon: '📰' },
{ to: '/config', label: 'Config', icon: '⚙️' },
]
@@ -24,6 +25,7 @@ const accentColorForPath = (path) => {
if (path.startsWith('/tareas')) return ui.accentColorTareas
if (path.startsWith('/planillas')) return ui.accentColorPlanillas
if (path.startsWith('/asistencias')) return ui.accentColorAsistencias
if (path.startsWith('/feed')) return ui.primaryColor
if (path.startsWith('/config')) return ui.accentColorConfiguracion
return ui.accentColorChat
}

View File

@@ -25,6 +25,9 @@ const routes = [
{ path: '/asistencias/nuevo', name: 'asistencias-new', component: () => import('@/views/asistencias/AsistenciaForm.vue') },
{ path: '/asistencias/:id', name: 'asistencias-edit', component: () => import('@/views/asistencias/AsistenciaForm.vue'), props: true },
// ────── Feed en tiempo real ──────
{ path: '/feed', name: 'feed', component: () => import('@/views/RealtimeFeedView.vue') },
// 404
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/views/NotFound.vue') }
]

View File

@@ -7,6 +7,7 @@ import { useAsistenciasStore } from './useAsistencias'
export const useRealtimeStore = defineStore('realtime', {
state: () => ({
_sse: null,
events: [],
}),
actions: {
init() {
@@ -19,13 +20,16 @@ export const useRealtimeStore = defineStore('realtime', {
console.log('🟢 Conexión SSE establecida correctamente');
};
this._sse.onmessage = (e) => {
console.log('📩 SSE message raw:', e.data);
this._sse.onmessage = (e) => {
console.log('📩 SSE message raw:', e.data);
try {
const payload = JSON.parse(e.data);
console.log('📦 Payload parseado:', payload);
console.log('🧪 Tabla recibida:', payload.table);
try {
const payload = JSON.parse(e.data);
console.log('📦 Payload parseado:', payload);
console.log('🧪 Tabla recibida:', payload.table);
// store event for feed
this.events.unshift({ ...payload, receivedAt: new Date().toISOString() });
switch (payload.table) {
case 'Planilla':

View File

@@ -0,0 +1,53 @@
<script setup>
import { onMounted } from 'vue'
import { useRealtimeStore } from '@/stores/useRealtime'
import RealtimeEventCard from '@/components/realtime/RealtimeEventCard.vue'
const realtime = useRealtimeStore()
onMounted(() => {
realtime.init()
})
</script>
<template>
<div class="realtime-feed-container">
<header class="page-header">
<h1>Feed en Tiempo Real</h1>
</header>
<div class="feed-list overflow-auto pr-2" style="max-height: calc(100vh - 160px);">
<RealtimeEventCard
v-for="(ev, idx) in realtime.events"
:key="idx"
:event="ev"
/>
<div v-if="realtime.events.length === 0" class="text-center text-gray-500 mt-4">
No hay eventos aún.
</div>
</div>
</div>
</template>
<style scoped>
.realtime-feed-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.page-header h1 {
color: var(--primary-color);
font-size: 2.2em;
font-weight: 600;
}
</style>