|
|
@@ -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>
|