CompleteProfileDialog.vue 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. <template>
  2. <q-dialog ref="dialogRef" persistent @hide="onDialogHide">
  3. <q-card class="complete-profile-card">
  4. <q-card-section class="complete-profile-header">
  5. <div class="text-h6 text-white text-weight-bold">
  6. {{ $t("associado.complete_profile.title") }}
  7. </div>
  8. <div class="text-caption text-white q-mt-xs" style="opacity: 0.85;">
  9. {{ $t("associado.complete_profile.message") }}
  10. </div>
  11. </q-card-section>
  12. <q-card-section class="q-pt-md">
  13. <div class="flex flex-center q-mb-md">
  14. <div class="avatar-wrap">
  15. <q-avatar size="80px" class="complete-profile-avatar">
  16. <img
  17. v-if="localPhotoUrl"
  18. :src="localPhotoUrl"
  19. style="object-fit: cover; width: 100%; height: 100%;"
  20. />
  21. <q-icon v-else name="mdi-account" size="48px" color="white" />
  22. </q-avatar>
  23. <q-btn
  24. round
  25. unelevated
  26. size="xs"
  27. icon="mdi-pencil-outline"
  28. class="avatar-edit-btn"
  29. color="white"
  30. text-color="violet-normal"
  31. :loading="uploadingPhoto"
  32. @click="avatarInputRef.click()"
  33. />
  34. <input
  35. ref="avatarInputRef"
  36. type="file"
  37. accept="image/*"
  38. style="display: none"
  39. @change="onAvatarSelected"
  40. />
  41. </div>
  42. </div>
  43. <div v-if="!localPhotoUrl" class="text-center text-caption text-negative q-mb-sm">
  44. {{ $t("associado.complete_profile.photo_hint") }}
  45. </div>
  46. <div class="q-col-gutter-sm row">
  47. <div class="col-12">
  48. <q-input
  49. v-model="form.name"
  50. outlined
  51. dense
  52. :label="$t('common.terms.name')"
  53. :readonly="!!initialUser.name"
  54. :bg-color="initialUser.name ? 'grey-2' : 'white'"
  55. />
  56. </div>
  57. <div class="col-12 col-sm-6">
  58. <q-input
  59. v-model="form.cpf"
  60. outlined
  61. dense
  62. label="CPF"
  63. mask="###.###.###-##"
  64. :readonly="!!initialUser.cpf"
  65. :bg-color="initialUser.cpf ? 'grey-2' : 'white'"
  66. />
  67. </div>
  68. <div class="col-12 col-sm-6">
  69. <q-input
  70. v-model="form.email"
  71. outlined
  72. dense
  73. label="E-mail"
  74. :readonly="!!initialUser.email"
  75. :bg-color="initialUser.email ? 'grey-2' : 'white'"
  76. />
  77. </div>
  78. <div class="col-12 col-sm-6">
  79. <q-input
  80. v-model="form.phone"
  81. outlined
  82. dense
  83. mask="(##) #####-####"
  84. :label="$t('auth.first_access.whatsapp')"
  85. :readonly="!!initialUser.phone"
  86. :bg-color="initialUser.phone ? 'grey-2' : 'white'"
  87. />
  88. </div>
  89. <div class="col-12 col-sm-6">
  90. <q-select
  91. v-model="form.position_id"
  92. outlined
  93. dense
  94. :label="$t('associado.position')"
  95. :options="positionOptions"
  96. option-value="id"
  97. option-label="name"
  98. emit-value
  99. map-options
  100. :readonly="!!initialUser.position_id"
  101. :bg-color="initialUser.position_id ? 'grey-2' : 'white'"
  102. />
  103. </div>
  104. <div class="col-12 col-sm-6">
  105. <q-select
  106. v-model="form.sector_id"
  107. outlined
  108. dense
  109. :label="$t('associado.sector')"
  110. :options="sectorOptions"
  111. option-value="id"
  112. option-label="name"
  113. emit-value
  114. map-options
  115. :readonly="!!initialUser.sector_id"
  116. :bg-color="initialUser.sector_id ? 'grey-2' : 'white'"
  117. />
  118. </div>
  119. <div class="col-12 col-sm-6">
  120. <q-input
  121. v-model="form.registration"
  122. outlined
  123. dense
  124. :label="$t('associado.registration')"
  125. readonly
  126. bg-color="grey-2"
  127. />
  128. </div>
  129. <div class="col-12 col-sm-6">
  130. <q-input
  131. v-model="form.admission_date"
  132. outlined
  133. dense
  134. :label="$t('associado.admission_date')"
  135. readonly
  136. bg-color="grey-2"
  137. />
  138. </div>
  139. </div>
  140. <TermsAcceptCheckbox
  141. v-if="!initialUser.terms_accepted_at"
  142. v-model="termsAccepted"
  143. class="q-mt-md"
  144. />
  145. </q-card-section>
  146. <q-card-actions class="q-px-md q-pb-md">
  147. <q-btn
  148. unelevated
  149. no-caps
  150. color="violet-normal"
  151. class="full-width"
  152. :label="$t('associado.complete_profile.save_continue')"
  153. :loading="saving"
  154. :disable="!canSave"
  155. @click="onSave"
  156. />
  157. </q-card-actions>
  158. </q-card>
  159. </q-dialog>
  160. </template>
  161. <script setup>
  162. import { ref, computed, onMounted, useTemplateRef } from "vue";
  163. import { useQuasar, useDialogPluginComponent } from "quasar";
  164. import { useI18n } from "vue-i18n";
  165. import { userStore } from "src/stores/user";
  166. import { getPositions } from "src/api/position";
  167. import { getSectors } from "src/api/sector";
  168. import { uploadMyAvatar, updateMyProfile } from "src/api/user";
  169. import { useMediaFile } from "src/composables/useMediaFile";
  170. import TermsAcceptCheckbox from "src/components/TermsAcceptCheckbox.vue";
  171. defineEmits([...useDialogPluginComponent.emits]);
  172. const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
  173. const $q = useQuasar();
  174. const { t } = useI18n();
  175. const store = userStore();
  176. const { prepareFile } = useMediaFile();
  177. const avatarInputRef = useTemplateRef("avatarInputRef");
  178. const initialUser = ref({});
  179. const localPhotoUrl = ref(null);
  180. const uploadingPhoto = ref(false);
  181. const saving = ref(false);
  182. const positionOptions = ref([]);
  183. const termsAccepted = ref(false);
  184. const sectorOptions = ref([]);
  185. const form = ref({
  186. name: "",
  187. cpf: "",
  188. email: "",
  189. phone: "",
  190. position_id: null,
  191. sector_id: null,
  192. registration: "",
  193. admission_date: "",
  194. });
  195. const canSave = computed(() => {
  196. return (
  197. (!!initialUser.value.terms_accepted_at || termsAccepted.value) &&
  198. !!localPhotoUrl.value &&
  199. !!form.value.name?.trim() &&
  200. !!form.value.cpf?.trim() &&
  201. !!form.value.email?.trim() &&
  202. !!form.value.phone?.trim() &&
  203. !!form.value.position_id &&
  204. !!form.value.sector_id
  205. );
  206. });
  207. const onAvatarSelected = async (event) => {
  208. const selected = event.target.files?.[0];
  209. event.target.value = "";
  210. if (!selected) return;
  211. const { file } = await prepareFile(selected);
  212. if (!file) return;
  213. uploadingPhoto.value = true;
  214. try {
  215. const updated = await uploadMyAvatar(file);
  216. store.user = updated;
  217. localPhotoUrl.value = updated.photo_url;
  218. $q.notify({ type: "positive", message: t("http.success") });
  219. } catch {
  220. $q.notify({ type: "negative", message: t("http.errors.failed") });
  221. } finally {
  222. uploadingPhoto.value = false;
  223. }
  224. };
  225. const onSave = async () => {
  226. saving.value = true;
  227. try {
  228. const payload = {};
  229. if (!initialUser.value.name) payload.name = form.value.name;
  230. if (!initialUser.value.cpf) payload.cpf = form.value.cpf;
  231. if (!initialUser.value.email) payload.email = form.value.email;
  232. if (!initialUser.value.phone) payload.phone = form.value.phone;
  233. if (!initialUser.value.position_id) payload.position_id = form.value.position_id;
  234. if (!initialUser.value.sector_id) payload.sector_id = form.value.sector_id;
  235. if (!initialUser.value.terms_accepted_at) payload.terms_accepted = 1;
  236. if (Object.keys(payload).length > 0) {
  237. const updated = await updateMyProfile(payload);
  238. store.user = updated;
  239. }
  240. onDialogOK();
  241. } catch {
  242. $q.notify({ type: "negative", message: t("http.errors.failed") });
  243. } finally {
  244. saving.value = false;
  245. }
  246. };
  247. onMounted(async () => {
  248. const user = store.user ?? {};
  249. initialUser.value = {
  250. name: user.name || null,
  251. cpf: user.cpf || null,
  252. email: user.email || null,
  253. phone: user.phone || null,
  254. position_id: user.position_id ?? user.position?.id ?? null,
  255. sector_id: user.sector_id ?? user.sector?.id ?? null,
  256. registration: user.registration || null,
  257. admission_date: user.admission_date || null,
  258. terms_accepted_at: user.terms_accepted_at || null,
  259. };
  260. form.value = {
  261. name: initialUser.value.name || "",
  262. cpf: initialUser.value.cpf || "",
  263. email: initialUser.value.email || "",
  264. phone: initialUser.value.phone || "",
  265. position_id: initialUser.value.position_id || null,
  266. sector_id: initialUser.value.sector_id || null,
  267. registration: initialUser.value.registration || "",
  268. admission_date: initialUser.value.admission_date || "",
  269. };
  270. localPhotoUrl.value = user.photo_url || null;
  271. try {
  272. const [positions, sectors] = await Promise.all([getPositions(), getSectors()]);
  273. positionOptions.value = positions ?? [];
  274. sectorOptions.value = sectors ?? [];
  275. } catch {
  276. // silent
  277. }
  278. });
  279. </script>
  280. <style scoped lang="scss">
  281. @use "src/css/quasar.variables.scss" as *;
  282. .complete-profile-card {
  283. width: 95vw;
  284. max-width: 480px;
  285. }
  286. .complete-profile-header {
  287. background: linear-gradient(135deg, $violet-normal 0%, $violet-dark 100%);
  288. border-radius: 4px 4px 0 0;
  289. }
  290. .avatar-wrap {
  291. position: relative;
  292. display: inline-block;
  293. }
  294. .complete-profile-avatar {
  295. background: rgba(112, 32, 130, 0.18) !important;
  296. border: 2px solid $violet-normal;
  297. img {
  298. width: 100%;
  299. height: 100%;
  300. object-fit: cover;
  301. }
  302. }
  303. .avatar-edit-btn {
  304. position: absolute;
  305. bottom: -2px;
  306. right: -2px;
  307. width: 24px;
  308. height: 24px;
  309. min-height: 24px;
  310. }
  311. </style>