浏览代码

✨ feat(avaliacao): implementar fluxo de avaliação pós-agendamento

- DashboardTodayServices: badge 'avaliado!' e botão de avaliar por agendamento
- ScheduleRatingDialog: dialog de avaliação com estrelas, melhorias e opção de bloquear cliente
- DashboardPage: abertura do dialog de avaliação via evento @rate
- api/review: createReview e getImprovementTypes
- i18n: chaves de avaliação e privacidade

Fase: dev | Origin: avaliacao
Gustavo Zanatta 3 天之前
父节点
当前提交
b1d1630c49

+ 11 - 0
src/api/review.js

@@ -0,0 +1,11 @@
+import api from 'src/api'
+
+export const createReview = async (reviewData) => {
+  const { data } = await api.post('/reviews', reviewData)
+  return data.payload
+}
+
+export const getImprovementTypes = async (origin = 'both') => {
+  const { data } = await api.get('/improvement-types', { params: { origin } })
+  return data.payload
+}

文件差异内容过多而无法显示
+ 6 - 0
src/assets/diarinho_perfil_cliente_favoritos.svg


+ 23 - 4
src/components/dashboard/DashboardTodayServices.vue

@@ -85,12 +85,16 @@
                 </div>
 
                 <div v-else>
+                  <div v-if="item.provider_reviewed" class="rate-btn reviewed-badge">
+                    <q-icon name="mdi-star" size="14px" class="q-mr-xs" />
+                    {{ $t('provider.dashboard.today_services.reviewed_badge') }}
+                  </div>
                   <q-btn
-                    unelevated rounded no-caps
+                    v-else
+                    unelevated no-caps
                     class="rate-btn"
                     icon="mdi-star-outline"
                     :label="$t('provider.dashboard.today_services.rate_btn')"
-                    size="sm"
                     @click.stop="emit('rate', item)"
                   />
                 </div>
@@ -315,11 +319,26 @@ const openHelp = () => {
 }
 
 .rate-btn {
-  background: linear-gradient(90deg, #8B5CF6, #EC4899);
+  background: #EC4899;
   color: white;
   font-weight: 700;
-  font-size: 12px;
+  font-size: 13px;
   white-space: nowrap;
+  border-radius: 10px !important;
+  min-width: 72px;
+  min-height: 56px;
+  padding: 6px 10px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+}
+
+.reviewed-badge {
+  cursor: default;
+  opacity: 0.85;
+  font-size: 11px;
 }
 
 .progress-track {

+ 252 - 0
src/components/dashboard/ScheduleRatingDialog.vue

@@ -0,0 +1,252 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="rating-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>
+
+      <!-- Avatar -->
+      <div class="column items-center q-pb-sm">
+        <q-avatar size="64px" class="q-mb-sm">
+          <span
+            :style="avatarStyle"
+            class="text-weight-bold full-width full-height flex flex-center"
+            style="font-size: 20px; border-radius: 50%;"
+          >
+            {{ initials }}
+          </span>
+        </q-avatar>
+        <div class="text-body1 text-text text-weight-bold text-center q-px-lg" style="line-height:1.3">
+          {{ $t('provider.dashboard.schedule_rating.title') }}
+          <span class="text-primary"> {{ schedule.client_name + '?' }}</span>
+        </div>
+      </div>
+
+      <!-- Estrelas -->
+      <div class="column items-center q-pb-xs">
+        <q-rating
+          v-model="stars"
+          :max="5"
+          size="lg"
+          color="amber"
+          icon="mdi-star-outline"
+          icon-selected="mdi-star"
+          @update:model-value="onStarsChange"
+        />
+      </div>
+
+      <!-- Tags de melhoria/qualidade -->
+      <q-card-section v-if="stars > 0" class="q-pt-xs q-pb-xs">
+        <div class="text-caption text-grey-7 text-center q-mb-sm">
+          {{ isNegative ? $t('provider.dashboard.schedule_rating.negative_label') : $t('provider.dashboard.schedule_rating.positive_label') }}
+        </div>
+        <div v-if="loadingTags" class="row justify-center q-py-sm">
+          <q-spinner-dots color="primary" size="24px" />
+        </div>
+        <div v-else class="row justify-center q-gutter-xs">
+          <div
+            v-for="tag in tags"
+            :key="tag.id"
+            class="tag-pill"
+            :class="{ 'tag-pill--selected': selectedTagIds.includes(tag.id) }"
+            @click="toggleTag(tag.id)"
+          >
+            {{ tag.description }}
+          </div>
+        </div>
+      </q-card-section>
+
+      <!-- Comentário -->
+      <q-card-section class="q-pt-xs q-pb-xs q-px-lg">
+        <div class="text-caption text-grey-7 q-mb-xs">
+          {{ $t('provider.dashboard.schedule_rating.comment_placeholder') }}
+        </div>
+        <q-input
+          v-model="comment"
+          type="textarea"
+          outlined
+          dense
+          rows="3"
+          color="primary"
+          input-class="text-black"
+          hide-bottom-space
+        />
+      </q-card-section>
+
+      <!-- Checkbox — apenas para avaliação negativa -->
+      <q-card-section v-if="isNegative" class="q-pt-xs q-pb-xs q-px-lg">
+        <q-checkbox
+          v-model="blockClient"
+          :label="$t('provider.dashboard.schedule_rating.block_label')"
+          color="primary"
+          keep-color
+          class="text-text"
+          checked-icon="mdi-check-circle"
+          unchecked-icon="mdi-checkbox-blank-circle-outline"
+        />
+      </q-card-section>
+
+      <!-- Botão enviar -->
+      <q-card-section class="q-pt-sm q-pb-xs q-px-lg row">
+        <q-btn
+          unelevated
+          rounded
+          no-caps
+          full-width
+          color="primary"
+          class="submit-btn col-12"
+          :label="$t('provider.dashboard.schedule_rating.submit_btn')"
+          :loading="loading"
+          :disable="stars === 0"
+          @click="submit"
+        />
+      </q-card-section>
+
+      <!-- Ajuda -->
+      <q-card-section class="q-pt-xs q-pb-lg text-center">
+        <span class="text-caption text-grey-6 cursor-pointer" @click="openHelp">
+          {{ $t('provider.dashboard.schedule_rating.help_link') }}
+        </span>
+      </q-card-section>
+
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { useI18n } from 'vue-i18n'
+import { createReview, getImprovementTypes } from 'src/api/review'
+import { userStore } from 'src/stores/user'
+import ProfileHelpDialog from 'src/components/profile/ProfileHelpDialog.vue'
+
+const props = defineProps({
+  schedule: {
+    type: Object,
+    required: true
+  }
+})
+
+defineEmits([...useDialogPluginComponent.emits])
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const { t } = useI18n()
+const $q = useQuasar()
+const store = userStore()
+
+const stars = ref(0)
+const selectedTagIds = ref([])
+const comment = ref(null)
+const blockClient = ref(false)
+const tags = ref([])
+const loadingTags = ref(false)
+const loading = ref(false)
+
+const isNegative = computed(() => stars.value > 0 && stars.value <= 2)
+const isPositive = computed(() => stars.value >= 3)
+
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+]
+
+const avatarStyle = computed(() => {
+  const c = avatarColors[props.schedule.client_id % avatarColors.length]
+  return { background: c.background, color: c.color }
+})
+
+const initials = computed(() =>
+  props.schedule.client_name?.slice(0, 2).toUpperCase() ?? '??'
+)
+
+const onStarsChange = () => {
+  selectedTagIds.value = []
+  blockClient.value = false
+}
+
+const toggleTag = (id) => {
+  const idx = selectedTagIds.value.indexOf(id)
+  if (idx === -1) selectedTagIds.value.push(id)
+  else selectedTagIds.value.splice(idx, 1)
+}
+
+const openHelp = () => {
+  $q.dialog({ component: ProfileHelpDialog })
+}
+
+const submit = async () => {
+  if (stars.value === 0) return
+  loading.value = true
+  try {
+    await createReview({
+      schedule_id: props.schedule.id,
+      origin: 'provider',
+      origin_id: store.user.provider.id,
+      stars: stars.value,
+      comment: comment.value || null,
+      improvements_ids: selectedTagIds.value,
+      block_provider: false,
+      block_client: isNegative.value && blockClient.value,
+      favorite_provider: false,
+    })
+
+    onDialogOK(true)
+  } catch (error) {
+    const status = error?.response?.status
+    if (status === 422) {
+      $q.notify({ message: t('provider.dashboard.schedule_rating.already_reviewed'), color: 'negative', icon: 'mdi-alert-circle-outline' })
+    } else {
+      $q.notify({ message: t('http.errors.failed'), color: 'negative' })
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(async () => {
+  loadingTags.value = true
+  try {
+    const result = await getImprovementTypes('provider')
+    tags.value = result ?? []
+  } catch {
+    tags.value = []
+  } finally {
+    loadingTags.value = false
+  }
+})
+</script>
+
+<style scoped lang="scss">
+.rating-dialog-card {
+  width: 320px;
+  max-width: 96vw;
+  border-radius: 20px !important;
+  overflow: hidden;
+}
+
+.tag-pill {
+  border: 1.5px solid #d1d5db;
+  border-radius: 20px;
+  padding: 5px 14px;
+  font-size: 12px;
+  color: #6b7280;
+  cursor: pointer;
+  transition: border-color 0.15s, color 0.15s;
+  user-select: none;
+
+  &--selected {
+    border-color: #8B5CF6;
+    color: #8B5CF6;
+    font-weight: 600;
+  }
+}
+
+.submit-btn {
+  font-size: 15px;
+  font-weight: 700;
+  padding: 10px 0;
+}
+</style>

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

@@ -268,7 +268,19 @@
         "in_progress": "Serviço em andamento!",
         "end_time_label": "Término do serviço em",
         "rate_btn": "Avaliar",
-        "help": "ajuda"
+        "help": "ajuda",
+        "reviewed_badge": "avaliado!"
+      },
+      "schedule_rating": {
+        "title": "Como foi o serviço de",
+        "positive_label": "O que mais gostou?",
+        "negative_label": "O que poderia melhorar?",
+        "comment_placeholder": "Deseja deixar um comentário?",
+        "block_label": "Não receber mais pedidos deste cliente",
+        "submit_btn": "enviar avaliação",
+        "help_link": "Ajuda",
+        "already_reviewed": "Você já avaliou este serviço.",
+        "reviewed_badge": "avaliado!"
       },
       "opportunities": {
         "title": "Oportunidades",
@@ -489,6 +501,14 @@
       "footer_disclaimer": "⚡ Respostas automáticas por IA • Atendimento humano disponível",
       "coming_soon": "Em breve disponível"
     },
+    "privacy": {
+      "title": "Privacidade",
+      "description": "Contas bloqueadas",
+      "blocked_title": "Contas bloqueadas",
+      "empty_message": "Você não possui clientes bloqueados",
+      "empty_sub": "Você pode bloquear clientes que não deseja receber novos pedidos.",
+      "unblock_btn": "Desbloquear"
+    },
     "logout": {
       "title": "Sair",
       "description": "Desconectar da sua conta"

+ 11 - 1
src/pages/dashboard/DashboardPage.vue

@@ -9,7 +9,7 @@
       <DashboardHeaderBar :data="headerBar" />
       <DashboardSummaryInfos :data="summaryInfos" />
       <DashboardPriceSuggest :data="priceSuggestion"/>
-      <DashboardTodayServices v-if="todayServices?.length > 0" :data="todayServices" @refresh="loadDashboard" />
+      <DashboardTodayServices v-if="todayServices?.length > 0" :data="todayServices" @refresh="loadDashboard" @rate="openRatingDialog" />
       <DashboardScrollAreaSchedules />
       <DashboardSolicitations
         v-if="solicitations?.length > 0"
@@ -35,6 +35,7 @@ import DashboardNextSchedules from 'src/components/dashboard/DashboardNextSchedu
 import DashboardOpportunities from 'src/components/dashboard/DashboardOpportunities.vue';
 import SolicitationDetailsDialog from 'src/components/dashboard/SolicitationDetailsDialog.vue';
 import NextSchedulesDetailsDialog from 'src/components/dashboard/NextSchedulesDetailsDialog.vue';
+import ScheduleRatingDialog from 'src/components/dashboard/ScheduleRatingDialog.vue';
 import { onMounted, ref } from 'vue';
 import { useQuasar } from 'quasar';
 import { dadosDashboard } from 'src/api/dashboard';
@@ -94,6 +95,15 @@ const openNextScheduleDialog = (schedule) => {
   });
 };
 
+const openRatingDialog = (schedule) => {
+  $q.dialog({
+    component: ScheduleRatingDialog,
+    componentProps: { schedule }
+  }).onOk(() => {
+    loadDashboard()
+  })
+};
+
 onMounted(async () => {
   await loadDashboard();
   loading.value = false;

部分文件因为文件数量过多而无法显示