Actualiser assets/js/app.js
CI / PHP Syntax Check (push) Has been cancelled
CI / JavaScript Lint (push) Has been cancelled
CI / Docker Build Test (push) Has been cancelled
CI / Validate Translation Files (push) Has been cancelled
CI / Auto-merge develop → main (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled

This commit is contained in:
2026-06-18 08:43:08 +00:00
parent 8ae455d82c
commit c0c1a312c6
+223 -15
View File
@@ -1384,6 +1384,140 @@ async function _loadConfigPage() {
} catch (e) {
container.innerHTML = `<p class="settings-hint">Erreur de chargement.</p>`;
}
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]) => `<option value="${key}">${label}</option>`).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 = `<p class="settings-hint">Aucune sous-catégorie pour cette catégorie.</p>`;
return;
}
container.innerHTML = list.map(sc => `
<div class="loc-row" style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border,#e2e8f0)">
<input type="text" class="form-input" style="flex:1" value="${escapeHtml(sc.label)}" id="subcat-label-${sc.id}">
<button class="btn btn-small btn-primary" onclick="updateSubcategoryRow(${sc.id})" title="Enregistrer">💾</button>
<button class="btn btn-small btn-secondary" onclick="removeSubcategoryRow(${sc.id})" title="Supprimer">🗑</button>
</div>
`).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 '<option value="">-- Aucune --</option>';
const opts = list.map(sc =>
`<option value="${sc.key}" ${sc.key === selectedValue ? 'selected' : ''}>${escapeHtml(sc.label)}</option>`
).join('');
return '<option value="">-- Aucune --</option>' + 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 = `<span class="inv-badge badge-category" data-cat="${catKey}" data-itemname="${escapeHtml(item.name)}">${catIcon} ${catLabel}</span>`;
const subBadge = (item.subcategory && SUBCATEGORY_LABELS[item.subcategory])
? `<span class="inv-badge badge-subcategory">${SUBCATEGORY_LABELS[item.subcategory]}</span>`
const subBadge = (item.subcategory && SUBCATEGORY_LABEL_LOOKUP[catKey + '::' + item.subcategory])
? `<span class="inv-badge badge-subcategory">${SUBCATEGORY_LABEL_LOOKUP[catKey + '::' + item.subcategory]}</span>`
: '';
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]) =>
`<option value="${key}" ${mapToLocalCategory(currentProduct.category, currentProduct.name, currentProduct.brand) === key ? 'selected' : ''}>${label}</option>`
const currentCatForEdit = mapToLocalCategory(currentProduct.category, currentProduct.name, currentProduct.brand);
const categoryOptions = Object.entries(CATEGORY_LABELS).map(([key, label]) =>
`<option value="${key}" ${currentCatForEdit === key ? 'selected' : ''}>${label}</option>`
).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 = `
<div class="edit-unknown-card ${isUnknown ? 'highlight' : ''}">
@@ -8824,11 +9015,17 @@ function showProductAction() {
</div>
<div class="form-group">
<label>${t('product.category_label')}</label>
<select id="edit-action-category" class="form-input">
<select id="edit-action-category" class="form-input" onchange="onEditActionCategoryChange()">
<option value="">${t('form.select_placeholder')}</option>
${categoryOptions}
</select>
</div>
<div class="form-group" id="edit-action-subcategory-group" style="display:${editSubcategoryVisible}">
<label>📂 Sous-catégorie <span class="subcategory-required-mark" style="display:${editSubcategoryRequired ? 'inline' : 'none'};color:#e74c3c">*</span></label>
<select id="edit-action-subcategory" class="form-input">
${editSubcategoryOptions}
</select>
</div>
<div class="form-group">
<label>${t('product.notes_label')}</label>
<textarea id="edit-action-notes" class="form-input" rows="2" placeholder="${escapeHtml(t('product.notes_placeholder') || '')}">${escapeHtml(currentProduct.notes || '')}</textarea>
@@ -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');