Merge pull request #43 from josedario87/codex/add-event-status-dots-to-navbar-and-cards
Add realtime event indicators
This commit is contained in:
@@ -11,7 +11,11 @@
|
|||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between w-full">
|
<div class="flex items-center justify-between w-full">
|
||||||
<h2 class="text-xl font-semibold text-gray-800" :style="{ color: ui.accentColorAsistencias }">Asistencia ID: {{ asistencia.id }}</h2>
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-800" :style="{ color: ui.accentColorAsistencias }">Asistencia ID: {{ asistencia.id }}</h2>
|
||||||
|
<span v-if="lastOp === 'INSERT'" class="realtime-dot dot-insert"></span>
|
||||||
|
<span v-if="lastOp === 'UPDATE'" class="realtime-dot dot-update"></span>
|
||||||
|
</div>
|
||||||
<span :class="['px-2 py-1 text-xs font-bold text-white rounded-full', getStatusClass(asistencia.estado)]">
|
<span :class="['px-2 py-1 text-xs font-bold text-white rounded-full', getStatusClass(asistencia.estado)]">
|
||||||
{{ asistencia.estado || 'N/A' }}
|
{{ asistencia.estado || 'N/A' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -24,9 +28,11 @@
|
|||||||
import { defineProps, defineEmits, computed } from 'vue';
|
import { defineProps, defineEmits, computed } from 'vue';
|
||||||
import { useAsistenciasStore } from '../../stores/useAsistencias';
|
import { useAsistenciasStore } from '../../stores/useAsistencias';
|
||||||
import { useUi } from '../../stores/useUi.js';
|
import { useUi } from '../../stores/useUi.js';
|
||||||
|
import { useRealtimeStore } from '../../stores/useRealtime';
|
||||||
import NucleoDataCard from '../ui/NucleoDataCard.vue';
|
import NucleoDataCard from '../ui/NucleoDataCard.vue';
|
||||||
|
|
||||||
const ui = useUi();
|
const ui = useUi();
|
||||||
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
asistencia: {
|
asistencia: {
|
||||||
@@ -74,6 +80,10 @@ const deleteAsistenciaInternal = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const lastOp = computed(() =>
|
||||||
|
realtime.getLatestOperation('Asistencia', props.asistencia.id)
|
||||||
|
);
|
||||||
|
|
||||||
const getStatusClass = (status) => {
|
const getStatusClass = (status) => {
|
||||||
if (!status) return 'bg-gray-400 text-white'; // Default for N/A, ensuring text is visible
|
if (!status) return 'bg-gray-400 text-white'; // Default for N/A, ensuring text is visible
|
||||||
const statusNormalized = status.toLowerCase().replace(/\s+/g, '-');
|
const statusNormalized = status.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|||||||
@@ -16,7 +16,11 @@
|
|||||||
:style="{ borderColor: ui.accentColorEmpleados }"
|
:style="{ borderColor: ui.accentColorEmpleados }"
|
||||||
/>
|
/>
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
<h2 class="text-lg md:text-xl font-semibold" :style="{ color: ui.accentColorEmpleados }">{{ employee.name }}</h2>
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="text-lg md:text-xl font-semibold" :style="{ color: ui.accentColorEmpleados }">{{ employee.name }}</h2>
|
||||||
|
<span v-if="lastOp === 'INSERT'" class="realtime-dot dot-insert"></span>
|
||||||
|
<span v-if="lastOp === 'UPDATE'" class="realtime-dot dot-update"></span>
|
||||||
|
</div>
|
||||||
<p class="text-xs text-gray-500">ID: {{ employee.id }}</p>
|
<p class="text-xs text-gray-500">ID: {{ employee.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,14 +50,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps, defineEmits } from 'vue'; // Removed PropType, useRouter as they are not used
|
import { defineProps, defineEmits, computed } from 'vue';
|
||||||
import { useUi } from '../../stores/useUi.js';
|
import { useUi } from '../../stores/useUi.js';
|
||||||
import { useEmpleadosStore } from '../../stores/useEmpleados'; // Added import
|
import { useEmpleadosStore } from '../../stores/useEmpleados'; // Added import
|
||||||
|
import { useRealtimeStore } from '../../stores/useRealtime';
|
||||||
import NucleoDataCard from '../ui/NucleoDataCard.vue';
|
import NucleoDataCard from '../ui/NucleoDataCard.vue';
|
||||||
|
|
||||||
const ui = useUi();
|
const ui = useUi();
|
||||||
const emit = defineEmits(['edit']);
|
const emit = defineEmits(['edit']);
|
||||||
const empleadosStore = useEmpleadosStore();
|
const empleadosStore = useEmpleadosStore();
|
||||||
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
employee: {
|
employee: {
|
||||||
@@ -81,6 +87,10 @@ const deleteEmpleado = async () => {
|
|||||||
alert('Ocurrió un error al eliminar el empleado.');
|
alert('Ocurrió un error al eliminar el empleado.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const lastOp = computed(() =>
|
||||||
|
realtime.getLatestOperation('Cliente', props.employee.id)
|
||||||
|
);
|
||||||
// buttonHover function removed as it's no longer used
|
// buttonHover function removed as it's no longer used
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between w-full">
|
<div class="flex items-center justify-between w-full">
|
||||||
<h2 class="text-xl font-semibold" :style="{ color: ui.accentColorPlanillas }">Planilla ID: {{ planilla.id }}</h2>
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="text-xl font-semibold" :style="{ color: ui.accentColorPlanillas }">Planilla ID: {{ planilla.id }}</h2>
|
||||||
|
<span v-if="lastOp === 'INSERT'" class="realtime-dot dot-insert"></span>
|
||||||
|
<span v-if="lastOp === 'UPDATE'" class="realtime-dot dot-update"></span>
|
||||||
|
</div>
|
||||||
<span :class="['px-2 py-1 text-xs font-bold text-white rounded-full', getStatusClass(planilla.estado)]">
|
<span :class="['px-2 py-1 text-xs font-bold text-white rounded-full', getStatusClass(planilla.estado)]">
|
||||||
{{ planilla.estado || 'N/A' }}
|
{{ planilla.estado || 'N/A' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -23,9 +27,11 @@
|
|||||||
import { defineProps, defineEmits, computed } from 'vue';
|
import { defineProps, defineEmits, computed } from 'vue';
|
||||||
import { usePlanillasStore } from '../../stores/usePlanillas';
|
import { usePlanillasStore } from '../../stores/usePlanillas';
|
||||||
import { useUi } from '../../stores/useUi.js';
|
import { useUi } from '../../stores/useUi.js';
|
||||||
|
import { useRealtimeStore } from '../../stores/useRealtime';
|
||||||
import NucleoDataCard from '../ui/NucleoDataCard.vue';
|
import NucleoDataCard from '../ui/NucleoDataCard.vue';
|
||||||
|
|
||||||
const ui = useUi();
|
const ui = useUi();
|
||||||
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
planilla: {
|
planilla: {
|
||||||
@@ -62,6 +68,10 @@ const cardFields = computed(() => [
|
|||||||
{ label: 'Total', value: formatCurrency(props.planilla.total) },
|
{ label: 'Total', value: formatCurrency(props.planilla.total) },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const lastOp = computed(() =>
|
||||||
|
realtime.getLatestOperation('Planilla', props.planilla.id)
|
||||||
|
);
|
||||||
|
|
||||||
const editPlanilla = () => {
|
const editPlanilla = () => {
|
||||||
emit('edit', props.planilla.id);
|
emit('edit', props.planilla.id);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,11 @@
|
|||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between w-full">
|
<div class="flex items-center justify-between w-full">
|
||||||
<h2 class="text-xl font-semibold" :style="{ color: ui.accentColorTareas }">Tarea ID: {{ tarea.id }}</h2>
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="text-xl font-semibold" :style="{ color: ui.accentColorTareas }">Tarea ID: {{ tarea.id }}</h2>
|
||||||
|
<span v-if="lastOp === 'INSERT'" class="realtime-dot dot-insert"></span>
|
||||||
|
<span v-if="lastOp === 'UPDATE'" class="realtime-dot dot-update"></span>
|
||||||
|
</div>
|
||||||
<span :class="['px-2 py-1 text-xs font-bold text-white rounded-full', getStatusClass(tarea.estado)]">
|
<span :class="['px-2 py-1 text-xs font-bold text-white rounded-full', getStatusClass(tarea.estado)]">
|
||||||
{{ tarea.estado || 'N/A' }}
|
{{ tarea.estado || 'N/A' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -24,9 +28,11 @@
|
|||||||
import { defineProps, defineEmits, computed } from 'vue';
|
import { defineProps, defineEmits, computed } from 'vue';
|
||||||
import { useTareasStore } from '../../stores/useTareas';
|
import { useTareasStore } from '../../stores/useTareas';
|
||||||
import { useUi } from '../../stores/useUi.js';
|
import { useUi } from '../../stores/useUi.js';
|
||||||
|
import { useRealtimeStore } from '../../stores/useRealtime';
|
||||||
import NucleoDataCard from '../ui/NucleoDataCard.vue';
|
import NucleoDataCard from '../ui/NucleoDataCard.vue';
|
||||||
|
|
||||||
const ui = useUi();
|
const ui = useUi();
|
||||||
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
tarea: {
|
tarea: {
|
||||||
@@ -87,6 +93,10 @@ const deleteTareaInternal = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const lastOp = computed(() =>
|
||||||
|
realtime.getLatestOperation('TareaRealizada', props.tarea.id)
|
||||||
|
);
|
||||||
|
|
||||||
const getStatusClass = (status) => {
|
const getStatusClass = (status) => {
|
||||||
if (!status) return 'bg-gray-400 text-white';
|
if (!status) return 'bg-gray-400 text-white';
|
||||||
const statusNormalized = status.toLowerCase().replace(/\s+/g, '-');
|
const statusNormalized = status.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useUi } from '@/stores/useUi'
|
import { useUi } from '@/stores/useUi'
|
||||||
|
import { useRealtimeStore } from '@/stores/useRealtime'
|
||||||
|
|
||||||
const ui = useUi()
|
const ui = useUi()
|
||||||
|
const realtime = useRealtimeStore()
|
||||||
|
|
||||||
// enlaces de la app
|
// enlaces de la app
|
||||||
const links = [
|
const links = [
|
||||||
@@ -18,7 +20,16 @@ const links = [
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const activePath = ref(route.path)
|
const activePath = ref(route.path)
|
||||||
watch(route, v => (activePath.value = v.path))
|
watch(route, v => {
|
||||||
|
activePath.value = v.path
|
||||||
|
const table = tableForPath(v.path)
|
||||||
|
if (table) realtime.clearBadgesForTable(table)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const table = tableForPath(activePath.value)
|
||||||
|
if (table) realtime.clearBadgesForTable(table)
|
||||||
|
})
|
||||||
|
|
||||||
const accentColorForPath = (path) => {
|
const accentColorForPath = (path) => {
|
||||||
if (path.startsWith('/empleados')) return ui.accentColorEmpleados
|
if (path.startsWith('/empleados')) return ui.accentColorEmpleados
|
||||||
@@ -30,6 +41,19 @@ const accentColorForPath = (path) => {
|
|||||||
return ui.accentColorChat
|
return ui.accentColorChat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tableForPath = (path) => {
|
||||||
|
if (path.startsWith('/empleados')) return 'Cliente'
|
||||||
|
if (path.startsWith('/tareas')) return 'TareaRealizada'
|
||||||
|
if (path.startsWith('/planillas')) return 'Planilla'
|
||||||
|
if (path.startsWith('/asistencias')) return 'Asistencia'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBadge = (path, op) => {
|
||||||
|
const table = tableForPath(path)
|
||||||
|
return table && realtime.badges[table]?.[op]
|
||||||
|
}
|
||||||
|
|
||||||
// clases dinámicas p/ mostrar / ocultar barra
|
// clases dinámicas p/ mostrar / ocultar barra
|
||||||
const sidebarClasses = computed(() => ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full')
|
const sidebarClasses = computed(() => ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full')
|
||||||
|
|
||||||
@@ -74,7 +98,12 @@ const handleLinkClick = () => {
|
|||||||
@click="handleLinkClick"
|
@click="handleLinkClick"
|
||||||
>
|
>
|
||||||
<span class="text-lg" aria-hidden="true">{{ l.icon }}</span>
|
<span class="text-lg" aria-hidden="true">{{ l.icon }}</span>
|
||||||
<span class="truncate">{{ l.label }}</span>
|
<span class="truncate flex-1">{{ l.label }}</span>
|
||||||
|
<span class="flex gap-1">
|
||||||
|
<span v-if="hasBadge(l.to, 'INSERT')" class="realtime-dot dot-insert"></span>
|
||||||
|
<span v-if="hasBadge(l.to, 'UPDATE')" class="realtime-dot dot-update"></span>
|
||||||
|
<span v-if="hasBadge(l.to, 'DELETE')" class="realtime-dot dot-delete"></span>
|
||||||
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export const useRealtimeStore = defineStore('realtime', {
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
_sse: null,
|
_sse: null,
|
||||||
events: [],
|
events: [],
|
||||||
|
badges: {
|
||||||
|
Planilla: { INSERT: false, UPDATE: false, DELETE: false },
|
||||||
|
Cliente: { INSERT: false, UPDATE: false, DELETE: false },
|
||||||
|
TareaRealizada: { INSERT: false, UPDATE: false, DELETE: false },
|
||||||
|
Asistencia: { INSERT: false, UPDATE: false, DELETE: false },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
init() {
|
init() {
|
||||||
@@ -31,6 +37,11 @@ export const useRealtimeStore = defineStore('realtime', {
|
|||||||
// store event for feed
|
// store event for feed
|
||||||
this.events.unshift({ ...payload, receivedAt: new Date().toISOString() });
|
this.events.unshift({ ...payload, receivedAt: new Date().toISOString() });
|
||||||
|
|
||||||
|
// mark badge for module and operation
|
||||||
|
if (this.badges[payload.table]) {
|
||||||
|
this.badges[payload.table][payload.operation] = true;
|
||||||
|
}
|
||||||
|
|
||||||
switch (payload.table) {
|
switch (payload.table) {
|
||||||
case 'Planilla':
|
case 'Planilla':
|
||||||
usePlanillasStore().fetchPlanillas();
|
usePlanillasStore().fetchPlanillas();
|
||||||
@@ -54,5 +65,21 @@ export const useRealtimeStore = defineStore('realtime', {
|
|||||||
console.warn('SSE connection lost, reloading...');
|
console.warn('SSE connection lost, reloading...');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
clearBadgesForTable(table) {
|
||||||
|
if (this.badges[table]) {
|
||||||
|
this.badges[table].INSERT = false;
|
||||||
|
this.badges[table].UPDATE = false;
|
||||||
|
this.badges[table].DELETE = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getLatestOperation(table, id) {
|
||||||
|
const ev = this.events.find(
|
||||||
|
(e) =>
|
||||||
|
e.table === table &&
|
||||||
|
((e.new && e.new.id === id) || (e.old && e.old.id === id))
|
||||||
|
);
|
||||||
|
return ev ? ev.operation : null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,3 +86,8 @@ body {
|
|||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.realtime-dot{width:0.6rem;height:0.6rem;border-radius:50%;display:inline-block;}
|
||||||
|
.dot-insert{background-color:#22c55e;}
|
||||||
|
.dot-update{background-color:#a855f7;}
|
||||||
|
.dot-delete{background-color:#ef4444;}
|
||||||
|
|||||||
Reference in New Issue
Block a user