@innertia-solutions/innertia-nuxt 0.1.44 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ <script setup>
2
+ import { IconBell, IconChecks, IconBellOff } from '@tabler/icons-vue'
3
+
4
+ /*
5
+ * App.NotificationBell — campana del top bar: dropdown flotante (Preline hs-dropdown)
6
+ * con lista de notificaciones del usuario, badge de no leídas, tabs Todas/No leídas
7
+ * y footer "Marcar todas" + "Ver todas". Estilo calcado del template CRM.
8
+ *
9
+ * Backend: notification center de innertia-laravel (vía useNotifications).
10
+ * `allRoute`: ruta de la vista "ver todas" (cada producto monta la suya).
11
+ */
12
+ const props = defineProps({
13
+ allRoute: { type: String, default: '/backoffice/notifications' },
14
+ })
15
+
16
+ const { fetchNotifications, markAsRead, markAllAsRead } = useNotifications()
17
+ const store = useNotificationsStore()
18
+
19
+ const tab = ref('all') // all | unread
20
+ const loading = ref(false)
21
+ const serverUnread = ref(0) // conteo exacto de no leídas (del backend)
22
+ const ddRoot = ref(null)
23
+
24
+ // Cierra el dropdown Preline al navegar (--auto-close:false para que los tabs no lo cierren).
25
+ function closeDropdown() {
26
+ try { window.HSDropdown?.close?.(ddRoot.value) } catch { /* noop */ }
27
+ }
28
+
29
+ const items = computed(() => tab.value === 'unread'
30
+ ? store.notifications.filter(n => !n.read_at)
31
+ : store.notifications)
32
+
33
+ const badge = computed(() => serverUnread.value > 9 ? '9+' : String(serverUnread.value))
34
+
35
+ async function load() {
36
+ loading.value = true
37
+ try {
38
+ const res = await fetchNotifications({ all: 1 })
39
+ serverUnread.value = res?.unread ?? store.unreadCount
40
+ } catch { /* noop */ } finally {
41
+ loading.value = false
42
+ }
43
+ }
44
+ onMounted(load)
45
+
46
+ async function openItem(n) {
47
+ if (!n.read_at) {
48
+ try {
49
+ await markAsRead(n.id)
50
+ serverUnread.value = Math.max(0, serverUnread.value - 1)
51
+ } catch { /* noop */ }
52
+ }
53
+ const link = n.data?.link ?? n.data?.url
54
+ if (link) { closeDropdown(); navigateTo(link) }
55
+ }
56
+
57
+ function goAll() {
58
+ closeDropdown()
59
+ navigateTo(props.allRoute)
60
+ }
61
+
62
+ async function markAll() {
63
+ try {
64
+ await markAllAsRead()
65
+ serverUnread.value = 0
66
+ } catch { /* noop */ }
67
+ }
68
+
69
+ const initial = (t) => (t || '?').trim().charAt(0).toUpperCase()
70
+
71
+ function timeAgo(iso) {
72
+ if (!iso) return ''
73
+ const d = new Date(iso)
74
+ const s = Math.floor((Date.now() - d.getTime()) / 1000)
75
+ if (s < 60) return 'hace un momento'
76
+ const m = Math.floor(s / 60); if (m < 60) return `hace ${m} min`
77
+ const h = Math.floor(m / 60); if (h < 24) return `hace ${h} h`
78
+ const days = Math.floor(h / 24); if (days < 7) return `hace ${days} d`
79
+ return d.toLocaleDateString('es-CL', { day: 'numeric', month: 'short' })
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <div ref="ddRoot" class="hs-dropdown [--placement:bottom-right] [--auto-close:false] relative inline-flex">
85
+ <!-- Botón campana -->
86
+ <button
87
+ id="hs-notifications-dd"
88
+ type="button"
89
+ class="relative inline-flex justify-center items-center size-9 rounded-full text-foreground-inverse hover:bg-plain/10 focus:outline-hidden"
90
+ aria-haspopup="menu" aria-expanded="false" aria-label="Notificaciones"
91
+ @click="load"
92
+ >
93
+ <IconBell class="size-4 shrink-0" />
94
+ <span
95
+ v-if="serverUnread > 0"
96
+ class="absolute -top-0.5 -end-0.5 min-w-4 h-4 px-1 inline-flex justify-center items-center text-[10px] font-semibold leading-none text-white bg-red-500 rounded-full"
97
+ >{{ badge }}</span>
98
+ </button>
99
+
100
+ <!-- Dropdown -->
101
+ <div
102
+ class="hs-dropdown-menu hs-dropdown-open:opacity-100 w-90 max-w-[calc(100vw-2rem)] transition-[opacity,margin] duration opacity-0 hidden z-20 bg-dropdown border border-dropdown-line rounded-xl shadow-xl overflow-hidden"
103
+ role="menu" aria-orientation="vertical" aria-labelledby="hs-notifications-dd"
104
+ >
105
+ <!-- Header + tabs (subrayado after inset, estilo template) -->
106
+ <div class="px-5 pt-3 flex justify-between items-center border-b border-dropdown-divider">
107
+ <nav class="flex gap-1" role="tablist">
108
+ <button
109
+ type="button"
110
+ class="relative px-2 py-1.5 mb-2 inline-flex justify-center items-center gap-x-2 text-sm rounded-lg hover:bg-muted-hover after:absolute after:-bottom-2 after:inset-x-2 after:z-10 after:h-0.5 after:pointer-events-none transition-colors"
111
+ :class="tab === 'all' ? 'text-foreground after:bg-secondary' : 'text-muted-foreground-1 hover:text-foreground after:bg-transparent'"
112
+ @click="tab = 'all'"
113
+ >Todas</button>
114
+ <button
115
+ type="button"
116
+ class="relative px-2 py-1.5 mb-2 inline-flex justify-center items-center gap-x-2 text-sm rounded-lg hover:bg-muted-hover after:absolute after:-bottom-2 after:inset-x-2 after:z-10 after:h-0.5 after:pointer-events-none transition-colors"
117
+ :class="tab === 'unread' ? 'text-foreground after:bg-secondary' : 'text-muted-foreground-1 hover:text-foreground after:bg-transparent'"
118
+ @click="tab = 'unread'"
119
+ >No leídas<span v-if="serverUnread" class="ms-1 text-xs text-muted-foreground">({{ serverUnread }})</span></button>
120
+ </nav>
121
+ </div>
122
+
123
+ <!-- Lista -->
124
+ <div class="max-h-96 overflow-y-auto [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-card-line">
125
+ <!-- Loading -->
126
+ <div v-if="loading && !store.notifications.length" class="p-4 space-y-4">
127
+ <div v-for="i in 4" :key="i" class="flex gap-3 animate-pulse">
128
+ <span class="size-9 rounded-full bg-surface-1 shrink-0" />
129
+ <div class="flex-1 space-y-2"><span class="block h-3 w-1/3 bg-surface-1 rounded" /><span class="block h-3 w-3/4 bg-surface-1 rounded" /></div>
130
+ </div>
131
+ </div>
132
+
133
+ <!-- Vacío -->
134
+ <div v-else-if="!items.length" class="py-12 flex flex-col items-center text-center text-muted-foreground">
135
+ <IconBellOff class="size-8 mb-2 opacity-30" :stroke-width="1.5" />
136
+ <p class="text-sm">{{ tab === 'unread' ? 'Sin notificaciones nuevas.' : 'No tienes notificaciones.' }}</p>
137
+ </div>
138
+
139
+ <!-- Items -->
140
+ <ul v-else class="divide-y divide-dropdown-divider">
141
+ <li
142
+ v-for="n in items" :key="n.id"
143
+ class="relative w-full flex gap-x-3 p-4 cursor-pointer hover:bg-muted-hover transition-colors"
144
+ :class="!n.read_at ? 'bg-surface' : ''"
145
+ @click="openItem(n)"
146
+ >
147
+ <div class="relative shrink-0">
148
+ <span class="flex items-center justify-center size-9 rounded-full bg-primary/10 text-primary text-sm font-semibold uppercase">{{ initial(n.title) }}</span>
149
+ <span v-if="!n.read_at" class="absolute -top-0.5 -start-0.5 size-2.5 bg-primary rounded-full ring-2 ring-dropdown" />
150
+ </div>
151
+ <div class="grow min-w-0">
152
+ <p class="text-xs text-muted-foreground">{{ timeAgo(n.created_at) }}</p>
153
+ <p class="text-sm font-medium text-foreground">{{ n.title }}</p>
154
+ <p v-if="n.body" class="text-sm text-muted-foreground line-clamp-2">{{ n.body }}</p>
155
+ </div>
156
+ </li>
157
+ </ul>
158
+ </div>
159
+
160
+ <!-- Footer -->
161
+ <div class="flex items-center border-t border-dropdown-divider divide-x divide-dropdown-divider">
162
+ <button
163
+ v-if="serverUnread > 0"
164
+ type="button"
165
+ class="flex-1 p-4 flex justify-center items-center gap-x-2 text-sm font-medium text-muted-foreground-1 hover:text-primary-hover focus:outline-hidden"
166
+ @click="markAll"
167
+ >
168
+ <IconChecks class="size-4 shrink-0" />
169
+ Marcar todas como leídas
170
+ </button>
171
+ <button
172
+ type="button"
173
+ class="flex-1 p-4 flex justify-center items-center gap-x-2 text-sm font-medium text-muted-foreground-1 hover:text-primary-hover focus:outline-hidden"
174
+ @click="goAll"
175
+ >
176
+ Ver todas
177
+ </button>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup>
2
+ /*
3
+ * StatCard — indicador/KPI para la fila de resumen entre el título y la tabla.
4
+ * Estilo alineado a finance/index.vue (bg-card + rounded-card + tokens).
5
+ */
6
+ defineProps({
7
+ label: { type: String, required: true },
8
+ value: { type: [String, Number], default: '—' },
9
+ hint: { type: String, default: '' },
10
+ valueClass: { type: String, default: 'text-foreground' },
11
+ icon: { type: [Object, Function], default: null },
12
+ iconClass: { type: String, default: 'bg-primary/10 text-primary' },
13
+ loading: { type: Boolean, default: false },
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <div class="bg-card border border-card-line rounded-card p-5 flex items-start justify-between gap-3">
19
+ <div class="min-w-0">
20
+ <p class="text-xs uppercase tracking-wider text-muted-foreground">{{ label }}</p>
21
+ <p v-if="loading" class="mt-2 h-7 w-16 bg-surface-1 rounded animate-pulse" />
22
+ <p v-else class="mt-2 text-2xl font-semibold" :class="valueClass">{{ value }}</p>
23
+ <span v-if="hint && !loading" class="text-xs text-muted-foreground">{{ hint }}</span>
24
+ </div>
25
+ <div v-if="icon" class="size-10 shrink-0 rounded-lg flex items-center justify-center" :class="iconClass">
26
+ <component :is="icon" class="size-5" :stroke-width="1.8" />
27
+ </div>
28
+ </div>
29
+ </template>
@@ -27,6 +27,10 @@ const props = defineProps({
27
27
  loading: { type: Boolean, default: false },
28
28
  emptyText: { type: String, default: 'Selecciona un elemento para ver su detalle.' },
29
29
  selectedId: { type: [String, Number], default: null },
30
+ /** Alto del contenedor (CSS). Ajústalo para que la página quepa sin scroll. */
31
+ height: { type: String, default: 'calc(100dvh - 13rem)' },
32
+ /** Auto-refresco por realtime (se suscribe a los meta.channels del backend). */
33
+ realtime: { type: Boolean, default: true },
30
34
  })
31
35
 
32
36
  const emit = defineEmits(['select'])
@@ -50,7 +54,7 @@ watch(search, (v) => {
50
54
  })
51
55
 
52
56
  const queryKey = computed(() => [props.name, 'md', debouncedSearch.value, props.params])
53
- const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({
57
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch } = useInfiniteQuery({
54
58
  queryKey,
55
59
  queryFn: ({ pageParam = 1 }) => api.get(props.endpoint, {
56
60
  params: { search: debouncedSearch.value, page: pageParam, perPage: props.perPage, ...props.params },
@@ -64,6 +68,17 @@ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useI
64
68
  })
65
69
  const rows = computed(() => data.value?.pages.flatMap(p => p?.data ?? (Array.isArray(p) ? p : [])) ?? [])
66
70
 
71
+ // Realtime: se suscribe a los canales que anuncia el backend en meta.channels
72
+ // (entity.{tabla}[.{tenant}]) y refetchea la lista al recibir {canal}.changed.
73
+ const rt = props.realtime ? useTableRealtime(() => refetch()) : null
74
+ watch(data, () => {
75
+ if (!rt) return
76
+ const pages = data.value?.pages ?? []
77
+ const meta = pages[pages.length - 1]?.meta ?? pages[0]?.meta
78
+ rt.sync(meta?.channels ?? [])
79
+ }, { immediate: true })
80
+ onBeforeUnmount(() => rt?.teardown())
81
+
67
82
  let observer = null
68
83
  onMounted(() => {
69
84
  observer = new IntersectionObserver((entries) => {
@@ -83,7 +98,7 @@ function pick(row) {
83
98
  </script>
84
99
 
85
100
  <template>
86
- <div class="flex rounded-card border border-card-line overflow-hidden bg-card h-[calc(100dvh-13rem)] min-h-[520px]">
101
+ <div class="flex rounded-card border border-card-line overflow-hidden bg-card min-h-[360px]" :style="{ height }">
87
102
  <!-- ── Lista ── -->
88
103
  <aside class="w-80 shrink-0 border-e border-card-line flex flex-col">
89
104
  <div v-if="title || $slots.action" class="p-3 flex items-center justify-between gap-2 border-b border-card-line">
@@ -1,22 +1,37 @@
1
+ /**
2
+ * Centro de notificaciones del usuario. Habla con los endpoints del notification
3
+ * center de innertia-laravel (prefijo `notifications`, montado con
4
+ * \Innertia\Saas\Notifications::routes()):
5
+ * GET notifications ?all=1&page=N → {data,total,unread,current_page,last_page}
6
+ * PATCH notifications/{id}/read
7
+ * PATCH notifications/read-all
8
+ * DELETE notifications/{id}
9
+ * DELETE notifications (borra las leídas)
10
+ */
1
11
  export function useNotifications() {
2
12
  const api = useApi()
3
13
  const store = useNotificationsStore()
4
14
 
5
15
  async function fetchNotifications(params = {}) {
6
- const data = await api.get('auth/me/notifications', { params })
7
- store.setNotifications(data?.data ?? data ?? [])
8
- return data
16
+ const res = await api.get('notifications', { params })
17
+ store.setNotifications(res?.data ?? [])
18
+ return res
9
19
  }
10
20
 
11
21
  async function markAsRead(id) {
12
- await api.put(`auth/me/notifications/${id}/read`)
22
+ await api.patch(`notifications/${id}/read`)
13
23
  store.markRead(id)
14
24
  }
15
25
 
16
26
  async function markAllAsRead() {
17
- await api.put('auth/me/notifications/read-all')
27
+ await api.patch('notifications/read-all')
18
28
  store.markAllRead()
19
29
  }
20
30
 
21
- return { fetchNotifications, markAsRead, markAllAsRead }
31
+ async function remove(id) {
32
+ await api.delete(`notifications/${id}`)
33
+ store.remove(id)
34
+ }
35
+
36
+ return { fetchNotifications, markAsRead, markAllAsRead, remove }
22
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -18,6 +18,7 @@ export const useNotificationsStore = defineStore('notifications', {
18
18
  const now = new Date().toISOString()
19
19
  this.notifications.forEach(n => { if (!n.read_at) n.read_at = now })
20
20
  },
21
+ remove(id) { this.notifications = this.notifications.filter(n => n.id !== id) },
21
22
  clear() { this.notifications = [] },
22
23
  },
23
24
  persist: false,