Explorar o código

feat: :sparkles: avaliacoes no agendamento e oportunidade + fluxo de bloqueio de cliente e prestador nas avaliacoes

avaliacoes no agendamento e oportunidade + fluxo de bloqueio de cliente e prestador nas avaliacoes
Gustavo Zanatta hai 2 días
pai
achega
34397f6978

+ 6 - 2
src/api/improvementType.js

@@ -5,8 +5,12 @@ export const getImprovementType = async (id) => {
   return data.payload;
 };
 
-export const getImprovementTypes = async () => {
-  const { data } = await api.get("/improvement-types");
+export const getImprovementTypes = async (origin) => {
+  const { data } = await api.get("/improvement-types", {
+    params: {
+      origin
+    }
+  });
   return data.payload;
 };
 

+ 36 - 0
src/api/review.js

@@ -0,0 +1,36 @@
+import api from 'src/api'
+
+export const getReviews = async () => {
+  const { data } = await api.get('/reviews')
+  return data.payload
+}
+
+export const getReviewsBySchedule = async (scheduleId) => {
+  const { data } = await api.get(`/reviews/schedule/${scheduleId}`)
+  return data.payload
+}
+
+export const getReviewsByOrigin = async (origin, originId) => {
+  const { data } = await api.get(`/reviews/${origin}/${originId}`)
+  return data.payload
+}
+
+export const createReview = async (reviewData) => {
+  const { data } = await api.post('/reviews', reviewData)
+  return data.payload
+}
+
+export const updateReview = async (id, reviewData) => {
+  const { data } = await api.put(`/reviews/${id}`, reviewData)
+  return data.payload
+}
+
+export const deleteReview = async (id) => {
+  const { data } = await api.delete(`/reviews/${id}`)
+  return data.payload
+}
+
+export const getFinishedSchedules = async () => {
+  const { data } = await api.get('/schedules/finished')
+  return data.payload
+}

+ 16 - 0
src/api/reviewImprovement.js

@@ -0,0 +1,16 @@
+import api from 'src/api'
+
+export const getReviewImprovements = async (reviewId) => {
+  const { data } = await api.get(`/review-improvements/${reviewId}`)
+  return data.payload
+}
+
+export const createReviewImprovement = async (improvementData) => {
+  const { data } = await api.post('/review-improvements', improvementData)
+  return data.payload
+}
+
+export const deleteReviewImprovement = async (id) => {
+  const { data } = await api.delete(`/review-improvements/${id}`)
+  return data.payload
+}

+ 2 - 1
src/helpers/utils.js

@@ -73,7 +73,8 @@ const formatDateYMDtoDMY = (dateTime) => {
   const formattedDate = `${day}/${month}/${year}`;
   if (timePart) {
     const [hours, minutes, seconds] = timePart.split(":");
-    const formattedTime = `${hours}:${minutes}:${seconds}`;
+    const hasSeconds = seconds ? true : false;
+    const formattedTime = `${hours}:${minutes}${hasSeconds ? `:${seconds}` : ''}`;
     return `${formattedDate} ${formattedTime}`;
   }
   return formattedDate;

+ 27 - 2
src/i18n/locales/en.json

@@ -16,7 +16,8 @@
       "resend_email": "Resend email",
       "download_certificate": "Download certificate",
       "download_boleto": "Download Boleto",
-      "copy_paste_code": "Copy and paste the code below to make the payment"
+      "copy_paste_code": "Copy and paste the code below to make the payment",
+      "review": "Review"
     },
     "terms": {
       "name": "Name",
@@ -571,6 +572,29 @@
       "commercial": "Commercial"
     }
   },
+  "reviews": {
+    "singular": "Review",
+    "plural": "Reviews",
+    "header": "Reviews",
+    "add_button": "Add Review",
+    "edit_button": "Edit Review",
+    "empty_state": "No reviews found",
+    "schedule": "Schedule",
+    "origin": "Origin",
+    "origin_id": "Reviewer",
+    "stars": "Stars",
+    "comment": "Comment",
+    "improvements": "Improvements",
+    "reviewed_at": "Reviewed at",
+    "origins": {
+      "providers": "Provider",
+      "clients": "Client"
+    },
+    "improvements_suggested": "Improvements Suggested",
+    "no_improvements_suggested": "No improvements suggested",
+    "block_client": "Do not receive more requests from this client",
+    "block_provider": "Do not request this provider anymore"
+  },
   "orders": {
     "singular": "Order",
     "plural": "Orders",
@@ -635,7 +659,8 @@
       "user": "User",
       "improvement_type": "Improvement Type",
       "service_type": "Service Type",
-      "speciality": "Speciality"
+      "speciality": "Speciality",
+      "reviews": "Reviews"
     }
   },
   "charts": {

+ 27 - 2
src/i18n/locales/es.json

@@ -16,7 +16,8 @@
       "resend_email": "Reenviar correo electrónico",
       "download_certificate": "Descargar certificado",
       "download_boleto": "Descargar Boleto",
-      "copy_paste_code": "Copie y pegue el código a continuación para realizar el pago"
+      "copy_paste_code": "Copie y pegue el código a continuación para realizar el pago",
+      "review": "Evaluar"
     },
     "terms": {
       "name": "Nombre",
@@ -571,6 +572,29 @@
       "commercial": "Comercial"
     }
   },
+  "reviews": {
+    "singular": "Evaluación",
+    "plural": "Evaluaciones",
+    "header": "Evaluaciones",
+    "add_button": "Agregar Evaluación",
+    "edit_button": "Editar Evaluación",
+    "empty_state": "No se encontraron evaluaciones",
+    "schedule": "Agendamiento",
+    "origin": "Origen",
+    "origin_id": "Evaluador",
+    "stars": "Estrellas",
+    "comment": "Comentario",
+    "improvements": "Mejoras",
+    "reviewed_at": "Evaluado en",
+    "origins": {
+      "providers": "Proveedor",
+      "clients": "Cliente"
+    },
+    "improvements_suggested": "Mejoras Sugeridas",
+    "no_improvements_suggested": "No se sugirieron mejoras",
+    "block_client": "No recibir más pedidos de este cliente",
+    "block_provider": "No solicitar más este diarista"
+  },
   "orders": {
     "singular": "Pedido",
     "plural": "Pedidos",
@@ -635,7 +659,8 @@
       "user": "Usuario",
       "improvement_type": "Tipo de Mejora",
       "service_type": "Tipo de Servicio",
-      "speciality": "Especialidad"
+      "speciality": "Especialidad",
+      "reviews": "Evaluaciones"
     }
   },
   "charts": {

+ 27 - 2
src/i18n/locales/pt.json

@@ -16,7 +16,8 @@
       "resend_email": "Reenviar e-mail",
       "download_certificate": "Baixar certificado",
       "download_boleto": "Baixar Boleto",
-      "copy_paste_code": "Copie e cole o código abaixo para efetuar o pagamento"
+      "copy_paste_code": "Copie e cole o código abaixo para efetuar o pagamento",
+      "review": "Avaliar"
     },
     "terms": {
       "name": "Nome",
@@ -571,6 +572,29 @@
       "commercial": "Comercial"
     }
   },
+  "reviews": {
+    "singular": "Avaliação",
+    "plural": "Avaliações",
+    "header": "Avaliações",
+    "add_button": "Adicionar Avaliação",
+    "edit_button": "Editar Avaliação",
+    "empty_state": "Nenhuma avaliação encontrada",
+    "schedule": "Agendamento",
+    "origin": "Origem",
+    "origin_id": "Avaliador",
+    "stars": "Estrelas",
+    "comment": "Comentário",
+    "improvements": "Melhorias",
+    "reviewed_at": "Avaliado em",
+    "origins": {
+      "providers": "Prestador",
+      "clients": "Cliente"
+    },
+    "improvements_suggested": "Melhorias Sugeridas",
+    "no_improvements_suggested": "Nenhuma melhoria sugerida",
+    "block_client": "Não receber mais pedidos deste cliente",
+    "block_provider": "Não solicitar mais este diarista"
+  },
   "orders": {
     "singular": "Pedido",
     "plural": "Pedidos",
@@ -635,7 +659,8 @@
       "user": "Usuário",
       "improvement_type": "Tipo de Melhoria",
       "service_type": "Tipo de Serviço",
-      "speciality": "Especialidade"
+      "speciality": "Especialidade",
+      "reviews": "Avaliações"
     }
   },
   "charts": {

+ 28 - 4
src/pages/dashboard/DashboardPage.vue

@@ -75,7 +75,7 @@
                             clickable
                             @click="openScheduleDialog(schedule)"
                           >
-                            <div class="q-my-auto q-pr-md" style="width: 30px">
+                            <div class="q-my-auto" style="width: 30px">
                               {{ schedule.id }}
                             </div>
                             <q-item-section avatar>
@@ -96,6 +96,12 @@
                                 <span class="gradient-diarista">
                                   {{ schedule.provider_name }}
                                 </span>
+                                <span class="q-my-auto q-pl-sm">
+                                  <q-icon
+                                    v-if="schedule.reviews?.length > 0"
+                                    name="mdi-star"
+                                  />
+                                </span>
                               </q-item-label>
                             </q-item-section>
                             
@@ -165,7 +171,7 @@
                             clickable
                             @click="openScheduleDialog(schedule)"
                           >
-                            <div class="q-my-auto q-pr-md" style="width: 30px">
+                            <div class="q-my-auto" style="width: 30px">
                               {{ schedule.id }}
                             </div>
                             <q-item-section avatar>
@@ -186,6 +192,12 @@
                                 <span class="gradient-diarista">
                                   {{ schedule.provider_name }}
                                 </span>
+                                <span class="q-my-auto q-pl-sm">
+                                  <q-icon
+                                    v-if="schedule.reviews?.length > 0"
+                                    name="mdi-star"
+                                  />
+                                </span>
                               </q-item-label>
                             </q-item-section>
                             
@@ -273,7 +285,7 @@
                             clickable
                             @click="openCustomScheduleDialog(schedule)"
                           >
-                            <div class="q-my-auto q-pr-md" style="width: 30px">
+                            <div class="q-my-auto" style="width: 30px">
                               {{ schedule.id }}
                             </div>
                             <q-item-section avatar>
@@ -294,6 +306,12 @@
                                 <span class="gradient-diarista">
                                   {{ schedule.provider_name || 'N/A' }}
                                 </span>
+                                <span class="q-my-auto q-pl-sm">
+                                  <q-icon
+                                    v-if="schedule.reviews?.length > 0"
+                                    name="mdi-star"
+                                  />
+                                </span>
                               </q-item-label>
                             </q-item-section>
                             
@@ -370,7 +388,7 @@
                             clickable
                             @click="openCustomScheduleDialog(schedule)"
                           >
-                            <div class="q-my-auto q-pr-md" style="width: 30px">
+                            <div class="q-my-auto" style="width: 30px">
                               {{ schedule.id }}
                             </div>
                             <q-item-section avatar>
@@ -391,6 +409,12 @@
                                 <span class="gradient-diarista">
                                   {{ schedule.provider_name || 'N/A' }}
                                 </span>
+                                <span class="q-my-auto q-pl-sm">
+                                  <q-icon
+                                    v-if="schedule.reviews?.length > 0"
+                                    name="mdi-star"
+                                  />
+                                </span>
                               </q-item-label>
                             </q-item-section>
                             

+ 75 - 3
src/pages/opportunity/components/ViewCustomScheduleDialog.vue

@@ -115,6 +115,51 @@
           </div>
         </div>
 
+        <div v-if="schedule?.reviews?.length > 0" class="text-caption text-grey-7">{{ $t('reviews.header') }}</div>
+        <div v-if="schedule?.reviews?.length > 0" class="col-12">
+          <q-card v-for="review in schedule.reviews" :key="review.id" class="q-pa-md column" bordered>
+            <div>
+              {{ review.origin == 'client' ? $t('reviews.origins.clients') : $t('reviews.origins.providers') }}
+            </div>
+            <div class="col-12 row">
+              <div class="col-4">
+                <q-rating
+                  v-model="review.stars"
+                  :max="5"
+                  size="md"
+                  color="amber"
+                  icon="mdi-star-outline"
+                  icon-selected="mdi-star"
+                  class="q-mb-xs"
+                  readonly
+                />
+              </div>
+              <div class="col-8">
+                <div>{{ $t('reviews.comment') + ':' }}</div>
+                {{ review.comment }}
+              </div>
+            </div>
+            <div class="col-12 row q-pt-md">
+              <div class="col-4">
+                <div>
+                  {{ $t("reviews.improvements") + ':' }}
+                </div>
+                <div v-for="improvement in review.improvements" :key="improvement.id">
+                  {{ improvement.improvement_type_name }}
+                </div>
+              </div>
+              <div class="col-8">
+                <div>
+                  {{ $t("common.terms.created_at") + ' :' }}
+                </div>
+                <div>
+                  {{ formatDateYMDtoDMY(review.created_at) }}
+                </div>
+              </div>
+            </div>
+          </q-card>
+        </div>
+
         <div v-if="isClientView && isPending && !hasProvider && activeProposals.length > 0" class="q-mt-lg">
           <q-separator class="q-mb-md" />
           <div class="text-h6 q-mb-md">{{ $t('opportunities.proposals_received') }}</div>
@@ -182,6 +227,16 @@
       </q-card-section>
 
       <q-card-actions align="right" class="q-px-md q-pb-md">
+        <q-btn
+          v-if="schedule?.status === 'finished' && (!schedule.reviews?.length > 0 || !schedule.reviews?.some(review => review.origin == viewMode))"
+          :label="$t('common.actions.review')"
+          color="primary"
+          icon="mdi-star-box"
+          @click="reviewSchedule"
+        />
+
+        <q-space/>
+
         <q-btn
           :label="$t('common.actions.close')"
           flat
@@ -217,7 +272,7 @@
           @click="handleMarkAsPaid"
         />
         <q-btn
-          v-if="(schedule?.status === 'paid' || schedule?.status === 'started' ) && viewMode == 'provider' && !schedule.code_verified"
+          v-if="(schedule?.status === 'paid' || schedule?.status === 'started' || schedule?.status === 'finished') && viewMode == 'provider' && !schedule.code_verified"
           unelevated
           :label="$t('schedules.fill_code')"
           color="secondary"
@@ -229,13 +284,15 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from 'vue'
+import { ref, computed, onMounted, defineAsyncComponent } from 'vue'
 import { useDialogPluginComponent, useQuasar } from 'quasar'
 import { useI18n } from 'vue-i18n'
 import DefaultDialogHeader from 'src/components/defaults/DefaultDialogHeader.vue'
 import { getOpportunityProposals, proposeOpportunity, acceptProposal, refuseProposal, verifyScheduleCode, refuseOpportunity } from 'src/api/customSchedule'
 import { updateScheduleStatus } from 'src/api/schedule'
-import { formatToBRLCurrency } from 'src/helpers/utils'
+import { formatDateYMDtoDMY, formatToBRLCurrency } from 'src/helpers/utils'
+
+const ScheduleReviewDialog = defineAsyncComponent(() => import('src/pages/review/components/ScheduleReviewDialog.vue'))
 
 const props = defineProps({
   schedule: {
@@ -453,6 +510,21 @@ const handleCancel = async () => {
   await updateStatus(props.schedule.id, 'cancelled')
 }
 
+const reviewSchedule = () => {
+  $q.dialog({
+    component: ScheduleReviewDialog,
+    componentProps: {
+      scheduleId: props.schedule.id,
+      clientId: props.schedule.client_id,
+      providerId: props.schedule.provider_id,
+      origin: props.viewMode,
+      title: () => t('common.actions.review')
+    }
+  }).onOk(() => {
+    emit('refreshData');
+  })
+}
+
 onMounted(async () => {
   await loadProposals()
 })

+ 11 - 0
src/pages/review/ReviewsPage.vue

@@ -0,0 +1,11 @@
+<template>
+  <q-page class="q-pa-md">
+    <DefaultHeaderPage />
+    <ReviewsPanel />
+  </q-page>
+</template>
+
+<script setup>
+import ReviewsPanel from './components/ReviewsPanel.vue'
+import DefaultHeaderPage from 'src/components/layout/DefaultHeaderPage.vue'
+</script>

+ 298 - 0
src/pages/review/components/AddEditReviewDialog.vue

@@ -0,0 +1,298 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin" style="width: 800px; max-width: 90vw">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm">
+
+          <q-select
+            v-if="!review"
+            v-model="selectedSchedule"
+            :label="$t('reviews.schedule')"
+            :options="scheduleOptions"
+            :rules="[inputRules.required]"
+            :error="!!serverErrors?.schedule_id"
+            :error-message="serverErrors?.schedule_id"
+            :loading="loadingSchedules"
+            option-value="value"
+            option-label="label"
+            emit-value
+            map-options
+            class="col-12"
+            @update:model-value="onScheduleChange"
+          />
+
+          <q-input
+            v-else
+            :model-value="review.schedule_label"
+            :label="$t('reviews.schedule')"
+            readonly
+            class="col-12"
+          />
+
+          <q-select
+            v-if="!review"
+            v-model="form.origin"
+            :label="$t('reviews.origin')"
+            :options="originOptions"
+            :rules="[inputRules.required]"
+            :error="!!serverErrors?.origin"
+            :error-message="serverErrors?.origin"
+            emit-value
+            map-options
+            class="col-12 col-md-6"
+            @update:model-value="onOriginChange"
+          />
+
+          <q-input
+            v-else
+            :model-value="$t(`reviews.origins.${review.origin}`)"
+            :label="$t('reviews.origin')"
+            readonly
+            class="col-12 col-md-6"
+          />
+
+          <template v-if="!review">
+            <ProviderSelect
+              v-if="form.origin === 'providers'"
+              :key="'provider-select'"
+              v-model="selectedOriginModel"
+              :label="$t('reviews.origin_id')"
+              :rules="[inputRules.required]"
+              :error="!!serverErrors?.origin_id"
+              :error-message="serverErrors?.origin_id"
+              class="col-12 col-md-6"
+              @update:model-value="onOriginIdChange"
+            />
+            <ClientSelect
+              v-else-if="form.origin === 'clients'"
+              :key="'client-select'"
+              v-model="selectedOriginModel"
+              :label="$t('reviews.origin_id')"
+              :rules="[inputRules.required]"
+              :error="!!serverErrors?.origin_id"
+              :error-message="serverErrors?.origin_id"
+              class="col-12 col-md-6"
+              @update:model-value="onOriginIdChange"
+            />
+            <q-input
+              v-else
+              :label="$t('reviews.origin_id')"
+              readonly
+              :placeholder="$t('reviews.origin')"
+              class="col-12 col-md-6"
+              disable
+            />
+          </template>
+
+          <div class="col-12">
+            <div class="text-caption q-mb-xs">{{ $t('reviews.stars') }}</div>
+            <q-rating
+              v-model="form.stars"
+              :max="5"
+              size="md"
+              color="amber"
+              icon="star_border"
+              icon-selected="star"
+              class="q-mb-xs"
+            />
+            <div
+              v-if="serverErrors?.stars"
+              class="text-negative text-caption"
+            >
+              {{ serverErrors.stars }}
+            </div>
+          </div>
+
+          <q-input
+            v-model="form.comment"
+            :label="$t('reviews.comment')"
+            :error="!!serverErrors?.comment"
+            :error-message="serverErrors?.comment"
+            type="textarea"
+            autogrow
+            class="col-12"
+            @update:model-value="serverErrors.comment = null"
+          />
+
+          <q-select
+            v-if="!review"
+            v-model="selectedImprovements"
+            :label="$t('reviews.improvements')"
+            :options="improvementOptions"
+            :loading="loadingImprovements"
+            option-value="value"
+            option-label="label"
+            emit-value
+            map-options
+            multiple
+            use-chips
+            class="col-12"
+            @update:model-value="($event) => attImprovementsOnForm($event)"
+          />
+          <div v-else>
+            <div>{{reviewsImprovements?.length > 0 ?  t('reviews.improvements_suggested') : t('reviews.no_improvements_suggested')}}</div>
+            <q-chip
+              v-for="improvement in reviewsImprovements"
+              :key="improvement.id"
+              class="q-mb-xs"
+            >
+              {{ improvement.description }}
+            </q-chip>
+          </div>
+
+        </q-card-section>
+
+        <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-btn
+            flat
+            :label="$t('common.actions.cancel')"
+            color="negative"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            type="submit"
+            :label="$t('common.actions.save')"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+            color="primary"
+            unelevated
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useDialogPluginComponent } from 'quasar'
+import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker'
+import { useSubmitHandler } from 'src/composables/useSubmitHandler'
+import { createReview, updateReview, getFinishedSchedules } from 'src/api/review'
+import { createReviewImprovement } from 'src/api/reviewImprovement'
+import { getImprovementTypes } from 'src/api/improvementType'
+import DefaultDialogHeader from 'src/components/defaults/DefaultDialogHeader.vue'
+import ProviderSelect from 'src/components/provider/ProviderSelect.vue'
+import ClientSelect from 'src/components/client/ClientSelect.vue'
+import { useInputRules } from 'src/composables/useInputRules'
+import { useI18n } from 'vue-i18n'
+import { format, parseISO } from 'date-fns'
+
+const props = defineProps({
+  review: {
+    type: Object,
+    default: null
+  },
+  title: {
+    type: Function,
+    default: () => ''
+  }
+})
+
+defineEmits([...useDialogPluginComponent.emits])
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const { inputRules } = useInputRules()
+const { t } = useI18n()
+const formRef = ref(null)
+
+const selectedSchedule = ref(props.review ? props.review.schedule_id : null)
+const selectedOriginModel = ref(null)
+const selectedImprovements = ref([])
+
+const scheduleOptions = ref([])
+const improvementOptions = ref([])
+const loadingSchedules = ref(false)
+const loadingImprovements = ref(false)
+const reviewsImprovements = ref([])
+
+const originOptions = computed(() => [
+  { label: t('reviews.origins.providers'), value: 'providers' },
+  { label: t('reviews.origins.clients'), value: 'clients' }
+])
+
+const { form, hasUpdatedFields } = useFormUpdateTracker({
+  schedule_id: props.review ? props.review.schedule_id : null,
+  origin:      props.review ? props.review.origin : null,
+  origin_id:   props.review ? props.review.origin_id : null,
+  stars:       props.review ? Number(props.review.stars) : 0,
+  comment:     props.review ? props.review.comment : null,
+  improvements_ids: props.review ? props.review.improvements?.map(i => i.id) : [],
+})
+
+const {
+  loading,
+  serverErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  onSuccess: () => onDialogOK(true),
+  formRef: formRef,
+})
+
+const onScheduleChange = (val) => {
+  form.schedule_id = val
+  serverErrors.schedule_id = null
+}
+
+const onOriginChange = () => {
+  selectedOriginModel.value = null
+  form.origin_id = null
+  serverErrors.origin = null
+  serverErrors.origin_id = null
+}
+
+const onOriginIdChange = (selected) => {
+  form.origin_id = selected?.value ?? null
+  serverErrors.origin_id = null
+}
+
+const onOKClick = async () => {
+  await submitForm(async () => {
+    if (props.review) {
+      return updateReview(props.review.id, {
+        stars:   form.stars,
+        comment: form.comment,
+      })
+    } else {
+      const created = await createReview({ ...form })
+      for (const improvementTypeId of selectedImprovements.value) {
+        await createReviewImprovement({
+          review_id: created.id,
+          improvement_type_id: improvementTypeId,
+        })
+      }
+      return created
+    }
+  })
+}
+
+const attImprovementsOnForm = (event) => {
+  form.improvements_ids = event;
+  serverErrors.improvements_ids = null;
+}
+
+onMounted(async () => {
+  loadingSchedules.value = true
+  loadingImprovements.value = true
+  try {
+    const [schedules, improvements] = await Promise.all([
+      getFinishedSchedules(),
+      getImprovementTypes(),
+    ])
+    scheduleOptions.value = schedules.map((s) => ({
+      value: s.id,
+      label: `${s.id} - ${s.client_name ?? '?'} - ${s.provider_name ?? '?'} - ${s.date ? format(parseISO(s.date), 'dd/MM/yyyy') : '?'}`,
+    }))
+    improvementOptions.value = improvements.map((i) => ({
+      value: i.id,
+      label: i.description,
+    }))
+    reviewsImprovements.value = props.review?.reviews_improvements;
+  } catch (error) {
+    console.error('Error loading data:', error)
+  } finally {
+    loadingSchedules.value = false
+    loadingImprovements.value = false
+  }
+})
+</script>

+ 128 - 0
src/pages/review/components/ReviewsPanel.vue

@@ -0,0 +1,128 @@
+<template>
+  <DefaultTable
+    ref="tableRef"
+    :columns="columns"
+    :loading="loading"
+    :api-call="getReviews"
+    :add-button-label="$t('reviews.add_button')"
+    :empty-message="$t('reviews.empty_state')"
+    :delete-function="deleteReview"
+    :mostrar-selecao-de-colunas="false"
+    :mostrar-botao-fullscreen="false"
+    :mostrar-toggle-inativos="false"
+    :open-item="true"
+    @on-row-click="onRowClick"
+    @on-add-item="onAddItem"
+  >
+    <template #body-cell-stars="template_props">
+      <q-td :props="template_props">
+        <q-rating
+          :model-value="Number(template_props.row.stars)"
+          :max="5"
+          size="xs"
+          color="amber"
+          readonly
+        />
+        <span class="q-ml-xs text-caption">{{ template_props.row.stars }}</span>
+      </q-td>
+    </template>
+
+    <template #body-cell-origin="template_props">
+      <q-td :props="template_props">
+        <q-chip
+          :label="$t(`reviews.origins.${template_props.row.origin}`)"
+          :color="template_props.row.origin === 'providers' ? 'blue' : 'teal'"
+          text-color="white"
+          size="sm"
+        />
+      </q-td>
+    </template>
+
+    <template #body-cell-reviewed_at="template_props">
+      <q-td :props="template_props">
+        {{ format(parseISO(template_props.row.created_at), 'dd/MM/yyyy HH:mm') }}
+      </q-td>
+    </template>
+  </DefaultTable>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { useQuasar } from 'quasar'
+import { getReviews, deleteReview } from 'src/api/review'
+import DefaultTable from 'src/components/defaults/DefaultTable.vue'
+import AddEditReviewDialog from './AddEditReviewDialog.vue'
+import { format, parseISO } from 'date-fns'
+
+const { t } = useI18n()
+const $q = useQuasar()
+const tableRef = ref(null)
+const loading = ref(false)
+
+const columns = computed(() => [
+  {
+    name: 'schedule_label',
+    label: t('reviews.schedule'),
+    align: 'left',
+    field: 'schedule_label',
+    sortable: false
+  },
+  {
+    name: 'origin',
+    label: t('reviews.origin'),
+    align: 'center',
+    format: (val) => t(`reviews.origins.${val}`),
+    field: 'origin',
+    sortable: true
+  },
+  {
+    name: 'stars',
+    label: t('reviews.stars'),
+    align: 'center',
+    field: 'stars',
+    sortable: true
+  },
+  {
+    name: 'reviewed_at',
+    label: t('reviews.reviewed_at'),
+    align: 'left',
+    format: (val) => val ? format(parseISO(val), 'dd/MM/yyyy HH:mm') : '-',
+    field: 'created_at',
+    sortable: true
+  },
+  {
+    name: 'actions',
+    label: t('common.terms.actions'),
+    align: 'center',
+    field: 'actions'
+  }
+])
+
+const onAddItem = () => {
+  $q.dialog({
+    component: AddEditReviewDialog,
+    componentProps: {
+      title: () => t('reviews.add_button')
+    }
+  }).onOk((success) => {
+    if (success) {
+      tableRef.value.refresh()
+    }
+  })
+}
+
+const onRowClick = ({ row }) => {
+  $q.dialog({
+    component: AddEditReviewDialog,
+    componentProps: {
+      review: row,
+      title: () => t('reviews.edit_button')
+    }
+  }).onOk((success) => {
+    if (success) {
+      tableRef.value.refresh()
+    }
+  })
+}
+</script>

+ 185 - 0
src/pages/review/components/ScheduleReviewDialog.vue

@@ -0,0 +1,185 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin" style="width: 800px; max-width: 90vw">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm">
+          <div class="col-12">
+            <div class="text-caption q-mb-xs">{{ $t('reviews.stars') }}</div>
+            <q-rating
+              v-model="form.stars"
+              :max="5"
+              size="md"
+              color="amber"
+              icon="mdi-star-outline"
+              icon-selected="mdi-star"
+              class="q-mb-xs"
+            />
+            <div
+              v-if="serverErrors?.stars"
+              class="text-negative text-caption"
+            >
+              {{ serverErrors.stars }}
+            </div>
+          </div>
+
+          <q-input
+            v-model="form.comment"
+            :label="$t('reviews.comment')"
+            :error="!!serverErrors?.comment"
+            :error-message="serverErrors?.comment"
+            type="textarea"
+            autogrow
+            class="col-12"
+            @update:model-value="serverErrors.comment = null"
+          >
+            <template #append>
+              <q-icon name="mdi-camera-plus-outline">
+                <q-tooltip class="text-caption">{{ emDesenvolvimento }}</q-tooltip>
+              </q-icon>
+            </template>
+          </q-input>
+
+          <q-select
+            v-model="selectedImprovements"
+            :label="$t('reviews.improvements')"
+            :options="improvementOptions"
+            :loading="loadingImprovements"
+            option-value="value"
+            option-label="label"
+            emit-value
+            map-options
+            multiple
+            use-chips
+            class="col-12"
+            @update:model-value="($event) => attImprovementsOnForm($event)"
+          />
+          <q-checkbox
+            v-if="origin == 'client'"
+            v-model="form.block_provider"
+            :label="t('reviews.block_provider')"
+            checked-icon="mdi-check-circle"
+            unchecked-icon="mdi-checkbox-blank-circle-outline"
+          />
+          <q-checkbox
+            v-if="origin == 'provider'"
+            v-model="form.block_client"
+            :label="t('reviews.block_client')"
+            checked-icon="mdi-check-circle"
+            unchecked-icon="mdi-checkbox-blank-circle-outline"
+          />
+
+        </q-card-section>
+
+        <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-btn
+            flat
+            :label="$t('common.actions.cancel')"
+            color="negative"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            type="submit"
+            :label="$t('common.actions.save')"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+            color="primary"
+            unelevated
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useDialogPluginComponent } from 'quasar'
+import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker'
+import { useSubmitHandler } from 'src/composables/useSubmitHandler'
+import { createReview } from 'src/api/review'
+import { getImprovementTypes } from 'src/api/improvementType'
+import DefaultDialogHeader from 'src/components/defaults/DefaultDialogHeader.vue'
+import { useI18n } from 'vue-i18n'
+
+const props = defineProps({
+  scheduleId: {
+    type: Object,
+    required: true
+  },
+  clientId: {
+    type: Number,
+    required: true
+  },
+  providerId: {
+    type: Number,
+    required: true
+  },
+  origin: {
+    type: String,
+    required: true
+  },
+  title: {
+    type: Function,
+    default: () => ''
+  }
+})
+
+defineEmits([...useDialogPluginComponent.emits])
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const { t } = useI18n()
+const formRef = ref(null)
+
+const selectedImprovements = ref([])
+
+const improvementOptions = ref([])
+const loadingImprovements = ref(false)
+const emDesenvolvimento = 'Em Desenvolvimento...'
+
+const { form, hasUpdatedFields } = useFormUpdateTracker({
+  schedule_id: props.scheduleId,
+  origin: props.origin,
+  origin_id: props.origin === 'provider' ? props.providerId : props.clientId,
+  stars:       props.review ? Number(props.review.stars) : 0,
+  comment:     props.review ? props.review.comment : null,
+  improvements_ids: props.review ? props.review.improvements?.map(i => i.id) : [],
+  block_provider: props.review ? props.review.block_provider : false,
+  block_client: props.review ? props.review.block_client : false,
+})
+const {
+  loading,
+  serverErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  onSuccess: () => onDialogOK(true),
+  formRef: formRef,
+})
+
+const onOKClick = async () => {
+  await submitForm(async () => {
+    await submitForm(() => createReview({ ...form }));
+  })
+};
+
+const attImprovementsOnForm = (event) => {
+  form.improvements_ids = event;
+  serverErrors.improvements_ids = null;
+}
+
+onMounted(async () => {
+  // loadingSchedules.value = true
+  loadingImprovements.value = true
+  try {
+    const improvements = await getImprovementTypes(props.origin);
+    improvementOptions.value = improvements.map((i) => ({
+      value: i.id,
+      label: i.description,
+    }))
+  } catch (error) {
+    console.error('Error loading data:', error)
+  } finally {
+    // loadingSchedules.value = false
+    loadingImprovements.value = false
+  }
+})
+</script>

+ 1 - 0
src/pages/schedule/components/AddEditScheduleDialog.vue

@@ -550,6 +550,7 @@ const onOKClick = async () => {
       return
     }
     
+    // TODO: refatorar para uso do form adicionando as schedulesdates como um campo do form, para aproveitar melhor o useFormUpdateTracker e evitar ter que montar esse objeto manualmente  
     const formData = {
       client_id: form.client_id,
       provider_id: form.provider_id,

+ 84 - 2
src/pages/schedule/components/ViewScheduleDialog.vue

@@ -68,10 +68,65 @@
               size="sm"
             />
           </div>
+
+          <div v-if="schedule?.reviews?.length > 0" class="text-caption text-grey-7">{{ $t('reviews.header') }}</div>
+          <div v-if="schedule?.reviews?.length > 0" class="col-12">
+            <q-card v-for="review in schedule.reviews" :key="review.id" class="q-pa-md column" bordered>
+              <div>
+                {{ review.origin == 'client' ? $t('reviews.origins.clients') : $t('reviews.origins.providers') }}
+              </div>
+              <div class="col-12 row">
+                <div class="col-4">
+                  <q-rating
+                    v-model="review.stars"
+                    :max="5"
+                    size="md"
+                    color="amber"
+                    icon="mdi-star-outline"
+                    icon-selected="mdi-star"
+                    class="q-mb-xs"
+                    readonly
+                  />
+                </div>
+                <div class="col-8">
+                  <div>{{ $t('reviews.comment') + ':' }}</div>
+                  {{ review.comment }}
+                </div>
+              </div>
+              <div class="col-12 row q-pt-md">
+                <div class="col-4">
+                  <div>
+                    {{ $t("reviews.improvements") + ':' }}
+                  </div>
+                  <div v-for="improvement in review.improvements" :key="improvement.id">
+                    {{ improvement.improvement_type_name }}
+                  </div>
+                </div>
+                <div class="col-8">
+                  <div>
+                    {{ $t("common.terms.created_at") + ' :' }}
+                  </div>
+                  <div>
+                    {{ formatDateYMDtoDMY(review.created_at) }}
+                  </div>
+                </div>
+              </div>
+            </q-card>
+          </div>
         </div>
       </q-card-section>
 
       <q-card-actions align="right" class="q-px-md q-pb-md">
+
+        <q-btn
+          v-if="schedule?.status === 'finished' && (!schedule.reviews?.length > 0 || !schedule.reviews?.some(review => review.origin == viewMode))"
+          :label="$t('common.actions.review')"
+          color="primary"
+          icon="mdi-star-box"
+          @click="reviewSchedule"
+        />
+
+        <q-space/>
         <q-btn
           :label="$t('common.actions.close')"
           flat
@@ -101,7 +156,7 @@
             />
           </template>
           <q-btn
-            v-if="schedule?.status === 'paid'"
+            v-if="canFillCode"
             unelevated
             :label="$t('schedules.fill_code')"
             color="secondary"
@@ -129,7 +184,11 @@ import { useDialogPluginComponent, useQuasar } from 'quasar'
 import { verifyScheduleCode } from 'src/api/customSchedule'
 import { useI18n } from 'vue-i18n'
 import DefaultDialogHeader from 'src/components/defaults/DefaultDialogHeader.vue'
-import { formatToBRLCurrency } from 'src/helpers/utils'
+import { formatDateYMDtoDMY, formatToBRLCurrency } from 'src/helpers/utils'
+import { computed } from 'vue'
+import { defineAsyncComponent } from 'vue'
+
+const ScheduleReviewDialog = defineAsyncComponent(() => import('src/pages/review/components/ScheduleReviewDialog.vue'))
 
 const props = defineProps({
   schedule: {
@@ -155,6 +214,14 @@ const $q = useQuasar()
 const { t } = useI18n()
 const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } = useDialogPluginComponent()
 
+const canFillCode = computed(() => {
+  return (props.schedule?.status === 'paid' || 
+          props.schedule?.status === 'started' || 
+          props.schedule?.status === 'finished') 
+          && props.viewMode == 'provider' 
+          && !props.schedule.code_verified
+})
+
 const formatAddress = (address) => {
   if (!address) return 'N/A'
   const parts = [
@@ -257,4 +324,19 @@ const fillCode = () => {
     onDialogOK();
   })
 }
+
+const reviewSchedule = () => {
+  $q.dialog({
+    component: ScheduleReviewDialog,
+    componentProps: {
+      scheduleId: props.schedule.id,
+      clientId: props.schedule.client_id,
+      providerId: props.schedule.provider_id,
+      origin: props.viewMode,
+      title: () => t('common.actions.review')
+    }
+  }).onOk(() => {
+    emit('refreshData');
+  })
+}
 </script>

+ 22 - 0
src/router/routes/review.route.js

@@ -0,0 +1,22 @@
+export default [
+  {
+    path: '/reviews',
+    name: 'ReviewsPage',
+    component: () => import('pages/review/ReviewsPage.vue'),
+    meta: {
+      title: 'reviews.header',
+      requireAuth: true,
+      requiredPermission: 'config.review',
+      breadcrumbs: [
+        {
+          name: 'DashboardPage',
+          title: 'ui.navigation.dashboard'
+        },
+        {
+          name: 'ReviewsPage',
+          title: 'reviews.header'
+        }
+      ]
+    }
+  }
+]

+ 9 - 0
src/stores/navigation.js

@@ -31,6 +31,15 @@ export const navigationStore = defineStore("navigation", () => {
       permission: false,
       permissionScope: "config.custom_schedule",
     },
+    {
+      type: "single",
+      title: "ui.navigation.reviews",
+      name: "ReviewsPage",
+      icon: "mdi-star-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "config.review",
+    },
     {
       type: "expansive",
       title: "ui.navigation.registration",