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
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:
+223
-15
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user