Merge pull request #49 from josedario87/codex/create-flexible-notification-snackbar

Add snackbar for realtime events
This commit is contained in:
josedario87
2025-06-11 04:40:00 -06:00
committed by GitHub
6 changed files with 134 additions and 4 deletions

View File

@@ -2,6 +2,7 @@
import { watchEffect, computed } from 'vue' // Added computed
import TopBar from '@/components/ui/TopBar.vue'
import NavBar from '@/components/ui/NavBar.vue'
import SnackbarContainer from '@/components/ui/SnackbarContainer.vue'
import { useUi } from '@/stores/useUi'
const ui = useUi()
@@ -80,6 +81,7 @@ const transitionDurationStyle = computed(() => {
</transition>
</router-view>
</main>
<SnackbarContainer />
</div>
</template>

View File

@@ -3,6 +3,7 @@ import { ref, watch, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useUi } from '@/stores/useUi'
import { useRealtimeStore } from '@/stores/useRealtime'
import { moduleInfo } from '@/constants/moduleInfo'
const ui = useUi()
const realtime = useRealtimeStore()
@@ -10,10 +11,10 @@ const realtime = useRealtimeStore()
// enlaces de la app
const links = [
{ to: '/', label: 'Chat', icon: '💬' },
{ to: '/empleados', label: 'Empleados', icon: '👥' },
{ to: '/tareas', label: 'Tareas', icon: '📋' },
{ to: '/planillas', label: 'Planillas', icon: '📂' },
{ to: '/asistencias', label: 'Asistencias', icon: '⏰' },
{ to: '/empleados', label: 'Empleados', icon: moduleInfo.Cliente.icon },
{ to: '/tareas', label: 'Tareas', icon: moduleInfo.TareaRealizada.icon },
{ to: '/planillas', label: 'Planillas', icon: moduleInfo.Planilla.icon },
{ to: '/asistencias', label: 'Asistencias', icon: moduleInfo.Asistencia.icon },
{ to: '/feed', label: 'Feed', icon: '📰' },
{ to: '/config', label: 'Config', icon: '⚙️' },
]

View File

@@ -0,0 +1,80 @@
<template>
<div class="snackbar-wrapper pointer-events-none">
<transition-group name="snackbar" tag="div" class="flex flex-col gap-2">
<div
v-for="snack in snackbar.snacks"
:key="snack.id"
:class="['snackbar-item', typeClass(snack.type)]"
>
<div class="flex-1">
<div v-if="snack.icon || snack.header" class="flex items-center gap-1 text-sm opacity-80 mb-1">
<span v-if="snack.icon">{{ snack.icon }}</span>
<span v-if="snack.header">{{ snack.header }}</span>
</div>
<span>{{ snack.text }}</span>
</div>
<button class="ml-3 text-white/70 hover:text-white" @click="snackbar.remove(snack.id)">&times;</button>
</div>
</transition-group>
</div>
</template>
<script setup>
import { useSnackbarStore } from '@/stores/useSnackbar'
const typeClass = (t) => {
switch (t) {
case 'success':
return 'bg-green-600/90'
case 'error':
return 'bg-red-600/90'
default:
return 'bg-gray-800/90'
}
}
const snackbar = useSnackbarStore()
</script>
<style scoped>
.snackbar-wrapper {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 1000;
}
.snackbar-item{
/* permite hacer clics dentro del snackbar */
pointer-events:auto;
/* texto blanco */
color:#fff;
/* px-4 py-3 → 0.75 rem arriba/abajo, 1 rem lados */
padding:0.75rem 1rem;
/* rounded-md ≈ 6 px */
border-radius:0.375rem;
/* shadow-lg de Tailwind llevado a CSS puro */
box-shadow:
0 10px 15px -3px rgba(0,0,0,.10),
0 4px 6px -2px rgba(0,0,0,.05);
/* flex items-start */
display:flex;
align-items:flex-start;
}
.snackbar-enter-active,
.snackbar-leave-active {
transition: opacity 0.35s, transform 0.35s;
}
.snackbar-enter-from,
.snackbar-leave-to {
opacity: 0;
transform: translateY(16px);
}
</style>

View File

@@ -0,0 +1,6 @@
export const moduleInfo = {
Cliente: { label: 'Empleados', icon: '👥' },
TareaRealizada: { label: 'Tareas', icon: '📋' },
Planilla: { label: 'Planillas', icon: '📂' },
Asistencia: { label: 'Asistencias', icon: '⏰' }
}

View File

@@ -3,6 +3,8 @@ import { usePlanillasStore } from './usePlanillas'
import { useEmpleadosStore } from './useEmpleados'
import { useTareasStore } from './useTareas'
import { useAsistenciasStore } from './useAsistencias'
import { useSnackbarStore } from './useSnackbar'
import { moduleInfo } from '@/constants/moduleInfo'
export const useRealtimeStore = defineStore('realtime', {
state: () => ({
@@ -55,6 +57,19 @@ export const useRealtimeStore = defineStore('realtime', {
addEvent();
}
const snackbar = useSnackbarStore();
const idPart = payload.new?.id || payload.old?.id;
const message = `${payload.operation} ${payload.table}${idPart ? ' #' + idPart : ''}`;
const type = payload.operation === 'DELETE' ? 'error' :
payload.operation === 'INSERT' ? 'success' : 'info';
const info = moduleInfo[payload.table] || {};
snackbar.push({
text: message,
type,
icon: info.icon,
header: info.label
});
// mark badge for module and operation
if (this.badges[payload.table]) {
this.badges[payload.table][payload.operation] = true;

View File

@@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
export const useSnackbarStore = defineStore('snackbar', {
state: () => ({
snacks: [],
maxSnacks: 5
}),
actions: {
push({ text, type = 'info', icon = '', header = '', duration = 6000 }) {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2)
const snack = { id, text, type, icon, header }
if (this.snacks.length >= this.maxSnacks) {
this.snacks.shift()
}
this.snacks.push(snack)
if (duration > 0) {
setTimeout(() => this.remove(id), duration)
}
return id
},
remove(id) {
const index = this.snacks.findIndex(s => s.id === id)
if (index !== -1) this.snacks.splice(index, 1)
}
}
})