|
|
@@ -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>
|