Files
FarmaFinder/frontend/src/components/admin/PharmacyManagement.jsx
T
2026-07-01 15:54:10 +02:00

716 lines
23 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = /<!DOCTYPE|<html[\s>]/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 (
<div className="admin-section">
<div className="section-header">
<h2>Gestionar Farmacias</h2>
<button
type="button"
className="btn-primary"
onClick={() => {
resetForm();
setShowForm(true);
}}
>
+ Añadir Nueva Farmacia
</button>
</div>
<div className="pharmacy-tools-card">
<h3>Ciudad, región e importación</h3>
<p className="pharmacy-tools-hint">
<strong>Buscar ciudad</strong> establece latitud, longitud y radio para el filtro de mapa y las importaciones.
Elige una <strong>fuente de datos</strong>: <strong>OpenStreetMap</strong> es gratuita (sin clave);{' '}
<strong>URL de datos abiertos</strong> carga JSON alojado por ti. Geocodificación usa{' '}
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">
OpenStreetMap
</a>{' '}
(Nominatim).
</p>
<form className="city-lookup-form" onSubmit={handleCityLookup}>
<div className="form-group city-lookup-input-wrap">
<label htmlFor="city-finder">Buscar ciudad</label>
<input
id="city-finder"
type="search"
placeholder="Ej: Rubí, Madrid, Valencia…"
value={cityQuery}
onChange={(e) => {
setCityQuery(e.target.value);
setCityLookupMessage(null);
}}
autoComplete="address-level2"
/>
</div>
<button
type="submit"
className="btn-secondary city-lookup-submit"
disabled={cityLookupLoading}
>
{cityLookupLoading ? 'Buscando…' : 'Buscar ciudad'}
</button>
</form>
{cityLookupMessage && (
<p
className={`city-lookup-feedback ${cityLookupMessage.type === 'ok' ? 'ok' : 'err'}`}
role="status"
>
{cityLookupMessage.text}
</p>
)}
<div className="region-presets">
<label htmlFor="region-preset">Preset de área</label>
<select
id="region-preset"
value={regionPreset}
onChange={(e) => applyPreset(e.target.value)}
>
{REGION_PRESETS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</div>
<div className="region-grid">
<div className="form-group">
<label htmlFor="region-lat">Latitud</label>
<input
id="region-lat"
type="text"
inputMode="decimal"
value={regionLat}
onChange={onRegionFieldChange(setRegionLat)}
placeholder="41.5631"
/>
</div>
<div className="form-group">
<label htmlFor="region-lon">Longitud</label>
<input
id="region-lon"
type="text"
inputMode="decimal"
value={regionLon}
onChange={onRegionFieldChange(setRegionLon)}
placeholder="2.0038"
/>
</div>
<div className="form-group">
<label htmlFor="region-radio">Radio (m)</label>
<input
id="region-radio"
type="text"
inputMode="numeric"
value={regionRadio}
onChange={onRegionFieldChange(setRegionRadio)}
placeholder="1500"
/>
</div>
</div>
<div className="import-mode-row">
<div className="form-group import-mode-select-wrap">
<label htmlFor="import-mode">Fuente de datos</label>
<select
id="import-mode"
value={importMode}
onChange={(e) => {
setImportMode(e.target.value);
setImportFeedback(null);
}}
>
<option value="osm">OpenStreetMap (Overpass, gratuito)</option>
<option value="webhook">n8n webhook (heredado)</option>
<option value="openData">URL de datos abiertos JSON</option>
</select>
</div>
</div>
{importMode === 'openData' && (
<div className="form-group open-data-url-row">
<label htmlFor="open-data-url">URL JSON</label>
<input
id="open-data-url"
type="url"
placeholder="https://…/pharmacies.json"
value={openDataUrl}
onChange={(e) => setOpenDataUrl(e.target.value)}
autoComplete="off"
/>
</div>
)}
<div className="tool-actions-row">
<button
type="button"
className="btn-primary btn-import-webhook"
onClick={handlePharmacyImport}
disabled={importing}
>
{importing
? 'Importando…'
: importMode === 'webhook'
? 'Importar desde webhook'
: importMode === 'openData'
? 'Importar desde URL'
: `Importar desde ${importMode === 'osm' ? 'Overpass' : 'OpenStreetMap'}`}
</button>
<label className="filter-region-toggle">
<input
type="checkbox"
checked={filterByRegion}
onChange={(e) => setFilterByRegion(e.target.checked)}
/>
Mostrar solo farmacias dentro del radio
</label>
</div>
{importFeedback && (
<div
className={`import-feedback ${importFeedback.type === 'success' ? 'success' : 'error'}`}
role="status"
>
{importFeedback.text}
</div>
)}
</div>
{showForm && (
<form className="admin-form" onSubmit={handleSubmit}>
<h3>{editingPharmacy ? 'Editar Farmacia' : 'Añadir Nueva Farmacia'}</h3>
<div className="form-group">
<label>Nombre *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="form-group">
<label>Dirección *</label>
<input
type="text"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
required
/>
</div>
<div className="form-group">
<label>Teléfono</label>
<input
type="text"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>Latitud</label>
<input
type="number"
step="any"
value={formData.latitude}
onChange={(e) => setFormData({ ...formData, latitude: e.target.value })}
/>
</div>
<div className="form-group">
<label>Longitud</label>
<input
type="number"
step="any"
value={formData.longitude}
onChange={(e) => setFormData({ ...formData, longitude: e.target.value })}
/>
</div>
</div>
<fieldset className="hours-editor">
<legend>Horario de apertura</legend>
<p className="hours-editor-hint">Marca un día como <em>Cerrado</em> si la farmacia no abre ese día.</p>
{DAY_KEYS.map((day) => {
const d = hoursDraft[day];
return (
<div key={day} className={`hours-row ${d.closed ? 'is-closed' : ''}`}>
<span className="hours-day">{DAY_LABEL[day]}</span>
<label className="hours-closed-toggle">
<input
type="checkbox"
checked={d.closed}
onChange={(e) => updateDay(day, { closed: e.target.checked })}
/>
Cerrado
</label>
<input
type="time"
value={d.open}
disabled={d.closed}
onChange={(e) => updateDay(day, { open: e.target.value })}
aria-label={`${DAY_LABEL[day]} abre a las`}
/>
<span className="hours-sep"></span>
<input
type="time"
value={d.close}
disabled={d.closed}
onChange={(e) => updateDay(day, { close: e.target.value })}
aria-label={`${DAY_LABEL[day]} cierra a las`}
/>
</div>
);
})}
</fieldset>
<div className="form-actions">
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? 'Guardando...' : editingPharmacy ? 'Actualizar' : 'Añadir'} Farmacia
</button>
<button type="button" className="btn-secondary" onClick={resetForm} disabled={saving}>
Cancelar
</button>
</div>
</form>
)}
{loading ? (
<div className="loading">Cargando farmacias...</div>
) : (
<div className="admin-list">
<p className="list-meta">
Mostrando {displayedPharmacies.length} de {pharmacies.length} farmacias
{filterByRegion && ' (dentro del radio)'}
</p>
{displayedPharmacies.length === 0 ? (
<p className="empty-state">
{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.'}
</p>
) : (
displayedPharmacies.map((pharmacy) => (
<div key={pharmacy.id} className="admin-item">
<div className="item-content">
<h4>{pharmacy.name}</h4>
<p>📍 {pharmacy.address}</p>
{pharmacy.phone && <p>📞 {pharmacy.phone}</p>}
{(pharmacy.latitude != null || pharmacy.longitude != null) && (
<p>
🌐 {pharmacy.latitude}, {pharmacy.longitude}
</p>
)}
</div>
<div className="item-actions">
<button type="button" className="btn-edit" onClick={() => handleEdit(pharmacy)}>
Editar
</button>
<button type="button" className="btn-delete" onClick={() => handleDelete(pharmacy.id)}>
Eliminar
</button>
</div>
</div>
))
)}
</div>
)}
</div>
);
}
export default PharmacyManagement;