fix: barcode EAN checksum validation + recipe persons dialog conflict
- Manual barcode input now blocks on invalid EAN checksum (was warning-only) - Native BarcodeDetector now validates EAN/UPC checksum before confirming - Renamed duplicate adjustRecipePersons (rescaler) to scaleRecipePersons to restore +/- buttons in the recipe generation dialog - Added error.barcode_checksum translation key (all 5 languages) - Bump version to v1.7.35
This commit is contained in:
@@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||||
|
|
||||||
|
## [1.7.35] - 2026-06-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Barcode scanner accepts invalid codes** — Manual barcode input with an incorrect EAN checksum now blocks the lookup and shows an error (previously showed a warning but proceeded anyway). The native `BarcodeDetector` path now also validates EAN-8/EAN-13/UPC checksum before confirming a scan, consistent with the Quagga fallback which already did this check.
|
||||||
|
- **Recipe persons +/− buttons stopped working in the generation dialog** — A duplicate `adjustRecipePersons` function added for the post-generation rescaler was overriding the one that updated the persons input in the recipe setup dialog. The rescaler is now named `scaleRecipePersons` to avoid the conflict.
|
||||||
|
|
||||||
## [1.7.34] - 2026-05-30
|
## [1.7.34] - 2026-05-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+290
-43
@@ -2085,13 +2085,15 @@ function _showScanConfirm(name) {
|
|||||||
|
|
||||||
// ===== AI NUMBER OCR (Gemini reads printed barcode digits) =====
|
// ===== AI NUMBER OCR (Gemini reads printed barcode digits) =====
|
||||||
let _numOcrRunning = false;
|
let _numOcrRunning = false;
|
||||||
async function _tryGeminiNumberOCR() {
|
async function _tryGeminiNumberOCR(options = {}) {
|
||||||
|
const { chainToVisual = false } = options;
|
||||||
if (_numOcrRunning || !_requireGemini()) return;
|
if (_numOcrRunning || !_requireGemini()) return;
|
||||||
const video = document.getElementById('scanner-video');
|
const video = document.getElementById('scanner-video');
|
||||||
if (!video || !video.videoWidth) { showToast(t('error.camera'), 'error'); return; }
|
if (!video || !video.videoWidth) { showToast(t('error.camera'), 'error'); return; }
|
||||||
_numOcrRunning = true;
|
_numOcrRunning = true;
|
||||||
const btn = document.getElementById('scan-num-ocr-btn');
|
const btn = document.getElementById('scan-num-ocr-btn');
|
||||||
if (btn) { btn.disabled = true; btn.textContent = t('scan.num_ocr_searching'); }
|
if (btn) { btn.disabled = true; btn.textContent = t('scan.num_ocr_searching'); }
|
||||||
|
_setScanStatus(t('scan.status_ocr_searching'), 'retry', t('scan.method_ai_ocr'));
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = video.videoWidth;
|
canvas.width = video.videoWidth;
|
||||||
@@ -2100,12 +2102,28 @@ async function _tryGeminiNumberOCR() {
|
|||||||
const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
|
const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
|
||||||
const result = await api('gemini_number_ocr', {}, 'POST', { image: imageBase64 });
|
const result = await api('gemini_number_ocr', {}, 'POST', { image: imageBase64 });
|
||||||
if (result.barcode) {
|
if (result.barcode) {
|
||||||
|
scanLog(`AI OCR: found barcode ${result.barcode}`);
|
||||||
showToast(t('scan.num_ocr_found').replace('{code}', result.barcode), 'success');
|
showToast(t('scan.num_ocr_found').replace('{code}', result.barcode), 'success');
|
||||||
onBarcodeDetected(result.barcode);
|
onBarcodeDetected(result.barcode);
|
||||||
|
} else {
|
||||||
|
scanLog('AI OCR: barcode digits not found');
|
||||||
|
if (chainToVisual && scannerStream && !_aiFallbackExhausted) {
|
||||||
|
scanLog('AI OCR failed — switching to visual product identification');
|
||||||
|
_setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision'));
|
||||||
|
await _tryGeminiVisualBarcode();
|
||||||
} else {
|
} else {
|
||||||
showToast(t('scan.num_ocr_not_found'), 'warning');
|
showToast(t('scan.num_ocr_not_found'), 'warning');
|
||||||
|
_setScanStatus(t('scan.status_scanning'), '', '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
|
scanLog(`AI OCR error: ${e.message}`);
|
||||||
|
if (chainToVisual && scannerStream && !_aiFallbackExhausted) {
|
||||||
|
_setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision'));
|
||||||
|
await _tryGeminiVisualBarcode();
|
||||||
|
} else {
|
||||||
|
_setScanStatus(t('scan.status_scanning'), '', '');
|
||||||
|
}
|
||||||
showToast(t('error.connection'), 'error');
|
showToast(t('error.connection'), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
_numOcrRunning = false;
|
_numOcrRunning = false;
|
||||||
@@ -2115,33 +2133,85 @@ async function _tryGeminiNumberOCR() {
|
|||||||
|
|
||||||
// ===== AI VISUAL PRODUCT IDENTIFICATION (auto-fallback after 5s) =====
|
// ===== AI VISUAL PRODUCT IDENTIFICATION (auto-fallback after 5s) =====
|
||||||
let _aiBarcodeVisualRunning = false;
|
let _aiBarcodeVisualRunning = false;
|
||||||
async function _tryGeminiVisualBarcode() {
|
let _aiDetectedProductDraft = null;
|
||||||
if (_aiBarcodeVisualRunning || !_requireGemini()) return;
|
let _aiInventoryCandidates = [];
|
||||||
const video = document.getElementById('scanner-video');
|
|
||||||
if (!video || !video.videoWidth) return;
|
|
||||||
|
|
||||||
_aiBarcodeVisualRunning = true;
|
function _showScanAiOverlay(msg) {
|
||||||
stopScanner(); // stop scanner loop while AI processes
|
const el = document.getElementById('scan-ai-overlay');
|
||||||
_setScanStatus(t('scan.ai_fallback_searching'), 'retry', 'Gemini Vision');
|
const msgEl = document.getElementById('scan-ai-overlay-msg');
|
||||||
|
if (el) el.style.display = 'flex';
|
||||||
|
if (msgEl) msgEl.textContent = msg || '';
|
||||||
|
}
|
||||||
|
function _hideScanAiOverlay() {
|
||||||
|
const el = document.getElementById('scan-ai-overlay');
|
||||||
|
if (el) el.style.display = 'none';
|
||||||
|
}
|
||||||
|
function _showAiRetryButton() {
|
||||||
|
const btn = document.getElementById('scan-ai-retry-btn');
|
||||||
|
if (btn) btn.style.display = '';
|
||||||
|
}
|
||||||
|
function _clearAiMatchPanel() {
|
||||||
|
const result = document.getElementById('scan-result');
|
||||||
|
if (!result) return;
|
||||||
|
result.style.display = 'none';
|
||||||
|
result.innerHTML = '';
|
||||||
|
_aiDetectedProductDraft = null;
|
||||||
|
_aiInventoryCandidates = [];
|
||||||
|
}
|
||||||
|
function _renderAiCandidateRow(item, idx) {
|
||||||
|
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
|
||||||
|
const qty = (item.total_qty !== null && item.total_qty !== undefined)
|
||||||
|
? `${parseFloat(item.total_qty)} ${item.unit || ''}`.trim()
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<button class="scan-ai-candidate-item" type="button" onclick="_selectAiInventoryCandidate(${idx})">
|
||||||
|
<span class="scan-ai-candidate-icon">${catIcon}</span>
|
||||||
|
<span class="scan-ai-candidate-info">
|
||||||
|
<span class="scan-ai-candidate-name">${escapeHtml(item.name || '')}</span>
|
||||||
|
<span class="scan-ai-candidate-meta">${escapeHtml((item.brand || '') + (qty ? ' · ' + qty : ''))}</span>
|
||||||
|
</span>
|
||||||
|
<span class="scan-ai-candidate-cta">${t('scan.ai_match_use_btn')}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
function _showAiMatchChoices(aiProduct, candidates) {
|
||||||
|
const result = document.getElementById('scan-result');
|
||||||
|
if (!result) return;
|
||||||
|
const aiName = aiProduct?.name || t('product.not_recognized');
|
||||||
|
const aiBrand = aiProduct?.brand || '';
|
||||||
|
const catIcon = CATEGORY_ICONS[mapToLocalCategory(aiProduct?.category || '', aiName)] || '📦';
|
||||||
|
const itemsHtml = (candidates || []).slice(0, 3).map((it, i) => _renderAiCandidateRow(it, i)).join('');
|
||||||
|
|
||||||
|
result.innerHTML = `
|
||||||
|
<div class="scan-ai-match-box">
|
||||||
|
<div class="scan-ai-match-head">
|
||||||
|
<div class="scan-ai-match-title">${t('scan.ai_match_title')}</div>
|
||||||
|
<div class="scan-ai-match-subtitle">${t('scan.ai_match_subtitle')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${itemsHtml ? `
|
||||||
|
<div class="scan-ai-match-list-wrap">
|
||||||
|
<div class="scan-ai-match-list-title">${t('scan.ai_match_existing')}</div>
|
||||||
|
<div class="scan-ai-match-list">${itemsHtml}</div>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="scan-ai-match-empty">${t('scan.ai_match_none')}</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<button class="btn btn-primary scan-ai-add-btn" type="button" onclick="_confirmAiDetectedProduct()">
|
||||||
|
${t('scan.ai_match_add_btn').replace('{name}', escapeHtml(aiName))}
|
||||||
|
</button>
|
||||||
|
<div class="scan-ai-detected-label">${t('scan.ai_detected_label')}</div>
|
||||||
|
<div class="scan-ai-detected-pill">${catIcon} ${escapeHtml(aiName)}${aiBrand ? ' · ' + escapeHtml(aiBrand) : ''}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
result.style.display = 'block';
|
||||||
|
}
|
||||||
|
async function _confirmAiDetectedProduct() {
|
||||||
|
const p = _aiDetectedProductDraft;
|
||||||
|
if (!p) return;
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = video.videoWidth;
|
|
||||||
canvas.height = video.videoHeight;
|
|
||||||
canvas.getContext('2d').drawImage(video, 0, 0);
|
|
||||||
const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
|
|
||||||
|
|
||||||
const result = await api('gemini_barcode_visual', {}, 'POST', {
|
|
||||||
image: imageBase64,
|
|
||||||
lang: _currentLang || 'it',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.found && result.product) {
|
|
||||||
const p = result.product;
|
|
||||||
scanLog(`AI visual: found "${p.name}" (${p.brand})`);
|
|
||||||
showToast(t('scan.ai_fallback_found'), 'success');
|
|
||||||
// Build a synthetic product (no barcode) and show the inventory form
|
|
||||||
const saveResult = await api('product_save', {}, 'POST', {
|
const saveResult = await api('product_save', {}, 'POST', {
|
||||||
barcode: '',
|
barcode: '',
|
||||||
name: p.name || t('product.not_recognized'),
|
name: p.name || t('product.not_recognized'),
|
||||||
@@ -2168,26 +2238,112 @@ async function _tryGeminiVisualBarcode() {
|
|||||||
weight_info: '',
|
weight_info: '',
|
||||||
};
|
};
|
||||||
addToScanRecents(currentProduct);
|
addToScanRecents(currentProduct);
|
||||||
|
_clearAiMatchPanel();
|
||||||
showLoading(false);
|
showLoading(false);
|
||||||
setTimeout(() => showProductAction(), 300);
|
setTimeout(() => showProductAction(), 250);
|
||||||
} else {
|
} else {
|
||||||
showLoading(false);
|
showLoading(false);
|
||||||
showToast(t('error.connection'), 'error');
|
showToast(t('error.connection'), 'error');
|
||||||
}
|
}
|
||||||
|
} catch (_) {
|
||||||
|
showLoading(false);
|
||||||
|
showToast(t('error.connection'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function _selectAiInventoryCandidate(idx) {
|
||||||
|
const p = _aiInventoryCandidates[idx];
|
||||||
|
if (!p) return;
|
||||||
|
currentProduct = {
|
||||||
|
id: p.id,
|
||||||
|
barcode: p.barcode || '',
|
||||||
|
name: p.name || '',
|
||||||
|
brand: p.brand || '',
|
||||||
|
category: p.category || '',
|
||||||
|
image_url: p.image_url || '',
|
||||||
|
unit: p.unit || 'pz',
|
||||||
|
default_quantity: p.default_quantity || 1,
|
||||||
|
package_unit: p.package_unit || '',
|
||||||
|
_confCount: 0,
|
||||||
|
weight_info: '',
|
||||||
|
};
|
||||||
|
if (p.notes) {
|
||||||
|
const pesoMatch = p.notes.match(/Peso:\s*([^·]+)/);
|
||||||
|
if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
|
||||||
|
}
|
||||||
|
addToScanRecents(currentProduct);
|
||||||
|
_clearAiMatchPanel();
|
||||||
|
setTimeout(() => showProductAction(), 250);
|
||||||
|
}
|
||||||
|
async function _retryAiScan() {
|
||||||
|
const btn = document.getElementById('scan-ai-retry-btn');
|
||||||
|
if (btn) btn.style.display = 'none';
|
||||||
|
_aiFallbackExhausted = false;
|
||||||
|
_clearAiMatchPanel();
|
||||||
|
await _tryGeminiNumberOCR({ chainToVisual: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _tryGeminiVisualBarcode() {
|
||||||
|
if (_aiBarcodeVisualRunning || !_requireGemini()) return;
|
||||||
|
const video = document.getElementById('scanner-video');
|
||||||
|
if (!video || !video.videoWidth) return;
|
||||||
|
|
||||||
|
// ★ Capture the frame BEFORE stopping the stream — after stopScanner() the
|
||||||
|
// video element is blanked and drawImage would send a black image to Gemini.
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||||
|
const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
|
||||||
|
if (!imageBase64) { scanLog('AI visual: failed to capture frame'); return; }
|
||||||
|
|
||||||
|
_aiBarcodeVisualRunning = true;
|
||||||
|
stopScanner(); // stop scanner loop while AI processes (stream already captured above)
|
||||||
|
_setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision'));
|
||||||
|
_showScanAiOverlay(t('scan.ai_overlay_msg'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('gemini_barcode_visual', {}, 'POST', {
|
||||||
|
image: imageBase64,
|
||||||
|
lang: _currentLang || 'it',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.found && result.product) {
|
||||||
|
const p = result.product;
|
||||||
|
scanLog(`AI visual: found "${p.name}" (${p.brand})`);
|
||||||
|
_hideScanAiOverlay();
|
||||||
|
showToast(t('scan.ai_fallback_found'), 'success');
|
||||||
|
_aiDetectedProductDraft = {
|
||||||
|
name: p.name || t('product.not_recognized'),
|
||||||
|
brand: p.brand || '',
|
||||||
|
category: p.category || '',
|
||||||
|
};
|
||||||
|
let candidates = [];
|
||||||
|
try {
|
||||||
|
const invRes = await api('inventory_search', {
|
||||||
|
q: _aiDetectedProductDraft.name,
|
||||||
|
limit: 3,
|
||||||
|
});
|
||||||
|
candidates = (invRes.items || []).slice(0, 3);
|
||||||
|
} catch (_) {
|
||||||
|
candidates = [];
|
||||||
|
}
|
||||||
|
_aiInventoryCandidates = candidates;
|
||||||
|
_showAiMatchChoices(_aiDetectedProductDraft, candidates);
|
||||||
} else {
|
} else {
|
||||||
scanLog('AI visual: product not identified — exhausted for this session');
|
scanLog('AI visual: product not identified — exhausted for this session');
|
||||||
_aiFallbackExhausted = true;
|
_aiFallbackExhausted = true;
|
||||||
showLoading(false);
|
_hideScanAiOverlay();
|
||||||
_setScanStatus(t('scan.ai_fallback_exhausted'), 'retry', '');
|
_setScanStatus(t('scan.ai_fallback_exhausted'), 'retry', '');
|
||||||
// Restart the scanner so the user can keep trying with the barcode reader,
|
_showAiRetryButton();
|
||||||
// but the 5s AI timer will NOT fire again (_aiFallbackExhausted=true).
|
// Restart barcode scanner — AI timer won't fire again (_aiFallbackExhausted=true).
|
||||||
setTimeout(() => initScanner(), 300);
|
setTimeout(() => initScanner(), 300);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
scanLog(`AI visual error: ${e.message}`);
|
scanLog(`AI visual error: ${e.message}`);
|
||||||
_aiFallbackExhausted = true;
|
_aiFallbackExhausted = true;
|
||||||
showLoading(false);
|
_hideScanAiOverlay();
|
||||||
_setScanStatus(t('scan.ai_fallback_exhausted'), 'retry', '');
|
_setScanStatus(t('scan.ai_fallback_exhausted'), 'retry', '');
|
||||||
|
_showAiRetryButton();
|
||||||
setTimeout(() => initScanner(), 300);
|
setTimeout(() => initScanner(), 300);
|
||||||
} finally {
|
} finally {
|
||||||
_aiBarcodeVisualRunning = false;
|
_aiBarcodeVisualRunning = false;
|
||||||
@@ -3837,6 +3993,18 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
|
|||||||
// Track current page for auto-refresh
|
// Track current page for auto-refresh
|
||||||
let _currentPageId = 'dashboard';
|
let _currentPageId = 'dashboard';
|
||||||
let _currentPageParam = null;
|
let _currentPageParam = null;
|
||||||
|
let _pageHistory = [{ pageId: 'dashboard', param: null }];
|
||||||
|
|
||||||
|
function goBack(fallbackPage = 'dashboard') {
|
||||||
|
if (_pageHistory.length > 1) {
|
||||||
|
// Drop current page and navigate to the previous entry without re-adding history.
|
||||||
|
_pageHistory.pop();
|
||||||
|
const prev = _pageHistory[_pageHistory.length - 1] || { pageId: fallbackPage, param: null };
|
||||||
|
showPage(prev.pageId, prev.param, { skipHistory: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showPage(fallbackPage, null, { skipHistory: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh current page data without full navigation
|
// Refresh current page data without full navigation
|
||||||
function refreshCurrentPage() {
|
function refreshCurrentPage() {
|
||||||
@@ -3854,7 +4022,17 @@ function refreshCurrentPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPage(pageId, param = null) {
|
function showPage(pageId, param = null, options = {}) {
|
||||||
|
const skipHistory = !!options.skipHistory;
|
||||||
|
if (!skipHistory) {
|
||||||
|
const last = _pageHistory[_pageHistory.length - 1];
|
||||||
|
const sameAsLast = !!last && last.pageId === pageId && (last.param ?? null) === (param ?? null);
|
||||||
|
if (!sameAsLast) {
|
||||||
|
_pageHistory.push({ pageId, param });
|
||||||
|
if (_pageHistory.length > 80) _pageHistory.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_currentPageId = pageId;
|
_currentPageId = pageId;
|
||||||
_currentPageParam = param;
|
_currentPageParam = param;
|
||||||
// Hide all pages
|
// Hide all pages
|
||||||
@@ -3886,7 +4064,7 @@ function showPage(pageId, param = null) {
|
|||||||
}
|
}
|
||||||
loadInventory();
|
loadInventory();
|
||||||
break;
|
break;
|
||||||
case 'scan': _aiFallbackExhausted = false; initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); switchScanTab('barcode');
|
case 'scan': _aiFallbackExhausted = false; _hideScanAiOverlay(); { const _rb = document.getElementById('scan-ai-retry-btn'); if (_rb) _rb.style.display = 'none'; } initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); switchScanTab('barcode');
|
||||||
// Pre-warm the embedding model the first time user visits scan page
|
// Pre-warm the embedding model the first time user visits scan page
|
||||||
if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) {
|
if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) {
|
||||||
window._getCategoryPipeline(); // fire-and-forget
|
window._getCategoryPipeline(); // fire-and-forget
|
||||||
@@ -5031,10 +5209,11 @@ async function loadBannerAlerts() {
|
|||||||
if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; }
|
if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [invData, predData, anomalyData, finishedData, statsData] = await Promise.all([
|
const [invData, predData, anomalyData, dupLossData, finishedData, statsData] = await Promise.all([
|
||||||
api('inventory_list'),
|
api('inventory_list'),
|
||||||
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
|
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
|
||||||
api('inventory_anomalies').catch(err => { console.warn('[Banner] anomalies fetch failed:', err); return { anomalies: [] }; }),
|
api('inventory_anomalies').catch(err => { console.warn('[Banner] anomalies fetch failed:', err); return { anomalies: [] }; }),
|
||||||
|
api('inventory_duplicate_loss_checks').catch(err => { console.warn('[Banner] duplicate loss checks fetch failed:', err); return { checks: [] }; }),
|
||||||
api('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }),
|
api('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }),
|
||||||
api('stats').catch(() => ({ opened: [] })),
|
api('stats').catch(() => ({ opened: [] })),
|
||||||
]);
|
]);
|
||||||
@@ -5158,14 +5337,21 @@ async function loadBannerAlerts() {
|
|||||||
_bannerQueue.push({ type: 'anomaly', data: an });
|
_bannerQueue.push({ type: 'anomaly', data: an });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Finished products: inventory hit 0, waiting for user confirmation
|
// 6. Potentially lost products due to rapid duplicate "out" events
|
||||||
|
const dupChecks = dupLossData.checks || [];
|
||||||
|
dupChecks.forEach(ch => {
|
||||||
|
if (confirmed['dup_' + ch.dismiss_key]) return;
|
||||||
|
_bannerQueue.push({ type: 'dup_loss_check', data: ch });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Finished products: inventory hit 0, waiting for user confirmation
|
||||||
const finished = finishedData.finished || [];
|
const finished = finishedData.finished || [];
|
||||||
finished.forEach(fin => {
|
finished.forEach(fin => {
|
||||||
if (confirmed['fin_' + fin.product_id]) return;
|
if (confirmed['fin_' + fin.product_id]) return;
|
||||||
_bannerQueue.push({ type: 'finished', data: fin });
|
_bannerQueue.push({ type: 'finished', data: fin });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. Products with no expiry date set (and not permanently dismissed)
|
// 8. Products with no expiry date set (and not permanently dismissed)
|
||||||
// Warn for ALL food/drink items — only skip igiene/pulizia (non-food).
|
// Warn for ALL food/drink items — only skip igiene/pulizia (non-food).
|
||||||
// Items are capped at 8 per load (opened packages first) to avoid banner overflow.
|
// Items are capped at 8 per load (opened packages first) to avoid banner overflow.
|
||||||
const noExpiryDismissed = _getNoExpiryDismissed();
|
const noExpiryDismissed = _getNoExpiryDismissed();
|
||||||
@@ -5246,6 +5432,8 @@ function _bannerPriority(entry) {
|
|||||||
// Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong)
|
// Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong)
|
||||||
return entry.data.direction === 'missing' ? 260 : 250;
|
return entry.data.direction === 'missing' ? 260 : 250;
|
||||||
}
|
}
|
||||||
|
case 'dup_loss_check':
|
||||||
|
return 700; // high-priority check: likely double-consume loss
|
||||||
case 'finished':
|
case 'finished':
|
||||||
return 600; // product ran out — confirm before removing from DB
|
return 600; // product ran out — confirm before removing from DB
|
||||||
case 'no_expiry':
|
case 'no_expiry':
|
||||||
@@ -5447,6 +5635,30 @@ function renderBannerItem() {
|
|||||||
}
|
}
|
||||||
actionsEl.innerHTML = btns;
|
actionsEl.innerHTML = btns;
|
||||||
|
|
||||||
|
} else if (entry.type === 'dup_loss_check') {
|
||||||
|
const ch = entry.data;
|
||||||
|
banner.className = 'alert-banner banner-dup-loss';
|
||||||
|
iconEl.textContent = '🧪';
|
||||||
|
|
||||||
|
const locInfo = LOCATIONS[ch.location] || { icon: '📦', label: ch.location || '—' };
|
||||||
|
const locText = `${locInfo.icon} ${locInfo.label}`;
|
||||||
|
const qtyPair = `${ch.q1} + ${ch.q2}`;
|
||||||
|
|
||||||
|
titleEl.textContent = t('dashboard.banner_dup_loss_title').replace('{name}', ch.name);
|
||||||
|
detailEl.textContent = t('dashboard.banner_dup_loss_detail')
|
||||||
|
.replace('{location}', locText)
|
||||||
|
.replace('{seconds}', Math.round(ch.dt_sec || 0))
|
||||||
|
.replace('{qty_pair}', qtyPair);
|
||||||
|
|
||||||
|
let btns = '';
|
||||||
|
if (ch.inventory_id && ch.inventory_id > 0) {
|
||||||
|
btns += `<button class="btn-banner btn-banner-edit" onclick="editReviewItem(${ch.inventory_id}, ${ch.product_id})">${t('dashboard.banner_dup_loss_action_fix')}</button>`;
|
||||||
|
} else {
|
||||||
|
btns += `<button class="btn-banner btn-banner-edit" onclick="openDuplicateLossCheck(${ch.product_id})">${t('dashboard.banner_dup_loss_action_open')}</button>`;
|
||||||
|
}
|
||||||
|
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissDuplicateLossCheck()">${t('dashboard.banner_dup_loss_action_done')}</button>`;
|
||||||
|
actionsEl.innerHTML = btns;
|
||||||
|
|
||||||
} else if (entry.type === 'no_expiry') {
|
} else if (entry.type === 'no_expiry') {
|
||||||
const item = entry.data;
|
const item = entry.data;
|
||||||
banner.className = 'alert-banner banner-no-expiry';
|
banner.className = 'alert-banner banner-no-expiry';
|
||||||
@@ -5584,6 +5796,32 @@ function dismissBannerAnomaly() {
|
|||||||
dismissBannerItem();
|
dismissBannerItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dismissDuplicateLossCheck() {
|
||||||
|
const entry = _bannerQueue[_bannerIndex];
|
||||||
|
if (!entry || entry.type !== 'dup_loss_check') return;
|
||||||
|
const key = entry.data.dismiss_key;
|
||||||
|
setReviewConfirmed('dup_' + key);
|
||||||
|
showToast(t('dashboard.banner_dup_loss_toast_done'), 'success');
|
||||||
|
dismissBannerItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDuplicateLossCheck(productId) {
|
||||||
|
showLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api('product_get', { id: productId });
|
||||||
|
if (data.product) {
|
||||||
|
currentProduct = data.product;
|
||||||
|
showProductAction();
|
||||||
|
} else {
|
||||||
|
showToast(t('error.not_found'), 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast(t('error.connection'), 'error');
|
||||||
|
} finally {
|
||||||
|
showLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function weighBannerItem() {
|
function weighBannerItem() {
|
||||||
const entry = _bannerQueue[_bannerIndex];
|
const entry = _bannerQueue[_bannerIndex];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
@@ -6585,6 +6823,7 @@ async function initScanner() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
stopScanner();
|
stopScanner();
|
||||||
|
_clearAiMatchPanel();
|
||||||
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
const track = stream.getVideoTracks()[0];
|
const track = stream.getVideoTracks()[0];
|
||||||
@@ -6624,8 +6863,8 @@ async function initScanner() {
|
|||||||
clearTimeout(_aiFallbackTimer);
|
clearTimeout(_aiFallbackTimer);
|
||||||
_aiFallbackTimer = setTimeout(() => {
|
_aiFallbackTimer = setTimeout(() => {
|
||||||
if (scannerStream && !_aiFallbackExhausted) { // still scanning — no barcode found yet
|
if (scannerStream && !_aiFallbackExhausted) { // still scanning — no barcode found yet
|
||||||
scanLog('5s elapsed without barcode — triggering AI visual fallback');
|
scanLog('5s elapsed without barcode — triggering AI OCR fallback');
|
||||||
_tryGeminiVisualBarcode();
|
_tryGeminiNumberOCR({ chainToVisual: true });
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
@@ -6739,6 +6978,13 @@ async function startNativeScanner(videoEl) {
|
|||||||
// For other formats (code_128, code_39) require 2 to avoid false reads.
|
// For other formats (code_128, code_39) require 2 to avoid false reads.
|
||||||
const highConfidence = ['ean_13','ean_8','upc_a','upc_e'].includes(format);
|
const highConfidence = ['ean_13','ean_8','upc_a','upc_e'].includes(format);
|
||||||
if (highConfidence || detectCount >= 2 || detectionHistory[code].count >= 2) {
|
if (highConfidence || detectCount >= 2 || detectionHistory[code].count >= 2) {
|
||||||
|
if (highConfidence && !validateEANChecksum(code)) {
|
||||||
|
_invalidBarcodeCount++;
|
||||||
|
scanLog(`Invalid EAN checksum (native): ${code} (retry #${_invalidBarcodeCount})`);
|
||||||
|
_setScanStatus(t('scan.status_invalid').replace('{code}', code), 'invalid', 'Native');
|
||||||
|
lastDetected = ''; detectCount = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
scanning = false;
|
scanning = false;
|
||||||
quaggaRunning = false;
|
quaggaRunning = false;
|
||||||
updateFeedback(null);
|
updateFeedback(null);
|
||||||
@@ -6967,6 +7213,7 @@ function stopScanner() {
|
|||||||
if (tb) tb.classList.remove('torch-on');
|
if (tb) tb.classList.remove('torch-on');
|
||||||
// Hide live code
|
// Hide live code
|
||||||
_hideScanLiveCode();
|
_hideScanLiveCode();
|
||||||
|
_clearAiMatchPanel();
|
||||||
// Also stop AI camera
|
// Also stop AI camera
|
||||||
if (aiStream) {
|
if (aiStream) {
|
||||||
aiStream.getTracks().forEach(t => t.stop());
|
aiStream.getTracks().forEach(t => t.stop());
|
||||||
@@ -7133,7 +7380,7 @@ function autoSubmitEAN(inputEl, force = false) {
|
|||||||
if (!raw) { showToast(t('error.barcode_empty'), 'error'); inputEl.focus(); return; }
|
if (!raw) { showToast(t('error.barcode_empty'), 'error'); inputEl.focus(); return; }
|
||||||
if (!/^\d{4,14}$/.test(raw)) { showToast(t('error.barcode_format'), 'error'); inputEl.focus(); return; }
|
if (!/^\d{4,14}$/.test(raw)) { showToast(t('error.barcode_format'), 'error'); inputEl.focus(); return; }
|
||||||
if (isComplete && !isValid) {
|
if (isComplete && !isValid) {
|
||||||
showToast('⚠️ Checksum EAN errato — verifica le cifre', 'warning');
|
showToast(t('error.barcode_checksum'), 'error'); inputEl.focus(); return;
|
||||||
}
|
}
|
||||||
stopScanner();
|
stopScanner();
|
||||||
onBarcodeDetected(raw);
|
onBarcodeDetected(raw);
|
||||||
@@ -7888,7 +8135,7 @@ function showProductAction() {
|
|||||||
|
|
||||||
// Update back button: go back to shopping if came from shopping list scan
|
// Update back button: go back to shopping if came from shopping list scan
|
||||||
const backBtn = document.getElementById('action-back-btn');
|
const backBtn = document.getElementById('action-back-btn');
|
||||||
if (backBtn) backBtn.onclick = _spesaScanTarget ? () => { _spesaScanTarget = null; showPage('shopping'); } : () => showPage('scan');
|
if (backBtn) backBtn.onclick = () => goBack();
|
||||||
|
|
||||||
// Show "shopping target" banner if we came from the shopping list
|
// Show "shopping target" banner if we came from the shopping list
|
||||||
const banner = document.getElementById('shopping-scan-target-banner');
|
const banner = document.getElementById('shopping-scan-target-banner');
|
||||||
@@ -7902,7 +8149,7 @@ function showProductAction() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="shopping-scan-target-actions">
|
<div class="shopping-scan-target-actions">
|
||||||
<button class="btn btn-success stb-btn" onclick="confirmShoppingItemFound()">✅ ${t('shopping.scan_target_found')}</button>
|
<button class="btn btn-success stb-btn" onclick="confirmShoppingItemFound()">✅ ${t('shopping.scan_target_found')}</button>
|
||||||
<button class="btn btn-secondary stb-btn" onclick="_spesaScanTarget=null; document.getElementById('shopping-scan-target-banner').style.display='none'; document.getElementById('action-back-btn').onclick=()=>showPage('scan')">✕ ${t('btn.cancel')}</button>
|
<button class="btn btn-secondary stb-btn" onclick="_spesaScanTarget=null; document.getElementById('shopping-scan-target-banner').style.display='none'; document.getElementById('action-back-btn').onclick=()=>goBack()">✕ ${t('btn.cancel')}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else if (banner) {
|
} else if (banner) {
|
||||||
@@ -13666,7 +13913,7 @@ async function toggleRecipeFavorite(btn) {
|
|||||||
* Scale recipe ingredient quantities (#123).
|
* Scale recipe ingredient quantities (#123).
|
||||||
* Delta: +1 or -1. Min 1, max 20 persons.
|
* Delta: +1 or -1. Min 1, max 20 persons.
|
||||||
*/
|
*/
|
||||||
function adjustRecipePersons(delta) {
|
function scaleRecipePersons(delta) {
|
||||||
const newPersons = Math.max(1, Math.min(20, _recipeCurrentPersons + delta));
|
const newPersons = Math.max(1, Math.min(20, _recipeCurrentPersons + delta));
|
||||||
if (newPersons === _recipeCurrentPersons) return;
|
if (newPersons === _recipeCurrentPersons) return;
|
||||||
_recipeCurrentPersons = newPersons;
|
_recipeCurrentPersons = newPersons;
|
||||||
@@ -13711,9 +13958,9 @@ function renderRecipe(r) {
|
|||||||
html += '<div class="recipe-meta">';
|
html += '<div class="recipe-meta">';
|
||||||
if (r.meal) html += `<span class="recipe-tag">${_mealLabel(r.meal)}</span>`;
|
if (r.meal) html += `<span class="recipe-tag">${_mealLabel(r.meal)}</span>`;
|
||||||
html += `<span class="recipe-tag recipe-persons-ctrl">
|
html += `<span class="recipe-tag recipe-persons-ctrl">
|
||||||
<button class="btn-persons-adj" onclick="adjustRecipePersons(-1)">−</button>
|
<button class="btn-persons-adj" onclick="scaleRecipePersons(-1)">−</button>
|
||||||
<span id="recipe-persons-display">👥 ${r.persons} ${t('recipes.persons_short')}</span>
|
<span id="recipe-persons-display">👥 ${r.persons} ${t('recipes.persons_short')}</span>
|
||||||
<button class="btn-persons-adj" onclick="adjustRecipePersons(+1)">+</button>
|
<button class="btn-persons-adj" onclick="scaleRecipePersons(+1)">+</button>
|
||||||
</span>`;
|
</span>`;
|
||||||
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`;
|
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`;
|
||||||
if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`;
|
if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`;
|
||||||
|
|||||||
+24
-13
@@ -64,7 +64,7 @@
|
|||||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||||
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
||||||
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
||||||
<span class="app-preloader-version" id="preloader-version">v1.7.25</span>
|
<span class="app-preloader-version" id="preloader-version">v1.7.35</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<!-- Title — left-aligned; grows to fill space -->
|
<!-- Title — left-aligned; grows to fill space -->
|
||||||
<div class="header-title-wrap">
|
<div class="header-title-wrap">
|
||||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.25</span>
|
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.35</span>
|
||||||
</h1>
|
</h1>
|
||||||
<!-- Update badge — shown alongside title, never replaces it -->
|
<!-- Update badge — shown alongside title, never replaces it -->
|
||||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
<!-- ===== INVENTORY LIST ===== -->
|
<!-- ===== INVENTORY LIST ===== -->
|
||||||
<section class="page" id="page-inventory">
|
<section class="page" id="page-inventory">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
|
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
|
||||||
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
|
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
<!-- ===== SCAN PAGE ===== -->
|
<!-- ===== SCAN PAGE ===== -->
|
||||||
<section class="page" id="page-scan">
|
<section class="page" id="page-scan">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="stopScanner(); showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="scan.title">Scansiona</h2>
|
<h2 data-i18n="scan.title">Scansiona</h2>
|
||||||
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
|
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,6 +256,14 @@
|
|||||||
<span id="scan-status-method" class="scan-status-method"></span>
|
<span id="scan-status-method" class="scan-status-method"></span>
|
||||||
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
|
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- AI processing overlay (shown when Gemini Vision is analyzing) -->
|
||||||
|
<div class="scan-ai-overlay" id="scan-ai-overlay" style="display:none">
|
||||||
|
<div class="scan-ai-overlay-inner">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span class="scan-ai-overlay-label">Gemini Vision</span>
|
||||||
|
<span class="scan-ai-overlay-msg" id="scan-ai-overlay-msg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Success flash overlay -->
|
<!-- Success flash overlay -->
|
||||||
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
|
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
|
||||||
<div class="scan-confirm-check">✓</div>
|
<div class="scan-confirm-check">✓</div>
|
||||||
@@ -274,6 +282,9 @@
|
|||||||
<!-- Scan errors -->
|
<!-- Scan errors -->
|
||||||
<div class="scan-result" id="scan-result" style="display:none"></div>
|
<div class="scan-result" id="scan-result" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- AI retry button (shown after visual identification fails) -->
|
||||||
|
<button class="btn btn-accent scan-ai-retry-btn" id="scan-ai-retry-btn" style="display:none" onclick="_retryAiScan()" data-i18n="scan.ai_retry_btn">🤖 Riprova con AI</button>
|
||||||
|
|
||||||
<!-- Recent scans -->
|
<!-- Recent scans -->
|
||||||
<div class="scan-recents" id="scan-recents" style="display:none">
|
<div class="scan-recents" id="scan-recents" style="display:none">
|
||||||
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
|
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
|
||||||
@@ -333,7 +344,7 @@
|
|||||||
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
||||||
<section class="page" id="page-action">
|
<section class="page" id="page-action">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" id="action-back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" id="action-back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="action.title">Cosa vuoi fare?</h2>
|
<h2 data-i18n="action.title">Cosa vuoi fare?</h2>
|
||||||
</div>
|
</div>
|
||||||
<!-- Banner: shopping list scan context -->
|
<!-- Banner: shopping list scan context -->
|
||||||
@@ -356,7 +367,7 @@
|
|||||||
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
||||||
<section class="page" id="page-add">
|
<section class="page" id="page-add">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
|
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="product-preview-small" id="add-product-preview"></div>
|
<div class="product-preview-small" id="add-product-preview"></div>
|
||||||
@@ -419,7 +430,7 @@
|
|||||||
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
||||||
<section class="page" id="page-use">
|
<section class="page" id="page-use">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="use.title">Usa / Consuma</h2>
|
<h2 data-i18n="use.title">Usa / Consuma</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="product-preview-small" id="use-product-preview"></div>
|
<div class="product-preview-small" id="use-product-preview"></div>
|
||||||
@@ -475,7 +486,7 @@
|
|||||||
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
||||||
<section class="page" id="page-product-form">
|
<section class="page" id="page-product-form">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 id="product-form-title">Nuovo Prodotto</h2>
|
<h2 id="product-form-title">Nuovo Prodotto</h2>
|
||||||
</div>
|
</div>
|
||||||
<form class="form" onsubmit="submitProduct(event)">
|
<form class="form" onsubmit="submitProduct(event)">
|
||||||
@@ -663,7 +674,7 @@
|
|||||||
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
||||||
<section class="page" id="page-products">
|
<section class="page" id="page-products">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
|
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
@@ -675,7 +686,7 @@
|
|||||||
<!-- ===== RECIPE PAGE ===== -->
|
<!-- ===== RECIPE PAGE ===== -->
|
||||||
<section class="page" id="page-recipe">
|
<section class="page" id="page-recipe">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-page-container">
|
<div class="recipe-page-container">
|
||||||
@@ -689,7 +700,7 @@
|
|||||||
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
|
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
|
||||||
<section class="page" id="page-shopping">
|
<section class="page" id="page-shopping">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
|
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="shopping-container">
|
<div class="shopping-container">
|
||||||
@@ -797,7 +808,7 @@
|
|||||||
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
||||||
<section class="page" id="page-ai">
|
<section class="page" id="page-ai">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="stopScanner(); showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
|
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-container">
|
<div class="ai-container">
|
||||||
@@ -835,7 +846,7 @@
|
|||||||
<!-- ===== SETTINGS PAGE ===== -->
|
<!-- ===== SETTINGS PAGE ===== -->
|
||||||
<section class="page" id="page-settings">
|
<section class="page" id="page-settings">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
|
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-tabs">
|
<div class="settings-tabs">
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "EverShelf",
|
"name": "EverShelf",
|
||||||
"short_name": "EverShelf",
|
"short_name": "EverShelf",
|
||||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||||
"version": "1.7.25",
|
"version": "1.7.35",
|
||||||
"start_url": "/evershelf/",
|
"start_url": "/evershelf/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f0f4e8",
|
"background_color": "#f0f4e8",
|
||||||
|
|||||||
+21
-1
@@ -151,6 +151,12 @@
|
|||||||
"banner_anomaly_untracked_detail": "Du hast <strong>{inv_qty} {unit}</strong> im Bestand, aber die gebuchten Abgänge übersteigen die Eingänge — der Anfangsbestand wurde wahrscheinlich nie als \"Eingang\" erfasst. Bitte korrigiere die Menge oder trage die fehlenden Eingänge nach.",
|
"banner_anomaly_untracked_detail": "Du hast <strong>{inv_qty} {unit}</strong> im Bestand, aber die gebuchten Abgänge übersteigen die Eingänge — der Anfangsbestand wurde wahrscheinlich nie als \"Eingang\" erfasst. Bitte korrigiere die Menge oder trage die fehlenden Eingänge nach.",
|
||||||
"banner_anomaly_ghost_title": "weniger Bestand als erwartet",
|
"banner_anomaly_ghost_title": "weniger Bestand als erwartet",
|
||||||
"banner_anomaly_ghost_detail": "Laut Buchungen solltest du {expected_qty} {unit} von {name} haben, aber der Bestand zeigt nur {inv_qty} {unit}. Hast du etwas ohne Buchung entnommen?",
|
"banner_anomaly_ghost_detail": "Laut Buchungen solltest du {expected_qty} {unit} von {name} haben, aber der Bestand zeigt nur {inv_qty} {unit}. Hast du etwas ohne Buchung entnommen?",
|
||||||
|
"banner_dup_loss_title": "Prüfung Doppelabbuchung: {name}",
|
||||||
|
"banner_dup_loss_detail": "Mögliche doppelte Buchung in {location}: zwei schnelle Abgänge ({qty_pair}) in ~{seconds}s. Bitte prüfen und ggf. korrigieren.",
|
||||||
|
"banner_dup_loss_action_fix": "Menge korrigieren",
|
||||||
|
"banner_dup_loss_action_open": "Produktkarte öffnen",
|
||||||
|
"banner_dup_loss_action_done": "Bereits geprüft",
|
||||||
|
"banner_dup_loss_toast_done": "Prüfung als erledigt markiert",
|
||||||
"consumed": "Verbraucht: {n} ({pct}%)",
|
"consumed": "Verbraucht: {n} ({pct}%)",
|
||||||
"wasted": "Weggeworfen: {n} ({pct}%)",
|
"wasted": "Weggeworfen: {n} ({pct}%)",
|
||||||
"more_opened": "und {n} weitere geöffnet...",
|
"more_opened": "und {n} weitere geöffnet...",
|
||||||
@@ -221,10 +227,23 @@
|
|||||||
"status_invalid": "Ungültig: {code} — versuche erneut",
|
"status_invalid": "Ungültig: {code} — versuche erneut",
|
||||||
"status_confirmed": "Bestätigt!",
|
"status_confirmed": "Bestätigt!",
|
||||||
"status_parallel": "Kombinierter Scan aktiv...",
|
"status_parallel": "Kombinierter Scan aktiv...",
|
||||||
|
"status_ocr_searching": "Ich lese die Barcode-Ziffern...",
|
||||||
|
"status_ai_visual_searching": "Jetzt versuche ich, das Produkt zu erkennen...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
"ai_fallback_searching": "KI identifiziert Produkt...",
|
"ai_fallback_searching": "KI identifiziert Produkt...",
|
||||||
"ai_fallback_found": "Produkt von KI erkannt",
|
"ai_fallback_found": "Produkt von KI erkannt",
|
||||||
"ai_fallback_not_found": "KI: Produkt nicht erkannt",
|
"ai_fallback_not_found": "KI: Produkt nicht erkannt",
|
||||||
"ai_fallback_exhausted": "KI: Produkt nicht erkannt — Barcode erneut scannen"
|
"ai_fallback_exhausted": "KI: Produkt nicht erkannt — Barcode erneut scannen",
|
||||||
|
"ai_overlay_msg": "Gemini Vision analysiert das Produkt...",
|
||||||
|
"ai_retry_btn": "Mit KI erneut versuchen",
|
||||||
|
"ai_match_title": "Produkt von KI erkannt",
|
||||||
|
"ai_match_subtitle": "Waehle ein vorhandenes Produkt oder fuege das erkannte hinzu.",
|
||||||
|
"ai_match_existing": "Mogliche Treffer in der Vorratskammer",
|
||||||
|
"ai_match_none": "Keine ahnlichen Produkte in der Vorratskammer gefunden.",
|
||||||
|
"ai_match_use_btn": "Dieses nutzen",
|
||||||
|
"ai_match_add_btn": "\"{name}\" hinzufugen",
|
||||||
|
"ai_detected_label": "KI erkannt"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "Was möchtest du tun?",
|
"title": "Was möchtest du tun?",
|
||||||
@@ -1069,6 +1088,7 @@
|
|||||||
"ai_quota": "KI-Kontingent erschöpft. Bitte in ein paar Minuten erneut versuchen.",
|
"ai_quota": "KI-Kontingent erschöpft. Bitte in ein paar Minuten erneut versuchen.",
|
||||||
"barcode_empty": "Barcode eingeben",
|
"barcode_empty": "Barcode eingeben",
|
||||||
"barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)",
|
"barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)",
|
||||||
|
"barcode_checksum": "Ungültiger EAN-Prüfziffer — bitte die Barcode-Ziffern prüfen",
|
||||||
"min_chars": "Mindestens 2 Zeichen eingeben",
|
"min_chars": "Mindestens 2 Zeichen eingeben",
|
||||||
"not_in_inventory": "Produkt nicht im Bestand",
|
"not_in_inventory": "Produkt nicht im Bestand",
|
||||||
"appliance_exists": "Gerät bereits vorhanden",
|
"appliance_exists": "Gerät bereits vorhanden",
|
||||||
|
|||||||
+21
-1
@@ -151,6 +151,12 @@
|
|||||||
"banner_anomaly_untracked_detail": "You have <strong>{inv_qty} {unit}</strong> in inventory, but recorded outflows exceed inflows — the initial stock was likely never added as an \"in\" transaction. You can correct the quantity or log the missing entries.",
|
"banner_anomaly_untracked_detail": "You have <strong>{inv_qty} {unit}</strong> in inventory, but recorded outflows exceed inflows — the initial stock was likely never added as an \"in\" transaction. You can correct the quantity or log the missing entries.",
|
||||||
"banner_anomaly_ghost_title": "you have less stock than expected",
|
"banner_anomaly_ghost_title": "you have less stock than expected",
|
||||||
"banner_anomaly_ghost_detail": "Based on recorded operations you should have {expected_qty} {unit} of {name}, but inventory shows only {inv_qty} {unit}. Did you take stock without recording it?",
|
"banner_anomaly_ghost_detail": "Based on recorded operations you should have {expected_qty} {unit} of {name}, but inventory shows only {inv_qty} {unit}. Did you take stock without recording it?",
|
||||||
|
"banner_dup_loss_title": "Double-consume check: {name}",
|
||||||
|
"banner_dup_loss_detail": "Possible duplicate entry in {location}: two close out events ({qty_pair}) in ~{seconds}s. Please verify and fix if needed.",
|
||||||
|
"banner_dup_loss_action_fix": "Fix quantity",
|
||||||
|
"banner_dup_loss_action_open": "Open product card",
|
||||||
|
"banner_dup_loss_action_done": "Already checked",
|
||||||
|
"banner_dup_loss_toast_done": "Check marked as reviewed",
|
||||||
"consumed": "Consumed: {n} ({pct}%)",
|
"consumed": "Consumed: {n} ({pct}%)",
|
||||||
"wasted": "Wasted: {n} ({pct}%)",
|
"wasted": "Wasted: {n} ({pct}%)",
|
||||||
"more_opened": "and {n} more opened...",
|
"more_opened": "and {n} more opened...",
|
||||||
@@ -221,10 +227,23 @@
|
|||||||
"status_invalid": "Invalid: {code} — retrying",
|
"status_invalid": "Invalid: {code} — retrying",
|
||||||
"status_confirmed": "Confirmed!",
|
"status_confirmed": "Confirmed!",
|
||||||
"status_parallel": "Using combined scan methods...",
|
"status_parallel": "Using combined scan methods...",
|
||||||
|
"status_ocr_searching": "Reading the barcode digits...",
|
||||||
|
"status_ai_visual_searching": "Now trying to recognize the product...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
"ai_fallback_searching": "AI identifying product...",
|
"ai_fallback_searching": "AI identifying product...",
|
||||||
"ai_fallback_found": "Product identified by AI",
|
"ai_fallback_found": "Product identified by AI",
|
||||||
"ai_fallback_not_found": "AI: product not recognized",
|
"ai_fallback_not_found": "AI: product not recognized",
|
||||||
"ai_fallback_exhausted": "AI: product not recognized — try scanning the barcode"
|
"ai_fallback_exhausted": "AI: product not recognized — try scanning the barcode",
|
||||||
|
"ai_overlay_msg": "Gemini Vision is analyzing the product...",
|
||||||
|
"ai_retry_btn": "Retry with AI",
|
||||||
|
"ai_match_title": "Product recognized by AI",
|
||||||
|
"ai_match_subtitle": "Choose an existing pantry item or add the detected one.",
|
||||||
|
"ai_match_existing": "Possible pantry matches",
|
||||||
|
"ai_match_none": "No similar pantry products found.",
|
||||||
|
"ai_match_use_btn": "Use this",
|
||||||
|
"ai_match_add_btn": "Add \"{name}\"",
|
||||||
|
"ai_detected_label": "AI detected"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "What do you want to do?",
|
"title": "What do you want to do?",
|
||||||
@@ -1069,6 +1088,7 @@
|
|||||||
"ai_quota": "AI quota exhausted. Please try again in a few minutes.",
|
"ai_quota": "AI quota exhausted. Please try again in a few minutes.",
|
||||||
"barcode_empty": "Enter a barcode",
|
"barcode_empty": "Enter a barcode",
|
||||||
"barcode_format": "Barcode must contain only numbers (4-14 digits)",
|
"barcode_format": "Barcode must contain only numbers (4-14 digits)",
|
||||||
|
"barcode_checksum": "Invalid EAN checksum — please check the barcode digits",
|
||||||
"min_chars": "Type at least 2 characters",
|
"min_chars": "Type at least 2 characters",
|
||||||
"not_in_inventory": "Product not in inventory",
|
"not_in_inventory": "Product not in inventory",
|
||||||
"appliance_exists": "Appliance already exists",
|
"appliance_exists": "Appliance already exists",
|
||||||
|
|||||||
+21
-1
@@ -149,6 +149,12 @@
|
|||||||
"banner_anomaly_untracked_detail": "Tienes <strong>{inv_qty} {unit}</strong> en inventario, pero las salidas registradas superan las entradas — el stock inicial probablemente nunca se añadió como transacción «entrada». Puedes corregir la cantidad o registrar las entradas faltantes.",
|
"banner_anomaly_untracked_detail": "Tienes <strong>{inv_qty} {unit}</strong> en inventario, pero las salidas registradas superan las entradas — el stock inicial probablemente nunca se añadió como transacción «entrada». Puedes corregir la cantidad o registrar las entradas faltantes.",
|
||||||
"banner_anomaly_ghost_title": "tienes menos stock del esperado",
|
"banner_anomaly_ghost_title": "tienes menos stock del esperado",
|
||||||
"banner_anomaly_ghost_detail": "Según las operaciones registradas deberías tener {expected_qty} {unit} de {name}, pero el inventario solo muestra {inv_qty} {unit}. ¿Tomaste stock sin registrarlo?",
|
"banner_anomaly_ghost_detail": "Según las operaciones registradas deberías tener {expected_qty} {unit} de {name}, pero el inventario solo muestra {inv_qty} {unit}. ¿Tomaste stock sin registrarlo?",
|
||||||
|
"banner_dup_loss_title": "Control de doble salida: {name}",
|
||||||
|
"banner_dup_loss_detail": "Posible registro duplicado en {location}: dos salidas seguidas ({qty_pair}) en ~{seconds}s. Revisa y corrige si hace falta.",
|
||||||
|
"banner_dup_loss_action_fix": "Corregir cantidad",
|
||||||
|
"banner_dup_loss_action_open": "Abrir ficha del producto",
|
||||||
|
"banner_dup_loss_action_done": "Ya revisado",
|
||||||
|
"banner_dup_loss_toast_done": "Control marcado como revisado",
|
||||||
"consumed": "Consumido: {n} ({pct}%)",
|
"consumed": "Consumido: {n} ({pct}%)",
|
||||||
"wasted": "Desperdiciado: {n} ({pct}%)",
|
"wasted": "Desperdiciado: {n} ({pct}%)",
|
||||||
"more_opened": "y {n} más abiertos...",
|
"more_opened": "y {n} más abiertos...",
|
||||||
@@ -218,10 +224,23 @@
|
|||||||
"status_invalid": "Inválido: {code} — reintentando",
|
"status_invalid": "Inválido: {code} — reintentando",
|
||||||
"status_confirmed": "Confirmado!",
|
"status_confirmed": "Confirmado!",
|
||||||
"status_parallel": "Escaneo combinado activo...",
|
"status_parallel": "Escaneo combinado activo...",
|
||||||
|
"status_ocr_searching": "Estoy leyendo los números del código de barras...",
|
||||||
|
"status_ai_visual_searching": "Ahora intento reconocer el producto...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
"ai_fallback_searching": "Identificación de IA en curso...",
|
"ai_fallback_searching": "Identificación de IA en curso...",
|
||||||
"ai_fallback_found": "Producto identificado por IA",
|
"ai_fallback_found": "Producto identificado por IA",
|
||||||
"ai_fallback_not_found": "IA: producto no reconocido",
|
"ai_fallback_not_found": "IA: producto no reconocido",
|
||||||
"ai_fallback_exhausted": "IA: producto no reconocido — prueba a escanear el código"
|
"ai_fallback_exhausted": "IA: producto no reconocido — prueba a escanear el código",
|
||||||
|
"ai_overlay_msg": "Gemini Vision está analizando el producto...",
|
||||||
|
"ai_retry_btn": "Reintentar con IA",
|
||||||
|
"ai_match_title": "Producto reconocido por IA",
|
||||||
|
"ai_match_subtitle": "Elige un producto ya en despensa o agrega el detectado.",
|
||||||
|
"ai_match_existing": "Posibles coincidencias en despensa",
|
||||||
|
"ai_match_none": "No se encontraron productos similares en despensa.",
|
||||||
|
"ai_match_use_btn": "Usar este",
|
||||||
|
"ai_match_add_btn": "Agregar \"{name}\"",
|
||||||
|
"ai_detected_label": "IA detecto"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "¿Qué quieres hacer?",
|
"title": "¿Qué quieres hacer?",
|
||||||
@@ -1020,6 +1039,7 @@
|
|||||||
"ai_quota": "Cuota de IA agotada. Inténtalo de nuevo en unos minutos.",
|
"ai_quota": "Cuota de IA agotada. Inténtalo de nuevo en unos minutos.",
|
||||||
"barcode_empty": "Introduce un código de barras",
|
"barcode_empty": "Introduce un código de barras",
|
||||||
"barcode_format": "El código de barras solo puede contener números (4-14 dígitos)",
|
"barcode_format": "El código de barras solo puede contener números (4-14 dígitos)",
|
||||||
|
"barcode_checksum": "Suma de comprobación EAN inválida — verifica los dígitos del código",
|
||||||
"min_chars": "Escribe al menos 2 caracteres",
|
"min_chars": "Escribe al menos 2 caracteres",
|
||||||
"not_in_inventory": "Producto no en inventario",
|
"not_in_inventory": "Producto no en inventario",
|
||||||
"appliance_exists": "El electrodoméstico ya existe",
|
"appliance_exists": "El electrodoméstico ya existe",
|
||||||
|
|||||||
+21
-1
@@ -149,6 +149,12 @@
|
|||||||
"banner_anomaly_untracked_detail": "Vous avez <strong>{inv_qty} {unit}</strong> en inventaire, mais les sorties enregistrées dépassent les entrées — le stock initial n'a probablement jamais été ajouté comme transaction « entrée ». Vous pouvez corriger la quantité ou saisir les entrées manquantes.",
|
"banner_anomaly_untracked_detail": "Vous avez <strong>{inv_qty} {unit}</strong> en inventaire, mais les sorties enregistrées dépassent les entrées — le stock initial n'a probablement jamais été ajouté comme transaction « entrée ». Vous pouvez corriger la quantité ou saisir les entrées manquantes.",
|
||||||
"banner_anomaly_ghost_title": "vous avez moins de stock que prévu",
|
"banner_anomaly_ghost_title": "vous avez moins de stock que prévu",
|
||||||
"banner_anomaly_ghost_detail": "D'après les opérations enregistrées vous devriez avoir {expected_qty} {unit} de {name}, mais l'inventaire n'en montre que {inv_qty} {unit}. Avez-vous pris du stock sans l'enregistrer ?",
|
"banner_anomaly_ghost_detail": "D'après les opérations enregistrées vous devriez avoir {expected_qty} {unit} de {name}, mais l'inventaire n'en montre que {inv_qty} {unit}. Avez-vous pris du stock sans l'enregistrer ?",
|
||||||
|
"banner_dup_loss_title": "Vérification double sortie : {name}",
|
||||||
|
"banner_dup_loss_detail": "Doublon possible dans {location} : deux sorties rapprochées ({qty_pair}) en ~{seconds}s. Vérifiez et corrigez si besoin.",
|
||||||
|
"banner_dup_loss_action_fix": "Corriger la quantité",
|
||||||
|
"banner_dup_loss_action_open": "Ouvrir la fiche produit",
|
||||||
|
"banner_dup_loss_action_done": "Déjà vérifié",
|
||||||
|
"banner_dup_loss_toast_done": "Contrôle marqué comme vérifié",
|
||||||
"consumed": "Consommé : {n} ({pct}%)",
|
"consumed": "Consommé : {n} ({pct}%)",
|
||||||
"wasted": "Gaspillé : {n} ({pct}%)",
|
"wasted": "Gaspillé : {n} ({pct}%)",
|
||||||
"more_opened": "et {n} autres ouverts...",
|
"more_opened": "et {n} autres ouverts...",
|
||||||
@@ -218,10 +224,23 @@
|
|||||||
"status_invalid": "Invalide : {code} — nouvel essai",
|
"status_invalid": "Invalide : {code} — nouvel essai",
|
||||||
"status_confirmed": "Confirmé !",
|
"status_confirmed": "Confirmé !",
|
||||||
"status_parallel": "Scan combiné actif...",
|
"status_parallel": "Scan combiné actif...",
|
||||||
|
"status_ocr_searching": "Je lis les chiffres du code-barres...",
|
||||||
|
"status_ai_visual_searching": "J'essaie maintenant de reconnaître le produit...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
"ai_fallback_searching": "Identification IA en cours...",
|
"ai_fallback_searching": "Identification IA en cours...",
|
||||||
"ai_fallback_found": "Produit identifié par l'IA",
|
"ai_fallback_found": "Produit identifié par l'IA",
|
||||||
"ai_fallback_not_found": "IA : produit non reconnu",
|
"ai_fallback_not_found": "IA : produit non reconnu",
|
||||||
"ai_fallback_exhausted": "IA : produit non reconnu — réessayez avec le code-barres"
|
"ai_fallback_exhausted": "IA : produit non reconnu — réessayez avec le code-barres",
|
||||||
|
"ai_overlay_msg": "Gemini Vision analyse le produit...",
|
||||||
|
"ai_retry_btn": "Reessayer avec l'IA",
|
||||||
|
"ai_match_title": "Produit reconnu par l'IA",
|
||||||
|
"ai_match_subtitle": "Choisissez un produit deja en stock ou ajoutez celui detecte.",
|
||||||
|
"ai_match_existing": "Correspondances possibles dans le stock",
|
||||||
|
"ai_match_none": "Aucun produit similaire trouve dans le stock.",
|
||||||
|
"ai_match_use_btn": "Utiliser celui-ci",
|
||||||
|
"ai_match_add_btn": "Ajouter \"{name}\"",
|
||||||
|
"ai_detected_label": "IA a detecte"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "Que voulez-vous faire ?",
|
"title": "Que voulez-vous faire ?",
|
||||||
@@ -1020,6 +1039,7 @@
|
|||||||
"ai_quota": "Quota IA épuisé. Réessayez dans quelques minutes.",
|
"ai_quota": "Quota IA épuisé. Réessayez dans quelques minutes.",
|
||||||
"barcode_empty": "Entrez un code-barres",
|
"barcode_empty": "Entrez un code-barres",
|
||||||
"barcode_format": "Le code-barres ne doit contenir que des chiffres (4-14 chiffres)",
|
"barcode_format": "Le code-barres ne doit contenir que des chiffres (4-14 chiffres)",
|
||||||
|
"barcode_checksum": "Somme de contrôle EAN invalide — vérifiez les chiffres du code-barres",
|
||||||
"min_chars": "Tapez au moins 2 caractères",
|
"min_chars": "Tapez au moins 2 caractères",
|
||||||
"not_in_inventory": "Produit absent de l'inventaire",
|
"not_in_inventory": "Produit absent de l'inventaire",
|
||||||
"appliance_exists": "L'appareil existe déjà",
|
"appliance_exists": "L'appareil existe déjà",
|
||||||
|
|||||||
+21
-1
@@ -151,6 +151,12 @@
|
|||||||
"banner_anomaly_untracked_detail": "Hai <strong>{inv_qty} {unit}</strong> in inventario, ma le uscite registrate superano le entrate — le scorte iniziali probabilmente non sono mai state aggiunte come entrata. Puoi correggere la quantità o registrare le entrate mancanti.",
|
"banner_anomaly_untracked_detail": "Hai <strong>{inv_qty} {unit}</strong> in inventario, ma le uscite registrate superano le entrate — le scorte iniziali probabilmente non sono mai state aggiunte come entrata. Puoi correggere la quantità o registrare le entrate mancanti.",
|
||||||
"banner_anomaly_ghost_title": "hai meno scorte del previsto",
|
"banner_anomaly_ghost_title": "hai meno scorte del previsto",
|
||||||
"banner_anomaly_ghost_detail": "In base alle operazioni registrate dovresti avere {expected_qty} {unit} di {name}, ma l'inventario mostra solo {inv_qty} {unit}. Hai prelevato senza registrarlo?",
|
"banner_anomaly_ghost_detail": "In base alle operazioni registrate dovresti avere {expected_qty} {unit} di {name}, ma l'inventario mostra solo {inv_qty} {unit}. Hai prelevato senza registrarlo?",
|
||||||
|
"banner_dup_loss_title": "Controllo doppio scarico: {name}",
|
||||||
|
"banner_dup_loss_detail": "Possibile doppia registrazione in {location}: due uscite ravvicinate ({qty_pair}) in ~{seconds}s. Verifica se va corretta.",
|
||||||
|
"banner_dup_loss_action_fix": "Correggi quantità",
|
||||||
|
"banner_dup_loss_action_open": "Apri scheda prodotto",
|
||||||
|
"banner_dup_loss_action_done": "Già verificato",
|
||||||
|
"banner_dup_loss_toast_done": "Controllo segnato come verificato",
|
||||||
"consumed": "Consumati: {n} ({pct}%)",
|
"consumed": "Consumati: {n} ({pct}%)",
|
||||||
"wasted": "Buttati: {n} ({pct}%)",
|
"wasted": "Buttati: {n} ({pct}%)",
|
||||||
"more_opened": "e altri {n} prodotti aperti...",
|
"more_opened": "e altri {n} prodotti aperti...",
|
||||||
@@ -221,10 +227,23 @@
|
|||||||
"status_invalid": "Non valido: {code} — riprovo",
|
"status_invalid": "Non valido: {code} — riprovo",
|
||||||
"status_confirmed": "Confermato!",
|
"status_confirmed": "Confermato!",
|
||||||
"status_parallel": "Doppia scansione attiva...",
|
"status_parallel": "Doppia scansione attiva...",
|
||||||
|
"status_ocr_searching": "Sto leggendo i numeri del codice a barre...",
|
||||||
|
"status_ai_visual_searching": "Ora provo a riconoscere il prodotto...",
|
||||||
|
"method_ai_ocr": "Gemini OCR",
|
||||||
|
"method_ai_vision": "Gemini Vision",
|
||||||
"ai_fallback_searching": "Identificazione AI in corso...",
|
"ai_fallback_searching": "Identificazione AI in corso...",
|
||||||
"ai_fallback_found": "Prodotto identificato dall'AI",
|
"ai_fallback_found": "Prodotto identificato dall'AI",
|
||||||
"ai_fallback_not_found": "AI: prodotto non riconosciuto",
|
"ai_fallback_not_found": "AI: prodotto non riconosciuto",
|
||||||
"ai_fallback_exhausted": "AI: prodotto non riconosciuto — riprova con il barcode"
|
"ai_fallback_exhausted": "AI: prodotto non riconosciuto — riprova con il barcode",
|
||||||
|
"ai_overlay_msg": "Gemini Vision sta analizzando il prodotto...",
|
||||||
|
"ai_retry_btn": "Riprova con AI",
|
||||||
|
"ai_match_title": "Prodotto riconosciuto con AI",
|
||||||
|
"ai_match_subtitle": "Scegli se usare un prodotto gia presente oppure aggiungere quello rilevato.",
|
||||||
|
"ai_match_existing": "Possibili corrispondenze in dispensa",
|
||||||
|
"ai_match_none": "Nessun prodotto simile trovato in dispensa.",
|
||||||
|
"ai_match_use_btn": "Usa questo",
|
||||||
|
"ai_match_add_btn": "Aggiungi \"{name}\"",
|
||||||
|
"ai_detected_label": "AI ha trovato"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"title": "Cosa vuoi fare?",
|
"title": "Cosa vuoi fare?",
|
||||||
@@ -1069,6 +1088,7 @@
|
|||||||
"ai_quota": "Quota AI esaurita. Riprova tra qualche minuto.",
|
"ai_quota": "Quota AI esaurita. Riprova tra qualche minuto.",
|
||||||
"barcode_empty": "Inserisci un codice a barre",
|
"barcode_empty": "Inserisci un codice a barre",
|
||||||
"barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)",
|
"barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)",
|
||||||
|
"barcode_checksum": "Checksum EAN non valido — verifica le cifre del codice",
|
||||||
"min_chars": "Scrivi almeno 2 caratteri",
|
"min_chars": "Scrivi almeno 2 caratteri",
|
||||||
"not_in_inventory": "Prodotto non nell'inventario",
|
"not_in_inventory": "Prodotto non nell'inventario",
|
||||||
"appliance_exists": "Elettrodomestico già presente",
|
"appliance_exists": "Elettrodomestico già presente",
|
||||||
|
|||||||
Reference in New Issue
Block a user