Files
dh-inspeccion-v2/web-v2/src/features/map/AssetGeometryEditor.tsx
T
admin cab9cd7c01
DH V2 CI / API · typecheck, tests, build (push) Successful in 38s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m36s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m45s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m5s
fix: restore map geometries and SMTP configuration
2026-09-15 23:58:49 -03:00

292 lines
12 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import {
Map,
NavigationControl,
type GeoJSONSource,
} from 'maplibre-gl';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAssetGeometry,
removeAssetGeometry,
upsertAssetGeometry,
} from '../../lib/api';
import type {
AssetGeometry,
AssetGeometryType,
GeoJsonGeometry,
Position,
} from '../../lib/api';
import { formatDate } from '../../lib/format';
const osmStyle = {
version: 8 as const,
sources: {
osm: {
type: 'raster' as const,
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
};
function localDateTime(value: string | number | Date): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function geometryVertices(geometry: GeoJsonGeometry | null): Position[] {
if (!geometry) return [];
if (geometry.type === 'Point') return [geometry.coordinates];
if (geometry.type === 'LineString') return geometry.coordinates;
return geometry.coordinates[0]?.slice(0, -1) ?? [];
}
function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null {
if (type === 'POINT') return vertices[0] ? { type: 'Point', coordinates: vertices[0] } : null;
if (type === 'LINESTRING') return vertices.length >= 2 ? { type: 'LineString', coordinates: vertices } : null;
if (vertices.length < 3) return null;
return { type: 'Polygon', coordinates: [[...vertices, vertices[0]!]] };
}
function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) {
const features: unknown[] = [];
if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry });
if (geometry?.type !== 'Point') {
vertices.forEach((coordinates, index) => features.push({
type: 'Feature', properties: { kind: 'vertex', index: index + 1 },
geometry: { type: 'Point', coordinates },
}));
}
return { type: 'FeatureCollection', features };
}
function geometryBounds(geometry: GeoJsonGeometry | null) {
const points = geometryVertices(geometry);
if (!points.length) return null;
return points.reduce<[number, number, number, number]>((result, point) => [
Math.min(result[0], point[0]), Math.min(result[1], point[1]),
Math.max(result[2], point[0]), Math.max(result[3], point[1]),
], [points[0]![0], points[0]![1], points[0]![0], points[0]![1]]);
}
function GeometryDrawingMap({
geometry,
vertices,
editable,
onAddVertex,
}: {
geometry: GeoJsonGeometry | null;
vertices: Position[];
editable: boolean;
onAddVertex: (position: Position) => void;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(null);
const addVertexRef = useRef(onAddVertex);
const dataRef = useRef(drawingCollection(geometry, vertices));
addVertexRef.current = onAddVertex;
dataRef.current = drawingCollection(geometry, vertices);
useEffect(() => {
if (!containerRef.current) return;
const bounds = geometryBounds(geometry);
const map = new Map({
container: containerRef.current,
style: osmStyle,
center: bounds ? [bounds[0], bounds[1]] : [-68.8458, -32.8895],
zoom: bounds ? 12 : 6,
});
mapRef.current = map;
map.addControl(new NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('drawing', { type: 'geojson', data: dataRef.current as never });
map.addLayer({
id: 'drawing-fill', type: 'fill', source: 'drawing',
filter: ['==', ['geometry-type'], 'Polygon'],
paint: { 'fill-color': '#2864dc', 'fill-opacity': 0.2 },
});
map.addLayer({
id: 'drawing-line', type: 'line', source: 'drawing',
filter: ['in', ['geometry-type'], ['literal', ['LineString', 'Polygon']]],
paint: { 'line-color': '#2864dc', 'line-width': 3 },
});
map.addLayer({
id: 'drawing-points', type: 'circle', source: 'drawing',
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': ['case', ['==', ['get', 'kind'], 'vertex'], 5, 8],
'circle-color': ['case', ['==', ['get', 'kind'], 'vertex'], '#f59e0b', '#2864dc'],
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2,
},
});
if (bounds) {
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 14 });
} else {
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 45, maxZoom: 16 });
}
}
});
if (editable) {
map.getCanvas().style.cursor = 'crosshair';
map.on('click', (event) => addVertexRef.current([
Number(event.lngLat.lng.toFixed(7)),
Number(event.lngLat.lat.toFixed(7)),
]));
}
return () => {
mapRef.current = null;
map.remove();
};
}, [editable]);
useEffect(() => {
const source = mapRef.current?.getSource('drawing') as GeoJSONSource | undefined;
source?.setData(drawingCollection(geometry, vertices) as never);
}, [geometry, vertices]);
return <div ref={containerRef} className="geometry-map" />;
}
const geometryNames: Record<AssetGeometryType, string> = {
POINT: 'Punto',
LINESTRING: 'Línea',
POLYGON: 'Polígono',
};
export function AssetGeometryEditor({
assetId,
assetName,
canEdit,
onChanged,
}: {
assetId: string;
assetName: string;
canEdit: boolean;
onChanged?: () => void;
}) {
const [stored, setStored] = useState<AssetGeometry | null>(null);
const [type, setType] = useState<AssetGeometryType>('POINT');
const [vertices, setVertices] = useState<Position[]>([]);
const [accuracyM, setAccuracyM] = useState('');
const [capturedAt, setCapturedAt] = useState('');
const [deviceLabel, setDeviceLabel] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const geometry = useMemo(() => draftGeometry(type, vertices), [type, vertices]);
const applyStored = (value: AssetGeometry | null) => {
setStored(value);
if (value) {
setType(value.geometryType);
setVertices(geometryVertices(value.geometry));
setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM));
setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : '');
setDeviceLabel(value.deviceLabel ?? '');
}
};
useEffect(() => {
getAssetGeometry(assetId)
.then(applyStored)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId]);
const changeType = (next: AssetGeometryType) => {
setType(next); setVertices([]); setSuccess(''); setError('');
};
const addVertex = (position: Position) => {
if (!canEdit) return;
setVertices((current) => type === 'POINT' ? [position] : [...current, position]);
setSuccess('');
};
const useDeviceLocation = () => {
if (!navigator.geolocation) {
setError('Este navegador no permite obtener la ubicación del dispositivo.');
return;
}
setLocating(true); setError('');
navigator.geolocation.getCurrentPosition(
(position) => {
setType('POINT');
setVertices([[
Number(position.coords.longitude.toFixed(7)),
Number(position.coords.latitude.toFixed(7)),
]]);
setAccuracyM(Number(position.coords.accuracy.toFixed(3)).toString());
setCapturedAt(localDateTime(position.timestamp));
setDeviceLabel('Navegador web');
setLocating(false);
},
(locationError) => {
setError(locationError.code === 1
? 'No se otorgó permiso para acceder a la ubicación.'
: 'No fue posible obtener una ubicación precisa.');
setLocating(false);
},
{ enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 },
);
};
const save = async () => {
if (!geometry) return;
setSaving(true); setError(''); setSuccess('');
try {
const updated = await upsertAssetGeometry(assetId, {
geometry,
accuracyM: accuracyM ? Number(accuracyM) : null,
capturedAt: capturedAt ? new Date(capturedAt).toISOString() : null,
deviceLabel: deviceLabel.trim() || null,
});
applyStored(updated);
setSuccess('Ubicación guardada correctamente');
onChanged?.();
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const remove = async () => {
if (!window.confirm(`¿Quitar la geometría actual de ${assetName}? El cambio quedará auditado.`)) return;
setSaving(true); setError(''); setSuccess('');
try {
await removeAssetGeometry(assetId);
setStored(null); setVertices([]); setAccuracyM(''); setCapturedAt(''); setDeviceLabel('');
setSuccess('La geometría fue retirada y el cambio quedó auditado');
onChanged?.();
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>;
const minimum = type === 'POINT' ? 1 : type === 'LINESTRING' ? 2 : 3;
return <article className="panel geometry-editor">
<div className="panel-heading"><div><span className="eyebrow">POSTGIS · WGS 84</span><h2>Ubicación geográfica</h2></div>{stored ? <span className="tag">{geometryNames[stored.geometryType]}</span> : <span className="tag">Sin geometría</span>}</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<p className="section-copy">Elegí el tipo y marcá {type === 'POINT' ? 'la posición' : 'los vértices'} directamente sobre el mapa. Las coordenadas se guardan en EPSG:4326.</p>
{canEdit && <div className="geometry-toolbar"><div className="geometry-type-buttons">{(['POINT', 'LINESTRING', 'POLYGON'] as AssetGeometryType[]).map((item) => <button type="button" key={item} className={`button ${type === item ? 'primary' : 'secondary'}`} onClick={() => changeType(item)}>{geometryNames[item]}</button>)}</div><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={locating}><Icon name="map" />{locating ? 'Ubicando…' : 'Usar mi ubicación'}</button></div>}
<GeometryDrawingMap geometry={geometry} vertices={vertices} editable={canEdit} onAddVertex={addVertex} />
<div className="geometry-progress"><strong>{vertices.length} vértice{vertices.length === 1 ? '' : 's'}</strong><span>{geometry ? 'Geometría lista para guardar' : `Faltan ${Math.max(0, minimum - vertices.length)} vértices`}</span>{canEdit && vertices.length > 0 && <div><button type="button" className="button text" onClick={() => setVertices((current) => current.slice(0, -1))}>Deshacer último</button><button type="button" className="button text" onClick={() => setVertices([])}>Limpiar</button></div>}</div>
<div className="form-grid geometry-metadata"><label className="field"><span>Precisión GPS <em>metros · opcional</em></span><input type="number" min="0" max="100000" step="0.001" value={accuracyM} onChange={(event) => setAccuracyM(event.target.value)} disabled={!canEdit} /></label><label className="field"><span>Fecha y hora de captura <em>opcional</em></span><input type="datetime-local" value={capturedAt} onChange={(event) => setCapturedAt(event.target.value)} disabled={!canEdit} /></label><label className="field"><span>Dispositivo <em>opcional</em></span><input value={deviceLabel} onChange={(event) => setDeviceLabel(event.target.value)} disabled={!canEdit} maxLength={255} placeholder="Tablet, navegador, GPS…" /></label></div>
{stored && <div className="geometry-audit-note"><span>Fuente: {stored.source}</span><span>Actualizada: {formatDate(stored.updatedAt)}</span>{stored.accuracyM != null && <span>Precisión: {stored.accuracyM} m</span>}</div>}
{canEdit && <div className="form-actions">{stored && <button type="button" className="button danger-outline" onClick={remove} disabled={saving}>Quitar geometría</button>}<button type="button" className="button primary" onClick={save} disabled={!geometry || saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar ubicación'}</button></div>}
</article>;
}