SchedulingDialog.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. <template>
  2. <q-dialog ref="dialogRef" persistent maximized transition-show="slide-up" transition-hide="slide-down">
  3. <div class="dialog-root">
  4. <div class="dialog-header row items-center q-px-md q-pt-md q-pb-sm bg-white">
  5. <q-btn v-close-popup flat round dense icon="mdi-chevron-left" color="primary" />
  6. <div class="col text-center text-subtitle1 text-weight-bold text-primary gradient-diarista q-mb-xs">
  7. {{ $t('scheduling_page.title') }}
  8. </div>
  9. <div style="width: 36px" />
  10. </div>
  11. <div class="dialog-body">
  12. <div class="q-px-md q-pt-md">
  13. <div class="text-h6 text-weight-bold gradient-diarista q-mb-xs">
  14. {{ $t('scheduling_page.about_provider') }}
  15. </div>
  16. <q-card class="card-border shadow-card bg-surface text-text" :flat="false">
  17. <q-card-section class="q-pa-md">
  18. <div class="row items-center no-wrap q-gutter-x-md">
  19. <q-avatar :style="avatarStyle" size="52px" class="text-weight-bold text-body1">
  20. {{ provider?.provider_name?.slice(0, 1).toUpperCase() ?? '—' }}
  21. </q-avatar>
  22. <div class="col min-width-0">
  23. <div class="text-weight-bold text-text">{{ provider?.provider_name ?? '—' }}</div>
  24. <div class="text-caption text-grey-6">{{ provider?.district ?? '—' }}</div>
  25. <div class="row items-center q-gutter-x-md q-mt-xs">
  26. <div class="row items-center">
  27. <q-icon name="mdi-star" color="warning" size="14px" />
  28. <span class="text-caption text-weight-medium q-ml-xs">
  29. {{ (provider?.average_rating != null ? Number(provider.average_rating).toFixed(1) : '') + ' (' + (provider?.total_reviews ?? 0) + ')' }}
  30. </span>
  31. </div>
  32. <div class="row items-center">
  33. <q-icon name="mdi-broom" color="secondary" size="14px" />
  34. <span class="text-caption q-ml-xs">{{ provider?.total_services ?? 0 }}</span>
  35. </div>
  36. </div>
  37. </div>
  38. <div class="column items-center q-gutter-y-xs">
  39. <q-btn flat round dense icon="mdi-heart-outline" color="pink-4" size="sm" />
  40. <q-btn flat round dense icon="mdi-information-outline" color="grey-5" size="sm" />
  41. </div>
  42. </div>
  43. </q-card-section>
  44. </q-card>
  45. </div>
  46. <div class="q-px-md q-pt-lg">
  47. <div class="text-h6 text-weight-bold gradient-diarista q-mb-xs">
  48. {{ $t('scheduling_page.schedule_service') }}
  49. </div>
  50. <div v-if="loadingAvailability" class="row items-center justify-center q-py-lg">
  51. <q-spinner-dots color="primary" size="36px" />
  52. </div>
  53. <div v-else class="calendar-wrapper shadow-card q-mb-md">
  54. <q-date
  55. v-model="selectedDate"
  56. square
  57. class="full-width"
  58. :first-day-of-week="0"
  59. :options="dateOptions"
  60. minimal
  61. @update:model-value="onDateSelected"
  62. />
  63. </div>
  64. </div>
  65. <div class="q-px-md q-pt-sm q-pb-xl">
  66. <div class="row items-center justify-between q-mb-sm">
  67. <div class="text-h6 text-weight-bold gradient-diarista">
  68. {{ $t('scheduling_page.reviews_title') }}
  69. </div>
  70. <span class="text-caption text-primary cursor-pointer">
  71. {{ $t('scheduling_page.see_all') }}
  72. </span>
  73. </div>
  74. <div v-if="loadingReviews" class="row items-center justify-center q-py-md">
  75. <q-spinner-dots color="primary" size="36px" />
  76. </div>
  77. <div v-else-if="reviews.length === 0" class="text-center text-grey-6 text-body2 q-py-md">
  78. {{ $t('scheduling_page.no_reviews') }}
  79. </div>
  80. <div v-else class="reviews-scroll">
  81. <q-card
  82. v-for="review in reviews"
  83. :key="review.id"
  84. class="review-card card-border bg-white q-mr-sm shadow-card"
  85. :flat="false"
  86. >
  87. <q-card-section class="q-pa-sm">
  88. <div class="row items-center no-wrap q-gutter-x-sm q-mb-xs">
  89. <q-avatar size="32px" :style="clientAvatarStyle(review)" class="text-weight-bold text-caption">
  90. {{ review.schedule?.client?.name?.slice(0, 1).toUpperCase() ?? '?' }}
  91. </q-avatar>
  92. <div class="col text-weight-medium text-text text-caption ellipsis">
  93. {{ review.schedule?.client?.name ?? $t('scheduling_page.unknown_client') }}
  94. </div>
  95. </div>
  96. <div class="row items-center q-mb-xs">
  97. <q-icon
  98. v-for="s in 5"
  99. :key="s"
  100. :name="s <= review.stars ? 'mdi-star' : 'mdi-star-outline'"
  101. color="warning"
  102. size="14px"
  103. />
  104. </div>
  105. <div class="text-caption text-text review-comment">
  106. {{ review.comment ?? '' }}
  107. </div>
  108. </q-card-section>
  109. </q-card>
  110. </div>
  111. </div>
  112. </div>
  113. </div>
  114. </q-dialog>
  115. </template>
  116. <script setup>
  117. import { ref, computed, onMounted } from 'vue';
  118. import { useDialogPluginComponent, useQuasar } from 'quasar';
  119. import { date } from 'quasar';
  120. import { getProviderWorkingDays, getProviderBlockedDays } from 'src/api/providerAvailability';
  121. import { getProviderReceivedReviews } from 'src/api/review';
  122. import ServiceSelectionSheet from './ServiceSelectionSheet.vue';
  123. import ServiceTimeSelectionDialog from './ServiceTimeSelectionDialog.vue';
  124. import OrderSummaryDialog from './OrderSummaryDialog.vue';
  125. const props = defineProps({
  126. provider: {
  127. type: Object,
  128. required: true,
  129. },
  130. });
  131. defineEmits([...useDialogPluginComponent.emits]);
  132. const { dialogRef } = useDialogPluginComponent();
  133. const $q = useQuasar();
  134. const onDateSelected = (val) => {
  135. if (!val) return;
  136. selectedDate.value = null;
  137. $q.dialog({
  138. component: ServiceSelectionSheet,
  139. componentProps: { provider: props.provider, selectedDate: val },
  140. }).onOk(({ serviceType, date: date_, provider: prov }) => {
  141. $q.dialog({
  142. component: ServiceTimeSelectionDialog,
  143. componentProps: { serviceType, selectedDate: date_, provider: prov },
  144. }).onOk((booking) => {
  145. $q.dialog({
  146. component: OrderSummaryDialog,
  147. componentProps: { provider: props.provider, initialBooking: booking },
  148. });
  149. });
  150. });
  151. };
  152. const selectedDate = ref(null);
  153. const workingDays = ref([]);
  154. const blockedDays = ref([]);
  155. const loadingAvailability = ref(true);
  156. const reviews = ref([]);
  157. const loadingReviews = ref(true);
  158. const avatarColors = [
  159. { background: '#ffd5df', color: '#932e57' },
  160. { background: '#d7e8ff', color: '#2158a8' },
  161. { background: '#dfd', color: '#2a7a3b' },
  162. { background: '#ffe5cc', color: '#8a4500' },
  163. ];
  164. const avatarStyle = computed(() => {
  165. const idx = (props.provider?.provider_id ?? 0) % avatarColors.length;
  166. return avatarColors[idx];
  167. });
  168. const clientAvatarStyle = (review) => {
  169. const idx = (review.id ?? 0) % avatarColors.length;
  170. return avatarColors[idx];
  171. };
  172. const availableWeekDays = computed(() =>
  173. [...new Set(workingDays.value.map((wd) => wd.day))]
  174. );
  175. const blockedDateSet = computed(() =>
  176. new Set(blockedDays.value.map((bd) => bd.date))
  177. );
  178. const dateOptions = (d) => {
  179. const today = date.formatDate(new Date(), 'YYYY/MM/DD');
  180. if (d < today) return false;
  181. const raw = d.replace(/\//g, '-');
  182. const parsed = new Date(`${raw}T12:00:00`);
  183. const dayOfWeek = parsed.getDay();
  184. const isWorkingDay = availableWeekDays.value.includes(dayOfWeek);
  185. const isBlocked = blockedDateSet.value.has(raw);
  186. return isWorkingDay && !isBlocked;
  187. };
  188. const loadAvailability = async () => {
  189. loadingAvailability.value = true;
  190. try {
  191. const [wd, bd] = await Promise.all([
  192. getProviderWorkingDays(props.provider.provider_id),
  193. getProviderBlockedDays(props.provider.provider_id),
  194. ]);
  195. workingDays.value = wd ?? [];
  196. blockedDays.value = bd ?? [];
  197. } catch {
  198. workingDays.value = [];
  199. blockedDays.value = [];
  200. } finally {
  201. loadingAvailability.value = false;
  202. }
  203. };
  204. const loadReviews = async () => {
  205. loadingReviews.value = true;
  206. try {
  207. const all = await getProviderReceivedReviews(props.provider.provider_id);
  208. reviews.value = (all ?? []).slice(0, 10);
  209. } catch {
  210. reviews.value = [];
  211. } finally {
  212. loadingReviews.value = false;
  213. }
  214. };
  215. onMounted(() => {
  216. Promise.all([loadAvailability(), loadReviews()]);
  217. });
  218. </script>
  219. <style scoped lang="scss">
  220. .dialog-root {
  221. width: 100vw;
  222. max-width: 100vw;
  223. height: 100vh;
  224. max-height: 100vh;
  225. display: flex;
  226. flex-direction: column;
  227. overflow: hidden;
  228. background: #F9FAFB;
  229. }
  230. .dialog-header {
  231. flex-shrink: 0;
  232. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
  233. }
  234. .dialog-body {
  235. flex: 1;
  236. min-height: 0;
  237. overflow-y: auto;
  238. overflow-x: clip;
  239. }
  240. .calendar-wrapper {
  241. border-radius: 20px;
  242. overflow: hidden;
  243. background: white;
  244. :deep(.q-date) {
  245. background: white;
  246. width: 100%;
  247. }
  248. :deep(.q-date__calendar-days .q-btn__content) {
  249. color: #1E293B !important;
  250. }
  251. // dias desabilitados: visíveis mas opacos
  252. :deep(.q-date__calendar-days .q-btn.disabled .q-btn__content),
  253. :deep(.q-date__calendar-days .q-btn[disabled] .q-btn__content) {
  254. color: #000000 !important;
  255. opacity: 1 !important;
  256. }
  257. // o Quasar aplica opacity no elemento .q-btn quando disabled — reseta
  258. :deep(.q-date__calendar-days .q-btn.disabled),
  259. :deep(.q-date__calendar-days .q-btn[disabled]) {
  260. opacity: 1 !important;
  261. }
  262. // cabeçalho dos dias (dom, seg, ter, qua...)
  263. :deep(.q-date__calendar-weekdays > div) {
  264. color: #6366F1;
  265. font-weight: 700;
  266. opacity: 0.8;
  267. }
  268. :deep(.q-date__navigation) {
  269. .q-btn { color: #6366F1 !important; }
  270. .q-btn__content { color: #6366F1 !important; }
  271. }
  272. :deep(.q-date__event) {
  273. bottom: 4px;
  274. height: 6px;
  275. width: 6px;
  276. border-radius: 50%;
  277. }
  278. :deep(.q-date__today .q-btn__content) {
  279. color: #7c4dff !important;
  280. background: #7c4dff15;
  281. border-radius: 50%;
  282. }
  283. :deep(.q-date__selected .q-btn__content),
  284. :deep(.q-date__calendar-item .q-btn.q-date__selected .q-btn__content) {
  285. background: #6366F1 !important;
  286. color: #ffffff !important;
  287. border-radius: 50%;
  288. box-shadow: 0 4px 10px rgba(99, 102, 241, 0.4);
  289. }
  290. :deep(.q-date__view--months .q-btn),
  291. :deep(.q-date__view--years .q-btn) {
  292. color: #6366F1 !important;
  293. }
  294. :deep(.q-date__calendar-item--out) {
  295. color: #b9b9b9 !important;
  296. opacity: 0.8 !important;
  297. }
  298. }
  299. // Reviews scroll horizontal
  300. .reviews-scroll {
  301. display: flex;
  302. flex-direction: row;
  303. overflow-x: auto;
  304. -webkit-overflow-scrolling: touch;
  305. scrollbar-width: none;
  306. padding-bottom: 8px;
  307. &::-webkit-scrollbar { display: none; }
  308. }
  309. .review-card {
  310. flex-shrink: 0;
  311. min-width: 220px;
  312. max-width: 240px;
  313. border-radius: 12px;
  314. margin-right: 8px;
  315. }
  316. .review-comment {
  317. display: -webkit-box;
  318. -webkit-line-clamp: 3;
  319. line-clamp: 3;
  320. -webkit-box-orient: vertical;
  321. overflow: hidden;
  322. }
  323. .min-width-0 {
  324. min-width: 0;
  325. }
  326. </style>