OrderSummaryDialog.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. <template>
  2. <q-dialog ref="dialogRef" 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, getClientProviderBlocks } from 'src/api/schedule';
  95. import { userStore } from 'src/stores/user';
  96. import ServiceSelectionSheet from './ServiceSelectionSheet.vue';
  97. import ServiceTimeSelectionDialog from './ServiceTimeSelectionDialog.vue';
  98. import { useRouter } from 'vue-router';
  99. const props = defineProps({
  100. provider: { type: Object, required: true },
  101. initialBooking: { type: Object, required: true },
  102. });
  103. defineEmits([...useDialogPluginComponent.emits]);
  104. const { dialogRef, onDialogOK } = useDialogPluginComponent();
  105. const $q = useQuasar();
  106. const { t, locale } = useI18n();
  107. const store = userStore();
  108. const bookings = ref([props.initialBooking]);
  109. const submitting = ref(false);
  110. const primaryAddress = ref(null);
  111. const router = useRouter();
  112. const showCalendar = ref(false);
  113. const addDateValue = ref(null);
  114. const loadingAvailability = ref(false);
  115. const workingDays = ref([]);
  116. const blockedDays = ref([]);
  117. const providerClientBlocks = ref({
  118. existing_schedules: [],
  119. fully_blocked_weeks: [],
  120. });
  121. const normalizeDate = (d) => d.replace(/\//g, '-');
  122. const getWeekStart = (dateStr) => {
  123. const d = new Date(normalizeDate(dateStr) + 'T12:00:00');
  124. d.setDate(d.getDate() - d.getDay());
  125. return d.toISOString().slice(0, 10);
  126. };
  127. const blockedWeekStartSet = computed(() =>
  128. new Set(providerClientBlocks.value.fully_blocked_weeks ?? [])
  129. );
  130. const getServerWeekCount = (newDateStr) => {
  131. const newWeek = getWeekStart(newDateStr);
  132. return (providerClientBlocks.value.existing_schedules ?? []).filter(
  133. (schedule) => getWeekStart(schedule.date) === newWeek
  134. ).length;
  135. };
  136. const getLocalWeekCount = (newDateStr) => {
  137. const newWeek = getWeekStart(newDateStr);
  138. return bookings.value.filter((booking) => getWeekStart(booking.date) === newWeek).length;
  139. };
  140. const wouldExceedWeekLimit = (newDateStr) => {
  141. const count = getServerWeekCount(newDateStr) + getLocalWeekCount(newDateStr);
  142. return count >= 2;
  143. };
  144. const availableWeekDays = computed(() =>
  145. [...new Set(workingDays.value.map(wd => wd.day))]
  146. );
  147. const blockedDateSet = computed(() =>
  148. new Set(blockedDays.value.filter(bd => bd.period === 'all').map(bd => bd.date))
  149. );
  150. const dateOptions = (d) => {
  151. const today = date.formatDate(new Date(), 'YYYY/MM/DD');
  152. if (d < today) return false;
  153. if (wouldExceedWeekLimit(d)) return false;
  154. const raw = normalizeDate(d);
  155. const parsed = new Date(`${raw}T12:00:00`);
  156. const dayOfWeek = parsed.getDay();
  157. const isWorking = availableWeekDays.value.includes(dayOfWeek);
  158. const isBlocked = blockedDateSet.value.has(raw);
  159. const isWeekBlocked = blockedWeekStartSet.value.has(getWeekStart(raw));
  160. return isWorking && !isBlocked && !isWeekBlocked;
  161. };
  162. const loadAvailability = async () => {
  163. loadingAvailability.value = true;
  164. try {
  165. const clientId = store.user?.client_id;
  166. const defaultClientBlocks = { existing_schedules: [], fully_blocked_weeks: [] };
  167. const [wd, bd, clientBlocks] = await Promise.all([
  168. getProviderWorkingDays(props.provider.provider_id),
  169. getProviderBlockedDays(props.provider.provider_id),
  170. clientId
  171. ? getClientProviderBlocks(clientId, props.provider.provider_id)
  172. : Promise.resolve(defaultClientBlocks),
  173. ]);
  174. workingDays.value = wd ?? [];
  175. blockedDays.value = bd ?? [];
  176. providerClientBlocks.value = clientBlocks ?? defaultClientBlocks;
  177. } catch {
  178. workingDays.value = [];
  179. blockedDays.value = [];
  180. providerClientBlocks.value = { existing_schedules: [], fully_blocked_weeks: [] };
  181. } finally {
  182. loadingAvailability.value = false;
  183. }
  184. };
  185. const loadPrimaryAddress = async () => {
  186. try {
  187. const clientId = store.user?.client_id;
  188. if (!clientId) return;
  189. const addresses = await getAddresses('client', clientId);
  190. primaryAddress.value = (addresses ?? []).find(a => a.is_primary) ?? null;
  191. } catch {
  192. primaryAddress.value = null;
  193. }
  194. };
  195. onMounted(() => Promise.all([loadAvailability(), loadPrimaryAddress()]));
  196. const formatHour = (h) => `${String(h).padStart(2, '0')}:00`;
  197. const onAddDateSelected = (val) => {
  198. if (!val) return;
  199. addDateValue.value = null;
  200. const valFormatted = normalizeDate(val);
  201. const blocksOfDate = blockedDays.value.filter(
  202. bd => bd.date === valFormatted && bd.period !== 'all'
  203. );
  204. const dayOfWeek = new Date(`${valFormatted}T12:00:00`).getDay();
  205. const dayPeriods = workingDays.value
  206. .filter(wd => wd.day === dayOfWeek)
  207. .map(wd => wd.period);
  208. const workingDayBlocks = [];
  209. if (!dayPeriods.includes('afternoon')) {
  210. workingDayBlocks.push({ init_hour: '14:00:00', end_hour: '20:00:00' });
  211. }
  212. if (!dayPeriods.includes('morning')) {
  213. workingDayBlocks.push({ init_hour: '7:00:00', end_hour: '13:00:00' });
  214. }
  215. const existingBookingBlocks = bookings.value
  216. .filter(b => b.date.replace(/\//g, '-') === valFormatted)
  217. .map(b => ({
  218. init_hour: `${b.slot.startHour}:00:00`,
  219. end_hour: `${b.slot.endHour}:00:00`,
  220. }));
  221. const serverBookingBlocks = (providerClientBlocks.value.existing_schedules ?? [])
  222. .filter((schedule) => schedule.date === valFormatted)
  223. .map((schedule) => ({
  224. init_hour: schedule.start_time,
  225. end_hour: schedule.end_time,
  226. }));
  227. const partialBlocks = [...blocksOfDate, ...workingDayBlocks, ...existingBookingBlocks, ...serverBookingBlocks];
  228. $q.dialog({
  229. component: ServiceSelectionSheet,
  230. componentProps: { provider: props.provider, selectedDate: val, partialBlocks },
  231. }).onOk(({ serviceType, date: date_, provider: prov }) => {
  232. $q.dialog({
  233. component: ServiceTimeSelectionDialog,
  234. componentProps: { serviceType, selectedDate: date_, provider: prov, partialBlocks },
  235. }).onOk((booking) => {
  236. if (wouldExceedWeekLimit(booking.date)) {
  237. $q.notify({ type: 'negative', message: t('scheduling_page.order_summary.week_limit_error') });
  238. return;
  239. }
  240. bookings.value.push(booking);
  241. showCalendar.value = false;
  242. });
  243. });
  244. };
  245. const confirmRemove = (idx) => {
  246. $q.dialog({
  247. title: t('scheduling_page.order_summary.remove_confirm_title'),
  248. cancel: { label: t('scheduling_page.order_summary.remove_confirm_cancel'), flat: true, color: 'grey-6' },
  249. ok: { label: t('scheduling_page.order_summary.remove_confirm_ok'), unelevated: true, color: 'primary', rounded: true, noCaps: true },
  250. persistent: true,
  251. }).onOk(() => {
  252. bookings.value.splice(idx, 1);
  253. });
  254. };
  255. const formatDate = (dateStr) => {
  256. const d = new Date(normalizeDate(dateStr) + 'T12:00:00');
  257. const localeMap = { pt: 'pt-BR', en: 'en-US', es: 'es-ES' };
  258. const loc = localeMap[locale.value] ?? 'pt-BR';
  259. const weekday = new Intl.DateTimeFormat(loc, { weekday: 'long' }).format(d);
  260. const dateFormatted = new Intl.DateTimeFormat(loc, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d);
  261. return `${weekday.charAt(0).toUpperCase() + weekday.slice(1)}, ${dateFormatted}`;
  262. };
  263. const submitOrder = async () => {
  264. if (!primaryAddress.value) {
  265. $q.notify({ type: 'warning', message: t('scheduling_page.order_summary.no_primary_address') });
  266. return;
  267. }
  268. const payload = {
  269. client_id: store.user.client_id,
  270. provider_id: props.provider.provider_id,
  271. address_id: primaryAddress.value.id,
  272. schedule_type: 'default',
  273. schedules: bookings.value.map(b => ({
  274. date: normalizeDate(b.date),
  275. period_type: b.serviceType.hoursCount,
  276. start_time: formatHour(b.slot.startHour),
  277. end_time: formatHour(b.slot.endHour),
  278. total_amount: b.serviceType.price,
  279. offers_meal: b.meal === 'offer' ? true : b.meal === 'no_offer' ? false : null,
  280. })),
  281. };
  282. submitting.value = true;
  283. try {
  284. await createSchedule(payload);
  285. $q.notify({ type: 'positive', message: t('scheduling_page.order_summary.submit_success') });
  286. onDialogOK();
  287. } catch (err) {
  288. const msg = err?.response?.data?.message
  289. ?? err?.message
  290. ?? t('scheduling_page.order_summary.submit_error');
  291. $q.notify({ type: 'negative', message: msg });
  292. } finally {
  293. submitting.value = false;
  294. router.push({ name: 'DashboardPage' });
  295. }
  296. };
  297. </script>
  298. <style scoped lang="scss">
  299. .dialog-root {
  300. width: 100vw;
  301. max-width: 100vw;
  302. height: 100vh;
  303. max-height: 100vh;
  304. display: flex;
  305. flex-direction: column;
  306. overflow: hidden;
  307. background: #F9FAFB;
  308. }
  309. .dialog-header {
  310. flex-shrink: 0;
  311. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
  312. }
  313. .dialog-body {
  314. flex: 1;
  315. min-height: 0;
  316. overflow-y: auto;
  317. overflow-x: clip;
  318. }
  319. .info-banner {
  320. background: rgba(139, 92, 246, 0.08);
  321. border: 1px solid rgba(139, 92, 246, 0.2);
  322. }
  323. .calendar-wrapper {
  324. border-radius: 20px;
  325. overflow: hidden;
  326. background: white;
  327. :deep(.q-date) { background: white; width: 100%; }
  328. :deep(.q-date__main),
  329. :deep(.q-date__content),
  330. :deep(.q-date__calendar) { background: white !important; }
  331. :deep(.q-date__calendar-item .q-btn) {
  332. font-size: 13px !important;
  333. min-width: 0 !important;
  334. padding: 6px 2px !important;
  335. }
  336. :deep(.q-date__calendar-item .q-btn.disabled),
  337. :deep(.q-date__calendar-item .q-btn[disabled]) { opacity: 1 !important; }
  338. :deep(.q-date__calendar-item .q-btn.disabled .q-btn__content),
  339. :deep(.q-date__calendar-item .q-btn[disabled] .q-btn__content) { color: #CBD5E1 !important; }
  340. :deep(.q-date__calendar-days .q-btn__content) { font-weight: 500; color: #1E293B; }
  341. :deep(.q-date__calendar-weekdays > div) { color: #6366F1; font-weight: 700; opacity: 0.8; }
  342. :deep(.q-date__navigation) {
  343. .q-btn { color: #6366F1 !important; }
  344. .q-btn__content { color: #6366F1 !important; }
  345. }
  346. :deep(.q-date__event) { bottom: 4px; height: 6px; width: 6px; border-radius: 50%; }
  347. :deep(.q-date__today .q-btn__content) { color: #7c4dff !important; background: #7c4dff15; border-radius: 50%; }
  348. :deep(.q-date__selected .q-btn__content) {
  349. background: #6366F1 !important;
  350. color: white !important;
  351. border-radius: 50%;
  352. box-shadow: 0 4px 10px rgba(99, 102, 241, 0.4);
  353. }
  354. }
  355. </style>