feat(ui): add realtime feed view
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
60
ui/src/components/realtime/RealtimeEventCard.vue
Normal file
60
ui/src/components/realtime/RealtimeEventCard.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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') }
|
||||
]
|
||||
|
||||
@@ -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':
|
||||
|
||||
53
ui/src/views/RealtimeFeedView.vue
Normal file
53
ui/src/views/RealtimeFeedView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user