import React, { useState, useEffect, useMemo } from 'react'; import './AdminComponents.css'; import { DAY_KEYS, DAY_LABEL } from '../../utils/hours'; function emptyHoursDraft() { const draft = {}; for (const day of DAY_KEYS) { draft[day] = { open: '09:00', close: '21:00', closed: true }; } return draft; } function hoursToDraft(raw) { let parsed = null; if (raw) { try { parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { parsed = null; } } const draft = {}; for (const day of DAY_KEYS) { const v = parsed && parsed[day]; if (Array.isArray(v) && v.length === 2) { draft[day] = { open: v[0], close: v[1], closed: false }; } else { draft[day] = { open: '09:00', close: '21:00', closed: true }; } } return draft; } function draftToHours(draft) { const out = {}; let hasAny = false; for (const day of DAY_KEYS) { const d = draft[day]; if (d && !d.closed && d.open && d.close) { out[day] = [d.open, d.close]; hasAny = true; } else { out[day] = null; } } return hasAny ? out : null; } /** Distance in metres between two WGS84 points */ function haversineMeters(lat1, lon1, lat2, lon2) { const R = 6371000; const toRad = (d) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(Math.min(1, a))); } const REGION_PRESETS = [ { id: 'custom', label: 'Coordenadas personalizadas', lat: '', lon: '', radio: '' }, { id: 'rubi', label: 'Ejemplo: Área de Rubí (1.5 km)', lat: '41.5631', lon: '2.0038', radio: '1500', }, ]; async function geocodeErrorMessage(response) { const text = await response.text(); let body = {}; try { body = text ? JSON.parse(text) : {}; } catch { /* non-JSON */ } if (typeof body.error === 'string' && body.error.trim()) return body.error; if (response.status === 401) { return 'Sesión expirada o no has iniciado sesión. Inicia sesión de nuevo en el panel Admin y reintenta.'; } if (response.status === 404) { const looksLikeHtml = /]/i.test(text || ''); if (looksLikeHtml) { return 'La app no pudo conectar con la API (404). Usa http://localhost:3000 con frontend y backend activos.'; } return 'Servicio de geocodificación no encontrado. Actualiza el backend y reinícialo.'; } return `Búsqueda fallida (HTTP ${response.status}).`; } function PharmacyManagement() { const [pharmacies, setPharmacies] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [showForm, setShowForm] = useState(false); const [editingPharmacy, setEditingPharmacy] = useState(null); const [formData, setFormData] = useState({ name: '', address: '', phone: '', latitude: '', longitude: '', }); const [hoursDraft, setHoursDraft] = useState(() => emptyHoursDraft()); const [cityQuery, setCityQuery] = useState(''); const [cityLookupLoading, setCityLookupLoading] = useState(false); const [cityLookupMessage, setCityLookupMessage] = useState(null); const [regionLat, setRegionLat] = useState('41.5631'); const [regionLon, setRegionLon] = useState('2.0038'); const [regionRadio, setRegionRadio] = useState('1500'); const [regionPreset, setRegionPreset] = useState('rubi'); const [filterByRegion, setFilterByRegion] = useState(false); const [importing, setImporting] = useState(false); const [importFeedback, setImportFeedback] = useState(null); /** @type {'webhook' | 'osm' | 'openData'} */ const [importMode, setImportMode] = useState('osm'); const [openDataUrl, setOpenDataUrl] = useState(''); useEffect(() => { fetchPharmacies(); }, []); const fetchPharmacies = async () => { setLoading(true); try { const response = await fetch('/api/pharmacies', { credentials: 'include', }); const data = await response.json(); setPharmacies(data); } catch (error) { console.error('Error fetching pharmacies:', error); alert('Error al cargar farmacias'); } finally { setLoading(false); } }; const applyPreset = (id) => { setRegionPreset(id); const p = REGION_PRESETS.find((x) => x.id === id); if (!p || id === 'custom') return; setRegionLat(p.lat); setRegionLon(p.lon); setRegionRadio(p.radio); }; const displayedPharmacies = useMemo(() => { if (!filterByRegion) return pharmacies; const lat = parseFloat(regionLat); const lon = parseFloat(regionLon); const r = parseFloat(regionRadio); if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(r)) { return pharmacies; } return pharmacies.filter((p) => { if (p.latitude == null || p.longitude == null) return false; return haversineMeters(lat, lon, p.latitude, p.longitude) <= r; }); }, [pharmacies, filterByRegion, regionLat, regionLon, regionRadio]); const handleCityLookup = async (e) => { e?.preventDefault(); const q = cityQuery.trim(); if (!q) { setCityLookupMessage({ type: 'err', text: 'Introduce una ciudad o lugar.' }); return; } setCityLookupLoading(true); setCityLookupMessage(null); try { const response = await fetch(`/api/admin/geocode?q=${encodeURIComponent(q)}`, { credentials: 'include', }); if (!response.ok) { throw new Error(await geocodeErrorMessage(response)); } const data = await response.json(); setRegionLat(String(data.lat)); setRegionLon(String(data.lon)); setRegionRadio(String(data.radius)); setRegionPreset('custom'); setCityLookupMessage({ type: 'ok', text: `Using: ${data.displayName} — radius ~${data.radius} m (you can edit below).`, }); } catch (err) { setCityLookupMessage({ type: 'err', text: err.message }); } finally { setCityLookupLoading(false); } }; const handlePharmacyImport = async () => { setImporting(true); setImportFeedback(null); try { if (importMode === 'webhook') { const body = {}; const lat = parseFloat(regionLat); const lon = parseFloat(regionLon); const radio = parseFloat(regionRadio); if (Number.isFinite(lat) && Number.isFinite(lon) && Number.isFinite(radio)) { body.lat = lat; body.lon = lon; body.radio = radio; } const response = await fetch('/api/admin/pharmacies/import-webhook', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body), }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || `Import failed (${response.status})`); } setImportFeedback({ type: 'success', text: `[n8n] Imported ${data.inserted} new. Skipped ${data.skipped} duplicate(s). ${data.invalid || 0} invalid. ${data.totalReceived} from webhook.`, }); await fetchPharmacies(); return; } if (importMode === 'openData') { const url = openDataUrl.trim(); if (!url) { throw new Error('Paste an open-data JSON URL (array or GeoJSON).'); } const response = await fetch('/api/admin/pharmacies/import-external', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ source: 'openData', openDataUrl: url }), }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || `Import failed (${response.status})`); } const label = data.message || `openData: ${data.totalReceived} rows`; setImportFeedback({ type: 'success', text: `${label}. Inserted ${data.inserted}, skipped ${data.skipped}, invalid ${data.invalid || 0}.`, }); await fetchPharmacies(); return; } const lat = parseFloat(regionLat); const lon = parseFloat(regionLon); const radio = parseFloat(regionRadio); if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(radio)) { throw new Error('Establece latitud, longitud y radio (usa Buscar ciudad o un preset).'); } const response = await fetch('/api/admin/pharmacies/import-external', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ source: importMode, lat, lon, radio, }), }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || `Import failed (${response.status})`); } const src = data.source === 'osm' ? 'OSM' : 'OpenStreetMap'; setImportFeedback({ type: 'success', text: `[${src}] ${data.message || `Received ${data.totalReceived} pharmacies.`} Inserted ${data.inserted}, skipped ${data.skipped}, invalid ${data.invalid || 0}.`, }); await fetchPharmacies(); } catch (error) { setImportFeedback({ type: 'error', text: error.message }); } finally { setImporting(false); } }; const handleSubmit = async (e) => { e.preventDefault(); if (saving) return; setSaving(true); try { const payload = { ...formData, latitude: formData.latitude ? parseFloat(formData.latitude) : null, longitude: formData.longitude ? parseFloat(formData.longitude) : null, opening_hours: draftToHours(hoursDraft), }; if (editingPharmacy) { const response = await fetch(`/api/admin/pharmacies/${editingPharmacy.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(payload), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Error al actualizar farmacia'); } } else { const response = await fetch('/api/admin/pharmacies', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(payload), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Error al crear farmacia'); } } resetForm(); fetchPharmacies(); alert(editingPharmacy ? '¡Farmacia actualizada!' : '¡Farmacia añadida!'); } catch (error) { console.error('Error saving pharmacy:', error); alert(`Error al guardar farmacia: ${error.message}`); } finally { setSaving(false); } }; const handleEdit = (pharmacy) => { setEditingPharmacy(pharmacy); setFormData({ name: pharmacy.name || '', address: pharmacy.address || '', phone: pharmacy.phone || '', latitude: pharmacy.latitude ?? '', longitude: pharmacy.longitude ?? '', }); setHoursDraft(hoursToDraft(pharmacy.opening_hours)); setShowForm(true); }; const handleDelete = async (id) => { if (!confirm('¿Estás seguro de que quieres eliminar esta farmacia?')) return; try { const response = await fetch(`/api/admin/pharmacies/${id}`, { method: 'DELETE', credentials: 'include', }); if (!response.ok) throw new Error('Error al eliminar farmacia'); fetchPharmacies(); alert('¡Farmacia eliminada!'); } catch (error) { console.error('Error deleting pharmacy:', error); alert('Error al eliminar farmacia'); } }; const resetForm = () => { setFormData({ name: '', address: '', phone: '', latitude: '', longitude: '', }); setHoursDraft(emptyHoursDraft()); setEditingPharmacy(null); setShowForm(false); }; const updateDay = (day, patch) => { setHoursDraft((prev) => ({ ...prev, [day]: { ...prev[day], ...patch } })); }; const onRegionFieldChange = (setter) => (e) => { setRegionPreset('custom'); setter(e.target.value); }; return (
Buscar ciudad establece latitud, longitud y radio para el filtro de mapa y las importaciones. Elige una fuente de datos: OpenStreetMap es gratuita (sin clave);{' '} URL de datos abiertos carga JSON alojado por ti. Geocodificación usa{' '} OpenStreetMap {' '} (Nominatim).
{cityLookupMessage && ({cityLookupMessage.text}
)}Mostrando {displayedPharmacies.length} de {pharmacies.length} farmacias {filterByRegion && ' (dentro del radio)'}
{displayedPharmacies.length === 0 ? ({pharmacies.length === 0 ? 'Aún no hay farmacias. Importa desde webhook o añade una manualmente.' : 'No hay farmacias en este radio con coordenadas. Amplía el radio, busca otra ciudad o desactiva el filtro de región.'}
) : ( displayedPharmacies.map((pharmacy) => (📍 {pharmacy.address}
{pharmacy.phone &&📞 {pharmacy.phone}
} {(pharmacy.latitude != null || pharmacy.longitude != null) && (🌐 {pharmacy.latitude}, {pharmacy.longitude}
)}