11 Commits 34e7f1ce70 ... 760262b9c6

Tác giả SHA1 Thông báo Ngày
  Gustavo Zanatta 760262b9c6 feat: :sparkles: feat (primeiro acesso associado) criado fluxo de primeiro acesso do associado 15 giờ trước cách đây
  Gustavo Zanatta c73bfc72ae feat: :sparkles: feat (notificacoes) refatorada exibicao das notificacoes 1 ngày trước cách đây
  Gustavo Zanatta cda90a6b79 feat: :sparkles: feat (estatisticas da landingpage) reestilizada a exibicao dos dados 1 ngày trước cách đây
  Gustavo Zanatta 636cb36ad6 feat: :sparkles: feat (afastamentos) criada funcao de importar afastamentos 1 ngày trước cách đây
  Gustavo Zanatta 4bc09aa312 feat: ✨ feat (agendamentos associado) adicionada informacoes dos agendamentos do associado 1 ngày trước cách đây
  Gustavo Zanatta b3a614eeb9 feat: ✨ feat (agendamento) permite cancelar agendamento autorizado 1 ngày trước cách đây
  Gustavo Zanatta aadb0b6b74 feat: :sparkles: feat (agendamento) permite cancelar agendamento autorizado 1 ngày trước cách đây
  Gustavo Zanatta fee8fc41d2 feat: :sparkles: feat (parceiro dashboard) adicionad redirecionamento e refactor icones 1 ngày trước cách đây
  Gustavo Zanatta 77c7f06c93 feat: :sparkles: feat (dependentes do associado) adicionada aprovacao de dependentes + correcao no status 1 ngày trước cách đây
  Gustavo Zanatta a874a31035 style: :lipstick: style (status e borda) corrigido cor de status e removida borda desnecessaria 1 ngày trước cách đây
  Gustavo Zanatta 7b9f1e6c89 feat: :sparkles: feat (separacao parceiro convenio) foi criada nova tela para separar parceiro e convenio 2 ngày trước cách đây
44 tập tin đã thay đổi với 1966 bổ sung656 xóa
  1. 5 0
      src/api/appointment.js
  2. 30 0
      src/api/firstAccess.js
  3. 18 0
      src/api/profile.js
  4. 14 0
      src/api/user.js
  5. 18 1
      src/components/CompleteProfileDialog.vue
  6. 20 8
      src/components/ImportHistoryDialog.vue
  7. 216 0
      src/components/NotificationCard.vue
  8. 7 20
      src/components/NotificationDetailDialog.vue
  9. 9 140
      src/components/UnreadNotificationsDialog.vue
  10. 1 1
      src/components/layout/AppHeader.vue
  11. 28 0
      src/css/app.scss
  12. 3 1
      src/css/quasar.variables.scss
  13. 22 0
      src/helpers/notification.js
  14. 3 1
      src/helpers/utils.js
  15. 42 7
      src/i18n/locales/en.json
  16. 42 7
      src/i18n/locales/es.json
  17. 42 7
      src/i18n/locales/pt.json
  18. 1 0
      src/layouts/MainLayout.vue
  19. 1 1
      src/pages/agendamentos/AppointmentsAdminPage.vue
  20. 1 1
      src/pages/associado/agendamentos/AgendamentosPage.vue
  21. 1 1
      src/pages/associado/carteirinha/CarteirinhaPage.vue
  22. 21 136
      src/pages/associado/notificacoes/NotificacoesAssociadoPage.vue
  23. 3 3
      src/pages/associado/profile/ProfilePage.vue
  24. 2 1
      src/pages/associado/profile/components/AddEditDependentDialog.vue
  25. 41 14
      src/pages/company-settings/CompanySettingsPage.vue
  26. 60 2
      src/pages/dashboard/DashboardPage.vue
  27. 132 0
      src/pages/dashboard/components/AssociadoApprovalDialog.vue
  28. 125 0
      src/pages/dashboard/components/DependentApprovalDialog.vue
  29. 78 7
      src/pages/gestao-associados/GestaoAssociadosPage.vue
  30. 1 0
      src/pages/gestao-associados/components/AddEditAssociadoDialog.vue
  31. 232 0
      src/pages/gestao-associados/components/AssociadoAppointmentsDialog.vue
  32. 419 0
      src/pages/login/FirstAccessFormPage.vue
  33. 178 0
      src/pages/login/FirstAccessPage.vue
  34. 8 0
      src/pages/login/LoginPage.vue
  35. 19 0
      src/pages/login/firstAccessStorage.js
  36. 0 140
      src/pages/notificacoes/components/NotificationCard.vue
  37. 25 7
      src/pages/notificacoes/components/NotificationHistoryPanel.vue
  38. 18 4
      src/pages/parceiros-convenios/AgendamentosParceiroPage.vue
  39. 21 136
      src/pages/parceiros-convenios/NotificacoesParceiroPage.vue
  40. 17 8
      src/pages/parceiros-convenios/components/PartnerDashboardStats.vue
  41. 9 1
      src/router/index.js
  42. 12 0
      src/router/routes.js
  43. 12 1
      src/router/routes/associado.route.js
  44. 9 0
      src/stores/navigation.js

+ 5 - 0
src/api/appointment.js

@@ -39,6 +39,11 @@ export const rejectAppointmentParceiro = async (id) => {
   return data.payload;
 };
 
+export const getAppointmentsByUser = async (userId) => {
+  const { data } = await api.get(`/appointment/admin/user/${userId}`);
+  return data.payload;
+};
+
 export const getAdminCounters = async () => {
   const { data } = await api.get("/appointment/admin/counters");
   return data.payload;

+ 30 - 0
src/api/firstAccess.js

@@ -0,0 +1,30 @@
+import api from "src/api";
+
+export const checkFirstAccess = async (registration) => {
+  const { data } = await api.post("/first-access/check", { registration });
+  return { ...data.payload, message: data.message };
+};
+
+export const registerFirstAccess = async (payload) => {
+  const formData = new FormData();
+  Object.entries(payload).forEach(([key, value]) => {
+    if (value !== null && value !== undefined) {
+      formData.append(key, value);
+    }
+  });
+
+  const { data } = await api.post("/first-access/register", formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const getPublicPositions = async () => {
+  const { data } = await api.get("/public/positions");
+  return data.payload;
+};
+
+export const getPublicSectors = async () => {
+  const { data } = await api.get("/public/sectors");
+  return data.payload;
+};

+ 18 - 0
src/api/profile.js

@@ -24,3 +24,21 @@ export const deleteDependent = async (id) => {
   const { data } = await api.delete(`/user-dependent/${id}`);
   return data.payload;
 };
+
+export const getDependentsPaginated = async ({ page = 1, perPage = 10, filter, status } = {}) => {
+  const params = { page, per_page: perPage };
+  if (status) params.status = status;
+  if (filter) params.search = filter;
+  const { data } = await api.get("/user-dependent/paginated", { params });
+  return { data: { result: data.payload } };
+};
+
+export const approveDependent = async (id) => {
+  const { data } = await api.put(`/user-dependent/${id}/approve`);
+  return data.payload;
+};
+
+export const refuseDependent = async (id) => {
+  const { data } = await api.put(`/user-dependent/${id}/refuse`);
+  return data.payload;
+};

+ 14 - 0
src/api/user.js

@@ -86,6 +86,11 @@ export const approveUser = async (id) => {
   return data.payload;
 };
 
+export const refuseUser = async (id) => {
+  const { data } = await api.put(`/user/${id}/refuse`);
+  return data.payload;
+};
+
 export const updateMyProfile = async (payload) => {
   const { data } = await api.put("/user/my/profile", payload);
   return data.payload;
@@ -99,3 +104,12 @@ export const importAssociados = async (file) => {
   });
   return data.payload;
 };
+
+export const importAfastamentos = async (file) => {
+  const formData = new FormData();
+  formData.append("file", file);
+  const { data } = await api.post("/user/import/afastamentos", formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};

+ 18 - 1
src/components/CompleteProfileDialog.vue

@@ -82,6 +82,18 @@
             />
           </div>
 
+          <div class="col-12 col-sm-6">
+            <q-input
+              v-model="form.phone"
+              outlined
+              dense
+              mask="(##) #####-####"
+              :label="$t('auth.first_access.whatsapp')"
+              :readonly="!!initialUser.phone"
+              :bg-color="initialUser.phone ? 'grey-2' : 'white'"
+            />
+          </div>
+
           <div class="col-12 col-sm-6">
             <q-select
               v-model="form.position_id"
@@ -186,6 +198,7 @@ const form = ref({
   name: "",
   cpf: "",
   email: "",
+  phone: "",
   position_id: null,
   sector_id: null,
   registration: "",
@@ -198,6 +211,7 @@ const canSave = computed(() => {
     !!form.value.name?.trim() &&
     !!form.value.cpf?.trim() &&
     !!form.value.email?.trim() &&
+    !!form.value.phone?.trim() &&
     !!form.value.position_id &&
     !!form.value.sector_id
   );
@@ -227,6 +241,7 @@ const onSave = async () => {
     if (!initialUser.value.name)        payload.name        = form.value.name;
     if (!initialUser.value.cpf)         payload.cpf         = form.value.cpf;
     if (!initialUser.value.email)       payload.email       = form.value.email;
+    if (!initialUser.value.phone)       payload.phone       = form.value.phone;
     if (!initialUser.value.position_id) payload.position_id = form.value.position_id;
     if (!initialUser.value.sector_id)   payload.sector_id   = form.value.sector_id;
 
@@ -249,6 +264,7 @@ onMounted(async () => {
     name:           user.name           || null,
     cpf:            user.cpf            || null,
     email:          user.email          || null,
+    phone:          user.phone          || null,
     position_id:    user.position_id    ?? user.position?.id    ?? null,
     sector_id:      user.sector_id      ?? user.sector?.id      ?? null,
     registration:   user.registration   || null,
@@ -259,6 +275,7 @@ onMounted(async () => {
     name:           initialUser.value.name           || "",
     cpf:            initialUser.value.cpf            || "",
     email:          initialUser.value.email          || "",
+    phone:          initialUser.value.phone          || "",
     position_id:    initialUser.value.position_id    || null,
     sector_id:      initialUser.value.sector_id      || null,
     registration:   initialUser.value.registration   || "",
@@ -297,7 +314,7 @@ onMounted(async () => {
 
 .complete-profile-avatar {
   background: rgba(112, 32, 130, 0.18) !important;
-  border: 2px solid $violet-normal;
+  // border: 2px solid $violet-normal;
 
   img {
     width: 100%;

+ 20 - 8
src/components/ImportHistoryDialog.vue

@@ -30,6 +30,7 @@
 </template>
 
 <script setup>
+import { computed } from "vue";
 import { useDialogPluginComponent } from "quasar";
 import { useI18n } from "vue-i18n";
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
@@ -48,14 +49,25 @@ defineEmits([...useDialogPluginComponent.emits]);
 const { t } = useI18n();
 const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
 
-const columns = [
-  { name: "imported_at", label: t("import_log.date_time"),   field: "imported_at", align: "left",   sortable: false },
-  { name: "user",        label: t("import_log.imported_by"), field: "user",        align: "left",   sortable: false },
-  { name: "total",       label: t("import_log.total"),       field: "total",       align: "center", sortable: false },
-  { name: "created",     label: t("import_log.created"),     field: "created",     align: "center", sortable: false },
-  { name: "updated",     label: t("import_log.updated"),     field: "updated",     align: "center", sortable: false },
-  { name: "inactivated", label: t("import_log.inactivated"), field: "inactivated", align: "center", sortable: false },
-];
+const columns = computed(() => {
+  const base = [
+    { name: "imported_at", label: t("import_log.date_time"),   field: "imported_at", align: "left",   sortable: false },
+    { name: "user",        label: t("import_log.imported_by"), field: "user",        align: "left",   sortable: false },
+    { name: "total",       label: t("import_log.total"),       field: "total",       align: "center", sortable: false },
+    { name: "created",     label: t("import_log.created"),     field: "created",     align: "center", sortable: false },
+  ];
+
+  if (props.type === "afastamento") {
+    base.push({ name: "on_leave", label: t("import_log.on_leave"), field: "on_leave", align: "center", sortable: false });
+  } else {
+    base.push(
+      { name: "updated",     label: t("import_log.updated"),     field: "updated",     align: "center", sortable: false },
+      { name: "inactivated", label: t("import_log.inactivated"), field: "inactivated", align: "center", sortable: false },
+    );
+  }
+
+  return base;
+});
 
 const apiCall = (params) => getImportLogsPaginated({ ...params, type: props.type });
 

+ 216 - 0
src/components/NotificationCard.vue

@@ -0,0 +1,216 @@
+<template>
+  <q-card
+    flat
+    bordered
+    class="notification-card"
+    :class="{ 'notification-card--unread': unread === true, 'notification-card--clickable': clickable }"
+    @click="handleClick"
+  >
+    <q-badge
+      v-if="unread === true"
+      color="violet-normal"
+      rounded
+      class="notification-card__badge"
+    />
+
+    <div class="notification-card__image">
+      <img
+        v-if="imgSrc && !imgError"
+        :src="imgSrc"
+        alt=""
+        class="notification-card__img"
+        @error="imgError = true"
+      />
+      <div v-else class="notification-card__img-placeholder flex flex-center">
+        <q-icon name="mdi-bell-outline" size="32px" :color="unread === false ? 'grey-4' : 'violet-normal'" />
+      </div>
+    </div>
+
+    <q-card-section class="notification-card__body q-pt-sm q-pb-xs">
+      <div
+        class="notification-card__title text-weight-bold"
+        :class="unread === false ? 'text-grey-7' : 'text-violet-normal'"
+      >
+        {{ notification.title }}
+      </div>
+      <div class="notification-card__message text-grey-7">
+        {{ notification.message }}
+      </div>
+    </q-card-section>
+
+    <q-card-actions class="notification-card__actions q-pt-xs q-pb-sm q-px-md flex justify-between items-center">
+      <div class="text-caption text-grey-6">
+        {{ formattedDate }}
+      </div>
+
+      <q-btn
+        v-if="showStatsButton"
+        unelevated
+        dense
+        color="violet-normal"
+        text-color="white"
+        :label="$t('notification.details')"
+        size="sm"
+        padding="4px 10px"
+        class="notification-card__details-btn"
+        @click.stop
+      >
+        <q-menu anchor="bottom right" self="top right" class="notification-card__menu">
+          <q-list dense style="min-width: 200px">
+            <q-item>
+              <q-item-section avatar>
+                <q-icon name="mdi-send-outline" color="violet-normal" size="18px" />
+              </q-item-section>
+              <q-item-section>
+                <q-item-label class="text-body2">
+                  {{ $t('notification.sent_to', { count: notification.sent_count ?? 0 }) }}
+                </q-item-label>
+              </q-item-section>
+            </q-item>
+            <q-item>
+              <q-item-section avatar>
+                <q-icon name="mdi-eye-outline" color="violet-normal" size="18px" />
+              </q-item-section>
+              <q-item-section>
+                <q-item-label class="text-body2">
+                  {{ $t('notification.seen_by', { count: notification.seen_count ?? 0 }) }}
+                </q-item-label>
+              </q-item-section>
+            </q-item>
+          </q-list>
+        </q-menu>
+      </q-btn>
+
+      <q-icon
+        v-else-if="showReadIcon && unread === false"
+        name="mdi-check-circle"
+        color="positive"
+        size="18px"
+      />
+    </q-card-actions>
+  </q-card>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { useI18n } from "vue-i18n";
+import { resolveNotificationImageUrl, formatNotificationDate } from "src/helpers/notification";
+
+const { t } = useI18n();
+
+const props = defineProps({
+  notification: { type: Object, required: true },
+  unread: { type: Boolean, default: null },
+  clickable: { type: Boolean, default: true },
+  showStatsButton: { type: Boolean, default: false },
+  showReadIcon: { type: Boolean, default: false },
+  date: { type: String, default: null },
+});
+
+const emit = defineEmits(["click"]);
+
+const imgError = ref(false);
+const imgSrc = computed(() => resolveNotificationImageUrl(props.notification));
+const formattedDate = computed(() =>
+  formatNotificationDate(props.date ?? props.notification.created_at, t("common.terms.today"))
+);
+
+const handleClick = () => {
+  if (props.clickable) emit("click");
+};
+</script>
+
+<style scoped lang="scss">
+.notification-card {
+  position: relative;
+  border-radius: 8px;
+  overflow: hidden;
+  min-width: 0;
+  max-width: 100%;
+
+  &__badge {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    z-index: 1;
+  }
+
+  &--clickable {
+    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: 90px;
+    overflow: hidden;
+    background: #f0e8f1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  &__img {
+    width: 100%;
+    height: 100%;
+    object-fit: contain;
+    display: block;
+  }
+
+  &__img-placeholder {
+    width: 100%;
+    height: 100%;
+  }
+
+  &__body {
+    min-width: 0;
+    overflow: hidden;
+  }
+
+  &__actions {
+    gap: 8px;
+    min-width: 0;
+  }
+
+  // `nowrap` faria o título ter largura mínima igual ao texto inteiro e empurrar
+  // o card para fora da tela; o clamp trunca em 2 linhas sem pressionar a largura.
+  &__title {
+    max-width: 100%;
+    font-size: 16px;
+    line-height: 1.3;
+    overflow-wrap: anywhere;
+    word-break: break-word;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  &__message {
+    max-width: 100%;
+    font-size: 13px;
+    line-height: 1.45;
+    overflow-wrap: anywhere;
+    word-break: break-word;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  &__menu {
+    border-radius: 8px;
+  }
+}
+</style>

+ 7 - 20
src/components/NotificationDetailDialog.vue

@@ -44,6 +44,7 @@ import { ref, computed, onMounted } from "vue";
 import { useI18n } from "vue-i18n";
 import { useDialogPluginComponent } from "quasar";
 import { userStore } from "src/stores/user";
+import { resolveNotificationImageUrl, formatNotificationDate } from "src/helpers/notification";
 import {
   markNotificationAsReadAssociado,
   markNotificationAsReadParceiro,
@@ -62,25 +63,9 @@ const store = userStore();
 const markedReadId = ref(null);
 const imageError = ref(false);
 
-const imageUrl = computed(() => {
-  const direct = props.item.notification?.image_url;
-  if (!direct) return null;
-  if (direct.startsWith("http")) return direct;
-  const base = (process.env.API_URL ?? "").replace(/\/$/, "");
-  return `${base}/${direct.replace(/^\//, "")}`;
-});
+const imageUrl = computed(() => resolveNotificationImageUrl(props.item.notification));
 
-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 formatDate = (dateStr) => formatNotificationDate(dateStr, t("common.terms.today"));
 
 const handleClose = () => {
   onDialogOK(markedReadId.value);
@@ -104,7 +89,7 @@ onMounted(async () => {
 <style scoped lang="scss">
 .notif-detail-card {
   width: 90vw;
-  max-width: 600px;
+  max-width: 640px;
   min-width: 300px;
 
   &__scroll {
@@ -113,6 +98,7 @@ onMounted(async () => {
   }
 
   &__title {
+    min-width: 0;
     white-space: normal;
     word-break: break-word;
   }
@@ -129,7 +115,7 @@ onMounted(async () => {
 
   &__img {
     width: 100%;
-    max-height: 350px;
+    max-height: 420px;
     object-fit: contain;
     display: block;
   }
@@ -137,6 +123,7 @@ onMounted(async () => {
   &__message {
     white-space: pre-wrap;
     word-break: break-word;
+    overflow-wrap: anywhere;
     line-height: 1.6;
   }
 }

+ 9 - 140
src/components/UnreadNotificationsDialog.vue

@@ -20,71 +20,16 @@
           <q-spinner color="violet-normal" size="50px" />
         </div>
 
-        <div v-else class="row q-col-gutter-md q-pt-xs">
-          <div
+        <div v-else class="notifications-grid q-pt-xs">
+          <NotificationCard
             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="openDetail(item)"
-            >
-              <div class="notif-card__image">
-                <img
-                  v-if="imageUrl(item) && !imageErrors.has(item.id)"
-                  :src="imageUrl(item)"
-                  alt=""
-                  class="notif-card__img"
-                  @error="onImageError(item)"
-                />
-                <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>
+            :notification="item.notification"
+            :unread="!item.read"
+            :date="item.created_at"
+            show-read-icon
+            @click="openDetail(item)"
+          />
         </div>
       </q-card-section>
 
@@ -108,7 +53,6 @@
 
 <script setup>
 import { ref, computed, watch } from "vue";
-import { useI18n } from "vue-i18n";
 import { useQuasar } from "quasar";
 import { userStore } from "src/stores/user";
 import {
@@ -116,6 +60,7 @@ import {
   getMyUnreadNotificationsParceiro,
 } from "src/api/notification";
 import NotificationDetailDialog from "src/components/NotificationDetailDialog.vue";
+import NotificationCard from "src/components/NotificationCard.vue";
 
 const props = defineProps({
   modelValue: { type: Boolean, required: true },
@@ -123,13 +68,11 @@ const props = defineProps({
 
 const emit = defineEmits(["update:modelValue"]);
 
-const { t } = useI18n();
 const $q = useQuasar();
 const store = userStore();
 
 const loading = ref(false);
 const notifications = ref([]);
-const imageErrors = ref(new Set());
 
 const localUnreadCount = computed(() => notifications.value.filter((n) => !n.read).length);
 
@@ -148,30 +91,6 @@ const fetchUnread = async () => {
   }
 };
 
-const imageUrl = (item) => {
-  const direct = item.notification?.image_url;
-  if (!direct) return null;
-  if (direct.startsWith("http")) return direct;
-  const base = (process.env.API_URL ?? "").replace(/\/$/, "");
-  return `${base}/${direct.replace(/^\//, "")}`;
-};
-
-const onImageError = (item) => {
-  imageErrors.value = new Set(imageErrors.value).add(item.id);
-};
-
-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 openDetail = (item) => {
   $q.dialog({
     component: NotificationDetailDialog,
@@ -208,54 +127,4 @@ watch(
     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>

+ 1 - 1
src/components/layout/AppHeader.vue

@@ -123,7 +123,7 @@ const userInitial = computed(() => {
 
 .app-header__avatar {
   background: rgba(white, 0.15);
-  border: 2px solid rgba(white, 0.3);
+  // border: 2px solid rgba(white, 0.3);
   cursor: pointer;
   flex-shrink: 0;
 }

+ 28 - 0
src/css/app.scss

@@ -150,6 +150,15 @@ input[type="number"]::-webkit-outer-spin-button {
   background: #fff !important;
 }
 
+:where(.q-field--standout.q-field--highlighted) {
+  .q-field__native,
+  .q-field__input,
+  .q-field__prefix,
+  .q-field__suffix {
+    color: $color-text !important;
+  }
+}
+
 .q-field--dark .q-field__native {
   color: black;
 }
@@ -209,3 +218,22 @@ input[type="number"]::-webkit-outer-spin-button {
     border-color: $violet-normal !important;
   }
 }
+
+.notifications-grid {
+  display: grid;
+  width: 100%;
+  gap: 16px;
+  grid-template-columns: minmax(0, 1fr);
+
+  @media (min-width: 600px) {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  @media (min-width: 1024px) {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+
+  @media (min-width: 1440px) {
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+  }
+}

+ 3 - 1
src/css/quasar.variables.scss

@@ -48,6 +48,7 @@ $positive: #2e7d32; // Material Green 800
 $negative: #d32f2f; // Material Red 700
 $info: #0288d1; // Material Light Blue 700
 $inactive: #FFE100; // Material Orange 800
+$pending: #ed6c02; // Material Orange 900
 
 // Extended Color System with Light/Dark Variants
 $colors: (
@@ -122,7 +123,8 @@ $colors: (
   "surface": #FEFEFE,
 
   // Inactive
-  "inactive": #ed6c02
+  "inactive": #ed6c02,
+  "pending": #da833c
 );
 
 // Dark Theme Color Overrides

+ 22 - 0
src/helpers/notification.js

@@ -0,0 +1,22 @@
+export const resolveNotificationImageUrl = (content) => {
+  if (!content) return null;
+  const media = content.media?.[0]?.url;
+  if (media) return media;
+  const direct = content.image_url;
+  if (!direct) return null;
+  if (direct.startsWith("http")) return direct;
+  const base = (process.env.API_URL ?? "").replace(/\/$/, "");
+  return `${base}/${direct.replace(/^\//, "")}`;
+};
+
+export const formatNotificationDate = (dateStr, todayLabel) => {
+  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 todayLabel;
+  return date.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
+};

+ 3 - 1
src/helpers/utils.js

@@ -107,9 +107,10 @@ const normalizeString = (val) =>
 const getStatusColor = (status) => {
   const s = typeof status === "object" ? status?.value : status;
   if (s === "active")   return "positive";
-  if (s === "pending")  return "warning";
+  if (s === "pending")  return "warning-light";
   if (s === "on_leave") return "orange";
   if (s === "inactive") return "inactive";
+  if (s === "refused")  return "negative";
   return "grey-6";
 };
 
@@ -119,6 +120,7 @@ const getStatusI18nKey = (status) => {
   if (s === "inactive") return "common.status.inactive";
   if (s === "pending")  return "common.status.pending";
   if (s === "on_leave") return "common.status.on_leave";
+  if (s === "refused")  return "common.status.refused";
   return "common.status.inactive";
 };
 

+ 42 - 7
src/i18n/locales/en.json

@@ -11,7 +11,10 @@
         "description": "Your digital membership card with QR Code"
       },
       "convenios": {
-        "description": "Browse available partners and agreements"
+        "description": "Browse available agreements"
+      },
+      "parceiros": {
+        "description": "Browse available partners"
       },
       "loja": {
         "description": "Browse available store items"
@@ -158,6 +161,7 @@
       "inactive": "Inactive",
       "pending": "Pending",
       "on_leave": "On Leave",
+      "refused": "Refused",
       "canceled": "Canceled",
       "loading": "Please wait...",
       "yes": "Yes",
@@ -249,7 +253,23 @@
     "confirm": "Confirm",
     "back_to_site": "Back to Site",
     "wrong_type": "User found, but the selected type is incorrect. Please select the correct login type.",
-    "email_or_badge": "Email or Badge"
+    "email_or_badge": "Email or Badge",
+    "first_access": {
+      "button": "My First Access",
+      "title": "First Access",
+      "registration": "Badge",
+      "registration_hint": "Enter your badge number to get started.",
+      "form_title": "Complete your registration",
+      "form_hint": "Fill in the fields below to finish your first access. Grey fields are filled in by the administration.",
+      "whatsapp": "WhatsApp",
+      "photo_hint": "Tap the icon to add your photo",
+      "photo_required": "The photo is required",
+      "finish": "Finish Registration",
+      "success": "Registration completed successfully!",
+      "success_login": "Registration completed! Sign in to access the system.",
+      "already_done": "The first access for this badge has already been completed. Please sign in or use \"Forgot your password?\".",
+      "back_to_login": "Back to login"
+    }
   },
   "business": {
     "advertise": "Advertise",
@@ -446,8 +466,10 @@
       "subtitle": "Subtitle"
     },
     "stat": {
+      "title": "Stat {n}",
       "value": "Value (Stat {n})",
-      "label": "Label (Stat {n})"
+      "label": "Label (Stat {n})",
+      "preview": "How it looks on the landing page"
     },
     "contact": {
       "email": "Contact Email",
@@ -485,8 +507,17 @@
     "approve_title": "Approve registration",
     "approve_message": "Confirm approval of {name}'s registration?",
     "approve_confirm": "Confirm approval",
+    "dependent_registered_at": "Registration Date/Time",
+    "dependent_approval_dialog_title": "Dependent Approval",
+    "dependent_approve_confirm": "Approve",
+    "dependent_refuse_confirm": "Refuse",
+    "pending_approval_dialog_title": "Associate Approval",
+    "appointments_dialog_title": "{name}'s Appointments",
+    "pending_approve": "Approve",
+    "pending_refuse": "Refuse",
     "import_associados": "Associates",
     "import_sync_result": "{created} created · {updated} updated · {inactivated} inactivated",
+    "import_afastamentos_result": "{created} created · {on_leave} on leave",
     "import_processing": "Import is still processing. Please refresh the page in a moment.",
     "search_placeholder": "Search Members",
     "filter_by_position": "Filter by position",
@@ -587,6 +618,7 @@
       "contratos_a_vencer": "Expiring Contracts",
       "novos_mes": "New (month)",
       "associados_pendentes": "Pending Members",
+      "dependentes_pendentes": "Pending Dependents",
       "ultimos_acessos": "Last Accesses",
       "total_acessos_app": "Serprati App Accesses",
       "association_date": "Association Date",
@@ -704,8 +736,8 @@
     "dashboard": {
       "title": "Dashboard",
       "authorization": "Pending Authorizations",
-      "scheduling": "Appointments",
-      "completed": "Completed",
+      "scheduling": "Authorized Appointments",
+      "completed": "Completed Appointments",
       "not_authorized": "Not Authorized"
     }
   },
@@ -728,7 +760,9 @@
       "pedido": "Order",
       "parceiro": "Partner",
       "servico": "Service",
-      "solicitacao": "Request"
+      "solicitacao": "Request",
+      "data_hora": "Date/Time",
+      "observacoes": "Notes"
     }
   },
   "notification": {
@@ -785,7 +819,8 @@
     "total": "Total",
     "created": "New",
     "updated": "Updated",
+    "on_leave": "On leave",
     "inactivated": "Inactivated",
     "no_history": "No imports recorded"
   }
-}
+}

+ 42 - 7
src/i18n/locales/es.json

@@ -11,7 +11,10 @@
         "description": "Su tarjeta digital con código QR de identificación"
       },
       "convenios": {
-        "description": "Consulte los socios y convenios disponibles"
+        "description": "Consulte los convenios disponibles"
+      },
+      "parceiros": {
+        "description": "Consulte los socios disponibles"
       },
       "loja": {
         "description": "Consulte los artículos disponibles en la tienda"
@@ -159,6 +162,7 @@
       "inactive": "Inactivo",
       "pending": "Pendiente",
       "on_leave": "De Baja",
+      "refused": "Rechazado",
       "canceled": "Cancelado",
       "loading": "Por favor espere...",
       "yes": "Sí",
@@ -250,7 +254,23 @@
     "confirm": "Confirmar",
     "back_to_site": "Volver al Sitio",
     "wrong_type": "Usuario encontrado, pero el tipo seleccionado es incorrecto. Seleccione el tipo correcto.",
-    "email_or_badge": "Correo o Credencial"
+    "email_or_badge": "Correo o Credencial",
+    "first_access": {
+      "button": "Mi Primer Acceso",
+      "title": "Primer Acceso",
+      "registration": "Credencial",
+      "registration_hint": "Ingrese el número de su credencial para comenzar.",
+      "form_title": "Complete su registro",
+      "form_hint": "Complete los datos a continuación para finalizar su primer acceso. Los campos en gris son completados por la administración.",
+      "whatsapp": "WhatsApp",
+      "photo_hint": "Toque el ícono para agregar su foto",
+      "photo_required": "La foto es obligatoria",
+      "finish": "Finalizar Registro",
+      "success": "¡Registro completado con éxito!",
+      "success_login": "¡Registro completado! Inicie sesión para acceder al sistema.",
+      "already_done": "El primer acceso de esta credencial ya fue realizado. Inicie sesión normalmente o use \"¿Olvidó su contraseña?\".",
+      "back_to_login": "Volver al inicio de sesión"
+    }
   },
   "business": {
     "advertise": "Anunciar",
@@ -447,8 +467,10 @@
       "subtitle": "Subtítulo"
     },
     "stat": {
+      "title": "Estadística {n}",
       "value": "Valor (Estadística {n})",
-      "label": "Etiqueta (Estadística {n})"
+      "label": "Etiqueta (Estadística {n})",
+      "preview": "Cómo se ve en la landing page"
     },
     "contact": {
       "email": "Correo de Contacto",
@@ -486,8 +508,17 @@
     "approve_title": "Aprobar registro",
     "approve_message": "¿Confirma la aprobación del registro de {name}?",
     "approve_confirm": "Confirmar aprobación",
+    "dependent_registered_at": "Fecha/Hora de Registro",
+    "dependent_approval_dialog_title": "Aprobación de Dependiente",
+    "dependent_approve_confirm": "Aprobar",
+    "dependent_refuse_confirm": "Rechazar",
+    "pending_approval_dialog_title": "Aprobación de Asociado",
+    "appointments_dialog_title": "Citas de {name}",
+    "pending_approve": "Aprobar",
+    "pending_refuse": "Rechazar",
     "import_associados": "Asociados",
     "import_sync_result": "{created} creados · {updated} actualizados · {inactivated} inactivados",
+    "import_afastamentos_result": "{created} nuevos · {on_leave} en licencia",
     "import_processing": "La importación está en progreso. Actualice la página en unos instantes.",
     "search_placeholder": "Buscar por asociados",
     "filter_by_position": "Filtrar por cargo",
@@ -588,6 +619,7 @@
       "contratos_a_vencer": "Contratos por Vencer",
       "novos_mes": "Nuevos (mes)",
       "associados_pendentes": "Asociados Pendientes",
+      "dependentes_pendentes": "Dependientes Pendientes",
       "ultimos_acessos": "Últimos Accesos",
       "total_acessos_app": "Accesos al App Serprati",
       "association_date": "Fecha de Asociación",
@@ -704,8 +736,8 @@
     "dashboard": {
       "title": "Dashboard",
       "authorization": "Autorizaciones Pendientes",
-      "scheduling": "Citas",
-      "completed": "Completados",
+      "scheduling": "Citas Autorizadas",
+      "completed": "Citas Completadas",
       "not_authorized": "No Autorizados"
     }
   },
@@ -728,7 +760,9 @@
       "pedido": "Pedido",
       "parceiro": "Socio",
       "servico": "Servicio",
-      "solicitacao": "Solicitud"
+      "solicitacao": "Solicitud",
+      "data_hora": "Fecha/Hora",
+      "observacoes": "Observaciones"
     }
   },
   "notification": {
@@ -785,7 +819,8 @@
     "total": "Total",
     "created": "Nuevos",
     "updated": "Actualizados",
+    "on_leave": "En licencia",
     "inactivated": "Desactivados",
     "no_history": "Ninguna importación registrada"
   }
-}
+}

+ 42 - 7
src/i18n/locales/pt.json

@@ -11,7 +11,10 @@
         "description": "Sua carteira digital com QR Code de identificação"
       },
       "convenios": {
-        "description": "Confira os parceiros e convênios disponíveis para você"
+        "description": "Confira os convênios disponíveis para você"
+      },
+      "parceiros": {
+        "description": "Confira os parceiros disponíveis para você"
       },
       "loja": {
         "description": "Confira os itens disponíveis na loja"
@@ -159,6 +162,7 @@
       "inactive": "Inativo",
       "pending": "Pendente",
       "on_leave": "Afastado",
+      "refused": "Recusado",
       "canceled": "Cancelado",
       "loading": "Por favor, aguarde...",
       "yes": "Sim",
@@ -250,7 +254,23 @@
     "confirm": "Confirmar",
     "back_to_site": "Voltar ao Site",
     "wrong_type": "Usuário encontrado, mas o tipo selecionado está incorreto. Selecione o login correto.",
-    "email_or_badge": "E-mail ou Crachá"
+    "email_or_badge": "E-mail ou Crachá",
+    "first_access": {
+      "button": "Meu Primeiro Acesso",
+      "title": "Primeiro Acesso",
+      "registration": "Crachá",
+      "registration_hint": "Informe o número do seu crachá para começar.",
+      "form_title": "Complete seu cadastro",
+      "form_hint": "Preencha os dados abaixo para concluir seu primeiro acesso. Os campos em cinza são preenchidos pela administração.",
+      "whatsapp": "WhatsApp",
+      "photo_hint": "Toque no ícone para adicionar sua foto",
+      "photo_required": "A foto é obrigatória",
+      "finish": "Concluir Cadastro",
+      "success": "Cadastro concluído com sucesso!",
+      "success_login": "Cadastro concluído! Faça login para acessar o sistema.",
+      "already_done": "O primeiro acesso deste crachá já foi realizado. Faça login normalmente ou use \"Esqueceu a senha?\".",
+      "back_to_login": "Voltar ao login"
+    }
   },
   "business": {
     "advertise": "Anunciar",
@@ -447,8 +467,10 @@
       "subtitle": "Subtítulo"
     },
     "stat": {
+      "title": "Estatística {n}",
       "value": "Valor (Estatística {n})",
-      "label": "Rótulo (Estatística {n})"
+      "label": "Rótulo (Estatística {n})",
+      "preview": "Como aparece na landing page"
     },
     "contact": {
       "email": "E-mail de Contato",
@@ -486,8 +508,17 @@
     "approve_title": "Aprovar cadastro",
     "approve_message": "Confirma a aprovação do cadastro de {name}?",
     "approve_confirm": "Confirmar aprovação",
+    "dependent_registered_at": "Data/Hora de Cadastro",
+    "dependent_approval_dialog_title": "Aprovação de Dependente",
+    "dependent_approve_confirm": "Aprovar",
+    "dependent_refuse_confirm": "Recusar",
+    "pending_approval_dialog_title": "Aprovação de Associado",
+    "appointments_dialog_title": "Agendamentos de {name}",
+    "pending_approve": "Aprovar",
+    "pending_refuse": "Recusar",
     "import_associados": "Associados",
     "import_sync_result": "{created} criados · {updated} atualizados · {inactivated} inativados",
+    "import_afastamentos_result": "{created} novos · {on_leave} afastados",
     "import_processing": "Importação em andamento. Atualize a página em alguns instantes.",
     "search_placeholder": "Buscar por associados",
     "filter_by_position": "Filtrar por cargo",
@@ -588,6 +619,7 @@
       "contratos_a_vencer": "Contratos a Vencer",
       "novos_mes": "Novos (mês)",
       "associados_pendentes": "Associados Pendentes",
+      "dependentes_pendentes": "Dependentes Pendentes",
       "ultimos_acessos": "Últimos Acessos",
       "total_acessos_app": "Acessos ao App Serprati",
       "association_date": "Data Associação",
@@ -705,8 +737,8 @@
     "dashboard": {
       "title": "Dashboard",
       "authorization": "Autorizações Pendentes",
-      "scheduling": "Agendamentos",
-      "completed": "Concluídos",
+      "scheduling": "Agendamentos Autorizados",
+      "completed": "Agendamentos Concluídos",
       "not_authorized": "Não Autorizados"
     }
   },
@@ -729,7 +761,9 @@
       "pedido": "Pedido",
       "parceiro": "Parceiro",
       "servico": "Serviço",
-      "solicitacao": "Solicitação"
+      "solicitacao": "Solicitação",
+      "data_hora": "Data/Horário",
+      "observacoes": "Observações"
     }
   },
   "notification": {
@@ -786,7 +820,8 @@
     "total": "Total",
     "created": "Novos",
     "updated": "Atualizados",
+    "on_leave": "Afastados",
     "inactivated": "Desativados",
     "no_history": "Nenhuma importação registrada"
   }
-}
+}

+ 1 - 0
src/layouts/MainLayout.vue

@@ -96,6 +96,7 @@ const isProfileIncomplete = (user) => {
     !user.name ||
     !user.cpf ||
     !user.email ||
+    !user.phone ||
     !(user.position_id ?? user.position?.id) ||
     !(user.sector_id   ?? user.sector?.id)
   );

+ 1 - 1
src/pages/agendamentos/AppointmentsAdminPage.vue

@@ -287,7 +287,7 @@ const columnsAprovados = computed(() => [
 
 const statusColor = (status) => {
   const map = {
-    pendente: "warning",
+    pendente: "warning-light",
     confirmado: "positive",
     recusado: "negative",
     cancelado: "grey-6",

+ 1 - 1
src/pages/associado/agendamentos/AgendamentosPage.vue

@@ -191,7 +191,7 @@ const pagedAppointments = computed(() => {
 });
 
 const statusColor = (status) => {
-  const map = { pendente: "warning", confirmado: "positive", cancelado: "negative", recusado: "negative", concluido: "grey" };
+  const map = { pendente: "warning-light", confirmado: "positive", cancelado: "negative", recusado: "negative", concluido: "grey" };
   return map[status] ?? "grey";
 };
 

+ 1 - 1
src/pages/associado/carteirinha/CarteirinhaPage.vue

@@ -159,7 +159,7 @@ onMounted(async () => {
   }
 
   .card-avatar {
-    border: 2px solid rgba(255, 255, 255, 0.4);
+    // border: 2px solid rgba(255, 255, 255, 0.4);
     background: rgba(255, 255, 255, 0.15);
     flex-shrink: 0;
   }

+ 21 - 136
src/pages/associado/notificacoes/NotificacoesAssociadoPage.vue

@@ -13,63 +13,15 @@
       </div>
 
       <div v-else>
-        <div class="row q-px-md q-col-gutter-md">
-          <div
+        <div class="notifications-grid q-px-md">
+          <NotificationCard
             v-for="item in pagedItems"
             :key="item.id"
-            class="col-xl-4 col-lg-4 col-md-4 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-card-actions>
-            </q-card>
-          </div>
+            :notification="item.notification"
+            :unread="!item.read"
+            :date="item.created_at"
+            @click="openDetail(item)"
+          />
         </div>
 
         <div v-if="totalPages > 1" class="flex flex-center q-mt-lg">
@@ -91,11 +43,13 @@
 
 <script setup>
 import { ref, computed, onMounted } from "vue";
-import { useI18n } from "vue-i18n";
-import { getMyNotificationsAssociado, markNotificationAsReadAssociado } from "src/api/notification";
+import { useQuasar } from "quasar";
+import { getMyNotificationsAssociado } from "src/api/notification";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import NotificationCard from "src/components/NotificationCard.vue";
+import NotificationDetailDialog from "src/components/NotificationDetailDialog.vue";
 
-const { t } = useI18n();
+const $q = useQuasar();
 
 const loading = ref(true);
 const notifications = ref([]);
@@ -109,34 +63,15 @@ const pagedItems = computed(() => {
   return notifications.value.slice(start, start + PER_PAGE);
 });
 
-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 {
-    await markNotificationAsReadAssociado(item.id);
-    item.read = true;
-  } catch (e) {
-    console.error(e);
-  }
+const openDetail = (item) => {
+  $q.dialog({
+    component: NotificationDetailDialog,
+    componentProps: { item },
+  }).onOk((markedId) => {
+    if (!markedId) return;
+    const found = notifications.value.find((n) => n.id === markedId);
+    if (found) found.read = true;
+  });
 };
 
 onMounted(async () => {
@@ -150,53 +85,3 @@ onMounted(async () => {
 });
 </script>
 
-<style scoped lang="scss">
-.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;
-    -webkit-box-orient: vertical;
-    overflow: hidden;
-    line-height: 1.4;
-  }
-}
-</style>

+ 3 - 3
src/pages/associado/profile/ProfilePage.vue

@@ -151,7 +151,7 @@ const avatarInputRef = useTemplateRef("avatarInputRef");
 const statusBadgeColor = computed(() => {
   switch (user.value?.status) {
     case "active":   return "positive";
-    case "inactive": return "warning";
+    case "inactive": return "grey";
     case "canceled": return "negative";
     default:         return "grey";
   }
@@ -180,7 +180,7 @@ const dependentStatusColor = (status) => {
   switch (status) {
     case "approved": return "positive";
     case "refused":  return "negative";
-    case "pending":  return "warning";
+    case "pending":  return "warning-light";
     default:         return "grey";
   }
 };
@@ -286,7 +286,7 @@ const onAddDependent = () => {
 
 .profile-avatar {
   background: rgba(255, 255, 255, 0.18) !important;
-  border: 2px solid rgba(255, 255, 255, 0.45);
+  // border: 2px solid rgba(255, 255, 255, 0.45);
 }
 
 .profile-avatar-edit {

+ 2 - 1
src/pages/associado/profile/components/AddEditDependentDialog.vue

@@ -19,9 +19,10 @@
             :options="kinshipOptions"
             emit-value
             map-options
-            class="col-md-6 col-12"
+            :class="dependent ? 'col-md-6 col-12' : 'col-12'"
           />
           <DefaultSelect
+            v-if="dependent"
             v-model="form.status"
             v-model:error="validationErrors.status"
             :rules="[inputRules.required]"

+ 41 - 14
src/pages/company-settings/CompanySettingsPage.vue

@@ -78,20 +78,40 @@
                 <div class="text-subtitle2 q-mb-sm">{{ $t("company_settings.section.stats") }}</div>
               </div>
 
-              <template v-for="n in 3" :key="n">
-                <DefaultInput
-                  v-model="form[`stat${n}_value`]"
-                  v-model:error="validationErrors[`stat${n}_value`]"
-                  :label="$t('company_settings.stat.value', { n })"
-                  class="col-md-3 col-6"
-                />
-                <DefaultInput
-                  v-model="form[`stat${n}_label`]"
-                  v-model:error="validationErrors[`stat${n}_label`]"
-                  :label="$t('company_settings.stat.label', { n })"
-                  class="col-md-3 col-6"
-                />
-              </template>
+              <div class="col-12">
+                <div class="row q-col-gutter-md">
+                  <div v-for="n in 3" :key="n" class="col-md-4 col-12">
+                    <q-card flat bordered class="q-pa-md column q-gutter-sm">
+                      <div class="text-caption text-weight-medium text-grey-8">
+                        {{ $t("company_settings.stat.title", { n }) }}
+                      </div>
+
+                      <DefaultInput
+                        v-model="form[`stat${n}_value`]"
+                        v-model:error="validationErrors[`stat${n}_value`]"
+                        :label="$t('company_settings.stat.value', { n })"
+                      />
+                      <DefaultInput
+                        v-model="form[`stat${n}_label`]"
+                        v-model:error="validationErrors[`stat${n}_label`]"
+                        :label="$t('company_settings.stat.label', { n })"
+                      />
+
+                      <q-separator class="q-my-xs" />
+
+                      <div class="stat-preview rounded-borders q-pa-md column items-start q-gutter-xs">
+                        <q-icon :name="statIcons[n]" size="28px" class="text-white" style="opacity: .9" />
+                        <span class="text-white text-weight-bold" style="font-size: 1.35rem; line-height: 1.2;">
+                          {{ form[`stat${n}_value`] || "—" }}
+                        </span>
+                        <span class="text-white text-caption" style="opacity: .8">
+                          {{ form[`stat${n}_label`] || "—" }}
+                        </span>
+                      </div>
+                    </q-card>
+                  </div>
+                </div>
+              </div>
 
               <div class="col-12">
                 <div class="text-subtitle2 q-mb-sm">{{ $t("company_settings.section.contacts") }}</div>
@@ -242,6 +262,8 @@ const { t } = useI18n();
 const heroFormRef = useTemplateRef("heroFormRef");
 const imageInputRef = useTemplateRef("imageInputRef");
 
+const statIcons = { 1: "mdi-account-group", 2: "mdi-handshake", 3: "mdi-heart-outline" };
+
 const settings = ref({});
 const uploadingImage = ref(false);
 const removingImage = ref(false);
@@ -385,6 +407,11 @@ onMounted(() => {
 </script>
 
 <style scoped>
+.stat-preview {
+  background-color: var(--q-primary);
+  min-height: 96px;
+}
+
 .benefit-row {
   border: 1px solid rgba(0, 0, 0, 0.12);
   min-height: 52px;

+ 60 - 2
src/pages/dashboard/DashboardPage.vue

@@ -35,12 +35,15 @@
 
         <q-card v-if="activeCard" flat class="bg-white" style="border-radius: 12px">
           <DefaultTableServerSide
+            ref="tableRef"
             :key="activeCard"
             :columns="tableColumns"
             :api-call="activeApiCall"
             :add-item="false"
             sem-table-top
+            :open-item="['dependentes_pendentes', 'associados_pendentes'].includes(activeCard)"
             :show-search-field="!['ultimos_acessos', 'associados_com_acesso_app'].includes(activeCard)"
+            @on-row-click="onRowClick"
           >
             <template #body-cell-status="{ row }">
               <q-td>
@@ -76,17 +79,25 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from "vue";
+import { ref, computed, onMounted, useTemplateRef } from "vue";
 import { useI18n } from "vue-i18n";
+import { useQuasar } from "quasar";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
 import { getDashboardStats } from "src/api/dashboard";
 import { getUsersPaginated } from "src/api/user";
+import { getDependentsPaginated } from "src/api/profile";
 import { getPartnerAgreementsPaginated, getExpiringPartnerAgreementsPaginated } from "src/api/partnerAgreement";
 import { getAccessLogsPaginated } from "src/api/userAccessLog";
 import { formatDateYMDtoDMY, getStatusColor, getStatusI18nKey } from "src/helpers/utils";
+import { permissionStore } from "src/stores/permission";
+import DependentApprovalDialog from "./components/DependentApprovalDialog.vue";
+import AssociadoApprovalDialog from "./components/AssociadoApprovalDialog.vue";
 
 const { t } = useI18n();
+const $q = useQuasar();
+const permission_store = permissionStore();
+const tableRef = useTemplateRef("tableRef");
 
 const statsLoading = ref(true);
 const stats = ref({});
@@ -99,6 +110,7 @@ const statCards = [
   { key: "contratos_a_vencer",   icon: "mdi-file-clock",     labelKey: "dashboard.stats.contratos_a_vencer" },
   { key: "novos_mes",            icon: "mdi-account-plus",   labelKey: "dashboard.stats.novos_mes" },
   { key: "associados_pendentes", icon: "mdi-account-alert",  labelKey: "dashboard.stats.associados_pendentes" },
+  { key: "dependentes_pendentes", icon: "mdi-account-child-circle", labelKey: "dashboard.stats.dependentes_pendentes" },
   { key: "ultimos_acessos",      icon: "mdi-login-variant",  labelKey: "dashboard.stats.ultimos_acessos" },
   { key: "associados_com_acesso_app", icon: "mdi-cellphone-check", labelKey: "dashboard.stats.total_acessos_app" },
 ];
@@ -125,6 +137,12 @@ const columnsContratosAVencer = computed(() => [
   { name: "status",       label: t("common.terms.status"),     field: "status",        align: "left" },
 ]);
 
+const columnsDependentes = computed(() => [
+  { name: "responsible_user_name", label: t("associado.associado"), field: (row) => row.responsible_user?.name ?? "—", align: "left", sortable: false },
+  { name: "name",       label: t("associado.dependent"),                  field: "name",       align: "left", sortable: false },
+  { name: "created_at", label: t("associado.dependent_registered_at"),    field: (row) => formatDateTime(row.created_at), align: "left", sortable: false },
+]);
+
 const columnsAccessLogs = 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 },
@@ -136,6 +154,7 @@ const tableColumns = computed(() => {
   if (activeCard.value === "contratos_a_vencer") return columnsContratosAVencer.value;
   if (activeCard.value === "parceiros" || activeCard.value === "novos_mes") return columnsParceiros.value;
   if (activeCard.value === "ultimos_acessos" || activeCard.value === "associados_com_acesso_app") return columnsAccessLogs.value;
+  if (activeCard.value === "dependentes_pendentes") return columnsDependentes.value;
   return columnsAssociados.value;
 });
 
@@ -148,6 +167,8 @@ const activeApiCall = computed(() => {
       return (p) => getUsersPaginated({ ...p, type: "associado", status: "active" });
     case "associados_pendentes":
       return (p) => getUsersPaginated({ ...p, type: "associado", status: "pending" });
+    case "dependentes_pendentes":
+      return (p) => getDependentsPaginated({ ...p, status: "pending" });
     case "parceiros":
       return (p) => getPartnerAgreementsPaginated({ ...p });
     case "contratos_a_vencer":
@@ -165,7 +186,8 @@ const activeApiCall = computed(() => {
 
 const formatDateTime = (isoString) => {
   if (!isoString) return "—";
-  const d = new Date(isoString);
+  const d = new Date(isoString.includes("T") ? isoString : isoString.replace(" ", "T"));
+  if (Number.isNaN(d.getTime())) return isoString;
   return d.toLocaleString("pt-BR", {
     day: "2-digit", month: "2-digit", year: "numeric",
     hour: "2-digit", minute: "2-digit", second: "2-digit",
@@ -176,6 +198,42 @@ const onCardClick = (card) => {
   activeCard.value = activeCard.value === card.key ? null : card.key;
 };
 
+const onRowClick = ({ row }) => {
+  if (activeCard.value === "dependentes_pendentes") {
+    onDependentRowClick(row);
+  } else if (activeCard.value === "associados_pendentes") {
+    onAssociadoRowClick(row);
+  }
+};
+
+const onDependentRowClick = (row) => {
+  if (!permission_store.getAccess("associado.dependente_aprovacao", "edit")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.edit") });
+    return;
+  }
+  $q.dialog({
+    component: DependentApprovalDialog,
+    componentProps: { dependent: row },
+  }).onOk(async () => {
+    tableRef.value?.refresh();
+    stats.value = await getDashboardStats();
+  });
+};
+
+const onAssociadoRowClick = (row) => {
+  if (!permission_store.getAccess("associado", "edit")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.edit") });
+    return;
+  }
+  $q.dialog({
+    component: AssociadoApprovalDialog,
+    componentProps: { associado: row },
+  }).onOk(async () => {
+    tableRef.value?.refresh();
+    stats.value = await getDashboardStats();
+  });
+};
+
 onMounted(async () => {
   try {
     stats.value = await getDashboardStats();

+ 132 - 0
src/pages/dashboard/components/AssociadoApprovalDialog.vue

@@ -0,0 +1,132 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 560px; max-width: 95vw">
+      <DefaultDialogHeader :title="() => $t('associado.pending_approval_dialog_title')" @close="onDialogCancel" />
+      <q-card-section class="q-gutter-y-sm q-pt-none">
+        <div class="row q-col-gutter-sm">
+          <div class="col-8">
+            <div class="text-caption text-grey-7">{{ $t("common.terms.name") }}</div>
+            <div class="text-body1">{{ associado.name ?? "—" }}</div>
+          </div>
+          <div class="col-4">
+            <div class="text-caption text-grey-7">{{ $t("common.terms.status") }}</div>
+            <q-badge
+              :color="getStatusColor(associado.status)"
+              :label="$t(getStatusI18nKey(associado.status))"
+              class="text-capitalize q-mt-xs"
+              outline
+            />
+          </div>
+        </div>
+
+        <div class="row q-col-gutter-sm">
+          <div class="col-8">
+            <div class="text-caption text-grey-7">{{ $t("common.terms.email") }}</div>
+            <div class="text-body1">{{ associado.email ?? "—" }}</div>
+          </div>
+          <div class="col-4">
+            <div class="text-caption text-grey-7">{{ $t("common.terms.cpf") }}</div>
+            <div class="text-body1">{{ associado.cpf ?? "—" }}</div>
+          </div>
+        </div>
+
+        <q-separator class="q-my-sm" />
+
+        <div class="row q-col-gutter-sm">
+          <div class="col-6">
+            <div class="text-caption text-grey-7">{{ $t("associado.registration") }}</div>
+            <div class="text-body1">{{ associado.registration ?? "—" }}</div>
+          </div>
+          <div class="col-6">
+            <div class="text-caption text-grey-7">{{ $t("associado.position") }}</div>
+            <div class="text-body1">{{ associado.position?.name ?? "—" }}</div>
+          </div>
+        </div>
+
+        <div class="row q-col-gutter-sm">
+          <div class="col-6">
+            <div class="text-caption text-grey-7">{{ $t("associado.sector") }}</div>
+            <div class="text-body1">{{ associado.sector?.name ?? "—" }}</div>
+          </div>
+          <div class="col-6">
+            <div class="text-caption text-grey-7">{{ $t("associado.admission_date") }}</div>
+            <div class="text-body1">{{ associado.admission_date ?? "—" }}</div>
+          </div>
+        </div>
+
+        <div class="row q-col-gutter-sm">
+          <div class="col-6">
+            <div class="text-caption text-grey-7">{{ $t("associado.validity") }}</div>
+            <div class="text-body1">{{ associado.expiry_date ?? "—" }}</div>
+          </div>
+        </div>
+      </q-card-section>
+
+      <q-card-actions>
+        <q-space />
+        <q-btn
+          outline
+          color="negative"
+          :label="$t('associado.pending_refuse')"
+          :loading="loading === 'refuse'"
+          :disable="!!loading"
+          @click="onRefuse"
+        />
+        <q-btn
+          unelevated
+          color="violet-normal"
+          :label="$t('associado.pending_approve')"
+          :loading="loading === 'approve'"
+          :disable="!!loading"
+          @click="onApprove"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { approveUser, refuseUser } from "src/api/user";
+import { getStatusColor, getStatusI18nKey } from "src/helpers/utils";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { associado } = defineProps({
+  associado: { type: Object, required: true },
+});
+
+const { t } = useI18n();
+const $q = useQuasar();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+
+const loading = ref(null);
+
+const onApprove = async () => {
+  loading.value = "approve";
+  try {
+    await approveUser(associado.id);
+    onDialogOK();
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loading.value = null;
+  }
+};
+
+const onRefuse = async () => {
+  loading.value = "refuse";
+  try {
+    await refuseUser(associado.id);
+    onDialogOK();
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loading.value = null;
+  }
+};
+</script>

+ 125 - 0
src/pages/dashboard/components/DependentApprovalDialog.vue

@@ -0,0 +1,125 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 480px">
+      <DefaultDialogHeader :title="() => $t('associado.dependent_approval_dialog_title')" @close="onDialogCancel" />
+      <q-card-section class="q-gutter-y-sm q-pt-none">
+        <div class="row">
+          <div class="col-12">
+            <div class="text-caption text-grey-7">{{ $t("common.terms.name") }} ({{ $t("associado.associado") }})</div>
+            <div class="text-body1">{{ dependent.responsible_user?.name ?? "—" }}</div>
+          </div>
+        </div>
+
+        <div class="row q-col-gutter-sm">
+          <div class="col-6">
+            <div class="text-caption text-grey-7">{{ $t("associado.position") }}</div>
+            <div class="text-body1">{{ dependent.responsible_user?.position ?? "—" }}</div>
+          </div>
+          <div class="col-6">
+            <div class="text-caption text-grey-7">{{ $t("associado.sector") }}</div>
+            <div class="text-body1">{{ dependent.responsible_user?.sector ?? "—" }}</div>
+          </div>
+        </div>
+
+        <q-separator class="q-my-sm" />
+
+        <div class="row">
+          <div class="col-12">
+            <div class="text-caption text-grey-7">{{ $t("associado.dependent") }}</div>
+            <div class="text-body1">{{ dependent.name }}</div>
+          </div>
+        </div>
+
+        <div class="row q-col-gutter-sm">
+          <div class="col-6">
+            <div class="text-caption text-grey-7">{{ $t("associado.kinship") }}</div>
+            <div class="text-body1">{{ kinshipLabel }}</div>
+          </div>
+          <div class="col-6">
+            <div class="text-caption text-grey-7">{{ $t("associado.dependent_registered_at") }}</div>
+            <div class="text-body1">{{ formattedCreatedAt }}</div>
+          </div>
+        </div>
+      </q-card-section>
+
+      <q-card-actions>
+        <q-space />
+        <q-btn
+          outline
+          color="negative"
+          :label="$t('associado.dependent_refuse_confirm')"
+          :loading="loading === 'refuse'"
+          :disable="!!loading"
+          @click="onRefuse"
+        />
+        <q-btn
+          unelevated
+          color="violet-normal"
+          :label="$t('associado.dependent_approve_confirm')"
+          :loading="loading === 'approve'"
+          :disable="!!loading"
+          @click="onApprove"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { approveDependent, refuseDependent } from "src/api/profile";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dependent } = defineProps({
+  dependent: { type: Object, required: true },
+});
+
+const { t } = useI18n();
+const $q = useQuasar();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+
+const loading = ref(null);
+
+const kinshipLabel = computed(() =>
+  dependent.kinship ? t(`associado.kinship_options.${dependent.kinship}`) : "—",
+);
+
+const formattedCreatedAt = computed(() => {
+  if (!dependent.created_at) return "—";
+  const d = new Date(dependent.created_at.replace(" ", "T"));
+  if (Number.isNaN(d.getTime())) return dependent.created_at;
+  return d.toLocaleString("pt-BR", {
+    day: "2-digit", month: "2-digit", year: "numeric",
+    hour: "2-digit", minute: "2-digit",
+  });
+});
+
+const onApprove = async () => {
+  loading.value = "approve";
+  try {
+    await approveDependent(dependent.id);
+    onDialogOK();
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loading.value = null;
+  }
+};
+
+const onRefuse = async () => {
+  loading.value = "refuse";
+  try {
+    await refuseDependent(dependent.id);
+    onDialogOK();
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loading.value = null;
+  }
+};
+</script>

+ 78 - 7
src/pages/gestao-associados/GestaoAssociadosPage.vue

@@ -7,6 +7,13 @@
       class="hidden"
       @change="onFileSelected"
     />
+    <input
+      ref="importAfastamentosFileInput"
+      type="file"
+      accept=".xlsx"
+      class="hidden"
+      @change="onAfastamentosFileSelected"
+    />
 
     <DefaultHeaderPage>
       <template #after>
@@ -19,6 +26,8 @@
             :label="$t('associado.import_afastamentos')"
             icon="mdi-upload"
             padding="6px 10px"
+            :loading="importingAfastamentos"
+            @click="onImportAfastamentosClick"
           />
           <q-btn
             unelevated
@@ -38,8 +47,18 @@
             icon="mdi-clock-outline"
             padding="6px 10px"
             :title="$t('import_log.history_title')"
-            @click="onOpenImportHistory"
-          />
+          >
+            <q-menu>
+              <q-list dense>
+                <q-item v-close-popup clickable @click="onOpenImportHistory('associado')">
+                  <q-item-section>{{ $t('associado.import_associados') }}</q-item-section>
+                </q-item>
+                <q-item v-close-popup clickable @click="onOpenImportHistory('afastamento')">
+                  <q-item-section>{{ $t('associado.import_afastamentos') }}</q-item-section>
+                </q-item>
+              </q-list>
+            </q-menu>
+          </q-btn>
           <q-btn
             unelevated
             dense
@@ -95,8 +114,7 @@
             :disable="row.status === 'on_leave' || row.status?.value === 'on_leave'"
             @click.stop="onSetOnLeave(row)"
           />
-          <q-btn flat round dense color="violet-normal" icon="mdi-calendar" @click.stop />
-          <q-btn flat round dense color="violet-normal" icon="mdi-sofa" @click.stop />
+          <q-btn flat round dense color="violet-normal" icon="mdi-calendar" @click.stop="onOpenAppointments(row)" />
           <q-btn
             v-if="row.status === 'pending' || row.status?.value === 'pending'"
             flat 
@@ -144,7 +162,7 @@ import { ref, computed, defineAsyncComponent, useTemplateRef } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { permissionStore } from "src/stores/permission";
-import { getAssociadosPaginated, importAssociados, setUserOnLeave, approveUser } from "src/api/user";
+import { getAssociadosPaginated, importAssociados, importAfastamentos, setUserOnLeave, approveUser } from "src/api/user";
 import { getStatusColor, getStatusI18nKey } from "src/helpers/utils";
 import { useImportPoller } from "src/composables/useImportPoller";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
@@ -159,6 +177,9 @@ const AddEditAssociadoDialog = defineAsyncComponent(
 const ImportHistoryDialog = defineAsyncComponent(
   () => import("src/components/ImportHistoryDialog.vue"),
 );
+const AssociadoAppointmentsDialog = defineAsyncComponent(
+  () => import("./components/AssociadoAppointmentsDialog.vue"),
+);
 
 const permission_store = permissionStore();
 const $q = useQuasar();
@@ -167,6 +188,9 @@ const tableRef = ref(null);
 const importFileInput = useTemplateRef("importFileInput");
 const { polling: importing, start: startPolling } = useImportPoller();
 
+const importAfastamentosFileInput = useTemplateRef("importAfastamentosFileInput");
+const { polling: importingAfastamentos, start: startAfastamentosPolling } = useImportPoller();
+
 const selectedPosition = ref(null);
 const selectedSector = ref(null);
 const extraFilters = computed(() => ({
@@ -309,8 +333,19 @@ const onApprove = (row) => {
   });
 };
 
-const onOpenImportHistory = () => {
-  $q.dialog({ component: ImportHistoryDialog, componentProps: { type: 'associado' } });
+const onOpenAppointments = (row) => {
+  if (!permission_store.getAccess("agendamento", "view")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.view") });
+    return;
+  }
+  $q.dialog({
+    component: AssociadoAppointmentsDialog,
+    componentProps: { associado: row },
+  });
+};
+
+const onOpenImportHistory = (type) => {
+  $q.dialog({ component: ImportHistoryDialog, componentProps: { type } });
 };
 
 const onImportClick = () => {
@@ -349,6 +384,42 @@ const onFileSelected = async (event) => {
     $q.notify({ type: "negative", message: t("http.errors.failed") });
   }
 };
+
+const onImportAfastamentosClick = () => {
+  if (!permission_store.getAccess("associado", "add")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.add") });
+    return;
+  }
+  importAfastamentosFileInput.value.value = null;
+  importAfastamentosFileInput.value.click();
+};
+
+const onAfastamentosFileSelected = async (event) => {
+  const file = event.target.files?.[0];
+  if (!file) return;
+
+  try {
+    const { import_id } = await importAfastamentos(file);
+
+    startAfastamentosPolling(import_id, {
+      onComplete: (stats) => {
+        $q.notify({
+          type: "positive",
+          message: t("associado.import_afastamentos_result", {
+            created:  stats.created,
+            on_leave: stats.on_leave,
+          }),
+          timeout: 6000,
+        });
+        tableRef.value?.refresh();
+      },
+      onError: () => $q.notify({ type: "negative", message: t("http.errors.failed") }),
+      onTimeout: () => $q.notify({ type: "warning", message: t("associado.import_processing") }),
+    });
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  }
+};
 </script>
 
 <style scoped>

+ 1 - 0
src/pages/gestao-associados/components/AddEditAssociadoDialog.vue

@@ -154,6 +154,7 @@ const statusOptions = computed(() => [
   { label: t("common.status.inactive"), value: "inactive" },
   { label: t("common.status.pending"),  value: "pending" },
   { label: t("common.status.on_leave"), value: "on_leave" },
+  { label: t("common.status.refused"),  value: "refused" },
 ]);
 
 const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({

+ 232 - 0
src/pages/gestao-associados/components/AssociadoAppointmentsDialog.vue

@@ -0,0 +1,232 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin" style="width: 1200px; max-width: 95vw">
+      <DefaultDialogHeader :title="() => $t('associado.appointments_dialog_title', { name: associado.name })" @close="onDialogCancel" />
+
+      <q-card-section class="q-pt-none">
+        <div v-if="loading" class="flex flex-center q-py-xl">
+          <q-spinner color="violet-normal" size="48px" />
+        </div>
+
+        <div v-else-if="appointments.length === 0" class="text-center text-grey q-py-xl">
+          {{ $t("http.errors.no_records_found") }}
+        </div>
+
+        <q-table
+          v-else
+          class="softpar-table"
+          :rows="appointments"
+          :columns="columns"
+          row-key="id"
+          hide-pagination
+          :rows-per-page-options="[0]"
+        >
+          <template #body-cell-servico="props">
+            <q-td :props="props">
+              {{ excerpt(props.row.partner_agreement_service?.name ?? "—", 20) }}
+              <q-tooltip v-if="props.row.partner_agreement_service?.name">
+                {{ props.row.partner_agreement_service.name }}
+              </q-tooltip>
+            </q-td>
+          </template>
+
+          <template #body-cell-parceiro="props">
+            <q-td :props="props">
+              {{ excerpt(props.row.partner_agreement?.trade_name ?? props.row.partner_agreement?.company_name ?? "—", 20) }}
+              <q-tooltip v-if="props.row.partner_agreement?.trade_name ?? props.row.partner_agreement?.company_name">
+                {{ props.row.partner_agreement?.trade_name ?? props.row.partner_agreement?.company_name }}
+              </q-tooltip>
+            </q-td>
+          </template>
+
+          <template #body-cell-data_hora="props">
+            <q-td :props="props">
+              {{ formatDateTime(props.row) }}
+            </q-td>
+          </template>
+
+          <template #body-cell-observacoes="props">
+            <q-td :props="props">
+              {{ excerpt(props.row.observations ?? "—", 20) }}
+              <q-tooltip v-if="props.row.observations">
+                {{ props.row.observations }}
+              </q-tooltip>
+            </q-td>
+          </template>
+
+          <template #body-cell-status="props">
+            <q-td :props="props">
+              <q-chip
+                outline
+                :color="statusColor(props.row.status)"
+                :label="$t(`agendamento.status.${props.row.status}`)"
+                size="sm"
+              />
+            </q-td>
+          </template>
+
+          <template #body-cell-acoes="props">
+            <q-td :props="props">
+              <div class="row no-wrap items-center" style="gap: 4px">
+                <q-btn
+                  dense
+                  round
+                  outline
+                  icon="mdi-check"
+                  color="positive"
+                  size="sm"
+                  :disable="!canApprove(props.row)"
+                  :loading="actionId === props.row.id && actionType === 'approve'"
+                  @click="onApprove(props.row)"
+                />
+                <q-btn
+                  dense
+                  round
+                  outline
+                  icon="mdi-close"
+                  color="negative"
+                  size="sm"
+                  :disable="!canReject(props.row)"
+                  :loading="actionId === props.row.id && actionType === 'reject'"
+                  @click="onReject(props.row)"
+                />
+              </div>
+            </q-td>
+          </template>
+        </q-table>
+      </q-card-section>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import {
+  getAppointmentsByUser,
+  approveAppointment,
+  rejectAppointment,
+} from "src/api/appointment";
+import { permissionStore } from "src/stores/permission";
+import { excerpt } from "src/helpers/utils";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import ApproveAppointmentDialog from "src/components/ApproveAppointmentDialog.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { associado } = defineProps({
+  associado: { type: Object, required: true },
+});
+
+const { t } = useI18n();
+const $q = useQuasar();
+const permission_store = permissionStore();
+const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
+
+const loading = ref(true);
+const appointments = ref([]);
+const actionId = ref(null);
+const actionType = ref(null);
+
+const columns = computed(() => [
+  { name: "pedido",       label: t("agendamento.col.pedido"),      field: "order_number", align: "left" },
+  { name: "parceiro",     label: t("agendamento.col.parceiro"),    field: (row) => row.partner_agreement?.trade_name ?? row.partner_agreement?.company_name ?? "—", align: "left" },
+  { name: "servico",      label: t("agendamento.col.servico"),     field: (row) => row.partner_agreement_service?.name ?? "—", align: "left" },
+  { name: "data_hora",    label: t("agendamento.col.data_hora"),   field: (row) => formatDateTime(row), align: "left" },
+  { name: "observacoes",  label: t("agendamento.col.observacoes"), field: (row) => row.observations ?? "—", align: "left" },
+  { name: "acoes",        label: t("common.terms.actions"),        field: "id", align: "center" },
+  { name: "status",       label: t("common.terms.status"),         field: "status", align: "center" },
+]);
+
+const statusColor = (status) => {
+  const map = { pendente: "warning-light", confirmado: "positive", cancelado: "negative", recusado: "negative", concluido: "grey" };
+  return map[status] ?? "grey";
+};
+
+const formatDateTime = (row) => {
+  if (!row.date) return "—";
+  const d = new Date(`${row.date}T00:00:00`);
+  if (Number.isNaN(d.getTime())) return "—";
+  const dateStr = d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
+  return row.time ? `${dateStr} ${row.time}` : dateStr;
+};
+
+const isFutureDateTime = (row) => {
+  if (!row.date) return false;
+  const dt = new Date(`${row.date}T${row.time || "00:00"}`);
+  if (Number.isNaN(dt.getTime())) return false;
+  return dt.getTime() > Date.now();
+};
+
+const canApprove = (row) => row.status === "pendente";
+
+const canReject = (row) =>
+  row.status === "pendente" || (row.status === "confirmado" && isFutureDateTime(row));
+
+const onApprove = (row) => {
+  if (!canApprove(row)) return;
+  if (!permission_store.getAccess("agendamento", "edit")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.edit") });
+    return;
+  }
+  $q.dialog({ component: ApproveAppointmentDialog }).onOk(async ({ date, time }) => {
+    actionId.value   = row.id;
+    actionType.value = "approve";
+    try {
+      await approveAppointment(row.id, { date, time });
+      row.status = "confirmado";
+      row.date   = date;
+      row.time   = time;
+      $q.notify({ type: "positive", message: t("http.success") });
+    } catch {
+      $q.notify({ type: "negative", message: t("http.errors.failed") });
+    } finally {
+      actionId.value   = null;
+      actionType.value = null;
+    }
+  });
+};
+
+const onReject = (row) => {
+  if (!canReject(row)) return;
+  if (!permission_store.getAccess("agendamento", "edit")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.edit") });
+    return;
+  }
+  $q.dialog({
+    title: t("common.ui.messages.confirm_action"),
+    message: t("agendamento.confirm_reject"),
+    cancel: true,
+    persistent: true,
+  }).onOk(async () => {
+    actionId.value   = row.id;
+    actionType.value = "reject";
+    try {
+      await rejectAppointment(row.id);
+      row.status = "recusado";
+      $q.notify({ type: "positive", message: t("http.success") });
+    } catch {
+      $q.notify({ type: "negative", message: t("http.errors.failed") });
+    } finally {
+      actionId.value   = null;
+      actionType.value = null;
+    }
+  });
+};
+
+onMounted(async () => {
+  try {
+    appointments.value = await getAppointmentsByUser(associado.id);
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style lang="scss">
+@import "src/css/table.scss";
+</style>

+ 419 - 0
src/pages/login/FirstAccessFormPage.vue

@@ -0,0 +1,419 @@
+<template>
+  <q-page class="login-page column items-center justify-center">
+    <div class="login-overlay" />
+
+    <div class="login-card column items-center">
+      <div class="column items-center fields-card">
+        <q-img :src="Logo" class="login-logo q-mb-md" />
+
+        <p class="login-title">{{ $t("auth.first_access.form_title") }}</p>
+        <p class="login-description">{{ $t("auth.first_access.form_hint") }}</p>
+
+        <div class="flex flex-center q-mb-md">
+          <div class="avatar-wrap">
+            <q-avatar size="96px" class="first-access-avatar">
+              <img v-if="photoPreview" :src="photoPreview" class="avatar-img" />
+              <q-icon v-else name="mdi-account" size="52px" color="white" />
+            </q-avatar>
+            <q-btn
+              round
+              unelevated
+              size="sm"
+              icon="mdi-camera-outline"
+              class="avatar-edit-btn"
+              color="violet-normal"
+              text-color="white"
+              @click="photoInputRef.click()"
+            />
+            <input
+              ref="photoInputRef"
+              type="file"
+              accept="image/*"
+              style="display: none"
+              @change="onPhotoSelected"
+            />
+          </div>
+        </div>
+        <div
+          class="text-center text-caption q-mb-md"
+          :class="photoError ? 'text-negative' : 'text-grey-7'"
+        >
+          {{ photoError || $t("auth.first_access.photo_hint") }}
+        </div>
+
+        <q-form
+          ref="formRef"
+          class="full-width"
+          autocomplete="off"
+          @submit="onSubmit"
+        >
+          <div class="row q-col-gutter-sm">
+            <DefaultInput
+              v-model="form.registration"
+              v-model:error="validationErrors.registration"
+              class="col-12 col-sm-6"
+              readonly
+              bg-color="grey-2"
+              :label="$t('auth.first_access.registration')"
+            />
+
+            <DefaultInput
+              v-model="form.name"
+              v-model:error="validationErrors.name"
+              class="col-12 col-sm-6"
+              :readonly="isLocked('name')"
+              :bg-color="isLocked('name') ? 'grey-2' : undefined"
+              :label="$t('common.terms.name')"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.cpf"
+              v-model:error="validationErrors.cpf"
+              class="col-12 col-sm-6"
+              :readonly="isLocked('cpf')"
+              :bg-color="isLocked('cpf') ? 'grey-2' : undefined"
+              :mask="masks.Brasil.cpf"
+              :label="$t('common.terms.cpf')"
+              :rules="[inputRules.required, inputRules.cpf]"
+            />
+
+            <DefaultInput
+              v-model="form.email"
+              v-model:error="validationErrors.email"
+              class="col-12 col-sm-6"
+              type="email"
+              :readonly="isLocked('email')"
+              :bg-color="isLocked('email') ? 'grey-2' : undefined"
+              :label="$t('common.terms.email')"
+              :rules="[inputRules.required, inputRules.email]"
+            />
+
+            <DefaultInput
+              v-model="form.phone"
+              v-model:error="validationErrors.phone"
+              class="col-12 col-sm-6"
+              :readonly="isLocked('phone')"
+              :bg-color="isLocked('phone') ? 'grey-2' : undefined"
+              mask="(##) #####-####"
+              :label="$t('auth.first_access.whatsapp')"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultSelect
+              v-model="form.position_id"
+              v-model:error="validationErrors.position_id"
+              class="col-12 col-sm-6"
+              :options="positionOptions"
+              option-value="id"
+              option-label="name"
+              emit-value
+              map-options
+              :readonly="isLocked('position_id')"
+              :bg-color="isLocked('position_id') ? 'grey-2' : undefined"
+              :label="$t('associado.position')"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultSelect
+              v-model="form.sector_id"
+              v-model:error="validationErrors.sector_id"
+              class="col-12 col-sm-6"
+              :options="sectorOptions"
+              option-value="id"
+              option-label="name"
+              emit-value
+              map-options
+              :readonly="isLocked('sector_id')"
+              :bg-color="isLocked('sector_id') ? 'grey-2' : undefined"
+              :label="$t('associado.sector')"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultPasswordInput
+              v-model="form.password"
+              v-model:error="validationErrors.password"
+              class="col-12 col-sm-6"
+              :label="$t('common.terms.password')"
+              :rules="[inputRules.required, inputRules.password]"
+            />
+
+            <DefaultPasswordInput
+              v-model="form.password_confirmation"
+              v-model:error="validationErrors.password_confirmation"
+              class="col-12 col-sm-6"
+              :label="$t('auth.confirm_password')"
+              :rules="[inputRules.required, inputRules.samePassword(form.password)]"
+            />
+          </div>
+
+          <p class="password-hint q-mt-sm">{{ $t("auth.password_hint") }}</p>
+
+          <q-btn
+            class="full-width q-mt-md login-btn"
+            color="violet-normal"
+            :label="$t('auth.first_access.finish')"
+            type="submit"
+            unelevated
+            :loading
+          >
+            <template #loading>
+              <q-spinner />
+            </template>
+          </q-btn>
+        </q-form>
+
+        <a href="#" class="login-link q-mt-md" @click.prevent="goBack">
+          <q-icon name="mdi-arrow-left" size="12px" class="q-mr-xs" />
+          {{ $t("auth.first_access.back_to_login") }}
+        </a>
+      </div>
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { ref, onBeforeMount, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useRouter } from "vue-router";
+import { useAuth } from "src/composables/useAuth";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import {
+  registerFirstAccess,
+  getPublicPositions,
+  getPublicSectors,
+} from "src/api/firstAccess";
+import { getFirstAccessData, clearFirstAccessData } from "./firstAccessStorage";
+import masks from "src/helpers/masks";
+
+import Logo from "src/assets/logo_serprati.svg";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+
+const router = useRouter();
+const $q = useQuasar();
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+const { login } = useAuth();
+
+const formRef = useTemplateRef("formRef");
+const photoInputRef = useTemplateRef("photoInputRef");
+
+const token = ref(null);
+const lockedFields = ref([]);
+const photoFile = ref(null);
+const photoPreview = ref(null);
+const photoError = ref(null);
+const positionOptions = ref([]);
+const sectorOptions = ref([]);
+
+const form = ref({
+  registration: null,
+  name: null,
+  cpf: null,
+  email: null,
+  phone: null,
+  position_id: null,
+  sector_id: null,
+  password: null,
+  password_confirmation: null,
+});
+
+const isLocked = (field) => lockedFields.value.includes(field);
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({ formRef });
+
+const onPhotoSelected = (event) => {
+  const file = event.target.files?.[0];
+  event.target.value = "";
+  if (!file) return;
+
+  photoFile.value = file;
+  photoPreview.value = URL.createObjectURL(file);
+  photoError.value = null;
+};
+
+const onSubmit = async () => {
+  if (!photoFile.value) {
+    photoError.value = t("auth.first_access.photo_required");
+    return;
+  }
+
+  const password = form.value.password;
+
+  const user = await submitForm(() =>
+    registerFirstAccess({
+      ...form.value,
+      token: token.value,
+      photo: photoFile.value,
+    }),
+  );
+
+  if (!user) return;
+
+  clearFirstAccessData();
+
+  try {
+    await login(form.value.registration, password, "associado");
+    $q.notify({ type: "positive", message: t("auth.first_access.success") });
+    router.push({ name: "CarteirinhaPage" });
+  } catch {
+    $q.notify({ type: "positive", message: t("auth.first_access.success_login") });
+    router.push({ name: "LoginPage" });
+  }
+};
+
+const goBack = () => {
+  clearFirstAccessData();
+  router.push({ name: "LoginPage" });
+};
+
+onBeforeMount(async () => {
+  const data = getFirstAccessData();
+
+  if (!data?.token) {
+    router.replace({ name: "FirstAccessPage" });
+    return;
+  }
+
+  token.value = data.token;
+  form.value.registration = data.registration;
+
+  if (data.user) {
+    const { photo_url, ...fields } = data.user;
+
+    Object.entries(fields).forEach(([key, value]) => {
+      if (value === null || value === "") return;
+      form.value[key] = value;
+      lockedFields.value.push(key);
+    });
+
+    if (photo_url) {
+      photoPreview.value = photo_url;
+    }
+  }
+
+  try {
+    const [positions, sectors] = await Promise.all([
+      getPublicPositions(),
+      getPublicSectors(),
+    ]);
+    positionOptions.value = positions ?? [];
+    sectorOptions.value = sectors ?? [];
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+@use "sass:map";
+@use "src/css/quasar.variables.scss" as *;
+
+.login-page {
+  min-height: 100dvh;
+  background-image: url("src/assets/pessoas_fundo.jpg");
+  background-size: cover;
+  background-position: center;
+  position: relative;
+}
+
+.login-overlay {
+  position: absolute;
+  inset: 0;
+  background: rgba(74, 20, 140, 0.48);
+  z-index: 0;
+}
+
+.login-card {
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: calc(100% - 32px);
+  min-height: calc(100dvh - 32px);
+  margin: 16px 0;
+  background: rgba(255, 255, 255, 0.747);
+  border-radius: 16px;
+  box-shadow: 0 4px 32px rgba(0, 0, 0, 0.15);
+}
+
+.fields-card {
+  width: 100%;
+  max-width: 600px;
+  padding: 32px;
+  border-radius: 12px;
+}
+
+.login-logo {
+  width: 300px;
+}
+
+.login-title {
+  color: map.get($colors, "violet-normal");
+  font-size: 18px;
+  font-weight: 700;
+  margin: 0 0 8px;
+  text-align: center;
+}
+
+.login-description {
+  color: map.get($colors, "text-2");
+  font-size: 13px;
+  text-align: center;
+  margin: 0 0 16px;
+}
+
+.password-hint {
+  color: map.get($colors, "text-2");
+  font-size: 12px;
+  margin: 0;
+}
+
+.avatar-wrap {
+  position: relative;
+  display: inline-block;
+}
+
+.first-access-avatar {
+  background: rgba(112, 32, 130, 0.18) !important;
+}
+
+.avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.avatar-edit-btn {
+  position: absolute;
+  bottom: -2px;
+  right: -2px;
+}
+
+.login-btn {
+  height: 44px;
+  font-size: 15px;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+}
+
+.login-link {
+  color: map.get($colors, "violet-normal");
+  font-size: 13px;
+  text-decoration: none;
+  cursor: pointer;
+
+  &:hover {
+    color: map.get($colors, "violet-normal-hover");
+    text-decoration: underline;
+  }
+}
+</style>

+ 178 - 0
src/pages/login/FirstAccessPage.vue

@@ -0,0 +1,178 @@
+<template>
+  <q-page class="login-page column items-center justify-center">
+    <div class="login-overlay" />
+
+    <div class="login-card column items-center">
+      <div class="column items-center fields-card">
+        <q-img :src="Logo" class="login-logo q-mb-lg" />
+
+        <p class="login-title">{{ $t("auth.first_access.title") }}</p>
+        <p class="login-description">{{ $t("auth.first_access.registration_hint") }}</p>
+
+        <UserTypeBadge tipo="associado" />
+
+        <q-form
+          ref="formRef"
+          class="full-width"
+          autocomplete="off"
+          @submit="onSubmit"
+        >
+          <DefaultInput
+            v-model="form.registration"
+            v-model:error="validationErrors.registration"
+            autofocus
+            :label="$t('auth.first_access.registration')"
+            :rules="[inputRules.required]"
+          >
+            <template #append>
+              <q-icon name="mdi-badge-account-outline" color="grey-5" />
+            </template>
+          </DefaultInput>
+
+          <q-btn
+            class="full-width q-mt-sm login-btn"
+            color="violet-normal"
+            :label="$t('auth.continue')"
+            type="submit"
+            unelevated
+            :loading
+          >
+            <template #loading>
+              <q-spinner />
+            </template>
+          </q-btn>
+        </q-form>
+
+        <a href="#" class="login-link q-mt-md" @click.prevent="goBack">
+          <q-icon name="mdi-arrow-left" size="12px" class="q-mr-xs" />
+          {{ $t("auth.first_access.back_to_login") }}
+        </a>
+      </div>
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { ref, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useRouter } from "vue-router";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { checkFirstAccess } from "src/api/firstAccess";
+import { setFirstAccessData } from "./firstAccessStorage";
+
+import Logo from "src/assets/logo_serprati.svg";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import UserTypeBadge from "./components/UserTypeBadge.vue";
+
+const router = useRouter();
+const $q = useQuasar();
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+
+const formRef = useTemplateRef("formRef");
+const form = ref({ registration: null });
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({ formRef });
+
+const onSubmit = async () => {
+  const payload = await submitForm(() => checkFirstAccess(form.value.registration));
+  if (!payload) return;
+
+  if (payload.status === "already_done") {
+    $q.notify({
+      type: "warning",
+      message: payload.message ?? t("auth.first_access.already_done"),
+      timeout: 6000,
+    });
+    return;
+  }
+
+  setFirstAccessData(payload);
+  router.push({ name: "FirstAccessFormPage" });
+};
+
+const goBack = () => router.push({ name: "LoginPage" });
+</script>
+
+<style lang="scss" scoped>
+@use "sass:map";
+@use "src/css/quasar.variables.scss" as *;
+
+.login-page {
+  min-height: 100dvh;
+  background-image: url("src/assets/pessoas_fundo.jpg");
+  background-size: cover;
+  background-position: center;
+  position: relative;
+}
+
+.login-overlay {
+  position: absolute;
+  inset: 0;
+  background: rgba(74, 20, 140, 0.48);
+  z-index: 0;
+}
+
+.login-card {
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: calc(100% - 32px);
+  min-height: calc(100dvh - 32px);
+  background: rgba(255, 255, 255, 0.747);
+  border-radius: 16px;
+  box-shadow: 0 4px 32px rgba(0, 0, 0, 0.15);
+}
+
+.fields-card {
+  width: 100%;
+  max-width: 600px;
+  padding: 32px;
+  border-radius: 12px;
+}
+
+.login-logo {
+  width: 380px;
+}
+
+.login-title {
+  color: map.get($colors, "violet-normal");
+  font-size: 18px;
+  font-weight: 700;
+  margin: 0 0 8px;
+  text-align: center;
+}
+
+.login-description {
+  color: map.get($colors, "text-2");
+  font-size: 13px;
+  text-align: center;
+  margin: 0 0 20px;
+}
+
+.login-btn {
+  height: 44px;
+  font-size: 15px;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+}
+
+.login-link {
+  color: map.get($colors, "violet-normal");
+  font-size: 13px;
+  text-decoration: none;
+  cursor: pointer;
+
+  &:hover {
+    color: map.get($colors, "violet-normal-hover");
+    text-decoration: underline;
+  }
+}
+</style>

+ 8 - 0
src/pages/login/LoginPage.vue

@@ -64,6 +64,14 @@
               <q-spinner />
             </template>
           </q-btn>
+          <q-btn
+            v-if="isAssociado"
+            class="full-width q-mt-sm login-btn"
+            color="violet-normal"
+            :label="$t('auth.first_access.button')"
+            outline
+            @click="router.push({ name: 'FirstAccessPage' })"
+          />
         </q-form>
 
         <div class="column items-center q-mt-md gap-xs">

+ 19 - 0
src/pages/login/firstAccessStorage.js

@@ -0,0 +1,19 @@
+const STORAGE_KEY = "first_access_data";
+
+export const setFirstAccessData = (payload) => {
+  sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
+};
+
+export const getFirstAccessData = () => {
+  const raw = sessionStorage.getItem(STORAGE_KEY);
+  if (!raw) return null;
+  try {
+    return JSON.parse(raw);
+  } catch {
+    return null;
+  }
+};
+
+export const clearFirstAccessData = () => {
+  sessionStorage.removeItem(STORAGE_KEY);
+};

+ 0 - 140
src/pages/notificacoes/components/NotificationCard.vue

@@ -1,140 +0,0 @@
-<template>
-  <q-card flat bordered class="notification-card">
-    <div class="notification-card__image">
-      <img
-        v-if="imageUrl"
-        :src="imageUrl"
-        alt=""
-        class="notification-card__img"
-      />
-      <div v-if="!imageUrl" class="notification-card__img-placeholder flex flex-center">
-        <q-icon name="mdi-bell-outline" size="40px" color="violet-normal" />
-      </div>
-    </div>
-
-    <q-card-section class="notification-card__body q-pt-sm q-pb-xs">
-      <div class="notification-card__title text-violet-normal text-weight-bold ellipsis">
-        {{ notification.title }}
-      </div>
-      <div class="notification-card__message text-caption text-grey-7 q-mt-xs">
-        {{ notification.message }}
-      </div>
-    </q-card-section>
-
-    <q-card-actions class="notification-card__actions q-pt-xs q-pb-sm q-px-md flex justify-between items-center">
-      <div class="text-caption text-grey-6">
-        {{ formatDate(notification.created_at) }}
-      </div>
-
-      <q-btn
-        unelevated
-        dense
-        color="violet-normal"
-        text-color="white"
-        :label="$t('notification.details')"
-        size="sm"
-        padding="4px 10px"
-        class="notification-card__details-btn"
-      >
-        <q-menu anchor="bottom right" self="top right" class="notification-card__menu">
-          <q-list dense style="min-width: 200px">
-            <q-item>
-              <q-item-section avatar>
-                <q-icon name="mdi-send-outline" color="violet-normal" size="18px" />
-              </q-item-section>
-              <q-item-section>
-                <q-item-label class="text-body2">
-                  {{ $t('notification.sent_to', { count: notification.sent_count ?? 0 }) }}
-                </q-item-label>
-              </q-item-section>
-            </q-item>
-            <q-item>
-              <q-item-section avatar>
-                <q-icon name="mdi-eye-outline" color="violet-normal" size="18px" />
-              </q-item-section>
-              <q-item-section>
-                <q-item-label class="text-body2">
-                  {{ $t('notification.seen_by', { count: notification.seen_count ?? 0 }) }}
-                </q-item-label>
-              </q-item-section>
-            </q-item>
-          </q-list>
-        </q-menu>
-      </q-btn>
-    </q-card-actions>
-  </q-card>
-</template>
-
-<script setup>
-import { computed } from "vue";
-import { useI18n } from "vue-i18n";
-
-const { t } = useI18n();
-
-const { notification } = defineProps({
-  notification: {
-    type: Object,
-    required: true,
-  },
-});
-
-const imageUrl = computed(() => {
-  if (!notification.image_url) return null;
-  if (notification.image_url.startsWith("http")) return notification.image_url;
-  return process.env.API_URL + notification.image_url;
-});
-
-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" });
-};
-</script>
-
-<style scoped lang="scss">
-.notification-card {
-  border-radius: 8px;
-  overflow: hidden;
-
-  &__image {
-    width: 100%;
-    height: 120px;
-    overflow: hidden;
-  }
-
-  &__img {
-    width: 100%;
-    height: 100%;
-    object-fit: cover;
-  }
-
-  &__img-placeholder {
-    width: 100%;
-    height: 100%;
-    background: #f0e8f1;
-  }
-
-  &__title {
-    font-size: 14px;
-    line-height: 1.3;
-  }
-
-  &__message {
-    display: -webkit-box;
-    -webkit-line-clamp: 2;
-    -webkit-box-orient: vertical;
-    overflow: hidden;
-    line-height: 1.4;
-  }
-
-  &__menu {
-    border-radius: 8px;
-  }
-}
-</style>

+ 25 - 7
src/pages/notificacoes/components/NotificationHistoryPanel.vue

@@ -9,14 +9,14 @@
     </div>
 
     <div v-else>
-      <div class="row q-px-md q-col-gutter-md">
-        <div
+      <div class="notifications-grid q-px-md">
+        <NotificationCard
           v-for="item in notifications"
           :key="item.id"
-          class="col-xl-4 col-lg-4 col-md-4 col-sm-6 col-12"
-        >
-          <NotificationCard :notification="item" />
-        </div>
+          :notification="item"
+          show-stats-button
+          @click="openDetail(item)"
+        />
       </div>
 
       <div class="flex flex-center q-mt-lg">
@@ -37,8 +37,12 @@
 
 <script setup>
 import { ref, computed, onMounted } from "vue";
+import { useQuasar } from "quasar";
 import { getNotificationsPaginated } from "src/api/notification";
-import NotificationCard from "./NotificationCard.vue";
+import NotificationCard from "src/components/NotificationCard.vue";
+import NotificationDetailDialog from "src/components/NotificationDetailDialog.vue";
+
+const $q = useQuasar();
 
 const PER_PAGE = 12;
 
@@ -65,6 +69,20 @@ const refresh = async () => {
   await fetchData();
 };
 
+const openDetail = (item) => {
+  $q.dialog({
+    component: NotificationDetailDialog,
+    componentProps: {
+      item: {
+        notification: item,
+        id: item.id,
+        created_at: item.created_at,
+        read: true,
+      },
+    },
+  });
+};
+
 defineExpose({ refresh });
 
 onMounted(fetchData);

+ 18 - 4
src/pages/parceiros-convenios/AgendamentosParceiroPage.vue

@@ -12,7 +12,7 @@
         <div class="counters-row q-mb-md">
           <div class="counter-card">
             <span class="counter-value">
-              <q-icon name="mdi-clock-outline" color="warning" />
+              <q-icon name="mdi-clock-outline" color="warning-light" />
               {{ counters.pendentes }}
             </span>
             <span class="counter-label">{{ $t("agendamento.status.pendente") }}</span>
@@ -77,6 +77,7 @@
                   size="sm"
                   :unelevated="props.row.status === 'confirmado'"
                   :outline="props.row.status !== 'confirmado'"
+                  :disable="!canApprove(props.row)"
                   :loading="actionId === props.row.id && actionType === 'approve'"
                   @click.prevent.stop="onApprove(props.row)"
                 />
@@ -88,6 +89,7 @@
                   size="sm"
                   :unelevated="props.row.status === 'recusado' || props.row.status === 'cancelado'"
                   :outline="props.row.status !== 'recusado' && props.row.status !== 'cancelado'"
+                  :disable="!canReject(props.row)"
                   :loading="actionId === props.row.id && actionType === 'reject'"
                   @click.prevent.stop="onReject(props.row)"
                 />
@@ -165,7 +167,7 @@ const pagedAppointments = computed(() => {
 });
 
 const statusColor = (status) => {
-  const map = { pendente: "warning", confirmado: "positive", cancelado: "negative", recusado: "negative", concluido: "grey" };
+  const map = { pendente: "warning-light", confirmado: "positive", cancelado: "negative", recusado: "negative", concluido: "grey" };
   return map[status] ?? "grey";
 };
 
@@ -184,8 +186,20 @@ const formatDateTime = (date, time) => {
   return time ? `${dateStr} ${time}` : dateStr;
 };
 
+const isFutureDateTime = (row) => {
+  if (!row.date) return false;
+  const dt = new Date(`${row.date}T${row.time || "00:00"}`);
+  if (isNaN(dt)) return false;
+  return dt.getTime() > Date.now();
+};
+
+const canApprove = (row) => row.status === "pendente";
+
+const canReject = (row) =>
+  row.status === "pendente" || (row.status === "confirmado" && isFutureDateTime(row));
+
 const onApprove = (row) => {
-  if (row.status !== "pendente") return;
+  if (!canApprove(row)) return;
   $q.dialog({ component: ApproveAppointmentDialog }).onOk(async ({ date, time }) => {
     actionId.value   = row.id;
     actionType.value = "approve";
@@ -203,7 +217,7 @@ const onApprove = (row) => {
 };
 
 const onReject = (row) => {
-  if (row.status !== "pendente") return;
+  if (!canReject(row)) return;
   $q.dialog({
     title: t("common.ui.messages.confirm_action"),
     message: t("agendamento.confirm_reject"),

+ 21 - 136
src/pages/parceiros-convenios/NotificacoesParceiroPage.vue

@@ -13,63 +13,15 @@
       </div>
 
       <div v-else>
-        <div class="row q-px-md q-col-gutter-md">
-          <div
+        <div class="notifications-grid q-px-md">
+          <NotificationCard
             v-for="item in pagedItems"
             :key="item.id"
-            class="col-xl-4 col-lg-4 col-md-4 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-card-actions>
-            </q-card>
-          </div>
+            :notification="item.notification"
+            :unread="!item.read"
+            :date="item.created_at"
+            @click="openDetail(item)"
+          />
         </div>
 
         <div v-if="totalPages > 1" class="flex flex-center q-mt-lg">
@@ -91,11 +43,13 @@
 
 <script setup>
 import { ref, computed, onMounted } from "vue";
-import { useI18n } from "vue-i18n";
-import { getMyNotificationsParceiro, markNotificationAsReadParceiro } from "src/api/notification";
+import { useQuasar } from "quasar";
+import { getMyNotificationsParceiro } from "src/api/notification";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import NotificationCard from "src/components/NotificationCard.vue";
+import NotificationDetailDialog from "src/components/NotificationDetailDialog.vue";
 
-const { t } = useI18n();
+const $q = useQuasar();
 
 const loading = ref(true);
 const notifications = ref([]);
@@ -109,34 +63,15 @@ const pagedItems = computed(() => {
   return notifications.value.slice(start, start + PER_PAGE);
 });
 
-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 {
-    await markNotificationAsReadParceiro(item.id);
-    item.read = true;
-  } catch (e) {
-    console.error(e);
-  }
+const openDetail = (item) => {
+  $q.dialog({
+    component: NotificationDetailDialog,
+    componentProps: { item },
+  }).onOk((markedId) => {
+    if (!markedId) return;
+    const found = notifications.value.find((n) => n.id === markedId);
+    if (found) found.read = true;
+  });
 };
 
 onMounted(async () => {
@@ -150,53 +85,3 @@ onMounted(async () => {
 });
 </script>
 
-<style scoped lang="scss">
-.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;
-    -webkit-box-orient: vertical;
-    overflow: hidden;
-    line-height: 1.4;
-  }
-}
-</style>

+ 17 - 8
src/pages/parceiros-convenios/components/PartnerDashboardStats.vue

@@ -8,11 +8,11 @@
     <div class="row q-col-gutter-sm">
 
       <div class="col-12 col-md-6">
-        <q-card flat class="dashboard-card">
+        <q-card flat clickable class="dashboard-card" @click="goToAgendamentos">
           <q-card-section class="dashboard-content">
 
             <q-icon
-              name="mdi-message-text-outline"
+              name="mdi-clock-outline"
               size="20px"
               color="violet-normal"
             />
@@ -30,11 +30,11 @@
       </div>
 
       <div class="col-12 col-md-6">
-        <q-card flat class="dashboard-card">
+        <q-card flat clickable class="dashboard-card" @click="goToAgendamentos">
           <q-card-section class="dashboard-content">
 
             <q-icon
-              name="mdi-clock-outline"
+              name="mdi-check-circle-outline"
               size="20px"
               color="violet-normal"
             />
@@ -52,11 +52,11 @@
       </div>
 
       <div class="col-12 col-md-6">
-        <q-card flat class="dashboard-card">
+        <q-card flat clickable class="dashboard-card" @click="goToAgendamentos">
           <q-card-section class="dashboard-content">
 
             <q-icon
-              name="mdi-account-group-outline"
+              name="mdi-list-box-outline"
               size="20px"
               color="violet-normal"
             />
@@ -74,11 +74,11 @@
       </div>
 
       <div class="col-12 col-md-6">
-        <q-card flat class="dashboard-card">
+        <q-card flat clickable class="dashboard-card" @click="goToAgendamentos">
           <q-card-section class="dashboard-content">
 
             <q-icon
-              name="mdi-alert-circle-outline"
+              name="mdi-close-circle-outline"
               size="20px"
               color="violet-normal"
             />
@@ -101,12 +101,20 @@
 </template>
 
 <script setup>
+import { useRouter } from "vue-router";
+
 defineProps({
   stats: {
     type: Object,
     required: true,
   },
 });
+
+const router = useRouter();
+
+function goToAgendamentos() {
+  router.push({ name: "AgendamentosPage" });
+}
 </script>
 
 <style scoped lang="scss">
@@ -124,6 +132,7 @@ defineProps({
 .dashboard-card {
   border-radius: 6px;
   background: white;
+  cursor: pointer;
 }
 
 .dashboard-content {

+ 9 - 1
src/router/index.js

@@ -12,7 +12,15 @@ import { i18n } from "src/boot/i18n";
 import { userStore } from "src/stores/user";
 import { useAuth } from "src/composables/useAuth";
 
-const AUTH_ROUTES = ["LoginPage", "ForgotPasswordPage", "VerifyEmailPage", "VerifyCodePage", "ResetPasswordPage"];
+const AUTH_ROUTES = [
+  "LoginPage",
+  "ForgotPasswordPage",
+  "VerifyEmailPage",
+  "VerifyCodePage",
+  "ResetPasswordPage",
+  "FirstAccessPage",
+  "FirstAccessFormPage",
+];
 
 const HOME_BY_TIPO = {
   administrador: "DashboardPage",

+ 12 - 0
src/router/routes.js

@@ -71,6 +71,18 @@ const routes = [
         component: () => import("pages/login/LoginPage.vue"),
         meta: { title: "Login" },
       },
+      {
+        path: "/primeiro-acesso",
+        name: "FirstAccessPage",
+        component: () => import("pages/login/FirstAccessPage.vue"),
+        meta: { title: "Primeiro Acesso" },
+      },
+      {
+        path: "/primeiro-acesso/dados",
+        name: "FirstAccessFormPage",
+        component: () => import("pages/login/FirstAccessFormPage.vue"),
+        meta: { title: "Primeiro Acesso" },
+      },
       {
         path: "/recuperar-senha",
         name: "ForgotPasswordPage",

+ 12 - 1
src/router/routes/associado.route.js

@@ -21,10 +21,21 @@ export default [
   //     breadcrumbs: [{ name: "CarteirinhaPage", title: "ui.navigation.carteirinha", translate: true }],
   //   },
   // },
+  {
+    path: "/associado/parceiros",
+    name: "AssociadoParceirosPage",
+    component: () => import("pages/parceiros-convenios/ParceirosConveniosPage.vue"),
+    meta: {
+      title: { value: "ui.navigation.partners", translate: true },
+      description: { value: "page.associado.parceiros.description", translate: true },
+      requireAuth: true,
+      breadcrumbs: [{ name: "AssociadoParceirosPage", title: "ui.navigation.partners", translate: true }],
+    },
+  },
   {
     path: "/associado/convenios",
     name: "ConveniosPage",
-    component: () => import("pages/parceiros-convenios/ParceirosConveniosPage.vue"),
+    component: () => import("pages/parceiros-convenios/ConveniosMedicosPage.vue"),
     meta: {
       title: { value: "ui.navigation.convenios", translate: true },
       description: { value: "page.associado.convenios.description", translate: true },

+ 9 - 0
src/stores/navigation.js

@@ -118,6 +118,15 @@ export const navigationStore = defineStore("navigation", () => {
       permissionScope: "associado.carteirinha",
       allowedTypes: ["associado"],
     },
+    {
+      type: "single",
+      title: "ui.navigation.partners",
+      name: "AssociadoParceirosPage",
+      icon: "mdi-handshake-outline",
+      permission: false,
+      permissionScope: "associado.convenio",
+      allowedTypes: ["associado"],
+    },
     {
       type: "single",
       title: "ui.navigation.convenios",