Files
FarmaFinder/apps/frontend/src/components/admin/PharmacyProductLink.jsx
T
Antoni Nuñez Romeu 38374341ed
Run Tests on Branches / Detect Changes (push) Successful in 11s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Successful in 1m44s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
feat(i18n): add bilingual support (Català / Castellano)
Add lightweight i18n system using React Context + useTranslation hook.
No external dependencies. Language persisted in localStorage('ff-lang').

New files:
- src/i18n/locales/ca.js (~410 translation keys)
- src/i18n/locales/es.js (~410 translation keys)
- src/i18n/LanguageContext.jsx (Provider + t() function)
- src/i18n/useTranslation.js (hook)
- src/i18n/index.js (barrel export)

Modified 22 files:
- main.jsx: wrap App with LanguageProvider
- App.test.jsx: add LanguageProvider wrapper for tests
- 13 user components: BottomNav, HomeView, LoginModal, SearchView,
  ProfileView, AlertsView, ScannerView, PharmacyList, MedicineResults,
  ProductResults, SavedNotifications, ErrorBoundary, SearchBar
- 6 admin components: AdminView, LoginForm, PharmacyManagement,
  MedicineManagement, PharmacyMedicineLink, PharmacyProductLink

ProfileView includes language selector (globe icon + modal CA/ES)
placed next to the Theme selector.

No routes changed. No API calls affected. No navigation changes.
2026-07-17 09:43:45 +02:00

464 lines
16 KiB
React

import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from '../../i18n';
import './AdminComponents.css';
const MAX_PHARMACY_RESULTS = 25;
function normalize(s) {
return (s || '').toString().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
}
function PharmacyProductLink() {
const { t } = useTranslation();
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 ambas APIs 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 {
// Search both CIMA and Parapharmacy
const [cimaRes, paraRes] = await Promise.allSettled([
fetch(`/api/products/search?q=${encodeURIComponent(q)}`, {
credentials: 'include',
signal: controller.signal,
}),
fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(q)}&limit=10`, {
credentials: 'include',
signal: controller.signal,
})
]);
const cimaData = cimaRes.status === 'fulfilled' ? await cimaRes.value.json() : { results: [] };
const paraData = paraRes.status === 'fulfilled' ? await paraRes.value.json() : { results: [] };
// Handle both array and object responses
const cimaResults = Array.isArray(cimaData) ? cimaData : (cimaData.results || []);
const paraResults = Array.isArray(paraData) ? paraData : (paraData.results || []);
const allResults = [
...cimaResults,
...paraResults.map(p => ({ ...p, source: p.source || 'parapharmacy' }))
];
setProductResults(allResults);
} 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(t('admin.linkProduct.selectProductFirst'));
return;
}
try {
// Build identifier: source:id
const productId = selectedProduct._id || selectedProduct.id;
const source = selectedProduct.source || 'parapharmacy';
const identifier = `${source}:${productId}`;
const payload = {
pharmacy_id: parseInt(formData.pharmacy_id),
product_source: source,
product_off_id: productId,
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(t('admin.linkProduct.linkError'));
resetForm();
if (selectedPharmacy) {
fetchPharmacyProducts(selectedPharmacy.id);
}
alert(t('admin.linkProduct.linkSuccess'));
} catch (error) {
console.error('Error linking product:', error);
alert(t('admin.linkProduct.linkError'));
}
};
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(t('admin.linkProduct.updateError'));
fetchPharmacyProducts(selectedPharmacy.id);
alert(t('admin.linkProduct.updateSuccess'));
} catch (error) {
console.error('Error updating:', error);
alert(t('admin.linkProduct.updateError'));
}
};
const handleDelete = async (id) => {
if (!confirm(t('admin.linkProduct.deleteConfirm'))) return;
try {
const response = await fetch(`/api/admin/pharmacy-products/${id}`, {
method: 'DELETE',
credentials: 'include'
});
if (!response.ok) throw new Error(t('admin.linkProduct.deleteError'));
fetchPharmacyProducts(selectedPharmacy.id);
alert(t('admin.linkProduct.deleteSuccess'));
} catch (error) {
console.error('Error deleting:', error);
alert(t('admin.linkProduct.deleteError'));
}
};
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) => {
const colors = {
cima: '#2563eb',
promofarma: '#16a34a',
docmorris: '#e11d48',
primor: '#7c3aed',
parapharmacy: '#16a34a'
};
const labels = {
cima: 'CIMA',
promofarma: 'Promofarma',
docmorris: 'DocMorris',
primor: 'Primor',
parapharmacy: 'Parafarmacia'
};
return (
<span
className="source-badge"
style={{ backgroundColor: colors[source] || '#6b7280' }}
>
{labels[source] || source}
</span>
);
};
return (
<div className="admin-section">
<h2>{t('admin.linkProduct.title')}</h2>
<form className="admin-form" onSubmit={handleSubmit}>
<div className="form-group">
<label>{t('admin.linkProduct.pharmacy')}</label>
<input
ref={pharmacyInputRef}
type="text"
value={pharmacyQuery}
onChange={(e) => {
setPharmacyQuery(e.target.value);
if (selectedPharmacy) {
setSelectedPharmacy(null);
setFormData(prev => ({ ...prev, pharmacy_id: '' }));
}
setPharmacyDropdownOpen(true);
}}
onFocus={() => setPharmacyDropdownOpen(true)}
onBlur={() => setTimeout(() => setPharmacyDropdownOpen(false), 150)}
placeholder={pharmacies.length ? `${t('admin.linkProduct.searchPharmacy')} ${pharmacies.length} ${t('admin.linkProduct.pharmacies')}` : t('admin.linkProduct.loadingPharmacies')}
autoComplete="off"
required={!selectedPharmacy}
/>
{!selectedPharmacy && pharmacyDropdownOpen && (
<div className="medicine-search-results">
{filteredPharmacies.length === 0 ? (
<div className="search-result-item search-result-item--empty">
<span>{t('admin.linkProduct.noPharmacies')} "{pharmacyQuery}"</span>
</div>
) : (
filteredPharmacies.map((pharmacy) => (
<div
key={pharmacy.id}
className="search-result-item"
onMouseDown={(e) => { e.preventDefault(); pickPharmacy(pharmacy); }}
>
<strong>{pharmacy.name}</strong>
<span>{pharmacy.address}</span>
</div>
))
)}
</div>
)}
{selectedPharmacy && (
<div className="selected-medicine-info">
<p>✅ Selected: <strong>{selectedPharmacy.name}</strong></p>
<p className="medicine-details">{selectedPharmacy.address}</p>
<button type="button" className="btn-small" onClick={clearPharmacy}>
{t('admin.linkProduct.changePharmacy')}
</button>
</div>
)}
</div>
<div className="form-group">
<label>{t('admin.linkProduct.product')}</label>
<input
type="text"
value={productSearch}
onChange={(e) => {
setProductSearch(e.target.value);
setSelectedProduct(null);
}}
placeholder={t('admin.linkProduct.searchProduct')}
required
/>
{searching && <p className="loading-text">{t('admin.linkProduct.searching')}</p>}
{productResults.length > 0 && !selectedProduct && (
<div className="medicine-search-results">
{productResults.slice(0, 10).map((product) => (
<div
key={product._id || product.id || product.nregistro}
className="search-result-item"
onClick={() => selectProduct(product)}
>
<strong>{product.product_name || product.name}</strong>
{product.brand && <span> - {product.brand}</span>}
{product.brands && <span> - {product.brands}</span>}
{product.price != null && <span> - {product.price}€</span>}
{getSourceBadge(product.source || 'parapharmacy')}
</div>
))}
</div>
)}
{selectedProduct && (
<div className="selected-medicine-info">
<p>✅ Selected: <strong>{selectedProduct.product_name || selectedProduct.name}</strong></p>
<p className="medicine-details">
{selectedProduct.brand && `${t('admin.linkProduct.brand')} ${selectedProduct.brand} • `}
{selectedProduct.brands && `${t('admin.linkProduct.brand')} ${selectedProduct.brands} • `}
{selectedProduct.price != null && `${t('admin.linkProduct.price')} ${selectedProduct.price}€ • `}
{getSourceBadge(selectedProduct.source || 'parapharmacy')}
{' '}
{selectedProduct._id || selectedProduct.id || selectedProduct.nregistro}
</p>
<button
type="button"
className="btn-small"
onClick={() => {
setSelectedProduct(null);
setProductSearch('');
}}
>
{t('admin.linkProduct.changeProduct')}
</button>
</div>
)}
</div>
<div className="form-row">
<div className="form-group">
<label>{t('admin.linkProduct.priceInput')}</label>
<input
type="number"
step="0.01"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
placeholder="e.g., 3.50"
/>
</div>
<div className="form-group">
<label>{t('admin.linkProduct.stock')}</label>
<input
type="number"
value={formData.stock}
onChange={(e) => setFormData({ ...formData, stock: e.target.value })}
placeholder="e.g., 100"
/>
</div>
</div>
<div className="form-actions">
<button type="submit" className="btn-primary">
{t('admin.linkProduct.linkBtn')}
</button>
<button type="button" className="btn-secondary" onClick={resetForm}>
{t('admin.linkProduct.reset')}
</button>
</div>
</form>
{selectedPharmacy && (
<div className="pharmacy-medicines-section">
<h3>{t('admin.linkProduct.productsIn')} {selectedPharmacy.name}</h3>
{loading ? (
<div className="loading">{t('admin.linkProduct.loading')}</div>
) : pharmacyProducts.length === 0 ? (
<p className="empty-state">{t('admin.linkProduct.empty')}</p>
) : (
<div className="admin-list">
{pharmacyProducts.map((pp) => (
<div key={pp.id} className="admin-item">
<div className="item-content">
<h4>
{pp.product_name}
{getSourceBadge(pp.product_source)}
</h4>
<p>
<strong>{t('admin.linkProduct.priceLabel')}</strong> {pp.price ? `€${parseFloat(pp.price).toFixed(2)}` : t('admin.linkProduct.undefined')} •
<strong> Stock:</strong> {pp.stock || 0}
</p>
</div>
<div className="item-actions">
<button
className="btn-edit"
onClick={() => {
const newPrice = prompt(t('admin.linkProduct.newPrice'), pp.price || '');
const newStock = prompt(t('admin.linkProduct.newStock'), pp.stock || '0');
if (newPrice !== null && newStock !== null) {
handleUpdate(pp.id, newPrice ? parseFloat(newPrice) : null, parseInt(newStock) || 0);
}
}}
>
{t('admin.linkProduct.update')}
</button>
<button className="btn-delete" onClick={() => handleDelete(pp.id)}>
{t('admin.linkProduct.delete')}
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
export default PharmacyProductLink;