Browse Source

feat: :sparkles: feat (notificacao + log acesso) foi criada notificacao flutuante obrigatoria + log de acessos

foram criadas as notificacoes obrigatorias de ler pra associado e parceiro + tabela com log acessos na tela de gestao associados

fase:dev | origin:escopo
Gustavo Zanatta 2 weeks ago
parent
commit
cd65db2dec

+ 17 - 0
src/api/userAccessLog.js

@@ -0,0 +1,17 @@
+import api from "src/api";
+
+export const getAccessLogsPaginated = async ({
+  page = 1,
+  perPage = 10,
+  type,
+  date_from,
+  date_to,
+} = {}) => {
+  const params = { page, per_page: perPage };
+  if (type) params.type = type;
+  if (date_from) params.date_from = date_from;
+  if (date_to) params.date_to = date_to;
+
+  const { data } = await api.get("/user-access-log", { params });
+  return { data: { result: data.payload } };
+};

+ 256 - 0
src/components/UnreadNotificationsDialog.vue

@@ -0,0 +1,256 @@
+<template>
+  <q-dialog :model-value="modelValue" persistent>
+    <q-card class="unread-dialog-card">
+      <q-card-section class="q-pb-sm">
+        <div class="text-h6 text-violet-normal">
+          {{ $t('notification.pending_read_title') }}
+        </div>
+        <div class="text-caption text-grey-6 q-mt-xs">
+          {{ $t('notification.pending_read_subtitle') }}
+        </div>
+        <div class="text-caption text-grey-5 q-mt-xs">
+          {{ $t('notification.pending_read_hint') }}
+        </div>
+      </q-card-section>
+
+      <q-separator />
+
+      <q-card-section class="unread-dialog-card__scroll">
+        <div v-if="loading" class="flex flex-center q-pa-xl">
+          <q-spinner color="violet-normal" size="50px" />
+        </div>
+
+        <div v-else class="row q-col-gutter-md q-pt-xs">
+          <div
+            v-for="item in notifications"
+            :key="item.id"
+            class="col-xl-4 col-lg-4 col-md-6 col-sm-6 col-12"
+          >
+            <q-card
+              flat
+              bordered
+              class="notif-card"
+              :class="{ 'notif-card--unread': !item.read }"
+              @click="onRead(item)"
+            >
+              <div class="notif-card__image">
+                <img
+                  v-if="imageUrl(item)"
+                  :src="imageUrl(item)"
+                  alt=""
+                  class="notif-card__img"
+                />
+                <div v-else class="notif-card__placeholder flex flex-center">
+                  <q-icon
+                    name="mdi-bell-outline"
+                    size="40px"
+                    :color="item.read ? 'grey-4' : 'violet-normal'"
+                  />
+                </div>
+              </div>
+
+              <q-card-section class="q-pt-sm q-pb-xs">
+                <div class="row items-start justify-between no-wrap q-mb-xs">
+                  <div
+                    class="notif-card__title text-weight-bold ellipsis"
+                    :class="item.read ? 'text-grey-7' : 'text-violet-normal'"
+                  >
+                    {{ item.notification?.title }}
+                  </div>
+                  <q-badge
+                    v-if="!item.read"
+                    color="violet-normal"
+                    rounded
+                    class="q-ml-xs"
+                    style="flex-shrink: 0"
+                  />
+                </div>
+                <div class="notif-card__message text-caption text-grey-7">
+                  {{ item.notification?.message }}
+                </div>
+              </q-card-section>
+
+              <q-card-actions class="q-pt-xs q-pb-sm q-px-md">
+                <div class="text-caption text-grey-6">
+                  {{ formatDate(item.created_at) }}
+                </div>
+                <q-space />
+                <q-icon
+                  v-if="item.read"
+                  name="mdi-check-circle"
+                  color="positive"
+                  size="18px"
+                />
+              </q-card-actions>
+            </q-card>
+          </div>
+        </div>
+      </q-card-section>
+
+      <q-separator />
+
+      <q-card-actions align="right" class="q-pa-md">
+        <div class="text-caption text-grey-6 q-mr-auto">
+          {{ localUnreadCount > 0 ? $t('notification.pending_read_hint') : '' }}
+        </div>
+        <q-btn
+          v-if="localUnreadCount === 0"
+          color="violet-normal"
+          :label="$t('notification.pending_read_close')"
+          @click="close"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, watch } from "vue";
+import { useI18n } from "vue-i18n";
+import { userStore } from "src/stores/user";
+import {
+  getMyUnreadNotificationsAssociado,
+  markNotificationAsReadAssociado,
+  getMyUnreadNotificationsParceiro,
+  markNotificationAsReadParceiro,
+} from "src/api/notification";
+
+const props = defineProps({
+  modelValue: { type: Boolean, required: true },
+});
+
+const emit = defineEmits(["update:modelValue"]);
+
+const { t } = useI18n();
+const store = userStore();
+
+const loading = ref(false);
+const notifications = ref([]);
+
+const localUnreadCount = computed(() => notifications.value.filter((n) => !n.read).length);
+
+const fetchUnread = async () => {
+  loading.value = true;
+  try {
+    if (store.isAssociado) {
+      notifications.value = await getMyUnreadNotificationsAssociado();
+    } else if (store.isParceiro) {
+      notifications.value = await getMyUnreadNotificationsParceiro();
+    }
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+};
+
+const imageUrl = (item) => {
+  const media = item.notification?.media?.[0]?.url;
+  if (media) return media;
+  const direct = item.notification?.image_url;
+  if (!direct) return null;
+  return direct.startsWith("http") ? direct : process.env.API_URL + direct;
+};
+
+const formatDate = (dateStr) => {
+  if (!dateStr) return "";
+  const date = new Date(dateStr);
+  const today = new Date();
+  const isToday =
+    date.getDate() === today.getDate() &&
+    date.getMonth() === today.getMonth() &&
+    date.getFullYear() === today.getFullYear();
+  if (isToday) return t("common.terms.today");
+  return date.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
+};
+
+const onRead = async (item) => {
+  if (item.read) return;
+  try {
+    if (store.isAssociado) {
+      await markNotificationAsReadAssociado(item.id);
+    } else if (store.isParceiro) {
+      await markNotificationAsReadParceiro(item.id);
+    }
+    item.read = true;
+  } catch (e) {
+    console.error(e);
+  }
+};
+
+const close = () => {
+  store.clearUnreadCount();
+  emit("update:modelValue", false);
+};
+
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (val) fetchUnread();
+  },
+  { immediate: true }
+);
+</script>
+
+<style scoped lang="scss">
+.unread-dialog-card {
+  width: 90vw;
+  max-width: 900px;
+  min-width: 320px;
+
+  &__scroll {
+    max-height: 60vh;
+    overflow-y: auto;
+  }
+}
+
+.notif-card {
+  border-radius: 8px;
+  overflow: hidden;
+  cursor: pointer;
+  transition: box-shadow 0.2s, transform 0.15s;
+
+  &:hover {
+    box-shadow: 0 4px 16px rgba(102, 29, 117, 0.15);
+    transform: translateY(-2px);
+  }
+
+  &--unread {
+    border-top: 3px solid #7b2d97;
+  }
+
+  &__image {
+    width: 100%;
+    height: 120px;
+    overflow: hidden;
+  }
+
+  &__img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    display: block;
+  }
+
+  &__placeholder {
+    width: 100%;
+    height: 100%;
+    background: #f0e8f1;
+  }
+
+  &__title {
+    font-size: 14px;
+    line-height: 1.3;
+    min-width: 0;
+  }
+
+  &__message {
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+    line-height: 1.4;
+  }
+}
+</style>

+ 17 - 1
src/i18n/locales/en.json

@@ -476,6 +476,18 @@
     },
     "search_placeholder": "Search Members"
   },
+  "access_log": {
+    "title": "Access Logs",
+    "description": "Access history for members and partners",
+    "user": "User",
+    "user_type": "Type",
+    "accessed_at": "Access Date/Time",
+    "filter_type": "Filter by type",
+    "filter_date_from": "Start date",
+    "filter_date_to": "End date",
+    "all_types": "All types",
+    "clear_filters": "Clear filters"
+  },
   "charts": {
     "nps": {
       "promotion_zone": "Promotion Zone",
@@ -652,7 +664,11 @@
     "recipient_parceiro":   "Partners",
     "details":              "Details",
     "sent_to":              "Sent to {count} user(s)",
-    "seen_by":              "Seen by {count} user(s)"
+    "seen_by":              "Seen by {count} user(s)",
+    "pending_read_title":   "Pending Notifications",
+    "pending_read_subtitle": "You have unread notifications. Read all of them to continue.",
+    "pending_read_hint":    "Click on each notification to mark it as read.",
+    "pending_read_close":   "Close"
   },
   "associate_validation": {
     "title":               "Validate Card",

+ 18 - 2
src/i18n/locales/es.json

@@ -208,7 +208,7 @@
       "associado": "Asociado",
       "parceiro": "Socio"
     },
-    "forgot_password": "¿Olvidé mi contraseña?",
+    "forgot_password": "¿Olvidé tu contraseña?",
     "forgot_password_title": "Ingrese su correo para restablecer su contraseña",
     "forgot_password_description": "Le enviaremos un código de verificación a su correo.",
     "continue": "Continuar",
@@ -476,6 +476,18 @@
     },
     "search_placeholder": "Buscar por asociados"
   },
+  "access_log": {
+    "title": "Registros de Acceso",
+    "description": "Historial de accesos de asociados y socios",
+    "user": "Usuario",
+    "user_type": "Tipo",
+    "accessed_at": "Fecha/Hora de Acceso",
+    "filter_type": "Filtrar por tipo",
+    "filter_date_from": "Fecha inicial",
+    "filter_date_to": "Fecha final",
+    "all_types": "Todos los tipos",
+    "clear_filters": "Limpiar filtros"
+  },
   "charts": {
     "nps": {
       "promotion_zone": "Zona de Promoción",
@@ -651,7 +663,11 @@
     "recipient_parceiro":   "Socios",
     "details":              "Detalles",
     "sent_to":              "Enviado a {count} usuario(s)",
-    "seen_by":              "Visto por {count} usuario(s)"
+    "seen_by":              "Visto por {count} usuario(s)",
+    "pending_read_title":   "Notificaciones Pendientes",
+    "pending_read_subtitle": "Tiene notificaciones no leídas. Léalas todas para continuar.",
+    "pending_read_hint":    "Haga clic en cada notificación para marcarla como leída.",
+    "pending_read_close":   "Cerrar"
   },
   "associate_validation": {
     "title":               "Validar Tarjeta",

+ 18 - 2
src/i18n/locales/pt.json

@@ -208,7 +208,7 @@
       "associado": "Associado",
       "parceiro": "Parceiro"
     },
-    "forgot_password": "Esqueci a senha?",
+    "forgot_password": "Esqueceu a senha?",
     "forgot_password_title": "Informe seu e-mail para redefinir sua senha",
     "forgot_password_description": "Enviaremos um código de verificação para o seu e-mail.",
     "continue": "Continuar",
@@ -476,6 +476,18 @@
     },
     "search_placeholder": "Buscar por associados"
   },
+  "access_log": {
+    "title": "Logs de Acesso",
+    "description": "Histórico de acessos de associados e parceiros",
+    "user": "Usuário",
+    "user_type": "Tipo",
+    "accessed_at": "Data/Hora do Acesso",
+    "filter_type": "Filtrar por tipo",
+    "filter_date_from": "Data inicial",
+    "filter_date_to": "Data final",
+    "all_types": "Todos os tipos",
+    "clear_filters": "Limpar filtros"
+  },
   "charts": {
     "nps": {
       "promotion_zone": "Zona de Promoção",
@@ -652,7 +664,11 @@
     "recipient_parceiro":   "Parceiros",
     "details":              "Detalhes",
     "sent_to":              "Enviado para {count} usuário(s)",
-    "seen_by":              "Visto por {count} usuário(s)"
+    "seen_by":              "Visto por {count} usuário(s)",
+    "pending_read_title":   "Notificações Pendentes",
+    "pending_read_subtitle": "Você possui notificações não lidas. Leia todas para continuar.",
+    "pending_read_hint":    "Clique em cada notificação para marcá-la como lida.",
+    "pending_read_close":   "Fechar"
   },
   "associate_validation": {
     "title":               "Validar Carteirinha",

+ 17 - 1
src/layouts/MainLayout.vue

@@ -1,4 +1,9 @@
 <template>
+  <UnreadNotificationsDialog
+    v-if="store.isAssociado || store.isParceiro"
+    v-model="showUnreadDialog"
+  />
+
   <q-layout class="relative" view="hHh lpR fFf">
     <LeftMenuLayoutAdministrador v-if="!$q.screen.lt.sm && store.userTipo == 'administrador'" />
     <LeftMenuLayoutAssociado v-if="!$q.screen.lt.sm && store.userTipo == 'associado'" />
@@ -62,7 +67,7 @@
 </template>
 
 <script setup>
-import { ref, useTemplateRef, watch } from "vue";
+import { ref, useTemplateRef, watch, onMounted } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import { useAuth } from "src/composables/useAuth";
 import { useQuasar } from "quasar";
@@ -72,6 +77,7 @@ import LeftMenuLayoutAssociado from "src/components/layout/LeftMenuLayoutAssocia
 import LeftMenuLayoutParceiro from "src/components/layout/LeftMenuLayoutParceiro.vue";
 import LeftMenuLayoutMobile from "src/components/layout/LeftMenuLayoutMobile.vue";
 import AppHeader from "src/components/layout/AppHeader.vue";
+import UnreadNotificationsDialog from "src/components/UnreadNotificationsDialog.vue";
 
 const store = userStore();
 const router = useRouter();
@@ -81,6 +87,16 @@ const $q = useQuasar();
 
 const leftDrawerOpen = ref(false);
 const scrollAreaRef = useTemplateRef("scrollAreaRef");
+const showUnreadDialog = ref(false);
+
+onMounted(async () => {
+  if (store.isAssociado || store.isParceiro) {
+    await store.fetchUser();
+    if (store.hasUnreadNotifications) {
+      showUnreadDialog.value = true;
+    }
+  }
+});
 
 let oldValue = route.path;
 // const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches

+ 5 - 0
src/pages/gestao-associados/GestaoAssociadosPage.vue

@@ -56,6 +56,10 @@
         </q-td>
       </template>
     </DefaultTableServerSide>
+
+    <q-separator class="q-my-xl" />
+
+    <AccessLogsTable />
   </div>
 </template>
 
@@ -68,6 +72,7 @@ import { getAssociadosPaginated } from "src/api/user";
 import { getStatusColor, getStatusI18nKey } from "src/helpers/utils";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
+import AccessLogsTable from "./components/AccessLogsTable.vue";
 
 const AddEditAssociadoDialog = defineAsyncComponent(
   () => import("./components/AddEditAssociadoDialog.vue"),

+ 218 - 0
src/pages/gestao-associados/components/AccessLogsTable.vue

@@ -0,0 +1,218 @@
+<template>
+  <div>
+    <div class="row items-center justify-between q-mb-md q-mt-xl">
+      <div>
+        <div class="text-subtitle1 text-weight-bold text-grey-8">
+          {{ $t("access_log.title") }}
+        </div>
+        <div class="text-caption text-grey-6">
+          {{ $t("access_log.description") }}
+        </div>
+      </div>
+    </div>
+
+    <div class="row q-col-gutter-sm q-mb-md items-end">
+      <div class="col-12 col-sm-3">
+        <q-select
+          v-model="filters.type"
+          :options="typeOptions"
+          :label="$t('access_log.filter_type')"
+          emit-value
+          map-options
+          outlined
+          dense
+          clearable
+          color="violet-normal"
+          @update:model-value="onFilterChange"
+        />
+      </div>
+
+      <div class="col-12 col-sm-3">
+        <q-input
+          v-model="filters.date_from"
+          :label="$t('access_log.filter_date_from')"
+          outlined
+          dense
+          clearable
+          color="violet-normal"
+          readonly
+          @clear="onFilterChange"
+        >
+          <template #append>
+            <q-icon name="mdi-calendar" class="cursor-pointer">
+              <q-popup-proxy cover transition-show="scale" transition-hide="scale">
+                <q-date
+                  v-model="filters.date_from"
+                  mask="YYYY-MM-DD"
+                  color="violet-normal"
+                  @update:model-value="onFilterChange"
+                >
+                  <div class="row items-center justify-end">
+                    <q-btn v-close-popup flat :label="$t('common.actions.cancel')" color="grey-7" />
+                    <q-btn v-close-popup flat :label="$t('common.actions.save')" color="violet-normal" />
+                  </div>
+                </q-date>
+              </q-popup-proxy>
+            </q-icon>
+          </template>
+        </q-input>
+      </div>
+
+      <div class="col-12 col-sm-3">
+        <q-input
+          v-model="filters.date_to"
+          :label="$t('access_log.filter_date_to')"
+          outlined
+          dense
+          clearable
+          color="violet-normal"
+          readonly
+          @clear="onFilterChange"
+        >
+          <template #append>
+            <q-icon name="mdi-calendar" class="cursor-pointer">
+              <q-popup-proxy cover transition-show="scale" transition-hide="scale">
+                <q-date
+                  v-model="filters.date_to"
+                  mask="YYYY-MM-DD"
+                  color="violet-normal"
+                  :options="limitDateTo"
+                  @update:model-value="onFilterChange"
+                >
+                  <div class="row items-center justify-end">
+                    <q-btn v-close-popup flat :label="$t('common.actions.cancel')" color="grey-7" />
+                    <q-btn v-close-popup flat :label="$t('common.actions.save')" color="violet-normal" />
+                  </div>
+                </q-date>
+              </q-popup-proxy>
+            </q-icon>
+          </template>
+        </q-input>
+      </div>
+
+      <div class="col-12 col-sm-3">
+        <q-btn
+          v-if="hasActiveFilters"
+          flat
+          dense
+          no-caps
+          color="violet-normal"
+          icon="mdi-filter-remove-outline"
+          :label="$t('access_log.clear_filters')"
+          @click="clearFilters"
+        />
+      </div>
+    </div>
+
+    <DefaultTableServerSide
+      ref="tableRef"
+      :columns="columns"
+      :api-call="apiCallWithFilters"
+      :add-item="false"
+      :show-search-field="false"
+    >
+      <template #body-cell-user_type="{ row }">
+        <q-td class="text-center">
+          <q-badge
+            outline
+            :color="row.user_type === 'associado' ? 'violet-normal' : 'teal'"
+            :label="row.user_type === 'associado' ? $t('auth.type.associado') : $t('auth.type.parceiro')"
+          />
+        </q-td>
+      </template>
+
+      <template #body-cell-accessed_at="{ row }">
+        <q-td>
+          {{ formatDateTime(row.accessed_at) }}
+        </q-td>
+      </template>
+    </DefaultTableServerSide>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { useI18n } from "vue-i18n";
+import { getAccessLogsPaginated } from "src/api/userAccessLog";
+import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
+
+const { t } = useI18n();
+const tableRef = ref(null);
+
+const filters = ref({
+  type: null,
+  date_from: null,
+  date_to: null,
+});
+
+const hasActiveFilters = computed(
+  () => !!filters.value.type || !!filters.value.date_from || !!filters.value.date_to,
+);
+
+const typeOptions = computed(() => [
+  { label: t("auth.type.associado"), value: "associado" },
+  { label: t("auth.type.parceiro"),  value: "parceiro" },
+]);
+
+const columns = computed(() => [
+  {
+    name: "user_name",
+    label: t("access_log.user"),
+    field: "user_name",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "user_type",
+    label: t("access_log.user_type"),
+    field: "user_type",
+    align: "center",
+    sortable: false,
+  },
+  {
+    name: "accessed_at",
+    label: t("access_log.accessed_at"),
+    field: "accessed_at",
+    align: "left",
+    sortable: false,
+  },
+]);
+
+const apiCallWithFilters = (params) =>
+  getAccessLogsPaginated({
+    ...params,
+    type: filters.value.type || undefined,
+    date_from: filters.value.date_from || undefined,
+    date_to: filters.value.date_to || undefined,
+  });
+
+const onFilterChange = () => {
+  tableRef.value?.refresh();
+};
+
+const clearFilters = () => {
+  filters.value.type = null;
+  filters.value.date_from = null;
+  filters.value.date_to = null;
+  tableRef.value?.refresh();
+};
+
+// Data final não pode ser anterior à data inicial
+const limitDateTo = (date) => {
+  if (!filters.value.date_from) return true;
+  return date >= filters.value.date_from.replace(/-/g, "/");
+};
+
+const formatDateTime = (isoString) => {
+  if (!isoString) return "—";
+  const d = new Date(isoString);
+  return d.toLocaleString("pt-BR", {
+    day: "2-digit",
+    month: "2-digit",
+    year: "numeric",
+    hour: "2-digit",
+    minute: "2-digit",
+    second: "2-digit",
+  });
+};
+</script>

+ 12 - 0
src/stores/user.js

@@ -16,6 +16,9 @@ export const userStore = defineStore("user", () => {
     return typeof t === "object" ? t.value : t;
   });
 
+  const unreadNotificationsCount = computed(() => user.value?.unread_notifications_count ?? 0);
+  const hasUnreadNotifications = computed(() => unreadNotificationsCount.value > 0);
+
   const setUser = (userData) => {
     user.value = userData;
   };
@@ -30,6 +33,12 @@ export const userStore = defineStore("user", () => {
     setUser(response);
   };
 
+  const clearUnreadCount = () => {
+    if (user.value) {
+      user.value = { ...user.value, unread_notifications_count: 0 };
+    }
+  };
+
   return {
     user,
     accessToken,
@@ -37,8 +46,11 @@ export const userStore = defineStore("user", () => {
     isAssociado,
     isParceiro,
     userTipo,
+    unreadNotificationsCount,
+    hasUnreadNotifications,
     setUser,
     resetUser,
     fetchUser,
+    clearUnreadCount,
   };
 });