Forráskód Böngészése

feat: :sparkles: feat (agendamentos) fluxo cancelamento de um agendamento

foi criado o fluxo de cancelamento de um agendamento por parte do prestador

fase:dev | origin:escopo
Gustavo Zanatta 2 hete
szülő
commit
476b3661bc

+ 5 - 0
src/api/schedule.js

@@ -5,6 +5,11 @@ export const updateScheduleStatus = async (id, status) => {
   return data.payload
 }
 
+export const cancelSchedule = async (id, cancelText) => {
+  const { data } = await api.patch(`/schedule/${id}/cancel`, { cancel_text: cancelText })
+  return data.payload
+}
+
 export const verifyScheduleCode = async (scheduleId, code) => {
   const response = await api.post(`/custom-schedule-verify-code/${scheduleId}`, { code })
   return response.data

+ 2 - 0
src/components/dashboard/DashboardNextSchedules.vue

@@ -56,6 +56,7 @@
                 size="sm"
                 class="col-auto q-px-none btn-details"
                 :label="$t('provider.dashboard.next_schedules.details')"
+                @click="emit('view-details', item)"
               />
               <q-space />
               <div class="row items-center q-gutter-x-xs text-grey-7" style="font-size: 11px;">
@@ -83,6 +84,7 @@ import { labelsPeriodTypes } from 'src/helpers/arraysOptions/labelsPeriodTypes.j
 import { useQuasar } from 'quasar';
 import { formatAddress } from 'src/helpers/utils';
 defineProps({ data: { type: Array, default: () => [] } });
+const emit = defineEmits(['view-details']);
 
 const t = useI18n().t;
 const $q = useQuasar();

+ 138 - 0
src/components/dashboard/ScheduleCancelDialog.vue

@@ -0,0 +1,138 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="cancel-dialog-card bg-surface shadow-card" :flat="false">
+
+      <div class="row justify-end q-pt-sm q-pr-sm">
+        <q-btn flat round dense icon="close" color="grey-6" size="sm" @click="onDialogCancel" />
+      </div>
+
+      <q-card-section class="q-pt-none q-pb-sm q-px-lg text-center">
+        <div class="cancel-title text-secondary text-weight-bold">
+          {{ $t('provider.dashboard.cancel_schedule.title') }}
+        </div>
+      </q-card-section>
+
+      <q-card-section class="q-pt-none q-pb-sm q-px-lg">
+        <div class="text-body2 text-grey-8 text-weight-bold q-mb-xs">
+          {{ $t('provider.dashboard.cancel_schedule.reason_label') }}
+        </div>
+        <q-input
+          v-model="cancelText"
+          type="textarea"
+          outlined
+          dense
+          :placeholder="$t('provider.dashboard.cancel_schedule.reason_placeholder')"
+          rows="4"
+          color="secondary"
+          :rules="[val => (val && val.trim().length >= 5) || ' ']"
+          hide-bottom-space
+        />
+      </q-card-section>
+
+      <q-card-section class="q-pt-xs q-pb-md q-px-lg">
+        <div class="warning-box row no-wrap q-gutter-x-sm q-pa-sm">
+          <q-icon name="mdi-alert-outline" color="secondary" size="22px" class="q-mt-xs flex-shrink-0" />
+          <div>
+            <span class="text-caption text-weight-bold text-grey-9">
+              {{ $t('provider.dashboard.cancel_schedule.warning_title') }}
+            </span>
+            <span class="text-caption text-grey-8">
+              {{ ' ' + $t('provider.dashboard.cancel_schedule.warning_free') }}
+            </span>
+            <br />
+            <span class="text-caption text-grey-8">
+              {{ $t('provider.dashboard.cancel_schedule.warning_fee') }}
+            </span>
+          </div>
+        </div>
+      </q-card-section>
+
+      <q-card-section class="q-pt-none q-pb-lg q-px-lg">
+        <div class="row justify-center q-gutter-x-md">
+          <q-btn
+            unelevated
+            rounded
+            no-caps
+            class="btn-action bg-grey-3 text-grey-8"
+            :label="$t('provider.dashboard.cancel_schedule.btn_cancel')"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            unelevated
+            rounded
+            no-caps
+            color="secondary"
+            class="btn-action"
+            :loading="loading"
+            :disable="!cancelText || cancelText.trim().length < 5"
+            :label="$t('provider.dashboard.cancel_schedule.btn_keep')"
+            @click="confirmCancel"
+          />
+        </div>
+      </q-card-section>
+
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { useI18n } from 'vue-i18n'
+import { cancelSchedule } from 'src/api/schedule'
+
+const props = defineProps({
+  schedule: {
+    type: Object,
+    required: true
+  }
+})
+
+const { t } = useI18n()
+const $q = useQuasar()
+const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } = useDialogPluginComponent()
+
+const cancelText = ref('')
+const loading = ref(false)
+
+const confirmCancel = async () => {
+  if (!cancelText.value || cancelText.value.trim().length < 5) return
+  loading.value = true
+  try {
+    await cancelSchedule(props.schedule.id, cancelText.value.trim())
+    onDialogOK()
+  } catch {
+    $q.notify({ message: t('http.errors.failed'), color: 'negative' })
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.cancel-dialog-card {
+  width: 340px;
+  max-width: 94vw;
+  border-radius: 20px !important;
+  overflow: hidden;
+}
+
+.cancel-title {
+  font-size: 18px;
+  line-height: 1.35;
+}
+
+.warning-box {
+  background: #e8f4fd;
+  border-radius: 10px;
+}
+
+.btn-action {
+  min-width: 110px;
+  font-weight: 700;
+}
+
+.flex-shrink-0 {
+  flex-shrink: 0;
+}
+</style>

+ 186 - 2
src/components/dashboard/SolicitationDetailsDialog.vue

@@ -2,7 +2,109 @@
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <q-card class="solicitation-dialog-card bg-surface shadow-card" :flat="false">
 
-      <template v-if="view === 'details'">
+      <div class="text-text">
+      </div>
+      <template v-if="isPaid">
+        <div class="row justify-end q-pt-sm q-pr-sm">
+          <q-btn flat round dense icon="close" color="grey-6" size="sm" @click="onDialogCancel" />
+        </div>
+
+        <q-card-section class="column items-center q-pt-xs q-pb-xs">
+          <q-avatar size="72px" class="q-mb-sm">
+            <img :src="solicitation.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'" />
+          </q-avatar>
+          <div class="text-subtitle1 text-weight-bold text-text">
+            {{ solicitation.client_name }}
+          </div>
+          <div v-if="solicitation.address?.district" class="text-caption text-grey-6 q-mt-xs">
+            {{ solicitation.address.district }}
+          </div>
+          <div v-if="solicitation.address" class="text-caption text-grey-5 text-center q-mt-xs">
+            {{ formatAddressShort(solicitation.address) }}
+          </div>
+        </q-card-section>
+
+        <q-separator class="q-mx-md q-my-xs" />
+
+        <q-card-section class="q-pt-xs q-pb-xs q-px-lg">
+          <div
+            v-for="service in servicesList"
+            :key="service.label"
+            class="row items-center q-gutter-x-sm q-mb-xs"
+          >
+            <q-icon
+              :name="service.active ? 'mdi-check' : 'mdi-close'"
+              :color="service.active ? 'secondary' : 'grey-4'"
+              size="16px"
+            />
+            <span class="text-body2 text-grey-8">{{ service.label }}</span>
+          </div>
+        </q-card-section>
+
+        <q-separator class="q-mx-md q-my-xs" />
+
+        <q-card-section class="q-pt-xs q-pb-xs q-px-lg">
+          <div class="detail-row">
+            <span class="detail-label text-secondary text-weight-bold">{{ $t('common.terms.date') }}</span>
+            <span class="detail-value text-grey-9">{{ fullDateLabel }}</span>
+          </div>
+          <div class="detail-row">
+            <span class="detail-label text-secondary text-weight-bold">{{ $t('common.terms.hour') }}</span>
+            <span class="detail-value text-grey-9">
+              {{ solicitation.start_time?.slice(0, 5) }} {{ $t('common.to') }} {{ solicitation.end_time?.slice(0, 5) }}
+            </span>
+          </div>
+          <div class="detail-row">
+            <span class="detail-label text-secondary text-weight-bold">{{ $t('common.terms.total_amount') }}</span>
+            <span class="detail-value text-grey-9">{{ formatCurrency(baseAmount) }}</span>
+          </div>
+          <div class="detail-row">
+            <span class="detail-label text-secondary text-weight-bold">{{ $t('provider.dashboard.cancel_schedule.service_fee') }}</span>
+            <span class="detail-value text-grey-9">{{ formatCurrency(serviceFee) }}</span>
+          </div>
+        </q-card-section>
+
+        <q-separator class="q-mx-md q-my-xs" />
+
+        <q-card-section class="q-pt-xs q-pb-sm text-center">
+          <span class="text-weight-bold text-grey-9">{{ $t('provider.dashboard.cancel_schedule.total') }}</span>
+          <span class="total-value text-secondary text-weight-bold q-ml-xs">{{ formatCurrency(solicitation.total_amount) }}</span>
+        </q-card-section>
+
+        <q-card-section class="q-pt-none q-pb-xs q-px-lg">
+          <q-btn
+            unelevated
+            rounded
+            no-caps
+            color="secondary"
+            class="full-width btn-close-paid"
+            :label="$t('provider.dashboard.cancel_schedule.close')"
+            @click="onDialogCancel"
+          />
+        </q-card-section>
+
+        <q-card-section class="q-pt-xs q-pb-md text-center">
+          <div class="row justify-center q-gutter-x-lg">
+            <q-btn
+              flat
+              no-caps
+              color="grey-7"
+              size="sm"
+              :label="$t('provider.dashboard.cancel_schedule.btn_cancel_order')"
+              @click="openCancelDialog"
+            />
+            <q-btn
+              flat
+              no-caps
+              color="grey-7"
+              size="sm"
+              :label="$t('provider.dashboard.cancel_schedule.help')"
+            />
+          </div>
+        </q-card-section>
+      </template>
+
+      <template v-else-if="view === 'details'">
         <div class="row justify-end q-pt-sm q-pr-sm">
           <q-btn flat round dense icon="close" color="grey-6" size="sm" @click="onDialogCancel" />
         </div>
@@ -166,10 +268,11 @@
 
 <script setup>
 import { ref, computed } from 'vue'
-import { useDialogPluginComponent } from 'quasar'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
 import { useI18n } from 'vue-i18n'
 import { formatCurrency } from 'src/helpers/utils'
 import { labelsPeriodTypes } from 'src/helpers/arraysOptions/labelsPeriodTypes.js'
+import ScheduleCancelDialog from './ScheduleCancelDialog.vue'
 
 const props = defineProps({
   solicitation: {
@@ -184,8 +287,11 @@ const props = defineProps({
 })
 
 const { t } = useI18n()
+const $q = useQuasar()
 const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } = useDialogPluginComponent()
 
+const isPaid = computed(() => props.solicitation.status === 'paid')
+
 const view = ref(props.initialView)
 const loading = ref(false)
 
@@ -217,6 +323,50 @@ const dayMonthLabel = computed(() => {
   return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })
 })
 
+const fullDateLabel = computed(() => {
+  const d = parseLocalDate(props.solicitation.date)
+  if (!d) return ''
+  return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' })
+})
+
+const SERVICE_FEE_RATE = 0.06
+const serviceFee = computed(() => {
+  const total = parseFloat(props.solicitation.total_amount) || 0
+  return parseFloat((total * SERVICE_FEE_RATE).toFixed(2))
+})
+const baseAmount = computed(() => {
+  const total = parseFloat(props.solicitation.total_amount) || 0
+  return parseFloat((total - serviceFee.value).toFixed(2))
+})
+
+const servicesList = computed(() => {
+  const list = []
+  const s = props.solicitation
+  if (s.schedule_type === 'custom' && s.specialities?.length) {
+    s.specialities.forEach(sp => list.push({ label: sp.name, active: true }))
+  }
+  if (s.offers_meal !== undefined) {
+    list.push({
+      label: s.offers_meal
+        ? t('provider.dashboard.solicitations.offers_meal')
+        : t('provider.dashboard.solicitations.not_offers_meal'),
+      active: !!s.offers_meal
+    })
+  }
+  if (!list.length) {
+    list.push({ label: t('provider.dashboard.next_schedules.default'), active: true })
+  }
+  return list
+})
+
+const formatAddressShort = (addr) => {
+  if (!addr) return ''
+  const parts = [addr.address, addr.number].filter(Boolean)
+  const street = parts.join(', ')
+  const suffix = addr.district ? `, ${addr.district}` : ''
+  return street + suffix
+}
+
 const backFromConfirm = () => {
   if (props.initialView !== 'details') {
     onDialogCancel()
@@ -234,6 +384,15 @@ const confirmReject = () => {
   loading.value = true
   onDialogOK({ action: 'reject', id: props.solicitation.id })
 }
+
+const openCancelDialog = () => {
+  $q.dialog({
+    component: ScheduleCancelDialog,
+    componentProps: { schedule: props.solicitation }
+  }).onOk(() => {
+    onDialogOK({ action: 'cancelled', id: props.solicitation.id })
+  })
+}
 </script>
 
 <style scoped lang="scss">
@@ -255,6 +414,12 @@ const confirmReject = () => {
   font-weight: 700;
 }
 
+.btn-close-paid {
+  font-weight: 700;
+  font-size: 16px;
+  padding: 10px 0;
+}
+
 .confirm-title {
   font-size: 20px;
   line-height: 1.35;
@@ -267,4 +432,23 @@ const confirmReject = () => {
   padding: 0 8px;
 }
 
+.detail-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  margin-bottom: 4px;
+}
+
+.detail-label {
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.detail-value {
+  font-size: 13px;
+}
+
+.total-value {
+  font-size: 22px;
+}
 </style>

+ 16 - 0
src/i18n/locales/en.json

@@ -293,6 +293,22 @@
         "title": "Last ones performed",
         "reschedule": "Reschedule"
       },
+      "cancel_schedule": {
+        "title": "Are you sure you want to cancel your order?",
+        "reason_label": "What is the reason for cancellation?",
+        "reason_placeholder": "Describe the reason...",
+        "warning_title": "Attention!",
+        "warning_free": "Free cancellations up to 12 hours before the scheduled time.",
+        "warning_fee": "Cancellations after this period result in a 50% refund as compensation for the professional.",
+        "btn_cancel": "cancel",
+        "btn_keep": "keep the order",
+        "btn_cancel_order": "Cancel order",
+        "help": "Help",
+        "schedule_details_title": "Details",
+        "service_fee": "Service fee:",
+        "total": "Total:",
+        "close": "close"
+      },
       "providers_close": {
         "title": "Close to you",
         "until_8h": "Up to 8h",

+ 16 - 0
src/i18n/locales/es.json

@@ -293,6 +293,22 @@
         "title": "Últimas realizadas",
         "reschedule": "Reprogramar"
       },
+      "cancel_schedule": {
+        "title": "¿Estás seguro de que deseas cancelar tu pedido?",
+        "reason_label": "¿Cuál es el motivo de la cancelación?",
+        "reason_placeholder": "Describe el motivo...",
+        "warning_title": "¡Atención!",
+        "warning_free": "Cancelaciones gratuitas hasta 12 horas antes del horario programado.",
+        "warning_fee": "Las cancelaciones después de este período generan un reembolso del 50% como compensación al profesional.",
+        "btn_cancel": "cancelar",
+        "btn_keep": "mantener el pedido",
+        "btn_cancel_order": "Cancelar pedido",
+        "help": "Ayuda",
+        "schedule_details_title": "Detalles",
+        "service_fee": "Tarifa de servicio:",
+        "total": "Total:",
+        "close": "cerrar"
+      },
       "providers_close": {
         "title": "Cerca de ti",
         "until_8h": "Hasta 8h",

+ 16 - 0
src/i18n/locales/pt.json

@@ -293,6 +293,22 @@
         "title": "Últimas realizadas",
         "reschedule": "Reagendar"
       },
+      "cancel_schedule": {
+        "title": "Tem certeza que deseja cancelar seu pedido?",
+        "reason_label": "Qual é o motivo do cancelamento?",
+        "reason_placeholder": "Descreva o motivo...",
+        "warning_title": "Atenção!",
+        "warning_free": "Cancelamentos gratuitos até 12h antes do horário agendado.",
+        "warning_fee": "Cancelamentos após este período geram reembolso de 50% do valor, como taxa de compensação ao profissional.",
+        "btn_cancel": "cancelar",
+        "btn_keep": "manter o pedido",
+        "btn_cancel_order": "Cancelar pedido",
+        "help": "Ajuda",
+        "schedule_details_title": "Detalhes",
+        "service_fee": "Taxa de serviço:",
+        "total": "Total:",
+        "close": "fechar"
+      },
       "providers_close": {
         "title": "Próximos a você",
         "until_8h": "Até 8h",

+ 15 - 3
src/pages/dashboard/DashboardPage.vue

@@ -9,16 +9,17 @@
       <DashboardHeaderBar :data="headerBar" />
       <DashboardSummaryInfos :data="summaryInfos" />
       <DashboardPriceSuggest :data="priceSuggestion"/>
-      <DashboardTodayServices :data="todayServices" @refresh="loadDashboard" />
+      <DashboardTodayServices v-if="todayServices?.length > 0" :data="todayServices" @refresh="loadDashboard" />
       <DashboardScrollAreaSchedules />
       <DashboardSolicitations
+        v-if="solicitations?.length > 0"
         :data="solicitations"
         @accept="(item) => openDetailsDialog(item, 'confirm-accept')"
         @reject="(item) => openDetailsDialog(item, 'confirm-reject')"
         @view-details="(item) => openDetailsDialog(item)"
       />
-      <DashboardNextSchedules :data="nextSchedules" />
-      <DashboardOpportunities :data="opportunities"/>
+      <DashboardNextSchedules v-if="nextSchedules?.length > 0" :data="nextSchedules" @view-details="(item) => openNextScheduleDialog(item)" />
+      <DashboardOpportunities v-if="opportunities?.length > 0" :data="opportunities"/>
     </template>
   </div>
 </template>
@@ -81,6 +82,17 @@ const openDetailsDialog = (solicitation, initialView = 'details') => {
   });
 };
 
+const openNextScheduleDialog = (schedule) => {
+  $q.dialog({
+    component: SolicitationDetailsDialog,
+    componentProps: { solicitation: schedule }
+  }).onOk(async ({ action }) => {
+    if (action === 'cancelled') {
+      await loadDashboard();
+    }
+  });
+};
+
 onMounted(async () => {
   await loadDashboard();
   loading.value = false;