Merge branch 'main' into codex/remove-chat-interface-and-navbar,-add-floating-chat-button
This commit is contained in:
@@ -3,6 +3,8 @@ import { watchEffect, computed } from 'vue' // Added computed
|
||||
import TopBar from '@/components/ui/TopBar.vue'
|
||||
import NavBar from '@/components/ui/NavBar.vue'
|
||||
import FloatingChat from '@/components/chat/FloatingChat.vue'
|
||||
import SnackbarContainer from '@/components/ui/SnackbarContainer.vue'
|
||||
|
||||
import { useUi } from '@/stores/useUi'
|
||||
|
||||
const ui = useUi()
|
||||
@@ -82,6 +84,7 @@ const transitionDurationStyle = computed(() => {
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
<SnackbarContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,16 +3,18 @@ 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()
|
||||
|
||||
// enlaces de la app
|
||||
const links = [
|
||||
{ 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: '⚙️' },
|
||||
]
|
||||
@@ -53,15 +55,26 @@ const hasBadge = (path, op) => {
|
||||
return table && realtime.badges[table]?.[op]
|
||||
}
|
||||
|
||||
const hasAnyBadge = (path) => {
|
||||
const table = tableForPath(path)
|
||||
if (!table) return false
|
||||
const b = realtime.badges[table]
|
||||
return b.INSERT || b.UPDATE || b.DELETE
|
||||
}
|
||||
|
||||
// clases dinámicas p/ mostrar / ocultar barra
|
||||
const sidebarClasses = computed(() => ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full')
|
||||
|
||||
const handleLinkClick = () => {
|
||||
const handleLinkClick = (path) => {
|
||||
// Close sidebar if desktopNavbarPersistent is false or if it's mobile view (width < 768px)
|
||||
// Assuming 768px is the 'md' breakpoint.
|
||||
if (!ui.desktopNavbarPersistent || window.innerWidth < 768) {
|
||||
ui.closeSidebar()
|
||||
}
|
||||
// Clear badges for this module when the link is clicked
|
||||
const table = tableForPath(path)
|
||||
if (table) realtime.clearBadgesForTable(table)
|
||||
|
||||
// Otherwise, (desktopNavbarPersistent is true AND width >= 768px), do nothing to keep sidebar open.
|
||||
}
|
||||
</script>
|
||||
@@ -88,9 +101,9 @@ const handleLinkClick = () => {
|
||||
<RouterLink
|
||||
:to="l.to"
|
||||
class="nav-link flex items-center gap-3 w-full px-3 py-2 rounded-md font-medium transition group"
|
||||
:class="activePath.startsWith(l.to) ? 'active' : ''"
|
||||
:class="[activePath.startsWith(l.to) ? 'active' : '', hasAnyBadge(l.to) ? 'notified' : '']"
|
||||
:style="{ '--accent-color': accentColorForPath(l.to) }"
|
||||
@click="handleLinkClick"
|
||||
@click="handleLinkClick(l.to)"
|
||||
>
|
||||
<span class="text-lg" aria-hidden="true">{{ l.icon }}</span>
|
||||
<span class="truncate flex-1">{{ l.label }}</span>
|
||||
@@ -116,7 +129,7 @@ ul { list-style: none; padding-left: 0; }
|
||||
border: 1px solid var(--accent-color);
|
||||
overflow: hidden;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
transition: background-color 0.3s ease, color 0.3s ease, transform 0.3s ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -158,6 +171,10 @@ ul { list-style: none; padding-left: 0; }
|
||||
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px 0 rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.nav-link.notified {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
||||
/* Scrollbar styling using primary color */
|
||||
.custom-scroll::-webkit-scrollbar { width: 8px; }
|
||||
.custom-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
80
ui/src/components/ui/SnackbarContainer.vue
Normal file
80
ui/src/components/ui/SnackbarContainer.vue
Normal 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)">×</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>
|
||||
6
ui/src/constants/moduleInfo.js
Normal file
6
ui/src/constants/moduleInfo.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export const moduleInfo = {
|
||||
Cliente: { label: 'Empleados', icon: '👥' },
|
||||
TareaRealizada: { label: 'Tareas', icon: '📋' },
|
||||
Planilla: { label: 'Planillas', icon: '📂' },
|
||||
Asistencia: { label: 'Asistencias', icon: '⏰' }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
26
ui/src/stores/useSnackbar.js
Normal file
26
ui/src/stores/useSnackbar.js
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user