diff --git a/assets/js/app.js b/assets/js/app.js
index 3352a2d..e8f0a53 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -1384,6 +1384,140 @@ async function _loadConfigPage() {
} catch (e) {
container.innerHTML = `
Erreur de chargement.
`;
}
+ await _loadSubcategoryConfigSection();
+}
+
+async function _loadSubcategoryConfigSection() {
+ const catSelect = document.getElementById('subcat-config-category');
+ if (!catSelect) return;
+ if (!catSelect.dataset.populated) {
+ catSelect.innerHTML = Object.entries(CATEGORY_LABELS).map(([key, label]) => ``).join('');
+ catSelect.dataset.populated = 'true';
+ }
+ await renderSubcategoryConfigForCategory(catSelect.value || Object.keys(CATEGORY_LABELS)[0]);
+}
+
+async function renderSubcategoryConfigForCategory(category) {
+ const catSelect = document.getElementById('subcat-config-category');
+ if (catSelect) catSelect.value = category;
+ const requiredCheckbox = document.getElementById('subcat-config-required');
+ if (requiredCheckbox) requiredCheckbox.checked = REQUIRED_SUBCATEGORY_CATEGORIES.includes(category);
+
+ const container = document.getElementById('subcat-list-container');
+ if (!container) return;
+ const list = SUBCATEGORIES_BY_CATEGORY[category] || [];
+ if (list.length === 0) {
+ container.innerHTML = `Aucune sous-catégorie pour cette catégorie.
`;
+ return;
+ }
+ container.innerHTML = list.map(sc => `
+
+
+
+
+
+ `).join('');
+}
+
+async function onSubcatConfigCategoryChange() {
+ const cat = document.getElementById('subcat-config-category')?.value || '';
+ await renderSubcategoryConfigForCategory(cat);
+}
+
+async function toggleSubcategoryRequired() {
+ const category = document.getElementById('subcat-config-category')?.value || '';
+ const checked = document.getElementById('subcat-config-required')?.checked;
+ let updated = REQUIRED_SUBCATEGORY_CATEGORIES.slice();
+ if (checked && !updated.includes(category)) updated.push(category);
+ if (!checked) updated = updated.filter(c => c !== category);
+
+ showLoading(true);
+ try {
+ const result = await api('app_settings_save', {}, 'POST', { settings: { subcategory_required_categories: updated } });
+ showLoading(false);
+ if (result.success) {
+ REQUIRED_SUBCATEGORY_CATEGORIES = updated;
+ showToast('Préférence enregistrée', 'success');
+ } else {
+ showToast('Erreur lors de l\'enregistrement', 'error');
+ }
+ } catch (e) {
+ showLoading(false);
+ showToast('Erreur lors de l\'enregistrement', 'error');
+ }
+}
+
+async function addSubcategoryRow() {
+ const category = document.getElementById('subcat-config-category')?.value || '';
+ const labelInput = document.getElementById('new-subcat-label');
+ const label = labelInput.value.trim();
+ if (!category) { showToast('Choisis une catégorie', 'warning'); return; }
+ if (!label) { showToast('Indique un nom pour la nouvelle sous-catégorie', 'warning'); return; }
+
+ showLoading(true);
+ try {
+ const result = await api('subcategories_add', {}, 'POST', { category, label });
+ showLoading(false);
+ if (result.success) {
+ showToast(`Sous-catégorie "${label}" ajoutée`, 'success');
+ labelInput.value = '';
+ if (!SUBCATEGORIES_BY_CATEGORY[category]) SUBCATEGORIES_BY_CATEGORY[category] = [];
+ SUBCATEGORIES_BY_CATEGORY[category].push({ id: result.id, category, key: result.key, label });
+ SUBCATEGORY_LABEL_LOOKUP[category + '::' + result.key] = label;
+ renderSubcategoryConfigForCategory(category);
+ } else {
+ showToast(result.error || 'Erreur lors de l\'ajout', 'error');
+ }
+ } catch (e) {
+ showLoading(false);
+ showToast('Erreur lors de l\'ajout', 'error');
+ }
+}
+
+async function updateSubcategoryRow(id) {
+ const label = document.getElementById(`subcat-label-${id}`)?.value.trim();
+ if (!label) { showToast('Le nom ne peut pas être vide', 'warning'); return; }
+
+ showLoading(true);
+ try {
+ const result = await api('subcategories_update', {}, 'POST', { id, label });
+ showLoading(false);
+ if (result.success) {
+ showToast('Sous-catégorie mise à jour', 'success');
+ for (const cat in SUBCATEGORIES_BY_CATEGORY) {
+ const row = SUBCATEGORIES_BY_CATEGORY[cat].find(sc => sc.id === id);
+ if (row) { row.label = label; SUBCATEGORY_LABEL_LOOKUP[cat + '::' + row.key] = label; break; }
+ }
+ } else {
+ showToast(result.error || 'Erreur', 'error');
+ }
+ } catch (e) {
+ showLoading(false);
+ showToast('Erreur', 'error');
+ }
+}
+
+async function removeSubcategoryRow(id) {
+ if (!confirm('Supprimer cette sous-catégorie ?')) return;
+
+ showLoading(true);
+ try {
+ const result = await api('subcategories_remove', {}, 'POST', { id });
+ showLoading(false);
+ if (result.success) {
+ showToast('Sous-catégorie supprimée', 'success');
+ for (const cat in SUBCATEGORIES_BY_CATEGORY) {
+ SUBCATEGORIES_BY_CATEGORY[cat] = SUBCATEGORIES_BY_CATEGORY[cat].filter(sc => sc.id !== id);
+ }
+ const category = document.getElementById('subcat-config-category')?.value || '';
+ renderSubcategoryConfigForCategory(category);
+ } else {
+ showToast(result.error || 'Impossible de supprimer', 'error');
+ }
+ } catch (e) {
+ showLoading(false);
+ showToast('Erreur lors de la suppression', 'error');
+ }
}
function renderLocationsList(locations) {
@@ -1817,10 +1951,57 @@ const CATEGORY_LABELS = {
'altro': `📦 ${t('categories.altro')}`
};
-const SUBCATEGORY_LABELS = {
- 'vin': '🍷 Vin', 'biere': '🍺 Bière', 'spiritueux': '🥃 Spiritueux',
- 'soda': '🥤 Soda', 'jus': '🧃 Jus', 'eau': '💧 Eau', 'autre': '📦 Autre'
-};
+// Sous-catégories chargées dynamiquement depuis la base (table subcategories), gérables via Config
+let SUBCATEGORIES_BY_CATEGORY = {}; // { category: [{id, category, key, label}, ...] }
+let SUBCATEGORY_LABEL_LOOKUP = {}; // { "category::key": label } — évite les collisions entre catégories
+let REQUIRED_SUBCATEGORY_CATEGORIES = ['bevande']; // valeur par défaut, écrasée par syncSettingsFromDB()
+
+async function loadCustomSubcategories() {
+ try {
+ const result = await api('subcategories_list', {}, 'GET');
+ if (result.success && Array.isArray(result.subcategories)) {
+ SUBCATEGORIES_BY_CATEGORY = {};
+ SUBCATEGORY_LABEL_LOOKUP = {};
+ result.subcategories.forEach(sc => {
+ if (!SUBCATEGORIES_BY_CATEGORY[sc.category]) SUBCATEGORIES_BY_CATEGORY[sc.category] = [];
+ SUBCATEGORIES_BY_CATEGORY[sc.category].push(sc);
+ SUBCATEGORY_LABEL_LOOKUP[sc.category + '::' + sc.key] = sc.label;
+ });
+ }
+ } catch (e) {
+ console.warn('[EverShelf] Could not load subcategories:', e);
+ }
+}
+
+function getSubcategoryOptionsHtml(category, selectedValue = '') {
+ const list = SUBCATEGORIES_BY_CATEGORY[category];
+ if (!list || list.length === 0) return '';
+ const opts = list.map(sc =>
+ ``
+ ).join('');
+ return '' + opts;
+}
+
+function updateSubcategoryField(selectId, groupId, category, selectedValue = '') {
+ const select = document.getElementById(selectId);
+ const group = document.getElementById(groupId);
+ if (!select) return;
+ const list = SUBCATEGORIES_BY_CATEGORY[category];
+ select.innerHTML = getSubcategoryOptionsHtml(category, selectedValue);
+ if (group) {
+ group.style.display = (list && list.length > 0) ? 'block' : 'none';
+ const mark = group.querySelector('.subcategory-required-mark');
+ if (mark) {
+ mark.style.color = '#e74c3c';
+ mark.style.display = REQUIRED_SUBCATEGORY_CATEGORIES.includes(category) ? 'inline' : 'none';
+ }
+ }
+}
+
+function onEditActionCategoryChange() {
+ const cat = document.getElementById('edit-action-category')?.value || '';
+ updateSubcategoryField('edit-action-subcategory', 'edit-action-subcategory-group', cat);
+}
// Detect best unit/quantity from Open Food Facts quantity_info string
// Returns the actual package weight/volume as default (e.g. 700g → unit:'g', quantity:700)
@@ -2789,6 +2970,7 @@ async function syncSettingsFromDB() {
const srv = res.settings;
if (srv.review_confirmed) _reviewConfirmedCache = srv.review_confirmed;
+ if (Array.isArray(srv.subcategory_required_categories)) REQUIRED_SUBCATEGORY_CATEGORIES = srv.subcategory_required_categories;
// meal_plan is stored in SQLite app_settings so all devices stay in sync
if (srv.meal_plan) {
@@ -6676,8 +6858,8 @@ function renderInventoryItem(item) {
const catIcon = CATEGORY_ICONS[catKey] || '📦';
const catLabel = t('categories.' + catKey) || catKey;
const catBadge = `${catIcon} ${catLabel}`;
- const subBadge = (item.subcategory && SUBCATEGORY_LABELS[item.subcategory])
- ? `${SUBCATEGORY_LABELS[item.subcategory]}`
+ const subBadge = (item.subcategory && SUBCATEGORY_LABEL_LOOKUP[catKey + '::' + item.subcategory])
+ ? `${SUBCATEGORY_LABEL_LOOKUP[catKey + '::' + item.subcategory]}`
: '';
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const days = daysUntilExpiry(item.expiry_date);
@@ -6786,7 +6968,7 @@ function filterInventory() {
// Match by inferred category: "biscotti" → queryCat="snack" → all snack items
if (queryCat !== 'altro' && itemCat === queryCat) return true;
if (i.subcategory && i.subcategory.toLowerCase().includes(q)) return true;
- if (i.subcategory && (SUBCATEGORY_LABELS[i.subcategory] || '').toLowerCase().includes(q)) return true;
+ if (i.subcategory && (SUBCATEGORY_LABEL_LOOKUP[itemCat + '::' + i.subcategory] || '').toLowerCase().includes(q)) return true;
return false;
});
renderInventory(filtered);
@@ -8397,6 +8579,7 @@ function startManualEntry(barcode = '') {
document.getElementById('pf-name').value = '';
document.getElementById('pf-brand').value = '';
document.getElementById('pf-category').value = '';
+ updateSubcategoryField('pf-subcategory', 'pf-subcategory-group', '');
document.getElementById('pf-unit').value = 'pz';
document.getElementById('pf-defqty').value = '1';
document.getElementById('pf-notes').value = '';
@@ -8508,7 +8691,10 @@ function onCategoryChange(fromAutoDetect = false) {
const cat = document.getElementById('pf-category').value;
const unitSelect = document.getElementById('pf-unit');
const qtyInput = document.getElementById('pf-defqty');
-
+
+ // Met à jour les options/visibilité de la sous-catégorie pour la nouvelle catégorie
+ updateSubcategoryField('pf-subcategory', 'pf-subcategory-group', cat);
+
// If user manually changed category via dropdown, don't auto-fill qty/unit
if (!fromAutoDetect) {
// Mark qty as "set" so future auto-detects won't overwrite either
@@ -8668,8 +8854,8 @@ async function submitProduct(e) {
e.preventDefault();
const pfCategory = document.getElementById('pf-category').value;
const pfSubcategory = document.getElementById('pf-subcategory')?.value || '';
- if (pfCategory === 'bevande' && !pfSubcategory) {
- showToast('Merci de préciser le type de boisson', 'error');
+ if (REQUIRED_SUBCATEGORY_CATEGORIES.includes(pfCategory) && !pfSubcategory) {
+ showToast('Merci de préciser la sous-catégorie', 'error');
document.getElementById('pf-subcategory').focus();
return;
}
@@ -8805,9 +8991,14 @@ function showProductAction() {
}
// Always build the edit form, but only show it auto-opened for unknown products
- const categoryOptions = Object.entries(CATEGORY_LABELS).map(([key, label]) =>
- ``
+ const currentCatForEdit = mapToLocalCategory(currentProduct.category, currentProduct.name, currentProduct.brand);
+ const categoryOptions = Object.entries(CATEGORY_LABELS).map(([key, label]) =>
+ ``
).join('');
+ const editSubcategoryOptions = getSubcategoryOptionsHtml(currentCatForEdit, currentProduct.subcategory || '');
+ const editSubcategoryList = SUBCATEGORIES_BY_CATEGORY[currentCatForEdit];
+ const editSubcategoryVisible = (editSubcategoryList && editSubcategoryList.length > 0) ? 'block' : 'none';
+ const editSubcategoryRequired = REQUIRED_SUBCATEGORY_CATEGORIES.includes(currentCatForEdit);
editInfoEl.innerHTML = `
@@ -8824,11 +9015,17 @@ function showProductAction() {
-
+
+
+
+ ${editSubcategoryOptions}
+
+
@@ -9399,8 +9596,14 @@ async function saveEditedProductInfo() {
}
const brand = (document.getElementById('edit-action-brand')?.value || '').trim();
const category = document.getElementById('edit-action-category')?.value || '';
+ const subcategory = document.getElementById('edit-action-subcategory')?.value || '';
+ const effectiveCategory = category || currentProduct.category || '';
+ if (REQUIRED_SUBCATEGORY_CATEGORIES.includes(effectiveCategory) && !subcategory) {
+ showToast('Merci de préciser la sous-catégorie', 'error');
+ document.getElementById('edit-action-subcategory')?.focus();
+ return;
+ }
const notes = (document.getElementById('edit-action-notes')?.value || '').trim();
-
showLoading(true);
try {
const result = await api('product_save', {}, 'POST', {
@@ -9408,7 +9611,8 @@ async function saveEditedProductInfo() {
barcode: currentProduct.barcode || null,
name: name,
brand: brand,
- category: category || currentProduct.category || '',
+ category: effectiveCategory,
+ subcategory: subcategory || null,
image_url: currentProduct.image_url || '',
unit: currentProduct.unit || 'pz',
default_quantity: currentProduct.default_quantity || 1,
@@ -9421,6 +9625,7 @@ async function saveEditedProductInfo() {
currentProduct.brand = brand;
currentProduct.notes = notes;
if (category) currentProduct.category = category;
+ currentProduct.subcategory = subcategory || null;
showToast(t('toast.product_updated'), 'success');
// Refresh the action page with updated data
showProductAction();
@@ -19653,6 +19858,9 @@ async function _initApp() {
// Load custom locations (merges into LOCATIONS before any UI renders)
await loadCustomLocations();
+
+ // Load custom subcategories (merges into SUBCATEGORIES_BY_CATEGORY before any UI renders)
+ await loadCustomSubcategories();
// Check for setup wizard resume (after language change)
const resumeStep = localStorage.getItem('evershelf_setup_step');