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.
This commit is contained in:
parent
643c698de2
commit
7e58077dd0
129
package-lock.json
generated
129
package-lock.json
generated
@ -8,6 +8,7 @@
|
||||
"name": "golscontros-frontend-v1",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
||||
"@primeuix/themes": "^1.2.5",
|
||||
"@primevue/auto-import-resolver": "^4.4.1",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
@ -538,6 +539,24 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/geojson-area": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz",
|
||||
"integrity": "sha512-bBqqFn1kIbLBfn7Yq1PzzwVkPYQr9lVUeT8Dhd0NL5n76PBuXzOcuLV7GOSbEB1ia8qWxH4COCvFpziEu/yReA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"wgs84": "0.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/geojson-normalize": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/geojson-normalize/-/geojson-normalize-0.0.1.tgz",
|
||||
"integrity": "sha512-82V7YHcle8lhgIGqEWwtXYN5cy0QM/OHq3ypGhQTbvHR57DF0vMHMjjVSQKFfVXBe/yWCBZTyOuzvK7DFFnx5Q==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"geojson-normalize": "geojson-normalize"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||
@ -546,6 +565,41 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/mapbox-gl-draw": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-draw/-/mapbox-gl-draw-1.5.1.tgz",
|
||||
"integrity": "sha512-DnR/oarZVoIrVHssAn+mtpuGzYH+ebORoPjow46zTBNPod/HQnvIZGtL6hIb5BVWxxH49RC9D20ipxiO9WDRxA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/geojson-area": "^0.2.2",
|
||||
"@mapbox/geojson-normalize": "^0.0.1",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@turf/projection": "^7.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"nanoid": "^5.0.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/mapbox-gl-draw/node_modules/nanoid": {
|
||||
"version": "5.1.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz",
|
||||
"integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/mapbox-gl-supported": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz",
|
||||
@ -1220,6 +1274,63 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@turf/clone": {
|
||||
"version": "7.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@turf/clone/-/clone-7.3.4.tgz",
|
||||
"integrity": "sha512-pwQ+RyQw986uu7IulY/18NRAebwZZScb084bvVqVkTrllwLSv4oVBqUxmUMiwtp+PNdiRGRFOvNyZqtRsiD+Jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@turf/helpers": "7.3.4",
|
||||
"@types/geojson": "^7946.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/turf"
|
||||
}
|
||||
},
|
||||
"node_modules/@turf/helpers": {
|
||||
"version": "7.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.4.tgz",
|
||||
"integrity": "sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "^7946.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/turf"
|
||||
}
|
||||
},
|
||||
"node_modules/@turf/meta": {
|
||||
"version": "7.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.4.tgz",
|
||||
"integrity": "sha512-tlmw9/Hs1p2n0uoHVm1w3ugw1I6L8jv9YZrcdQa4SH5FX5UY0ATrKeIvfA55FlL//PGuYppJp+eyg/0eb4goqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@turf/helpers": "7.3.4",
|
||||
"@types/geojson": "^7946.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/turf"
|
||||
}
|
||||
},
|
||||
"node_modules/@turf/projection": {
|
||||
"version": "7.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@turf/projection/-/projection-7.3.4.tgz",
|
||||
"integrity": "sha512-p91zOaLmzoBHzU/2H6Ot1tOhTmAom85n1P7I4Oo0V9xU8hmJXWfNnomLFf/6rnkKDIFZkncLQIBz4iIecZ61sA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@turf/clone": "7.3.4",
|
||||
"@turf/helpers": "7.3.4",
|
||||
"@turf/meta": "7.3.4",
|
||||
"@types/geojson": "^7946.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/turf"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@ -1823,6 +1934,12 @@
|
||||
"integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@ -2820,6 +2937,12 @@
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@ -3052,6 +3175,12 @@
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wgs84": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wgs84/-/wgs84-0.0.0.tgz",
|
||||
"integrity": "sha512-ANHlY4Rb5kHw40D0NJ6moaVfOCMrp9Gpd1R/AIQYg2ko4/jzcJ+TVXYYF6kXJqQwITvEZP4yEthjM7U6rYlljQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
||||
"@primeuix/themes": "^1.2.5",
|
||||
"@primevue/auto-import-resolver": "^4.4.1",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import MapboxDraw from '@mapbox/mapbox-gl-draw';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
||||
import type { Feature, GeoJsonProperties, Polygon, Position } from 'geojson';
|
||||
|
||||
import Card from 'primevue/card';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
import Textarea from 'primevue/textarea';
|
||||
|
||||
import type { LocationCreateRequest, LocationFormErrors } from '../../types/locations.interfaces';
|
||||
import type { LocationCreateRequest, LocationFormErrors, LocationGeofenceGeoJSON } from '../../types/locations.interfaces';
|
||||
|
||||
type GeofenceFeature = Feature<Polygon, GeoJsonProperties>;
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
@ -20,6 +26,7 @@ type FormData = {
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
geofence_radius_m: string;
|
||||
geofence: GeofenceFeature | null;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
@ -32,7 +39,7 @@ const props = defineProps<{
|
||||
country?: string | null;
|
||||
zip_code?: string | null;
|
||||
coordinates?: string | null;
|
||||
geofence?: string | null;
|
||||
geofence?: LocationCreateRequest['geofence'] | string | null;
|
||||
};
|
||||
formErrors?: LocationFormErrors;
|
||||
isEditing?: boolean;
|
||||
@ -48,6 +55,7 @@ const mapToken = import.meta.env.VITE_MAPBOX_TOKEN as string | undefined;
|
||||
const mapContainer = ref<HTMLElement | null>(null);
|
||||
const mapRef = ref<any>(null);
|
||||
const markerRef = ref<any>(null);
|
||||
const drawRef = ref<MapboxDraw | null>(null);
|
||||
const mapReady = ref(false);
|
||||
|
||||
const form = ref<FormData>({
|
||||
@ -61,26 +69,181 @@ const form = ref<FormData>({
|
||||
latitude: '18.039300',
|
||||
longitude: '-92.579000',
|
||||
geofence_radius_m: '75',
|
||||
geofence: null,
|
||||
});
|
||||
|
||||
const geocodingSearch = ref('');
|
||||
const geocodingLoading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const geofenceEditedWithDraw = ref(false);
|
||||
const radiusTouched = ref(false);
|
||||
const applyingProgrammaticGeofence = ref(false);
|
||||
|
||||
const canInitializeMap = computed(() => !!mapToken && !!mapContainer.value);
|
||||
|
||||
const toRadians = (value: number) => (value * Math.PI) / 180;
|
||||
const toDegrees = (value: number) => (value * 180) / Math.PI;
|
||||
|
||||
const distanceInMeters = (lat1: number, lng1: number, lat2: number, lng2: number): number => {
|
||||
const earthRadius = 6378137;
|
||||
const dLat = toRadians(lat2 - lat1);
|
||||
const dLng = toRadians(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return earthRadius * c;
|
||||
};
|
||||
|
||||
const hexToBytes = (hex: string): Uint8Array | null => {
|
||||
if (hex.length % 2 !== 0) return null;
|
||||
if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
|
||||
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const parseHexEwkbPoint = (value: string): { lat: number; lng: number } | null => {
|
||||
const bytes = hexToBytes(value.trim());
|
||||
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;
|
||||
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null;
|
||||
return { lat, lng };
|
||||
};
|
||||
|
||||
const parseCoordinates = (value?: string | null): { lat: number; lng: number } | null => {
|
||||
if (!value) return null;
|
||||
const match = value.match(/POINT\s*\(\s*([-\d.]+)\s+([-\d.]+)\s*\)/i);
|
||||
if (!match) return null;
|
||||
const normalized = value.trim();
|
||||
|
||||
const match = normalized.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 cloneGeofenceFeature = (feature: GeofenceFeature): GeofenceFeature => {
|
||||
return JSON.parse(JSON.stringify(feature)) as GeofenceFeature;
|
||||
};
|
||||
|
||||
const normalizeRing = (ring: Position[]): Position[] => {
|
||||
if (ring.length < 3) return ring;
|
||||
|
||||
const first = ring[0];
|
||||
const last = ring[ring.length - 1];
|
||||
if (!first || !last) {
|
||||
return ring;
|
||||
}
|
||||
|
||||
const firstLng = Number(first[0]);
|
||||
const firstLat = Number(first[1]);
|
||||
const lastLng = Number(last[0]);
|
||||
const lastLat = Number(last[1]);
|
||||
if ([firstLng, firstLat, lastLng, lastLat].some((value) => Number.isNaN(value))) {
|
||||
return ring;
|
||||
}
|
||||
|
||||
if (firstLng === lastLng && firstLat === lastLat) {
|
||||
return ring;
|
||||
}
|
||||
|
||||
return [...ring, [firstLng, firstLat]];
|
||||
};
|
||||
|
||||
const coerceGeofenceFeature = (value: unknown): GeofenceFeature | null => {
|
||||
if (!value) return null;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
return coerceGeofenceFeature(JSON.parse(trimmed));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const wkt = trimmed.replace(/^SRID=\d+;/i, '');
|
||||
const polygonMatch = wkt.match(/POLYGON\s*\(\((.+)\)\)/i);
|
||||
if (!polygonMatch) return null;
|
||||
|
||||
const polygonContent = polygonMatch[1];
|
||||
if (!polygonContent) return null;
|
||||
|
||||
const ring = polygonContent
|
||||
.split(',')
|
||||
.map((pair) => pair.trim().split(/\s+/).map(Number))
|
||||
.filter((coords) => coords.length >= 2 && !Number.isNaN(coords[0]) && !Number.isNaN(coords[1]))
|
||||
.map((coords) => [coords[0], coords[1]] as Position);
|
||||
|
||||
if (ring.length < 3) return null;
|
||||
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [normalizeRing(ring)],
|
||||
},
|
||||
properties: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') return null;
|
||||
|
||||
const maybeFeature = value as Partial<GeofenceFeature>;
|
||||
if (maybeFeature.type === 'Feature' && maybeFeature.geometry?.type === 'Polygon' && maybeFeature.geometry.coordinates) {
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [normalizeRing(maybeFeature.geometry.coordinates[0] ?? [])],
|
||||
},
|
||||
properties: maybeFeature.properties ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
const maybePolygon = value as Partial<LocationGeofenceGeoJSON>;
|
||||
if (maybePolygon.type === 'Polygon' && maybePolygon.coordinates) {
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [normalizeRing(maybePolygon.coordinates[0] ?? [])],
|
||||
},
|
||||
properties: {},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateLatLng = (lat: number, lng: number) => {
|
||||
@ -96,12 +259,12 @@ const getCurrentLatLng = (): { lat: number; lng: number } | null => {
|
||||
return { lat, lng };
|
||||
};
|
||||
|
||||
const createCirclePolygonWkt = (lat: number, lng: number, radiusMeters: number, segments = 48): string => {
|
||||
const createCirclePolygonFeature = (lat: number, lng: number, radiusMeters: number, segments = 48): GeofenceFeature => {
|
||||
const earthRadius = 6378137;
|
||||
const angularDistance = radiusMeters / earthRadius;
|
||||
const latRad = toRadians(lat);
|
||||
const lngRad = toRadians(lng);
|
||||
const points: string[] = [];
|
||||
const points: Position[] = [];
|
||||
|
||||
for (let i = 0; i <= segments; i += 1) {
|
||||
const bearing = (2 * Math.PI * i) / segments;
|
||||
@ -116,10 +279,141 @@ const createCirclePolygonWkt = (lat: number, lng: number, radiusMeters: number,
|
||||
Math.cos(angularDistance) - Math.sin(latRad) * Math.sin(destLat)
|
||||
);
|
||||
|
||||
points.push(`${toDegrees(destLng)} ${toDegrees(destLat)}`);
|
||||
points.push([toDegrees(destLng), toDegrees(destLat)]);
|
||||
}
|
||||
|
||||
return `SRID=4326;POLYGON((${points.join(',')}))`;
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [normalizeRing(points)],
|
||||
},
|
||||
properties: {},
|
||||
};
|
||||
};
|
||||
|
||||
const fitMapToGeofence = (feature: GeofenceFeature) => {
|
||||
if (!mapRef.value) return;
|
||||
const ring = feature.geometry.coordinates[0];
|
||||
if (!ring || ring.length === 0) return;
|
||||
|
||||
const bounds = new mapboxgl.LngLatBounds(ring[0] as [number, number], ring[0] as [number, number]);
|
||||
for (const position of ring) {
|
||||
bounds.extend(position as [number, number]);
|
||||
}
|
||||
|
||||
mapRef.value.fitBounds(bounds, {
|
||||
padding: 40,
|
||||
duration: 0,
|
||||
maxZoom: 15,
|
||||
});
|
||||
};
|
||||
|
||||
const updateRadiusFromGeofence = (feature: GeofenceFeature | null) => {
|
||||
if (!feature) return;
|
||||
|
||||
const center = getCurrentLatLng();
|
||||
if (!center) return;
|
||||
|
||||
const ring = feature.geometry.coordinates[0];
|
||||
if (!ring || ring.length === 0 || !ring[0]) return;
|
||||
|
||||
const firstPoint = ring[0];
|
||||
const lng = Number(firstPoint[0]);
|
||||
const lat = Number(firstPoint[1]);
|
||||
if (Number.isNaN(lat) || Number.isNaN(lng)) return;
|
||||
|
||||
const radius = distanceInMeters(center.lat, center.lng, lat, lng);
|
||||
if (!Number.isNaN(radius) && radius >= 0) {
|
||||
form.value.geofence_radius_m = Math.round(radius).toString();
|
||||
}
|
||||
};
|
||||
|
||||
const setDrawGeofence = (feature: GeofenceFeature | null) => {
|
||||
if (!drawRef.value) return;
|
||||
|
||||
applyingProgrammaticGeofence.value = true;
|
||||
try {
|
||||
drawRef.value.deleteAll();
|
||||
if (!feature) {
|
||||
form.value.geofence = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const added = drawRef.value.add(cloneGeofenceFeature(feature));
|
||||
const featureId = Array.isArray(added) ? added[0] : added;
|
||||
const savedFeature = featureId ? (drawRef.value.get(featureId) as GeofenceFeature | undefined) : undefined;
|
||||
form.value.geofence = savedFeature ? cloneGeofenceFeature(savedFeature) : cloneGeofenceFeature(feature);
|
||||
} finally {
|
||||
applyingProgrammaticGeofence.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const previewGeofenceFromRadius = () => {
|
||||
const current = getCurrentLatLng();
|
||||
if (!current) return;
|
||||
|
||||
const parsedRadius = Number(form.value.geofence_radius_m);
|
||||
if (Number.isNaN(parsedRadius) || parsedRadius < 0) return;
|
||||
|
||||
if (parsedRadius === 0) {
|
||||
form.value.geofence = null;
|
||||
if (drawRef.value) {
|
||||
setDrawGeofence(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const generated = createCirclePolygonFeature(current.lat, current.lng, parsedRadius);
|
||||
form.value.geofence = generated;
|
||||
|
||||
if (drawRef.value) {
|
||||
setDrawGeofence(generated);
|
||||
}
|
||||
};
|
||||
|
||||
const syncGeofenceFromDraw = () => {
|
||||
if (!drawRef.value) return;
|
||||
|
||||
const drawData = drawRef.value.getAll();
|
||||
const polygonFeatures = drawData.features.filter((feature: any): feature is GeofenceFeature => {
|
||||
return feature.geometry.type === 'Polygon';
|
||||
});
|
||||
|
||||
if (polygonFeatures.length === 0) {
|
||||
form.value.geofence = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const latest = polygonFeatures[polygonFeatures.length - 1];
|
||||
if (!latest) {
|
||||
form.value.geofence = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (polygonFeatures.length > 1) {
|
||||
setDrawGeofence(latest);
|
||||
} else {
|
||||
form.value.geofence = cloneGeofenceFeature(latest);
|
||||
}
|
||||
|
||||
updateRadiusFromGeofence(form.value.geofence);
|
||||
};
|
||||
|
||||
const applyInitialGeofence = (value: unknown) => {
|
||||
const parsed = coerceGeofenceFeature(value);
|
||||
form.value.geofence = parsed ? cloneGeofenceFeature(parsed) : null;
|
||||
geofenceEditedWithDraw.value = false;
|
||||
radiusTouched.value = false;
|
||||
|
||||
if (drawRef.value) {
|
||||
setDrawGeofence(form.value.geofence);
|
||||
}
|
||||
|
||||
if (form.value.geofence) {
|
||||
fitMapToGeofence(form.value.geofence);
|
||||
updateRadiusFromGeofence(form.value.geofence);
|
||||
}
|
||||
};
|
||||
|
||||
const buildPostgisPayload = (): LocationCreateRequest | null => {
|
||||
@ -129,13 +423,40 @@ const buildPostgisPayload = (): LocationCreateRequest | null => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const radius = Number(form.value.geofence_radius_m);
|
||||
if (Number.isNaN(radius) || radius < 0) {
|
||||
errorMessage.value = '';
|
||||
|
||||
const parsedRadius = Number(form.value.geofence_radius_m);
|
||||
let geofencePolygon: LocationGeofenceGeoJSON | null = null;
|
||||
const hasExistingGeofence = !!form.value.geofence;
|
||||
|
||||
if (geofenceEditedWithDraw.value && form.value.geofence) {
|
||||
geofencePolygon = form.value.geofence.geometry;
|
||||
} else if (hasExistingGeofence && !radiusTouched.value && form.value.geofence) {
|
||||
geofencePolygon = form.value.geofence.geometry;
|
||||
} else {
|
||||
if (Number.isNaN(parsedRadius) || parsedRadius < 0) {
|
||||
errorMessage.value = 'El radio de geocerca debe ser un número válido.';
|
||||
return null;
|
||||
}
|
||||
|
||||
errorMessage.value = '';
|
||||
if (parsedRadius > 0) {
|
||||
const generated = createCirclePolygonFeature(current.lat, current.lng, parsedRadius);
|
||||
geofencePolygon = generated.geometry;
|
||||
form.value.geofence = generated;
|
||||
geofenceEditedWithDraw.value = false;
|
||||
|
||||
if (drawRef.value) {
|
||||
setDrawGeofence(generated);
|
||||
}
|
||||
} else {
|
||||
geofencePolygon = null;
|
||||
form.value.geofence = null;
|
||||
geofenceEditedWithDraw.value = false;
|
||||
if (drawRef.value) {
|
||||
drawRef.value.deleteAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: form.value.name.trim(),
|
||||
@ -146,7 +467,7 @@ const buildPostgisPayload = (): LocationCreateRequest | null => {
|
||||
country: form.value.country.trim() || null,
|
||||
zip_code: form.value.zip_code.trim() || null,
|
||||
coordinates: `SRID=4326;POINT(${current.lng} ${current.lat})`,
|
||||
geofence: radius > 0 ? createCirclePolygonWkt(current.lat, current.lng, radius) : null,
|
||||
geofence: geofencePolygon,
|
||||
};
|
||||
};
|
||||
|
||||
@ -218,10 +539,18 @@ const initializeMap = () => {
|
||||
container: mapContainer.value!,
|
||||
style: 'mapbox://styles/mapbox/streets-v12',
|
||||
center: [current.lng, current.lat],
|
||||
zoom: 12,
|
||||
zoom: 8,
|
||||
});
|
||||
|
||||
mapRef.value.addControl(new mapboxgl.NavigationControl(), 'top-right');
|
||||
drawRef.value = new MapboxDraw({
|
||||
displayControlsDefault: false,
|
||||
controls: {
|
||||
polygon: true,
|
||||
trash: true,
|
||||
},
|
||||
});
|
||||
mapRef.value.addControl(drawRef.value, 'top-left');
|
||||
|
||||
markerRef.value = new mapboxgl.Marker({ draggable: true })
|
||||
.setLngLat([current.lng, current.lat])
|
||||
@ -234,13 +563,42 @@ const initializeMap = () => {
|
||||
});
|
||||
|
||||
mapRef.value.on('click', (event: any) => {
|
||||
markerRef.value!.setLngLat(event.lngLat);
|
||||
const drawMode = drawRef.value?.getMode();
|
||||
if (drawMode === 'draw_polygon' || drawMode === 'direct_select') {
|
||||
return;
|
||||
}
|
||||
|
||||
markerRef.value?.setLngLat(event.lngLat);
|
||||
updateLatLng(event.lngLat.lat, event.lngLat.lng);
|
||||
reverseGeocode(event.lngLat.lat, event.lngLat.lng);
|
||||
});
|
||||
|
||||
mapRef.value.on('draw.create', () => {
|
||||
if (applyingProgrammaticGeofence.value) return;
|
||||
geofenceEditedWithDraw.value = true;
|
||||
radiusTouched.value = false;
|
||||
syncGeofenceFromDraw();
|
||||
});
|
||||
|
||||
mapRef.value.on('draw.update', () => {
|
||||
if (applyingProgrammaticGeofence.value) return;
|
||||
geofenceEditedWithDraw.value = true;
|
||||
radiusTouched.value = false;
|
||||
syncGeofenceFromDraw();
|
||||
});
|
||||
|
||||
mapRef.value.on('draw.delete', () => {
|
||||
if (applyingProgrammaticGeofence.value) return;
|
||||
form.value.geofence = null;
|
||||
geofenceEditedWithDraw.value = false;
|
||||
});
|
||||
|
||||
mapRef.value.on('load', () => {
|
||||
mapReady.value = true;
|
||||
if (form.value.geofence) {
|
||||
setDrawGeofence(form.value.geofence);
|
||||
fitMapToGeofence(form.value.geofence);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@ -255,7 +613,16 @@ const cancelForm = () => emit('cancel');
|
||||
watch(
|
||||
() => props.initialData,
|
||||
(newData: typeof props.initialData) => {
|
||||
if (!newData) return;
|
||||
if (!newData) {
|
||||
form.value.geofence = null;
|
||||
geofenceEditedWithDraw.value = false;
|
||||
radiusTouched.value = false;
|
||||
if (drawRef.value) {
|
||||
drawRef.value.deleteAll();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
form.value.name = newData.name ?? '';
|
||||
form.value.description = newData.description ?? '';
|
||||
form.value.address = newData.address ?? '';
|
||||
@ -263,20 +630,33 @@ watch(
|
||||
form.value.state = newData.state ?? '';
|
||||
form.value.country = newData.country ?? '';
|
||||
form.value.zip_code = newData.zip_code ?? '';
|
||||
radiusTouched.value = false;
|
||||
|
||||
const parsed = parseCoordinates(newData.coordinates ?? null);
|
||||
if (parsed) {
|
||||
updateLatLng(parsed.lat, parsed.lng);
|
||||
syncMarkerWithInputs();
|
||||
}
|
||||
|
||||
applyInitialGeofence(newData.geofence ?? null);
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const onRadiusInput = () => {
|
||||
radiusTouched.value = true;
|
||||
geofenceEditedWithDraw.value = false;
|
||||
previewGeofenceFromRadius();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [form.value.latitude, form.value.longitude],
|
||||
() => {
|
||||
syncMarkerWithInputs();
|
||||
|
||||
if (radiusTouched.value && !geofenceEditedWithDraw.value) {
|
||||
previewGeofenceFromRadius();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -285,6 +665,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
drawRef.value = null;
|
||||
if (mapRef.value) {
|
||||
mapRef.value.remove();
|
||||
mapRef.value = null;
|
||||
@ -293,45 +674,45 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col lg:flex-row gap-5 min-h-[75vh]">
|
||||
<div class="w-full lg:basis-2/5 lg:max-w-[40%]">
|
||||
<div class="flex flex-col lg:flex-row gap-4 h-[calc(100vh-15rem)] overflow-hidden min-h-0">
|
||||
<div class="w-full lg:w-[25%] shrink-0 overflow-y-auto min-h-0">
|
||||
<Card>
|
||||
<template #content>
|
||||
<form class="space-y-4" @submit.prevent="submitForm">
|
||||
<form class="space-y-2" @submit.prevent="submitForm">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase mb-2">Nombre de la Ubicación</label>
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Nombre de la Ubicación</label>
|
||||
<InputText v-model="form.name" class="w-full" placeholder="Ej. Centro Logístico Norte" />
|
||||
<small v-if="props.formErrors?.name" class="text-red-500">{{ props.formErrors.name[0] }}</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase mb-2">Descripción</label>
|
||||
<Textarea v-model="form.description" rows="3" class="w-full" placeholder="Detalles operativos" />
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Descripción</label>
|
||||
<Textarea v-model="form.description" rows="2" class="w-full" placeholder="Detalles operativos" />
|
||||
<small v-if="props.formErrors?.description" class="text-red-500">{{ props.formErrors.description[0] }}</small>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<template #title>Dirección</template>
|
||||
<template #content>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs font-semibold mb-2">Calle y Número</label>
|
||||
<InputText v-model="form.address" class="w-full" />
|
||||
<label class="block text-xs font-semibold mb-1">Calle y Número</label>
|
||||
<Textarea v-model="form.address" rows="2" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold mb-2">Ciudad</label>
|
||||
<label class="block text-xs font-semibold mb-1">Ciudad</label>
|
||||
<InputText v-model="form.city" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold mb-2">Estado</label>
|
||||
<label class="block text-xs font-semibold mb-1">Estado</label>
|
||||
<InputText v-model="form.state" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold mb-2">País</label>
|
||||
<label class="block text-xs font-semibold mb-1">País</label>
|
||||
<InputText v-model="form.country" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold mb-2">C.P.</label>
|
||||
<label class="block text-xs font-semibold mb-1">C.P.</label>
|
||||
<InputText v-model="form.zip_code" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
@ -341,18 +722,18 @@ onBeforeUnmount(() => {
|
||||
<Card>
|
||||
<template #title>Geolocalización</template>
|
||||
<template #content>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold mb-2">Latitud</label>
|
||||
<label class="block text-xs font-semibold mb-1">Latitud</label>
|
||||
<InputText v-model="form.latitude" class="w-full" placeholder="19.432600" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold mb-2">Longitud</label>
|
||||
<label class="block text-xs font-semibold mb-1">Longitud</label>
|
||||
<InputText v-model="form.longitude" class="w-full" placeholder="-99.133200" />
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs font-semibold mb-2">Radio geocerca (metros)</label>
|
||||
<InputText v-model="form.geofence_radius_m" class="w-full" placeholder="75" />
|
||||
<label class="block text-xs font-semibold mb-1">Radio geocerca (metros)</label>
|
||||
<InputText v-model="form.geofence_radius_m" class="w-full" placeholder="75" @input="onRadiusInput" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -371,8 +752,8 @@ onBeforeUnmount(() => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="w-full lg:basis-3/5 lg:max-w-[60%]">
|
||||
<Card class="h-full">
|
||||
<div class="w-full lg:flex-1 min-w-0 min-h-0 flex flex-col">
|
||||
<Card class="h-full flex flex-col">
|
||||
<template #title>Mapa</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-3 h-full">
|
||||
@ -391,7 +772,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="map" ref="mapContainer" class="w-full h-[60vh] rounded-md border border-surface-200" />
|
||||
<div id="map" ref="mapContainer" class="w-full flex-1 min-h-80 lg:min-h-0 rounded-md border border-surface-200" />
|
||||
|
||||
<small v-if="!mapToken" class="text-amber-600">
|
||||
Define VITE_MAPBOX_TOKEN para habilitar el mapa.
|
||||
@ -407,5 +788,47 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
/* Make the map Card grow and its content area fill remaining height */
|
||||
.lg\:flex-1 :deep(.p-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.lg\:flex-1 :deep(.p-card-body),
|
||||
.lg\:flex-1 :deep(.p-card-content) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.lg\:flex-1 :deep(.p-card-content > div) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Align Mapbox Draw controls with PrimeVue visual language */
|
||||
:deep(.mapboxgl-ctrl-group) {
|
||||
border: 1px solid var(--p-content-border-color, #d6d9de);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgb(15 23 42 / 12%);
|
||||
}
|
||||
|
||||
:deep(.mapboxgl-ctrl-group button) {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background-color: var(--p-content-background, #ffffff);
|
||||
}
|
||||
|
||||
:deep(.mapboxgl-ctrl-group button:hover) {
|
||||
background-color: var(--p-surface-100, #f1f5f9);
|
||||
}
|
||||
|
||||
:deep(.mapboxgl-ctrl-group button:focus-visible) {
|
||||
outline: 2px solid var(--p-primary-color, #3b82f6);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@ import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Paginator from 'primevue/paginator';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
@ -211,10 +210,94 @@ 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>
|
||||
@ -267,7 +350,7 @@ const onPageChange = (event: any) => {
|
||||
<Column header="Coordenadas" style="min-width: 220px">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-xs">
|
||||
{{ data.coordinates || '-' }}
|
||||
{{ formatCoordinates(data.coordinates) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
@ -319,24 +402,9 @@ const onPageChange = (event: any) => {
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="showFormDialog"
|
||||
modal
|
||||
maximizable
|
||||
:header="isEditMode ? 'Editar Ubicación' : 'Nueva Ubicación'"
|
||||
:style="{ width: '95vw', maxWidth: '1400px' }"
|
||||
>
|
||||
<LocationForm
|
||||
:initialData="currentLocation || undefined"
|
||||
:formErrors="formErrors"
|
||||
:isEditing="isEditMode"
|
||||
:loading="submitting"
|
||||
@submit="handleFormSubmit"
|
||||
@cancel="closeFormDialog"
|
||||
/>
|
||||
</Dialog>
|
||||
<ConfirmDialog />
|
||||
<Toast />
|
||||
</template>
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import type { Polygon } from 'geojson';
|
||||
|
||||
export type LocationGeofenceGeoJSON = Polygon;
|
||||
|
||||
export interface LocationFormErrors {
|
||||
name?: string[];
|
||||
description?: string[];
|
||||
@ -20,7 +24,7 @@ export interface Location {
|
||||
country: string | null;
|
||||
zip_code: string | null;
|
||||
coordinates: string | null;
|
||||
geofence: string | null;
|
||||
geofence: LocationGeofenceGeoJSON | null;
|
||||
tenant_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@ -62,7 +66,7 @@ export interface LocationCreateRequest {
|
||||
country?: string | null;
|
||||
zip_code?: string | null;
|
||||
coordinates?: string | null;
|
||||
geofence?: string | null;
|
||||
geofence?: LocationGeofenceGeoJSON | null;
|
||||
}
|
||||
|
||||
export type LocationUpdateRequest = Partial<LocationCreateRequest>;
|
||||
|
||||
32
src/types/mapbox-gl-draw.d.ts
vendored
Normal file
32
src/types/mapbox-gl-draw.d.ts
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
declare module '@mapbox/mapbox-gl-draw' {
|
||||
import type { FeatureCollection, Polygon } from 'geojson';
|
||||
|
||||
type DrawMode = 'simple_select' | 'direct_select' | 'draw_polygon' | string;
|
||||
|
||||
interface DrawControls {
|
||||
point?: boolean;
|
||||
line_string?: boolean;
|
||||
polygon?: boolean;
|
||||
trash?: boolean;
|
||||
combine_features?: boolean;
|
||||
uncombine_features?: boolean;
|
||||
}
|
||||
|
||||
interface DrawOptions {
|
||||
displayControlsDefault?: boolean;
|
||||
controls?: DrawControls;
|
||||
defaultMode?: DrawMode;
|
||||
}
|
||||
|
||||
export default class MapboxDraw {
|
||||
constructor(options?: DrawOptions);
|
||||
add(feature: GeoJSON.Feature): string[] | string;
|
||||
get(id: string): GeoJSON.Feature | undefined;
|
||||
getAll(): FeatureCollection;
|
||||
delete(id: string): void;
|
||||
deleteAll(): void;
|
||||
getMode(): DrawMode;
|
||||
}
|
||||
|
||||
export type DrawPolygonFeature = GeoJSON.Feature<Polygon>;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user