Procházet zdrojové kódy

feat: :sparkles: feat(agendamento-sob-medida) Finalizado a parte de oportunidades já com a regra de negocio

Foram implementadas validações na listagem de oportunidades para garantir a disponibilidade do prestador, considerando conflitos de horário, limite semanal por cliente e regras de bloqueio definidas pelo sistema.

fase:dev | origin:escopo
kayo henrique před 1 týdnem
rodič
revize
4ce9fc3249

+ 25 - 3
src/api/opportunities.js

@@ -1,7 +1,29 @@
 import api from 'src/api'
 
 export const getProviderOpportunities = async (providerId) => {
-  const { data } = await api.get(`/custom-schedule-available?provider_id=${providerId}`)
+  try {
+    const { data } = await api.get(
+      `/custom-schedule-available`,
+      {
+        params: {
+          provider_id: providerId
+        }
+      }
+    )
 
-  return data.payload
-}
+    return data?.payload || []
+  } catch (error) {
+    console.error('[API] getProviderOpportunities error:', error)
+    return []
+  }
+}
+
+export const getOpportunityById = async (id) => {
+  try {
+    const { data } = await api.get(`/custom-schedule/${id}`)
+    return data?.payload || null
+  } catch (error) {
+    console.error('[API] getOpportunityById error:', error)
+    return null
+  }
+}

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

@@ -275,6 +275,22 @@
         "address_not_found": "Address not available",
         "currency": "$ {value}"
       },
+      "opportunity_details": {
+        "title": "Service details",
+        "client_default": "Client",
+        "price_label": "Full day (up to 8h)",
+        "distance_text": "It is {distance} away from your registered address.",
+        "distance_default": "0 km",
+        "sob_medida": "Request made",
+        "sob_medida_highlight": "custom",
+        "para": "for",
+        "info_title": "Service information",
+        "description_not_found": "No description provided",
+        "address_not_found": "Address not provided",
+        "hour_not_found": "Time not provided",
+        "button_accept": "accept job",
+        "alert_text": "If your request is accepted by the client, you will receive a notification confirming the booking."
+      },
       "favorites": {
         "title": "Your favorites",
         "view_schedule": "View schedule"

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

@@ -275,6 +275,22 @@
         "address_not_found": "Dirección no informada",
         "currency": "R$ {value}"
       },
+      "opportunity_details": {
+        "title": "Detalles del servicio",
+        "client_default": "Cliente",
+        "price_label": "Jornada completa (hasta 8h)",
+        "distance_text": "Está a {distance} de distancia de su dirección registrada.",
+        "distance_default": "0 km",
+        "sob_medida": "Solicitud hecha",
+        "sob_medida_highlight": "a medida",
+        "para": "para",
+        "info_title": "Información del servicio",
+        "description_not_found": "Sin descripción proporcionada",
+        "address_not_found": "Dirección no informada",
+        "hour_not_found": "Horario no informado",
+        "button_accept": "quiero atender",
+        "alert_text": "Si el cliente acepta tu solicitud, recibirás una notificación confirmando el servicio."
+      },
       "favorites": {
         "title": "Tus favoritos",
         "view_schedule": "Ver agenda"

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

@@ -275,6 +275,22 @@
         "address_not_found": "Endereço não informado",
         "currency": "R$ {value}"
       },
+      "opportunity_details": {
+        "title": "Detalhes do serviço",
+        "client_default": "Cliente",
+        "price_label": "Integral (até 8h)",
+        "distance_text": "Há {distance} de distância do seu endereço cadastrado.",
+        "distance_default": "0 km",
+        "sob_medida": "Pedido feito",
+        "sob_medida_highlight": "sob medida",
+        "para": "para",
+        "info_title": "Informações do serviço",
+        "description_not_found": "Sem descrição informada",
+        "address_not_found": "Endereço não informado",
+        "hour_not_found": "Horário não informado",
+        "button_accept": "quero atender",
+        "alert_text": "Se seu pedido for aceito pelo cliente você receberá um aviso confirmando o agendamento."
+      },
       "favorites": {
         "title": "Seus favoritos",
         "view_schedule": "Ver agenda"

+ 5 - 1
src/pages/opportunities/OpportunitiesPage.vue

@@ -144,9 +144,13 @@ const normalizeOpportunity = (item) => ({
 })
 
 const goToOpportunityDetails = (item) => {
+
+  const id = item.custom_schedule?.id || item.id
+  
   router.push({
     name: 'OpportunityDetailsPage',
-    params: { id: item.id }
+    params: { id },
+    state: { opportunity: item }
   })
 }
 

+ 164 - 114
src/pages/opportunities/components/OpportunityDetailsPage.vue

@@ -1,5 +1,6 @@
 <template>
-  <q-page class="details-page">
+  <q-page v-if="details" class="details-page">
+
     <!-- HEADER -->
     <div class="page-header">
       <q-btn
@@ -10,33 +11,62 @@
         class="back-btn"
         @click="router.back()"
       />
-      <div class="page-title">{{ details.title }}</div>
+      <div class="page-title">
+        {{ $t('provider.dashboard.opportunity_details.title') }}
+      </div>
     </div>
 
     <!-- CLIENTE -->
     <div class="client-section">
-      <img :src="AvatarMock" class="client-avatar" />
-      <div class="client-name">{{ details.clientName }}</div>
+      <img :src="details.avatar" class="client-avatar" />
+
+      <div class="client-name">
+        {{ details.clientName }}
+        <span class="rating"> {{ details.rating }}</span>
+      </div>
+
       <div class="client-price">{{ details.price }}</div>
+
+      <div class="date">{{ details.date }}</div>
+      <div class="hour">{{ details.hour }}</div>
     </div>
 
-    <!-- INFOS -->
-    <div class="details-info">
-      <div>{{ details.date }}</div>
-      <div>{{ details.hour }}</div>
-      <div>{{ details.address }}</div>
-      <div>{{ details.distance }}</div>
+    <!-- ENDEREÇO -->
+    <div class="address">
+      <q-icon name="place" size="16px" />
+      {{ details.address }}
+    </div>
+
+    <div class="distance">
+      {{ $t('provider.dashboard.opportunity_details.distance_text', { distance: details.distance }) }}
+    </div>
+
+    <!-- TIPO -->
+    <div class="service-type">
+      {{ $t('provider.dashboard.opportunity_details.sob_medida') }}
+      <span>
+        {{ $t('provider.dashboard.opportunity_details.sob_medida_highlight') }}
+      </span>
+      <br />
+      {{ $t('provider.dashboard.opportunity_details.para') }}
+      <strong>{{ details.tags[0] }}</strong>
     </div>
 
     <!-- TAGS -->
-    <div class="tags-row">
-      <q-chip dense color="grey-3">
-        {{ details.tags[0] }}
+    <div v-if="details.tags?.length" class="tags-row">
+      <q-chip
+        v-for="(tag, index) in details.tags"
+        :key="index"
+        outline
+        class="chip"
+      >
+        {{ tag }}
       </q-chip>
+    </div>
 
-      <q-chip dense color="grey-3">
-        {{ details.tags[1] }}
-      </q-chip>
+    <!-- INFO -->
+    <div class="info-title">
+      {{ $t('provider.dashboard.opportunity_details.info_title') }}
     </div>
 
     <!-- DESCRIÇÃO -->
@@ -49,107 +79,91 @@
       unelevated
       rounded
       no-caps
-      color="secondary"
-      :label="details.buttonLabel"
-      class="full-width q-mt-md"
+      class="accept-btn"
+      :label="$t('provider.dashboard.opportunity_details.button_accept')"
       @click="goToProposalFlow"
     />
 
     <!-- ALERTA -->
-    <q-card flat class="bottom-alert">
-      {{ details.alertText }}
-    </q-card>
+    <div class="alert-box">
+      <q-icon name="warning" size="18px" class="alert-icon" />
+      <span class="alert-text">
+        {{ $t('provider.dashboard.opportunity_details.alert_text') }}
+      </span>
+    </div>
+
   </q-page>
 </template>
 
 <script setup>
 import { ref, onMounted } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
-
+import { useI18n } from 'vue-i18n'
 import AvatarMock from 'src/assets/foto_diarista_login.svg'
 import { getOpportunityById } from 'src/api/opportunities'
 
-// router
 const router = useRouter()
 const route = useRoute()
+const { t } = useI18n()
 
-// state
 const details = ref(null)
-const loading = ref(false)
+const loading = ref(true)
 
-// params
-const opportunityId = route.params.id
-
-// helpers
 const formatHour = (time) =>
   time ? time.slice(0, 5).replace(':', 'h') : ''
 
-// normalize (PADRÃO EMPRESA)
 const normalizeDetails = (item) => ({
-  title: 'Detalhes do serviço',
-
-  avatar: item.client?.user?.photo || AvatarMock,
+  avatar: AvatarMock,
 
   clientName:
-    item.client?.user?.name || 'Cliente',
+    item?.schedule?.client_name ||
+    t('provider.dashboard.opportunity_details.client_default'),
 
-  price: `R$${Number(
-    item.custom_schedule?.max_price || 0
-  ).toFixed(2)}`,
+  price: `R$ ${Number(item?.max_price || 0).toFixed(2)}`,
 
-  date: new Date(
-    item.custom_schedule?.created_at || item.created_at
-  ).toLocaleDateString('pt-BR'),
+  date: item?.schedule?.date || '',
 
-  hour: `Das ${formatHour(item.start_time)} às ${formatHour(item.end_time)}`,
+  hour:
+    item?.schedule?.start_time && item?.schedule?.end_time
+      ? `Das ${formatHour(item.schedule.start_time)} às ${formatHour(item.schedule.end_time)}`
+      : t('provider.dashboard.opportunity_details.hour_not_found'),
 
   address:
-    item.address?.address || 'Endereço não informado',
+    item?.schedule?.address?.address ||
+    t('provider.dashboard.opportunity_details.address_not_found'),
 
-  distance: '0 km',
+  distance:
+    item?.distance
+      ? `${item.distance} km`
+      : t('provider.dashboard.opportunity_details.distance_default'),
 
-  tags: [
-    item.custom_schedule?.service_type?.description,
-    item.custom_schedule?.offers_meal
-      ? 'Refeição no local'
-      : null
-  ].filter(Boolean),
+  tags: [item?.service_type_name].filter(Boolean),
 
   description:
-    item.custom_schedule?.description || '',
-
-  buttonLabel: 'Quero atender',
-
-  alertText:
-    'Se seu pedido for aceito pelo cliente você receberá um aviso confirmando o agendamento e aparecerá nos seus próximos serviços.'
+    item?.description ||
+    t('provider.dashboard.opportunity_details.description_not_found')
 })
 
-// load
-const loadDetails = async () => {
-  loading.value = true
-
+onMounted(async () => {
   try {
-    const response = await getOpportunityById(opportunityId)
-
-    console.log('DETAILS RESPONSE:', response)
-
-    details.value = normalizeDetails(response)
+    const id = route.params.id
+    const response = await getOpportunityById(id)
+
+    if (response) {
+      details.value = normalizeDetails(response)
+    } else {
+      console.warn('Nenhum dado retornado')
+    }
   } catch (error) {
     console.error('Erro ao carregar detalhes:', error)
-    details.value = null
   } finally {
     loading.value = false
   }
-}
+})
 
-// actions
 const goToProposalFlow = () => {
   console.log('Ir para proposta', details.value)
 }
-
-// lifecycle
-onMounted(loadDetails)
-
 </script>
 
 <style scoped lang="scss">
@@ -159,11 +173,17 @@ onMounted(loadDetails)
   min-height: 100vh;
 }
 
+.details-page {
+  padding: 16px;
+  background: #f7f7fb;
+  min-height: 100vh;
+}
+
 .page-header {
   display: flex;
   justify-content: center;
   position: relative;
-  margin-bottom: 24px;
+  margin-bottom: 20px;
 }
 
 .back-btn {
@@ -178,103 +198,133 @@ onMounted(loadDetails)
   color: #7c5cff;
 }
 
+/* CLIENTE */
 .client-section {
   text-align: center;
-  margin-top: 8px;
 }
 
 .client-avatar {
-  width: 84px;
-  height: 84px;
+  width: 90px;
+  height: 90px;
   border-radius: 50%;
   object-fit: cover;
 }
 
 .client-name {
   margin-top: 8px;
-  font-size: 18px;
-  font-weight: 500;
-  color: #666;
+  font-size: 16px;
+  color: #555;
+}
+
+.rating {
+  color: #ffb800;
+  font-size: 12px;
+  margin-left: 4px;
 }
 
 .client-price {
-  margin-top: 8px;
+  margin-top: 10px;
+  font-size: 28px;
+  font-weight: bold;
   color: #7c5cff;
-  font-size: 32px;
-  font-weight: 700;
 }
 
-.details-info {
-  margin-top: 12px;
-  text-align: center;
+.price-sub {
+  font-size: 12px;
+  color: #888;
+}
+
+/* DATA */
+.date {
+  margin-top: 10px;
+  font-weight: 600;
+}
+
+.hour {
   font-size: 13px;
-  line-height: 1.6;
   color: #666;
 }
 
-.distance-info {
-  margin-top: 12px;
+/* ENDEREÇO */
+.address {
+  margin-top: 16px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  gap: 4px;
+  color: #7c5cff;
+  font-weight: 600;
+}
+
+.distance {
   text-align: center;
   font-size: 12px;
-  color: #999;
+  color: #888;
+  margin-bottom: 12px;
 }
 
-.service-highlight {
-  margin-top: 16px;
+/* TIPO */
+.service-type {
   text-align: center;
   font-size: 13px;
-  color: #666;
+  margin-top: 12px;
 }
 
-.highlight-text {
+.service-type span {
   color: #7c5cff;
-  font-weight: 700;
+  font-weight: 600;
 }
 
+/* TAGS */
 .tags-row {
   display: flex;
   justify-content: center;
-  gap: 8px;
-  margin: 18px 0;
+  gap: 10px;
+  margin: 16px 0;
 }
 
-.tags-row .q-chip {
+.chip {
   border: 1px solid #7c5cff;
   color: #7c5cff;
   background: white;
-  font-size: 12px;
 }
 
+/* INFO */
 .info-title {
   text-align: center;
+  font-weight: bold;
   color: #7c5cff;
-  font-size: 18px;
-  font-weight: 700;
-  margin-bottom: 12px;
+  margin-top: 10px;
 }
 
 .description-box {
   text-align: center;
   font-size: 13px;
-  line-height: 1.6;
   color: #666;
+  margin: 10px 0 20px;
 }
 
-.full-width {
-  margin-top: 20px;
-  height: 48px;
-  font-size: 16px;
-  background: #8f6dfc !important;
+/* BOTÃO */
+.accept-btn {
+  width: 100%;
+  height: 50px;
+  background: linear-gradient(90deg, #7c5cff, #9f7aea);
+  color: white;
+  font-weight: bold;
 }
 
-.bottom-alert {
-  margin-top: 18px;
-  padding: 14px;
-  border-radius: 14px;
-  background: #dfeeff;
+/* ALERTA */
+.alert-box {
+  margin-top: 15px;
+  background: #e8f0ff;
+  padding: 12px;
+  border-radius: 12px;
   font-size: 12px;
-  line-height: 1.5;
-  color: #5c6b8a;
   text-align: center;
+  color: #5c6b8a;
+  display: flex;
+  gap: 6px;
+  align-items: center;
+  justify-content: center;
 }
 </style>