Bläddra i källkod

✨ feat(associado): implementar módulo de gestão e perfil de associados

Fase: dev | Origin: melhoria-interna
Gustavo Zanatta 1 vecka sedan
förälder
incheckning
bab3696640

+ 217 - 0
src/pages/associado/agendamentos/AgendamentosPage.vue

@@ -0,0 +1,217 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+
+    <div class="q-pa-md">
+      <DefaultTabs v-model="activeTab" :tabs-items="tabs" />
+
+      <div v-if="activeTab === 'novo'" class="q-mt-md">
+        <q-card flat bordered style="max-width: 600px">
+          <q-card-section>
+            <div class="text-h6 q-mb-md">{{ $t("associado.new_appointment") }}</div>
+            <q-form ref="appointmentFormRef" @submit="submitAppointment">
+              <div class="row q-col-gutter-sm">
+                <PartnerAgreementSelect
+                  v-model="selectedPartner"
+                  :label="$t('ui.navigation.convenios')"
+                  :rules="[inputRules.required]"
+                  class="col-12"
+                />
+                <PartnerAgreementServiceSelect
+                  v-model="selectedService"
+                  :partner-agreement-id="selectedPartner?.value"
+                  :label="$t('associado.service')"
+                  :rules="[inputRules.required]"
+                  class="col-12"
+                />
+                <DefaultInputDatePicker
+                  v-model="appointmentForm.date"
+                  :label="$t('common.terms.date')"
+                  :rules="[inputRules.required]"
+                  class="col-12 col-md-6"
+                />
+                <DefaultInput
+                  v-model="appointmentForm.time"
+                  :label="$t('common.terms.hour')"
+                  :rules="[inputRules.required]"
+                  mask="##:##"
+                  placeholder="HH:MM"
+                  class="col-12 col-md-6"
+                />
+                <DefaultInput
+                  v-model="appointmentForm.notes"
+                  :label="$t('associado.notes')"
+                  type="textarea"
+                  autogrow
+                  class="col-12"
+                />
+              </div>
+              <div class="q-mt-md flex justify-end">
+                <q-btn
+                  color="primary"
+                  type="submit"
+                  :label="$t('associado.schedule')"
+                  :loading="submitting"
+                />
+              </div>
+            </q-form>
+          </q-card-section>
+        </q-card>
+      </div>
+
+      <div v-else class="q-mt-md">
+        <div v-if="loadingList" class="flex flex-center q-py-xl">
+          <q-spinner color="primary" 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-list v-else separator bordered class="rounded-borders">
+          <q-item v-for="apt in appointments" :key="apt.id">
+            <q-item-section>
+              <q-item-label class="text-weight-medium">
+                {{ apt.partner_agreement?.trade_name || apt.partner_agreement?.company_name }}
+              </q-item-label>
+              <q-item-label caption>
+                {{ apt.partner_agreement_service?.name }}
+              </q-item-label>
+              <q-item-label caption>
+                {{ formatDate(apt.date) }} {{ apt.time ? `- ${apt.time}` : "" }}
+              </q-item-label>
+            </q-item-section>
+            <q-item-section side>
+              <q-chip
+                :color="statusColor(apt.status)"
+                text-color="white"
+                :label="$t(`associado.appointment_status.${apt.status}`)"
+                size="sm"
+              />
+            </q-item-section>
+            <q-item-section v-if="apt.status === 'pendente'" side>
+              <q-btn
+                flat
+                round
+                icon="mdi-cancel"
+                color="negative"
+                size="sm"
+                :loading="cancellingId === apt.id"
+                @click="onCancelAppointment(apt)"
+              />
+            </q-item-section>
+          </q-item>
+        </q-list>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, watch, onMounted, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { createAppointment, getMyAppointments, cancelAppointment } from "src/api/appointment";
+import { useInputRules } from "src/composables/useInputRules";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTabs from "src/components/defaults/DefaultTabs.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import PartnerAgreementSelect from "src/components/selects/PartnerAgreementSelect.vue";
+import PartnerAgreementServiceSelect from "src/components/selects/PartnerAgreementServiceSelect.vue";
+
+const $q = useQuasar();
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+const appointmentFormRef = useTemplateRef("appointmentFormRef");
+
+const activeTab = ref("novo");
+const tabs = computed(() => [
+  { name: "novo", label: t("associado.new_appointment") },
+  { name: "meus", label: t("associado.my_appointments") },
+]);
+
+const selectedPartner = ref(null);
+const selectedService = ref(null);
+const appointmentForm = reactive({ date: "", time: "", notes: "" });
+const submitting = ref(false);
+
+const appointments = ref([]);
+const loadingList = ref(false);
+const cancellingId = ref(null);
+
+const statusColor = (status) => {
+  const map = { pendente: "warning", confirmado: "positive", cancelado: "negative", concluido: "grey" };
+  return map[status] ?? "grey";
+};
+
+const formatDate = (date) => {
+  if (!date) return "";
+  const [y, m, d] = date.split("-");
+  return `${d}/${m}/${y}`;
+};
+
+const loadAppointments = async () => {
+  loadingList.value = true;
+  try {
+    appointments.value = await getMyAppointments();
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loadingList.value = false;
+  }
+};
+
+watch(activeTab, (val) => {
+  if (val === "meus") loadAppointments();
+});
+
+onMounted(() => {});
+
+const submitAppointment = async () => {
+  const valid = await appointmentFormRef.value?.validate();
+  if (!valid) return;
+  submitting.value = true;
+  try {
+    await createAppointment({
+      partner_agreement_id: selectedPartner.value.value,
+      partner_agreement_service_id: selectedService.value.value,
+      date: appointmentForm.date,
+      time: appointmentForm.time,
+      notes: appointmentForm.notes,
+    });
+    $q.notify({ type: "positive", message: t("http.success") });
+    selectedPartner.value = null;
+    selectedService.value = null;
+    appointmentForm.date = "";
+    appointmentForm.time = "";
+    appointmentForm.notes = "";
+    activeTab.value = "meus";
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    submitting.value = false;
+  }
+};
+
+const onCancelAppointment = (apt) => {
+  $q.dialog({
+    title: t("common.ui.messages.confirm_action"),
+    message: t("associado.confirm_cancel_appointment"),
+    cancel: true,
+    persistent: true,
+  }).onOk(async () => {
+    cancellingId.value = apt.id;
+    try {
+      await cancelAppointment(apt.id);
+      await loadAppointments();
+      $q.notify({ type: "positive", message: t("http.success") });
+    } catch {
+      $q.notify({ type: "negative", message: t("http.errors.failed") });
+    } finally {
+      cancellingId.value = null;
+    }
+  });
+};
+</script>

+ 251 - 0
src/pages/associado/carteirinha/CarteirinhaPage.vue

@@ -0,0 +1,251 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+
+    <div class="q-pa-md flex row q-gutter-sm">
+      <q-card
+        flat
+        class="associado-card"
+        :class="isVertical ? 'card-vertical' : 'card-horizontal'"
+      >
+        <div class="card-logo-row text-center">
+          <img :src="LogoSerPratiParceiro" class="card-logo" alt="SerPrati" />
+        </div>
+
+        <div class="card-body" :class="isVertical ? 'card-body--vertical' : 'card-body--horizontal'">
+          <q-avatar
+            class="card-avatar"
+            :size="isVertical ? '100px' : '76px'"
+          >
+            <img v-if="user?.avatar" :src="user.avatar" />
+            <q-icon v-else name="mdi-account" color="white" :size="isVertical ? '64px' : '40px'" />
+          </q-avatar>
+
+          <template v-if="!isVertical">
+            <div class="card-info">
+              <div class="card-info__row">
+                <span class="card-info__label">{{ $t('common.terms.name') }}</span>
+                <span class="card-info__value">{{ user?.name ?? '—' }}</span>
+              </div>
+              <div class="card-info__row q-pt-sm">
+                <span class="card-info__label">{{ $t('common.terms.cpf') }}</span>
+                <span class="card-info__value">{{ user?.cpf ?? '—' }}</span>
+              </div>
+              <div class="card-info__row q-pt-sm">
+                <span class="card-info__label">{{ $t('associado.registration') }}</span>
+                <span class="card-info__value">{{ user?.registration ?? '—' }}</span>
+              </div>
+            </div>
+            <div class="card-qr column">
+              <canvas v-if="qrReady" ref="qrCanvas" />
+              <q-spinner v-else color="white" size="48px" />
+              <div v-if="user?.expiry_date" class="card-info__row q-pt-sm text-right">
+                <span class="card-info__label text-right">{{ $t('associado.validity') }}</span>
+                <span class="card-info__value">{{ user.expiry_date }}</span>
+              </div>
+            </div>
+          </template>
+
+          <div v-if="isVertical" class="card-body__vertical-content">
+            <div class="card-info card-info--vertical">
+              <div class="card-info__row">
+                <span class="card-info__label">{{ $t('common.terms.name') }}</span>
+                <span class="card-info__value">{{ user?.name ?? '—' }}</span>
+              </div>
+              <div class="card-info__row q-pt-xs">
+                <span class="card-info__label">{{ $t('common.terms.cpf') }}</span>
+                <span class="card-info__value">{{ user?.cpf ?? '—' }}</span>
+              </div>
+              <div class="card-info__row q-pt-xs">
+                <span class="card-info__label">{{ $t('associado.registration') }}</span>
+                <span class="card-info__value">{{ user?.registration ?? '—' }}</span>
+              </div>
+              <div class="card-info__row q-pt-xs">
+                <span class="card-info__label">{{ $t('associado.validity') }}</span>
+                <span class="card-info__value">{{ user?.expiry_date ?? '—' }}</span>
+              </div>
+            </div>
+            <canvas v-if="qrReady" ref="qrCanvas" class="card-qr__canvas" />
+            <q-spinner v-else color="white" size="48px" />
+          </div>
+        </div>
+      </q-card>
+      <div class="q-mt-auto justify-end">
+        <q-btn
+          round
+          flat
+          :icon="isVertical ? 'mdi-phone-rotate-landscape' : 'mdi-phone-rotate-portrait'"
+          color="violet-normal"
+          size="sm"
+          class="self-start"
+          :title="isVertical ? $t('associado.horizontal') : $t('associado.vertical')"
+          @click="isVertical = !isVertical"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, useTemplateRef, watch, nextTick } from "vue";
+import QRCode from "qrcode";
+import { userStore } from "src/stores/user";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import LogoSerPratiParceiro from "src/assets/logo_serprati_parceiro.svg";
+
+const store = userStore();
+const user = ref(null);
+const isVertical = ref(false);
+const qrCanvas = useTemplateRef("qrCanvas");
+const qrReady = ref(false);
+
+const QR_SIZE_HORIZONTAL = 90;
+const QR_SIZE_VERTICAL   = 110;
+
+const generateQR = async () => {
+  if (!user.value?.cpf && !user.value?.registration) return;
+  await nextTick();
+  const canvas = qrCanvas.value instanceof Array ? qrCanvas.value[0] : qrCanvas.value;
+  if (!canvas) return;
+  const qrData = `CPF:${user.value.cpf ?? ""}|MAT:${user.value.registration ?? ""}`;
+  const size = isVertical.value ? QR_SIZE_VERTICAL : QR_SIZE_HORIZONTAL;
+  await QRCode.toCanvas(canvas, qrData, { width: size, margin: 1, color: { dark: "#ffffff", light: "#661d75" } });
+};
+
+watch(isVertical, async () => {
+  await nextTick();
+  await generateQR();
+});
+
+onMounted(async () => {
+  await store.fetchUser();
+  user.value = store.user;
+  qrReady.value = true;
+  await generateQR();
+});
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss" as vars;
+
+.associado-card {
+  border-radius: 16px;
+  background: linear-gradient(180deg, #661D75 0%, #BF36DB 100%);
+  overflow: hidden;
+  box-shadow: 0 8px 32px rgba(77, 22, 88, 0.35);
+  color: vars.$neutral-light;
+
+  .card-logo-row {
+    .card-logo {
+      height: 65px;
+    }
+  }
+
+  .card-body {
+    padding: 8px 16px 16px;
+    display: flex;
+    gap: 12px;
+
+    &--horizontal {
+      flex-direction: row;
+      align-items: center;
+    }
+
+    &--vertical {
+      flex-direction: column;
+      align-items: center;
+      text-align: center;
+    }
+  }
+
+  .card-avatar {
+    border: 2px solid rgba(255, 255, 255, 0.4);
+    background: rgba(255, 255, 255, 0.15);
+    flex-shrink: 0;
+  }
+
+  .card-info {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+
+    .card-info__label {
+      font-size: 11px;
+      opacity: 0.8;
+    }
+
+    .card-info__value {
+      font-size: 15px;
+      font-weight: 600;
+    }
+    &--vertical {
+      width: 100%;
+      align-items: flex-start;
+      text-align: left;
+
+    }
+
+    &__name {
+      font-size: 14px;
+      font-weight: 600;
+      color: vars.$neutral-light;
+      margin-bottom: 4px;
+    }
+
+    &__row {
+      display: flex;
+      flex-direction: column;
+      line-height: 1.2;
+    }
+
+    &__label {
+      font-size: 9px;
+      opacity: 0.75;
+      text-transform: uppercase;
+      letter-spacing: 0.4px;
+    }
+
+    &__value {
+      font-size: 11px;
+      font-weight: 500;
+      color: vars.$neutral-light;
+    }
+  }
+
+  .card-qr {
+    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    canvas {
+      border-radius: 6px;
+    }
+  }
+
+  .card-body__vertical-content {
+    width: 100%;
+    display: flex;
+    flex-direction: row;
+    align-items: flex-end;
+    justify-content: space-between;
+    gap: 8px;
+  }
+
+  .card-qr__canvas {
+    border-radius: 6px;
+    flex-shrink: 0;
+  }
+
+  &.card-horizontal {
+    width: 360px;
+    max-width: calc(100vw - 32px);
+  }
+
+  &.card-vertical {
+    width: 260px;
+    max-width: calc(100vw - 32px);
+  }
+}
+</style>

+ 289 - 0
src/pages/associado/convenios/ConveniosPage.vue

@@ -0,0 +1,289 @@
+<template>
+  <div class="convenios-page">
+    <DefaultHeaderPage />
+
+    <div class="convenios-page__search q-px-md q-pt-md">
+      <q-input
+        v-model="search"
+        :placeholder="$t('associado.search_by_segment')"
+        outlined
+        dense
+        bg-color="white"
+        clearable
+        class="convenios-page__search-input"
+      >
+        <template #prepend>
+          <q-icon name="mdi-magnify" />
+        </template>
+      </q-input>
+    </div>
+
+    <div class="convenios-page__chips q-px-md q-pt-sm q-pb-md">
+      <q-chip
+        clickable
+        :outline="selectedCategory !== null"
+        color="violet-normal"
+        text-color="white"
+        class="convenios-page__chip"
+        @click="selectedCategory = null"
+      >
+        {{ $t('common.terms.all') }}
+      </q-chip>
+      <q-chip
+        v-for="cat in categories"
+        :key="cat.value"
+        clickable
+        :outline="selectedCategory !== cat.value"
+        color="violet-normal"
+        text-color="white"
+        class="convenios-page__chip"
+        @click="selectedCategory = cat.value"
+      >
+        {{ cat.label }}
+      </q-chip>
+    </div>
+
+    <div class="convenios-page__content q-px-md q-pb-md">
+      <div v-if="loading" class="flex flex-center q-py-xl">
+        <q-spinner color="violet-normal" size="48px" />
+      </div>
+
+      <div v-else-if="filteredPartners.length === 0" class="text-center text-grey q-py-xl">
+        {{ $t("http.errors.no_records_found") }}
+      </div>
+
+      <div v-else class="row q-col-gutter-md">
+        <div
+          v-for="partner in filteredPartners"
+          :key="partner.id"
+          class="col-12 col-sm-6 col-md-4"
+        >
+          <q-card flat class="partner-card full-height cursor-pointer" @click="openPartnerDetail(partner)">
+            <div class="partner-card__logo-wrap">
+              <img
+                v-if="partner.logo?.url"
+                :src="partner.logo.url"
+                class="partner-card__logo-img"
+              />
+              <div v-else class="partner-card__logo-placeholder">
+                <q-icon name="mdi-store-outline" size="48px" color="grey-4" />
+              </div>
+            </div>
+
+            <q-card-section class="q-pt-sm">
+              <div class="row items-start justify-between no-wrap q-mb-xs">
+                <div class="text-subtitle2 text-weight-bold partner-card__name ellipsis" style="max-width: 65%">
+                  {{ partner.company_name }}
+                </div>
+                <q-chip
+                  v-if="partner.category?.name"
+                  dense
+                  class="partner-card__category-chip q-ma-none"
+                >
+                  {{ partner.category.name }}
+                </q-chip>
+                <div v-if="partner.discount_percentage" class="partner-card__discount-badge">
+                  {{ $t('associado.discount') }} -{{ partner.discount_percentage }}%
+                </div>
+              </div>
+
+              <div v-if="partner.address" class="text-caption partner-card__info row items-center q-mb-xs">
+                <q-icon name="mdi-map-marker-outline" size="14px" class="q-mr-xs" />
+                {{ partner.address }}<span v-if="partner.neighborhood">, {{ partner.neighborhood }}</span>
+              </div>
+
+              <div v-if="partner.phone" class="text-caption partner-card__info row items-center q-mb-xs">
+                <q-icon name="mdi-phone-outline" size="14px" class="q-mr-xs" />
+                {{ partner.phone }}
+              </div>
+
+              <div class="row items-center justify-between q-mt-sm">
+                <div class="row items-center text-caption partner-card__info">
+                  <q-icon name="mdi-star" size="14px" color="amber" class="q-mr-xs" />
+                  <span>—</span>
+                </div>
+                <div v-if="partner.contract_end" class="text-caption partner-card__info">
+                  <q-icon name="mdi-calendar-outline" size="13px" class="q-mr-xs" />
+                  {{ $t('associado.validity_until') }} {{ formatDate(partner.contract_end) }}
+                </div>
+              </div>
+            </q-card-section>
+          </q-card>
+        </div>
+      </div>
+    </div>
+
+    <q-dialog v-model="detailDialog">
+      <q-card style="width: 500px; max-width: 95vw">
+        <DefaultDialogHeader
+          :title="() => selectedPartner?.company_name || ''"
+          @close="detailDialog = false"
+        />
+        <q-card-section>
+          <div class="text-subtitle2 q-mb-sm">{{ $t("associado.services") }}</div>
+          <q-list v-if="selectedPartner?.services?.length > 0" separator>
+            <q-item v-for="service in selectedPartner.services" :key="service.id">
+              <q-item-section>
+                <q-item-label>{{ service.name }}</q-item-label>
+                <q-item-label caption>{{ service.description }}</q-item-label>
+              </q-item-section>
+              <q-item-section side>
+                <q-item-label v-if="service.discount_percent" class="text-positive">
+                  {{ service.discount_percent }}% off
+                </q-item-label>
+              </q-item-section>
+            </q-item>
+          </q-list>
+          <div v-else class="text-grey text-center q-py-sm">
+            {{ $t("http.errors.no_records_found") }}
+          </div>
+        </q-card-section>
+      </q-card>
+    </q-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { getPartnerAgreements } from "src/api/partnerAgreement";
+import { getCategories } from "src/api/category";
+import { normalizeString } from "src/helpers/utils";
+import { date } from "quasar";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+const partners = ref([]);
+const categories = ref([]);
+const loading = ref(true);
+const search = ref("");
+const selectedCategory = ref(null);
+const detailDialog = ref(false);
+const selectedPartner = ref(null);
+
+const formatDate = (d) => d ? date.formatDate(d, "MM/YYYY") : "—";
+
+const filteredPartners = computed(() => {
+  let list = partners.value;
+  if (selectedCategory.value) {
+    list = list.filter((p) => p.category_id === selectedCategory.value);
+  }
+  if (search.value) {
+    const needle = normalizeString(search.value);
+    list = list.filter((p) =>
+      normalizeString(p.company_name || "").includes(needle),
+    );
+  }
+  return list;
+});
+
+const openPartnerDetail = (partner) => {
+  selectedPartner.value = partner;
+  detailDialog.value = true;
+};
+
+onMounted(async () => {
+  try {
+    const [partnerList, categoryList] = await Promise.all([
+      getPartnerAgreements(),
+      getCategories("partner"),
+    ]);
+    partners.value = partnerList;
+    categories.value = categoryList.map((c) => ({ label: c.name, value: c.id }));
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss" as vars;
+
+.convenios-page {
+  background: #f3eaf7;
+  min-height: 100vh; // item 1: fundo ocupa a página toda
+
+  &__search-input {
+    max-width: 700px;
+    width: 100%;
+  }
+
+  &__chips {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px;
+  }
+
+  &__chip {
+    margin: 0;
+  }
+}
+
+.partner-card {
+  border-radius: 12px;
+  background: white;
+  transition: box-shadow 0.2s;
+
+  &:hover {
+    box-shadow: 0 4px 20px rgba(102, 29, 117, 0.15);
+  }
+
+  &__logo-wrap {
+    height: 150px;
+    border-radius: 12px 12px 0 0;
+    overflow: hidden;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 8px;
+  }
+
+  &__logo-img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    border-radius: 8px; // arredonda junto com o padding
+  }
+
+  &__logo-placeholder {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+  }
+
+  // item 2: desconto no lugar onde estava a categoria (canto inferior direito da imagem)
+  &__discount-badge {
+    // bottom: 12px;
+    // right: 12px;
+    background: #661d75;
+    color: white;
+    font-size: 11px;
+    font-weight: 600;
+    padding: 3px 10px;
+    border-radius: 20px;
+  }
+
+  // item 4: textos de info em roxo
+  &__name {
+    color: #661d75;
+  }
+
+  &__info {
+    color: #661d75;
+    opacity: 0.75;
+  }
+
+  // item 3: chip de categoria roxo claro / texto roxo escuro
+  &__category-chip {
+    background: #ead5f0 !important;
+    color: #661d75 !important;
+    font-size: 11px;
+    font-weight: 500;
+    height: 22px;
+  }
+}
+</style>

+ 110 - 0
src/pages/associado/interesses/InteressesPage.vue

@@ -0,0 +1,110 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+
+    <div class="q-pa-md">
+      <div v-if="loading" class="flex flex-center q-py-xl">
+        <q-spinner color="primary" size="48px" />
+      </div>
+
+      <div v-else-if="items.length === 0" class="text-center text-grey q-py-xl">
+        <q-icon name="mdi-heart-off-outline" size="64px" class="q-mb-sm" />
+        <div>{{ $t("associado.no_interests") }}</div>
+      </div>
+
+      <div v-else class="row q-col-gutter-md">
+        <div
+          v-for="item in items"
+          :key="item.id"
+          class="col-12 col-sm-6 col-md-4 col-lg-3"
+        >
+          <q-card flat bordered class="interest-card full-height column">
+            <q-card-section class="col">
+              <div class="flex items-start justify-between q-mb-sm">
+                <div class="text-subtitle1 text-weight-medium">{{ item.name }}</div>
+                <q-badge v-if="item.category?.name" color="primary" :label="item.category.name" />
+              </div>
+              <div v-if="item.description" class="text-body2 text-grey-7 q-mb-sm ellipsis-3-lines">
+                {{ item.description }}
+              </div>
+              <div class="text-body2">
+                <span class="text-weight-medium">{{ $t('associado.price') }}:</span>
+                R$ {{ formatPrice(item.price) }}
+              </div>
+            </q-card-section>
+            <q-card-actions>
+              <q-btn
+                color="negative"
+                icon="mdi-heart-remove-outline"
+                :label="$t('associado.remove_interest')"
+                :loading="togglingId === item.id"
+                flat
+                @click="removeInterest(item)"
+              />
+            </q-card-actions>
+          </q-card>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { getMyInterests, toggleInterest as apiToggleInterest } from "src/api/storeItem";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const $q = useQuasar();
+const { t } = useI18n();
+
+const items = ref([]);
+const loading = ref(true);
+const togglingId = ref(null);
+
+const formatPrice = (price) => {
+  if (!price) return "0,00";
+  return Number(price).toLocaleString("pt-BR", { minimumFractionDigits: 2 });
+};
+
+const removeInterest = async (item) => {
+  togglingId.value = item.id;
+  try {
+    await apiToggleInterest(item.id);
+    items.value = items.value.filter((i) => i.id !== item.id);
+    $q.notify({ type: "positive", message: t("http.success") });
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    togglingId.value = null;
+  }
+};
+
+onMounted(async () => {
+  try {
+    items.value = await getMyInterests();
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.interest-card {
+  transition: box-shadow 0.2s;
+  &:hover {
+    box-shadow: 0 4px 16px rgba(102, 29, 117, 0.12);
+  }
+}
+.ellipsis-3-lines {
+  display: -webkit-box;
+  -webkit-line-clamp: 3;
+  line-clamp: 3; /* For Firefox */
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+</style>

+ 158 - 0
src/pages/associado/loja/LojaPage.vue

@@ -0,0 +1,158 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+
+    <div class="q-pa-md">
+      <div class="row q-col-gutter-sm q-mb-md">
+        <div class="col-12 col-md-5">
+          <q-input
+            v-model="search"
+            :placeholder="$t('common.actions.search')"
+            outlined
+            dense
+            clearable
+          >
+            <template #prepend>
+              <q-icon name="mdi-magnify" />
+            </template>
+          </q-input>
+        </div>
+        <div class="col-12 col-md-4">
+          <CategorySelect
+            v-model="selectedCategory"
+            :label="$t('associado.filter_by_category')"
+          />
+        </div>
+      </div>
+
+      <div v-if="loading" class="flex flex-center q-py-xl">
+        <q-spinner color="primary" size="48px" />
+      </div>
+
+      <div v-else-if="filteredItems.length === 0" class="text-center text-grey q-py-xl">
+        {{ $t("http.errors.no_records_found") }}
+      </div>
+
+      <div v-else class="row q-col-gutter-md">
+        <div
+          v-for="item in filteredItems"
+          :key="item.id"
+          class="col-12 col-sm-6 col-md-4 col-lg-3"
+        >
+          <q-card flat bordered class="store-card full-height column">
+            <q-card-section class="col">
+              <div class="flex items-start justify-between q-mb-sm">
+                <div class="text-subtitle1 text-weight-medium">{{ item.name }}</div>
+                <q-badge v-if="item.category?.name" color="primary" :label="item.category.name" />
+              </div>
+              <div v-if="item.description" class="text-body2 text-grey-7 q-mb-sm ellipsis-3-lines">
+                {{ item.description }}
+              </div>
+              <div class="text-body2 q-mb-xs">
+                <span class="text-weight-medium">{{ $t('associado.price') }}:</span>
+                R$ {{ formatPrice(item.price) }}
+              </div>
+              <div v-if="item.size_s || item.size_m || item.size_l || item.size_xl" class="q-mt-xs">
+                <div class="text-caption text-grey-6">{{ $t('associado.available_sizes') }}:</div>
+                <div class="row q-gutter-xs q-mt-xs">
+                  <q-chip v-if="item.size_s && item.stock_s > 0" size="sm" outline color="primary">P</q-chip>
+                  <q-chip v-if="item.size_m && item.stock_m > 0" size="sm" outline color="primary">M</q-chip>
+                  <q-chip v-if="item.size_l && item.stock_l > 0" size="sm" outline color="primary">G</q-chip>
+                  <q-chip v-if="item.size_xl && item.stock_xl > 0" size="sm" outline color="primary">GG</q-chip>
+                </div>
+              </div>
+            </q-card-section>
+            <q-card-actions>
+              <q-btn
+                :color="item.user_interested ? 'negative' : 'primary'"
+                :icon="item.user_interested ? 'mdi-heart' : 'mdi-heart-outline'"
+                :label="item.user_interested ? $t('associado.remove_interest') : $t('associado.i_want')"
+                :loading="togglingId === item.id"
+                flat
+                @click="toggleInterest(item)"
+              />
+            </q-card-actions>
+          </q-card>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { getStoreItems, toggleInterest as apiToggleInterest } from "src/api/storeItem";
+import { normalizeString } from "src/helpers/utils";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import CategorySelect from "src/components/selects/CategorySelect.vue";
+
+const $q = useQuasar();
+const { t } = useI18n();
+
+const items = ref([]);
+const loading = ref(true);
+const search = ref("");
+const selectedCategory = ref(null);
+const togglingId = ref(null);
+
+const filteredItems = computed(() => {
+  let list = items.value;
+  if (selectedCategory.value) {
+    list = list.filter((i) => i.category_id === selectedCategory.value.value);
+  }
+  if (search.value) {
+    const needle = normalizeString(search.value);
+    list = list.filter((i) =>
+      normalizeString(i.name || "").includes(needle),
+    );
+  }
+  return list;
+});
+
+const formatPrice = (price) => {
+  if (!price) return "0,00";
+  return Number(price).toLocaleString("pt-BR", { minimumFractionDigits: 2 });
+};
+
+const toggleInterest = async (item) => {
+  togglingId.value = item.id;
+  try {
+    const result = await apiToggleInterest(item.id);
+    item.user_interested = result.interested;
+    $q.notify({ type: "positive", message: t("http.success") });
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    togglingId.value = null;
+  }
+};
+
+onMounted(async () => {
+  try {
+    items.value = await getStoreItems();
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.store-card {
+  transition: box-shadow 0.2s;
+  &:hover {
+    box-shadow: 0 4px 16px rgba(102, 29, 117, 0.12);
+  }
+}
+.ellipsis-3-lines {
+  display: -webkit-box;
+  -webkit-line-clamp: 3;
+  line-clamp: 3; /* For Firefox */
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+</style>

+ 112 - 0
src/pages/associado/notificacoes/NotificacoesPage.vue

@@ -0,0 +1,112 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+
+    <div class="q-pa-md">
+      <div v-if="loading" class="flex flex-center q-py-xl">
+        <q-spinner color="primary" size="48px" />
+      </div>
+
+      <div v-else-if="notifications.length === 0" class="text-center text-grey q-py-xl">
+        <q-icon name="mdi-bell-off-outline" size="64px" class="q-mb-sm" />
+        <div>{{ $t("associado.no_notifications") }}</div>
+      </div>
+
+      <div v-else class="column q-gutter-sm">
+        <q-card
+          v-for="notif in notifications"
+          :key="notif.id"
+          flat
+          bordered
+          :class="notif.read ? 'notification-read' : 'notification-unread'"
+        >
+          <q-card-section class="row items-start q-py-sm">
+            <q-icon
+              :name="notif.read ? 'mdi-bell-outline' : 'mdi-bell-ring'"
+              :color="notif.read ? 'grey-5' : 'primary'"
+              size="24px"
+              class="q-mr-md q-mt-xs"
+            />
+            <div class="col">
+              <div class="text-subtitle2" :class="notif.read ? 'text-grey-7' : ''">
+                {{ notif.notification?.title }}
+              </div>
+              <div class="text-body2 text-grey-7 q-mt-xs">
+                {{ notif.notification?.body }}
+              </div>
+              <div class="text-caption text-grey-5 q-mt-xs">
+                {{ formatDate(notif.created_at) }}
+              </div>
+            </div>
+            <q-btn
+              v-if="!notif.read"
+              flat
+              round
+              icon="mdi-check"
+              color="primary"
+              size="sm"
+              :loading="markingId === notif.id"
+              @click="markAsRead(notif)"
+            >
+              <q-tooltip>{{ $t("associado.mark_as_read") }}</q-tooltip>
+            </q-btn>
+          </q-card-section>
+        </q-card>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { getMyUnreadNotifications, markNotificationAsRead } from "src/api/notification";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const $q = useQuasar();
+const { t } = useI18n();
+
+const notifications = ref([]);
+const loading = ref(true);
+const markingId = ref(null);
+
+const formatDate = (dateStr) => {
+  if (!dateStr) return "";
+  const d = new Date(dateStr);
+  return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
+};
+
+const markAsRead = async (notif) => {
+  markingId.value = notif.id;
+  try {
+    await markNotificationAsRead(notif.id);
+    notif.read = true;
+    $q.notify({ type: "positive", message: t("http.success") });
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    markingId.value = null;
+  }
+};
+
+onMounted(async () => {
+  try {
+    notifications.value = await getMyUnreadNotifications();
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.notification-unread {
+  border-left: 3px solid $primary;
+}
+.notification-read {
+  opacity: 0.75;
+}
+</style>

+ 316 - 0
src/pages/associado/profile/ProfilePage.vue

@@ -0,0 +1,316 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+
+    <div class="q-pa-md">
+      <q-card flat class="profile-card">
+
+        <div class="profile-header row items-center q-pa-lg">
+          <div class="profile-avatar-wrap q-mr-md">
+            <q-avatar size="76px" class="profile-avatar">
+              <q-icon name="mdi-account" size="44px" color="white" />
+            </q-avatar>
+            <q-btn
+              round
+              unelevated
+              size="xs"
+              icon="mdi-pencil-outline"
+              class="profile-avatar-edit"
+              color="white"
+              text-color="violet-normal"
+              disable
+            />
+          </div>
+          <div class="column q-gutter-xs">
+            <span class="text-white text-h6 text-weight-bold">{{ user?.name || '—' }}</span>
+            <div>
+              <q-badge
+                :color="statusBadgeColor"
+                class="text-capitalize"
+                style="font-size:11px; padding: 3px 8px; border-radius: 20px;"
+              >
+                {{ statusLabel }}
+              </q-badge>
+            </div>
+            <span class="text-white profile-validity text-caption">
+              {{ $t('associado.validity') }} {{ user?.expiry_date || '—' }}
+            </span>
+          </div>
+        </div>
+
+        <div class="q-pa-md column">
+          <div
+            v-for="field in infoFields"
+            :key="field.label"
+            class="info-row row items-center no-wrap q-my-sm q-px-md"
+          >
+            <q-icon :name="field.icon" size="22px" class="info-icon q-mr-md" />
+            <div class="column">
+              <span class="info-label">{{ field.label }}</span>
+              <span class="info-value">{{ field.value || '—' }}</span>
+            </div>
+          </div>
+        </div>
+
+        <div class="q-px-md">
+          <div class="text-h6">{{ $t('associado.dependents') }}</div>
+
+          <q-list v-if="dependents.length > 0" class="column q-mb-md">
+            <div
+              v-for="dep in dependents"
+              :key="dep.id"
+              class="dependent-row row items-center no-wrap q-my-md"
+            >
+              <q-avatar size="40px" class="dependent-avatar q-mr-md" text-color="white">
+                {{ initials(dep.name) }}
+              </q-avatar>
+
+              <div class="col column">
+                <span class="dependent-name">{{ dep.name }}</span>
+                <span class="dependent-kinship">
+                  {{ $t(`associado.kinship_options.${dep.kinship}`) }}
+                </span>
+              </div>
+
+              <q-badge
+                :color="dependentStatusColor(dep.status)"
+                class="q-mr-sm text-capitalize"
+                style="font-size:11px; padding: 3px 8px; border-radius: 20px;"
+              >
+                {{ dependentStatusLabel(dep.status) }}
+              </q-badge>
+            </div>
+          </q-list>
+
+          <div v-else class="text-center q-py-md text-grey">
+            {{ $t('associado.no_dependents') }}
+          </div>
+
+          <div class="flex justify-end q-pb-md">
+            <q-btn
+              unelevated
+              icon="mdi-plus"
+              :label="$t('common.actions.add').toUpperCase()"
+              color="violet-normal"
+              text-color="white"
+              style="border-radius: 8px; padding: 8px 20px;"
+              @click="onAddDependent"
+            />
+          </div>
+        </div>
+
+      </q-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, defineAsyncComponent } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { userStore } from "src/stores/user";
+import { getDependentsByUser } from "src/api/profile";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const AddEditDependentDialog = defineAsyncComponent(
+  () => import("src/pages/associado/profile/components/AddEditDependentDialog.vue"),
+);
+
+const $q = useQuasar();
+const { t } = useI18n();
+const store = userStore();
+
+const user = ref(null);
+const dependents = ref([]);
+
+const statusBadgeColor = computed(() => {
+  switch (user.value?.status) {
+    case "active":   return "positive";
+    case "inactive": return "warning";
+    case "canceled": return "negative";
+    default:         return "grey";
+  }
+});
+
+const statusLabel = computed(() => {
+  const map = { active: t("common.status.active"), inactive: t("common.status.inactive"), canceled: t("common.status.canceled") };
+  return map[user.value?.status] ?? user.value?.status ?? "—";
+});
+
+const infoFields = computed(() => [
+  { icon: "mdi-card-account-details-outline", label: t("common.terms.cpf"),          value: user.value?.cpf },
+  { icon: "mdi-email-outline",                label: "E-mail",                        value: user.value?.email },
+  { icon: "mdi-briefcase-outline",            label: t("associado.position"),         value: user.value?.position?.name },
+  { icon: "mdi-domain",                       label: t("associado.sector"),           value: user.value?.sector?.name },
+  { icon: "mdi-card-text-outline",            label: t("associado.registration"),     value: user.value?.registration },
+  { icon: "mdi-calendar-outline",             label: t("associado.admission_date"),   value: user.value?.admission_date },
+]);
+
+const initials = (name) => {
+  if (!name) return "?";
+  return name.split(" ").slice(0, 2).map((w) => w[0]).join("").toUpperCase();
+};
+
+const dependentStatusColor = (status) => {
+  switch (status) {
+    case "approved": return "positive";
+    case "refused":  return "negative";
+    case "pending":  return "warning";
+    default:         return "grey";
+  }
+};
+
+const dependentStatusLabel = (status) => {
+  const map = {
+    approved: t("associado.dependent_statuses.approved"),
+    refused:  t("associado.dependent_statuses.refused"),
+    pending:  t("associado.dependent_statuses.pending"),
+  };
+  return map[status] ?? status ?? "—";
+};
+
+const loadUser = async () => {
+  await store.fetchUser();
+  user.value = store.user;
+};
+
+const loadDependents = async () => {
+  if (!user.value?.id) return;
+  try {
+    dependents.value = await getDependentsByUser(user.value.id);
+  } catch {
+    dependents.value = [];
+  }
+};
+
+onMounted(async () => {
+  await loadUser();
+  await loadDependents();
+});
+
+const onAddDependent = () => {
+  $q.dialog({
+    component: AddEditDependentDialog,
+    componentProps: {
+      userId: user.value.id,
+      title: () => t("common.actions.add") + " " + t("associado.dependent"),
+    },
+  }).onOk(() => loadDependents());
+};
+
+// const onEditDependent = (dep) => {
+//   $q.dialog({
+//     component: AddEditDependentDialog,
+//     componentProps: {
+//       dependent: dep,
+//       userId: user.value.id,
+//       title: () => t("common.actions.edit") + " " + t("associado.dependent"),
+//     },
+//   }).onOk(() => loadDependents());
+// };
+
+// const onDeleteDependent = (dep) => {
+//   $q.dialog({
+//     title: t("common.ui.messages.confirm_action"),
+//     message: t("common.ui.messages.are_you_sure_delete"),
+//     cancel: true,
+//     persistent: true,
+//   }).onOk(async () => {
+//     try {
+//       await deleteDependent(dep.id);
+//       await loadDependents();
+//       $q.notify({ type: "positive", message: t("http.success") });
+//     } catch {
+//       $q.notify({ type: "negative", message: t("http.errors.failed") });
+//     }
+//   });
+// };
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss" as *;
+
+.profile-card {
+  border-radius: 12px;
+  overflow: hidden;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.profile-header {
+  background: linear-gradient(135deg, $violet-normal 0%, $violet-dark 100%);
+}
+
+.profile-avatar-wrap {
+  position: relative;
+  flex-shrink: 0;
+}
+
+.profile-avatar {
+  background: rgba(255, 255, 255, 0.18) !important;
+  border: 2px solid rgba(255, 255, 255, 0.45);
+}
+
+.profile-avatar-edit {
+  position: absolute;
+  bottom: -2px;
+  right: -2px;
+  width: 22px;
+  height: 22px;
+  min-height: 22px;
+}
+
+.profile-validity {
+  opacity: 0.85;
+}
+
+.info-row {
+  background: $violet-light;
+  border-radius: 8px;
+  padding: 12px 16px;
+}
+
+.info-icon {
+  color: $violet-normal;
+  flex-shrink: 0;
+}
+
+.info-label {
+  font-size: 11px;
+  color: $color-text-2;
+  line-height: 1.2;
+}
+
+.info-value {
+  font-size: 14px;
+  color: $color-text;
+  font-weight: 500;
+  line-height: 1.4;
+}
+
+.dependent-row {
+  background: $violet-light;
+  border-radius: 8px;
+  padding: 10px 12px;
+}
+
+.dependent-avatar {
+  background: $violet-normal !important;
+  font-size: 14px;
+  font-weight: 700;
+  flex-shrink: 0;
+}
+
+.dependent-name {
+  font-size: 14px;
+  font-weight: 600;
+  color: $color-text;
+  line-height: 1.3;
+}
+
+.dependent-kinship {
+  font-size: 12px;
+  color: $color-text-2;
+  line-height: 1.2;
+}
+</style>

+ 118 - 0
src/pages/associado/profile/components/AddEditDependentDialog.vue

@@ -0,0 +1,118 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 500px">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm q-pt-none">
+          <DefaultInput
+            v-model="form.name"
+            v-model:error="validationErrors.name"
+            :rules="[inputRules.required]"
+            :label="$t('common.terms.name')"
+            class="col-12"
+          />
+          <DefaultSelect
+            v-model="form.kinship"
+            v-model:error="validationErrors.kinship"
+            :rules="[inputRules.required]"
+            :label="$t('associado.kinship')"
+            :options="kinshipOptions"
+            emit-value
+            map-options
+            class="col-md-6 col-12"
+          />
+          <DefaultSelect
+            v-model="form.status"
+            v-model:error="validationErrors.status"
+            :rules="[inputRules.required]"
+            :label="$t('common.terms.status')"
+            :options="statusOptions"
+            emit-value
+            map-options
+            class="col-md-6 col-12"
+          />
+        </q-card-section>
+        <q-card-actions>
+          <q-space />
+          <q-btn
+            outline
+            color="negative"
+            :label="$t('common.actions.cancel')"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            color="primary"
+            :label="dependent ? $t('common.actions.save') : $t('common.actions.add')"
+            type="submit"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, useTemplateRef } from "vue";
+import { useInputRules } from "src/composables/useInputRules";
+import { useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { createDependent, updateDependent } from "src/api/profile";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dependent, userId, title } = defineProps({
+  dependent: { type: Object, default: null },
+  userId: { type: Number, required: true },
+  title: { type: Function, default: () => "" },
+});
+
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const formRef = useTemplateRef("formRef");
+
+const kinshipOptions = computed(() => [
+  { label: t("associado.kinship_options.conjuge"), value: "conjuge" },
+  { label: t("associado.kinship_options.filho"), value: "filho" },
+  { label: t("associado.kinship_options.filha"), value: "filha" },
+  { label: t("associado.kinship_options.pai"), value: "pai" },
+  { label: t("associado.kinship_options.mae"), value: "mae" },
+  { label: t("associado.kinship_options.irmao"), value: "irmao" },
+  { label: t("associado.kinship_options.irma"), value: "irma" },
+  { label: t("associado.kinship_options.outro"), value: "outro" },
+]);
+
+const statusOptions = computed(() => [
+  { label: t("associado.dependent_statuses.approved"), value: "approved" },
+  { label: t("associado.dependent_statuses.refused"),  value: "refused" },
+  { label: t("associado.dependent_statuses.pending"),  value: "pending" },
+]);
+
+const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
+  name: dependent?.name ?? "",
+  kinship: dependent?.kinship ?? "",
+  status: dependent?.status ?? "pending",
+  responsible_user_id: userId,
+});
+
+const { loading, validationErrors, execute: submitForm } = useSubmitHandler({
+  onSuccess: () => onDialogOK(true),
+  formRef,
+});
+
+const onOKClick = async () => {
+  if (dependent) {
+    await submitForm(() => updateDependent(dependent.id, getUpdatedFields.value));
+  } else {
+    await submitForm(() => createDependent({ ...form }));
+  }
+};
+</script>

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

@@ -0,0 +1,245 @@
+<template>
+  <div>
+    <DefaultHeaderPage>
+      <template #after>
+        <div class="flex gap-sm q-mt-md">
+          <q-btn
+            unelevated
+            class="btn-gradient"
+            :label="$t('associado.import_afastamentos')"
+            icon="mdi-upload"
+            padding="8px 12px"
+          />
+          <q-btn
+            unelevated
+            class="btn-gradient"
+            :label="$t('common.actions.import')"
+            icon="mdi-upload"
+            padding="8px 12px"
+          />
+          <q-btn
+            unelevated
+            class="btn-gradient"
+            :label="$t('common.actions.new')"
+            icon="mdi-plus"
+            padding="8px 12px"
+            @click="onAddItem"
+          />
+        </div>
+      </template>
+    </DefaultHeaderPage>
+
+    <div class="q-px-none q-pb-sm">
+      <q-input
+        v-model="searchQuery"
+        outlined
+        dense
+        :placeholder="$t('associado.search_placeholder')"
+        clearable
+        class="search-input"
+      >
+        <template #prepend>
+          <q-icon name="mdi-magnify" color="violet-normal" />
+        </template>
+      </q-input>
+    </div>
+
+    <div v-if="loading" class="flex flex-center q-pa-xl">
+      <q-spinner color="violet-normal" size="50px" />
+    </div>
+
+    <DefaultTable
+      v-else
+      ref="tableRef"
+      v-model:rows="filteredRows"
+      :columns="columns"
+      no-api-call
+      :show-search-field="false"
+      :rows-per-page="10"
+      open-item
+      @on-row-click="onRowClick"
+    >
+      <template #body-cell-actions>
+        <q-td width="15%" class="text-center">
+          <div class="row no-wrap items-center" style="gap: 4px">
+            <q-btn flat round dense color="violet-normal" icon="mdi-pencil" />
+            <q-btn flat round dense color="violet-normal" icon="mdi-account-minus" />
+            <q-btn flat round dense color="violet-normal" icon="mdi-calendar" />
+            <q-btn flat round dense color="violet-normal" icon="mdi-sofa" />
+          </div>
+        </q-td>
+      </template>
+
+      <template #body-cell-status="{ row }">
+        <q-td class="text-center">
+          <q-badge
+            outline
+            :color="getStatusColor(row.status)"
+            :label="$t(getStatusI18nKey(row.status))"
+          />
+        </q-td>
+      </template>
+    </DefaultTable>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, defineAsyncComponent } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { permissionStore } from "src/stores/permission";
+import { getAssociados } from "src/api/user";
+import { normalizeString, getStatusColor, getStatusI18nKey } from "src/helpers/utils";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+
+const AddEditAssociadoDialog = defineAsyncComponent(
+  () => import("./components/AddEditAssociadoDialog.vue"),
+);
+
+const permission_store = permissionStore();
+const $q = useQuasar();
+const { t } = useI18n();
+const tableRef = ref(null);
+const loading = ref(true);
+const allRows = ref([]);
+const searchQuery = ref("");
+
+const columns = [
+  {
+    name: "registration",
+    label: t("associado.cracha"),
+    field: "registration",
+    align: "left",
+    sortable: true,
+    width: "10%",
+
+  },
+  {
+    name: "name",
+    label: t("common.terms.name"),
+    field: "name",
+    align: "left",
+    sortable: true,
+    width: "25%",
+  },
+  {
+    name: "position",
+    label: t("associado.position"),
+    field: (row) => row.position?.name ?? "—",
+    align: "left",
+    sortable: true,
+    width: "25%",
+  },
+  {
+    name: "admission_date",
+    label: t("associado.admission"),
+    field: (row) => (row.admission_date ? row.admission_date : "—"),
+    align: "left",
+    sortable: true,
+    width: "10%",
+  },
+  {
+    name: "expiry_date",
+    label: t("associado.validity"),
+    field: (row) => (row.expiry_date ? row.expiry_date : "—"),
+    align: "left",
+    sortable: true,
+    width: "10%",
+  },
+  {
+    name: "actions",
+    label: t("common.terms.actions"),
+    align: "right",
+    required: true,
+    width: "15%",
+  },
+  {
+    name: "status",
+    label: t("common.terms.status"),
+    field: "status",
+    align: "center",
+    sortable: true,
+    width: "5%",
+  },
+];
+
+const filteredRows = computed(() => {
+  if (!searchQuery.value) return allRows.value;
+
+  const needle = normalizeString(searchQuery.value);
+
+  return allRows.value.filter((row) => {
+    const fields = [
+      row.registration,
+      row.name,
+      row.email,
+      row.cpf,
+      row.position?.name,
+      row.sector?.name,
+      row.admission_date,
+      row.expiry_date,
+      row.status,
+    ];
+    return fields.some((f) => f && normalizeString(String(f)).includes(needle));
+  });
+});
+
+
+const onAddItem = async () => {
+  if (!permission_store.getAccess("associado", "add")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.add") });
+    return;
+  }
+  $q.dialog({ component: AddEditAssociadoDialog })
+    .onOk(async () => {
+      const updated = await getAssociados();
+      allRows.value = updated;
+    });
+};
+
+const onRowClick = async ({ row }) => {
+  if (!permission_store.getAccess("associado", "edit")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.edit") });
+    return;
+  }
+  $q.dialog({
+    component: AddEditAssociadoDialog,
+    componentProps: {
+      associado: row,
+      title: () => t("common.actions.edit") + " " + t("ui.navigation.associados"),
+    },
+  }).onOk(async () => {
+    const updated = await getAssociados();
+    allRows.value = updated;
+  });
+};
+
+onMounted(async () => {
+  try {
+    allRows.value = await getAssociados();
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style scoped>
+.search-input {
+  border-radius: 8px;
+}
+
+.gap-sm {
+  gap: 8px;
+}
+
+.btn-gradient {
+  background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
+  color: white !important;
+  border-radius: 8px !important;
+}
+
+.btn-gradient :deep(.q-icon) {
+  color: white !important;
+}
+</style>

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

@@ -0,0 +1,192 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 900px; max-width: 95vw">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm q-pt-none">
+          <DefaultInput
+            v-model="form.name"
+            v-model:error="validationErrors.name"
+            :rules="[inputRules.required]"
+            :label="$t('common.terms.name')"
+            :placeholder="$t('user.profile.name_and_surname')"
+            class="col-md-8 col-12"
+          />
+          <DefaultSelect
+            v-model="form.status"
+            v-model:error="validationErrors.status"
+            :options="statusOptions"
+            :label="$t('common.terms.status')"
+            emit-value
+            map-options
+            class="col-md-4 col-12"
+          />
+          <DefaultInput
+            v-model="form.email"
+            v-model:error="validationErrors.email"
+            :rules="[inputRules.email, inputRules.required]"
+            :label="$t('common.terms.email')"
+            :placeholder="$t('common.placeholders.email')"
+            class="col-md-8 col-12"
+          />
+          <DefaultInput
+            v-model="form.cpf"
+            v-model:error="validationErrors.cpf"
+            :label="$t('common.terms.cpf')"
+            :placeholder="$t('common.placeholders.cpf')"
+            :mask="'###.###.###-##'"
+            class="col-md-4 col-12"
+          />
+          <DefaultInput
+            v-model="form.registration"
+            v-model:error="validationErrors.registration"
+            :label="$t('associado.registration')"
+            :placeholder="$t('common.placeholders.registration')"
+            class="col-md-4 col-12"
+          />
+          <PositionSelect
+            v-model="selectedPosition"
+            v-model:error="validationErrors.position_id"
+            class="col-md-4 col-12"
+          />
+          <SectorSelect
+            v-model="selectedSector"
+            v-model:error="validationErrors.sector_id"
+            class="col-md-4 col-12"
+          />
+          <DefaultInputDatePicker
+            v-model:untreated-date="form.admission_date"
+            v-model:error="validationErrors.admission_date"
+            :label="$t('associado.admission_date')"
+            class="col-md-6 col-12"
+          />
+          <DefaultInputDatePicker
+            v-model:untreated-date="form.expiry_date"
+            v-model:error="validationErrors.expiry_date"
+            :label="$t('associado.validity')"
+            class="col-md-6 col-12"
+          />
+          <DefaultPasswordInput
+            v-model="form.password"
+            v-model:error="validationErrors.password"
+            :rules="associado ? [inputRules.password] : [inputRules.required, inputRules.password]"
+            :label="$t('common.terms.password')"
+            :placeholder="$t('auth.password_hint')"
+            class="col-md-6 col-12"
+          />
+          <DefaultPasswordInput
+            v-model="confirmPassword"
+            :rules="
+              associado
+                ? [inputRules.samePassword(form.password)]
+                : [inputRules.required, inputRules.samePassword(form.password)]
+            "
+            :label="$t('auth.confirm_password')"
+            class="col-md-6 col-12"
+          />
+        </q-card-section>
+        <q-card-actions>
+          <q-space />
+          <q-btn
+            outline
+            color="negative"
+            :label="$t('common.actions.cancel')"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            color="violet-normal"
+            :label="associado ? $t('common.actions.save') : $t('common.actions.add')"
+            type="submit"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, useTemplateRef, watch } from "vue";
+import { useInputRules } from "src/composables/useInputRules";
+import { useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { createUser, updateUser } from "src/api/user";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import PositionSelect from "src/components/selects/PositionSelect.vue";
+import SectorSelect from "src/components/selects/SectorSelect.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { associado, title } = defineProps({
+  associado: {
+    type: Object,
+    default: null,
+  },
+  title: {
+    type: Function,
+    default: () => useI18n().t("common.actions.add"),
+  },
+});
+
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const formRef = useTemplateRef("formRef");
+const confirmPassword = ref("");
+
+const selectedPosition = ref(
+  associado?.position ? { label: associado.position.name, value: associado.position.id } : null,
+);
+const selectedSector = ref(
+  associado?.sector ? { label: associado.sector.name, value: associado.sector.id } : null,
+);
+
+const statusOptions = computed(() => [
+  { label: t("common.status.active"),   value: "active" },
+  { label: t("common.status.inactive"), value: "inactive" },
+  { label: t("common.status.pending"),  value: "pending" },
+]);
+
+const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
+  name:           associado?.name           ?? "",
+  email:          associado?.email          ?? "",
+  cpf:            associado?.cpf            ?? "",
+  registration:   associado?.registration   ?? "",
+  password:       "",
+  type:           "associado",
+  status:         associado?.status         ?? "active",
+  position_id:    associado?.position_id    ?? null,
+  sector_id:      associado?.sector_id      ?? null,
+  admission_date: associado?.admission_date ?? null,
+  expiry_date:    associado?.expiry_date     ?? null,
+});
+
+const { loading, validationErrors, execute: submitForm } = useSubmitHandler({
+  onSuccess: () => onDialogOK(true),
+  formRef: formRef,
+});
+
+watch(selectedPosition, (val) => {
+  form.position_id = val?.value ?? null;
+});
+
+watch(selectedSector, (val) => {
+  form.sector_id = val?.value ?? null;
+});
+
+const onOKClick = async () => {
+  if (associado) {
+    await submitForm(() => updateUser(getUpdatedFields.value, associado.id));
+  } else {
+    await submitForm(() => createUser({ ...form }));
+  }
+};
+</script>