raul310882 7e58077dd0 feat: integrate Mapbox GL Draw for geofence management in LocationForm
- Added @mapbox/mapbox-gl-draw dependency to package.json.
- Enhanced LocationForm.vue to support geofence drawing and editing using Mapbox GL Draw.
- Implemented functions for creating, updating, and syncing geofences with the map.
- Updated types for geofence handling in locations.interfaces.ts.
- Refactored coordinate parsing and formatting logic for better clarity and functionality.
- Improved UI components in Locations.vue for better user experience and interaction.
2026-04-02 12:08:18 -06:00

411 lines
16 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import Card from 'primevue/card';
import Button from 'primevue/button';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import InputText from 'primevue/inputtext';
import Paginator from 'primevue/paginator';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { locationServices } from '../../services/location.services';
import type { Location, LocationCreateRequest, LocationFormErrors, LocationPaginatedResponse } from '../../types/locations.interfaces';
import LocationForm from './LocationForm.vue';
import { useAuth } from '@/modules/auth/composables/useAuth';
const locations = ref<Location[]>([]);
const pagination = ref({
first: 0,
rows: 5,
total: 0,
page: 1,
lastPage: 1,
});
const loading = ref(false);
const submitting = ref(false);
const searchName = ref('');
const searchCity = ref('');
const searchState = ref('');
const searchCountry = ref('');
const showFormDialog = ref(false);
const isEditMode = ref(false);
const currentLocation = ref<Location | null>(null);
const formErrors = ref<LocationFormErrors>({});
const confirm = useConfirm();
const toast = useToast();
const { hasPermission } = useAuth();
const canViewLocations = computed(() =>
hasPermission([
'locations.index',
'locations.show',
'locations.store',
'locations.update',
'locations.destroy',
])
);
const canCreateLocation = computed(() => hasPermission('locations.store'));
const canUpdateLocation = computed(() => hasPermission('locations.update'));
const canDeleteLocation = computed(() => hasPermission('locations.destroy'));
const handleCreateClick = () => {
if (!canCreateLocation.value) {
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes crear ubicaciones.', life: 4000 });
return;
}
isEditMode.value = false;
currentLocation.value = null;
formErrors.value = {};
showFormDialog.value = true;
};
const handleEditClick = async (location: Location) => {
if (!canUpdateLocation.value) {
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes actualizar ubicaciones.', life: 4000 });
return;
}
isEditMode.value = true;
formErrors.value = {};
try {
const response = await locationServices.getLocationById(location.id);
currentLocation.value = response.data;
showFormDialog.value = true;
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo cargar la ubicación', life: 3000 });
}
};
const closeFormDialog = () => {
showFormDialog.value = false;
isEditMode.value = false;
currentLocation.value = null;
formErrors.value = {};
};
const handleFormSubmit = async (payload: LocationCreateRequest) => {
formErrors.value = {};
submitting.value = true;
try {
if (isEditMode.value && currentLocation.value) {
await locationServices.updateLocation(currentLocation.value.id, payload, 'patch');
toast.add({ severity: 'success', summary: 'Ubicación actualizada', detail: 'La ubicación se actualizó correctamente.', life: 3000 });
} else {
await locationServices.createLocation(payload);
toast.add({ severity: 'success', summary: 'Ubicación creada', detail: 'La ubicación se registró correctamente.', life: 3000 });
}
closeFormDialog();
fetchLocations(pagination.value.page);
} catch (e: any) {
if (e?.response?.data?.errors) {
formErrors.value = e.response.data.errors;
}
toast.add({
severity: 'error',
summary: 'Error',
detail: e?.response?.data?.message || 'No se pudo guardar la ubicación.',
life: 3500,
});
} finally {
submitting.value = false;
}
};
const handleDelete = (locationId: number) => {
if (!canDeleteLocation.value) {
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes eliminar ubicaciones.', life: 4000 });
return;
}
confirm.require({
message: '¿Seguro que deseas eliminar esta ubicación?',
header: 'Confirmar eliminación',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sí, eliminar',
rejectLabel: 'Cancelar',
accept: async () => {
try {
await locationServices.deleteLocation(locationId);
toast.add({ severity: 'success', summary: 'Eliminado', detail: 'Ubicación eliminada correctamente', life: 3000 });
fetchLocations(pagination.value.page);
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo eliminar la ubicación', life: 3000 });
}
},
});
};
const fetchLocations = async (page = 1) => {
if (!canViewLocations.value) {
locations.value = [];
pagination.value = {
...pagination.value,
page: 1,
total: 0,
first: 0,
};
return;
}
pagination.value.page = page;
loading.value = true;
try {
const name = searchName.value || undefined;
const city = searchCity.value || undefined;
const state = searchState.value || undefined;
const country = searchCountry.value || undefined;
const response = await locationServices.getLocations(true, name, city, state, country);
const paginated = response as LocationPaginatedResponse;
locations.value = paginated.data;
pagination.value.total = paginated.total;
pagination.value.page = paginated.current_page;
pagination.value.lastPage = paginated.last_page;
pagination.value.first = (paginated.current_page - 1) * paginated.per_page;
pagination.value.rows = paginated.per_page;
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar las ubicaciones.', life: 3000 });
} finally {
loading.value = false;
}
};
watch(
() => canViewLocations.value,
(allowed) => {
if (allowed) {
fetchLocations();
} else {
locations.value = [];
}
},
{ immediate: true }
);
const clearFilters = () => {
searchName.value = '';
searchCity.value = '';
searchState.value = '';
searchCountry.value = '';
fetchLocations(1);
};
const onFilter = () => {
fetchLocations(1);
};
const onPageChange = (event: any) => {
const newPage = Math.floor(event.first / event.rows) + 1;
fetchLocations(newPage);
};
const hexToBytes = (hex: string): Uint8Array | null => {
const normalized = hex.trim();
if (normalized.length % 2 !== 0) return null;
if (!/^[0-9a-fA-F]+$/.test(normalized)) return null;
const bytes = new Uint8Array(normalized.length / 2);
for (let i = 0; i < normalized.length; i += 2) {
bytes[i / 2] = Number.parseInt(normalized.slice(i, i + 2), 16);
}
return bytes;
};
const parseHexEwkbPoint = (value: string): { lat: number; lng: number } | null => {
const bytes = hexToBytes(value);
if (!bytes || bytes.length < 1 + 4 + 16) return null;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const littleEndian = view.getUint8(0) === 1;
const typeWithFlags = view.getUint32(1, littleEndian);
const hasSrid = (typeWithFlags & 0x20000000) !== 0;
const geometryType = typeWithFlags & 0x0fffffff;
if (geometryType !== 1) return null;
let offset = 1 + 4;
if (hasSrid) {
if (bytes.length < offset + 4 + 16) return null;
offset += 4;
}
if (bytes.length < offset + 16) return null;
const lng = view.getFloat64(offset, littleEndian);
const lat = view.getFloat64(offset + 8, littleEndian);
if (Number.isNaN(lat) || Number.isNaN(lng)) return null;
return { lat, lng };
};
const parseCoordinates = (value?: string | null): { lat: number; lng: number } | null => {
if (!value) return null;
const normalized = value.trim();
const wkt = normalized.replace(/^SRID=\d+;/i, '');
const match = wkt.match(/POINT\s*\(\s*([-\d.]+)\s+([-\d.]+)\s*\)/i);
if (match) {
const lng = Number(match[1]);
const lat = Number(match[2]);
if (Number.isNaN(lat) || Number.isNaN(lng)) return null;
return { lat, lng };
}
return parseHexEwkbPoint(normalized);
};
const formatCoordinates = (value?: string | null): string => {
const point = parseCoordinates(value);
if (!point) return value || '-';
return `Lat ${point.lat.toFixed(6)}, Lng ${point.lng.toFixed(6)}`;
};
</script>
<template>
<div class="space-y-6">
<!-- Form view -->
<template v-if="showFormDialog">
<div class="flex items-center gap-3 mb-6">
<Button icon="pi pi-arrow-left" text rounded @click="closeFormDialog" />
<div>
<h2 class="text-3xl font-black text-surface-900 dark:text-white tracking-tight">
{{ isEditMode ? 'Editar Ubicación' : 'Nueva Ubicación' }}
</h2>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">Administra los datos y la geolocalización de la ubicación.</p>
</div>
</div>
<LocationForm
:initialData="currentLocation || undefined"
:formErrors="formErrors"
:isEditing="isEditMode"
:loading="submitting"
@submit="handleFormSubmit"
@cancel="closeFormDialog"
/>
</template>
<!-- List view -->
<template v-else>
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<div>
<h2 class="text-3xl font-black text-surface-900 dark:text-white tracking-tight">Gestión de Ubicaciones</h2>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">Administra las ubicaciones operativas del sistema.</p>
</div>
<Button
v-if="canCreateLocation"
label="Nueva Ubicación"
icon="pi pi-plus"
@click="handleCreateClick"
/>
</div>
<Card v-if="canViewLocations">
<template #content>
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 items-end">
<div>
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">Nombre</label>
<InputText v-model="searchName" placeholder="Ej. Bodega Norte" class="w-full" />
</div>
<div>
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">Ciudad</label>
<InputText v-model="searchCity" placeholder="Ej. Monterrey" class="w-full" />
</div>
<div>
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">Estado</label>
<InputText v-model="searchState" placeholder="Ej. Nuevo León" class="w-full" />
</div>
<div>
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">País</label>
<InputText v-model="searchCountry" placeholder="Ej. México" class="w-full" />
</div>
<div class="flex gap-2">
<Button icon="pi pi-search" text rounded @click="onFilter" />
<Button icon="pi pi-times" text rounded severity="secondary" label="Limpiar" @click="clearFilters" />
</div>
</div>
</template>
</Card>
<Card v-if="canViewLocations">
<template #content>
<DataTable :value="locations" :loading="loading" stripedRows responsiveLayout="scroll" class="p-datatable-sm">
<Column field="name" header="Nombre" style="min-width: 180px" />
<Column field="address" header="Dirección" style="min-width: 220px" />
<Column field="city" header="Ciudad" style="min-width: 140px" />
<Column field="state" header="Estado" style="min-width: 140px" />
<Column field="country" header="País" style="min-width: 120px" />
<Column field="zip_code" header="CP" style="min-width: 100px" />
<Column header="Coordenadas" style="min-width: 220px">
<template #body="{ data }">
<span class="font-mono text-xs">
{{ formatCoordinates(data.coordinates) }}
</span>
</template>
</Column>
<Column field="geofence" header="Geocerca" style="min-width: 180px">
<template #body="{ data }">
<span class="text-xs">{{ data.geofence ? 'Definida' : 'Sin definir' }}</span>
</template>
</Column>
<Column field="created_at" header="Fecha de Registro" style="min-width: 120px">
<template #body="{ data }">
<span class="text-sm">{{ new Date(data.created_at).toLocaleDateString('es-MX') }}</span>
</template>
</Column>
<Column header="Acciones" headerStyle="text-align: right" bodyStyle="text-align: right" style="min-width: 130px">
<template #body="{ data }">
<div class="flex items-center justify-end gap-2">
<Button
v-if="canUpdateLocation"
icon="pi pi-pencil"
text
rounded
size="small"
@click="handleEditClick(data)"
/>
<Button
v-if="canDeleteLocation"
icon="pi pi-trash"
text
rounded
size="small"
severity="danger"
@click="handleDelete(data.id)"
/>
</div>
</template>
</Column>
</DataTable>
<div class="mt-4">
<Paginator :first="pagination.first" :rows="pagination.rows" :totalRecords="pagination.total" :rowsPerPageOptions="[5, 10, 20, 50]" @page="onPageChange" />
</div>
</template>
</Card>
<Card v-else>
<template #content>
<div class="text-center py-10 text-surface-500 dark:text-surface-400">
<i class="pi pi-lock text-4xl mb-3"></i>
<p>No tienes permisos para visualizar este módulo.</p>
</div>
</template>
</Card>
</template>
</div>
<ConfirmDialog />
<Toast />
</template>