| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871 |
- <template>
- <q-dialog
- ref="dialogRef"
- maximized
- transition-hide="slide-down"
- transition-show="slide-up"
- >
- <div class="dialog-root">
- <div
- class="dialog-header row items-center q-px-md q-pt-md q-pb-sm bg-white"
- >
- <q-btn
- v-close-popup
- class="header-back-btn"
- color="primary"
- dense
- flat
- icon="mdi-chevron-left"
- round
- />
- <div
- class="col text-center font16 fontbold text-primary gradient-diarista q-mb-xs"
- >
- {{ $t("scheduling_page.title") }}
- </div>
- <div style="width: 36px"></div>
- </div>
- <div class="dialog-body">
- <div class="q-px-md q-pt-md">
- <div class="font16 fontbold gradient-diarista q-mb-xs">
- {{ $t("scheduling_page.about_provider") }}
- </div>
- <q-card
- class="card-border shadow-card bg-surface text-text"
- :flat="false"
- >
- <q-card-section class="q-pa-md">
- <div class="row items-center no-wrap q-gutter-x-md">
- <div class="col-2">
- <q-avatar
- :style="
- avatarColors[
- providerInfo.provider_id % avatarColors.length
- ]
- "
- >
- <img
- v-if="getProfileMediaUrl(providerInfo)"
- style="object-fit: cover; border-radius: 50%"
- :src="getProfileMediaUrl(providerInfo)"
- />
- <span v-else>
- {{
- getFirstName(providerInfo.provider_name)
- ?.slice(0, 2)
- .toUpperCase() ?? "??"
- }}
- </span>
- </q-avatar>
- </div>
- <div class="col-3 column">
- <div class="font12 fontbold text-text">
- {{ getFirstName(providerInfo?.provider_name) ?? "—" }}
- </div>
- <div class="font10 fontmedium text-grey-7">
- {{ providerDistrict ?? "—" }}
- </div>
- </div>
- <div class="col-3">
- <div class="row items-center q-gutter-x-md q-mt-xs">
- <div class="row items-center">
- <q-icon
- color="warning"
- name="mdi-star"
- size="12px"
- />
- <span class="font9 fontmedium q-ml-xs">
- {{
- (providerInfo?.average_rating != null
- ? Number(providerInfo.average_rating).toFixed(1)
- : "") +
- " (" +
- (providerInfo?.total_reviews ?? 0) +
- ")"
- }}
- </span>
- </div>
- <div class="row items-center">
- <q-icon
- color="secondary"
- name="mdi-broom"
- size="14px"
- />
- <span class="font9 fontmedium q-ml-xs">
- {{ providerInfo?.total_services ?? 0 }}
- </span>
- </div>
- </div>
- </div>
- <div class="col-2 column items-center q-gutter-y-xs">
- <q-btn
- color="pink-4"
- dense
- flat
- round
- size="sm"
- :icon="isFavorite ? 'mdi-heart' : 'mdi-heart-outline'"
- :loading="favoriteLoading"
- @click="toggleFavorite"
- />
- <q-btn
- color="grey-5"
- dense
- flat
- icon="mdi-information-outline"
- round
- size="sm"
- @click.stop="openProviderInfo"
- />
- </div>
- </div>
- </q-card-section>
- </q-card>
- </div>
- <div class="q-px-md q-pt-lg">
- <div class="font16 fontbold gradient-diarista q-mb-xs">
- {{ $t("scheduling_page.schedule_service") }}
- </div>
- <div
- v-if="loadingAvailability || loadingProvider"
- class="row items-center justify-center q-py-lg"
- >
- <q-spinner-dots
- color="primary"
- size="36px"
- />
- </div>
- <div
- v-else
- class="calendar-wrapper shadow-card q-mb-md"
- >
- <q-date
- v-model="selectedDate"
- class="full-width"
- minimal
- square
- :first-day-of-week="0"
- :options="dateOptions"
- @update:model-value="onDateSelected"
- />
- </div>
- </div>
- <div class="q-px-md q-pt-sm q-pb-xl">
- <div class="row items-center justify-between q-mb-sm">
- <div class="font16 fontbold gradient-diarista">
- {{ $t("scheduling_page.reviews_title") }}
- </div>
- <span class="text-text cursor-pointer">
- {{ $t("scheduling_page.see_all") }}
- <q-icon
- class="text-text"
- name="mdi-chevron-right"
- />
- </span>
- </div>
- <div
- v-if="loadingReviews"
- class="row items-center justify-center q-py-md"
- >
- <q-spinner-dots
- color="primary"
- size="36px"
- />
- </div>
- <div
- v-else-if="reviews.length === 0"
- class="text-center text-grey-6 text-body2 q-py-md"
- >
- {{ $t("scheduling_page.no_reviews") }}
- </div>
- <div
- v-else
- class="reviews-scroll"
- >
- <q-card
- v-for="review in reviews"
- :key="review.id"
- :flat="false"
- class="review-card card-border bg-white q-mr-sm shadow-card"
- >
- <q-card-section class="q-pa-sm">
- <div class="row items-center no-wrap q-gutter-x-sm q-mb-xs">
- <q-avatar
- :style="
- avatarColors[
- review.schedule?.client?.id % avatarColors.length
- ]
- "
- >
- <img
- v-if="review.schedule?.client?.profile_photo"
- style="object-fit: cover; border-radius: 50%"
- :src="review.schedule?.client?.profile_photo"
- />
- <span v-else>
- {{ getFirstName(review.schedule?.client?.name)?.slice(0, 1) ?? "—" }}
- </span>
- </q-avatar>
- <div class="col-3 text-text font10 fontbold">
- {{
- getFirstName(review.schedule?.client?.name) ??
- $t("scheduling_page.unknown_client")
- }}
- </div>
- <div class="row items-center q-mb-xs q-my-auto">
- <q-icon
- v-for="s in 5"
- :key="s"
- :name="s <= review.stars ? 'mdi-star' : 'mdi-star-outline'"
- color="warning"
- size="14px"
- />
- </div>
- </div>
- <div class="font9 fontregular text-text review-comment">
- {{ review.comment ?? "--------" }}
- </div>
- </q-card-section>
- </q-card>
- </div>
- </div>
- </div>
- </div>
- </q-dialog>
- </template>
- <script setup>
- import { avatarColors } from "src/helpers/avatarColors";
- import { computed, onMounted, ref } from "vue";
- import {
- createClientFavoriteProvider,
- deleteClientFavoriteProvider,
- getClientFavoriteProviders,
- } from "src/api/clientFavoriteProvider";
- import { date, useDialogPluginComponent, useQuasar } from "quasar";
- import { getClientProviderBlocks } from "src/api/schedule";
- import { getFirstName } from "src/helpers/utils";
- import { getPerfilPrestador } from "src/api/dashboard";
- import { getProfileMediaUrl } from "src/helpers/profileMedia";
- import {
- getProviderBlockedDays,
- getProviderWorkingDays,
- } from "src/api/providerAvailability";
- import { getProviderReceivedReviews } from "src/api/review";
- import { useI18n } from "vue-i18n";
- import { useRouter } from "vue-router";
- import { userStore } from "src/stores/user";
- import { useServicePackageStore } from "src/stores/servicePackage";
- import ServicePackageSummaryDialog from "./ServicePackageSummaryDialog.vue";
- import ServiceSelectionSheet from "./ServiceSelectionSheet.vue";
- import ServiceTimeSelectionDialog from "./ServiceTimeSelectionDialog.vue";
- import ProfileInfoDialog from "src/components/profile/ProfileInfoDialog.vue";
- defineEmits([...useDialogPluginComponent.emits]);
- const props = defineProps({
- provider: { required: true, type: Object },
- });
- const { t } = useI18n();
- const $q = useQuasar();
- const router = useRouter();
- const { dialogRef } = useDialogPluginComponent();
- const servicePackage = useServicePackageStore();
- const store = userStore();
- const providerInfo = ref({ ...props.provider });
- const blockedDays = ref([]);
- const bookings = ref([]);
- const favoriteId = ref(null);
- const favoriteLoading = ref(false);
- const isFavorite = ref(false);
- const loadingAvailability = ref(true);
- const loadingProvider = ref(true);
- const loadingReviews = ref(true);
- const providerClientBlocks = ref({
- existing_schedules: [],
- fully_blocked_weeks: [],
- });
- const reviews = ref([]);
- const selectedDate = ref(null);
- const workingDays = ref([]);
- const availableWeekDays = computed(() => [
- ...new Set(workingDays.value.map((wd) => wd.day)),
- ]);
- const providerDistrict = computed(
- () => providerInfo.value?.district ?? providerInfo.value?.provider_district,
- );
- const blockedDateSet = computed(
- () =>
- new Set(
- blockedDays.value
- .filter((bd) => bd.period === "all")
- .map((bd) => bd.date),
- ),
- );
- const blockedWeekStartSet = computed(
- () => new Set(providerClientBlocks.value.fully_blocked_weeks ?? []),
- );
- const normalizeDate = (dateStr) => (dateStr ?? "").replace(/\//g, "-");
- const getWeekStart = (dateStr) => {
- const normalizedDate = normalizeDate(dateStr);
- const d = new Date(`${normalizedDate}T12:00:00`);
- d.setDate(d.getDate() - d.getDay());
- return d.toISOString().split("T")[0];
- };
- const cartHasAnotherProvider = () =>
- servicePackage.items.length > 0 &&
- Number(servicePackage.items[0].provider_id) !==
- Number(providerInfo.value.provider_id);
- const getServicePackageWeekCount = (selectedDate) => {
- const weekStart = getWeekStart(selectedDate);
- return servicePackage.items.filter(
- (item) =>
- Number(item.provider_id) === Number(providerInfo.value.provider_id) &&
- getWeekStart(item.date) === weekStart,
- ).length;
- };
- const getLocalWeekCount = (selectedDate) => {
- const weekStart = getWeekStart(selectedDate);
- return bookings.value.filter(
- (booking) => getWeekStart(booking.date) === weekStart,
- ).length;
- };
- const getServerWeekCount = (selectedDate) => {
- const weekStart = getWeekStart(selectedDate);
- return (providerClientBlocks.value.existing_schedules ?? []).filter(
- (schedule) => getWeekStart(schedule.date) === weekStart,
- ).length;
- };
- const wouldExceedWeekLimit = (selectedDate) => {
- const servicePackageCount = getServicePackageWeekCount(selectedDate);
- const localCount = getLocalWeekCount(selectedDate);
- const serverCount = getServerWeekCount(selectedDate);
- return serverCount + localCount + servicePackageCount >= 2;
- };
- const dateOptions = (d) => {
- const today = date.formatDate(new Date(), "YYYY/MM/DD");
- if (d < today) return false;
- const raw = normalizeDate(d);
- const parsed = new Date(`${raw}T12:00:00`);
- const dayOfWeek = parsed.getDay();
- const isWorkingDay = availableWeekDays.value.includes(dayOfWeek);
- const isBlocked = blockedDateSet.value.has(raw);
- const isWeekBlocked = blockedWeekStartSet.value.has(getWeekStart(raw));
- if (!isWorkingDay || isBlocked || isWeekBlocked) return false;
- if (wouldExceedWeekLimit(raw)) return false;
- return true;
- };
- const formatHour = (h) => `${String(h).padStart(2, "0")}:00`;
- const loadAvailability = async () => {
- loadingAvailability.value = true;
- try {
- const clientId = store.user?.client?.id;
- const defaultClientBlocks = {
- existing_schedules: [],
- fully_blocked_weeks: [],
- };
- const [wd, bd, clientBlocks] = await Promise.all([
- getProviderWorkingDays(providerInfo.value.provider_id),
- getProviderBlockedDays(providerInfo.value.provider_id),
- clientId
- ? getClientProviderBlocks(clientId, providerInfo.value.provider_id)
- : Promise.resolve(defaultClientBlocks),
- ]);
- blockedDays.value = bd ?? [];
- providerClientBlocks.value = clientBlocks ?? defaultClientBlocks;
- workingDays.value = wd ?? [];
- } catch {
- blockedDays.value = [];
- providerClientBlocks.value = {
- existing_schedules: [],
- fully_blocked_weeks: [],
- };
- workingDays.value = [];
- } finally {
- loadingAvailability.value = false;
- }
- };
- const loadProviderProfile = async () => {
- const providerId = props.provider?.provider_id ?? props.provider?.id;
- if (!providerId) {
- loadingProvider.value = false;
- return;
- }
- loadingProvider.value = true;
- try {
- const payload = await getPerfilPrestador(providerId);
- providerInfo.value = { ...props.provider, ...payload };
- } catch {
- providerInfo.value = { ...props.provider };
- } finally {
- loadingProvider.value = false;
- }
- const hasAnyPrice = [
- "daily_price_8h",
- "daily_price_6h",
- "daily_price_4h",
- "daily_price_2h",
- ].some((key) => providerInfo.value?.[key] != null);
- if (!hasAnyPrice) {
- $q.notify({
- message: t("scheduling_page.provider_unavailable"),
- type: "warning",
- });
- dialogRef.value?.hide();
- }
- };
- const loadFavoriteState = async () => {
- const clientId = store.user?.client?.id;
- if (!clientId) return;
- try {
- const favorites = await getClientFavoriteProviders(clientId);
- const match = (favorites ?? []).find(
- (f) => Number(f.provider_id) === Number(providerInfo.value.provider_id),
- );
- isFavorite.value = !!match;
- favoriteId.value = match?.id ?? null;
- } catch {
- isFavorite.value = false;
- favoriteId.value = null;
- }
- };
- const loadReviews = async () => {
- loadingReviews.value = true;
- try {
- const all = await getProviderReceivedReviews(providerInfo.value.provider_id);
- reviews.value = (all ?? []).slice(0, 10);
- } catch {
- reviews.value = [];
- } finally {
- loadingReviews.value = false;
- }
- };
- const onDateSelected = (val) => {
- if (!val) return;
- selectedDate.value = null;
- const valFormatted = normalizeDate(val);
- const blocksOfDate = blockedDays.value.filter(
- (bd) => bd.date === valFormatted && bd.period !== "all",
- );
- const dayOfWeek = new Date(`${valFormatted}T12:00:00`).getDay();
- const dayPeriods = workingDays.value
- .filter((wd) => wd.day === dayOfWeek)
- .map((wd) => wd.period);
- const workingDayBlocks = [];
- if (!dayPeriods.includes("afternoon")) {
- workingDayBlocks.push({ init_hour: "14:00:00", end_hour: "20:00:00" });
- }
- if (!dayPeriods.includes("morning")) {
- workingDayBlocks.push({ init_hour: "07:00:00", end_hour: "13:00:00" });
- }
- const localBookingBlocks = bookings.value
- .filter((booking) => booking.date.replace(/\//g, "-") === valFormatted)
- .map((booking) => ({
- end_hour: `${booking.slot.endHour}:00:00`,
- init_hour: `${booking.slot.startHour}:00:00`,
- }));
- const servicePackageBookingBlocks = servicePackage.items
- .filter(
- (item) =>
- Number(item.provider_id) === Number(providerInfo.value.provider_id) &&
- normalizeDate(item.date) === valFormatted,
- )
- .map((item) => ({
- end_hour: item.end_time,
- init_hour: item.start_time,
- }));
- const serverBookingBlocks = (
- providerClientBlocks.value.existing_schedules ?? []
- )
- .filter((schedule) => normalizeDate(schedule.date) === valFormatted)
- .map((schedule) => ({
- end_hour: schedule.end_time,
- init_hour: schedule.start_time,
- }));
- const partialBlocks = [
- ...blocksOfDate,
- ...workingDayBlocks,
- ...localBookingBlocks,
- ...serverBookingBlocks,
- ...servicePackageBookingBlocks,
- ];
- $q.dialog({
- component: ServiceSelectionSheet,
- componentProps: {
- partialBlocks,
- provider: providerInfo.value,
- selectedDate: val,
- scheduleType: 'default'
- },
- }).onOk(({ action, serviceType, date: date_, provider: prov }) => {
- $q.dialog({
- component: ServiceTimeSelectionDialog,
- componentProps: {
- partialBlocks,
- provider: prov,
- selectedDate: date_,
- serviceType,
- },
- }).onOk((booking) => {
- if (action === "servicePackage") {
- if (cartHasAnotherProvider()) {
- $q.notify({
- message: t("service_package.other_provider_blocked"),
- type: "warning",
- timeout: 10000,
- progress: true,
- actions: [
- { icon: "close", color: "white", round: true, dense: true },
- ],
- });
- return;
- }
- servicePackage.addItem({
- date: normalizeDate(booking.date),
- end_time: formatHour(booking.slot.endHour),
- local_id: `${prov.provider_id}-${booking.date}-${booking.slot.value}-${Date.now()}`,
- offers_meal:
- booking.meal === "offer"
- ? true
- : booking.meal === "no_offer"
- ? false
- : null,
- period_type: booking.serviceType.hoursCount,
- provider_id: prov.provider_id,
- provider_name: prov.provider_name,
- provider_photo: getProfileMediaUrl(prov),
- start_time: formatHour(booking.slot.startHour),
- base_amount:
- booking.serviceType.basePrice ?? booking.serviceType.price,
- });
- if (!servicePackage.provider) {
- servicePackage.setProvider({ ...providerInfo.value });
- }
- $q.notify({
- message: t("service_package.add_success"),
- type: "positive",
- });
- dialogRef.value.hide();
- router.push({ name: "ScheduleCartPage" });
- return;
- }
- bookings.value.push(booking);
- $q.dialog({
- component: ServicePackageSummaryDialog,
- componentProps: { initialBooking: booking, provider: providerInfo.value },
- }).onOk(() => {
- dialogRef.value.hide();
- });
- });
- });
- };
- const openProviderInfo = () => {
- $q.dialog({
- component: ProfileInfoDialog,
- componentProps: {
- provider: providerInfo.value,
- },
- });
- };
- const toggleFavorite = async () => {
- if (favoriteLoading.value) return;
- const clientId = store.user?.client?.id;
- if (!clientId) {
- $q.notify({
- message: t("scheduling_page.favorite_error"),
- type: "negative",
- });
- return;
- }
- favoriteLoading.value = true;
- try {
- if (isFavorite.value) {
- if (favoriteId.value) {
- await deleteClientFavoriteProvider(favoriteId.value);
- }
- favoriteId.value = null;
- isFavorite.value = false;
- $q.notify({
- message: t("scheduling_page.favorite_removed"),
- type: "positive",
- });
- } else {
- const created = await createClientFavoriteProvider({
- client_id: clientId,
- provider_id: providerInfo.value.provider_id,
- });
- favoriteId.value = created?.id ?? null;
- isFavorite.value = true;
- $q.notify({
- message: t("scheduling_page.favorite_added"),
- type: "positive",
- });
- }
- } catch {
- $q.notify({
- message: t("scheduling_page.favorite_error"),
- type: "negative",
- });
- } finally {
- favoriteLoading.value = false;
- }
- };
- onMounted(() => {
- loadProviderProfile();
- loadAvailability();
- loadFavoriteState();
- loadReviews();
- });
- </script>
- <style scoped lang="scss">
- .dialog-root {
- width: 100vw;
- max-width: 100vw;
- height: 100vh;
- max-height: 100vh;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- background: #f9fafb;
- }
- .dialog-header {
- flex-shrink: 0;
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
- }
- .dialog-body {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- overflow-x: clip;
- }
- .calendar-wrapper {
- border-radius: 20px;
- overflow: hidden;
- background: white;
- :deep(.q-date) {
- background: white;
- width: 100%;
- }
- :deep(.q-date__calendar-days .q-btn__content) {
- color: #1e293b !important;
- }
- // dias desabilitados: visíveis mas opacos
- :deep(.q-date__calendar-days .q-btn.disabled .q-btn__content),
- :deep(.q-date__calendar-days .q-btn[disabled] .q-btn__content) {
- color: #000000 !important;
- opacity: 1 !important;
- }
- // o Quasar aplica opacity no elemento .q-btn quando disabled — reseta
- :deep(.q-date__calendar-days .q-btn.disabled),
- :deep(.q-date__calendar-days .q-btn[disabled]) {
- opacity: 1 !important;
- }
- // cabeçalho dos dias (dom, seg, ter, qua...)
- :deep(.q-date__calendar-weekdays > div) {
- color: #6366f1;
- font-weight: 700;
- opacity: 0.8;
- }
- :deep(.q-date__navigation) {
- .q-btn {
- color: #6366f1 !important;
- }
- .q-btn__content {
- color: #6366f1 !important;
- }
- }
- :deep(.q-date__event) {
- bottom: 4px;
- height: 6px;
- width: 6px;
- border-radius: 50%;
- }
- :deep(.q-date__today .q-btn__content) {
- color: #7c4dff !important;
- background: #7c4dff15;
- border-radius: 50%;
- }
- :deep(.q-date__selected .q-btn__content),
- :deep(.q-date__calendar-item .q-btn.q-date__selected .q-btn__content) {
- background: #6366f1 !important;
- color: #ffffff !important;
- border-radius: 50%;
- box-shadow: 0 4px 10px rgba(99, 102, 241, 0.4);
- }
- :deep(.q-date__view--months .q-btn),
- :deep(.q-date__view--years .q-btn) {
- color: #6366f1 !important;
- }
- :deep(.q-date__calendar-item--out) {
- color: #b9b9b9 !important;
- opacity: 0.8 !important;
- }
- }
- // Reviews scroll horizontal
- .reviews-scroll {
- display: flex;
- flex-direction: row;
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- scrollbar-width: none;
- padding-bottom: 8px;
- &::-webkit-scrollbar {
- display: none;
- }
- }
- .review-card {
- flex-shrink: 0;
- min-width: 220px;
- max-width: 240px;
- border-radius: 12px;
- margin-right: 8px;
- }
- .review-comment {
- display: -webkit-box;
- -webkit-line-clamp: 3;
- line-clamp: 3;
- -webkit-box-orient: vertical;
- overflow: hidden;
- }
- .min-width-0 {
- min-width: 0;
- }
- </style>
|