- 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.
411 lines
16 KiB
Vue
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>
|