Преглед изворни кода

feat(notifications): conecta sino aos dados reais e realtime

- api/notification.js (me, unread-count, read, read-all).
- DefaultHeaderPage: badge de não lidas, lista carregada da API,
  marcar lida/todas, clique navega para a origem (url).
- Assina a sala websocket user.{id} e recarrega no evento notification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ebagabee пре 4 недеља
родитељ
комит
452fd9bfc7
2 измењених фајлова са 114 додато и 100 уклоњено
  1. 21 0
      src/api/notification.js
  2. 93 100
      src/components/layout/DefaultHeaderPage.vue

+ 21 - 0
src/api/notification.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getMyNotifications = async () => {
+  const { data } = await api.get("/notification/me");
+  return data.payload;
+};
+
+export const getUnreadCount = async () => {
+  const { data } = await api.get("/notification/me/unread-count");
+  return data.payload?.count ?? 0;
+};
+
+export const markNotificationRead = async (id) => {
+  const { data } = await api.post(`/notification/${id}/read`);
+  return data.payload;
+};
+
+export const markAllNotificationsRead = async () => {
+  const { data } = await api.post("/notification/read-all");
+  return data.payload;
+};

+ 93 - 100
src/components/layout/DefaultHeaderPage.vue

@@ -109,6 +109,7 @@
                 self="top right"
                 self="top right"
                 :offset="[0, 8]"
                 :offset="[0, 8]"
                 class="header-menu"
                 class="header-menu"
+                @before-show="loadNotifications"
               >
               >
                 <div class="notifications-panel">
                 <div class="notifications-panel">
                   <div class="row items-center justify-between q-px-md q-py-sm">
                   <div class="row items-center justify-between q-px-md q-py-sm">
@@ -137,21 +138,22 @@
                     <q-item
                     <q-item
                       v-for="n in notifications"
                       v-for="n in notifications"
                       :key="n.id"
                       :key="n.id"
+                      v-close-popup
                       clickable
                       clickable
-                      :class="!n.read ? 'bg-grey-2' : ''"
-                      @click="markAsRead(n.id)"
+                      :class="!n.is_read ? 'bg-grey-2' : ''"
+                      @click="openNotification(n)"
                     >
                     >
                       <q-item-section avatar top>
                       <q-item-section avatar top>
                         <q-icon
                         <q-icon
-                          :name="n.icon"
-                          :color="n.read ? 'grey-5' : 'secondary'"
+                          :name="iconFor(n.notification_type)"
+                          :color="n.is_read ? 'grey-5' : 'secondary'"
                           size="24px"
                           size="24px"
                         />
                         />
                       </q-item-section>
                       </q-item-section>
                       <q-item-section>
                       <q-item-section>
                         <q-item-label
                         <q-item-label
                           class="text-primary"
                           class="text-primary"
-                          :class="!n.read ? 'text-weight-medium' : ''"
+                          :class="!n.is_read ? 'text-weight-medium' : ''"
                         >
                         >
                           {{ n.title }}
                           {{ n.title }}
                         </q-item-label>
                         </q-item-label>
@@ -159,10 +161,10 @@
                           {{ n.message }}
                           {{ n.message }}
                         </q-item-label>
                         </q-item-label>
                         <q-item-label caption class="text-grey-6 q-mt-xs">
                         <q-item-label caption class="text-grey-6 q-mt-xs">
-                          {{ formatNotificationDate(n.datetime) }}
+                          {{ formatNotificationDate(n.created_at) }}
                         </q-item-label>
                         </q-item-label>
                       </q-item-section>
                       </q-item-section>
-                      <q-item-section v-if="!n.read" side top>
+                      <q-item-section v-if="!n.is_read" side top>
                         <q-badge color="negative" rounded style="padding: 4px" />
                         <q-badge color="negative" rounded style="padding: 4px" />
                       </q-item-section>
                       </q-item-section>
                     </q-item>
                     </q-item>
@@ -259,11 +261,18 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { computed, ref } from "vue";
+import { computed, ref, onMounted, onBeforeUnmount } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import { useRoute, useRouter } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { useI18n } from "vue-i18n";
 import { userStore } from "src/stores/user";
 import { userStore } from "src/stores/user";
 import { useAuth } from "src/composables/useAuth";
 import { useAuth } from "src/composables/useAuth";
+import {
+  getMyNotifications,
+  getUnreadCount,
+  markNotificationRead,
+  markAllNotificationsRead,
+} from "src/api/notification";
+import { socket, joinRoom, leaveRoom } from "src/boot/socket.io";
 
 
 const { title, breadcrumbs, filterOpen, showFilterIcon } = defineProps({
 const { title, breadcrumbs, filterOpen, showFilterIcon } = defineProps({
   title: {
   title: {
@@ -298,106 +307,65 @@ const unitLabel = computed(() => {
   return units.map((u) => u.fantasy_name).join(", ");
   return units.map((u) => u.fantasy_name).join(", ");
 });
 });
 
 
-// TODO: substituir por dados reais da API de notificações (mock por enquanto).
-const notifications = ref([
-  {
-    id: 1,
-    icon: "mdi-cash-multiple",
-    title: "Novo faturamento gerado",
-    message: "As parcelas do mês foram geradas para as unidades ativas.",
-    datetime: "2026-07-02T09:15:00",
-    read: false,
-  },
-  {
-    id: 2,
-    icon: "mdi-account-plus-outline",
-    title: "Novo usuário cadastrado",
-    message: "Um novo usuário foi adicionado à unidade Centro.",
-    datetime: "2026-07-01T16:42:00",
-    read: false,
-  },
-  {
-    id: 3,
-    icon: "mdi-file-document-outline",
-    title: "Contrato atualizado",
-    message: "O contrato da franquia foi atualizado com sucesso.",
-    datetime: "2026-06-30T11:05:00",
-    read: false,
-  },
-  {
-    id: 4,
-    icon: "mdi-alert-circle-outline",
-    title: "Pagamento em atraso",
-    message: "A unidade Zona Sul possui uma parcela vencida há 3 dias.",
-    datetime: "2026-06-29T08:20:00",
-    read: false,
-  },
-  {
-    id: 5,
-    icon: "mdi-check-decagram-outline",
-    title: "Pagamento confirmado",
-    message: "O boleto da unidade Barra foi compensado com sucesso.",
-    datetime: "2026-06-28T14:10:00",
-    read: false,
-  },
-  {
-    id: 6,
-    icon: "mdi-lifebuoy",
-    title: "Novo chamado de suporte",
-    message: "A unidade Norte abriu um chamado sobre acesso ao sistema.",
-    datetime: "2026-06-27T10:55:00",
-    read: true,
-  },
-  {
-    id: 7,
-    icon: "mdi-package-variant-closed",
-    title: "Pacote de aulas publicado",
-    message: "Um novo pacote foi disponibilizado para todas as unidades.",
-    datetime: "2026-06-26T17:30:00",
-    read: true,
-  },
-  {
-    id: 8,
-    icon: "mdi-cake-variant-outline",
-    title: "Aniversariantes do dia",
-    message: "5 alunos fazem aniversário hoje na sua rede.",
-    datetime: "2026-06-25T07:00:00",
-    read: true,
-  },
-  {
-    id: 9,
-    icon: "mdi-store-plus-outline",
-    title: "Nova unidade cadastrada",
-    message: "A unidade Jardins foi adicionada à franquia.",
-    datetime: "2026-06-24T13:45:00",
-    read: true,
-  },
-  {
-    id: 10,
-    icon: "mdi-shield-check-outline",
-    title: "Atualização de segurança",
-    message: "Recomendamos revisar as permissões de acesso dos usuários.",
-    datetime: "2026-06-23T09:05:00",
-    read: true,
-  },
-]);
+// ------------------- Notificações (sino) -------------------
+const notifications = ref([]);
+const unreadCount = ref(0);
 
 
-const unreadCount = computed(
-  () => notifications.value.filter((n) => !n.read).length,
-);
+// Ícone por tipo de notificação (fallback genérico).
+const NOTIFICATION_ICONS = {
+  support_ticket_created: "mdi-lifebuoy",
+  support_ticket_reply: "mdi-message-reply-text-outline",
+  kanban_created: "mdi-clipboard-plus-outline",
+  kanban_reply: "mdi-comment-text-outline",
+  kanban_status_changed: "mdi-swap-horizontal",
+  tbr_month_closed: "mdi-cash-multiple",
+};
 
 
-const markAllAsRead = () => {
-  notifications.value.forEach((n) => (n.read = true));
+const iconFor = (type) => NOTIFICATION_ICONS[type] || "mdi-bell-outline";
+
+const loadUnreadCount = async () => {
+  try {
+    unreadCount.value = await getUnreadCount();
+  } catch (e) {
+    console.error("Falha ao carregar contador de notificações:", e);
+  }
 };
 };
 
 
-const markAsRead = (id) => {
-  const item = notifications.value.find((n) => n.id === id);
-  if (item) item.read = true;
+const loadNotifications = async () => {
+  try {
+    notifications.value = await getMyNotifications();
+  } catch (e) {
+    console.error("Falha ao carregar notificações:", e);
+  }
+};
+
+const markAllAsRead = async () => {
+  try {
+    await markAllNotificationsRead();
+    notifications.value.forEach((n) => (n.is_read = true));
+    unreadCount.value = 0;
+  } catch (e) {
+    console.error("Falha ao marcar todas como lidas:", e);
+  }
+};
+
+const openNotification = async (n) => {
+  if (!n.is_read) {
+    try {
+      await markNotificationRead(n.id);
+      n.is_read = true;
+      unreadCount.value = Math.max(0, unreadCount.value - 1);
+    } catch (e) {
+      console.error("Falha ao marcar como lida:", e);
+    }
+  }
+  if (n.url) router.push(n.url);
 };
 };
 
 
 const formatNotificationDate = (raw) => {
 const formatNotificationDate = (raw) => {
   if (!raw) return "";
   if (!raw) return "";
-  const d = new Date(raw);
+  // Backend envia "Y-m-d H:i:s" (horário local do servidor / America/Sao_Paulo).
+  const d = new Date(String(raw).replace(" ", "T"));
   return new Intl.DateTimeFormat("pt-BR", {
   return new Intl.DateTimeFormat("pt-BR", {
     day: "2-digit",
     day: "2-digit",
     month: "2-digit",
     month: "2-digit",
@@ -407,6 +375,31 @@ const formatNotificationDate = (raw) => {
   }).format(d);
   }).format(d);
 };
 };
 
 
+// Realtime: entra na sala do usuário e atualiza ao receber evento (best-effort).
+const userRoom = computed(() =>
+  user.value?.id ? `user.${user.value.id}` : null,
+);
+
+const onRealtimeNotification = () => {
+  loadUnreadCount();
+  loadNotifications();
+};
+
+onMounted(() => {
+  loadUnreadCount();
+  if (userRoom.value) {
+    joinRoom(userRoom.value);
+    socket.on("notification", onRealtimeNotification);
+  }
+});
+
+onBeforeUnmount(() => {
+  if (userRoom.value) {
+    socket.off("notification", onRealtimeNotification);
+    leaveRoom(userRoom.value);
+  }
+});
+
 const goToEditProfile = () => {
 const goToEditProfile = () => {
   if (!user.value?.id) return;
   if (!user.value?.id) return;
   router.push({ name: "UserEditPage", params: { id: user.value.id } });
   router.push({ name: "UserEditPage", params: { id: user.value.id } });