Sfoglia il codice sorgente

feat: :sparkles: feat (agendamentos) fluxo de aceite/recusa do prestador

foi criado o fluxo de aceite e recusa de um agendamento default solicitado pelo cliente. O prestador consegue recusar ou aceitar diretamente pelo card, ou pode ver mais detalhes do agendamento e aceitar/recusar por dentro do card.

fase:dev | origin:escopo
Gustavo Zanatta 3 settimane fa
parent
commit
c11bc350b3

+ 6 - 0
src/api/schedule.js

@@ -0,0 +1,6 @@
+import api from 'src/api'
+
+export const updateScheduleStatus = async (id, status) => {
+  const { data } = await api.patch(`/schedule/${id}/status`, { status })
+  return data.payload
+}

+ 17 - 11
src/components/dashboard/DashboardSolicitations.vue

@@ -34,7 +34,7 @@
                   {{ $t('common.from') }}
                   <span class="text-schedule-date-bold">
                     {{ item.start_time?.slice(0, 5) }}
-                    
+
                     {{ $t('common.to') }}
 
                     {{ item.end_time?.slice(0, 5) }}
@@ -61,6 +61,7 @@
                   size="sm"
                   class="col-auto q-px-none btn-details"
                   :label="$t('common.details')"
+                  @click="emit('view-details', item)"
                 />
                 <div class="text-schedule-date-bold text-text text-weight-bold">
                   {{ item.time_since_request }}
@@ -74,6 +75,7 @@
                 class="col-auto bg-grey-3 text-grey-8 btn-action"
                 size="sm"
                 :label="$t('common.refuse')"
+                @click="emit('reject', item)"
               />
               <q-btn
                 unelevated
@@ -83,6 +85,7 @@
                 class="col-auto btn-action"
                 size="sm"
                 :label="$t('common.accept')"
+                @click="emit('accept', item)"
               />
             </div>
           </q-card-section>
@@ -104,25 +107,28 @@ defineProps({
   }
 });
 
+const emit = defineEmits(['accept', 'reject', 'view-details']);
+
 const t = useI18n().t;
 
+const parseLocalDate = (iso) => {
+  if (!iso) return null;
+  const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
+  return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null;
+};
+
 const formatWeekday = (iso) => {
-  if (!iso) return '';
-  const d = new Date(iso);
+  const d = parseLocalDate(iso);
+  if (!d) return '';
   const w = d.toLocaleDateString('pt-BR', { weekday: 'long' });
   return w.charAt(0).toUpperCase() + w.slice(1);
 };
 
 const formatDayMonth = (iso) => {
-  if (!iso) return '';
-  return new Date(iso).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
+  const d = parseLocalDate(iso);
+  if (!d) return '';
+  return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
 };
-
-// const openTimeSynceCreatedSolicitation = (id) => {
-//   // Implementar lógica para abrir detalhes da solicitação
-  
-// };
-
 </script>
 
 <style scoped lang="scss">

+ 270 - 0
src/components/dashboard/SolicitationDetailsDialog.vue

@@ -0,0 +1,270 @@
+<template>
+  <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="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-sm q-pb-sm">
+          <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 class="text-price q-mt-xs">{{ formatCurrency(solicitation.total_amount) }}</div>
+          <div class="text-caption text-grey-6">{{ periodLabel }}</div>
+        </q-card-section>
+
+        <q-card-section class="column items-center q-pt-none q-pb-sm text-text">
+          <div class="text-body2 text-center">
+            <span class="text-weight-bold">{{ weekdayLabel + ' ,' + dayMonthLabel }}</span>
+          </div>
+          <div class="text-body2 text-center">
+            {{ $t('common.from') }} <strong>{{ solicitation.start_time?.slice(0, 5) }}</strong>
+            {{ $t('common.to') }} <strong>{{ solicitation.end_time?.slice(0, 5) }}</strong>
+          </div>
+        </q-card-section>
+
+        <q-card-section class="column items-center q-pt-none q-pb-xs">
+          <div class="row items-center q-gutter-x-xs">
+            <q-icon
+              :name="solicitation.offers_meal ? 'mdi-food' : 'mdi-food-off'"
+              :color="solicitation.offers_meal ? 'positive' : 'grey-5'"
+              size="18px"
+            />
+            <span class="text-body2 text-grey-7">
+              {{ solicitation.offers_meal
+                ? $t('provider.dashboard.solicitations.offers_meal')
+                : $t('provider.dashboard.solicitations.not_offers_meal') }}
+            </span>
+          </div>
+        </q-card-section>
+
+        <q-card-section class="column items-center q-pt-xs q-pb-sm text-text">
+          <div class="row items-center q-gutter-x-xs">
+            <q-icon name="mdi-map-marker" color="secondary" size="20px" />
+            <span class="text-body1 text-weight-bold">{{ solicitation.address?.district || 'N/A' }}</span>
+          </div>
+          <div class="text-caption text-grey-6 text-center q-mt-xs">
+            {{ $t('provider.dashboard.solicitations.distance_prefix') }}
+            <strong>{{ (solicitation.distance || 0) + ' km' }}</strong>
+            {{ $t('provider.dashboard.solicitations.distance_suffix') }}
+          </div>
+          <div class="q-mt-xs">
+            <span class="text-caption text-grey-6">{{ $t('provider.dashboard.solicitations.via_agenda_text') }} </span>
+            <span class="text-caption text-secondary text-weight-bold">{{ $t('provider.dashboard.solicitations.via_agenda') }}</span>
+          </div>
+        </q-card-section>
+
+        <q-separator />
+
+        <q-card-section class="q-py-sm">
+          <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('common.refuse')"
+              @click="view = 'confirm-reject'"
+            />
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              color="secondary"
+              class="btn-action"
+              :label="$t('common.accept')"
+              @click="view = 'confirm-accept'"
+            />
+          </div>
+          <div class="row justify-center q-mt-xs">
+            <q-btn flat no-caps color="grey-7" size="sm" :label="$t('common.actions.back')" @click="onDialogCancel" />
+          </div>
+        </q-card-section>
+
+        <div class="q-pa-md">
+          <span class="text-caption text-warning text-weight-bold">{{ $t('provider.dashboard.solicitations.same_day_warning_label') }} </span>
+          <span class="text-caption text-grey-8">{{ $t('provider.dashboard.solicitations.same_day_warning') }}</span>
+        </div>
+      </template>
+
+      <template v-else-if="view === 'confirm-accept'">
+        <q-card-section class="column items-center q-pt-xl q-pb-lg text-center">
+          <div class="confirm-title text-secondary text-weight-bold">
+            {{ $t('provider.dashboard.solicitations.confirm_accept') }}
+          </div>
+        </q-card-section>
+
+        <q-card-section class="q-pt-none q-pb-xl">
+          <div class="row justify-center q-gutter-x-md">
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              color="purple-5"
+              class="btn-action"
+              :label="$t('common.actions.back')"
+              @click="backFromConfirm"
+            />
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              color="secondary"
+              class="btn-action"
+              :loading="loading"
+              :label="$t('common.accept')"
+              @click="confirmAccept"
+            />
+          </div>
+        </q-card-section>
+      </template>
+
+      <template v-else-if="view === 'confirm-reject'">
+        <q-card-section class="column items-center q-pt-xl q-pb-md text-center">
+          <div class="confirm-title text-secondary text-weight-bold">
+            {{ $t('provider.dashboard.solicitations.confirm_reject') }}
+          </div>
+          <div class="q-mt-md text-body2 text-grey-7 confirm-tip">
+            <strong>{{ $t('provider.dashboard.solicitations.tip_label') }} </strong>
+            {{ $t('provider.dashboard.solicitations.reject_tip') }}
+          </div>
+        </q-card-section>
+
+        <q-card-section class="q-pt-none q-pb-xl">
+          <div class="row justify-center q-gutter-x-md">
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              color="purple-5"
+              class="btn-action"
+              :label="$t('common.actions.back')"
+              @click="backFromConfirm"
+            />
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              color="secondary"
+              class="btn-action"
+              :loading="loading"
+              :label="$t('common.refuse')"
+              @click="confirmReject"
+            />
+          </div>
+        </q-card-section>
+      </template>
+
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { useDialogPluginComponent } from 'quasar'
+import { useI18n } from 'vue-i18n'
+import { formatCurrency } from 'src/helpers/utils'
+import { labelsPeriodTypes } from 'src/helpers/arraysOptions/labelsPeriodTypes.js'
+
+const props = defineProps({
+  solicitation: {
+    type: Object,
+    required: true
+  },
+  initialView: {
+    type: String,
+    default: 'details',
+    validator: (v) => ['details', 'confirm-accept', 'confirm-reject'].includes(v)
+  }
+})
+
+const { t } = useI18n()
+const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } = useDialogPluginComponent()
+
+const view = ref(props.initialView)
+const loading = ref(false)
+
+const periodLabel = computed(() => {
+  const found = labelsPeriodTypes.find(l => l.value == props.solicitation.period_type)
+  return found ? t(found.label) : ''
+})
+
+const parseLocalDate = (dateStr) => {
+  if (!dateStr) return null
+  const s = String(dateStr)
+  const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/)
+  if (iso) return new Date(+iso[1], +iso[2] - 1, +iso[3])
+  const dmy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/)
+  if (dmy) return new Date(+dmy[3], +dmy[2] - 1, +dmy[1])
+  return null
+}
+
+const weekdayLabel = computed(() => {
+  const d = parseLocalDate(props.solicitation.date)
+  if (!d) return ''
+  const w = d.toLocaleDateString('pt-BR', { weekday: 'long' })
+  return w.charAt(0).toUpperCase() + w.slice(1)
+})
+
+const dayMonthLabel = computed(() => {
+  const d = parseLocalDate(props.solicitation.date)
+  if (!d) return ''
+  return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })
+})
+
+const backFromConfirm = () => {
+  if (props.initialView !== 'details') {
+    onDialogCancel()
+  } else {
+    view.value = 'details'
+  }
+}
+
+const confirmAccept = () => {
+  loading.value = true
+  onDialogOK({ action: 'accept', id: props.solicitation.id })
+}
+
+const confirmReject = () => {
+  loading.value = true
+  onDialogOK({ action: 'reject', id: props.solicitation.id })
+}
+</script>
+
+<style scoped lang="scss">
+.solicitation-dialog-card {
+  width: 320px;
+  max-width: 92vw;
+  border-radius: 20px !important;
+  overflow: hidden;
+}
+
+.text-price {
+  font-size: 24px;
+  font-weight: 700;
+  color: var(--q-secondary);
+}
+
+.btn-action {
+  min-width: 110px;
+  font-weight: 700;
+}
+
+.confirm-title {
+  font-size: 20px;
+  line-height: 1.35;
+  padding: 0 12px;
+}
+
+.confirm-tip {
+  font-size: 13px;
+  line-height: 1.5;
+  padding: 0 8px;
+}
+
+</style>

+ 13 - 1
src/i18n/locales/en.json

@@ -238,7 +238,19 @@
         "until_8h": "Full day (up to 8h)",
         "until_6h": "Standard (up to 6h)",
         "until_4h": "Medium (up to 4h)",
-        "until_2h": "Quick (up to 2h)"
+        "until_2h": "Quick (up to 2h)",
+        "offers_meal": "Offers meal",
+        "not_offers_meal": "Does not offer meal",
+        "distance_prefix": "At",
+        "distance_suffix": "km from your registered address.",
+        "via_agenda_text": "Request made via",
+        "via_agenda": "schedule",
+        "same_day_warning_label": "Note:",
+        "same_day_warning": "When receiving more than one request for the same day, check the time and distance to plan your travel time.",
+        "confirm_accept": "Are you sure you want to accept this service?",
+        "confirm_reject": "Are you sure you want to refuse this service?",
+        "tip_label": "Tip:",
+        "reject_tip": "If you are not available, access your profile and block the days you do not want to receive requests."
       },
       "opportunities": {
         "title": "Opportunities"

+ 13 - 1
src/i18n/locales/es.json

@@ -238,7 +238,19 @@
         "until_8h": "Integral (hasta 8h)",
         "until_6h": "Estándar (Hasta 6h)",
         "until_4h": "Medio Período (Hasta 4h)",
-        "until_2h": "Jornada Rápida (Hasta 2h)"
+        "until_2h": "Jornada Rápida (Hasta 2h)",
+        "offers_meal": "Ofrece comida",
+        "not_offers_meal": "No ofrece comida",
+        "distance_prefix": "A",
+        "distance_suffix": "km de su dirección registrada.",
+        "via_agenda_text": "Pedido hecho vía",
+        "via_agenda": "agenda",
+        "same_day_warning_label": "Aviso:",
+        "same_day_warning": "Al recibir más de una solicitud para el mismo día, verifique el horario y la distancia para planificar su tiempo de desplazamiento.",
+        "confirm_accept": "¿Está seguro de que desea aceptar este servicio?",
+        "confirm_reject": "¿Está seguro de que desea rechazar este servicio?",
+        "tip_label": "Consejo:",
+        "reject_tip": "Si no tienes disponibilidad, accede a tu perfil y cierra los días en que no deseas recibir solicitudes."
       },
       "opportunities": {
         "title": "Oportunidades"

+ 13 - 1
src/i18n/locales/pt.json

@@ -238,7 +238,19 @@
         "until_8h": "Integral (até 8h)",
         "until_6h": "Padrão (Até 6h)",
         "until_4h": "Meio Período (Até 4h)",
-        "until_2h": "Diária Rápida (Até 2h)"
+        "until_2h": "Diária Rápida (Até 2h)",
+        "offers_meal": "Oferece refeição",
+        "not_offers_meal": "Não oferece refeição",
+        "distance_prefix": "Há",
+        "distance_suffix": "km de distância do seu endereço cadastrado.",
+        "via_agenda_text": "Pedido feito via",
+        "via_agenda": "agenda",
+        "same_day_warning_label": "Aviso:",
+        "same_day_warning": "Ao receber mais de uma solicitação para o mesmo dia, verifique o horário e a distância para planejar seu tempo de deslocamento.",
+        "confirm_accept": "Tem certeza que deseja aceitar esse serviço?",
+        "confirm_reject": "Tem certeza que deseja recusar esse serviço?",
+        "tip_label": "Dica:",
+        "reject_tip": "Se você não tiver disponibilidade, acesse o seu perfil e feche os dias que não deseja receber solicitações."
       },
       "opportunities": {
         "title": "Oportunidades"

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

@@ -10,7 +10,12 @@
       <DashboardSummaryInfos :data="summaryInfos" />
       <DashboardPriceSuggest :data="priceSuggestion"/>
       <DashboardScrollAreaSchedules />
-      <DashboardSolicitations :data="solicitations"/>
+      <DashboardSolicitations
+        :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"/>
     </template>
@@ -25,8 +30,11 @@ import DashboardScrollAreaSchedules from 'src/components/dashboard/DashboardScro
 import DashboardSolicitations from 'src/components/dashboard/DashboardSolicitations.vue';
 import DashboardNextSchedules from 'src/components/dashboard/DashboardNextSchedules.vue';
 import DashboardOpportunities from 'src/components/dashboard/DashboardOpportunities.vue';
+import SolicitationDetailsDialog from 'src/components/dashboard/SolicitationDetailsDialog.vue';
 import { onMounted, ref } from 'vue';
+import { useQuasar } from 'quasar';
 import { dadosDashboard } from 'src/api/dashboard';
+import { updateScheduleStatus } from 'src/api/schedule';
 
 const headerBar = ref({});
 const summaryInfos = ref({});
@@ -35,10 +43,13 @@ const solicitations = ref([]);
 const nextSchedules = ref([]);
 const opportunities = ref([]);
 
+const $q = useQuasar();
+
 const loading = ref(true);
-onMounted( async () => {
+
+const loadDashboard = async () => {
   const response = await dadosDashboard();
-  if(response) {
+  if (response) {
     headerBar.value = response.headerBar;
     summaryInfos.value = response.summaryInfos;
     priceSuggestion.value = response.priceSuggested;
@@ -46,6 +57,28 @@ onMounted( async () => {
     nextSchedules.value = response.nextSchedules;
     opportunities.value = response.opportunities;
   }
+};
+
+const handleScheduleAction = async (id, status) => {
+  try {
+    await updateScheduleStatus(id, status);
+    await loadDashboard();
+  } catch (e) {
+    console.log(e);
+  }
+};
+
+const openDetailsDialog = (solicitation, initialView = 'details') => {
+  $q.dialog({
+    component: SolicitationDetailsDialog,
+    componentProps: { solicitation, initialView }
+  }).onOk(async ({ action, id }) => {
+    await handleScheduleAction(id, action === 'accept' ? 'accepted' : 'rejected');
+  });
+};
+
+onMounted(async () => {
+  await loadDashboard();
   loading.value = false;
 });
 </script>