OrderSummaryDialog.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  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">
  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="info-banner card-border q-pa-md">
  14. <div class="text-body2 text-weight-medium text-primary">
  15. {{ $t('scheduling_page.order_summary.info_text') }}
  16. </div>
  17. <div class="text-caption text-primary q-mt-xs" style="opacity: 0.75;">
  18. {{ $t('scheduling_page.order_summary.info_note') }}
  19. </div>
  20. </div>
  21. </div>
  22. <div class="q-px-md q-pt-md">
  23. <div class="text-h6 text-weight-bold gradient-diarista q-mb-sm">
  24. {{ $t('scheduling_page.order_summary.title') }}
  25. </div>
  26. <q-card
  27. v-for="(booking, idx) in bookings"
  28. :key="idx"
  29. :flat="false"
  30. class="card-border bg-surface q-mb-sm shadow-card"
  31. >
  32. <q-card-section class="q-pa-md row items-center no-wrap">
  33. <div class="col">
  34. <div class="text-body2 text-text">
  35. <span class="text-weight-bold">{{ $t('scheduling_page.order_summary.service_label') }}</span>
  36. <span class="text-weight-bold">{{ ` ${booking.serviceType.label} (${booking.serviceType.hours})` }}</span>
  37. </div>
  38. <div class="text-body2 text-weight-bold text-text">{{ formatDate(booking.date) }}</div>
  39. <div class="text-body2 text-text">
  40. {{ $t('scheduling_page.order_summary.time_range', { start: booking.slot.startHour, end: booking.slot.endHour }) }}
  41. </div>
  42. </div>
  43. <q-btn
  44. flat round dense
  45. icon="mdi-minus-circle-outline"
  46. color="grey-5"
  47. @click="confirmRemove(idx)"
  48. />
  49. </q-card-section>
  50. </q-card>
  51. <q-btn
  52. unelevated rounded no-caps
  53. :label="$t('scheduling_page.order_summary.send_btn')"
  54. color="secondary"
  55. class="full-width q-mt-sm"
  56. @click="submitOrder"
  57. />
  58. <q-btn
  59. outline rounded no-caps
  60. :label="$t('scheduling_page.order_summary.add_date_btn')"
  61. color="primary"
  62. class="full-width q-mt-xs"
  63. :disable="showCalendar"
  64. @click="showCalendar = true"
  65. />
  66. </div>
  67. <div v-if="showCalendar" class="q-px-md q-pt-lg q-pb-xl">
  68. <div v-if="loadingAvailability" class="row items-center justify-center q-py-lg">
  69. <q-spinner-dots color="primary" size="36px" />
  70. </div>
  71. <div v-else class="calendar-wrapper shadow-card">
  72. <q-date
  73. v-model="addDateValue"
  74. square
  75. class="full-width"
  76. :first-day-of-week="0"
  77. :options="dateOptions"
  78. minimal
  79. @update:model-value="onAddDateSelected"
  80. />
  81. </div>
  82. </div>
  83. </div>
  84. </div>
  85. </q-dialog>
  86. </template>
  87. <script setup>
  88. import { ref, computed, onMounted } from 'vue';
  89. import { useDialogPluginComponent, useQuasar } from 'quasar';
  90. import { date } from 'quasar';
  91. import { useI18n } from 'vue-i18n';
  92. import { getProviderWorkingDays, getProviderBlockedDays } from 'src/api/providerAvailability';
  93. import { getAddresses } from 'src/api/address';
  94. import { createSchedule } from 'src/api/schedule';
  95. import { userStore } from 'src/stores/user';
  96. import ServiceSelectionSheet from './ServiceSelectionSheet.vue';
  97. import ServiceTimeSelectionDialog from './ServiceTimeSelectionDialog.vue';
  98. const props = defineProps({
  99. provider: { type: Object, required: true },
  100. initialBooking: { type: Object, required: true },
  101. });
  102. defineEmits([...useDialogPluginComponent.emits]);
  103. const { dialogRef } = useDialogPluginComponent();
  104. const $q = useQuasar();
  105. const { t, locale } = useI18n();
  106. const store = userStore();
  107. const bookings = ref([props.initialBooking]);
  108. const submitting = ref(false);
  109. const primaryAddress = ref(null);
  110. const showCalendar = ref(false);
  111. const addDateValue = ref(null);
  112. const loadingAvailability = ref(false);
  113. const workingDays = ref([]);
  114. const blockedDays = ref([]);
  115. const getWeekStart = (dateStr) => {
  116. const d = new Date(dateStr.replace(/\//g, '-') + 'T12:00:00');
  117. d.setDate(d.getDate() - d.getDay());
  118. return d.toISOString().slice(0, 10);
  119. };
  120. const wouldExceedWeekLimit = (newDateStr) => {
  121. const newWeek = getWeekStart(newDateStr);
  122. const count = bookings.value.filter(b => getWeekStart(b.date) === newWeek).length;
  123. return count >= 2;
  124. };
  125. const availableWeekDays = computed(() =>
  126. [...new Set(workingDays.value.map(wd => wd.day))]
  127. );
  128. const blockedDateSet = computed(() =>
  129. new Set(blockedDays.value.filter(bd => bd.period === 'all').map(bd => bd.date))
  130. );
  131. const dateOptions = (d) => {
  132. const today = date.formatDate(new Date(), 'YYYY/MM/DD');
  133. if (d < today) return false;
  134. if (wouldExceedWeekLimit(d)) return false;
  135. const raw = d.replace(/\//g, '-');
  136. const parsed = new Date(`${raw}T12:00:00`);
  137. const dayOfWeek = parsed.getDay();
  138. const isWorking = availableWeekDays.value.includes(dayOfWeek);
  139. const isBlocked = blockedDateSet.value.has(raw);
  140. return isWorking && !isBlocked;
  141. };
  142. const loadAvailability = async () => {
  143. loadingAvailability.value = true;
  144. try {
  145. const [wd, bd] = await Promise.all([
  146. getProviderWorkingDays(props.provider.provider_id),
  147. getProviderBlockedDays(props.provider.provider_id),
  148. ]);
  149. workingDays.value = wd ?? [];
  150. blockedDays.value = bd ?? [];
  151. } catch {
  152. workingDays.value = [];
  153. blockedDays.value = [];
  154. } finally {
  155. loadingAvailability.value = false;
  156. }
  157. };
  158. const loadPrimaryAddress = async () => {
  159. try {
  160. const clientId = store.user?.client_id;
  161. if (!clientId) return;
  162. const addresses = await getAddresses('client', clientId);
  163. primaryAddress.value = (addresses ?? []).find(a => a.is_primary) ?? null;
  164. } catch {
  165. primaryAddress.value = null;
  166. }
  167. };
  168. onMounted(() => Promise.all([loadAvailability(), loadPrimaryAddress()]));
  169. const formatHour = (h) => `${String(h).padStart(2, '0')}:00`;
  170. const normalizeDate = (d) => d.replace(/\//g, '-');
  171. const onAddDateSelected = (val) => {
  172. if (!val) return;
  173. addDateValue.value = null;
  174. const valFormatted = val.replace(/\//g, '-');
  175. const blocksOfDate = blockedDays.value.filter(
  176. bd => bd.date === valFormatted && bd.period !== 'all'
  177. );
  178. const dayOfWeek = new Date(`${valFormatted}T12:00:00`).getDay();
  179. const dayPeriods = workingDays.value
  180. .filter(wd => wd.day === dayOfWeek)
  181. .map(wd => wd.period);
  182. const workingDayBlocks = [];
  183. if (!dayPeriods.includes('afternoon')) {
  184. workingDayBlocks.push({ init_hour: '14:00:00', end_hour: '20:00:00' });
  185. }
  186. if (!dayPeriods.includes('morning')) {
  187. workingDayBlocks.push({ init_hour: '7:00:00', end_hour: '13:00:00' });
  188. }
  189. const existingBookingBlocks = bookings.value
  190. .filter(b => b.date.replace(/\//g, '-') === valFormatted)
  191. .map(b => ({
  192. init_hour: `${b.slot.startHour}:00:00`,
  193. end_hour: `${b.slot.endHour}:00:00`,
  194. }));
  195. const partialBlocks = [...blocksOfDate, ...workingDayBlocks, ...existingBookingBlocks];
  196. $q.dialog({
  197. component: ServiceSelectionSheet,
  198. componentProps: { provider: props.provider, selectedDate: val, partialBlocks },
  199. }).onOk(({ serviceType, date: date_, provider: prov }) => {
  200. $q.dialog({
  201. component: ServiceTimeSelectionDialog,
  202. componentProps: { serviceType, selectedDate: date_, provider: prov, partialBlocks },
  203. }).onOk((booking) => {
  204. if (wouldExceedWeekLimit(booking.date)) {
  205. $q.notify({ type: 'negative', message: t('scheduling_page.order_summary.week_limit_error') });
  206. return;
  207. }
  208. bookings.value.push(booking);
  209. showCalendar.value = false;
  210. });
  211. });
  212. };
  213. const confirmRemove = (idx) => {
  214. $q.dialog({
  215. title: t('scheduling_page.order_summary.remove_confirm_title'),
  216. cancel: { label: t('scheduling_page.order_summary.remove_confirm_cancel'), flat: true, color: 'grey-6' },
  217. ok: { label: t('scheduling_page.order_summary.remove_confirm_ok'), unelevated: true, color: 'primary', rounded: true, noCaps: true },
  218. persistent: true,
  219. }).onOk(() => {
  220. bookings.value.splice(idx, 1);
  221. });
  222. };
  223. const formatDate = (dateStr) => {
  224. const d = new Date(normalizeDate(dateStr) + 'T12:00:00');
  225. const localeMap = { pt: 'pt-BR', en: 'en-US', es: 'es-ES' };
  226. const loc = localeMap[locale.value] ?? 'pt-BR';
  227. const weekday = new Intl.DateTimeFormat(loc, { weekday: 'long' }).format(d);
  228. const dateFormatted = new Intl.DateTimeFormat(loc, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d);
  229. return `${weekday.charAt(0).toUpperCase() + weekday.slice(1)}, ${dateFormatted}`;
  230. };
  231. const submitOrder = async () => {
  232. if (!primaryAddress.value) {
  233. $q.notify({ type: 'warning', message: t('scheduling_page.order_summary.no_primary_address') });
  234. return;
  235. }
  236. const payload = {
  237. client_id: store.user.client_id,
  238. provider_id: props.provider.provider_id,
  239. address_id: primaryAddress.value.id,
  240. schedule_type: 'default',
  241. schedules: bookings.value.map(b => ({
  242. date: normalizeDate(b.date),
  243. period_type: b.serviceType.hoursCount,
  244. start_time: formatHour(b.slot.startHour),
  245. end_time: formatHour(b.slot.endHour),
  246. total_amount: b.serviceType.price,
  247. offers_meal: b.meal === 'offer' ? true : b.meal === 'no_offer' ? false : null,
  248. })),
  249. };
  250. submitting.value = true;
  251. try {
  252. await createSchedule(payload);
  253. $q.notify({ type: 'positive', message: t('scheduling_page.order_summary.submit_success') });
  254. dialogRef.value.hide();
  255. } catch (err) {
  256. const msg = err?.response?.data?.message
  257. ?? err?.message
  258. ?? t('scheduling_page.order_summary.submit_error');
  259. $q.notify({ type: 'negative', message: msg });
  260. } finally {
  261. submitting.value = false;
  262. }
  263. };
  264. </script>
  265. <style scoped lang="scss">
  266. .dialog-root {
  267. width: 100vw;
  268. max-width: 100vw;
  269. height: 100vh;
  270. max-height: 100vh;
  271. display: flex;
  272. flex-direction: column;
  273. overflow: hidden;
  274. background: #F9FAFB;
  275. }
  276. .dialog-header {
  277. flex-shrink: 0;
  278. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
  279. }
  280. .dialog-body {
  281. flex: 1;
  282. min-height: 0;
  283. overflow-y: auto;
  284. overflow-x: clip;
  285. }
  286. .info-banner {
  287. background: rgba(139, 92, 246, 0.08);
  288. border: 1px solid rgba(139, 92, 246, 0.2);
  289. }
  290. .calendar-wrapper {
  291. border-radius: 20px;
  292. overflow: hidden;
  293. background: white;
  294. :deep(.q-date) { background: white; width: 100%; }
  295. :deep(.q-date__main),
  296. :deep(.q-date__content),
  297. :deep(.q-date__calendar) { background: white !important; }
  298. :deep(.q-date__calendar-item .q-btn) {
  299. font-size: 13px !important;
  300. min-width: 0 !important;
  301. padding: 6px 2px !important;
  302. }
  303. :deep(.q-date__calendar-item .q-btn.disabled),
  304. :deep(.q-date__calendar-item .q-btn[disabled]) { opacity: 1 !important; }
  305. :deep(.q-date__calendar-item .q-btn.disabled .q-btn__content),
  306. :deep(.q-date__calendar-item .q-btn[disabled] .q-btn__content) { color: #CBD5E1 !important; }
  307. :deep(.q-date__calendar-days .q-btn__content) { font-weight: 500; color: #1E293B; }
  308. :deep(.q-date__calendar-weekdays > div) { color: #6366F1; font-weight: 700; opacity: 0.8; }
  309. :deep(.q-date__navigation) {
  310. .q-btn { color: #6366F1 !important; }
  311. .q-btn__content { color: #6366F1 !important; }
  312. }
  313. :deep(.q-date__event) { bottom: 4px; height: 6px; width: 6px; border-radius: 50%; }
  314. :deep(.q-date__today .q-btn__content) { color: #7c4dff !important; background: #7c4dff15; border-radius: 50%; }
  315. :deep(.q-date__selected .q-btn__content) {
  316. background: #6366F1 !important;
  317. color: white !important;
  318. border-radius: 50%;
  319. box-shadow: 0 4px 10px rgba(99, 102, 241, 0.4);
  320. }
  321. }
  322. </style>