diff --git a/.mimocode/.cron-lock b/.mimocode/.cron-lock index bddd966..4918067 100644 --- a/.mimocode/.cron-lock +++ b/.mimocode/.cron-lock @@ -1 +1 @@ -{"pid":10782,"startedAt":1783578681151} +{"pid":596137,"startedAt":1783946773113} \ No newline at end of file diff --git a/.mimocode/mimocode.json b/.mimocode/mimocode.json new file mode 100644 index 0000000..758dd78 --- /dev/null +++ b/.mimocode/mimocode.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://mimo.xiaomi.com/mimocode/config.json", + "mcp": { + "n8n": { + "type": "remote", + "url": "https://n8n.hacecalor.net/mcp-server/http", + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjNDE1ODI4Yy00ZWU0LTQ1MjEtOGQ3Mi0xODZmYWNjNGU4ZmEiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6ImU1YzUyNjIxLTExZDAtNDM4MC04YmRjLTlhMmQ2MDA1OGU4OSIsImlhdCI6MTc4Mzk0NjY3Mn0._0LG3CJJjUg7-g1kvMjME5nBjIEWkrfEeAZrhDxpfC4" + } + } + } +} diff --git a/apps/backend/server.js b/apps/backend/server.js index fb3ffdc..bfb028a 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -240,6 +240,38 @@ async function initDatabase() { await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`); } + // Create junction table for pharmacy-product relationships (CIMA / Open Food Facts) + if (pgPool) { + await pgPool.query(` + CREATE TABLE IF NOT EXISTS pharmacy_products ( + id SERIAL PRIMARY KEY, + pharmacy_id INTEGER NOT NULL REFERENCES pharmacies(id), + product_source TEXT NOT NULL, + product_off_id TEXT NOT NULL, + product_name TEXT, + price REAL, + stock INTEGER DEFAULT 0, + UNIQUE(pharmacy_id, product_source, product_off_id) + ) + `); + await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_pharmacy_product ON pharmacy_products(product_source, product_off_id)`); + } else { + await dbRun(` + CREATE TABLE IF NOT EXISTS pharmacy_products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pharmacy_id INTEGER NOT NULL, + product_source TEXT NOT NULL, + product_off_id TEXT NOT NULL, + product_name TEXT, + price REAL, + stock INTEGER DEFAULT 0, + FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(id), + UNIQUE(pharmacy_id, product_source, product_off_id) + ) + `); + await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_product ON pharmacy_products(product_source, product_off_id)`); + } + // Create users table — PG when available, SQLite fallback if (pgPool) { await pgPool.query(` @@ -647,6 +679,56 @@ app.get('/api/products/:source/:id', async (req, res) => { } }); +// Get pharmacies that sell a specific product (by source and product ID) +app.get('/api/products/:source/:productId/pharmacies', async (req, res) => { + try { + const { source, productId } = req.params; + + if (source !== 'cima' && source !== 'openfoodfacts') { + return res.status(400).json({ error: 'Invalid source' }); + } + + let pharmacies = await userDbAll(` + SELECT + p.id, + p.name, + p.address, + p.phone, + p.latitude, + p.longitude, + p.opening_hours, + pp.price, + pp.stock + FROM pharmacies p + INNER JOIN pharmacy_products pp ON p.id = pp.pharmacy_id + WHERE pp.product_source = ? AND pp.product_off_id = ? + ORDER BY p.name + `, [source, productId]); + + if (pharmacies.length === 0) { + pharmacies = await userDbAll(` + SELECT + p.id, + p.name, + p.address, + p.phone, + p.latitude, + p.longitude, + p.opening_hours, + NULL as price, + 0 as stock + FROM pharmacies p + ORDER BY p.name + `); + } + + res.json(pharmacies); + } catch (error) { + console.error('Error fetching pharmacies for product:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // ========== AUTHENTICATION MIDDLEWARE ========== // Middleware to check if user is authenticated @@ -1680,10 +1762,9 @@ app.delete('/api/admin/pharmacies/:id', requireAdmin, async (req, res) => { try { const pharmacyId = parseInt(req.params.id); - // Delete related pharmacy_medicines first + // Delete related pharmacy_medicines and pharmacy_products first await userDbRun('DELETE FROM pharmacy_medicines WHERE pharmacy_id = ?', [pharmacyId]); - - // Delete the pharmacy + await userDbRun('DELETE FROM pharmacy_products WHERE pharmacy_id = ?', [pharmacyId]); await userDbRun('DELETE FROM pharmacies WHERE id = ?', [pharmacyId]); res.json({ message: 'Pharmacy deleted successfully' }); @@ -1901,6 +1982,115 @@ app.delete('/api/admin/pharmacy-medicines/:id', requireAdmin, async (req, res) = } }); +// ========== PHARMACY-PRODUCT ADMIN ROUTES ========== + +// List products for a specific pharmacy +app.get('/api/admin/pharmacies/:pharmacyId/products', requireAdmin, async (req, res) => { + try { + const pharmacyId = parseInt(req.params.pharmacyId); + + const products = await userDbAll(` + SELECT + pp.id, + pp.pharmacy_id, + pp.product_source, + pp.product_off_id, + pp.product_name, + pp.price, + pp.stock + FROM pharmacy_products pp + WHERE pp.pharmacy_id = ? + ORDER BY pp.product_name + `, [pharmacyId]); + + res.json(products); + } catch (error) { + console.error('Error fetching pharmacy products:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Add/upsert a product-pharmacy link +app.post('/api/admin/pharmacy-products', requireAdmin, async (req, res) => { + try { + const { pharmacy_id, product_source, product_off_id, product_name, price, stock } = req.body; + + if (!pharmacy_id || !product_source || !product_off_id) { + return res.status(400).json({ error: 'pharmacy_id, product_source, and product_off_id are required' }); + } + + if (product_source !== 'cima' && product_source !== 'openfoodfacts') { + return res.status(400).json({ error: 'product_source must be "cima" or "openfoodfacts"' }); + } + + const existing = await userDbGet( + 'SELECT * FROM pharmacy_products WHERE pharmacy_id = ? AND product_source = ? AND product_off_id = ?', + [pharmacy_id, product_source, product_off_id] + ); + + if (existing) { + await userDbRun( + 'UPDATE pharmacy_products SET product_name = ?, price = ?, stock = ? WHERE pharmacy_id = ? AND product_source = ? AND product_off_id = ?', + [product_name || null, price || null, stock || 0, pharmacy_id, product_source, product_off_id] + ); + } else { + await userDbRun( + 'INSERT INTO pharmacy_products (pharmacy_id, product_source, product_off_id, product_name, price, stock) VALUES (?, ?, ?, ?, ?, ?)', + [pharmacy_id, product_source, product_off_id, product_name || null, price || null, stock || 0] + ); + } + + const relationship = await userDbGet( + 'SELECT * FROM pharmacy_products WHERE pharmacy_id = ? AND product_source = ? AND product_off_id = ?', + [pharmacy_id, product_source, product_off_id] + ); + + res.status(201).json(relationship); + } catch (error) { + console.error('Error adding pharmacy product:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Update price/stock for a pharmacy-product link +app.put('/api/admin/pharmacy-products/:id', requireAdmin, async (req, res) => { + try { + const id = parseInt(req.params.id); + const { price, stock } = req.body; + + await userDbRun( + 'UPDATE pharmacy_products SET price = ?, stock = ? WHERE id = ?', + [price || null, stock || 0, id] + ); + + const updated = await userDbGet( + 'SELECT * FROM pharmacy_products WHERE id = ?', + [id] + ); + + if (!updated) { + return res.status(404).json({ error: 'Relationship not found' }); + } + + res.json(updated); + } catch (error) { + console.error('Error updating pharmacy product:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Delete a pharmacy-product link +app.delete('/api/admin/pharmacy-products/:id', requireAdmin, async (req, res) => { + try { + const id = parseInt(req.params.id); + await userDbRun('DELETE FROM pharmacy_products WHERE id = ?', [id]); + res.json({ message: 'Product removed from pharmacy successfully' }); + } catch (error) { + console.error('Error deleting pharmacy product:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // Push notifications app.get('/api/notifications/vapid-public-key', (req, res) => { diff --git a/apps/frontend/src/components/admin/AdminComponents.css b/apps/frontend/src/components/admin/AdminComponents.css index 4e580a0..48e6a56 100644 --- a/apps/frontend/src/components/admin/AdminComponents.css +++ b/apps/frontend/src/components/admin/AdminComponents.css @@ -397,6 +397,28 @@ border-color: var(--border-strong); } +/* Source badges */ +.source-badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + vertical-align: middle; +} + +.source-badge--cima { + background: rgba(37, 99, 235, 0.12); + color: #2563eb; +} + +.source-badge--off { + background: rgba(16, 185, 129, 0.12); + color: #10b981; +} + /* Pharmacies: search, region import, list filter */ .pharmacy-tools-card { background: var(--surface); diff --git a/apps/frontend/src/components/admin/PharmacyProductLink.jsx b/apps/frontend/src/components/admin/PharmacyProductLink.jsx new file mode 100644 index 0000000..dad7ac0 --- /dev/null +++ b/apps/frontend/src/components/admin/PharmacyProductLink.jsx @@ -0,0 +1,421 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import './AdminComponents.css'; + +const MAX_PHARMACY_RESULTS = 25; + +function normalize(s) { + return (s || '').toString().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, ''); +} + +function PharmacyProductLink() { + const [pharmacies, setPharmacies] = useState([]); + const [productSearch, setProductSearch] = useState(''); + const [productResults, setProductResults] = useState([]); + const [selectedPharmacy, setSelectedPharmacy] = useState(null); + const [selectedProduct, setSelectedProduct] = useState(null); + const [pharmacyProducts, setPharmacyProducts] = useState([]); + const [loading, setLoading] = useState(false); + const [searching, setSearching] = useState(false); + const [pharmacyQuery, setPharmacyQuery] = useState(''); + const [pharmacyDropdownOpen, setPharmacyDropdownOpen] = useState(false); + const pharmacyInputRef = useRef(null); + const [formData, setFormData] = useState({ + pharmacy_id: '', + price: '', + stock: '' + }); + + const filteredPharmacies = useMemo(() => { + const q = normalize(pharmacyQuery).trim(); + if (!q) return pharmacies.slice(0, MAX_PHARMACY_RESULTS); + const tokens = q.split(/\s+/).filter(Boolean); + return pharmacies + .filter(p => { + const hay = `${normalize(p.name)} ${normalize(p.address)}`; + return tokens.every(tok => hay.includes(tok)); + }) + .slice(0, MAX_PHARMACY_RESULTS); + }, [pharmacies, pharmacyQuery]); + + useEffect(() => { + fetchPharmacies(); + }, []); + + useEffect(() => { + if (selectedPharmacy) { + fetchPharmacyProducts(selectedPharmacy.id); + } + }, [selectedPharmacy]); + + // Buscar productos en la API mientras el usuario escribe + useEffect(() => { + const q = productSearch.trim(); + if (q.length < 2) { + setProductResults([]); + setSearching(false); + return; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(async () => { + setSearching(true); + try { + const response = await fetch(`/api/products/search?q=${encodeURIComponent(q)}`, { + credentials: 'include', + signal: controller.signal, + }); + const data = await response.json(); + setProductResults(Array.isArray(data) ? data : []); + } catch (error) { + if (error.name === 'AbortError') return; + console.error('Error searching products:', error); + } finally { + if (!controller.signal.aborted) setSearching(false); + } + }, 500); + + return () => { + clearTimeout(timeoutId); + controller.abort(); + }; + }, [productSearch]); + + const fetchPharmacies = async () => { + try { + const response = await fetch('/api/pharmacies', { + credentials: 'include', + }); + const data = await response.json(); + setPharmacies(data); + } catch (error) { + console.error('Error fetching pharmacies:', error); + } + }; + + const fetchPharmacyProducts = async (pharmacyId) => { + setLoading(true); + try { + const response = await fetch(`/api/admin/pharmacies/${pharmacyId}/products`, { + credentials: 'include', + }); + const data = await response.json(); + setPharmacyProducts(data); + } catch (error) { + console.error('Error fetching pharmacy products:', error); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!selectedProduct) { + alert('Por favor, selecciona un producto primero'); + return; + } + + try { + // Build identifier: source:id (e.g., openfoodfacts:9421025231209) + const identifier = selectedProduct._id + ? `${selectedProduct.source || 'openfoodfacts'}:${selectedProduct._id}` + : `${selectedProduct.source || 'openfoodfacts'}:${selectedProduct.id}`; + + const payload = { + pharmacy_id: parseInt(formData.pharmacy_id), + product_source: selectedProduct.source || 'openfoodfacts', + product_off_id: selectedProduct._id || selectedProduct.id, + product_name: selectedProduct.product_name || selectedProduct.name, + price: formData.price ? parseFloat(formData.price) : null, + stock: formData.stock ? parseInt(formData.stock) : 0 + }; + + const response = await fetch('/api/admin/pharmacy-products', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload) + }); + + if (!response.ok) throw new Error('Error al vincular producto a farmacia'); + + resetForm(); + if (selectedPharmacy) { + fetchPharmacyProducts(selectedPharmacy.id); + } + alert('¡Producto vinculado a la farmacia correctamente!'); + } catch (error) { + console.error('Error linking product:', error); + alert('Error al vincular producto a farmacia'); + } + }; + + const handleUpdate = async (id, price, stock) => { + try { + const response = await fetch(`/api/admin/pharmacy-products/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ price, stock }) + }); + + if (!response.ok) throw new Error('Error al actualizar'); + + fetchPharmacyProducts(selectedPharmacy.id); + alert('¡Actualizado correctamente!'); + } catch (error) { + console.error('Error updating:', error); + alert('Error al actualizar'); + } + }; + + const handleDelete = async (id) => { + if (!confirm('¿Eliminar este producto de la farmacia?')) return; + + try { + const response = await fetch(`/api/admin/pharmacy-products/${id}`, { + method: 'DELETE', + credentials: 'include' + }); + + if (!response.ok) throw new Error('Error al eliminar'); + + fetchPharmacyProducts(selectedPharmacy.id); + alert('¡Producto eliminado de la farmacia!'); + } catch (error) { + console.error('Error deleting:', error); + alert('Error al eliminar producto'); + } + }; + + const resetForm = () => { + setFormData({ + pharmacy_id: selectedPharmacy ? selectedPharmacy.id.toString() : '', + price: '', + stock: '' + }); + setSelectedProduct(null); + setProductSearch(''); + setProductResults([]); + }; + + const selectProduct = (product) => { + setSelectedProduct(product); + setProductSearch(product.product_name || product.name); + setProductResults([]); + }; + + const pickPharmacy = (pharmacy) => { + setSelectedPharmacy(pharmacy); + setFormData(prev => ({ ...prev, pharmacy_id: pharmacy.id.toString() })); + setPharmacyQuery(`${pharmacy.name} — ${pharmacy.address}`); + setPharmacyDropdownOpen(false); + }; + + const clearPharmacy = () => { + setSelectedPharmacy(null); + setFormData(prev => ({ ...prev, pharmacy_id: '' })); + setPharmacyQuery(''); + setPharmacyDropdownOpen(true); + setTimeout(() => pharmacyInputRef.current?.focus(), 0); + }; + + const getSourceBadge = (source) => { + if (source === 'cima') { + return CIMA; + } + return OFF; + }; + + return ( +
Aún no hay productos vinculados a esta farmacia.
+ ) : ( ++ Precio: {pp.price ? `€${parseFloat(pp.price).toFixed(2)}` : 'No definido'} • + Stock: {pp.stock || 0} +
+{pharmacy.address}
+ {pharmacy.phone && ( +{pharmacy.phone}
+ )} +