소스 검색

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

- api/notification.js (me, unread-count, read, read-all).
- DefaultHeaderPage: menu do sino com badge de não lidas, lista 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 3 주 전
부모
커밋
97b432197e
2개의 변경된 파일219개의 추가작업 그리고 3개의 파일을 삭제
  1. 21 0
      src/api/notification.js
  2. 198 3
      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;
+};

+ 198 - 3
src/components/layout/DefaultHeaderPage.vue

@@ -79,7 +79,96 @@
             class="flex items-center no-wrap"
             style="gap: 2px; flex-shrink: 0"
           >
-            <q-btn flat round dense icon="mdi-bell-badge" color="secondary" />
+            <!-- Notificações -->
+            <q-btn flat round dense icon="mdi-bell-badge" color="secondary">
+              <q-badge
+                v-if="unreadCount > 0"
+                color="negative"
+                floating
+                rounded
+                :label="unreadCount"
+              />
+              <q-menu
+                anchor="bottom right"
+                self="top right"
+                :offset="[0, 8]"
+                class="header-menu"
+                @before-show="loadNotifications"
+              >
+                <div class="notifications-panel">
+                  <div class="row items-center justify-between q-px-md q-py-sm">
+                    <span class="text-subtitle2 text-primary text-weight-medium">
+                      Notificações
+                    </span>
+                    <q-btn
+                      flat
+                      dense
+                      no-caps
+                      size="sm"
+                      color="secondary"
+                      label="Marcar tudo como lido"
+                      :disable="unreadCount === 0"
+                      @click="markAllAsRead"
+                    />
+                  </div>
+                  <q-separator />
+
+                  <q-scroll-area
+                    v-if="notifications.length"
+                    style="height: 360px"
+                    :thumb-style="{ width: '5px', borderRadius: '4px' }"
+                  >
+                    <q-list separator>
+                      <q-item
+                        v-for="n in notifications"
+                        :key="n.id"
+                        v-close-popup
+                        clickable
+                        :class="!n.is_read ? 'bg-grey-2' : ''"
+                        @click="openNotification(n)"
+                      >
+                        <q-item-section avatar top>
+                          <q-icon
+                            :name="iconFor(n.notification_type)"
+                            :color="n.is_read ? 'grey-5' : 'secondary'"
+                            size="24px"
+                          />
+                        </q-item-section>
+                        <q-item-section>
+                          <q-item-label
+                            class="text-primary"
+                            :class="!n.is_read ? 'text-weight-medium' : ''"
+                          >
+                            {{ n.title }}
+                          </q-item-label>
+                          <q-item-label caption lines="2">
+                            {{ n.message }}
+                          </q-item-label>
+                          <q-item-label caption class="text-grey-6 q-mt-xs">
+                            {{ formatNotificationDate(n.created_at) }}
+                          </q-item-label>
+                        </q-item-section>
+                        <q-item-section v-if="!n.is_read" side top>
+                          <q-badge
+                            color="negative"
+                            rounded
+                            style="padding: 4px"
+                          />
+                        </q-item-section>
+                      </q-item>
+                    </q-list>
+                  </q-scroll-area>
+
+                  <div
+                    v-else
+                    class="column items-center justify-center q-pa-lg text-grey-6"
+                  >
+                    <q-icon name="mdi-bell-off-outline" size="32px" />
+                    <span class="text-caption q-mt-sm">Sem notificações</span>
+                  </div>
+                </div>
+              </q-menu>
+            </q-btn>
             <q-btn flat round dense icon="mdi-account" color="secondary" />
             <q-btn flat round dense icon="mdi-cog-outline" color="secondary" />
           </div>
@@ -91,11 +180,18 @@
 </template>
 
 <script setup>
-import { computed, ref, watch } from "vue";
-import { useRoute } from "vue-router";
+import { computed, ref, watch, onMounted, onBeforeUnmount } from "vue";
+import { useRoute, useRouter } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { userStore } from "src/stores/user";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import {
+  getMyNotifications,
+  getUnreadCount,
+  markNotificationRead,
+  markAllNotificationsRead,
+} from "src/api/notification";
+import { socket, joinRoom, leaveRoom } from "src/boot/socket.io";
 
 const { breadcrumbs, showFilterIcon, title } = defineProps({
   breadcrumbs: {
@@ -113,11 +209,102 @@ const { breadcrumbs, showFilterIcon, title } = defineProps({
 });
 
 const route = useRoute();
+const router = useRouter();
 const { t } = useI18n();
 const store = userStore();
 
 const userUnits = computed(() => store.user?.units ?? []);
 
+// ------------------- Notificações (sino) -------------------
+const notifications = ref([]);
+const unreadCount = ref(0);
+
+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 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 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) => {
+  if (!raw) return "";
+  const d = new Date(String(raw).replace(" ", "T"));
+  return new Intl.DateTimeFormat("pt-BR", {
+    day: "2-digit",
+    month: "2-digit",
+    year: "numeric",
+    hour: "2-digit",
+    minute: "2-digit",
+  }).format(d);
+};
+
+const userRoom = computed(() =>
+  store.user?.id ? `user.${store.user.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 isUnitLoading = ref(false);
 let loadingTimer = null;
 
@@ -192,4 +379,12 @@ const displayBreadcrumbs = computed(() => {
   max-width: max-content;
 }
 
+.header-menu {
+  border-radius: 10px;
+}
+
+.notifications-panel {
+  width: 340px;
+  max-width: 90vw;
+}
 </style>