ServicePackagePage.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. <template>
  2. <q-page class="service-package-page bg-page q-pb-xl">
  3. <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-service-package">
  4. <q-btn
  5. class="header-back-btn"
  6. color="primary"
  7. dense
  8. flat
  9. icon="mdi-chevron-left"
  10. round
  11. @click="router.back()"
  12. />
  13. <div
  14. class="col text-center font16 fontbold gradient-diarista text-primary"
  15. >
  16. {{ $t("service_package.title") }}
  17. </div>
  18. <div style="width: 36px"></div>
  19. </div>
  20. <template v-if="servicePackage.items.length > 0">
  21. <div class="q-px-md q-pt-md column q-gutter-y-sm">
  22. <div class="service-package-discount-banner row items-center no-wrap q-pa-sm">
  23. <span class="font12 fontbold text-positive q-ml-sm">
  24. {{ servicePackageDiscountMessage }}
  25. </span>
  26. </div>
  27. <q-btn
  28. class="full-width"
  29. color="primary"
  30. icon="mdi-plus"
  31. no-caps
  32. outline
  33. padding="8px 16px"
  34. rounded
  35. :label="$t('service_package.add_more')"
  36. @click="openAddMoreDialog"
  37. />
  38. <q-card
  39. v-for="(item, index) in servicePackage.items"
  40. :key="item.local_id"
  41. :flat="false"
  42. class="service-package-item-card bg-surface shadow-card card-border"
  43. >
  44. <q-card-section class="row no-wrap q-pa-md">
  45. <q-avatar
  46. color="indigo-1"
  47. size="44px"
  48. text-color="indigo-4"
  49. >
  50. <img
  51. v-if="getProfileMediaUrl(item)"
  52. :src="getProfileMediaUrl(item)"
  53. style="object-fit: cover"
  54. />
  55. <span v-else>
  56. {{ getProviderInitial(item) }}
  57. </span>
  58. </q-avatar>
  59. <div class="column col q-ml-sm">
  60. <span class="font14 fontbold text-text">
  61. {{ item.provider_name }}
  62. </span>
  63. <span class="font12 text-grey-7">
  64. {{ formatDate(item.date) }}
  65. </span>
  66. <span class="font12 text-grey-7">
  67. {{
  68. $t("service_package.time_range", {
  69. start: item.start_time,
  70. end: item.end_time,
  71. })
  72. }}
  73. </span>
  74. <div class="row items-center q-gutter-x-sm q-mt-xs">
  75. <q-chip
  76. class="font10"
  77. color="purple-1"
  78. dense
  79. square
  80. text-color="primary"
  81. >
  82. {{
  83. $t("service_package.hours_short", { hours: item.period_type })
  84. }}
  85. </q-chip>
  86. <q-chip
  87. class="font10"
  88. color="grey-2"
  89. dense
  90. square
  91. text-color="grey-8"
  92. >
  93. {{
  94. item.offers_meal
  95. ? $t("service_package.with_meal")
  96. : $t("service_package.without_meal")
  97. }}
  98. </q-chip>
  99. </div>
  100. <div class="item-prices q-mt-sm">
  101. <div class="row items-center no-wrap">
  102. <span class="font11 fontbold text-primary">
  103. {{ $t("service_package.credit_card_total") }}
  104. </span>
  105. <q-space />
  106. <span class="font12 fontbold text-primary">
  107. {{ formatCurrencyValue(getCreditCardItemTotal(item)) }}
  108. </span>
  109. </div>
  110. </div>
  111. </div>
  112. <q-btn
  113. color="negative"
  114. dense
  115. flat
  116. icon="mdi-delete-outline"
  117. round
  118. @click="servicePackage.removeItem(index)"
  119. />
  120. </q-card-section>
  121. </q-card>
  122. </div>
  123. <div class="q-px-md q-pt-md">
  124. <q-card
  125. :flat="false"
  126. class="checkout-card bg-surface shadow-card card-border"
  127. >
  128. <q-card-section class="q-pa-md">
  129. <div class="row items-center no-wrap q-mb-sm">
  130. <span class="font14 fontbold text-primary">
  131. {{ $t("service_package.summary") }}
  132. </span>
  133. <q-space />
  134. </div>
  135. <div class="summary-row row items-center no-wrap q-py-xs">
  136. <span class="font12 text-grey-7">
  137. {{ $t("service_package.provider") }}
  138. </span>
  139. <q-space />
  140. <span class="font12 fontbold text-text text-right">
  141. {{ providerName }}
  142. </span>
  143. </div>
  144. <div class="summary-row row items-center no-wrap q-py-xs">
  145. <span class="font12 text-grey-7">
  146. {{ $t("service_package.address") }}
  147. </span>
  148. <q-space />
  149. <span class="font12 fontbold text-text text-right">
  150. {{ primaryAddressLabel }}
  151. </span>
  152. </div>
  153. <div class="summary-row row items-center no-wrap q-py-xs">
  154. <span class="font12 text-grey-7">
  155. {{ $t("service_package.quantity") }}
  156. </span>
  157. <q-space />
  158. <span class="font12 fontbold text-text">
  159. {{
  160. $t("service_package.schedule_count", {
  161. count: servicePackage.items.length,
  162. })
  163. }}
  164. </span>
  165. </div>
  166. <div class="summary-row row items-center no-wrap q-py-xs">
  167. <span class="font12 fontbold text-primary">
  168. {{ $t("service_package.credit_card_total") }}
  169. </span>
  170. <q-space />
  171. <span class="font14 fontbold text-primary">
  172. {{ formatCurrencyValue(creditCardTotalAmount) }}
  173. </span>
  174. </div>
  175. <div
  176. v-if="creditCardDiscountAmount > 0"
  177. class="summary-discount row items-center no-wrap q-py-xs q-px-sm q-mt-sm"
  178. >
  179. <span class="font12 fontbold text-positive q-ml-xs">
  180. {{
  181. $t("service_package.discount_saved", {
  182. value: formatPrice(creditCardDiscountAmount),
  183. })
  184. }}
  185. </span>
  186. </div>
  187. </q-card-section>
  188. <q-card-actions class="q-px-md q-pb-md">
  189. <q-btn
  190. class="full-width"
  191. color="primary"
  192. no-caps
  193. padding="10px 16px"
  194. rounded
  195. unelevated
  196. :disable="addressLoading || !platformFeesReady || !hasSingleProvider"
  197. :label="$t('service_package.close')"
  198. :loading="submitting"
  199. @click="submitServicePackage"
  200. />
  201. </q-card-actions>
  202. </q-card>
  203. </div>
  204. </template>
  205. <DashboardClientProposals
  206. v-if="schedulesProposals.length > 0"
  207. :data="schedulesProposals"
  208. class="q-pt-md"
  209. @refresh-data="loadCustomRequests"
  210. />
  211. <div
  212. v-if="pendingCustomRequests.length > 0"
  213. class="q-px-md q-pt-md column q-gutter-y-sm"
  214. >
  215. <div class="section-title gradient-diarista q-mb-sm font16 fontbold">
  216. {{ $t("service_package.pending_custom_title") }}
  217. </div>
  218. <CustomScheduleRequestCard
  219. v-for="entry in pendingCustomRequests"
  220. :key="entry.schedule.id"
  221. :proposals-count="entry.proposalsCount"
  222. :schedule="entry.schedule"
  223. />
  224. </div>
  225. <div
  226. v-if="servicePackage.items.length === 0 && !hasPendingCustomRequests"
  227. class="q-px-md q-pt-md"
  228. >
  229. <q-card
  230. :flat="false"
  231. class="empty-card bg-surface shadow-card card-border"
  232. >
  233. <q-card-section class="column items-center text-center q-pa-lg">
  234. <q-icon
  235. color="grey-5"
  236. name="mdi-shopping-outline"
  237. size="42px"
  238. />
  239. <span class="font14 fontbold text-text q-mt-sm">
  240. {{ $t("service_package.empty_title") }}
  241. </span>
  242. <span class="font12 text-grey-7 q-mt-xs">
  243. {{ $t("service_package.empty_message") }}
  244. </span>
  245. <q-btn
  246. class="q-mt-md"
  247. color="primary"
  248. no-caps
  249. padding="8px 18px"
  250. rounded
  251. unelevated
  252. :label="$t('service_package.view_favorites')"
  253. @click="router.push({ name: 'ProfilePage' })"
  254. />
  255. </q-card-section>
  256. </q-card>
  257. <q-card
  258. v-if="servicePackage.lastCreatedGroup"
  259. :flat="false"
  260. class="created-card bg-surface shadow-card card-border q-mt-md"
  261. >
  262. <q-card-section class="q-pa-md">
  263. <div class="row items-center no-wrap">
  264. <q-icon
  265. color="positive"
  266. name="mdi-check-circle-outline"
  267. size="24px"
  268. />
  269. <div class="column q-ml-sm">
  270. <span class="font14 fontbold text-text">
  271. {{ $t("service_package.last_request_title") }}
  272. </span>
  273. <span class="font12 text-grey-7">
  274. {{
  275. $t("service_package.created_count", {
  276. count: servicePackage.lastCreatedGroup.schedule_ids.length,
  277. })
  278. }}
  279. </span>
  280. </div>
  281. </div>
  282. </q-card-section>
  283. </q-card>
  284. </div>
  285. </q-page>
  286. </template>
  287. <script setup>
  288. import { computed, onMounted, ref } from "vue";
  289. import SchedulingDialog from "src/pages/search/components/SchedulingDialog.vue";
  290. import DashboardClientProposals from "src/pages/dashboard/components/DashboardClientProposals.vue";
  291. import CustomScheduleRequestCard from "src/pages/orders/components/CustomScheduleRequestCard.vue";
  292. import { createScheduleServicePackage } from "src/api/servicePackage";
  293. import { dadosPedidosCliente } from "src/api/dashboard";
  294. import { getAddresses } from "src/api/address";
  295. import { getProfileMediaUrl } from "src/helpers/profileMedia";
  296. import { useI18n } from "vue-i18n";
  297. import { usePaymentPlatformFees } from "src/composables/usePaymentPlatformFees";
  298. import { useQuasar } from "quasar";
  299. import { useRouter } from "vue-router";
  300. import { userStore } from "src/stores/user";
  301. import { useServicePackageStore } from "src/stores/servicePackage";
  302. const { t } = useI18n();
  303. const router = useRouter();
  304. const $q = useQuasar();
  305. const { platformFees, loadPlatformFees } = usePaymentPlatformFees();
  306. const servicePackage = useServicePackageStore();
  307. const store = userStore();
  308. const addressLoading = ref(false);
  309. const primaryAddress = ref(null);
  310. const submitting = ref(false);
  311. const customSchedulesNoProposals = ref([]);
  312. const schedulesProposals = ref([]);
  313. const pendingCustomRequests = computed(() => {
  314. const groups = new Map();
  315. for (const schedule of customSchedulesNoProposals.value) {
  316. groups.set(schedule.id, { schedule, proposalsCount: 0 });
  317. }
  318. for (const item of schedulesProposals.value) {
  319. const existing = groups.get(item.schedule_id);
  320. if (existing) {
  321. existing.proposalsCount += 1;
  322. continue;
  323. }
  324. groups.set(item.schedule_id, {
  325. schedule: {
  326. id: item.schedule_id,
  327. date: item.date,
  328. start_time: item.start_time,
  329. end_time: item.end_time,
  330. address: item.address,
  331. schedule_type: "custom",
  332. },
  333. proposalsCount: 1,
  334. });
  335. }
  336. return [...groups.values()];
  337. });
  338. const hasPendingCustomRequests = computed(() => pendingCustomRequests.value.length > 0);
  339. const isValidFee = (fee) =>
  340. fee !== null && fee !== undefined && Number.isFinite(Number(fee));
  341. const roundMoney = (value) => Number(Number(value ?? 0).toFixed(2));
  342. const roundNullableMoney = (value) => {
  343. if (value === null || value === undefined || !Number.isFinite(Number(value)))
  344. return null;
  345. return roundMoney(value);
  346. };
  347. const sumItems = (callback) => {
  348. let total = 0;
  349. for (const item of servicePackage.items) {
  350. const itemTotal = callback(item);
  351. if (itemTotal === null) return null;
  352. total += itemTotal;
  353. }
  354. return total;
  355. };
  356. const getTotalWithFee = (item, fee) => {
  357. if (!isValidFee(fee)) return null;
  358. return roundMoney(Number(item.base_amount ?? 0) * (1 + Number(fee)));
  359. };
  360. const hasServicePackageDiscount = computed(() => servicePackage.items.length >= 3);
  361. const getStandardCreditCardItemTotal = (item) =>
  362. getTotalWithFee(item, platformFees.value.credit_card);
  363. const getCreditCardItemTotal = (item) =>
  364. getTotalWithFee(
  365. item,
  366. hasServicePackageDiscount.value
  367. ? platformFees.value.service_package_min_3_schedules_credit_card
  368. : platformFees.value.credit_card,
  369. );
  370. const creditCardTotalAmount = computed(() =>
  371. roundNullableMoney(sumItems(getCreditCardItemTotal)),
  372. );
  373. const creditCardDiscountAmount = computed(() => {
  374. if (!hasServicePackageDiscount.value) return 0;
  375. return (
  376. roundNullableMoney(
  377. sumItems((item) => {
  378. const standardTotal = getStandardCreditCardItemTotal(item);
  379. const discountedTotal = getCreditCardItemTotal(item);
  380. if (standardTotal === null || discountedTotal === null) return null;
  381. return standardTotal - discountedTotal;
  382. }),
  383. ) ?? 0
  384. );
  385. });
  386. const missingSchedulesForDiscount = computed(() =>
  387. Math.max(3 - servicePackage.items.length, 0),
  388. );
  389. const servicePackageDiscountMessage = computed(() => {
  390. if (hasServicePackageDiscount.value) {
  391. return t("service_package.discount_applied");
  392. }
  393. return t("service_package.discount_hint", {
  394. count: missingSchedulesForDiscount.value,
  395. });
  396. });
  397. const platformFeesReady = computed(
  398. () =>
  399. isValidFee(platformFees.value.credit_card) &&
  400. (!hasServicePackageDiscount.value ||
  401. isValidFee(
  402. platformFees.value.service_package_min_3_schedules_credit_card,
  403. )),
  404. );
  405. const hasSingleProvider = computed(() => {
  406. const providers = new Set(
  407. servicePackage.items.map((item) => item.provider_id).filter(Boolean),
  408. );
  409. return providers.size <= 1;
  410. });
  411. const openAddMoreDialog = () => {
  412. const firstItem = servicePackage.items[0];
  413. if (!firstItem) return;
  414. const provider = servicePackage.provider ?? {
  415. provider_id: firstItem.provider_id,
  416. provider_name: firstItem.provider_name,
  417. profile_media_url: firstItem.provider_photo,
  418. };
  419. $q.dialog({
  420. component: SchedulingDialog,
  421. componentProps: { provider },
  422. });
  423. };
  424. const primaryAddressLabel = computed(() => {
  425. if (addressLoading.value) return t("service_package.loading");
  426. if (!primaryAddress.value) return t("service_package.no_primary_address");
  427. return [
  428. primaryAddress.value.address,
  429. primaryAddress.value.number,
  430. primaryAddress.value.district,
  431. ]
  432. .filter(Boolean)
  433. .join(", ");
  434. });
  435. const providerName = computed(() => {
  436. const providerNames = [
  437. ...new Set(servicePackage.items.map((item) => item.provider_name).filter(Boolean)),
  438. ];
  439. if (providerNames.length === 0) return t("service_package.unknown");
  440. if (providerNames.length === 1) return providerNames[0];
  441. return t("service_package.multiple_providers", { count: providerNames.length });
  442. });
  443. const normalizeDate = (value) =>
  444. String(value ?? "")
  445. .replace(/\//g, "-")
  446. .slice(0, 10);
  447. const formatDate = (value) => {
  448. const parsed = new Date(`${normalizeDate(value)}T12:00:00`);
  449. return new Intl.DateTimeFormat("pt-BR", {
  450. day: "2-digit",
  451. month: "2-digit",
  452. year: "numeric",
  453. }).format(parsed);
  454. };
  455. const formatPrice = (value) => {
  456. if (
  457. value === null ||
  458. value === undefined ||
  459. !Number.isFinite(Number(value))
  460. ) {
  461. return t("service_package.loading");
  462. }
  463. return Number(value ?? 0).toLocaleString("pt-BR", {
  464. minimumFractionDigits: 2,
  465. maximumFractionDigits: 2,
  466. });
  467. };
  468. const formatCurrencyValue = (value) => {
  469. if (
  470. value === null ||
  471. value === undefined ||
  472. !Number.isFinite(Number(value))
  473. ) {
  474. return t("service_package.loading");
  475. }
  476. return t("service_package.currency_value", { value: formatPrice(value) });
  477. };
  478. const getProviderInitial = (item) =>
  479. item.provider_name?.slice(0, 1).toUpperCase() ??
  480. t("service_package.provider_initial");
  481. const loadPrimaryAddress = async () => {
  482. const clientId = store.user?.client_id ?? store.user?.client?.id;
  483. if (!clientId) return;
  484. addressLoading.value = true;
  485. try {
  486. const addresses = await getAddresses("client", clientId);
  487. primaryAddress.value =
  488. (addresses ?? []).find((address) => address.is_primary) ?? null;
  489. } catch {
  490. primaryAddress.value = null;
  491. } finally {
  492. addressLoading.value = false;
  493. }
  494. };
  495. const submitServicePackage = async () => {
  496. if (!servicePackage.items.length || submitting.value) return;
  497. if (!hasSingleProvider.value) {
  498. $q.notify({
  499. type: "warning",
  500. message: t("service_package.same_provider_required"),
  501. });
  502. return;
  503. }
  504. if (!primaryAddress.value) {
  505. $q.notify({
  506. type: "warning",
  507. message: t("service_package.no_primary_address_notify"),
  508. });
  509. return;
  510. }
  511. const payload = {
  512. client_id: store.user.client_id ?? store.user?.client?.id,
  513. address_id: primaryAddress.value.id,
  514. schedule_type: "default",
  515. schedules: servicePackage.items.map((item) => ({
  516. provider_id: item.provider_id,
  517. date: item.date,
  518. period_type: item.period_type,
  519. start_time: item.start_time,
  520. end_time: item.end_time,
  521. total_amount: item.base_amount,
  522. offers_meal: item.offers_meal,
  523. })),
  524. };
  525. submitting.value = true;
  526. try {
  527. const createdServicePackage = await createScheduleServicePackage(payload);
  528. const createdSchedules = createdServicePackage?.schedules ?? [];
  529. const scheduleIds =
  530. createdServicePackage?.schedule_ids ??
  531. createdSchedules.map((schedule) => schedule.id);
  532. servicePackage.setLastCreatedGroup({
  533. local_group_id: createdServicePackage?.id ?? `sp-${Date.now()}`,
  534. service_package_id: createdServicePackage?.id ?? null,
  535. schedule_ids: scheduleIds,
  536. schedules: createdSchedules,
  537. });
  538. servicePackage.clear();
  539. $q.notify({
  540. type: "positive",
  541. message: t("service_package.closed_success"),
  542. });
  543. router.push({ name: "DashboardPage", query: { success: "order_sent" } });
  544. } catch (error) {
  545. const message =
  546. error?.response?.data?.message ??
  547. error?.message ??
  548. t("service_package.submit_error");
  549. $q.notify({ type: "negative", message });
  550. } finally {
  551. submitting.value = false;
  552. }
  553. };
  554. const loadCustomRequests = async () => {
  555. const response = await dadosPedidosCliente();
  556. if (response) {
  557. customSchedulesNoProposals.value = response.customSchedulesNoProposals ?? [];
  558. schedulesProposals.value = response.schedulesProposals ?? [];
  559. }
  560. };
  561. onMounted(() => {
  562. loadPrimaryAddress();
  563. loadPlatformFees().catch(() => {});
  564. loadCustomRequests();
  565. });
  566. </script>
  567. <style scoped lang="scss">
  568. .service-package-page {
  569. min-height: 100%;
  570. }
  571. .shadow-service-package {
  572. box-shadow: 0 1px 8px rgba(0, 0, 0, 0.1);
  573. }
  574. .service-package-item-card,
  575. .checkout-card,
  576. .empty-card,
  577. .created-card {
  578. border-radius: 8px;
  579. }
  580. .summary-row {
  581. border-top: 1px solid rgba(0, 0, 0, 0.05);
  582. }
  583. .item-prices {
  584. max-width: 220px;
  585. }
  586. .service-package-discount-banner {
  587. background: rgba(34, 197, 94, 0.1);
  588. border: 1px solid rgba(34, 197, 94, 0.22);
  589. border-radius: 8px;
  590. }
  591. .summary-discount {
  592. background: rgba(34, 197, 94, 0.12);
  593. border-radius: 8px;
  594. }
  595. </style>