瀏覽代碼

refactor: fluxo de consultas e exames de agendamentos

Gustavo Zanatta 3 周之前
父節點
當前提交
c7c7ffd3db
共有 29 個文件被更改,包括 1327 次插入216 次删除
  1. 48 1
      src/api/appointment.js
  2. 18 1
      src/api/partnerAgreement.js
  3. 8 4
      src/api/partnerAgreementService.js
  4. 5 3
      src/api/user.js
  5. 68 0
      src/components/ClinicScheduleNotice.vue
  6. 7 2
      src/components/defaults/DefaultInputDatePicker.vue
  7. 46 23
      src/components/selects/AssociadoSelect.vue
  8. 7 2
      src/components/selects/DependenteSelect.vue
  9. 85 0
      src/components/selects/PartnerAgreementExamsSelect.vue
  10. 39 19
      src/components/selects/PartnerAgreementSelect.vue
  11. 13 4
      src/components/selects/PartnerAgreementServiceSelect.vue
  12. 17 0
      src/composables/useInputRules.js
  13. 73 2
      src/helpers/utils.js
  14. 31 4
      src/i18n/locales/en.json
  15. 31 4
      src/i18n/locales/es.json
  16. 31 4
      src/i18n/locales/pt.json
  17. 177 36
      src/pages/agendamentos/AppointmentsAdminPage.vue
  18. 96 23
      src/pages/associado/agendamentos/AgendamentosPage.vue
  19. 58 7
      src/pages/parceiros-convenios/AgendamentosParceiroPage.vue
  20. 59 29
      src/pages/parceiros-convenios/ConveniosMedicosPage.vue
  21. 37 8
      src/pages/parceiros-convenios/ParceiroDadosPage.vue
  22. 14 2
      src/pages/parceiros-convenios/ParceiroServicoCadastroPage.vue
  23. 59 29
      src/pages/parceiros-convenios/ParceirosConveniosPage.vue
  24. 38 8
      src/pages/parceiros-convenios/components/CadastroFormPanel.vue
  25. 34 0
      src/pages/parceiros-convenios/components/NovaGuiaExameDialog.vue
  26. 213 0
      src/pages/parceiros-convenios/components/NovaGuiaExameForm.vue
  27. 3 0
      src/pages/parceiros-convenios/components/ServicoFormPanel.vue
  28. 2 1
      src/stores/navigation.js
  29. 10 0
      src/stores/user.js

+ 48 - 1
src/api/appointment.js

@@ -63,6 +63,18 @@ export const updateAppointment = async (id, payload) => {
   return data.payload;
 };
 
+export const acceptExamAppointment = async (id) => {
+  const { data } = await api.put(`/associado/appointment/${id}/accept`);
+  return data.payload;
+};
+
+export const refuseExamAppointment = async (id, refusalReason) => {
+  const { data } = await api.put(`/associado/appointment/${id}/refuse`, {
+    refusal_reason: refusalReason,
+  });
+  return data.payload;
+};
+
 // ─── Rotas do Parceiro ────────────────────────────────────────────────────────
 
 export const getPartnerAppointments = async () => {
@@ -80,6 +92,40 @@ export const rejectAppointmentParceiro = async (id) => {
   return data.payload;
 };
 
+// ─── Guias de exame do convênio médico ───────────────────────────────────────
+
+export const getPartnerExams = async () => {
+  const { data } = await api.get("/parceiro/appointment/exam");
+  return data.payload;
+};
+
+export const getPartnerExam = async (id) => {
+  const { data } = await api.get(`/parceiro/appointment/exam/${id}`);
+  return data.payload;
+};
+
+export const getPartnerExamAssociados = async ({ page = 1, perPage = 20, search } = {}) => {
+  const params = { page, per_page: perPage };
+  if (search) params.search = search;
+  const { data } = await api.get("/parceiro/appointment/exam/associado", { params });
+  return data.payload;
+};
+
+export const getPartnerExamDependentes = async (userId) => {
+  const { data } = await api.get(`/parceiro/appointment/exam/associado/${userId}/dependente`);
+  return data.payload;
+};
+
+export const createPartnerExam = async (payload) => {
+  const { data } = await api.post("/parceiro/appointment/exam", payload);
+  return data.payload;
+};
+
+export const createAdminExam = async (payload) => {
+  const { data } = await api.post("/appointment/exam", payload);
+  return data.payload;
+};
+
 export const getAppointmentsByUser = async (userId) => {
   const { data } = await api.get(`/appointment/admin/user/${userId}`);
   return data.payload;
@@ -90,10 +136,11 @@ export const getAdminCounters = async () => {
   return data.payload;
 };
 
-export const getAdminAppointmentsPaginated = async ({ page = 1, perPage = 10, filter, status } = {}) => {
+export const getAdminAppointmentsPaginated = async ({ page = 1, perPage = 10, filter, status, type } = {}) => {
   const params = { page, per_page: perPage };
   if (filter) params.search = filter;
   if (status) params.status = status;
+  if (type) params.type = type;
   const { data } = await api.get("/appointment/admin/list", { params });
   return { data: { result: data.payload } };
 };

+ 18 - 1
src/api/partnerAgreement.js

@@ -1,5 +1,13 @@
 import api from "src/api";
 
+export const getPartnerAgreementsForSelect = async ({ page = 1, perPage = 20, search, type } = {}) => {
+  const params = { page, per_page: perPage };
+  if (search) params.search = search;
+  if (type)   params.type   = type;
+  const { data } = await api.get("/associado-partner-agreement/paginated", { params });
+  return data.payload;
+};
+
 export const getPartnerAgreements = async ({ type } = {}) => {
   const params = {};
   if (type) params.type = type;
@@ -7,13 +15,14 @@ export const getPartnerAgreements = async ({ type } = {}) => {
   return data.payload;
 };
 
-export const getPartnerAgreementsPaginated = async ({ page = 1, perPage = 10, filter, status, expiresInDays, createdMonth, type, withStatusOptions } = {}) => {
+export const getPartnerAgreementsPaginated = async ({ page = 1, perPage = 10, filter, status, expiresInDays, createdMonth, type, categoryId, withStatusOptions } = {}) => {
   const params = { page, per_page: perPage };
   if (filter)            params.search             = filter;
   if (status)            params.status             = status;
   if (expiresInDays)     params.expires_in_days    = expiresInDays;
   if (createdMonth)      params.created_month      = createdMonth;
   if (type)              params.type               = type;
+  if (categoryId)        params.category_id        = categoryId;
   if (withStatusOptions) params.with_status_options = true;
   const { data } = await api.get("/partner-agreement/paginated", { params });
   return { data: { result: data.payload } };
@@ -101,6 +110,14 @@ export const deletePartnerMedia = async (id, mediaId) => {
 
 // ─── Rotas do Associado ───────────────────────────────────────────────────────
 
+export const getConveniosForSelect = async ({ page = 1, perPage = 20, search, type } = {}) => {
+  const params = { page, per_page: perPage };
+  if (search) params.search = search;
+  if (type)   params.type   = type;
+  const { data } = await api.get("/associado/partner-agreement/paginated", { params });
+  return data.payload;
+};
+
 export const getConvenios = async ({ type } = {}) => {
   const params = {};
   if (type) params.type = type;

+ 8 - 4
src/api/partnerAgreementService.js

@@ -1,7 +1,9 @@
 import api from "src/api";
 
-export const getServicesByConvenio = async (partnerAgreementId) => {
-  const { data } = await api.get(`/associado/partner-agreement-service/partner/${partnerAgreementId}`);
+export const getServicesByConvenio = async (partnerAgreementId, type, status) => {
+  const { data } = await api.get(`/associado/partner-agreement-service/partner/${partnerAgreementId}`, {
+    params: { ...(type ? { type } : {}), ...(status ? { status } : {}) },
+  });
   return data.payload;
 };
 
@@ -10,8 +12,10 @@ export const getConvenioService = async (id) => {
   return data.payload;
 };
 
-export const getServicesByPartner = async (partnerAgreementId) => {
-  const { data } = await api.get(`/partner-agreement-service/partner/${partnerAgreementId}`);
+export const getServicesByPartner = async (partnerAgreementId, type, status) => {
+  const { data } = await api.get(`/partner-agreement-service/partner/${partnerAgreementId}`, {
+    params: { ...(type ? { type } : {}), ...(status ? { status } : {}) },
+  });
   return data.payload;
 };
 

+ 5 - 3
src/api/user.js

@@ -44,9 +44,11 @@ export const deleteMyAvatar = async () => {
   return data.payload;
 };
 
-export const getAssociados = async () => {
-  const users = await getUsers();
-  return users.filter((u) => u.type === "associado");
+export const getAssociadosAtivosPaginated = async ({ page = 1, perPage = 20, search } = {}) => {
+  const params = { page, per_page: perPage, type: "associado", status: "active" };
+  if (search) params.search = search;
+  const { data } = await api.get("/user/paginated", { params });
+  return data.payload;
 };
 
 export const getParceiros = async () => {

+ 68 - 0
src/components/ClinicScheduleNotice.vue

@@ -0,0 +1,68 @@
+<template>
+  <q-banner dense rounded class="schedule-notice">
+    <template #avatar>
+      <q-icon name="mdi-information-outline" color="primary" />
+    </template>
+
+    <div class="schedule-notice__text">{{ text || $t("agendamento.aviso_horarios") }}</div>
+
+    <template v-if="whatsappUrl" #action>
+      <q-btn
+        flat
+        dense
+        no-caps
+        color="positive"
+        icon="mdi-whatsapp"
+        :label="$t('agendamento.falar_whatsapp')"
+        :href="whatsappUrl"
+        target="_blank"
+        rel="noopener"
+      />
+    </template>
+  </q-banner>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+const { partner, text, whatsappMessage } = defineProps({
+  partner: {
+    type: Object,
+    default: null,
+  },
+  text: {
+    type: String,
+    default: null,
+  },
+  whatsappMessage: {
+    type: String,
+    default: null,
+  },
+});
+
+const whatsappUrl = computed(() => {
+  const raw = partner?.whatsapp || partner?.phone;
+  if (!raw) return null;
+
+  const digits = String(raw).replace(/\D/g, "");
+  if (digits.length < 10) return null;
+
+  const url = `https://wa.me/${digits.startsWith("55") ? digits : `55${digits}`}`;
+
+  return whatsappMessage ? `${url}?text=${encodeURIComponent(whatsappMessage)}` : url;
+});
+</script>
+
+<style scoped lang="scss">
+@use "src/css/quasar.variables.scss" as vars;
+
+.schedule-notice {
+  border: 1px solid vars.$color-border;
+  background: vars.$surface;
+}
+
+.schedule-notice__text {
+  font-size: 0.9rem;
+  line-height: 1.4;
+}
+</style>

+ 7 - 2
src/components/defaults/DefaultInputDatePicker.vue

@@ -15,7 +15,7 @@
       >
         <q-popup-proxy cover transition-show="scale" transition-hide="scale">
           <template v-if="!time">
-            <q-date v-model="date" mask="YYYY-MM-DD" color="violet-normal">
+            <q-date v-model="date" mask="YYYY-MM-DD" color="violet-normal" :options="dateOptions">
               <div class="row items-center justify-end">
                 <q-btn v-close-popup label="OK" color="violet-normal" flat />
               </div>
@@ -35,6 +35,7 @@
                   v-model="date"
                   mask="YYYY-MM-DD HH:mm"
                   color="violet-normal"
+                  :options="dateOptions"
                   @update:model-value="handleDateSelection"
                 />
               </q-tab-panel>
@@ -57,7 +58,7 @@ import masks from "src/helpers/masks";
 
 import DefaultInput from "./DefaultInput.vue";
 
-const { label, time } = defineProps({
+const { label, time, dateOptions } = defineProps({
   label: {
     type: String,
     default: () => useI18n().t("common.terms.date"),
@@ -66,6 +67,10 @@ const { label, time } = defineProps({
     type: Boolean,
     default: false,
   },
+  dateOptions: {
+    type: [Array, Function],
+    default: undefined,
+  },
 });
 
 const treatedDate = defineModel({ type: [String, null] });

+ 46 - 23
src/components/selects/AssociadoSelect.vue

@@ -6,16 +6,18 @@
     hide-selected
     fill-input
     clearable
+    input-debounce="400"
     :options="options"
     :label
     :loading
     :placeholder
-    @filter="filterFn"
+    @filter="onFilter"
+    @virtual-scroll="onVirtualScroll"
   >
     <template #no-option>
       <q-item>
         <q-item-section class="text-grey">
-          {{ $t("http.errors.no_records_found") }}
+          {{ loading ? $t("common.status.loading") : $t("http.errors.no_records_found") }}
         </q-item-section>
       </q-item>
     </template>
@@ -23,13 +25,13 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from "vue";
-import { getAssociados } from "src/api/user";
-import { normalizeString } from "src/helpers/utils";
+import { ref, computed } from "vue";
+import { getAssociadosAtivosPaginated } from "src/api/user";
+import { getPartnerExamAssociados } from "src/api/appointment";
 import { useI18n } from "vue-i18n";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
-const { label, placeholder } = defineProps({
+const { label, placeholder, forParceiro } = defineProps({
   label: {
     type: String,
     default: () => useI18n().t("associado.associado"),
@@ -38,35 +40,56 @@ const { label, placeholder } = defineProps({
     type: String,
     default: () => useI18n().t("common.actions.search"),
   },
+  forParceiro: {
+    type: Boolean,
+    default: false,
+  },
 });
 
 const selected = defineModel({ type: Object });
 
-const loading = ref(true);
-const baseOptions = ref([]);
+const PER_PAGE = 20;
+
+const loading = ref(false);
 const options = ref([]);
+const total = ref(0);
+const search = ref("");
 
-const filterFn = (val, update) => {
-  const needle = normalizeString(val);
-  options.value = baseOptions.value.filter((v) =>
-    normalizeString(v.label).includes(needle),
-  );
-  update();
-};
+const hasMore = computed(() => options.value.length < total.value);
 
-onMounted(async () => {
+const fetchAssociados = async ({ reset = false } = {}) => {
+  if (loading.value || (!reset && !hasMore.value)) return;
+
+  loading.value = true;
   try {
-    const associados = await getAssociados();
-    baseOptions.value = associados.map((a) => ({
-      label: a.name,
-      value: a.id,
-      data: a,
+    const page = reset ? 1 : Math.floor(options.value.length / PER_PAGE) + 1;
+    const payload = await (forParceiro ? getPartnerExamAssociados : getAssociadosAtivosPaginated)({
+      page,
+      perPage: PER_PAGE,
+      search: search.value,
+    });
+
+    const mapped = (payload?.data ?? []).map((associado) => ({
+      label: associado.name,
+      value: associado.id,
+      data: associado,
     }));
-    options.value = baseOptions.value;
+
+    options.value = reset ? mapped : options.value.concat(mapped);
+    total.value = payload?.total ?? options.value.length;
   } catch (e) {
     console.error(e);
   } finally {
     loading.value = false;
   }
-});
+};
+
+const onFilter = (val, update) => {
+  search.value = val ?? "";
+  fetchAssociados({ reset: true }).then(() => update());
+};
+
+const onVirtualScroll = ({ to }) => {
+  if (to === options.value.length - 1) fetchAssociados();
+};
 </script>

+ 7 - 2
src/components/selects/DependenteSelect.vue

@@ -21,9 +21,10 @@
 import { ref, watch } from "vue";
 import { useI18n } from "vue-i18n";
 import { getDependentsByUser } from "src/api/profile";
+import { getPartnerExamDependentes } from "src/api/appointment";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
-const { userId, label } = defineProps({
+const { userId, label, forParceiro } = defineProps({
   userId: {
     type: Number,
     default: null,
@@ -32,6 +33,10 @@ const { userId, label } = defineProps({
     type: String,
     default: () => useI18n().t("associado.dependent"),
   },
+  forParceiro: {
+    type: Boolean,
+    default: false,
+  },
 });
 
 const selected = defineModel({ type: Object });
@@ -46,7 +51,7 @@ const loadDependents = async (id) => {
 
   loading.value = true;
   try {
-    const dependents = await getDependentsByUser(id);
+    const dependents = await (forParceiro ? getPartnerExamDependentes(id) : getDependentsByUser(id));
     options.value = dependents
       .filter((d) => d.status === "approved")
       .map((d) => ({ label: d.name, value: d.id, data: d }));

+ 85 - 0
src/components/selects/PartnerAgreementExamsSelect.vue

@@ -0,0 +1,85 @@
+<template>
+  <DefaultSelect
+    v-model="selectedExams"
+    v-bind="$attrs"
+    multiple
+    use-chips
+    :options="examOptions"
+    :label
+    :loading
+    :placeholder
+    :disable="!partnerAgreementId"
+  >
+    <template #option="{ itemProps, opt, selected, toggleOption }">
+      <q-item v-bind="itemProps">
+        <q-item-section side>
+          <q-checkbox :model-value="selected" @update:model-value="toggleOption(opt)" />
+        </q-item-section>
+        <q-item-section>
+          <q-item-label>{{ opt.label }}</q-item-label>
+          <q-item-label v-if="opt.priceLabel" caption>{{ opt.priceLabel }}</q-item-label>
+        </q-item-section>
+      </q-item>
+    </template>
+
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t("http.errors.no_records_found") }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </DefaultSelect>
+</template>
+
+<script setup>
+import { ref, watch } from "vue";
+import { getServicesByPartner } from "src/api/partnerAgreementService";
+import { useI18n } from "vue-i18n";
+import { formatToBRLCurrency } from "src/helpers/utils";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+const { label, placeholder, partnerAgreementId } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("agendamento.exames"),
+  },
+  placeholder: {
+    type: String,
+    default: () => useI18n().t("common.actions.search"),
+  },
+  partnerAgreementId: {
+    type: Number,
+    default: null,
+  },
+});
+
+const selectedExams = defineModel({ type: Array, default: () => [] });
+
+const loading = ref(false);
+const examOptions = ref([]);
+
+watch(
+  () => partnerAgreementId,
+  async (id) => {
+    selectedExams.value = [];
+    examOptions.value = [];
+    if (!id) return;
+    loading.value = true;
+    try {
+      const services = await getServicesByPartner(id, "exame", "active");
+      examOptions.value = services.map((s) => ({
+        label: s.name,
+        value: s.id,
+        priceLabel: formatToBRLCurrency(s.associate_price ?? s.price),
+        data: s,
+      }));
+    } catch (e) {
+      console.error(e);
+    } finally {
+      loading.value = false;
+    }
+  },
+  { immediate: true },
+);
+</script>

+ 39 - 19
src/components/selects/PartnerAgreementSelect.vue

@@ -6,16 +6,18 @@
     hide-selected
     fill-input
     clearable
+    input-debounce="400"
     :options="partnerOptions"
     :label
     :loading
     :placeholder
-    @filter="filterFn"
+    @filter="onFilter"
+    @virtual-scroll="onVirtualScroll"
   >
     <template #no-option>
       <q-item>
         <q-item-section class="text-grey">
-          {{ $t("http.errors.no_records_found") }}
+          {{ loading ? $t("common.status.loading") : $t("http.errors.no_records_found") }}
         </q-item-section>
       </q-item>
     </template>
@@ -23,9 +25,8 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from "vue";
-import { getPartnerAgreements, getConvenios } from "src/api/partnerAgreement";
-import { normalizeString } from "src/helpers/utils";
+import { ref, computed } from "vue";
+import { getPartnerAgreementsForSelect, getConveniosForSelect } from "src/api/partnerAgreement";
 import { useI18n } from "vue-i18n";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
@@ -50,30 +51,49 @@ const { label, placeholder, forAssociado, type } = defineProps({
 
 const selectedPartner = defineModel({ type: Object });
 
-const loading = ref(true);
-const baseOptions = ref([]);
+const PER_PAGE = 20;
+
+const loading = ref(false);
 const partnerOptions = ref([]);
+const total = ref(0);
+const search = ref("");
 
-const filterFn = (val, update) => {
-  const needle = normalizeString(val);
-  partnerOptions.value = baseOptions.value.filter((v) =>
-    normalizeString(v.label).includes(needle),
-  );
-  update();
-};
+const hasMore = computed(() => partnerOptions.value.length < total.value);
+
+const fetchPartners = async ({ reset = false } = {}) => {
+  if (loading.value || (!reset && !hasMore.value)) return;
 
-onMounted(async () => {
+  loading.value = true;
   try {
-    const partners = await (forAssociado ? getConvenios({ type }) : getPartnerAgreements({ type }));
-    baseOptions.value = partners.map((p) => ({
+    const page = reset ? 1 : Math.floor(partnerOptions.value.length / PER_PAGE) + 1;
+    const payload = await (forAssociado ? getConveniosForSelect : getPartnerAgreementsForSelect)({
+      page,
+      perPage: PER_PAGE,
+      search: search.value,
+      type,
+    });
+
+    const mapped = (payload?.data ?? []).map((p) => ({
       label: p.trade_name || p.company_name,
       value: p.id,
+      data: p,
     }));
-    partnerOptions.value = baseOptions.value;
+
+    partnerOptions.value = reset ? mapped : partnerOptions.value.concat(mapped);
+    total.value = payload?.total ?? partnerOptions.value.length;
   } catch (e) {
     console.error(e);
   } finally {
     loading.value = false;
   }
-});
+};
+
+const onFilter = (val, update) => {
+  search.value = val ?? "";
+  fetchPartners({ reset: true }).then(() => update());
+};
+
+const onVirtualScroll = ({ to }) => {
+  if (to === partnerOptions.value.length - 1) fetchPartners();
+};
 </script>

+ 13 - 4
src/components/selects/PartnerAgreementServiceSelect.vue

@@ -2,6 +2,7 @@
   <DefaultSelect
     v-model="selectedService"
     v-bind="$attrs"
+    clearable
     :options="serviceOptions"
     :label
     :loading
@@ -24,7 +25,7 @@ import { getServicesByPartner, getServicesByConvenio } from "src/api/partnerAgre
 import { useI18n } from "vue-i18n";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
-const { label, placeholder, partnerAgreementId, forAssociado } = defineProps({
+const { label, placeholder, partnerAgreementId, forAssociado, type } = defineProps({
   label: {
     type: String,
     default: () => useI18n().t("associado.service"),
@@ -41,23 +42,31 @@ const { label, placeholder, partnerAgreementId, forAssociado } = defineProps({
     type: Boolean,
     default: false,
   },
+  type: {
+    type: String,
+    default: null,
+  },
 });
 
+const ACTIVE_STATUS = "active";
+
 const selectedService = defineModel({ type: Object });
 
 const loading = ref(false);
 const serviceOptions = ref([]);
 
 watch(
-  () => partnerAgreementId,
-  async (id) => {
+  () => [partnerAgreementId, type],
+  async ([id, serviceType]) => {
     const savedSelection = selectedService.value;
     selectedService.value = null;
     serviceOptions.value = [];
     if (!id) return;
     loading.value = true;
     try {
-      const services = await (forAssociado ? getServicesByConvenio(id) : getServicesByPartner(id));
+      const services = await (forAssociado
+        ? getServicesByConvenio(id, serviceType, ACTIVE_STATUS)
+        : getServicesByPartner(id, serviceType, ACTIVE_STATUS));
       serviceOptions.value = services.map((s) => ({
         label: s.name,
         value: s.id,

+ 17 - 0
src/composables/useInputRules.js

@@ -1,4 +1,5 @@
 import { useI18n } from "vue-i18n";
+import { parseLocalDate, addDaysToToday, formatDateBR } from "src/helpers/utils";
 
 export const useInputRules = () => {
   const { t } = useI18n();
@@ -60,6 +61,22 @@ export const useInputRules = () => {
       if (!value) return true;
       return cepPattern.test(value) || t("validation.rules.cep");
     },
+    exactAdvanceDays: (days) => (value) => {
+      if (!value) return t("validation.rules.required");
+
+      const date = parseLocalDate(value);
+      if (!date) return t("validation.rules.date");
+
+      const expected = addDaysToToday(days);
+
+      return (
+        date.getTime() === expected.getTime() ||
+        t("validation.rules.exact_advance_days", {
+          days,
+          date: formatDateBR(expected),
+        })
+      );
+    },
     notSameDocument: (allDocuments) => (value) => {
       if (!value) return true;
       let found = 0;

+ 73 - 2
src/helpers/utils.js

@@ -7,7 +7,6 @@ import { useI18n } from "vue-i18n";
  * @returns {string} string cortada.
  */
 const excerpt = (string, size = 30) => {
-  console.log(size)
   if (size == null) return string;
   if (string.length > size) {
     string = string.substring(0, size) + "...";
@@ -87,7 +86,7 @@ const formatDateYMDtoDMY = (dateTime) => {
  * @returns {string} valor formatado.
  */
 const formatToBRLCurrency = (value) => {
-  if (value != null) {
+  if (value != null && value !== "" && !isNaN(parseFloat(value))) {
     value = parseFloat(value);
     return value.toLocaleString("pt-BR", {
       minimumFractionDigits: 2,
@@ -98,6 +97,73 @@ const formatToBRLCurrency = (value) => {
   return value;
 };
 
+/**
+ * @description Formata a moeda exibindo um placeholder quando não há valor.
+ * @param {number|string|null} value valor.
+ * @param {string} fallback texto exibido quando não há valor.
+ * @returns {string} valor formatado ou o placeholder.
+ */
+const formatToBRLCurrencyOrDash = (value, fallback = "R$ ----") => {
+  const formatted = formatToBRLCurrency(value);
+  return typeof formatted === "string" ? formatted : fallback;
+};
+
+/**
+ * @description Converte "DD/MM/YYYY" ou "YYYY-MM-DD" para Date local à meia-noite.
+ * Evita `new Date(string)`, que interpreta "YYYY-MM-DD" como UTC e erra o dia.
+ * @param {string} value data.
+ * @returns {Date|null} data local ou null se inválida.
+ */
+const parseLocalDate = (value) => {
+  if (!value) return null;
+
+  const datePart = String(value).trim().split(" ")[0];
+  const parts = datePart.includes("/")
+    ? datePart.split("/").reverse()
+    : datePart.split("-");
+
+  if (parts.length !== 3) return null;
+
+  const [year, month, day] = parts.map(Number);
+  if (!year || !month || !day) return null;
+
+  const date = new Date(year, month - 1, day);
+  return isNaN(date) ? null : date;
+};
+
+/**
+ * @description Data de hoje + N dias, à meia-noite local.
+ * @param {number} days quantidade de dias.
+ * @returns {Date} data resultante.
+ */
+const addDaysToToday = (days) => {
+  const date = new Date();
+  date.setHours(0, 0, 0, 0);
+  date.setDate(date.getDate() + days);
+  return date;
+};
+
+/**
+ * @description Formata um Date para "DD/MM/YYYY".
+ * @param {Date} date data.
+ * @returns {string} data formatada.
+ */
+const formatDateBR = (date) =>
+  date.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
+
+/**
+ * @description Formata data + hora do agendamento para exibição.
+ * @param {string} date data "YYYY-MM-DD".
+ * @param {string} time hora "HH:mm" (opcional).
+ * @returns {string} "DD/MM/YYYY HH:mm", "DD/MM/YYYY" ou "—".
+ */
+const formatDateTimeBR = (date, time) => {
+  const parsed = parseLocalDate(date);
+  if (!parsed) return "—";
+
+  return time ? `${formatDateBR(parsed)} ${time}` : formatDateBR(parsed);
+};
+
 const normalizeString = (val) =>
   val
     .toLowerCase()
@@ -130,7 +196,12 @@ export {
   excerpt,
   convertDateTime,
   formatToBRLCurrency,
+  formatToBRLCurrencyOrDash,
   normalizeString,
+  parseLocalDate,
+  addDaysToToday,
+  formatDateBR,
+  formatDateTimeBR,
   getStatusColor,
   getStatusI18nKey,
 };

+ 31 - 4
src/i18n/locales/en.json

@@ -312,7 +312,8 @@
       "cnpj": "This field must be a valid CNPJ",
       "cep": "This field must be a valid ZIP code",
       "value_smaller_than_zero": "Value cannot be less than zero",
-      "code_length": "The code must have 6 digits"
+      "code_length": "The code must have 6 digits",
+      "exact_advance_days": "The appointment must be scheduled exactly {days} days in advance. The only available date is {date}."
     },
     "permissions": {
       "view": "You don't have permission to view this",
@@ -817,7 +818,9 @@
       "scheduling": "Authorized Appointments",
       "completed": "Completed Appointments",
       "not_authorized": "Not Authorized"
-    }
+    },
+    "tab_consultas": "Consultations",
+    "tab_exames": "Exams"
   },
   "agendamento": {
     "associado": "Associate",
@@ -836,7 +839,8 @@
       "confirmado": "Confirmed",
       "recusado": "Rejected",
       "cancelado": "Cancelled",
-      "concluido": "Completed"
+      "concluido": "Completed",
+      "aguardando_aceite": "Awaiting acceptance"
     },
     "col": {
       "pedido": "Order",
@@ -845,7 +849,30 @@
       "solicitacao": "Request",
       "data_hora": "Date/Time",
       "observacoes": "Notes"
-    }
+    },
+    "tipo": "Type",
+    "tipo_consulta": "Consultation",
+    "tipo_exame": "Exam",
+    "exames": "Exams",
+    "total": "Total",
+    "data_opcional": "Date (optional)",
+    "hora_opcional": "Time (optional)",
+    "data_disponivel": "Available date: {date}",
+    "aviso_horarios": "Contact the clinic on WhatsApp to check the available times before choosing a date.",
+    "falar_whatsapp": "Chat on WhatsApp",
+    "nova_guia_exames": "New Exam Guide",
+    "guias_emitidas": "Issued Guides",
+    "gerar_guia_exames": "Generate exam guide",
+    "aviso_aceite_convenio": "The guide is only available after the member accepts the exams. If refused, a new guide must be issued.",
+    "guias_aguardando": "Exam guides awaiting your decision",
+    "guias_aguardando_aviso": "Review the exams the provider prepared for you and choose to accept or refuse.",
+    "aceitar": "Accept",
+    "recusar": "Refuse",
+    "recusar_guia": "Refuse exam guide",
+    "recusar_guia_aviso": "Once refused, this guide can no longer be changed. The provider will need to issue a new one with the adjustments.",
+    "motivo_recusa": "Reason for refusal",
+    "exame_aviso_whatsapp": "To schedule an exam, you must contact the partner directly",
+    "exame_whatsapp_mensagem": "Hello, I am {name}, a SerPrati member, and I would like to schedule an exam for {service}."
   },
   "notification": {
     "empty": "No notifications at the moment",

+ 31 - 4
src/i18n/locales/es.json

@@ -313,7 +313,8 @@
       "cnpj": "Este campo debe ser un CNPJ válido",
       "cep": "Este campo debe ser un código postal válido",
       "value_smaller_than_zero": "El valor no puede ser menor que cero",
-      "code_length": "El código debe tener 6 dígitos"
+      "code_length": "El código debe tener 6 dígitos",
+      "exact_advance_days": "La cita debe agendarse con exactamente {days} días de antelación. La única fecha disponible es {date}."
     },
     "permissions": {
       "view": "No tienes permiso para ver esto",
@@ -817,7 +818,9 @@
       "scheduling": "Citas Autorizadas",
       "completed": "Citas Completadas",
       "not_authorized": "No Autorizados"
-    }
+    },
+    "tab_consultas": "Consultas",
+    "tab_exames": "Exámenes"
   },
   "agendamento": {
     "associado": "Asociado",
@@ -836,7 +839,8 @@
       "confirmado": "Confirmado",
       "recusado": "Rechazado",
       "cancelado": "Cancelado",
-      "concluido": "Completado"
+      "concluido": "Completado",
+      "aguardando_aceite": "Esperando aceptación"
     },
     "col": {
       "pedido": "Pedido",
@@ -845,7 +849,30 @@
       "solicitacao": "Solicitud",
       "data_hora": "Fecha/Hora",
       "observacoes": "Observaciones"
-    }
+    },
+    "tipo": "Tipo",
+    "tipo_consulta": "Consulta",
+    "tipo_exame": "Examen",
+    "exames": "Exámenes",
+    "total": "Total",
+    "data_opcional": "Fecha (opcional)",
+    "hora_opcional": "Hora (opcional)",
+    "data_disponivel": "Fecha disponible: {date}",
+    "aviso_horarios": "Póngase en contacto con la clínica por WhatsApp para consultar los horarios disponibles antes de elegir la fecha.",
+    "falar_whatsapp": "Hablar por WhatsApp",
+    "nova_guia_exames": "Nueva Guía de Exámenes",
+    "guias_emitidas": "Guías Emitidas",
+    "gerar_guia_exames": "Generar guía de exámenes",
+    "aviso_aceite_convenio": "La guía solo está disponible después de que el asociado acepte los exámenes. Si los rechaza, será necesario generar una nueva guía.",
+    "guias_aguardando": "Guías de exámenes esperando su decisión",
+    "guias_aguardando_aviso": "Revise los exámenes que el convenio preparó para usted y elija aceptar o rechazar.",
+    "aceitar": "Aceptar",
+    "recusar": "Rechazar",
+    "recusar_guia": "Rechazar guía de exámenes",
+    "recusar_guia_aviso": "Al rechazar, esta guía ya no podrá modificarse. El convenio deberá generar una nueva guía con los ajustes.",
+    "motivo_recusa": "Motivo del rechazo",
+    "exame_aviso_whatsapp": "Para agendar un examen, es necesario ponerse en contacto directamente con el convenio",
+    "exame_whatsapp_mensagem": "Hola, soy {name}, asociado de SerPrati, y me gustaría agendar un examen para {service}."
   },
   "notification": {
     "empty": "Sin notificaciones en este momento",

+ 31 - 4
src/i18n/locales/pt.json

@@ -313,7 +313,8 @@
       "cnpj": "Este campo deve ser um CNPJ válido",
       "cep": "Este campo deve ser um CEP válido",
       "value_smaller_than_zero": "O valor não pode ser menor que zero",
-      "code_length": "O código deve ter 6 dígitos"
+      "code_length": "O código deve ter 6 dígitos",
+      "exact_advance_days": "O agendamento deve ser feito com exatamente {days} dias de antecedência. A única data disponível é {date}."
     },
     "permissions": {
       "view": "Você não tem permissão para visualizar isto",
@@ -818,7 +819,9 @@
       "scheduling": "Agendamentos Autorizados",
       "completed": "Agendamentos Concluídos",
       "not_authorized": "Não Autorizados"
-    }
+    },
+    "tab_consultas": "Consultas",
+    "tab_exames": "Exames"
   },
   "agendamento": {
     "associado": "Associado",
@@ -837,7 +840,8 @@
       "confirmado": "Confirmado",
       "recusado": "Recusado",
       "cancelado": "Cancelado",
-      "concluido": "Concluído"
+      "concluido": "Concluído",
+      "aguardando_aceite": "Aguardando aceite"
     },
     "col": {
       "pedido": "Pedido",
@@ -846,7 +850,30 @@
       "solicitacao": "Solicitação",
       "data_hora": "Data/Horário",
       "observacoes": "Observações"
-    }
+    },
+    "tipo": "Tipo",
+    "tipo_consulta": "Consulta",
+    "tipo_exame": "Exame",
+    "exames": "Exames",
+    "total": "Total",
+    "data_opcional": "Data (opcional)",
+    "hora_opcional": "Horário (opcional)",
+    "data_disponivel": "Data disponível: {date}",
+    "aviso_horarios": "Entre em contato com a clínica pelo WhatsApp para consultar os horários disponíveis antes de escolher a data.",
+    "falar_whatsapp": "Falar no WhatsApp",
+    "nova_guia_exames": "Nova Guia de Exames",
+    "guias_emitidas": "Guias Emitidas",
+    "gerar_guia_exames": "Gerar guia de exames",
+    "aviso_aceite_convenio": "A guia só fica disponível depois que o associado aceitar os exames. Se ele recusar, será necessário gerar uma nova guia.",
+    "guias_aguardando": "Guias de exame aguardando sua decisão",
+    "guias_aguardando_aviso": "Confira os exames que o convênio montou para você e escolha aceitar ou recusar.",
+    "aceitar": "Aceitar",
+    "recusar": "Recusar",
+    "recusar_guia": "Recusar guia de exames",
+    "recusar_guia_aviso": "Ao recusar, esta guia não poderá mais ser alterada. O convênio precisará gerar uma nova guia com os ajustes.",
+    "motivo_recusa": "Motivo da recusa",
+    "exame_aviso_whatsapp": "Para agendar um exame, é necessário entrar em contato diretamente com o convênio",
+    "exame_whatsapp_mensagem": "Olá, sou {name}, associado da SerPrati, e gostaria de agendar um exame para {service}."
   },
   "notification": {
     "empty": "Nenhuma notificação no momento",

+ 177 - 36
src/pages/agendamentos/AppointmentsAdminPage.vue

@@ -21,6 +21,22 @@
         <q-card-section>
           <q-form ref="formRef" @submit="submitAppointment">
             <div class="row q-col-gutter-sm">
+              <div class="col-12">
+                <div class="q-pl-xs q-mb-sm field-label">{{ $t("agendamento.tipo") }}</div>
+                <q-btn-toggle
+                  v-model="form.type"
+                  no-caps
+                  unelevated
+                  toggle-color="primary"
+                  color="white"
+                  text-color="primary"
+                  :options="[
+                    { label: $t('agendamento.tipo_consulta'), value: 'consulta' },
+                    { label: $t('agendamento.tipo_exame'), value: 'exame' },
+                  ]"
+                />
+              </div>
+
               <AssociadoSelect
                 v-model="form.associado"
                 :label="$t('agendamento.associado')"
@@ -48,29 +64,64 @@
                 :rules="[inputRules.required]"
                 class="col-12 input-violet"
               />
-              <PartnerAgreementServiceSelect
-                v-model="form.service"
-                :partner-agreement-id="form.partner?.value"
-                :label="$t('associado.service')"
-                :rules="[inputRules.required]"
-                class="col-12 input-violet"
-              />
-              <DefaultInputDatePicker
-                v-model:untreated-date="form.date"
-                :label="$t('common.terms.date')"
-                :rules="[inputRules.required]"
-                placeholder="dd/mm/aaaa"
-                lazy-rules
-                class="col-12 col-md-6"
-              />
-              <DefaultInput
-                v-model="form.time"
-                :label="$t('common.terms.hour2')"
-                :rules="[inputRules.required]"
-                mask="##:##"
-                placeholder="HH:MM"
-                class="col-12 col-md-6"
-              />
+              <template v-if="isConsulta">
+                <PartnerAgreementServiceSelect
+                  v-model="form.service"
+                  :partner-agreement-id="form.partner?.value"
+                  type="consulta"
+                  :label="$t('associado.service')"
+                  :rules="[inputRules.required]"
+                  class="col-12 input-violet"
+                />
+
+                <div v-if="form.partner" class="col-12">
+                  <ClinicScheduleNotice :partner="form.partner.data" />
+                </div>
+
+                <DefaultInputDatePicker
+                  v-model:untreated-date="form.date"
+                  :label="$t('common.terms.date')"
+                  :rules="[inputRules.required, inputRules.exactAdvanceDays(ADVANCE_DAYS)]"
+                  :hint="$t('agendamento.data_disponivel', { date: allowedDateLabel })"
+                  :date-options="allowedDateOptions"
+                  placeholder="dd/mm/aaaa"
+                  lazy-rules
+                  class="col-12 col-md-6"
+                />
+                <DefaultInput
+                  v-model="form.time"
+                  :label="$t('common.terms.hour2')"
+                  :rules="[inputRules.required]"
+                  mask="##:##"
+                  placeholder="HH:MM"
+                  class="col-12 col-md-6"
+                />
+              </template>
+
+              <template v-else>
+                <PartnerAgreementExamsSelect
+                  v-model="form.exams"
+                  :partner-agreement-id="form.partner?.value"
+                  :label="$t('agendamento.exames')"
+                  :rules="[inputRules.required]"
+                  class="col-12 input-violet"
+                />
+
+                <DefaultInputDatePicker
+                  v-model:untreated-date="form.date"
+                  :label="$t('agendamento.data_opcional')"
+                  :date-options="allowedDateOptions"
+                  placeholder="dd/mm/aaaa"
+                  class="col-12 col-md-6"
+                />
+                <DefaultInput
+                  v-model="form.time"
+                  :label="$t('agendamento.hora_opcional')"
+                  mask="##:##"
+                  placeholder="HH:MM"
+                  class="col-12 col-md-6"
+                />
+              </template>
               <DefaultInput
                 v-model="form.observations"
                 :label="$t('associado.notes')"
@@ -79,11 +130,18 @@
                 class="col-12"
               />
             </div>
+            <q-banner v-if="!isConsulta" dense rounded class="q-mt-md acceptance-hint">
+              <template #avatar>
+                <q-icon name="mdi-information-outline" color="primary" />
+              </template>
+              {{ $t("agendamento.aviso_aceite_convenio") }}
+            </q-banner>
+
             <div class="q-mt-md flex justify-end">
               <q-btn
                 color="primary"
                 type="submit"
-                :label="$t('agendamento.solicitar')"
+                :label="isConsulta ? $t('agendamento.solicitar') : $t('agendamento.gerar_guia_exames')"
                 :loading="submitting"
               />
             </div>
@@ -133,6 +191,16 @@
         :add-item="false"
         :show-search-field="true"
       >
+        <template #body-cell-type="{ row }">
+          <q-td class="text-center">
+            <q-chip
+              outline
+              size="sm"
+              :color="row.type === 'exame' ? 'info' : 'primary'"
+              :label="$t(`agendamento.tipo_${row.type}`)"
+            />
+          </q-td>
+        </template>
         <template #body-cell-service_name="{ row }">
           <q-td>
             {{ excerpt(row.service_name || '—', 35) }}
@@ -146,7 +214,11 @@
               :color="statusColor(row.status)"
               :label="$t(`agendamento.status.${row.status}`)"
               size="sm"
-            />
+            >
+              <q-tooltip v-if="row.refusal_reason">
+                {{ $t("agendamento.motivo_recusa") }}: {{ row.refusal_reason }}
+              </q-tooltip>
+            </q-chip>
           </q-td>
         </template>
         <template #body-cell-dependent_name="{ row }">
@@ -163,6 +235,7 @@
                 size="sm"
                 :unelevated="row.status === 'confirmado'"
                 :outline="row.status !== 'confirmado'"
+                :disable="!canModerate(row)"
                 :loading="actionId === row.id && actionType === 'approve'"
                 @click.prevent.stop="onApprove(row)"
               />
@@ -174,6 +247,7 @@
                 size="sm"
                 :unelevated="row.status === 'recusado' || row.status === 'cancelado'"
                 :outline="row.status !== 'recusado' && row.status !== 'cancelado'"
+                :disable="!canModerate(row)"
                 :loading="actionId === row.id && actionType === 'reject'"
                 @click.prevent.stop="onReject(row)"
               />
@@ -204,6 +278,16 @@
         :add-item="false"
         :show-search-field="true"
       >
+        <template #body-cell-type="{ row }">
+          <q-td class="text-center">
+            <q-chip
+              outline
+              size="sm"
+              :color="row.type === 'exame' ? 'info' : 'primary'"
+              :label="$t(`agendamento.tipo_${row.type}`)"
+            />
+          </q-td>
+        </template>
         <template #body-cell-service_name="{ row }">
           <q-td>
             {{ excerpt(row.service_name || '—', 35) }}
@@ -250,7 +334,7 @@ import { ref, computed, onMounted, useTemplateRef, nextTick, watch } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { useInputRules } from "src/composables/useInputRules";
-import { excerpt } from "src/helpers/utils";
+import { excerpt, addDaysToToday, formatDateBR } from "src/helpers/utils";
 
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
@@ -260,12 +344,15 @@ import AssociadoSelect from "src/components/selects/AssociadoSelect.vue";
 import DependenteSelect from "src/components/selects/DependenteSelect.vue";
 import PartnerAgreementSelect from "src/components/selects/PartnerAgreementSelect.vue";
 import PartnerAgreementServiceSelect from "src/components/selects/PartnerAgreementServiceSelect.vue";
+import PartnerAgreementExamsSelect from "src/components/selects/PartnerAgreementExamsSelect.vue";
 import ApproveAppointmentDialog from "src/components/ApproveAppointmentDialog.vue";
+import ClinicScheduleNotice from "src/components/ClinicScheduleNotice.vue";
 
 import {
   getAdminCounters,
   getAdminAppointmentsPaginated,
   createAppointment,
+  createAdminExam,
   approveAppointment,
   rejectAppointment,
   downloadAppointmentGuide,
@@ -288,17 +375,32 @@ const counters = ref({
   recusados: undefined,
 });
 
+const ADVANCE_DAYS = 2;
+
 const form = ref({
+  type: "consulta",
   associado: null,
   forDependent: false,
   dependent: null,
   partner: null,
   service: null,
+  exams: [],
   date: "",
   time: "",
   observations: "",
 });
 
+const isConsulta = computed(() => form.value.type === "consulta");
+
+const allowedDateLabel = computed(() => formatDateBR(addDaysToToday(ADVANCE_DAYS)));
+
+const allowedDateOptions = computed(() => {
+  const date = addDaysToToday(ADVANCE_DAYS);
+  const month = String(date.getMonth() + 1).padStart(2, "0");
+  const day = String(date.getDate()).padStart(2, "0");
+  return [`${date.getFullYear()}/${month}/${day}`];
+});
+
 watch(
   () => form.value.forDependent,
   (forDependent) => {
@@ -306,6 +408,16 @@ watch(
   },
 );
 
+watch(
+  () => form.value.type,
+  () => {
+    form.value.service = null;
+    form.value.exams = [];
+    form.value.date = "";
+    form.value.time = "";
+  },
+);
+
 watch(
   () => form.value.associado,
   () => {
@@ -337,6 +449,7 @@ const tabs = computed(() => [
 
 const columnsVisaoGeral = computed(() => [
   { name: "order_number", label: t("agendamento.col.pedido"), field: "order_number", align: "left" },
+  { name: "type", label: t("agendamento.tipo"), field: "type", align: "center" },
   { name: "cracha", label: t("associado.cracha"), field: "registration", align: "left" },
   { name: "user_name", label: t("common.terms.name"), field: "user_name", align: "left" },
   { name: "dependent_name", label: t("associado.dependent"), field: "dependent_name", align: "left" },
@@ -349,6 +462,7 @@ const columnsVisaoGeral = computed(() => [
 
 const columnsAprovados = computed(() => [
   { name: "order_number", label: t("agendamento.col.pedido"), field: "order_number", align: "left" },
+  { name: "type", label: t("agendamento.tipo"), field: "type", align: "center" },
   { name: "user_name", label: t("common.terms.name"), field: "user_name", align: "left" },
   { name: "dependent_name", label: t("associado.dependent"), field: "dependent_name", align: "left" },
   { name: "partner_name", label: t("agendamento.col.parceiro"), field: "partner_name", align: "left" },
@@ -361,6 +475,7 @@ const columnsAprovados = computed(() => [
 const statusColor = (status) => {
   const map = {
     pendente: "warning-light",
+    aguardando_aceite: "warning-light",
     confirmado: "positive",
     recusado: "negative",
     cancelado: "grey-6",
@@ -369,6 +484,8 @@ const statusColor = (status) => {
   return map[status] ?? "grey-6";
 };
 
+const canModerate = (row) => row.status === "pendente";
+
 const apiFetchAll = (params) => getAdminAppointmentsPaginated(params);
 const apiFetchAprovados = (params) =>
   getAdminAppointmentsPaginated({ ...params, status: "confirmado" });
@@ -388,11 +505,13 @@ const loadCounters = async () => {
 
 const resetForm = async () => {
   form.value = {
+    type: form.value.type,
     associado: null,
     forDependent: false,
     dependent: null,
     partner: null,
     service: null,
+    exams: [],
     date: "",
     time: "",
     observations: "",
@@ -404,21 +523,33 @@ const resetForm = async () => {
 const submitAppointment = async () => {
   const valid = await formRef.value?.validate();
   if (!valid) return;
+
+  const base = {
+    user_id: form.value.associado.value,
+    user_dependent_id: form.value.dependent?.value ?? null,
+    partner_agreement_id: form.value.partner.value,
+    date: form.value.date || null,
+    time: form.value.time || null,
+    observations: form.value.observations || null,
+  };
+
   submitting.value = true;
   try {
-    await createAppointment({
-      user_id: form.value.associado.value,
-      user_dependent_id: form.value.dependent?.value ?? null,
-      partner_agreement_id: form.value.partner.value,
-      partner_agreement_service_id: form.value.service.value,
-      time: form.value.time,
-      date: form.value.date,
-      observations: form.value.observations || null,
-    });
+    if (isConsulta.value) {
+      await createAppointment({
+        ...base,
+        partner_agreement_service_id: form.value.service.value,
+      });
+    } else {
+      await createAdminExam({
+        ...base,
+        service_ids: form.value.exams.map((e) => e.value),
+      });
+    }
     await resetForm();
     await loadCounters();
   } catch {
-    // silent
+    // erro já notificado pelo interceptor
   } finally {
     submitting.value = false;
   }
@@ -516,6 +647,16 @@ onMounted(() => {
   font-weight: 700;
 }
 
+.field-label {
+  font-size: 0.9rem;
+}
+
+.acceptance-hint {
+  border: 1px solid vars.$color-border;
+  background: vars.$surface;
+  font-size: 0.85rem;
+}
+
 .counters-row {
   display: flex;
   gap: 12px;

+ 96 - 23
src/pages/associado/agendamentos/AgendamentosPage.vue

@@ -28,20 +28,22 @@
           <q-card-section>
             <q-form ref="appointmentFormRef" @submit="submitAppointment">
               <div class="row q-col-gutter-sm">
-                <div class="col-12">
-                  <q-checkbox
-                    v-model="forDependent"
-                    color="violet-normal"
-                    :label="$t('agendamento.for_dependent_self')"
+                <template v-if="!isExameService">
+                  <div class="col-12">
+                    <q-checkbox
+                      v-model="forDependent"
+                      color="violet-normal"
+                      :label="$t('agendamento.for_dependent_self')"
+                    />
+                  </div>
+                  <DependenteSelect
+                    v-if="forDependent"
+                    v-model="selectedDependent"
+                    :user-id="user.user?.id ?? null"
+                    :rules="[inputRules.required]"
+                    class="col-12 input-violet"
                   />
-                </div>
-                <DependenteSelect
-                  v-if="forDependent"
-                  v-model="selectedDependent"
-                  :user-id="user.user?.id ?? null"
-                  :rules="[inputRules.required]"
-                  class="col-12 input-violet"
-                />
+                </template>
                 <PartnerAgreementSelect
                   v-model="selectedPartner"
                   :label="$t('ui.navigation.convenios')"
@@ -57,15 +59,48 @@
                   class="col-12 input-violet"
                   for-associado
                 />
-                <DefaultInput
-                  v-model="appointmentForm.observations"
-                  :label="$t('associado.notes')"
-                  type="textarea"
-                  autogrow
-                  class="col-12 input-violet"
-                />
+                <div v-if="isExameService" class="col-12">
+                  <ClinicScheduleNotice
+                    :partner="selectedPartner?.data"
+                    :text="$t('agendamento.exame_aviso_whatsapp')"
+                    :whatsapp-message="examWhatsappMessage"
+                  />
+                </div>
+
+                <template v-else>
+                  <div v-if="selectedPartner" class="col-12">
+                    <ClinicScheduleNotice :partner="selectedPartner.data" />
+                  </div>
+
+                  <DefaultInputDatePicker
+                    v-model:untreated-date="appointmentForm.date"
+                    :label="$t('common.terms.date')"
+                    :rules="[inputRules.required, inputRules.exactAdvanceDays(ADVANCE_DAYS)]"
+                    :hint="$t('agendamento.data_disponivel', { date: allowedDateLabel })"
+                    :date-options="allowedDateOptions"
+                    placeholder="dd/mm/aaaa"
+                    lazy-rules
+                    class="col-12 col-md-6 input-violet"
+                  />
+                  <DefaultInput
+                    v-model="appointmentForm.time"
+                    :label="$t('common.terms.hour2')"
+                    :rules="[inputRules.required]"
+                    mask="##:##"
+                    placeholder="HH:MM"
+                    class="col-12 col-md-6 input-violet"
+                  />
+
+                  <DefaultInput
+                    v-model="appointmentForm.observations"
+                    :label="$t('associado.notes')"
+                    type="textarea"
+                    autogrow
+                    class="col-12 input-violet"
+                  />
+                </template>
               </div>
-              <div class="q-mt-md flex justify-end">
+              <div v-if="!isExameService" class="q-mt-md flex justify-end">
                 <q-btn
                   unelevated
                   type="submit"
@@ -105,7 +140,11 @@
                 :color="statusColor(props.row.status)"
                 :label="$t(`agendamento.status.${props.row.status}`)"
                 size="sm"
-              />
+              >
+                <q-tooltip v-if="props.row.refusal_reason">
+                  {{ $t("agendamento.motivo_recusa") }}: {{ props.row.refusal_reason }}
+                </q-tooltip>
+              </q-chip>
             </q-td>
           </template>
 
@@ -165,11 +204,14 @@ import {
   downloadMyAppointmentGuide,
 } from "src/api/appointment";
 import { useInputRules } from "src/composables/useInputRules";
+import { addDaysToToday, formatDateBR } from "src/helpers/utils";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.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 DependenteSelect from "src/components/selects/DependenteSelect.vue";
 import PartnerAgreementServiceSelect from "src/components/selects/PartnerAgreementServiceSelect.vue";
+import ClinicScheduleNotice from "src/components/ClinicScheduleNotice.vue";
 import { userStore } from "src/stores/user";
 const $q = useQuasar();
 const { t } = useI18n();
@@ -201,16 +243,39 @@ const columns = computed(() => [
   { name: "status", label: t("common.terms.status"), field: "status", align: "center" },
 ]);
 
+const ADVANCE_DAYS = 2;
+
 const forDependent = ref(false);
 const selectedDependent = ref(null);
 const selectedPartner = ref(null);
 const selectedService = ref(null);
 const guideId = ref(null);
 
+const allowedDateLabel = computed(() => formatDateBR(addDaysToToday(ADVANCE_DAYS)));
+
+const allowedDateOptions = computed(() => {
+  const date = addDaysToToday(ADVANCE_DAYS);
+  const month = String(date.getMonth() + 1).padStart(2, "0");
+  const day = String(date.getDate()).padStart(2, "0");
+  return [`${date.getFullYear()}/${month}/${day}`];
+});
+
 watch(forDependent, (value) => {
   if (!value) selectedDependent.value = null;
 });
-const appointmentForm = reactive({ observations: "" });
+
+const isExameService = computed(() => {
+  const type = selectedService.value?.data?.type;
+  return (typeof type === "object" ? type?.value : type) === "exame";
+});
+
+const examWhatsappMessage = computed(() =>
+  t("agendamento.exame_whatsapp_mensagem", {
+    name: user.user?.name ?? "",
+    service: selectedService.value?.label ?? "",
+  }),
+);
+const appointmentForm = reactive({ observations: "", date: "", time: "" });
 const submitting = ref(false);
 const editingId = ref(null);
 
@@ -266,10 +331,14 @@ const resetForm = () => {
   selectedPartner.value = null;
   selectedService.value = null;
   appointmentForm.observations = "";
+  appointmentForm.date = "";
+  appointmentForm.time = "";
   editingId.value = null;
 };
 
 const submitAppointment = async () => {
+  if (isExameService.value) return;
+
   const valid = await appointmentFormRef.value?.validate();
   if (!valid) return;
   submitting.value = true;
@@ -277,6 +346,8 @@ const submitAppointment = async () => {
     partner_agreement_id: selectedPartner.value.value,
     partner_agreement_service_id: selectedService.value.value,
     user_dependent_id: selectedDependent.value?.value ?? null,
+    date: appointmentForm.date,
+    time: appointmentForm.time,
     observations: appointmentForm.observations,
   };
   try {
@@ -322,6 +393,8 @@ const onEditAppointment = (apt) => {
     label: apt.partner_agreement_service?.name || "",
   };
   appointmentForm.observations = apt.observations ?? "";
+  appointmentForm.date = apt.date ?? "";
+  appointmentForm.time = apt.time ?? "";
   activeTab.value = "novo";
 };
 </script>

+ 58 - 7
src/pages/parceiros-convenios/AgendamentosParceiroPage.vue

@@ -9,6 +9,18 @@
       </div>
 
       <template v-else>
+        <!-- Guia de exames: só convênio médico cria (mesma regra do menu e da API). -->
+        <div v-if="isConvenioMedico" class="flex justify-end q-mb-md">
+          <q-btn
+            unelevated
+            no-caps
+            color="primary"
+            icon="mdi-plus"
+            :label="$t('agendamento.nova_guia_exames')"
+            @click="onNewExam"
+          />
+        </div>
+
         <div class="counters-row q-mb-md">
           <div class="counter-card">
             <span class="counter-value">
@@ -46,11 +58,22 @@
           hide-pagination
           :rows-per-page-options="[0]"
         >
+          <template #body-cell-tipo="props">
+            <q-td :props="props">
+              <q-chip
+                outline
+                size="sm"
+                :color="typeValue(props.row) === 'exame' ? 'info' : 'primary'"
+                :label="$t(`agendamento.tipo_${typeValue(props.row)}`)"
+              />
+            </q-td>
+          </template>
+
           <template #body-cell-servico="props">
             <q-td :props="props">
-              {{ excerpt(props.row.partner_agreement_service?.name || '—', excerptSize) }}
-              <q-tooltip v-if="props.row.partner_agreement_service?.name">
-                {{ props.row.partner_agreement_service.name }}
+              {{ excerpt(serviceLabel(props.row), excerptSize) }}
+              <q-tooltip v-if="serviceLabel(props.row) !== '—'">
+                {{ serviceLabel(props.row) }}
               </q-tooltip>
             </q-td>
           </template>
@@ -62,7 +85,11 @@
                 :color="statusColor(props.row.status)"
                 :label="$t(`agendamento.status.${props.row.status}`)"
                 size="sm"
-              />
+              >
+                <q-tooltip v-if="props.row.refusal_reason">
+                  {{ $t("agendamento.motivo_recusa") }}: {{ props.row.refusal_reason }}
+                </q-tooltip>
+              </q-chip>
             </q-td>
           </template>
 
@@ -140,9 +167,13 @@ import {
 import { excerpt } from "src/helpers/utils";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import ApproveAppointmentDialog from "src/components/ApproveAppointmentDialog.vue";
+import NovaGuiaExameDialog from "./components/NovaGuiaExameDialog.vue";
+import { userStore } from "src/stores/user";
+import { storeToRefs } from "pinia";
 
 const $q = useQuasar();
 const { t } = useI18n();
+const { isConvenioMedico } = storeToRefs(userStore());
 
 const excerptSize = computed(() => {
   if ($q.screen.lt.md) return 20;
@@ -159,9 +190,10 @@ const actionType = ref(null);
 
 const columns = computed(() => [
   { name: "pedido",     label: t("agendamento.col.pedido"),     field: "order_number",                                                              align: "left" },
+  { name: "tipo",       label: t("agendamento.tipo"),            field: (row) => typeValue(row),                                                     align: "center" },
   { name: "associado",  label: t("agendamento.associado"),       field: (row) => row.user?.name || "—",                                              align: "left" },
   { name: "dependente", label: t("associado.dependent"),         field: (row) => row.user_dependent?.name || "—",                                    align: "left" },
-  { name: "servico",    label: t("agendamento.col.servico"),     field: (row) => row.partner_agreement_service?.name || "—",                        align: "left" },
+  { name: "servico",    label: t("agendamento.col.servico"),     field: (row) => serviceLabel(row),                                                  align: "left" },
   { name: "solicitacao",label: t("agendamento.col.solicitacao"), field: (row) => formatDate(row.created_at),                                         align: "left" },
   { name: "horario",    label: t("common.terms.hour2"),          field: (row) => formatDateTime(row.date, row.time),                                 align: "left" },
   { name: "acoes",      label: t("common.terms.actions"),        field: "id",                                                                        align: "center" },
@@ -181,6 +213,18 @@ const pagedAppointments = computed(() => {
   return appointments.value.slice(start, start + PER_PAGE);
 });
 
+const typeValue = (row) => {
+  const type = typeof row.type === "object" ? row.type?.value : row.type;
+  return type || (row.exams?.length ? "exame" : "consulta");
+};
+
+const serviceLabel = (row) => {
+  if (row.exams?.length) {
+    return row.exams.map((exam) => exam.name).filter(Boolean).join(", ") || "—";
+  }
+  return row.partner_agreement_service?.name || "—";
+};
+
 const statusColor = (status) => {
   const map = { pendente: "warning-light", confirmado: "positive", cancelado: "negative", recusado: "negative", concluido: "grey" };
   return map[status] ?? "grey";
@@ -271,7 +315,8 @@ const onDownloadGuide = async (row) => {
   }
 };
 
-onMounted(async () => {
+const loadAppointments = async () => {
+  loading.value = true;
   try {
     appointments.value = await getPartnerAppointments();
   } catch (e) {
@@ -279,7 +324,13 @@ onMounted(async () => {
   } finally {
     loading.value = false;
   }
-});
+};
+
+const onNewExam = () => {
+  $q.dialog({ component: NovaGuiaExameDialog }).onOk(loadAppointments);
+};
+
+onMounted(loadAppointments);
 </script>
 
 <style lang="scss">

+ 59 - 29
src/pages/parceiros-convenios/ConveniosMedicosPage.vue

@@ -88,13 +88,13 @@
       <q-spinner color="violet-normal" size="50px" />
     </div>
 
-    <div v-else-if="filteredItems.length === 0" class="flex flex-center q-pa-xl text-grey-6">
+    <div v-else-if="allItems.length === 0" class="flex flex-center q-pa-xl text-grey-6">
       {{ $t("http.errors.no_records_found") }}
     </div>
 
     <div v-else class="row q-col-gutter-md">
       <div
-        v-for="item in filteredItems"
+        v-for="item in allItems"
         :key="item.id"
         class="col-xl-2 col-lg-3 col-md-4 col-sm-6 col-12"
       >
@@ -105,19 +105,29 @@
         />
       </div>
     </div>
+
+    <div v-if="totalPages > 1" class="flex flex-center q-mt-lg">
+      <q-pagination
+        v-model="page"
+        :max="totalPages"
+        boundary-numbers
+        color="violet-normal"
+        active-color="violet-normal"
+        direction-links
+      />
+    </div>
   </div>
 </template>
 
 <script setup>
-import { ref, computed, onMounted, useTemplateRef, defineAsyncComponent } from "vue";
+import { ref, computed, watch, onMounted, useTemplateRef, defineAsyncComponent } from "vue";
 import { useRouter } from "vue-router";
-import { useQuasar } from "quasar";
+import { debounce, useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { permissionStore } from "src/stores/permission";
 import { userStore } from "src/stores/user";
-import { getPartnerAgreements, importConveniosMedicos } from "src/api/partnerAgreement";
+import { getPartnerAgreementsPaginated, importConveniosMedicos } from "src/api/partnerAgreement";
 import { getCategories } from "src/api/category";
-import { normalizeString } from "src/helpers/utils";
 import { useImportPoller } from "src/composables/useImportPoller";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import PartnerAgreementCard from "./components/PartnerAgreementCard.vue";
@@ -137,26 +147,16 @@ const { isAdministrador } = userStore();
 
 const loading      = ref(true);
 const allItems     = ref([]);
+const total        = ref(0);
+const page         = ref(1);
+const PER_PAGE     = 24;
 const categories   = ref([]);
 const activeCategory = ref("all");
 const searchQuery  = ref("");
 const importConvenioInput = useTemplateRef("importConvenioInput");
 const { polling: importingConvenios, start: startConvenioPolling } = useImportPoller();
 
-const filteredItems = computed(() => {
-  let list = allItems.value;
-  if (activeCategory.value !== "all") {
-    list = list.filter((p) => String(p.category_id) === activeCategory.value);
-  }
-  if (searchQuery.value) {
-    const needle = normalizeString(searchQuery.value);
-    list = list.filter((p) => {
-      const fields = [p.company_name, p.cnpj, p.responsible, p.email, p.phone, p.category?.name, p.address, p.city?.name];
-      return fields.some((f) => f && normalizeString(String(f)).includes(needle));
-    });
-  }
-  return list;
-});
+const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PER_PAGE)));
 
 const onAddItem = () => {
   if (!permission_store.getAccess("parceiro.convenio", "add")) {
@@ -175,14 +175,44 @@ const onEditItem = (item) => {
 };
 
 const loadItems = async () => {
-  const [items, cats] = await Promise.all([
-    getPartnerAgreements({ type: "agreement" }),
-    getCategories("agreement"),
-  ]);
-  allItems.value    = items;
-  categories.value  = cats;
+  loading.value = true;
+  try {
+    const { data: { result } } = await getPartnerAgreementsPaginated({
+      page: page.value,
+      perPage: PER_PAGE,
+      type: "agreement",
+      filter: searchQuery.value || undefined,
+      categoryId: activeCategory.value === "all" ? undefined : activeCategory.value,
+    });
+    allItems.value = result.data;
+    total.value = result.total;
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
 };
 
+const loadCategories = async () => {
+  try {
+    categories.value = await getCategories("agreement");
+  } catch (e) {
+    console.error(e);
+  }
+};
+
+watch(activeCategory, () => {
+  page.value = 1;
+  loadItems();
+});
+
+watch(searchQuery, debounce(() => {
+  page.value = 1;
+  loadItems();
+}, 400));
+
+watch(page, loadItems);
+
 const onOpenImportHistory = () => {
   $q.dialog({ component: ImportHistoryDialog, componentProps: { type: 'convenio' } });
 };
@@ -233,9 +263,9 @@ const onConvenioFileSelected = async (event) => {
   }
 };
 
-onMounted(async () => {
-  try { await loadItems(); }
-  finally { loading.value = false; }
+onMounted(() => {
+  loadCategories();
+  loadItems();
 });
 </script>
 

+ 37 - 8
src/pages/parceiros-convenios/ParceiroDadosPage.vue

@@ -270,7 +270,12 @@
           </q-form>
         </q-tab-panel>
 
-        <q-tab-panel name="servicos" class="q-pa-none">
+        <q-tab-panel
+          v-for="serviceTab in serviceTabs"
+          :key="serviceTab.name"
+          :name="serviceTab.name"
+          class="q-pa-none"
+        >
           <div class="bg-violet-light q-pb-md">
             <div class="row justify-end q-mb-sm q-gutter-sm">
               <q-btn
@@ -338,6 +343,7 @@ import {
   deleteMyPartnerMedia,
 } from "src/api/partnerAgreement";
 import { getServicesByPartner } from "src/api/partnerAgreementService";
+import { formatToBRLCurrencyOrDash } from "src/helpers/utils";
 import axios from "axios";
 
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
@@ -361,12 +367,34 @@ const partner     = ref(null);
 const partnerId   = computed(() => partner.value?.id ?? null);
 const activeTab   = ref(route.query.tab ?? "dados");
 
+const partnerType = computed(() => {
+  const type = partner.value?.type;
+  return typeof type === "object" ? type?.value : type;
+});
+
+const serviceTabs = computed(() =>
+  partnerType.value === "agreement"
+    ? [
+        { name: "consultas", label: t("parceiro.tab_consultas"), serviceType: "consulta" },
+        { name: "exames",    label: t("parceiro.tab_exames"),    serviceType: "exame" },
+      ]
+    : [{ name: "servicos", label: t("parceiro.tab_servicos"), serviceType: null }],
+);
+
+const activeServiceType = computed(
+  () => serviceTabs.value.find((tab) => tab.name === activeTab.value)?.serviceType ?? null,
+);
+
+const isServiceTab = computed(() =>
+  serviceTabs.value.some((tab) => tab.name === activeTab.value),
+);
+
 const tabsItems = computed(() => [
   { name: "dados",    label: t("parceiro.tab_dados") },
   { name: "contato",  label: t("parceiro.tab_contato") },
   { name: "endereco", label: t("parceiro.tab_endereco") },
   { name: "horario",  label: t("parceiro.tab_horario") },
-  { name: "servicos", label: t("parceiro.tab_servicos") },
+  ...serviceTabs.value,
 ]);
 
 // ─── Dados ───────────────────────────────────────────────────────────────────
@@ -564,8 +592,8 @@ const filteredServices = computed(() => {
 const serviceColumns = computed(() => [
   { name: "name",            label: t("common.terms.name"),         field: "name",            align: "left", sortable: true },
   { name: "category",        label: t("parceiro.service_category"), field: (r) => r.category?.name ?? "—", align: "left" },
-  { name: "price",           label: t("parceiro.price"),            field: "price",            align: "left" },
-  { name: "associate_price", label: t("parceiro.associate_price"),  field: "associate_price",  align: "left" },
+  { name: "price",           label: t("parceiro.price"),            field: (r) => formatToBRLCurrencyOrDash(r.price),           align: "left" },
+  { name: "associate_price", label: t("parceiro.associate_price"),  field: (r) => formatToBRLCurrencyOrDash(r.associate_price), align: "left" },
   { name: "actions",         label: t("common.terms.actions"),      align: "right", required: true },
 ]);
 
@@ -573,7 +601,7 @@ const loadServices = async () => {
   if (!partnerId.value) return;
   loadingServices.value = true;
   try {
-    services.value = await getServicesByPartner(partnerId.value);
+    services.value = await getServicesByPartner(partnerId.value, activeServiceType.value);
   } finally {
     loadingServices.value = false;
   }
@@ -583,6 +611,7 @@ const onAddService = () => {
   router.push({
     name:   "ParceiroDadosServicoPage",
     params: { id: partnerId.value },
+    query:  activeServiceType.value ? { type: activeServiceType.value } : {},
   });
 };
 
@@ -634,7 +663,7 @@ onMounted(async () => {
     const p = await getMyPartnerAgreement();
     partner.value = p;
     populateForms(p);
-    if (activeTab.value === "servicos") await loadServices();
+    if (isServiceTab.value) await loadServices();
   } catch {
     $q.notify({ type: "negative", message: t("http.errors.failed") });
   } finally {
@@ -642,8 +671,8 @@ onMounted(async () => {
   }
 });
 
-watch(activeTab, (tab) => {
-  if (tab === "servicos") loadServices();
+watch(activeTab, () => {
+  if (isServiceTab.value) loadServices();
 });
 </script>
 

+ 14 - 2
src/pages/parceiros-convenios/ParceiroServicoCadastroPage.vue

@@ -186,6 +186,7 @@ const { inputRules } = useInputRules();
 const partnerId = computed(() => Number(route.params.id));
 const serviceId = computed(() => (route.params.serviceId ? Number(route.params.serviceId) : null));
 const isEdit    = computed(() => !!serviceId.value);
+const serviceType = computed(() => route.query.type ?? null);
 
 const loadingPage      = ref(false);
 const loadingCategories = ref(true);
@@ -203,6 +204,7 @@ const form = ref({
   associate_price:      null,
   supplier_price:       null,
   requires_scheduling:  false,
+  type:                 serviceType.value,
 });
 
 const mediaItems      = ref([]);
@@ -240,6 +242,7 @@ const loadService = async () => {
     form.value.associate_price     = svc.associate_price     ?? null;
     form.value.supplier_price      = svc.supplier_price      ?? null;
     form.value.requires_scheduling = svc.requires_scheduling ?? false;
+    form.value.type                = svc.type ?? serviceType.value;
 
     if (Array.isArray(svc.media)) {
       mediaItems.value = svc.media.map((m) => ({ type: "existing", id: m.id, url: m.url }));
@@ -352,14 +355,23 @@ const onSubmit = async () => {
   });
 };
 
+const backTab = computed(() => {
+  const type = form.value.type ?? serviceType.value;
+  const value = typeof type === "object" ? type?.value : type;
+
+  if (value === "consulta") return "consultas";
+  if (value === "exame")    return "exames";
+  return "servicos";
+});
+
 const goBack = () => {
   if (route.name === "ParceiroDadosServicoPage") {
-    router.push({ name: "MeusDadosPage", query: { tab: "servicos" } });
+    router.push({ name: "MeusDadosPage", query: { tab: backTab.value } });
   } else {
     router.push({
       name:   "ParceiroCadastroPage",
       params: { id: partnerId.value },
-      query:  { tab: "servicos" },
+      query:  { tab: backTab.value },
     });
   }
 };

+ 59 - 29
src/pages/parceiros-convenios/ParceirosConveniosPage.vue

@@ -88,13 +88,13 @@
       <q-spinner color="violet-normal" size="50px" />
     </div>
 
-    <div v-else-if="filteredPartners.length === 0" class="flex flex-center q-pa-xl text-grey-6">
+    <div v-else-if="allPartners.length === 0" class="flex flex-center q-pa-xl text-grey-6">
       {{ $t("http.errors.no_records_found") }}
     </div>
 
     <div v-else class="row q-col-gutter-md">
       <div
-        v-for="partner in filteredPartners"
+        v-for="partner in allPartners"
         :key="partner.id"
         class="col-xl-2 col-lg-3 col-md-4 col-sm-6 col-12"
       >
@@ -105,19 +105,29 @@
         />
       </div>
     </div>
+
+    <div v-if="totalPages > 1" class="flex flex-center q-mt-lg">
+      <q-pagination
+        v-model="page"
+        :max="totalPages"
+        boundary-numbers
+        color="violet-normal"
+        active-color="violet-normal"
+        direction-links
+      />
+    </div>
   </div>
 </template>
 
 <script setup>
-import { ref, computed, onMounted, useTemplateRef, defineAsyncComponent } from "vue";
+import { ref, computed, watch, onMounted, useTemplateRef, defineAsyncComponent } from "vue";
 import { useRouter } from "vue-router";
-import { useQuasar } from "quasar";
+import { debounce, useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { permissionStore } from "src/stores/permission";
 import { userStore } from "src/stores/user";
-import { getPartnerAgreements, importParceiros } from "src/api/partnerAgreement";
+import { getPartnerAgreementsPaginated, importParceiros } from "src/api/partnerAgreement";
 import { getCategories } from "src/api/category";
-import { normalizeString } from "src/helpers/utils";
 import { useImportPoller } from "src/composables/useImportPoller";
 
 const ImportHistoryDialog = defineAsyncComponent(
@@ -137,26 +147,16 @@ const { isAdministrador } = userStore();
 
 const loading      = ref(true);
 const allPartners  = ref([]);
+const total        = ref(0);
+const page         = ref(1);
+const PER_PAGE     = 24;
 const categories   = ref([]);
 const activeCategory = ref("all");
 const searchQuery  = ref("");
 const importParceiroInput = useTemplateRef("importParceiroInput");
 const { polling: importingParceiros, start: startParceiroPolling } = useImportPoller();
 
-const filteredPartners = computed(() => {
-  let list = allPartners.value;
-  if (activeCategory.value !== "all") {
-    list = list.filter((p) => String(p.category_id) === activeCategory.value);
-  }
-  if (searchQuery.value) {
-    const needle = normalizeString(searchQuery.value);
-    list = list.filter((p) => {
-      const fields = [p.company_name, p.cnpj, p.responsible, p.email, p.phone, p.category?.name, p.address, p.city?.name];
-      return fields.some((f) => f && normalizeString(String(f)).includes(needle));
-    });
-  }
-  return list;
-});
+const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PER_PAGE)));
 
 const onAddItem = () => {
   if (!permission_store.getAccess("parceiro.convenio", "add")) {
@@ -175,14 +175,44 @@ const onEditItem = (partner) => {
 };
 
 const loadPartners = async () => {
-  const [partners, cats] = await Promise.all([
-    getPartnerAgreements({ type: "partner" }),
-    getCategories("partner"),
-  ]);
-  allPartners.value = partners;
-  categories.value  = cats;
+  loading.value = true;
+  try {
+    const { data: { result } } = await getPartnerAgreementsPaginated({
+      page: page.value,
+      perPage: PER_PAGE,
+      type: "partner",
+      filter: searchQuery.value || undefined,
+      categoryId: activeCategory.value === "all" ? undefined : activeCategory.value,
+    });
+    allPartners.value = result.data;
+    total.value = result.total;
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
 };
 
+const loadCategories = async () => {
+  try {
+    categories.value = await getCategories("partner");
+  } catch (e) {
+    console.error(e);
+  }
+};
+
+watch(activeCategory, () => {
+  page.value = 1;
+  loadPartners();
+});
+
+watch(searchQuery, debounce(() => {
+  page.value = 1;
+  loadPartners();
+}, 400));
+
+watch(page, loadPartners);
+
 const onOpenImportHistory = () => {
   $q.dialog({ component: ImportHistoryDialog, componentProps: { type: 'parceiro' } });
 };
@@ -229,9 +259,9 @@ const onParceiroFileSelected = async (event) => {
   }
 };
 
-onMounted(async () => {
-  try { await loadPartners(); }
-  finally { loading.value = false; }
+onMounted(() => {
+  loadCategories();
+  loadPartners();
 });
 </script>
 

+ 38 - 8
src/pages/parceiros-convenios/components/CadastroFormPanel.vue

@@ -310,7 +310,12 @@
         </q-form>
       </q-tab-panel>
 
-      <q-tab-panel name="servicos" class="q-pa-none">
+      <q-tab-panel
+        v-for="serviceTab in serviceTabs"
+        :key="serviceTab.name"
+        :name="serviceTab.name"
+        class="q-pa-none"
+      >
         <template v-if="!serviceFormVisible">
           <div class="bg-violet-light q-pb-md">
             <div class="row justify-end q-mb-sm q-gutter-sm">
@@ -361,6 +366,7 @@
           v-else
           :partner-id="entityId"
           :service-id="editingServiceId"
+          :service-type="serviceTab.serviceType"
           :show-scheduling="showScheduling"
           @back="onServiceFormBack"
         />
@@ -390,6 +396,7 @@ import {
 } from "src/api/partnerAgreement";
 import { permissionStore } from "src/stores/permission";
 import { getServicesByPartner } from "src/api/partnerAgreementService";
+import { formatToBRLCurrencyOrDash } from "src/helpers/utils";
 import axios from "axios";
 
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
@@ -426,12 +433,31 @@ const pageTitle = computed(() =>
   entityId.value ? t("parceiro.dados_parceiro") : t("parceiro.cadastro_parceiro"),
 );
 
+const isAgreement = computed(() => props.entityType === "agreement");
+
+const serviceTabs = computed(() =>
+  isAgreement.value
+    ? [
+        { name: "consultas", label: t("parceiro.tab_consultas"), serviceType: "consulta" },
+        { name: "exames",    label: t("parceiro.tab_exames"),    serviceType: "exame" },
+      ]
+    : [{ name: "servicos", label: t("parceiro.tab_servicos"), serviceType: null }],
+);
+
+const activeServiceType = computed(
+  () => serviceTabs.value.find((tab) => tab.name === activeTab.value)?.serviceType ?? null,
+);
+
+const isServiceTab = computed(() =>
+  serviceTabs.value.some((tab) => tab.name === activeTab.value),
+);
+
 const tabsItems = computed(() => [
   { name: "dados",    label: t("parceiro.tab_dados") },
   { name: "contato",  label: t("parceiro.tab_contato"),  disable: !entityId.value },
   { name: "endereco", label: t("parceiro.tab_endereco"), disable: !entityId.value },
   { name: "horario",  label: t("parceiro.tab_horario"),  disable: !entityId.value },
-  { name: "servicos", label: t("parceiro.tab_servicos"), disable: !entityId.value },
+  ...serviceTabs.value.map((tab) => ({ ...tab, disable: !entityId.value })),
 ]);
 
 const formDadosRef = useTemplateRef("formDadosRef");
@@ -654,8 +680,8 @@ const filteredServices = computed(() => {
 const serviceColumns = computed(() => [
   { name: "name",            label: t("common.terms.name"),         field: "name",            align: "left", sortable: true },
   { name: "category",        label: t("parceiro.service_category"), field: (r) => r.category?.name ?? "—", align: "left" },
-  { name: "price",           label: t("parceiro.price"),            field: "price",            align: "left" },
-  { name: "associate_price", label: t("parceiro.associate_price"),  field: "associate_price",  align: "left" },
+  { name: "price",           label: t("parceiro.price"),            field: (r) => formatToBRLCurrencyOrDash(r.price),           align: "left" },
+  { name: "associate_price", label: t("parceiro.associate_price"),  field: (r) => formatToBRLCurrencyOrDash(r.associate_price), align: "left" },
   { name: "actions",         label: t("common.terms.actions"),      align: "right", required: true },
 ]);
 
@@ -663,7 +689,7 @@ const loadServices = async () => {
   if (!entityId.value) return;
   loadingServices.value = true;
   try {
-    services.value = await getServicesByPartner(entityId.value);
+    services.value = await getServicesByPartner(entityId.value, activeServiceType.value);
   } finally {
     loadingServices.value = false;
   }
@@ -729,12 +755,16 @@ onMounted(async () => {
     } catch {
       $q.notify({ type: "negative", message: t("http.errors.failed") });
     }
-    if (activeTab.value === "servicos") loadServices();
+    if (isServiceTab.value) loadServices();
   }
 });
 
-watch(activeTab, (tab) => {
-  if (tab === "servicos") loadServices();
+watch(activeTab, () => {
+  if (isServiceTab.value) {
+    serviceFormVisible.value = false;
+    editingServiceId.value = null;
+    loadServices();
+  }
 });
 </script>
 

+ 34 - 0
src/pages/parceiros-convenios/components/NovaGuiaExameDialog.vue

@@ -0,0 +1,34 @@
+<template>
+  <q-dialog ref="dialogRef" persistent @hide="onDialogHide">
+    <q-card class="nova-guia-dialog">
+      <q-card-section class="row items-center justify-between">
+        <div class="text-h6">{{ $t('agendamento.nova_guia_exames') }}</div>
+        <q-btn v-close-popup dense flat round icon="mdi-close" />
+      </q-card-section>
+
+      <q-card-section class="q-pt-none">
+        <NovaGuiaExameForm @created="onDialogOK">
+          <template #actions>
+            <q-btn flat :label="$t('common.actions.cancel')" @click="onDialogCancel" />
+          </template>
+        </NovaGuiaExameForm>
+      </q-card-section>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { useDialogPluginComponent } from "quasar";
+import NovaGuiaExameForm from "./NovaGuiaExameForm.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+</script>
+
+<style scoped lang="scss">
+.nova-guia-dialog {
+  width: 720px;
+  max-width: 90vw;
+}
+</style>

+ 213 - 0
src/pages/parceiros-convenios/components/NovaGuiaExameForm.vue

@@ -0,0 +1,213 @@
+<template>
+  <q-form ref="formRef" @submit="submitExam">
+    <div class="row q-col-gutter-sm">
+      <AssociadoSelect
+        v-model="form.associado"
+        :label="$t('agendamento.associado')"
+        :rules="[inputRules.required]"
+        class="col-12 input-violet"
+        for-parceiro
+      />
+
+      <div class="col-12">
+        <q-checkbox
+          v-model="form.forDependent"
+          color="primary"
+          :disable="!form.associado"
+          :label="$t('agendamento.for_dependent')"
+        />
+      </div>
+
+      <DependenteSelect
+        v-if="form.forDependent"
+        v-model="form.dependent"
+        :user-id="form.associado?.value ?? null"
+        :rules="[inputRules.required]"
+        class="col-12 input-violet"
+        for-parceiro
+      />
+
+      <PartnerAgreementExamsSelect
+        v-model="form.exams"
+        :partner-agreement-id="partnerAgreementId"
+        :label="$t('agendamento.exames')"
+        :rules="[inputRules.required]"
+        class="col-12 input-violet"
+      />
+
+      <div v-if="form.exams.length" class="col-12">
+        <q-card flat bordered class="totals-card">
+          <div
+            v-for="exam in form.exams"
+            :key="exam.value"
+            class="row justify-between totals-card__line"
+          >
+            <span>{{ exam.label }}</span>
+            <span>{{ exam.priceLabel ?? "—" }}</span>
+          </div>
+          <div class="row justify-between totals-card__total">
+            <span>{{ $t("agendamento.total") }}</span>
+            <span>{{ formatToBRLCurrency(examsTotal) }}</span>
+          </div>
+        </q-card>
+      </div>
+
+      <DefaultInputDatePicker
+        v-model:untreated-date="form.date"
+        :label="$t('agendamento.data_opcional')"
+        :date-options="allowedDateOptions"
+        placeholder="dd/mm/aaaa"
+        class="col-12 col-md-6"
+      />
+      <DefaultInput
+        v-model="form.time"
+        :label="$t('agendamento.hora_opcional')"
+        mask="##:##"
+        placeholder="HH:MM"
+        class="col-12 col-md-6"
+      />
+
+      <DefaultInput
+        v-model="form.observations"
+        :label="$t('associado.notes')"
+        type="textarea"
+        autogrow
+        class="col-12"
+      />
+    </div>
+
+    <q-banner dense rounded class="q-mt-md acceptance-hint">
+      <template #avatar>
+        <q-icon name="mdi-information-outline" color="primary" />
+      </template>
+      {{ $t("agendamento.aviso_aceite_convenio") }}
+    </q-banner>
+
+    <div class="q-mt-md flex justify-end" style="gap: 8px">
+      <slot name="actions" />
+      <q-btn
+        color="primary"
+        type="submit"
+        :label="$t('agendamento.gerar_guia_exames')"
+        :loading="submitting"
+      />
+    </div>
+  </q-form>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, nextTick } from "vue";
+import { createPartnerExam } from "src/api/appointment";
+import { getMyPartnerAgreement } from "src/api/partnerAgreement";
+import { formatToBRLCurrency, addDaysToToday } from "src/helpers/utils";
+import { useInputRules } from "src/composables/useInputRules";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import AssociadoSelect from "src/components/selects/AssociadoSelect.vue";
+import DependenteSelect from "src/components/selects/DependenteSelect.vue";
+import PartnerAgreementExamsSelect from "src/components/selects/PartnerAgreementExamsSelect.vue";
+
+const emit = defineEmits(["created"]);
+
+const { inputRules } = useInputRules();
+
+const formRef = ref(null);
+const submitting = ref(false);
+const partnerAgreementId = ref(null);
+
+const ADVANCE_DAYS = 2;
+
+const allowedDateOptions = computed(() => {
+  const date = addDaysToToday(ADVANCE_DAYS);
+  const month = String(date.getMonth() + 1).padStart(2, "0");
+  const day = String(date.getDate()).padStart(2, "0");
+  return [`${date.getFullYear()}/${month}/${day}`];
+});
+
+const emptyForm = () => ({
+  associado: null,
+  forDependent: false,
+  dependent: null,
+  exams: [],
+  date: "",
+  time: "",
+  observations: "",
+});
+
+const form = ref(emptyForm());
+
+const examsTotal = computed(() =>
+  form.value.exams.reduce((sum, exam) => {
+    const service = exam.data ?? {};
+    return sum + Number(service.associate_price ?? service.price ?? 0);
+  }, 0),
+);
+
+const resetForm = async () => {
+  form.value = emptyForm();
+  await nextTick();
+  formRef.value?.resetValidation();
+};
+
+const submitExam = async () => {
+  const valid = await formRef.value?.validate();
+  if (!valid || !form.value.exams.length) return;
+
+  submitting.value = true;
+  try {
+    await createPartnerExam({
+      user_id: form.value.associado.value,
+      user_dependent_id: form.value.dependent?.value ?? null,
+      partner_agreement_id: partnerAgreementId.value,
+      service_ids: form.value.exams.map((e) => e.value),
+      date: form.value.date || null,
+      time: form.value.time || null,
+      observations: form.value.observations || null,
+    });
+    await resetForm();
+    emit("created");
+  } catch {
+    // erro já notificado pelo interceptor
+  } finally {
+    submitting.value = false;
+  }
+};
+
+onMounted(async () => {
+  try {
+    const partner = await getMyPartnerAgreement();
+    partnerAgreementId.value = partner?.id ?? null;
+  } catch (e) {
+    console.error(e);
+  }
+});
+
+defineExpose({ resetForm });
+</script>
+
+<style scoped lang="scss">
+@use "src/css/quasar.variables.scss" as vars;
+
+.totals-card {
+  padding: 12px 16px;
+}
+
+.totals-card__line {
+  font-size: 0.9rem;
+  padding: 2px 0;
+}
+
+.totals-card__total {
+  margin-top: 8px;
+  padding-top: 8px;
+  border-top: 1px solid vars.$color-border;
+  font-weight: 700;
+  color: #661d75;
+}
+
+.acceptance-hint {
+  border: 1px solid vars.$color-border;
+  background: vars.$surface;
+  font-size: 0.85rem;
+}
+</style>

+ 3 - 0
src/pages/parceiros-convenios/components/ServicoFormPanel.vue

@@ -181,6 +181,7 @@ import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.v
 const props = defineProps({
   partnerId:       { type: Number, required: true },
   serviceId:       { type: Number, default: null },
+  serviceType:     { type: String, default: null },
   showScheduling:  { type: Boolean, default: false },
 });
 
@@ -208,6 +209,7 @@ const form = ref({
   associate_price:      null,
   supplier_price:       null,
   requires_scheduling:  false,
+  type:                 props.serviceType,
 });
 
 const mediaItems       = ref([]);
@@ -245,6 +247,7 @@ const loadService = async () => {
     form.value.associate_price     = svc.associate_price     ?? null;
     form.value.supplier_price      = svc.supplier_price      ?? null;
     form.value.requires_scheduling = svc.requires_scheduling ?? false;
+    form.value.type                = svc.type ?? props.serviceType;
 
     if (Array.isArray(svc.media)) {
       mediaItems.value = svc.media.map((m) => ({ type: "existing", id: m.id, url: m.url }));

+ 2 - 1
src/stores/navigation.js

@@ -232,10 +232,11 @@ export const navigationStore = defineStore("navigation", () => {
 
   const getNavigationAccess = () => {
     const { getAccess } = permissionStore();
-    const { userTipo } = userStore();
+    const { userTipo, isConvenioMedico } = userStore();
 
     return navigationStructure
       .filter((menu) => {
+        if (menu.requiresConvenioMedico && !isConvenioMedico) return false;
         if (!menu.allowedTypes || menu.allowedTypes.length === 0) return true;
         return menu.allowedTypes.includes(userTipo);
       })

+ 10 - 0
src/stores/user.js

@@ -16,6 +16,14 @@ export const userStore = defineStore("user", () => {
     return typeof t === "object" ? t.value : t;
   });
 
+  const partnerType = computed(() => {
+    const t = user.value?.partner_type;
+    if (!t) return null;
+    return typeof t === "object" ? t.value : t;
+  });
+
+  const isConvenioMedico = computed(() => isParceiro.value && partnerType.value === "agreement");
+
   const unreadNotificationsCount = computed(() => user.value?.unread_notifications_count ?? 0);
   const hasUnreadNotifications = computed(() => unreadNotificationsCount.value > 0);
 
@@ -45,6 +53,8 @@ export const userStore = defineStore("user", () => {
     isAdministrador,
     isAssociado,
     isParceiro,
+    partnerType,
+    isConvenioMedico,
     userTipo,
     unreadNotificationsCount,
     hasUnreadNotifications,