ProfileAddressFormDialog.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. <template>
  2. <q-dialog ref="dialogRef" persistent maximized transition-show="slide-left" transition-hide="slide-right">
  3. <div class="bg-page full-height no-shadow">
  4. <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-profile bg-surface">
  5. <q-btn v-close-popup icon="mdi-chevron-left" flat round dense color="primary" />
  6. <q-space />
  7. <span class="text-subtitle1 text-weight-bold text-primary">
  8. {{ isEditing ? $t('profile.address.edit_address') : $t('profile.address.add_new_address') }}
  9. </span>
  10. <q-space />
  11. <div style="width: 32px"></div>
  12. </div>
  13. <q-card-section class="col">
  14. <div class="q-px-md q-pt-lg text-text">
  15. <q-card class="q-pa-lg bg-white shadow-card" style="border-radius: 25px;" :flat="false">
  16. <div class="text-weight-bold text-text q-mb-xs">{{ $t('profile.address.cep') }}</div>
  17. <q-input
  18. v-model="form.zip_code"
  19. input-class="text-text"
  20. outlined
  21. dense
  22. mask="#####-###"
  23. unmasked-value
  24. placeholder="00000-000"
  25. class="q-mb-md"
  26. @update:model-value="onCepChange"
  27. >
  28. <template #append>
  29. <q-spinner v-if="loadingCep" size="xs" color="primary" />
  30. </template>
  31. </q-input>
  32. <div class="text-weight-bold q-mb-xs text-text">{{ $t('profile.address.address_label') }}</div>
  33. <q-input
  34. v-model="form.address"
  35. outlined
  36. dense
  37. class="q-mb-md"
  38. input-class="text-text"
  39. :placeholder="$t('profile.address.address_placeholder')"
  40. />
  41. <div class="row q-col-gutter-sm q-mb-md">
  42. <div class="col-4">
  43. <div class="text-weight-bold q-mb-xs text-text">{{ $t('profile.address.number') }}</div>
  44. <q-input v-model="form.number" outlined dense input-class="text-text" placeholder="0000" />
  45. </div>
  46. <div class="col-8">
  47. <div class="text-weight-bold q-mb-xs text-text">{{ $t('profile.address.complement') }}</div>
  48. <q-input
  49. v-model="form.complement"
  50. outlined
  51. dense
  52. input-class="text-text"
  53. :placeholder="$t('profile.address.complement_placeholder')"
  54. />
  55. </div>
  56. </div>
  57. <div class="text-weight-bold q-mb-xs text-text">{{ $t('profile.address.district_label') }}</div>
  58. <q-input v-model="form.district" outlined dense class="q-mb-md" input-class="text-text" />
  59. <div class="row q-col-gutter-sm q-mb-lg">
  60. <div class="col-8">
  61. <div class="text-weight-bold q-mb-xs text-text">{{ $t('profile.address.city_label') }}</div>
  62. <q-input :model-value="form.city?.name" readonly outlined dense input-class="text-text" />
  63. </div>
  64. <div class="col-4">
  65. <div class="text-weight-bold q-mb-xs text-text">{{ $t('profile.address.state_label') }}</div>
  66. <q-input :model-value="form.state?.name" readonly outlined dense input-class="text-text" />
  67. </div>
  68. </div>
  69. <div class="q-mb-lg">
  70. <div class="row q-gutter-sm">
  71. <q-chip
  72. v-for="type in addressTypes"
  73. :key="type.value"
  74. :selected="form.address_type === type.value"
  75. clickable
  76. color="primary"
  77. :outline="form.address_type !== type.value"
  78. text-color="surface"
  79. :icon="type.icon"
  80. :icon-selected="type.icon"
  81. @click="form.address_type = type.value"
  82. >
  83. {{ $t(type.label) }}
  84. </q-chip>
  85. </div>
  86. </div>
  87. <div v-if="missingCoords" class="q-mb-md">
  88. <q-banner rounded dense class="bg-orange-1 text-orange-9 q-mb-sm">
  89. <template #avatar>
  90. <q-icon name="mdi-map-marker-off" color="orange-7" />
  91. </template>
  92. {{ $t('profile.address.missing_coords') }}
  93. </q-banner>
  94. <q-btn
  95. unelevated
  96. rounded
  97. no-caps
  98. outline
  99. color="primary"
  100. icon="mdi-map-marker"
  101. class="full-width"
  102. :label="$t('profile.address.update_on_map')"
  103. :loading="geocodingCep"
  104. @click="openMapDialog"
  105. />
  106. </div>
  107. <q-btn
  108. unelevated
  109. rounded
  110. no-caps
  111. color="primary"
  112. class="full-width q-py-md text-weight-bold"
  113. padding="8px 16px"
  114. style="font-size: 1.1rem;"
  115. :label="$t('common.actions.save')"
  116. :loading="saving"
  117. :disable="!hasUpdatedFields"
  118. @click="save"
  119. />
  120. </q-card>
  121. </div>
  122. <div class="q-pb-xl"></div>
  123. </q-card-section>
  124. </div>
  125. </q-dialog>
  126. </template>
  127. <script setup>
  128. import { ref, computed, onMounted } from 'vue';
  129. import { useDialogPluginComponent, useQuasar } from 'quasar';
  130. import { searchAddressByCEP, updateAddress, createAddress } from 'src/api/address';
  131. import { userStore } from 'src/stores/user';
  132. import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker';
  133. import { useGeocodingApi } from 'src/composables/useGeocodingApi';
  134. import { useI18n } from 'vue-i18n';
  135. import LocationMapDialog from 'src/components/shared/LocationMapDialog.vue';
  136. const props = defineProps({
  137. isEditing: {
  138. type: Boolean,
  139. default: false,
  140. },
  141. addressData: {
  142. type: Object,
  143. default: null,
  144. },
  145. });
  146. defineEmits([...useDialogPluginComponent.emits]);
  147. const { dialogRef, onDialogOK } = useDialogPluginComponent();
  148. const $q = useQuasar();
  149. const { t } = useI18n();
  150. const user = userStore();
  151. const clientId = user.user.client.id;
  152. const { geocodeFullAddress } = useGeocodingApi();
  153. const initialFormData = {
  154. zip_code: '',
  155. address: '',
  156. number: '',
  157. complement: '',
  158. district: '',
  159. city_id: null,
  160. state_id: null,
  161. city: null,
  162. state: null,
  163. source: 'client',
  164. source_id: clientId,
  165. address_type: 'home',
  166. latitude: null,
  167. longitude: null,
  168. };
  169. const { form, hasUpdatedFields, getUpdatedFields, setUpdateFormAsOriginal } =
  170. useFormUpdateTracker(initialFormData);
  171. const loadingCep = ref(false);
  172. const saving = ref(false);
  173. const addressId = ref(null);
  174. const geocodingCep = ref(false);
  175. const missingCoords = computed(() =>
  176. props.isEditing && form.latitude == null && form.longitude == null
  177. );
  178. const addressTypes = [
  179. { value: 'home', label: 'profile.address.type.home', icon: 'mdi-home-outline' },
  180. { value: 'commercial', label: 'profile.address.type.commercial', icon: 'mdi-briefcase-variant-outline' },
  181. { value: 'other', label: 'profile.address.type.other', icon: 'mdi-map-marker-outline' },
  182. ];
  183. const onCepChange = async (val) => {
  184. if (val?.length === 8) {
  185. loadingCep.value = true;
  186. try {
  187. const data = await searchAddressByCEP(val);
  188. if (data) {
  189. form.address = data.address;
  190. form.district = data.district;
  191. form.city_id = data.city_id;
  192. form.state_id = data.state_id;
  193. form.city = data.city;
  194. form.state = data.state;
  195. form.latitude = null;
  196. form.longitude = null;
  197. } else {
  198. $q.notify({ type: 'negative', message: t('profile.address.cep_not_found') });
  199. }
  200. } finally {
  201. loadingCep.value = false;
  202. }
  203. }
  204. };
  205. const openMapDialog = async () => {
  206. let initialLat = null;
  207. let initialLng = null;
  208. const hasAddress = form.address || form.zip_code;
  209. if (hasAddress) {
  210. geocodingCep.value = true;
  211. try {
  212. const geo = await geocodeFullAddress({
  213. address: form.address,
  214. number: form.number,
  215. district: form.district,
  216. zip_code: form.zip_code,
  217. city: form.city?.name,
  218. state: form.state?.name,
  219. });
  220. if (geo) {
  221. initialLat = geo.lat;
  222. initialLng = geo.lng;
  223. }
  224. } catch {
  225. // fallback to default
  226. } finally {
  227. geocodingCep.value = false;
  228. }
  229. }
  230. const dialogProps = initialLat !== null
  231. ? { initialLat, initialLng }
  232. : {};
  233. $q.dialog({
  234. component: LocationMapDialog,
  235. componentProps: dialogProps,
  236. }).onOk(async (geoData) => {
  237. form.latitude = geoData.lat;
  238. form.longitude = geoData.lng;
  239. form.address = geoData.address || form.address;
  240. form.number = geoData.number || form.number;
  241. form.district = geoData.district || form.district;
  242. if (geoData.zip_code) {
  243. form.zip_code = geoData.zip_code.replace(/\D/g, '');
  244. try {
  245. const addressData = await searchAddressByCEP(form.zip_code);
  246. if (addressData) {
  247. form.city_id = addressData.city_id;
  248. form.state_id = addressData.state_id;
  249. form.city = addressData.city;
  250. form.state = addressData.state;
  251. }
  252. } catch {
  253. // mantém cidade/estado atual se lookup falhar
  254. }
  255. }
  256. });
  257. };
  258. const save = async () => {
  259. saving.value = true;
  260. try {
  261. let response;
  262. if (props.isEditing && addressId.value) {
  263. response = await updateAddress(getUpdatedFields.value, addressId.value);
  264. } else {
  265. response = await createAddress({ ...form });
  266. }
  267. if (response) {
  268. setUpdateFormAsOriginal();
  269. onDialogOK(response);
  270. }
  271. } catch (error) {
  272. console.error('Erro ao salvar endereço:', error);
  273. $q.notify({ type: 'negative', message: t('profile.address.error_saving') });
  274. } finally {
  275. saving.value = false;
  276. }
  277. };
  278. onMounted(() => {
  279. if (props.isEditing && props.addressData) {
  280. addressId.value = props.addressData.id;
  281. const initialData = {
  282. zip_code: props.addressData.zip_code || '',
  283. address: props.addressData.address || '',
  284. number: props.addressData.number || '',
  285. complement: props.addressData.complement || '',
  286. district: props.addressData.district || '',
  287. city_id: props.addressData.city_id || null,
  288. state_id: props.addressData.state_id || null,
  289. city: props.addressData.city || null,
  290. state: props.addressData.state || null,
  291. source: 'client',
  292. source_id: clientId,
  293. address_type: props.addressData.address_type || 'home',
  294. latitude: props.addressData.latitude ?? null,
  295. longitude: props.addressData.longitude ?? null,
  296. };
  297. Object.assign(form, initialData);
  298. setUpdateFormAsOriginal();
  299. }
  300. });
  301. </script>
  302. <style scoped>
  303. </style>