AgendamentosPage.vue 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. <template>
  2. <div>
  3. <DefaultHeaderPage />
  4. <div class="q-pa-md">
  5. <div class="row q-gutter-xs q-mb-md flex-wrap">
  6. <div
  7. v-for="tab in tabs"
  8. :key="tab.name"
  9. :class="['cat-chip', activeTab === tab.name ? 'cat-chip--selected' : 'cat-chip--default']"
  10. @click="activeTab = tab.name"
  11. >
  12. {{ tab.label }}
  13. </div>
  14. </div>
  15. <!-- Tab: Nova Solicitação -->
  16. <div v-if="activeTab === 'novo'" class="q-mt-md">
  17. <q-banner v-if="isOnLeave" rounded class="bg-orange-1 text-orange-9 q-mb-md">
  18. <template #avatar>
  19. <q-icon name="mdi-account-off-outline" color="orange" />
  20. </template>
  21. {{ $t('associado.on_leave_appointment') }}
  22. </q-banner>
  23. <q-card v-if="!isOnLeave" flat bordered>
  24. <q-card-section>
  25. <q-form ref="appointmentFormRef" @submit="submitAppointment">
  26. <div class="row q-col-gutter-sm">
  27. <PartnerAgreementSelect
  28. v-model="selectedPartner"
  29. :label="$t('ui.navigation.convenios')"
  30. :rules="[inputRules.required]"
  31. class="col-12 input-violet"
  32. for-associado
  33. />
  34. <PartnerAgreementServiceSelect
  35. v-model="selectedService"
  36. :partner-agreement-id="selectedPartner?.value"
  37. :label="$t('associado.service')"
  38. :rules="[inputRules.required]"
  39. class="col-12 input-violet"
  40. for-associado
  41. />
  42. <DefaultInput
  43. v-model="appointmentForm.observations"
  44. :label="$t('associado.notes')"
  45. type="textarea"
  46. autogrow
  47. class="col-12 input-violet"
  48. />
  49. </div>
  50. <div class="q-mt-md flex justify-end">
  51. <q-btn
  52. unelevated
  53. type="submit"
  54. class="btn-gradient"
  55. :label="editingId ? $t('common.actions.save') : $t('associado.schedule')"
  56. :loading="submitting"
  57. />
  58. </div>
  59. </q-form>
  60. </q-card-section>
  61. </q-card>
  62. </div>
  63. <!-- Tab: Meus Agendamentos -->
  64. <div v-else class="q-mt-md">
  65. <div v-if="loadingList" class="flex flex-center q-py-xl">
  66. <q-spinner color="violet-normal" size="48px" />
  67. </div>
  68. <div v-else-if="appointments.length === 0" class="text-center text-grey q-py-xl">
  69. {{ $t("http.errors.no_records_found") }}
  70. </div>
  71. <q-table
  72. v-else
  73. class="softpar-table q-pa-sm"
  74. :rows="pagedAppointments"
  75. :columns="columns"
  76. row-key="id"
  77. hide-pagination
  78. :rows-per-page-options="[0]"
  79. >
  80. <template #body-cell-status="props">
  81. <q-td :props="props">
  82. <q-chip
  83. outline
  84. :color="statusColor(props.row.status)"
  85. :label="$t(`agendamento.status.${props.row.status}`)"
  86. size="sm"
  87. />
  88. </q-td>
  89. </template>
  90. <template #body-cell-acoes="props">
  91. <q-td :props="props">
  92. <q-btn
  93. dense
  94. round
  95. flat
  96. icon="mdi-pencil"
  97. color="violet-normal"
  98. size="sm"
  99. :disable="props.row.status !== 'pendente'"
  100. @click="onEditAppointment(props.row)"
  101. />
  102. <q-btn
  103. dense
  104. round
  105. flat
  106. icon="mdi-calendar"
  107. color="violet-normal"
  108. size="sm"
  109. disable
  110. />
  111. </q-td>
  112. </template>
  113. </q-table>
  114. <div v-if="totalPages > 1" class="flex flex-center q-mt-lg">
  115. <q-pagination
  116. v-model="currentPage"
  117. :max="totalPages"
  118. boundary-numbers
  119. color="violet-normal"
  120. active-color="violet-normal"
  121. direction-links
  122. />
  123. </div>
  124. </div>
  125. </div>
  126. </div>
  127. </template>
  128. <script setup>
  129. import { ref, reactive, computed, watch, useTemplateRef } from "vue";
  130. import { useI18n } from "vue-i18n";
  131. import { createAppointment, getMyAppointments, updateAppointment } from "src/api/appointment";
  132. import { useInputRules } from "src/composables/useInputRules";
  133. import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
  134. import DefaultInput from "src/components/defaults/DefaultInput.vue";
  135. import PartnerAgreementSelect from "src/components/selects/PartnerAgreementSelect.vue";
  136. import PartnerAgreementServiceSelect from "src/components/selects/PartnerAgreementServiceSelect.vue";
  137. import { userStore } from "src/stores/user";
  138. const { t } = useI18n();
  139. const { inputRules } = useInputRules();
  140. const appointmentFormRef = useTemplateRef("appointmentFormRef");
  141. const user = userStore();
  142. const isOnLeave = computed(() => {
  143. const s = user.user?.status;
  144. return s === "on_leave" || s?.value === "on_leave";
  145. });
  146. const activeTab = ref("novo");
  147. const PER_PAGE = 12;
  148. const currentPage = ref(1);
  149. const tabs = computed(() => [
  150. { name: "novo", label: t("associado.new_appointment") },
  151. { name: "meus", label: t("associado.my_appointments") },
  152. ]);
  153. const columns = computed(() => [
  154. { name: "pedido", label: t("agendamento.col.pedido"), field: "order_number", align: "left" },
  155. { name: "parceiro", label: t("agendamento.col.parceiro"), field: (row) => row.partner_agreement?.trade_name || row.partner_agreement?.company_name || "—", align: "left" },
  156. { name: "servico", label: t("agendamento.col.servico"), field: (row) => row.partner_agreement_service?.name || "—", align: "left" },
  157. { name: "solicitacao", label: t("agendamento.col.solicitacao"), field: (row) => formatDate(row.created_at), align: "left" },
  158. { name: "horario", label: t("common.terms.hour2"), field: (row) => formatDateTime(row.date, row.time), align: "left" },
  159. { name: "acoes", label: t("common.terms.actions"), field: "id", align: "center" },
  160. { name: "status", label: t("common.terms.status"), field: "status", align: "center" },
  161. ]);
  162. const selectedPartner = ref(null);
  163. const selectedService = ref(null);
  164. const appointmentForm = reactive({ observations: "" });
  165. const submitting = ref(false);
  166. const editingId = ref(null);
  167. const appointments = ref([]);
  168. const loadingList = ref(false);
  169. const totalPages = computed(() => Math.max(1, Math.ceil(appointments.value.length / PER_PAGE)));
  170. const pagedAppointments = computed(() => {
  171. const start = (currentPage.value - 1) * PER_PAGE;
  172. return appointments.value.slice(start, start + PER_PAGE);
  173. });
  174. const statusColor = (status) => {
  175. const map = { pendente: "warning-light", confirmado: "positive", cancelado: "negative", recusado: "negative", concluido: "grey" };
  176. return map[status] ?? "grey";
  177. };
  178. const formatDate = (isoStr) => {
  179. if (!isoStr) return "—";
  180. const d = new Date(isoStr);
  181. if (isNaN(d)) return "—";
  182. return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
  183. };
  184. const formatDateTime = (date, time) => {
  185. if (!date) return "—";
  186. const d = new Date(date + "T00:00:00");
  187. if (isNaN(d)) return "—";
  188. const dateStr = d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
  189. return time ? `${dateStr} ${time}` : dateStr;
  190. };
  191. const loadAppointments = async () => {
  192. loadingList.value = true;
  193. currentPage.value = 1;
  194. try {
  195. appointments.value = await getMyAppointments();
  196. } catch (e) {
  197. console.error(e);
  198. } finally {
  199. loadingList.value = false;
  200. }
  201. };
  202. watch(activeTab, (val) => {
  203. if (val === "meus") loadAppointments();
  204. });
  205. const resetForm = () => {
  206. selectedPartner.value = null;
  207. selectedService.value = null;
  208. appointmentForm.observations = "";
  209. editingId.value = null;
  210. };
  211. const submitAppointment = async () => {
  212. const valid = await appointmentFormRef.value?.validate();
  213. if (!valid) return;
  214. submitting.value = true;
  215. const payload = {
  216. partner_agreement_id: selectedPartner.value.value,
  217. partner_agreement_service_id: selectedService.value.value,
  218. observations: appointmentForm.observations,
  219. };
  220. try {
  221. if (editingId.value) {
  222. await updateAppointment(editingId.value, payload);
  223. } else {
  224. payload.user_id = user.user.id;
  225. await createAppointment(payload);
  226. }
  227. resetForm();
  228. activeTab.value = "meus";
  229. } catch {
  230. // silent
  231. } finally {
  232. submitting.value = false;
  233. }
  234. };
  235. const onEditAppointment = (apt) => {
  236. editingId.value = apt.id;
  237. selectedPartner.value = {
  238. value: apt.partner_agreement_id,
  239. label: apt.partner_agreement?.trade_name || apt.partner_agreement?.company_name || "",
  240. };
  241. selectedService.value = {
  242. value: apt.partner_agreement_service_id,
  243. label: apt.partner_agreement_service?.name || "",
  244. };
  245. appointmentForm.observations = apt.observations ?? "";
  246. activeTab.value = "novo";
  247. };
  248. </script>
  249. <style lang="scss">
  250. @import "src/css/table.scss";
  251. </style>
  252. <style scoped lang="scss">
  253. .cat-chip {
  254. display: inline-flex;
  255. align-items: center;
  256. justify-content: center;
  257. height: 28px;
  258. padding: 0 12px;
  259. border-radius: 5px;
  260. font-size: 12px;
  261. font-weight: 500;
  262. cursor: pointer;
  263. user-select: none;
  264. transition: background 0.15s, color 0.15s;
  265. &--default { background: #c9a3dc; color: #fff; }
  266. &--selected { background: #4d1658; color: #fff; }
  267. }
  268. .btn-gradient {
  269. background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
  270. color: white !important;
  271. border-radius: 8px !important;
  272. :deep(.q-icon) { color: white !important; }
  273. }
  274. </style>