Просмотр исходного кода

refactor: fluxo de consultas e exames de agendamentos

Gustavo Zanatta 1 неделя назад
Родитель
Сommit
91fd2fb535

+ 12 - 0
src/api/appointment.js

@@ -77,6 +77,18 @@ export const createAppointment = async (appointment) => {
   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;
+};
+
 export const cancelAppointment = async (id) => {
   const { data } = await api.put(`/associado/appointment/${id}`, { status: "cancelado" });
   return data.payload;

+ 16 - 0
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;
@@ -84,6 +92,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;
 };
 

+ 72 - 0
src/components/ClinicScheduleNotice.vue

@@ -0,0 +1,72 @@
+<template>
+  <div class="schedule-notice">
+    <div class="schedule-notice__head">
+      <q-icon name="mdi-information-outline" size="18px" color="violet-normal" />
+      <span>{{ text || $t("agendamento.aviso_horarios") }}</span>
+    </div>
+
+    <q-btn
+      v-if="whatsappUrl"
+      unelevated
+      dense
+      no-caps
+      color="positive"
+      icon="mdi-whatsapp"
+      size="sm"
+      class="schedule-notice__btn"
+      :label="$t('agendamento.falar_whatsapp')"
+      @click="openUrl(whatsappUrl)"
+    />
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { whatsappUrlFor } from "src/helpers/utils";
+import { openUrl } from "src/helpers/links";
+
+const { partner, text, whatsappMessage } = defineProps({
+  partner: {
+    type: Object,
+    default: null,
+  },
+  text: {
+    type: String,
+    default: null,
+  },
+  whatsappMessage: {
+    type: String,
+    default: null,
+  },
+});
+
+const whatsappUrl = computed(() => whatsappUrlFor(partner, whatsappMessage));
+</script>
+
+<style scoped lang="scss">
+@use "src/css/quasar.variables.scss" as vars;
+
+.schedule-notice {
+  background: vars.$violet-light;
+  border-radius: 10px;
+  padding: 12px 14px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+
+  &__head {
+    display: flex;
+    flex-direction: row;
+    align-items: flex-start;
+    gap: 8px;
+    font-size: 13px;
+    line-height: 1.45;
+    color: vars.$color-text;
+  }
+
+  &__btn {
+    align-self: flex-start;
+    border-radius: 8px;
+  }
+}
+</style>

+ 257 - 0
src/components/PendingExamsDialog.vue

@@ -0,0 +1,257 @@
+<template>
+  <q-dialog ref="dialogRef" persistent @hide="onDialogHide">
+    <q-card class="pending-exams-card">
+      <q-card-section class="q-pb-none">
+        <div class="text-h6 text-violet-normal">{{ $t("agendamento.guias_aguardando") }}</div>
+        <div class="text-body2 text-grey-8 q-mt-xs">
+          {{ $t("agendamento.guias_aguardando_aviso") }}
+        </div>
+      </q-card-section>
+
+      <q-separator class="q-mt-md" />
+
+      <q-card-section class="pending-exams-card__scroll">
+        <div
+          v-for="exam in pending"
+          :key="exam.id"
+          class="exam-block"
+        >
+          <div class="exam-block__header">
+            <span class="exam-block__order">#{{ exam.order_number }}</span>
+            <span class="exam-block__clinic">{{ exam.partner_agreement?.company_name || "—" }}</span>
+          </div>
+
+          <div v-if="exam.user_dependent" class="exam-block__dependent">
+            {{ $t("associado.dependent") }}: {{ exam.user_dependent.name }}
+          </div>
+
+          <div v-if="exam.date" class="exam-block__schedule">
+            <q-icon name="mdi-calendar-clock-outline" size="14px" />
+            <span>{{ formatDateTimeBR(exam.date, exam.time) }}</span>
+          </div>
+
+          <div
+            v-for="item in exam.exams"
+            :key="item.id"
+            class="exam-block__line"
+          >
+            <span>{{ item.name }}</span>
+            <span class="exam-block__price">{{ formatToBRLCurrency(item.service_price) ?? "—" }}</span>
+          </div>
+
+          <div class="exam-block__total">
+            <span>{{ $t("agendamento.total") }}</span>
+            <span>{{ formatToBRLCurrency(exam.service_price) ?? "—" }}</span>
+          </div>
+
+          <div v-if="exam.observations" class="exam-block__notes">
+            {{ exam.observations }}
+          </div>
+
+          <div class="exam-block__actions">
+            <q-btn
+              flat
+              dense
+              no-caps
+              size="sm"
+              color="violet-normal"
+              class="bg-white exam-block__btn"
+              icon="mdi-close-circle-outline"
+              :label="$t('agendamento.recusar')"
+              :disable="busyId === exam.id"
+              @click="onRefuse(exam)"
+            />
+            <q-btn
+              unelevated
+              dense
+              no-caps
+              size="sm"
+              color="violet-normal"
+              text-color="white"
+              class="exam-block__btn"
+              icon="mdi-check-circle-outline"
+              :label="$t('agendamento.aceitar')"
+              :loading="busyId === exam.id"
+              @click="onAccept(exam)"
+            />
+          </div>
+        </div>
+      </q-card-section>
+
+      <q-separator />
+
+      <q-card-actions align="right" class="q-pa-sm">
+        <q-btn
+          flat
+          color="violet-normal"
+          :label="$t('common.actions.close')"
+          :disable="pending.length > 0"
+          @click="onDialogOK()"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useI18n } from "vue-i18n";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { acceptExamAppointment, refuseExamAppointment } from "src/api/appointment";
+import { formatToBRLCurrency, formatDateTimeBR } from "src/helpers/utils";
+import RefuseExamDialog from "src/components/RefuseExamDialog.vue";
+
+const props = defineProps({
+  exams: { type: Array, required: true },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
+const { t } = useI18n();
+const $q = useQuasar();
+
+const pending = ref([...props.exams]);
+const busyId = ref(null);
+
+const removeFromList = (id) => {
+  pending.value = pending.value.filter((e) => e.id !== id);
+  if (pending.value.length === 0) onDialogOK();
+};
+
+const onAccept = async (exam) => {
+  busyId.value = exam.id;
+  try {
+    await acceptExamAppointment(exam.id);
+    removeFromList(exam.id);
+  } catch (e) {
+    $q.notify({ type: "negative", message: e?.response?.data?.message || t("http.errors.failed") });
+  } finally {
+    busyId.value = null;
+  }
+};
+
+const onRefuse = (exam) => {
+  $q.dialog({ component: RefuseExamDialog }).onOk(async ({ reason }) => {
+    busyId.value = exam.id;
+    try {
+      await refuseExamAppointment(exam.id, reason || null);
+      removeFromList(exam.id);
+    } catch (e) {
+      $q.notify({ type: "negative", message: e?.response?.data?.message || t("http.errors.failed") });
+    } finally {
+      busyId.value = null;
+    }
+  });
+};
+</script>
+
+<style scoped lang="scss">
+@use "src/css/quasar.variables.scss" as vars;
+
+.pending-exams-card {
+  width: 90vw;
+  max-width: 560px;
+  min-width: 300px;
+
+  &__scroll {
+    max-height: 60vh;
+    overflow-y: auto;
+  }
+}
+
+.exam-block {
+  background: vars.$violet-light;
+  border-radius: 10px;
+  padding: 12px 14px;
+  margin-bottom: 12px;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+
+  &__header {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    align-items: baseline;
+    gap: 10px;
+    margin-bottom: 6px;
+  }
+
+  &__order {
+    font-size: 12px;
+    font-weight: 700;
+    color: vars.$violet-normal;
+  }
+
+  &__clinic {
+    font-size: 12px;
+    color: vars.$color-text-2;
+    text-align: right;
+  }
+
+  &__dependent {
+    font-size: 12px;
+    color: vars.$color-text-2;
+    margin-bottom: 4px;
+  }
+
+  &__schedule {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    gap: 5px;
+    font-size: 12px;
+    font-weight: 600;
+    color: vars.$violet-normal;
+    margin-bottom: 6px;
+  }
+
+  &__line {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    gap: 10px;
+    font-size: 13px;
+    padding: 2px 0;
+  }
+
+  &__price {
+    flex-shrink: 0;
+    color: vars.$color-text-2;
+  }
+
+  &__total {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    margin-top: 6px;
+    padding-top: 6px;
+    border-top: 1px solid rgba(77, 22, 88, 0.15);
+    font-size: 13px;
+    font-weight: 700;
+    color: vars.$violet-normal;
+  }
+
+  &__notes {
+    margin-top: 8px;
+    font-size: 12px;
+    color: vars.$color-text-2;
+    white-space: pre-wrap;
+  }
+
+  &__actions {
+    display: flex;
+    flex-direction: row;
+    justify-content: flex-end;
+    gap: 8px;
+    margin-top: 10px;
+  }
+
+  &__btn {
+    border-radius: 8px;
+    padding: 3px 12px;
+  }
+}
+</style>

+ 61 - 0
src/components/RefuseExamDialog.vue

@@ -0,0 +1,61 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="refuse-card">
+      <q-card-section class="row items-center q-pb-none">
+        <div class="text-h6 text-violet-normal">{{ $t("agendamento.recusar_guia") }}</div>
+        <q-space />
+        <q-btn icon="mdi-close" flat round dense @click="onDialogCancel" />
+      </q-card-section>
+
+      <q-separator class="q-mt-sm" />
+
+      <q-card-section class="q-pt-md">
+        <div class="text-body2 text-grey-8 q-mb-md">
+          {{ $t("agendamento.recusar_guia_aviso") }}
+        </div>
+
+        <DefaultInput
+          v-model="reason"
+          :label="$t('agendamento.motivo_recusa')"
+          type="textarea"
+          autogrow
+          counter
+          maxlength="500"
+          class="input-violet"
+        />
+      </q-card-section>
+
+      <q-separator />
+
+      <q-card-actions align="right" class="q-pa-sm">
+        <q-btn flat color="grey-7" :label="$t('common.actions.cancel')" @click="onDialogCancel" />
+        <q-btn
+          unelevated
+          color="negative"
+          :label="$t('agendamento.recusar')"
+          @click="onDialogOK({ reason })"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+
+const reason = ref("");
+</script>
+
+<style scoped lang="scss">
+.refuse-card {
+  width: 90vw;
+  max-width: 520px;
+  min-width: 300px;
+}
+</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] });

+ 36 - 2
src/components/defaults/DefaultSelect.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="column no-wrap" :class="attrs.class" :style="attrs.style">
+  <div ref="rootRef" class="column no-wrap default-select" :class="attrs.class" :style="attrs.style">
     <div v-if="label || $slots.label" class="q-pl-xs">
       <slot name="label">
         <span>{{ label }}</span>
@@ -16,6 +16,7 @@
       hide-bottom-space
       :class="inputClass"
       :popup-content-class="popupContentClass"
+      :popup-content-style="popupContentStyle"
       @update:model-value="error = null"
     >
       <template v-for="(_, slotName) in $slots" #[slotName]="scope">
@@ -26,7 +27,7 @@
 </template>
 
 <script setup>
-import { ref, onBeforeMount, useAttrs, computed } from "vue";
+import { ref, onBeforeMount, onMounted, onBeforeUnmount, useAttrs, computed } from "vue";
 
 defineOptions({
   inheritAttrs: false,
@@ -58,6 +59,7 @@ const error = defineModel("error", {
   type: [String, Object, Array, Boolean, null],
 });
 
+const rootRef = ref(null);
 const selectRef = ref(null);
 const required = ref(false);
 
@@ -67,6 +69,27 @@ const selectAttrs = computed(() => {
   return rest;
 });
 
+const menuWidth = ref(null);
+
+const popupContentStyle = computed(() =>
+  menuWidth.value ? { width: menuWidth.value } : void 0,
+);
+
+let resizeObserver = null;
+
+onMounted(() => {
+  if (!rootRef.value || typeof ResizeObserver === "undefined") return;
+  resizeObserver = new ResizeObserver(([entry]) => {
+    menuWidth.value = `${entry.contentRect.width}px`;
+  });
+  resizeObserver.observe(rootRef.value);
+});
+
+onBeforeUnmount(() => {
+  resizeObserver?.disconnect();
+  resizeObserver = null;
+});
+
 const errorMessage = computed(() => {
   if (error.value == null) {
     return void 0;
@@ -87,3 +110,14 @@ defineExpose({
   selectRef,
 });
 </script>
+
+<style scoped lang="scss">
+.default-select {
+  min-width: 0;
+  max-width: 100%;
+
+  :deep(.q-field__native > span) {
+    min-width: 0;
+  }
+}
+</style>

+ 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();
@@ -45,6 +46,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;

+ 3 - 2
src/css/app.scss

@@ -172,10 +172,11 @@ input[type="number"]::-webkit-outer-spin-button {
   overflow-x: hidden !important;
 }
 
-// q-scrollarea__content is position:absolute — min-height: 100% ensures it fills
-// the full visible scroll area so page backgrounds extend to the bottom of the screen
+
 .q-scrollarea__content {
   min-height: 100% !important;
+  width: 100% !important;
+  box-sizing: border-box;
 }
 
 // All pages fill exactly the viewport width — never wider

+ 79 - 0
src/helpers/utils.js

@@ -97,6 +97,80 @@ const formatToBRLCurrency = (value) => {
   return value;
 };
 
+/**
+ * @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);
+};
+
+/**
+ * @description Monta o link de conversa do WhatsApp a partir do telefone do parceiro.
+ * @param {object} partner parceiro/convênio.
+ * @param {string} [message] texto que já abre digitado na conversa.
+ * @returns {string|null} URL wa.me ou null se não houver número utilizável.
+ */
+const whatsappUrlFor = (partner, message) => {
+  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 message ? `${url}?text=${encodeURIComponent(message)}` : url;
+};
+
 const normalizeString = (val) =>
   val
     .toLowerCase()
@@ -126,6 +200,11 @@ export {
   convertDateTime,
   formatToBRLCurrency,
   normalizeString,
+  parseLocalDate,
+  addDaysToToday,
+  formatDateBR,
+  formatDateTimeBR,
+  whatsappUrlFor,
   getStatusColor,
   getStatusI18nKey,
 };

+ 28 - 3
src/i18n/locales/en.json

@@ -314,7 +314,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",
@@ -758,14 +759,38 @@
       "confirmado": "Approved",
       "recusado": "Rejected",
       "cancelado": "Cancelled",
-      "concluido": "Completed"
+      "concluido": "Completed",
+      "aguardando_aceite": "Awaiting acceptance"
     },
     "col": {
       "pedido": "Order",
       "parceiro": "Partner",
       "servico": "Service",
       "solicitacao": "Request Date"
-    }
+    },
+    "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}."
   },
   "associate_validation": {
     "title": "Validate Membership Card",

+ 28 - 3
src/i18n/locales/es.json

@@ -314,7 +314,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",
@@ -758,14 +759,38 @@
       "confirmado": "Aprobado",
       "recusado": "Rechazado",
       "cancelado": "Cancelado",
-      "concluido": "Completado"
+      "concluido": "Completado",
+      "aguardando_aceite": "Esperando aceptación"
     },
     "col": {
       "pedido": "Pedido",
       "parceiro": "Socio",
       "servico": "Servicio",
       "solicitacao": "Solicitud"
-    }
+    },
+    "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}."
   },
   "associate_validation": {
     "title": "Validar Credencial",

+ 28 - 3
src/i18n/locales/pt.json

@@ -314,7 +314,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",
@@ -755,14 +756,38 @@
       "confirmado": "Aprovado",
       "recusado": "Recusado",
       "cancelado": "Cancelado",
-      "concluido": "Concluído"
+      "concluido": "Concluído",
+      "aguardando_aceite": "Aguardando aceite"
     },
     "col": {
       "pedido": "Pedido",
       "parceiro": "Parceiro",
       "servico": "Serviço",
       "solicitacao": "Solicitação"
-    }
+    },
+    "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}."
   },
   "associate_validation": {
     "title": "Validar Carteirinha",

+ 22 - 1
src/layouts/MainLayout.vue

@@ -52,9 +52,11 @@ import { useQuasar } from "quasar";
 import { userStore } from "src/stores/user";
 import { getMyUnreadNotificationsAssociado } from "src/api/notification";
 import { getPendingEvaluationAssociado } from "src/api/evaluation";
+import { getMyAppointments } from "src/api/appointment";
 import UnreadNotificationsDialog from "src/components/UnreadNotificationsDialog.vue";
 import CompleteProfileDialog from "src/components/CompleteProfileDialog.vue";
 import EvaluationDialog from "src/components/EvaluationDialog.vue";
+import PendingExamsDialog from "src/components/PendingExamsDialog.vue";
 
 import LeftMenuLayout from "src/components/layout/LeftMenuLayout.vue";
 import LeftMenuLayoutMobile from "src/components/layout/LeftMenuLayoutMobile.vue";
@@ -90,15 +92,34 @@ const isProfileIncomplete = (user) => {
   );
 };
 
+const checkPendingExams = async () => {
+  try {
+    const appointments = await getMyAppointments();
+    const pending = (appointments ?? []).filter((a) => {
+      const status = typeof a.status === "object" ? a.status?.value : a.status;
+      return status === "aguardando_aceite";
+    });
+
+    if (pending.length > 0) {
+      $q.dialog({ component: PendingExamsDialog, componentProps: { exams: pending } });
+    }
+  } catch {
+    // silent
+  }
+};
+
 const checkUnreadNotifications = async () => {
   try {
     const unread = await getMyUnreadNotificationsAssociado();
     if (unread && unread.length > 0) {
-      $q.dialog({ component: UnreadNotificationsDialog });
+      $q.dialog({ component: UnreadNotificationsDialog }).onOk(() => checkPendingExams());
+      return;
     }
   } catch {
     // silent
   }
+
+  await checkPendingExams();
 };
 
 const checkPendingEvaluation = async () => {

+ 317 - 45
src/pages/associado/agendamentos/AgendamentosPage.vue

@@ -25,18 +25,20 @@
         <q-card v-else flat class="form-card">
           <q-card-section>
             <q-form ref="appointmentFormRef" class="form-column" @submit="submitAppointment">
-              <q-checkbox
-                v-model="forDependent"
-                color="violet-normal"
-                :label="$t('agendamento.for_dependent_self')"
-              />
-              <DependenteSelect
-                v-if="forDependent"
-                v-model="selectedDependent"
-                :user-id="user.user?.id ?? null"
-                :rules="[inputRules.required]"
-                class="input-violet"
-              />
+              <template v-if="!isExameService">
+                <q-checkbox
+                  v-model="forDependent"
+                  color="violet-normal"
+                  :label="$t('agendamento.for_dependent_self')"
+                />
+                <DependenteSelect
+                  v-if="forDependent"
+                  v-model="selectedDependent"
+                  :user-id="user.user?.id ?? null"
+                  :rules="[inputRules.required]"
+                  class="input-violet"
+                />
+              </template>
               <PartnerAgreementSelect
                 v-model="selectedPartner"
                 :label="$t('ui.navigation.convenios')"
@@ -52,22 +54,58 @@
                 class="input-violet"
                 for-associado
               />
-              <DefaultInput
-                v-model="appointmentForm.observations"
-                :label="$t('associado.notes')"
-                type="textarea"
-                autogrow
-                class="input-violet"
+
+              <ClinicScheduleNotice
+                v-if="isExameService"
+                :partner="selectedPartner?.data"
+                :text="$t('agendamento.exame_aviso_whatsapp')"
+                :whatsapp-message="examWhatsappMessage"
+                class="q-my-sm"
               />
-              <div class="flex justify-end q-mt-sm">
-                <q-btn
-                  unelevated
-                  type="submit"
-                  class="btn-gradient"
-                  :label="editingId ? $t('common.actions.save') : $t('associado.schedule')"
-                  :loading="submitting"
+
+              <template v-else>
+                <ClinicScheduleNotice
+                  v-if="selectedPartner"
+                  :partner="selectedPartner.data"
+                  class="q-my-sm"
                 />
-              </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="input-violet"
+                />
+                <DefaultInput
+                  v-model="appointmentForm.time"
+                  :label="$t('common.terms.hour2')"
+                  :rules="[inputRules.required]"
+                  mask="##:##"
+                  placeholder="HH:MM"
+                  class="input-violet"
+                />
+
+                <DefaultInput
+                  v-model="appointmentForm.observations"
+                  :label="$t('associado.notes')"
+                  type="textarea"
+                  autogrow
+                  class="input-violet"
+                />
+                <div class="flex justify-end q-mt-sm">
+                  <q-btn
+                    unelevated
+                    type="submit"
+                    class="btn-gradient"
+                    :label="editingId ? $t('common.actions.save') : $t('associado.schedule')"
+                    :loading="submitting"
+                  />
+                </div>
+              </template>
             </q-form>
           </q-card-section>
         </q-card>
@@ -90,11 +128,19 @@
             class="apmt-card"
           >
             <div class="apmt-card__header">
-              <span class="apmt-card__order">#{{ apt.order_number }}</span>
+              <div class="apmt-card__id">
+                <span class="apmt-card__order">#{{ apt.order_number }}</span>
+                <span
+                  :class="['apmt-card__type', isExame(apt) ? 'apmt-card__type--exame' : 'apmt-card__type--consulta']"
+                >
+                  <q-icon :name="isExame(apt) ? 'mdi-test-tube' : 'mdi-stethoscope'" size="13px" />
+                  {{ isExame(apt) ? $t('agendamento.tipo_exame') : $t('agendamento.tipo_consulta') }}
+                </span>
+              </div>
               <q-chip
                 outline
                 :color="statusColor(apt.status)"
-                :label="$t(`agendamento.status.${apt.status}`)"
+                :label="$t(`agendamento.status.${statusValue(apt.status)}`)"
                 size="sm"
                 dense
                 class="apmt-card__status"
@@ -106,10 +152,39 @@
               <span class="apmt-card__value">{{ apt.partner_agreement?.trade_name || apt.partner_agreement?.company_name || '—' }}</span>
             </div>
 
-            <div class="apmt-card__row">
-              <q-icon name="mdi-briefcase-outline" size="15px" class="apmt-card__icon" />
-              <span class="apmt-card__value">{{ apt.partner_agreement_service?.name || '—' }}</span>
-            </div>
+            <template v-if="isExame(apt)">
+              <div class="apmt-card__exams">
+                <div class="apmt-card__exams-title">
+                  <q-icon name="mdi-test-tube" size="15px" class="apmt-card__icon" />
+                  {{ $t("agendamento.exames") }}
+                </div>
+                <div
+                  v-for="exam in apt.exams"
+                  :key="exam.id"
+                  class="apmt-card__exam-line"
+                >
+                  <span class="apmt-card__value">{{ exam.name }}</span>
+                  <span class="apmt-card__exam-price">{{ formatToBRLCurrency(exam.service_price) ?? '—' }}</span>
+                </div>
+                <div class="apmt-card__exam-total">
+                  <span>{{ $t("agendamento.total") }}</span>
+                  <span>{{ formatToBRLCurrency(apt.service_price) ?? '—' }}</span>
+                </div>
+              </div>
+            </template>
+
+            <template v-else>
+              <div class="apmt-card__row">
+                <q-icon name="mdi-briefcase-outline" size="15px" class="apmt-card__icon" />
+                <span class="apmt-card__value">{{ apt.partner_agreement_service?.name || '—' }}</span>
+              </div>
+
+              <div class="apmt-card__row">
+                <q-icon name="mdi-cash-multiple" size="15px" class="apmt-card__icon" />
+                <span class="apmt-card__label">{{ $t('common.terms.total_amount') }}:</span>
+                <span class="apmt-card__price">{{ consultaPrice(apt) }}</span>
+              </div>
+            </template>
 
             <div v-if="apt.user_dependent" class="apmt-card__row">
               <q-icon name="mdi-account-child-outline" size="15px" class="apmt-card__icon" />
@@ -128,9 +203,37 @@
               <span class="apmt-card__value">{{ formatDate(apt.created_at) }}</span>
             </div>
 
-            <div v-if="apt.status === 'pendente' || apt.can_issue_guide" class="apmt-card__actions">
+            <div v-if="apt.refusal_reason" class="apmt-card__row">
+              <q-icon name="mdi-comment-alert-outline" size="15px" class="apmt-card__icon" />
+              <span class="apmt-card__label">{{ $t('agendamento.motivo_recusa') }}:</span>
+              <span class="apmt-card__value">{{ apt.refusal_reason }}</span>
+            </div>
+
+            <div v-if="hasActions(apt)" class="apmt-card__actions">
+              <q-btn
+                v-if="isAwaitingDecision(apt)"
+                dense
+                flat
+                icon="mdi-close-circle-outline"
+                color="negative"
+                size="sm"
+                :label="$t('agendamento.recusar')"
+                :disable="decisionId === apt.id"
+                @click="onRefuseExam(apt)"
+              />
+              <q-btn
+                v-if="isAwaitingDecision(apt)"
+                dense
+                unelevated
+                icon="mdi-check-circle-outline"
+                color="positive"
+                size="sm"
+                :label="$t('agendamento.aceitar')"
+                :loading="decisionId === apt.id"
+                @click="onAcceptExam(apt)"
+              />
               <q-btn
-                v-if="apt.status === 'pendente'"
+                v-if="canEdit(apt)"
                 dense
                 flat
                 icon="mdi-pencil-outline"
@@ -179,13 +282,19 @@ import {
   getMyAppointments,
   updateAppointment,
   downloadMyAppointmentGuide,
+  acceptExamAppointment,
+  refuseExamAppointment,
 } from "src/api/appointment";
 import { useInputRules } from "src/composables/useInputRules";
+import { formatToBRLCurrency, addDaysToToday, formatDateBR, formatDateTimeBR } 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 RefuseExamDialog from "src/components/RefuseExamDialog.vue";
 import { userStore } from "src/stores/user";
 
 const $q = useQuasar();
@@ -208,16 +317,40 @@ const tabs = computed(() => [
   { name: "meus", label: t("associado.my_appointments") },
 ]);
 
+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 decisionId       = 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}`];
+});
+
+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 ?? "",
+  }),
+);
 
 watch(forDependent, (value) => {
   if (!value) selectedDependent.value = null;
 });
-const appointmentForm  = reactive({ observations: "" });
+const appointmentForm  = reactive({ observations: "", date: "", time: "" });
 const submitting       = ref(false);
 const editingId        = ref(null);
 const appointments     = ref([]);
@@ -230,9 +363,35 @@ const pagedAppointments = computed(() => {
   return appointments.value.slice(start, start + PER_PAGE);
 });
 
+const statusValue = (status) => (typeof status === "object" ? status?.value : status);
+
 const statusColor = (status) => {
-  const map = { pendente: "warning-light", confirmado: "positive", cancelado: "negative", recusado: "negative", concluido: "grey" };
-  return map[status] ?? "grey";
+  const map = {
+    pendente: "warning-light",
+    aguardando_aceite: "warning-light",
+    confirmado: "positive",
+    cancelado: "negative",
+    recusado: "negative",
+    concluido: "grey",
+  };
+  return map[statusValue(status)] ?? "grey";
+};
+
+const typeValue = (apt) => (typeof apt.type === "object" ? apt.type?.value : apt.type);
+const isExame = (apt) => typeValue(apt) === "exame";
+
+const isAwaitingDecision = (apt) => statusValue(apt.status) === "aguardando_aceite";
+
+const canEdit = (apt) => !isExame(apt) && statusValue(apt.status) === "pendente";
+
+const hasActions = (apt) => isAwaitingDecision(apt) || canEdit(apt) || apt.can_issue_guide;
+
+const PRICE_UNDEFINED = "-----";
+
+const consultaPrice = (apt) => {
+  const service = apt.partner_agreement_service;
+  const price = apt.service_price ?? service?.associate_price ?? service?.price;
+  return formatToBRLCurrency(price) ?? PRICE_UNDEFINED;
 };
 
 const formatDate = (isoStr) => {
@@ -242,13 +401,7 @@ const formatDate = (isoStr) => {
   return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
 };
 
-const formatDateTime = (date, time) => {
-  if (!date) return "—";
-  const d = new Date(date + "T00:00:00");
-  if (isNaN(d)) return "—";
-  const dateStr = d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
-  return time ? `${dateStr} ${time}` : dateStr;
-};
+const formatDateTime = formatDateTimeBR;
 
 const loadAppointments = async () => {
   loadingList.value = true;
@@ -272,10 +425,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;
@@ -283,6 +440,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 {
@@ -314,6 +473,32 @@ const onDownloadGuide = async (apt) => {
   }
 };
 
+const onAcceptExam = async (apt) => {
+  decisionId.value = apt.id;
+  try {
+    await acceptExamAppointment(apt.id);
+    await loadAppointments();
+  } catch (e) {
+    $q.notify({ type: "negative", message: e?.response?.data?.message || t("http.errors.failed") });
+  } finally {
+    decisionId.value = null;
+  }
+};
+
+const onRefuseExam = (apt) => {
+  $q.dialog({ component: RefuseExamDialog }).onOk(async ({ reason }) => {
+    decisionId.value = apt.id;
+    try {
+      await refuseExamAppointment(apt.id, reason || null);
+      await loadAppointments();
+    } catch (e) {
+      $q.notify({ type: "negative", message: e?.response?.data?.message || t("http.errors.failed") });
+    } finally {
+      decisionId.value = null;
+    }
+  });
+};
+
 const onEditAppointment = (apt) => {
   editingId.value              = apt.id;
   forDependent.value           = !!apt.user_dependent_id;
@@ -323,6 +508,8 @@ const onEditAppointment = (apt) => {
   selectedPartner.value        = { value: apt.partner_agreement_id, label: apt.partner_agreement?.trade_name || apt.partner_agreement?.company_name || "" };
   selectedService.value        = { value: apt.partner_agreement_service_id, label: apt.partner_agreement_service?.name || "" };
   appointmentForm.observations = apt.observations ?? "";
+  appointmentForm.date         = apt.date ?? "";
+  appointmentForm.time         = apt.time ?? "";
   activeTab.value              = "novo";
 };
 </script>
@@ -418,11 +605,20 @@ const onEditAppointment = (apt) => {
   &__header {
     display: flex;
     flex-direction: row;
-    align-items: center;
+    align-items: flex-start;
     justify-content: space-between;
+    gap: 8px;
     margin-bottom: 4px;
   }
 
+  &__id {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 5px;
+    min-width: 0;
+  }
+
   &__order {
     font-size: 13px;
     font-weight: 700;
@@ -430,8 +626,31 @@ const onEditAppointment = (apt) => {
     letter-spacing: 0.3px;
   }
 
+  &__type {
+    display: inline-flex;
+    flex-direction: row;
+    align-items: center;
+    gap: 4px;
+    padding: 2px 8px;
+    border-radius: 6px;
+    font-size: 11px;
+    font-weight: 600;
+    line-height: 16px;
+
+    &--consulta {
+      background: vars.$violet-light;
+      color: vars.$violet-normal;
+    }
+
+    &--exame {
+      background: #e1f0fa;
+      color: #01579b;
+    }
+  }
+
   &__status {
     font-size: 11px !important;
+    flex-shrink: 0;
   }
 
   &__row {
@@ -463,13 +682,66 @@ const onEditAppointment = (apt) => {
     min-width: 0;
   }
 
+  &__price {
+    font-weight: 700;
+    color: vars.$violet-normal;
+  }
+
   &__actions {
     display: flex;
     flex-direction: row;
+    flex-wrap: wrap;
     justify-content: flex-end;
+    gap: 6px;
     margin-top: 4px;
     border-top: 1px solid vars.$violet-light;
     padding-top: 6px;
   }
+
+  &__exams {
+    display: flex;
+    flex-direction: column;
+    gap: 3px;
+    background: vars.$violet-light;
+    border-radius: 8px;
+    padding: 10px 12px;
+  }
+
+  &__exams-title {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    gap: 6px;
+    font-size: 12px;
+    font-weight: 600;
+    color: vars.$violet-normal;
+    margin-bottom: 2px;
+  }
+
+  &__exam-line {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    gap: 10px;
+    font-size: 13px;
+    color: vars.$color-text;
+  }
+
+  &__exam-price {
+    flex-shrink: 0;
+    color: vars.$color-text-2;
+  }
+
+  &__exam-total {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    margin-top: 6px;
+    padding-top: 6px;
+    border-top: 1px solid rgba(77, 22, 88, 0.15);
+    font-size: 13px;
+    font-weight: 700;
+    color: vars.$violet-normal;
+  }
 }
 </style>

+ 23 - 0
src/pages/associado/convenios/components/PartnerAgreementCard.vue

@@ -49,6 +49,19 @@
         <span>{{ partner.phone ?? '--' }}</span>
       </div>
 
+      <q-btn
+        v-if="whatsappUrl"
+        unelevated
+        dense
+        no-caps
+        size="sm"
+        color="positive"
+        icon="mdi-whatsapp"
+        class="partner-card__whatsapp"
+        :label="$t('agendamento.falar_whatsapp')"
+        @click.stop="openUrl(whatsappUrl)"
+      />
+
       <!-- Footer: rating + validity -->
       <div class="partner-card__footer">
         <div class="partner-card__rating">
@@ -67,6 +80,8 @@
 <script setup>
 import { computed } from "vue";
 import { date } from "quasar";
+import { whatsappUrlFor } from "src/helpers/utils";
+import { openUrl } from "src/helpers/links";
 
 defineEmits(["edit"]);
 
@@ -80,12 +95,20 @@ const addressLine = computed(() => {
   return parts.length ? parts.join(", ") : "--";
 });
 
+const whatsappUrl = computed(() => whatsappUrlFor(partner));
+
 const formatDate = (d) => (d ? date.formatDate(d, "MM/YYYY") : "—");
 </script>
 
 <style lang="scss" scoped>
 @use "src/css/quasar.variables.scss" as vars;
 
+.partner-card__whatsapp {
+  align-self: flex-start;
+  border-radius: 8px;
+  margin-top: 4px;
+}
+
 .partner-card {
   width: 100%;
   box-sizing: border-box;