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
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.
407 lines
14 KiB
React
407 lines
14 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 PharmacyMedicineLink() {
|
|
const { t } = useTranslation();
|
|
const [pharmacies, setPharmacies] = useState([]);
|
|
const [medicineSearch, setMedicineSearch] = useState('');
|
|
const [medicineResults, setMedicineResults] = useState([]);
|
|
const [selectedPharmacy, setSelectedPharmacy] = useState(null);
|
|
const [selectedMedicine, setSelectedMedicine] = useState(null);
|
|
const [pharmacyMedicines, setPharmacyMedicines] = 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) {
|
|
fetchPharmacyMedicines(selectedPharmacy.id);
|
|
}
|
|
}, [selectedPharmacy]);
|
|
|
|
// Buscar medicamentos en la API de CIMA mientras el usuario escribe
|
|
useEffect(() => {
|
|
const q = medicineSearch.trim();
|
|
if (q.length < 2) {
|
|
setMedicineResults([]);
|
|
setSearching(false);
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(async () => {
|
|
setSearching(true);
|
|
try {
|
|
const response = await fetch(`/api/medicines/search?q=${encodeURIComponent(q)}`, {
|
|
credentials: 'include',
|
|
signal: controller.signal,
|
|
});
|
|
const data = await response.json();
|
|
setMedicineResults(Array.isArray(data) ? data : []);
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') return;
|
|
console.error('Error searching medicines:', error);
|
|
} finally {
|
|
if (!controller.signal.aborted) setSearching(false);
|
|
}
|
|
}, 500);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
controller.abort();
|
|
};
|
|
}, [medicineSearch]);
|
|
|
|
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 fetchPharmacyMedicines = async (pharmacyId) => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetch(`/api/admin/pharmacies/${pharmacyId}/medicines`, {
|
|
credentials: 'include',
|
|
});
|
|
const data = await response.json();
|
|
setPharmacyMedicines(data);
|
|
} catch (error) {
|
|
console.error('Error fetching pharmacy medicines:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
|
|
if (!selectedMedicine) {
|
|
alert(t('admin.linkMedicine.selectMedicineFirst'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = {
|
|
pharmacy_id: parseInt(formData.pharmacy_id),
|
|
medicine_nregistro: selectedMedicine.nregistro,
|
|
medicine_name: selectedMedicine.name,
|
|
price: formData.price ? parseFloat(formData.price) : null,
|
|
stock: formData.stock ? parseInt(formData.stock) : 0
|
|
};
|
|
|
|
const response = await fetch('/api/admin/pharmacy-medicines', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!response.ok) throw new Error(t('admin.linkMedicine.linkError'));
|
|
|
|
resetForm();
|
|
if (selectedPharmacy) {
|
|
fetchPharmacyMedicines(selectedPharmacy.id);
|
|
}
|
|
alert(t('admin.linkMedicine.linkSuccess'));
|
|
} catch (error) {
|
|
console.error('Error linking medicine:', error);
|
|
alert(t('admin.linkMedicine.linkError'));
|
|
}
|
|
};
|
|
|
|
const handleUpdate = async (id, price, stock) => {
|
|
try {
|
|
const response = await fetch(`/api/admin/pharmacy-medicines/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ price, stock })
|
|
});
|
|
|
|
if (!response.ok) throw new Error(t('admin.linkMedicine.updateError'));
|
|
|
|
fetchPharmacyMedicines(selectedPharmacy.id);
|
|
alert(t('admin.linkMedicine.updateSuccess'));
|
|
} catch (error) {
|
|
console.error('Error updating:', error);
|
|
alert(t('admin.linkMedicine.updateError'));
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id) => {
|
|
if (!confirm(t('admin.linkMedicine.deleteConfirm'))) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/admin/pharmacy-medicines/${id}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include'
|
|
});
|
|
|
|
if (!response.ok) throw new Error(t('admin.linkMedicine.deleteError'));
|
|
|
|
fetchPharmacyMedicines(selectedPharmacy.id);
|
|
alert(t('admin.linkMedicine.deleteSuccess'));
|
|
} catch (error) {
|
|
console.error('Error deleting:', error);
|
|
alert(t('admin.linkMedicine.deleteError'));
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setFormData({
|
|
pharmacy_id: selectedPharmacy ? selectedPharmacy.id.toString() : '',
|
|
price: '',
|
|
stock: ''
|
|
});
|
|
setSelectedMedicine(null);
|
|
setMedicineSearch('');
|
|
setMedicineResults([]);
|
|
};
|
|
|
|
const selectMedicine = (medicine) => {
|
|
setSelectedMedicine(medicine);
|
|
setMedicineSearch(medicine.name);
|
|
setMedicineResults([]);
|
|
};
|
|
|
|
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);
|
|
};
|
|
|
|
return (
|
|
<div className="admin-section">
|
|
<h2>{t('admin.linkMedicine.title')}</h2>
|
|
|
|
<form className="admin-form" onSubmit={handleSubmit}>
|
|
<div className="form-group">
|
|
<label>{t('admin.linkMedicine.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.linkMedicine.searchPharmacy')} ${pharmacies.length} ${t('admin.linkMedicine.pharmacies')}` : t('admin.linkMedicine.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.linkMedicine.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.linkMedicine.changePharmacy')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>{t('admin.linkMedicine.medicine')}</label>
|
|
<input
|
|
type="text"
|
|
value={medicineSearch}
|
|
onChange={(e) => {
|
|
setMedicineSearch(e.target.value);
|
|
setSelectedMedicine(null);
|
|
}}
|
|
placeholder={t('admin.linkMedicine.searchMedicine')}
|
|
required
|
|
/>
|
|
{searching && <p className="loading-text">{t('admin.linkMedicine.searching')}</p>}
|
|
|
|
{medicineResults.length > 0 && !selectedMedicine && (
|
|
<div className="medicine-search-results">
|
|
{medicineResults.slice(0, 10).map((medicine) => (
|
|
<div
|
|
key={medicine.nregistro}
|
|
className="search-result-item"
|
|
onClick={() => selectMedicine(medicine)}
|
|
>
|
|
<strong>{medicine.name}</strong>
|
|
{medicine.active_ingredient && <span> - {medicine.active_ingredient}</span>}
|
|
{medicine.dosage && <span> ({medicine.dosage})</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{selectedMedicine && (
|
|
<div className="selected-medicine-info">
|
|
<p>✅ Selected: <strong>{selectedMedicine.name}</strong></p>
|
|
<p className="medicine-details">
|
|
{selectedMedicine.active_ingredient && `${t('admin.linkMedicine.activeIngredient')} ${selectedMedicine.active_ingredient} • `}
|
|
{selectedMedicine.dosage && `${t('admin.linkMedicine.dosage')} ${selectedMedicine.dosage} • `}
|
|
{t('admin.linkMedicine.registrationNumber')} {selectedMedicine.nregistro}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="btn-small"
|
|
onClick={() => {
|
|
setSelectedMedicine(null);
|
|
setMedicineSearch('');
|
|
}}
|
|
>
|
|
{t('admin.linkMedicine.changeMedicine')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label>{t('admin.linkMedicine.price')}</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={formData.price}
|
|
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
|
|
placeholder="e.g., 12.50"
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>{t('admin.linkMedicine.stock')}</label>
|
|
<input
|
|
type="number"
|
|
value={formData.stock}
|
|
onChange={(e) => setFormData({ ...formData, stock: e.target.value })}
|
|
placeholder="e.g., 50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-actions">
|
|
<button type="submit" className="btn-primary">
|
|
{t('admin.linkMedicine.linkBtn')}
|
|
</button>
|
|
<button type="button" className="btn-secondary" onClick={resetForm}>
|
|
{t('admin.linkMedicine.reset')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{selectedPharmacy && (
|
|
<div className="pharmacy-medicines-section">
|
|
<h3>{t('admin.linkMedicine.medicationsIn')} {selectedPharmacy.name}</h3>
|
|
{loading ? (
|
|
<div className="loading">{t('admin.linkMedicine.loading')}</div>
|
|
) : pharmacyMedicines.length === 0 ? (
|
|
<p className="empty-state">{t('admin.linkMedicine.empty')}</p>
|
|
) : (
|
|
<div className="admin-list">
|
|
{pharmacyMedicines.map((pm) => (
|
|
<div key={pm.id} className="admin-item">
|
|
<div className="item-content">
|
|
<h4>{pm.medicine_name}</h4>
|
|
<p>
|
|
<strong>{t('admin.linkMedicine.priceLabel')}</strong> {pm.price ? `€${parseFloat(pm.price).toFixed(2)}` : t('admin.linkMedicine.undefined')} •
|
|
<strong> Stock:</strong> {pm.stock || 0}
|
|
</p>
|
|
</div>
|
|
<div className="item-actions">
|
|
<button
|
|
className="btn-edit"
|
|
onClick={() => {
|
|
const newPrice = prompt(t('admin.linkMedicine.newPrice'), pm.price || '');
|
|
const newStock = prompt(t('admin.linkMedicine.newStock'), pm.stock || '0');
|
|
if (newPrice !== null && newStock !== null) {
|
|
handleUpdate(pm.id, newPrice ? parseFloat(newPrice) : null, parseInt(newStock) || 0);
|
|
}
|
|
}}
|
|
>
|
|
{t('admin.linkMedicine.update')}
|
|
</button>
|
|
<button className="btn-delete" onClick={() => handleDelete(pm.id)}>
|
|
{t('admin.linkMedicine.delete')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default PharmacyMedicineLink;
|
|
|