ScheduleRatingDialog.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. <template>
  2. <q-dialog
  3. ref="dialogRef"
  4. @hide="onDialogHide"
  5. >
  6. <q-card
  7. class="rating-dialog-card bg-surface shadow-card"
  8. :flat="false"
  9. >
  10. <div class="row justify-end q-pt-sm q-pr-sm">
  11. <q-btn
  12. color="grey-6"
  13. dense
  14. flat
  15. icon="close"
  16. round
  17. size="sm"
  18. @click="onDialogCancel"
  19. />
  20. </div>
  21. <div class="column items-center q-pb-sm">
  22. <q-avatar
  23. class="q-mb-sm"
  24. size="64px"
  25. >
  26. <img
  27. v-if="photoUrl"
  28. style="object-fit: cover"
  29. :src="photoUrl"
  30. />
  31. <span
  32. v-else
  33. class="full-width full-height flex flex-center"
  34. style="font-size: 20px; border-radius: 50%;"
  35. :style="avatarStyle"
  36. >
  37. {{ initials }}
  38. </span>
  39. </q-avatar>
  40. <div
  41. class="text-text text-center font16 fontbold q-px-lg"
  42. style="line-height:1.3"
  43. >
  44. {{ $t('provider.dashboard.schedule_rating.title') }}
  45. <span class="text-primary">
  46. {{ (getFirstName(schedule.client_name) || '—') + '?' }}
  47. </span>
  48. </div>
  49. </div>
  50. <div class="column items-center q-pb-xs">
  51. <q-rating
  52. v-model="stars"
  53. color="amber"
  54. icon="mdi-star-outline"
  55. icon-selected="mdi-star"
  56. size="lg"
  57. :max="5"
  58. @update:model-value="onStarsChange"
  59. />
  60. </div>
  61. <q-card-section
  62. v-if="stars > 0"
  63. class="q-pt-xs q-pb-xs"
  64. >
  65. <div class="text-grey-7 text-center font12 q-mb-sm">
  66. {{ isNegative ? $t('provider.dashboard.schedule_rating.negative_label') : $t('provider.dashboard.schedule_rating.positive_label') }}
  67. </div>
  68. <div v-if="loadingTags" class="row justify-center q-py-sm">
  69. <q-spinner-dots
  70. color="primary"
  71. size="24px"
  72. />
  73. </div>
  74. <div v-else class="row justify-center q-gutter-xs">
  75. <div
  76. v-for="tag in tags"
  77. :key="tag.id"
  78. :class="{ 'tag-pill--selected': selectedTagIds.includes(tag.id), 'fontsemibold': selectedTagIds.includes(tag.id) }"
  79. class="tag-pill font12"
  80. @click="toggleTag(tag.id)"
  81. >
  82. {{ tag.description }}
  83. </div>
  84. </div>
  85. </q-card-section>
  86. <q-card-section class="q-pt-xs q-pb-xs q-px-lg">
  87. <div class="text-grey-7 font12 q-mb-xs">
  88. {{ $t('provider.dashboard.schedule_rating.comment_placeholder') }}
  89. </div>
  90. <q-input
  91. v-model="comment"
  92. color="primary"
  93. dense
  94. hide-bottom-space
  95. input-class="text-black"
  96. outlined
  97. rows="3"
  98. type="textarea"
  99. />
  100. </q-card-section>
  101. <q-card-section
  102. v-if="isNegative"
  103. class="q-pt-xs q-pb-xs q-px-lg"
  104. >
  105. <input
  106. ref="photoInputRef"
  107. accept="image/jpeg,image/png,image/webp"
  108. class="hidden"
  109. multiple
  110. type="file"
  111. @change="onPhotosSelected"
  112. />
  113. <div class="row q-gutter-xs">
  114. <div
  115. v-for="(preview, idx)
  116. in photoPreviews"
  117. :key="idx"
  118. class="photo-thumb"
  119. >
  120. <img :src="preview" />
  121. <q-btn
  122. class="photo-thumb__remove"
  123. dense
  124. flat
  125. icon="close"
  126. round
  127. size="xs"
  128. @click="removePhoto(idx)"
  129. />
  130. </div>
  131. <div
  132. v-if="photos.length < MAX_PHOTOS"
  133. class="photo-add"
  134. @click="photoInputRef.click()"
  135. >
  136. <q-icon
  137. color="grey-5"
  138. name="mdi-camera-plus-outline"
  139. size="24px"
  140. />
  141. <div class="photo-add__label font9">
  142. {{ $t('provider.dashboard.schedule_rating.add_photo') }}
  143. </div>
  144. </div>
  145. </div>
  146. </q-card-section>
  147. <q-card-section
  148. v-if="isNegative"
  149. class="q-pt-xs q-pb-xs q-px-lg"
  150. >
  151. <q-checkbox
  152. v-model="blockClient"
  153. checked-icon="mdi-check-circle"
  154. class="text-text"
  155. color="primary"
  156. keep-color
  157. unchecked-icon="mdi-checkbox-blank-circle-outline"
  158. :label="$t('provider.dashboard.schedule_rating.block_label')"
  159. />
  160. </q-card-section>
  161. <q-card-section class="q-pt-sm q-pb-xs q-px-lg row">
  162. <q-btn
  163. class="submit-btn font16 fontbold col-12"
  164. color="primary"
  165. full-width
  166. no-caps
  167. rounded
  168. unelevated
  169. :disable="stars === 0"
  170. :label="$t('provider.dashboard.schedule_rating.submit_btn')"
  171. :loading="loading"
  172. @click="submit"
  173. />
  174. </q-card-section>
  175. <q-card-section class="q-pt-xs q-pb-lg text-center">
  176. <span
  177. class="text-grey-6 font12 cursor-pointer"
  178. @click="openHelp"
  179. >
  180. {{ $t('provider.dashboard.schedule_rating.help_link') }}
  181. </span>
  182. </q-card-section>
  183. </q-card>
  184. </q-dialog>
  185. </template>
  186. <script setup>
  187. import { avatarColors, getFirstName } from 'src/helpers/utils'
  188. import { createReview, getImprovementTypes } from 'src/api/review'
  189. import { computed, onMounted, ref } from 'vue'
  190. import { useDialogPluginComponent, useQuasar } from 'quasar'
  191. import { useI18n } from 'vue-i18n'
  192. import { userStore } from 'src/stores/user'
  193. import ProfileHelpDialog from 'src/components/profile/ProfileHelpDialog.vue'
  194. const props = defineProps({
  195. schedule: {
  196. type: Object,
  197. required: true
  198. }
  199. })
  200. defineEmits([...useDialogPluginComponent.emits])
  201. const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
  202. const { t } = useI18n()
  203. const $q = useQuasar()
  204. const store = userStore()
  205. const MAX_PHOTOS = 5
  206. const stars = ref(0)
  207. const selectedTagIds = ref([])
  208. const comment = ref(null)
  209. const blockClient = ref(false)
  210. const tags = ref([])
  211. const loadingTags = ref(false)
  212. const loading = ref(false)
  213. const photos = ref([])
  214. const photoPreviews = ref([])
  215. const photoInputRef = ref(null)
  216. const avatarStyle = computed(() => {
  217. const c = avatarColors[props.schedule.client_id % avatarColors.length]
  218. return { background: c.background, color: c.color }
  219. })
  220. const isNegative = computed(() => stars.value > 0 && stars.value <= 2)
  221. const photoUrl = computed(() =>
  222. props.schedule.customer_photo
  223. ?? props.schedule.client_photo
  224. ?? props.schedule.client?.profile_media?.url
  225. ?? null
  226. )
  227. const initials = computed(() =>
  228. getFirstName(props.schedule.client_name)?.slice(0, 2).toUpperCase() ?? '??'
  229. )
  230. const onPhotosSelected = (event) => {
  231. const files = Array.from(event.target.files)
  232. const remaining = MAX_PHOTOS - photos.value.length
  233. files.slice(0, remaining).forEach(file => {
  234. photos.value.push(file)
  235. photoPreviews.value.push(URL.createObjectURL(file))
  236. })
  237. event.target.value = ''
  238. }
  239. const onStarsChange = () => {
  240. selectedTagIds.value = []
  241. blockClient.value = false
  242. if (!isNegative.value) clearPhotos()
  243. }
  244. const clearPhotos = () => {
  245. photoPreviews.value.forEach(preview => URL.revokeObjectURL(preview))
  246. photos.value = []
  247. photoPreviews.value = []
  248. }
  249. const removePhoto = (idx) => {
  250. URL.revokeObjectURL(photoPreviews.value[idx])
  251. photos.value.splice(idx, 1)
  252. photoPreviews.value.splice(idx, 1)
  253. }
  254. const toggleTag = (id) => {
  255. const idx = selectedTagIds.value.indexOf(id)
  256. if (idx === -1) selectedTagIds.value.push(id)
  257. else selectedTagIds.value.splice(idx, 1)
  258. }
  259. //
  260. const submit = async () => {
  261. if (stars.value === 0) return
  262. loading.value = true
  263. try {
  264. await createReview({
  265. schedule_id: props.schedule.id,
  266. origin: 'provider',
  267. origin_id: store.user.provider_id,
  268. stars: stars.value,
  269. comment: comment.value || null,
  270. improvements_ids: selectedTagIds.value,
  271. block_provider: false,
  272. block_client: isNegative.value && blockClient.value,
  273. favorite_provider: false,
  274. photos: isNegative.value ? photos.value : [],
  275. })
  276. onDialogOK(true)
  277. } catch (error) {
  278. const status = error?.response?.status
  279. if (status === 422) {
  280. $q.notify({ message: t('provider.dashboard.schedule_rating.already_reviewed'), color: 'negative', icon: 'mdi-alert-circle-outline' })
  281. } else {
  282. $q.notify({ message: t('http.errors.failed'), color: 'negative' })
  283. }
  284. } finally {
  285. loading.value = false
  286. }
  287. }
  288. //
  289. const openHelp = () => {
  290. $q.dialog({ component: ProfileHelpDialog })
  291. }
  292. //
  293. onMounted(async () => {
  294. loadingTags.value = true
  295. try {
  296. const result = await getImprovementTypes('provider')
  297. tags.value = result ?? []
  298. } catch {
  299. tags.value = []
  300. } finally {
  301. loadingTags.value = false
  302. }
  303. })
  304. </script>
  305. <style scoped lang="scss">
  306. .rating-dialog-card {
  307. width: 320px;
  308. max-width: 96vw;
  309. border-radius: 20px !important;
  310. overflow: hidden;
  311. }
  312. .tag-pill {
  313. border: 1.5px solid #d1d5db;
  314. border-radius: 20px;
  315. padding: 5px 14px;
  316. color: #6b7280;
  317. cursor: pointer;
  318. transition: border-color 0.15s, color 0.15s;
  319. user-select: none;
  320. &--selected {
  321. border-color: #8B5CF6;
  322. color: #8B5CF6;
  323. }
  324. }
  325. .submit-btn {
  326. padding: 10px 0;
  327. }
  328. .photo-thumb {
  329. position: relative;
  330. width: 64px;
  331. height: 64px;
  332. border-radius: 8px;
  333. overflow: hidden;
  334. flex-shrink: 0;
  335. img {
  336. width: 100%;
  337. height: 100%;
  338. object-fit: cover;
  339. }
  340. &__remove {
  341. position: absolute !important;
  342. top: 2px;
  343. right: 2px;
  344. background: rgba(0, 0, 0, 0.5) !important;
  345. color: white !important;
  346. min-height: unset !important;
  347. }
  348. }
  349. .photo-add {
  350. width: 64px;
  351. height: 64px;
  352. border-radius: 8px;
  353. border: 1.5px dashed #d1d5db;
  354. display: flex;
  355. flex-direction: column;
  356. align-items: center;
  357. justify-content: center;
  358. cursor: pointer;
  359. flex-shrink: 0;
  360. &__label {
  361. color: #9ca3af;
  362. text-align: center;
  363. margin-top: 2px;
  364. line-height: 1.2;
  365. }
  366. &:active { background: rgba(0, 0, 0, 0.03); }
  367. }
  368. .hidden { display: none; }
  369. </style>