Merge pull request #42 from josedario87/codex/add-live-feed-view-with-infinite-scroll
Add realtime feed view
This commit is contained in:
@@ -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. Las actualizaciones exhiben la tarjeta anterior y la nueva con una flecha que indica el cambio.
|
||||
|
||||
#### 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.
|
||||
|
||||
|
||||
@@ -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. Update events show the old card followed by the new one with an arrow in between.
|
||||
|
||||
|
||||
168
ui/src/components/realtime/RealtimeEventCard.vue
Normal file
168
ui/src/components/realtime/RealtimeEventCard.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div :class="['realtime-event', eventBgClass]">
|
||||
<p class="event-chip mb-2" :class="operationClass">
|
||||
<span class="event-icon">{{ operationIcon }}</span>
|
||||
{{ event.operation }} · {{ event.table }} • {{ formatTimestamp(event.receivedAt) }}
|
||||
</p>
|
||||
<template v-if="event.operation === 'UPDATE'">
|
||||
<div class="flex items-center space-x-2">
|
||||
<component
|
||||
v-if="component"
|
||||
:is="component"
|
||||
v-bind="componentPropsOld"
|
||||
class="old-card opacity-70"
|
||||
style="max-width: 350px;"
|
||||
/>
|
||||
<span class="text-2xl">→</span>
|
||||
<component
|
||||
v-if="component"
|
||||
:is="component"
|
||||
v-bind="componentPropsNew"
|
||||
class="new-card"
|
||||
style="max-width: 350px;"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<component
|
||||
v-else-if="component"
|
||||
:is="component"
|
||||
v-bind="componentProps"
|
||||
style="max-width: 400px;"
|
||||
/>
|
||||
</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 buildProps = (data) => {
|
||||
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 componentProps = computed(() => {
|
||||
return buildProps(itemData.value)
|
||||
})
|
||||
|
||||
const componentPropsOld = computed(() => buildProps(props.event.old))
|
||||
const componentPropsNew = computed(() => buildProps(props.event.new))
|
||||
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleString('es-HN', { hour12: true })
|
||||
}
|
||||
|
||||
const eventBgClass = computed(() => {
|
||||
if (props.event.operation === 'DELETE') return 'delete-card'
|
||||
if (props.event.operation === 'INSERT') return 'insert-card'
|
||||
return ''
|
||||
})
|
||||
|
||||
const operationIcon = computed(() => {
|
||||
switch (props.event.operation) {
|
||||
case 'INSERT': return '➕'
|
||||
case 'DELETE': return '🗑️'
|
||||
case 'UPDATE': return '✏️'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const operationClass = computed(() => {
|
||||
switch (props.event.operation) {
|
||||
case 'INSERT': return 'insert-chip'
|
||||
case 'DELETE': return 'delete-chip'
|
||||
case 'UPDATE': return 'update-chip'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.realtime-event {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.old-card,
|
||||
.new-card {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
.delete-card {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.insert-card {
|
||||
opacity: 0.7;
|
||||
|
||||
}
|
||||
.event-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background-image: linear-gradient(to top left, rgba(255, 255, 255, 0.3), transparent);
|
||||
}
|
||||
|
||||
|
||||
.event-icon {
|
||||
margin-right: 0.4rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Colores con efecto degradado y contraste 3D */
|
||||
.insert-chip {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
background-image: linear-gradient(to top left, #e3f7ea, #36f190);
|
||||
}
|
||||
|
||||
.delete-chip {
|
||||
background-color: #fecaca;
|
||||
color: #7f1d1d;
|
||||
background-image: linear-gradient(to top left, #ffd3d3, #ee2424);
|
||||
}
|
||||
|
||||
.update-chip {
|
||||
background-color: #fef08a;
|
||||
color: #000000;
|
||||
background-image: linear-gradient(to top left, #8a898a, #9a10e9);
|
||||
}
|
||||
|
||||
</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':
|
||||
|
||||
82
ui/src/views/RealtimeFeedView.vue
Normal file
82
ui/src/views/RealtimeFeedView.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<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>
|
||||
|
||||
<transition-group
|
||||
name="feed"
|
||||
tag="div"
|
||||
class="feed-list"
|
||||
style="max-height: calc(100vh - 160px); min-width: 60vw;"
|
||||
>
|
||||
<RealtimeEventCard
|
||||
v-for="(ev, idx) in realtime.events"
|
||||
:key="ev.receivedAt + idx"
|
||||
:event="ev"
|
||||
style="max-width: 100%; border-bottom: white 1px solid; padding: 20px 0 40px 20px ;"
|
||||
/>
|
||||
<div v-if="realtime.events.length === 0" class="text-center text-gray-500 mt-4">
|
||||
No hay eventos aún.
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.realtime-feed-container {
|
||||
padding: 20px;
|
||||
width: 70vw;
|
||||
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;
|
||||
}
|
||||
|
||||
.feed-enter-active,
|
||||
.feed-leave-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
.feed-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
.feed-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.feed-list {
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto;
|
||||
max-width: 100%;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.feed-list::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari */
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user