ScheduleRatingDialog.vue 11 KB

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