Explorar o código

feat: :sparkles: feat (calculo-distancias) criado calculo distancias com metodo matematico

foi criado o calculo de distancias com metodo matematico + funcao de preencher endereco no perfil com google maps quando nao tem lat long preenchido

fase:dev | origin:escopo
Gustavo Zanatta hai 2 semanas
pai
achega
f949e84092

+ 1 - 1
src/components/dashboard/DashboardProvidersClose.vue

@@ -41,7 +41,7 @@
                   </div>
                   <div class="row items-center">
                     <q-icon name="mdi-map-marker-outline" color="text" size="16px" />
-                    <span class="text-provider-close-jobs">{{ 0 + ' km' }}</span>
+                    <span class="text-provider-close-jobs">{{ (p.distance_km ?? '--') + ' km' }}</span>
                   </div>
                 </div>
               </div>

+ 94 - 1
src/components/profile/ProfileAddressFormDialog.vue

@@ -92,6 +92,27 @@
               </div>
             </div>
 
+            <div v-if="missingCoords" class="q-mb-md">
+              <q-banner rounded dense class="bg-orange-1 text-orange-9 q-mb-sm">
+                <template #avatar>
+                  <q-icon name="mdi-map-marker-off" color="orange-7" />
+                </template>
+                {{ $t('profile.address.missing_coords') }}
+              </q-banner>
+              <q-btn
+                unelevated
+                rounded
+                no-caps
+                outline
+                color="primary"
+                icon="mdi-map-marker"
+                class="full-width"
+                :label="$t('profile.address.update_on_map')"
+                :loading="geocodingCep"
+                @click="openMapDialog"
+              />
+            </div>
+
             <q-btn
               unelevated
               rounded
@@ -114,12 +135,14 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from 'vue';
+import { ref, computed, onMounted } from 'vue';
 import { useDialogPluginComponent, useQuasar } from 'quasar';
 import { searchAddressByCEP, updateAddress, createAddress } from 'src/api/address';
 import { userStore } from 'src/stores/user';
 import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker';
+import { useGeocodingApi } from 'src/composables/useGeocodingApi';
 import { useI18n } from 'vue-i18n';
+import LocationMapDialog from 'src/components/shared/LocationMapDialog.vue';
 
 const props = defineProps({
   isEditing: {
@@ -139,6 +162,7 @@ const $q = useQuasar();
 const { t } = useI18n();
 const user = userStore();
 const clientId = user.user.client.id;
+const { geocodeFullAddress } = useGeocodingApi();
 
 const initialFormData = {
   zip_code: '',
@@ -153,6 +177,8 @@ const initialFormData = {
   source: 'client',
   source_id: clientId,
   address_type: 'home',
+  latitude: null,
+  longitude: null,
 };
 
 const { form, hasUpdatedFields, getUpdatedFields, setUpdateFormAsOriginal } =
@@ -161,6 +187,11 @@ const { form, hasUpdatedFields, getUpdatedFields, setUpdateFormAsOriginal } =
 const loadingCep = ref(false);
 const saving = ref(false);
 const addressId = ref(null);
+const geocodingCep = ref(false);
+
+const missingCoords = computed(() =>
+  props.isEditing && form.latitude == null && form.longitude == null
+);
 
 const addressTypes = [
   { value: 'home', label: 'profile.address.type.home', icon: 'mdi-home-outline' },
@@ -180,6 +211,8 @@ const onCepChange = async (val) => {
         form.state_id = data.state_id;
         form.city = data.city;
         form.state = data.state;
+        form.latitude = null;
+        form.longitude = null;
       } else {
         $q.notify({ type: 'negative', message: t('profile.address.cep_not_found') });
       }
@@ -189,6 +222,64 @@ const onCepChange = async (val) => {
   }
 };
 
+const openMapDialog = async () => {
+  let initialLat = null;
+  let initialLng = null;
+
+  const hasAddress = form.address || form.zip_code;
+  if (hasAddress) {
+    geocodingCep.value = true;
+    try {
+      const geo = await geocodeFullAddress({
+        address:  form.address,
+        number:   form.number,
+        district: form.district,
+        zip_code: form.zip_code,
+        city:     form.city?.name,
+        state:    form.state?.name,
+      });
+      if (geo) {
+        initialLat = geo.lat;
+        initialLng = geo.lng;
+      }
+    } catch {
+      // fallback to default
+    } finally {
+      geocodingCep.value = false;
+    }
+  }
+
+  const dialogProps = initialLat !== null
+    ? { initialLat, initialLng }
+    : {};
+
+  $q.dialog({
+    component: LocationMapDialog,
+    componentProps: dialogProps,
+  }).onOk(async (geoData) => {
+    form.latitude = geoData.lat;
+    form.longitude = geoData.lng;
+    form.address = geoData.address || form.address;
+    form.number = geoData.number || form.number;
+    form.district = geoData.district || form.district;
+
+    if (geoData.zip_code) {
+      form.zip_code = geoData.zip_code.replace(/\D/g, '');
+      try {
+        const addressData = await searchAddressByCEP(form.zip_code);
+        if (addressData) {
+          form.city_id = addressData.city_id;
+          form.state_id = addressData.state_id;
+          form.city = addressData.city;
+          form.state = addressData.state;
+        }
+      } catch {
+        // mantém cidade/estado atual se lookup falhar
+      }
+    }
+  });
+};
+
 const save = async () => {
   saving.value = true;
   try {
@@ -228,6 +319,8 @@ onMounted(() => {
       source: 'client',
       source_id: clientId,
       address_type: props.addressData.address_type || 'home',
+      latitude: props.addressData.latitude ?? null,
+      longitude: props.addressData.longitude ?? null,
     };
 
     Object.assign(form, initialData);

+ 206 - 0
src/components/shared/LocationMapDialog.vue

@@ -0,0 +1,206 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized @show="initMap">
+    <div class="location-map-dialog">
+      <div ref="mapRef" class="location-map-container" />
+
+      <q-btn
+        flat
+        round
+        color="white"
+        icon="mdi-arrow-left"
+        class="location-map-back-btn"
+        @click="handleCancel"
+      />
+
+      <div class="location-map-bottom-card">
+        <p class="location-map-address-label">{{ $t('common.terms.address') }}</p>
+        <q-input
+          :model-value="currentAddress"
+          outlined
+          dense
+          readonly
+          class="location-map-address-input q-mb-md"
+          bg-color="white"
+          input-class="text-text"
+          :loading="reversing"
+          :placeholder="reversing ? '' : $t('auth.geocoding_failed_short')"
+        />
+        <q-btn
+          color="primary-button"
+          :label="$t('auth.confirm_location')"
+          rounded
+          padding="14px 16px"
+          class="full-width"
+          :loading="reversing"
+          :disable="!currentGeoData"
+          @click="handleConfirm"
+        />
+      </div>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, onUnmounted } from 'vue';
+import { useDialogPluginComponent, useQuasar } from 'quasar';
+import { useI18n } from 'vue-i18n';
+import { GoogleMap } from '@capacitor/google-maps';
+import { useGeocodingApi } from 'src/composables/useGeocodingApi';
+
+const props = defineProps({
+  initialLat: {
+    type: Number,
+    default: -15.7801,
+  },
+  initialLng: {
+    type: Number,
+    default: -47.9292,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const $q = useQuasar();
+const { t } = useI18n();
+const { reverseGeocode } = useGeocodingApi();
+
+const mapRef = ref(null);
+const reversing = ref(false);
+const currentAddress = ref('');
+const currentGeoData = ref(null);
+
+let googleMap = null;
+const markerId = ref(null);
+
+const handleCancel = async () => {
+  await destroyMap();
+  onDialogCancel();
+};
+
+const handleConfirm = async () => {
+  if (!currentGeoData.value) return;
+  await destroyMap();
+  onDialogOK(currentGeoData.value);
+};
+
+const destroyMap = async () => {
+  if (googleMap) {
+    await googleMap.destroy();
+    googleMap = null;
+  }
+};
+
+const updateMarkerPosition = async (lat, lng) => {
+  reversing.value = true;
+  try {
+    const geoData = await reverseGeocode(lat, lng);
+    if (geoData) {
+      currentGeoData.value = { ...geoData, lat, lng };
+      const parts = [geoData.address, geoData.number, geoData.district, geoData.city, geoData.state].filter(Boolean);
+      currentAddress.value = parts.join(', ');
+    } else {
+      currentGeoData.value = { lat, lng, address: '', number: '', district: '', city: '', state: '', zip_code: '' };
+      currentAddress.value = '';
+      $q.notify({ type: 'warning', message: t('auth.geocoding_failed') });
+    }
+  } catch {
+    currentGeoData.value = { lat, lng, address: '', number: '', district: '', city: '', state: '', zip_code: '' };
+    currentAddress.value = '';
+    $q.notify({ type: 'warning', message: t('auth.geocoding_failed') });
+  } finally {
+    reversing.value = false;
+  }
+};
+
+const moveMarkerTo = async (lat, lng) => {
+  if (markerId.value) {
+    await googleMap.removeMarker(markerId.value);
+  }
+  markerId.value = await googleMap.addMarker({
+    coordinate: { lat, lng },
+    draggable: true,
+  });
+};
+
+const initMap = async () => {
+  googleMap = await GoogleMap.create({
+    id: 'location-map-dialog',
+    element: mapRef.value,
+    apiKey: process.env.GOOGLE_MAPS_API_KEY,
+    config: {
+      center: { lat: props.initialLat, lng: props.initialLng },
+      zoom: 17,
+    },
+  });
+
+  markerId.value = await googleMap.addMarker({
+    coordinate: { lat: props.initialLat, lng: props.initialLng },
+    draggable: true,
+  });
+
+  await updateMarkerPosition(props.initialLat, props.initialLng);
+
+  await googleMap.setOnMarkerDragEndListener(async (event) => {
+    const { latitude, longitude } = event;
+    await updateMarkerPosition(latitude, longitude);
+  });
+
+  await googleMap.setOnMapClickListener(async (event) => {
+    const { latitude, longitude } = event;
+    await moveMarkerTo(latitude, longitude);
+    await updateMarkerPosition(latitude, longitude);
+  });
+};
+
+onUnmounted(async () => {
+  await destroyMap();
+});
+</script>
+
+<style lang="scss" scoped>
+.location-map-dialog {
+  position: relative;
+  width: 100vw;
+  height: 100dvh;
+  overflow: hidden;
+  background: #e5e3df;
+}
+
+.location-map-container {
+  width: 100%;
+  height: 100%;
+}
+
+.location-map-back-btn {
+  position: absolute;
+  top: calc(16px + env(safe-area-inset-top));
+  left: 16px;
+  z-index: 10;
+  background: rgba(0, 0, 0, 0.4);
+  border-radius: 50%;
+}
+
+.location-map-bottom-card {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 10;
+  background: white;
+  border-radius: 24px 24px 0 0;
+  padding: 20px 20px calc(20px + env(safe-area-inset-bottom));
+  box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.12);
+}
+
+.location-map-address-label {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--q-text);
+  margin: 0 0 8px;
+}
+
+.location-map-address-input {
+  border-radius: 8px;
+}
+</style>

+ 25 - 1
src/composables/useGeocodingApi.js

@@ -62,5 +62,29 @@ export const useGeocodingApi = () => {
     return { lat, lng, ...parseAddressComponents(result.address_components) };
   };
 
-  return { geocodeCep, reverseGeocode };
+  const geocodeFullAddress = async ({ address, number, district, zip_code, city, state }) => {
+    const parts = [address, number, district, zip_code, city, state, 'Brazil'].filter(Boolean);
+    const query = parts.join(', ');
+
+    if (window.google?.maps?.Geocoder) {
+      const results = await geocodeViaGoogleMaps({ address: query });
+      if (!results.length) return null;
+      const result = results[0];
+      return {
+        lat: result.geometry.location.lat(),
+        lng: result.geometry.location.lng(),
+        ...parseAddressComponents(result.address_components),
+      };
+    }
+
+    const { data } = await axios.get(GEOCODING_URL, {
+      params: { address: query, key: process.env.GOOGLE_MAPS_API_KEY },
+    });
+    if (data.status !== 'OK' || !data.results.length) return null;
+    const result = data.results[0];
+    const { lat, lng } = result.geometry.location;
+    return { lat, lng, ...parseAddressComponents(result.address_components) };
+  };
+
+  return { geocodeCep, reverseGeocode, geocodeFullAddress };
 };

+ 6 - 4
src/i18n/locales/en.json

@@ -407,9 +407,9 @@
       "services": "Services"
     },
     "dashboard_pending_custom_schedules": {
-        "pending_request_title": "Requesting cleaner proposals",
-        "pending_request_time": "15 minutes ago",
-        "waiting_status": "Waiting"
+      "pending_request_title": "Requesting cleaner proposals",
+      "pending_request_time": "15 minutes ago",
+      "waiting_status": "Waiting"
     },
     "summary": {
       "welcome": "Welcome,",
@@ -618,7 +618,9 @@
         "home": "Residential",
         "commercial": "Commercial",
         "other": "Other"
-      }
+      },
+      "missing_coords": "Coordinates not found for this address.",
+      "update_on_map": "Update on map"
     },
     "help": {
       "title": "Help",

+ 3 - 1
src/i18n/locales/es.json

@@ -614,7 +614,9 @@
         "home": "Residencial",
         "commercial": "Comercial",
         "other": "Otro"
-      }
+      },
+      "missing_coords": "Coordenadas no encontradas para esta dirección.",
+      "update_on_map": "Actualizar en el mapa"
     },
     "help": {
       "title": "Ayuda",

+ 9 - 7
src/i18n/locales/pt.json

@@ -444,12 +444,12 @@
       "btn_close": "fechar",
       "btn_help": "Ajuda"
     },
-      "client_proposals": {
-        "candidate": "Candidato",
-        "custom": "sob medida",
-        "age": "({idade} anos)",
-        "distance": "Distância"
-      },
+    "client_proposals": {
+      "candidate": "Candidato",
+      "custom": "sob medida",
+      "age": "({idade} anos)",
+      "distance": "Distância"
+    },
     "last_schedules": {
       "title": "Últimos serviços",
       "reschedule": "reagendar",
@@ -630,7 +630,9 @@
         "home": "Residencial",
         "commercial": "Comercial",
         "other": "Outro"
-      }
+      },
+      "missing_coords": "Coordenadas não encontradas para este endereço.",
+      "update_on_map": "Atualizar no mapa"
     },
     "help": {
       "title": "Ajuda",

+ 1 - 3
src/pages/dashboard/components/DashboardClientProposals.vue

@@ -50,7 +50,7 @@
               </span>
 
               <span class="distance">
-                {{ $t('dashboard_client.client_proposals.distance') }} {{ distancia }}
+                {{ $t('dashboard_client.client_proposals.distance') }} {{ (item.distance_km ?? '--') + ' km' }}
               </span>
             </div>
 
@@ -117,8 +117,6 @@ const formatDate = (date) => {
   return `${weekday}, ${day}-${month}`;
 };
 
-const distancia = '1,5km'
-
 const handleRefuseProposal = async (proposalId) => {
   // isLoading.value = true
   try {

+ 1 - 1
src/pages/search/SearchPage.vue

@@ -112,7 +112,7 @@
                     </div>
                     <div class="row items-center">
                       <q-icon name="mdi-map-marker-outline" color="text" size="16px" />
-                      <span class="text-provider-close-jobs">{{ 0 + ' km' }}</span>
+                      <span class="text-provider-close-jobs">{{ (p.distance_km ?? '--') + ' km' }}</span>
                     </div>
                   </div>
                 </div>