|
|
@@ -109,6 +109,7 @@
|
|
|
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">
|
|
|
@@ -137,21 +138,22 @@
|
|
|
<q-item
|
|
|
v-for="n in notifications"
|
|
|
:key="n.id"
|
|
|
+ v-close-popup
|
|
|
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-icon
|
|
|
- :name="n.icon"
|
|
|
- :color="n.read ? 'grey-5' : 'secondary'"
|
|
|
+ :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.read ? 'text-weight-medium' : ''"
|
|
|
+ :class="!n.is_read ? 'text-weight-medium' : ''"
|
|
|
>
|
|
|
{{ n.title }}
|
|
|
</q-item-label>
|
|
|
@@ -159,10 +161,10 @@
|
|
|
{{ n.message }}
|
|
|
</q-item-label>
|
|
|
<q-item-label caption class="text-grey-6 q-mt-xs">
|
|
|
- {{ formatNotificationDate(n.datetime) }}
|
|
|
+ {{ formatNotificationDate(n.created_at) }}
|
|
|
</q-item-label>
|
|
|
</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-item-section>
|
|
|
</q-item>
|
|
|
@@ -259,11 +261,18 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { computed, ref } from "vue";
|
|
|
+import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
|
|
import { useRoute, useRouter } from "vue-router";
|
|
|
import { useI18n } from "vue-i18n";
|
|
|
import { userStore } from "src/stores/user";
|
|
|
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({
|
|
|
title: {
|
|
|
@@ -298,106 +307,65 @@ const unitLabel = computed(() => {
|
|
|
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) => {
|
|
|
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", {
|
|
|
day: "2-digit",
|
|
|
month: "2-digit",
|
|
|
@@ -407,6 +375,31 @@ const formatNotificationDate = (raw) => {
|
|
|
}).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 = () => {
|
|
|
if (!user.value?.id) return;
|
|
|
router.push({ name: "UserEditPage", params: { id: user.value.id } });
|