Files
Fridger/public/app.js
T
me0nline 3d89aab10e
Build and Push Fridger Container / Build & Push Fridger Image (push) Successful in 1m23s
feat(ui): add dedicated Filters tab with scrollable categories and pack state cards
2026-10-11 00:00:59 +02:00

1063 lines
40 KiB
JavaScript

/**
* Fridger Frontend Application
* Route / : Current Stock Overview
* Route /manage : Tablet Scanner & Manager Station
*/
const state = {
currentRoute: '/', // '/', '/filters', or '/manage'
allItems: [],
items: [],
meta: {
categories: [],
locations: [],
units: []
},
filters: {
search: '',
category: 'All',
status: 'all'
},
manage: {
selectedItem: null,
action: null, // 'add' or 'remove'
qtyMode: 'packet', // 'packet' or 'amount'
packetCount: 1,
amountValue: 0
},
html5QrCode: null,
isScanning: false
};
// Initialize Application & Router
document.addEventListener('DOMContentLoaded', async () => {
await loadMetadata();
await loadStats();
await loadItems();
// Handle initial browser route
handleInitialRoute();
window.addEventListener('popstate', () => {
handleInitialRoute();
});
initLucide();
});
function initLucide() {
if (window.lucide) {
window.lucide.createIcons();
}
}
// ----------------------------------------------------
// Client-Side Router
// ----------------------------------------------------
function handleInitialRoute() {
const path = window.location.pathname;
if (path === '/manage' || path.startsWith('/manage')) {
setRoute('/manage');
} else if (path === '/filters' || path.startsWith('/filters')) {
setRoute('/filters');
} else {
setRoute('/');
}
}
function navigateTo(path) {
window.history.pushState({}, '', path);
setRoute(path);
}
function setRoute(path) {
if (path === '/manage' || path.startsWith('/manage')) {
state.currentRoute = '/manage';
} else if (path === '/filters' || path.startsWith('/filters')) {
state.currentRoute = '/filters';
} else {
state.currentRoute = '/';
}
const stockBtn = document.getElementById('nav-stock-btn');
const filtersBtn = document.getElementById('nav-filters-btn');
const manageBtn = document.getElementById('nav-manage-btn');
const stockPage = document.getElementById('stock-view-page');
const filtersPage = document.getElementById('filters-view-page');
const managePage = document.getElementById('manage-view-page');
const subTitle = document.getElementById('page-subtitle');
const activeClass = 'flex items-center justify-center gap-1.5 sm:gap-2 px-3 sm:px-5 py-2.5 sm:py-3 rounded-xl text-xs sm:text-sm font-bold transition-all bg-teal-500 text-white shadow-md active:scale-95 touch-manipulation min-h-[44px]';
const inactiveClass = 'relative flex items-center justify-center gap-1.5 sm:gap-2 px-3 sm:px-5 py-2.5 sm:py-3 rounded-xl text-xs sm:text-sm font-bold transition-all text-slate-300 hover:text-white hover:bg-slate-800/80 active:scale-95 touch-manipulation min-h-[44px]';
// Hide all pages first
stockPage.classList.add('hidden');
filtersPage.classList.add('hidden');
managePage.classList.add('hidden');
stockBtn.className = inactiveClass;
filtersBtn.className = inactiveClass;
manageBtn.className = inactiveClass;
if (state.currentRoute === '/') {
stockBtn.className = activeClass;
stockPage.classList.remove('hidden');
subTitle.textContent = 'Current Stock';
renderStockView();
} else if (state.currentRoute === '/filters') {
filtersBtn.className = activeClass;
filtersPage.classList.remove('hidden');
subTitle.textContent = 'Filter Stock';
renderFilterTabView();
} else {
manageBtn.className = activeClass;
managePage.classList.remove('hidden');
subTitle.textContent = 'Manager';
resetTabletManageStation();
renderTabletProductPicker('');
}
updateFilterUIState();
initLucide();
}
// ----------------------------------------------------
// API Calls
// ----------------------------------------------------
async function loadMetadata() {
try {
const res = await fetch('/api/meta');
if (res.ok) {
state.meta = await res.json();
renderCategoryFilterCards();
populateModalSelects();
}
} catch (err) {
console.error('Failed to load metadata:', err);
}
}
async function loadStats() {
try {
const res = await fetch('/api/stats');
if (res.ok) {
const stats = await res.json();
document.getElementById('stat-total').textContent = stats.total || 0;
document.getElementById('stat-full').textContent = stats.full || 0;
document.getElementById('stat-opened').textContent = stats.opened || 0;
document.getElementById('stat-low').textContent = stats.low || 0;
}
} catch (err) {
console.error('Failed to load stats:', err);
}
}
async function loadItems() {
try {
// Fetch all items to accurately compute category & status counts
const resAll = await fetch('/api/items');
if (resAll.ok) {
state.allItems = await resAll.json();
}
const params = new URLSearchParams();
if (state.filters.search) params.append('search', state.filters.search);
if (state.filters.category && state.filters.category !== 'All') params.append('category', state.filters.category);
if (state.filters.status && state.filters.status !== 'all') params.append('status', state.filters.status);
const res = await fetch(`/api/items?${params.toString()}`);
if (res.ok) {
state.items = await res.json();
if (state.currentRoute === '/') {
renderStockView();
} else if (state.currentRoute === '/filters') {
renderFilterTabView();
} else {
const query = document.getElementById('tablet-search-input')?.value || '';
renderTabletProductPicker(query);
}
}
updateFilterUIState();
} catch (err) {
console.error('Failed to load inventory:', err);
showToast('Failed to load inventory', 'error');
}
}
// ----------------------------------------------------
// ROUTE 1: / (CURRENT STOCK DISPLAY)
// ----------------------------------------------------
let searchDebounceTimer = null;
function handleSearch(val) {
state.filters.search = val.trim();
const clearBtn = document.getElementById('clear-search');
if (state.filters.search) {
clearBtn.classList.remove('hidden');
} else {
clearBtn.classList.add('hidden');
}
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
loadItems();
}, 200);
}
function clearSearch() {
document.getElementById('search-input').value = '';
document.getElementById('clear-search').classList.add('hidden');
state.filters.search = '';
loadItems();
}
// ----------------------------------------------------
// ROUTE 2: /filters (DEDICATED FILTERS TAB)
// ----------------------------------------------------
function setStatusFilter(status) {
state.filters.status = status;
updateFilterUIState();
loadItems();
}
function setCategoryFilter(category) {
state.filters.category = category;
updateFilterUIState();
loadItems();
}
function resetAllFilters() {
state.filters.category = 'All';
state.filters.status = 'all';
state.filters.search = '';
const searchInput = document.getElementById('search-input');
if (searchInput) searchInput.value = '';
const clearBtn = document.getElementById('clear-search');
if (clearBtn) clearBtn.classList.add('hidden');
updateFilterUIState();
loadItems();
showToast('Filters reset to All', 'info');
}
function renderCategoryFilterCards() {
const container = document.getElementById('filter-categories-container');
if (!container || !state.meta.categories) return;
let html = `
<!-- All Categories -->
<button
onclick="setCategoryFilter('All')"
data-filter-cat="All"
class="filter-cat-card p-3 sm:p-4 rounded-2xl border text-left flex items-center gap-3 transition-all active:scale-95 touch-manipulation min-h-[64px] bg-slate-900 border-slate-800 text-slate-300 hover:border-slate-700"
>
<div class="w-10 h-10 rounded-xl bg-teal-500/20 text-teal-300 flex items-center justify-center shrink-0">
<i data-lucide="layout-grid" class="w-5 h-5"></i>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-1">
<h4 class="text-xs sm:text-sm font-extrabold text-white truncate">All Categories</h4>
<span id="cat-count-all" class="text-[11px] font-bold px-1.5 py-0.5 rounded-md bg-slate-800 text-slate-400">0</span>
</div>
<p class="text-[10px] text-slate-400 truncate">View everything</p>
</div>
</button>
`;
state.meta.categories.forEach(cat => {
html += `
<button
onclick="setCategoryFilter('${escapeHtml(cat.id)}')"
data-filter-cat="${escapeHtml(cat.id)}"
class="filter-cat-card p-3 sm:p-4 rounded-2xl border text-left flex items-center gap-3 transition-all active:scale-95 touch-manipulation min-h-[64px] bg-slate-900 border-slate-800 text-slate-300 hover:border-slate-700"
>
<div class="w-10 h-10 rounded-xl bg-slate-800 text-teal-400 flex items-center justify-center shrink-0">
<i data-lucide="${escapeHtml(cat.icon || 'package')}" class="w-5 h-5"></i>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-1">
<h4 class="text-xs sm:text-sm font-extrabold text-white truncate">${escapeHtml(cat.name)}</h4>
<span id="cat-count-${escapeHtml(cat.id.replace(/[^a-zA-Z0-9]/g, ''))}" class="text-[11px] font-bold px-1.5 py-0.5 rounded-md bg-slate-800 text-slate-400">0</span>
</div>
<p class="text-[10px] text-slate-400 truncate">${escapeHtml(cat.id)}</p>
</div>
</button>
`;
});
container.innerHTML = html;
initLucide();
}
function renderFilterTabView() {
updateFilterUIState();
}
function updateFilterUIState() {
// 1. Update Pack State cards highlight & counts
const allItems = state.allItems || [];
const fullCount = allItems.filter(i => !i.is_opened && i.quantity >= i.initial_quantity).length;
const openedCount = allItems.filter(i => i.is_opened || i.quantity < i.initial_quantity).length;
const lowCount = allItems.filter(i => i.quantity <= (i.initial_quantity * 0.25)).length;
const countAllEl = document.getElementById('filter-count-all');
const countFullEl = document.getElementById('filter-count-full');
const countOpenedEl = document.getElementById('filter-count-opened');
const countLowEl = document.getElementById('filter-count-low');
const countTotalEl = document.getElementById('filter-total-count');
if (countAllEl) countAllEl.textContent = allItems.length;
if (countFullEl) countFullEl.textContent = fullCount;
if (countOpenedEl) countOpenedEl.textContent = openedCount;
if (countLowEl) countLowEl.textContent = lowCount;
if (countTotalEl) countTotalEl.textContent = state.items.length;
// Highlight status cards
document.querySelectorAll('[data-filter-status]').forEach(card => {
const status = card.getAttribute('data-filter-status');
const isSelected = state.filters.status === status;
if (isSelected) {
card.className = 'filter-status-card flex-1 min-w-[140px] sm:min-w-[160px] p-4 rounded-2xl border-2 text-left flex flex-col justify-between space-y-3 transition-all active:scale-95 touch-manipulation bg-teal-500/20 border-teal-500 text-white shadow-lg shadow-teal-500/20 ring-2 ring-teal-500/30';
} else {
card.className = 'filter-status-card flex-1 min-w-[140px] sm:min-w-[160px] p-4 rounded-2xl border text-left flex flex-col justify-between space-y-3 transition-all active:scale-95 touch-manipulation bg-slate-900 border-slate-800 text-slate-300 hover:border-slate-700';
}
});
// 2. Update Category cards counts & highlight
const catAllCountEl = document.getElementById('cat-count-all');
if (catAllCountEl) catAllCountEl.textContent = allItems.length;
if (state.meta.categories) {
state.meta.categories.forEach(cat => {
const sanitized = cat.id.replace(/[^a-zA-Z0-9]/g, '');
const countEl = document.getElementById(`cat-count-${sanitized}`);
if (countEl) {
const c = allItems.filter(i => i.category === cat.id).length;
countEl.textContent = c;
}
});
}
document.querySelectorAll('[data-filter-cat]').forEach(card => {
const cat = card.getAttribute('data-filter-cat');
const isSelected = state.filters.category === cat;
if (isSelected) {
card.className = 'filter-cat-card p-3 sm:p-4 rounded-2xl border-2 text-left flex items-center gap-3 transition-all active:scale-95 touch-manipulation min-h-[64px] bg-teal-500/20 border-teal-500 text-white shadow-lg shadow-teal-500/20 ring-2 ring-teal-500/30';
} else {
card.className = 'filter-cat-card p-3 sm:p-4 rounded-2xl border text-left flex items-center gap-3 transition-all active:scale-95 touch-manipulation min-h-[64px] bg-slate-900 border-slate-800 text-slate-300 hover:border-slate-700';
}
});
// 3. Top Navigation Badge
const navBadge = document.getElementById('nav-filter-badge');
let activeFilterCount = 0;
if (state.filters.category !== 'All') activeFilterCount++;
if (state.filters.status !== 'all') activeFilterCount++;
if (navBadge) {
if (activeFilterCount > 0) {
navBadge.textContent = activeFilterCount;
navBadge.classList.remove('hidden');
} else {
navBadge.classList.add('hidden');
}
}
// 4. Stock page active filter banner
const banner = document.getElementById('active-filter-indicator');
const bannerText = document.getElementById('active-filter-text');
if (banner && bannerText) {
if (activeFilterCount > 0) {
const parts = [];
if (state.filters.category !== 'All') parts.push(`Category: ${state.filters.category}`);
if (state.filters.status !== 'all') {
const labels = { full: 'Full Packs', opened: 'Opened / In-Use', low: 'Low Stock' };
parts.push(`Status: ${labels[state.filters.status] || state.filters.status}`);
}
bannerText.textContent = `Filtered by: ${parts.join(' • ')} (${state.items.length} items)`;
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
}
}
}
function renderStockView() {
const container = document.getElementById('stock-grid');
const emptyState = document.getElementById('empty-stock');
if (!container) return;
if (state.items.length === 0) {
emptyState.classList.remove('hidden');
container.innerHTML = '';
return;
}
emptyState.classList.add('hidden');
container.innerHTML = state.items.map(item => {
const isOpened = item.is_opened || item.quantity < item.initial_quantity;
const percent = item.initial_quantity > 0
? Math.min(100, Math.max(0, Math.round((item.quantity / item.initial_quantity) * 100)))
: 100;
let progressBarColor = 'bg-teal-500';
if (percent <= 20) progressBarColor = 'bg-rose-500';
else if (percent <= 50) progressBarColor = 'bg-amber-500';
return `
<div class="shelf-card rounded-2xl sm:rounded-3xl p-4 sm:p-5 flex flex-col justify-between space-y-3.5">
<div>
<!-- Header: Brand & Status -->
<div class="flex items-center justify-between gap-2 mb-2">
<span class="text-xs font-bold text-teal-400 bg-teal-500/10 px-2.5 py-1 rounded-lg truncate max-w-[140px]">
${escapeHtml(item.brand || item.category)}
</span>
<span class="text-xs font-bold px-2.5 py-1 rounded-lg flex items-center gap-1.5 shrink-0 ${isOpened ? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30' : 'bg-emerald-500/15 text-emerald-300 border border-emerald-500/20'}">
<span>${isOpened ? 'Opened' : 'Full Pack'}</span>
</span>
</div>
<!-- Product Details & Image -->
<div class="flex items-center gap-3.5 my-2">
${item.image_url ? `
<img src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.name)}" class="w-14 h-14 object-contain rounded-2xl bg-white/5 p-1 border border-slate-700/60 shrink-0">
` : `
<div class="w-14 h-14 rounded-2xl bg-slate-800/80 border border-slate-700 text-teal-400 flex items-center justify-center shrink-0">
<i data-lucide="${getCategoryIcon(item.category)}" class="w-7 h-7"></i>
</div>
`}
<div class="min-w-0 flex-1">
<h4 class="font-bold text-white text-base leading-snug line-clamp-2">${escapeHtml(item.name)}</h4>
${item.notes ? `<p class="text-xs text-slate-400 italic mt-0.5 truncate">${escapeHtml(item.notes)}</p>` : ''}
</div>
</div>
<!-- Stock & Grams Indicator -->
<div class="mt-3 bg-slate-950/80 rounded-2xl p-3 border border-slate-800/80">
<div class="flex items-center justify-between text-xs sm:text-sm mb-1.5">
<span class="font-bold text-white flex items-center gap-1">
<span class="text-lg font-black text-teal-300">${item.quantity}</span>
<span class="text-slate-400 font-normal">${item.unit}</span>
</span>
<span class="text-xs text-slate-400">
Pack: ${item.initial_quantity} ${item.unit} (${percent}%)
</span>
</div>
<div class="w-full bg-slate-800 h-2 rounded-full overflow-hidden">
<div class="${progressBarColor} h-full rounded-full transition-all duration-300" style="width: ${percent}%;"></div>
</div>
</div>
</div>
</div>
`;
}).join('');
initLucide();
}
// ----------------------------------------------------
// ----------------------------------------------------
// ROUTE 2: /manage (MANAGER WORKFLOW)
// ----------------------------------------------------
function resetTabletManageStation() {
state.manage.selectedItem = null;
state.manage.action = null;
document.getElementById('tablet-step-select').classList.remove('hidden');
document.getElementById('tablet-step-action').classList.add('hidden');
const searchInput = document.getElementById('tablet-search-input');
if (searchInput) searchInput.value = '';
const resultsContainer = document.getElementById('tablet-search-results-container');
if (resultsContainer) resultsContainer.classList.add('hidden');
const picker = document.getElementById('tablet-product-picker');
if (picker) picker.innerHTML = '';
}
function handleTabletSearch(val) {
renderTabletProductPicker(val);
const clean = val.trim();
if (/^\d{8,14}$/.test(clean)) {
const found = state.items.find(i => i.barcode === clean);
if (found) {
forwardToProductAction(found);
}
}
}
function renderTabletProductPicker(filterQuery = '') {
const picker = document.getElementById('tablet-product-picker');
const resultsContainer = document.getElementById('tablet-search-results-container');
if (!picker || !resultsContainer) return;
const query = filterQuery.toLowerCase().trim();
// Only display search results if user has entered a search query
if (!query) {
resultsContainer.classList.add('hidden');
picker.innerHTML = '';
return;
}
resultsContainer.classList.remove('hidden');
const matched = state.items.filter(i => {
return (i.name && i.name.toLowerCase().includes(query)) ||
(i.brand && i.brand.toLowerCase().includes(query)) ||
(i.barcode && i.barcode.includes(query));
});
if (matched.length === 0) {
picker.innerHTML = `
<div class="col-span-full py-4 text-center text-xs sm:text-sm text-slate-400">
No matching product found. Use "Scan Barcode" or tap "Add Manually".
</div>
`;
return;
}
picker.innerHTML = matched.map(item => `
<button
onclick="forwardToProductActionById(${item.id})"
class="p-3 rounded-2xl bg-slate-950 hover:bg-slate-800/80 border border-slate-800/90 text-left flex items-center gap-3 transition active:scale-95 touch-manipulation min-h-[52px]"
>
${item.image_url ? `
<img src="${escapeHtml(item.image_url)}" class="w-9 h-9 object-contain rounded-xl bg-white/5 p-0.5 shrink-0">
` : `
<div class="w-9 h-9 rounded-xl bg-slate-800 text-teal-400 flex items-center justify-center shrink-0">
<i data-lucide="${getCategoryIcon(item.category)}" class="w-4 h-4"></i>
</div>
`}
<div class="min-w-0 flex-1">
<h5 class="text-xs sm:text-sm font-bold text-white truncate">${escapeHtml(item.name)}</h5>
<div class="text-[11px] text-teal-300/90 font-medium">
${item.quantity} ${item.unit} <span class="text-slate-500 font-normal">(${item.initial_quantity}${item.unit} pack)</span>
</div>
</div>
</button>
`).join('');
initLucide();
}
function forwardToProductActionById(id) {
const item = state.items.find(i => i.id === id);
if (item) forwardToProductAction(item);
}
// Forwards to the action screen on /manage
function forwardToProductAction(item) {
state.manage.selectedItem = item;
state.manage.action = null; // reset action
state.manage.packetCount = 1;
state.manage.amountValue = item.quantity;
// Populate header banner
document.getElementById('action-product-name').textContent = item.name;
document.getElementById('action-product-sub').textContent = `Current Stock: ${item.quantity} ${item.unit} • 1 Full Pack = ${item.initial_quantity} ${item.unit}`;
document.getElementById('action-pack-size-label').textContent = `${item.initial_quantity} ${item.unit}`;
document.getElementById('action-amount-unit').textContent = item.unit;
document.getElementById('action-amount-val').textContent = item.quantity;
const slider = document.getElementById('action-amount-slider');
slider.max = Math.max(item.initial_quantity * 2, item.quantity + item.initial_quantity, 100);
slider.value = item.quantity;
slider.step = (item.unit === 'g' || item.unit === 'ml') ? '5' : '1';
const imgBox = document.getElementById('action-product-img');
if (item.image_url) {
imgBox.innerHTML = `<img src="${escapeHtml(item.image_url)}" class="w-full h-full object-contain p-1">`;
} else {
imgBox.innerHTML = `<i data-lucide="${getCategoryIcon(item.category)}" class="w-7 h-7 text-teal-400"></i>`;
}
// Switch view state
document.getElementById('tablet-step-select').classList.add('hidden');
document.getElementById('tablet-step-action').classList.remove('hidden');
document.getElementById('action-qty-section').classList.add('hidden');
resetActionButtonsStyle();
initLucide();
document.getElementById('tablet-step-action').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function cancelActionAndReturnToManage() {
resetTabletManageStation();
}
function setActionType(action) {
state.manage.action = action; // 'add' or 'remove'
const btnAdd = document.getElementById('action-btn-add');
const btnRemove = document.getElementById('action-btn-remove');
const qtySection = document.getElementById('action-qty-section');
const confirmBtn = document.getElementById('action-confirm-btn');
const confirmLabel = document.getElementById('action-confirm-label');
if (action === 'add') {
btnAdd.className = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[84px] bg-emerald-500/20 border-emerald-500 text-white shadow-lg shadow-emerald-500/20 ring-2 ring-emerald-500/40';
btnRemove.className = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[84px] bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-700';
confirmBtn.className = 'flex-1 py-4 rounded-2xl bg-emerald-500 hover:bg-emerald-400 text-white font-black text-base sm:text-lg shadow-xl shadow-emerald-500/25 active:scale-95 transition-all touch-manipulation flex items-center justify-center gap-2';
confirmLabel.textContent = `Confirm Add & Done`;
} else {
btnRemove.className = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[84px] bg-rose-500/20 border-rose-500 text-white shadow-lg shadow-rose-500/20 ring-2 ring-rose-500/40';
btnAdd.className = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[84px] bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-700';
confirmBtn.className = 'flex-1 py-4 rounded-2xl bg-rose-500 hover:bg-rose-400 text-white font-black text-base sm:text-lg shadow-xl shadow-rose-500/25 active:scale-95 transition-all touch-manipulation flex items-center justify-center gap-2';
confirmLabel.textContent = `Confirm Remove & Done`;
}
qtySection.classList.remove('hidden');
initLucide();
}
function resetActionButtonsStyle() {
const btnAdd = document.getElementById('action-btn-add');
const btnRemove = document.getElementById('action-btn-remove');
const def = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[84px] bg-slate-950 border-slate-800 text-slate-300 hover:border-slate-700';
btnAdd.className = def;
btnRemove.className = def;
}
function setActionQtyMode(mode) {
state.manage.qtyMode = mode;
const btnPack = document.getElementById('btn-mode-packet');
const btnAmount = document.getElementById('btn-mode-amount');
const packControls = document.getElementById('controls-packet');
const amountControls = document.getElementById('controls-amount');
const active = 'py-3 px-3 rounded-xl font-bold text-xs sm:text-sm transition bg-teal-500 text-white shadow-md touch-manipulation flex items-center justify-center gap-2';
const inactive = 'py-3 px-3 rounded-xl font-bold text-xs sm:text-sm transition text-slate-300 hover:text-white touch-manipulation flex items-center justify-center gap-2';
if (mode === 'packet') {
btnPack.className = active;
btnAmount.className = inactive;
packControls.classList.remove('hidden');
amountControls.classList.add('hidden');
} else {
btnAmount.className = active;
btnPack.className = inactive;
amountControls.classList.remove('hidden');
packControls.classList.add('hidden');
}
initLucide();
}
function changeActionPackCount(delta) {
state.manage.packetCount = Math.max(1, state.manage.packetCount + delta);
document.getElementById('action-pack-count-val').textContent = state.manage.packetCount;
}
function setActionPackCount(num) {
state.manage.packetCount = num;
document.getElementById('action-pack-count-val').textContent = num;
}
function onActionAmountSlider(val) {
state.manage.amountValue = parseFloat(val);
document.getElementById('action-amount-val').textContent = state.manage.amountValue;
}
function applyActionDelta(delta) {
state.manage.amountValue = Math.max(0, state.manage.amountValue + delta);
document.getElementById('action-amount-slider').value = state.manage.amountValue;
document.getElementById('action-amount-val').textContent = state.manage.amountValue;
}
function setActionAmountRatio(ratio) {
if (!state.manage.selectedItem) return;
state.manage.amountValue = Math.round(state.manage.selectedItem.initial_quantity * ratio);
document.getElementById('action-amount-slider').value = state.manage.amountValue;
document.getElementById('action-amount-val').textContent = state.manage.amountValue;
}
// Confirm & finish execution -> Returns right back to /manage ready for next scan!
async function confirmAndFinishAction() {
const item = state.manage.selectedItem;
const action = state.manage.action;
const mode = state.manage.qtyMode;
if (!item || !action) {
showToast('Please select Add or Remove action first', 'error');
return;
}
try {
if (mode === 'packet') {
const endpoint = action === 'add' ? `/api/items/${item.id}/add-packet` : `/api/items/${item.id}/remove-packet`;
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count: state.manage.packetCount })
});
if (res.ok) {
const updated = await res.json();
playBeepSound();
if (updated.deleted || updated.is_empty || updated.quantity <= 0) {
showToast(`✓ Removed empty ${updated.name} from stock`, 'success');
} else {
showToast(`✓ ${action === 'add' ? 'Added' : 'Removed'} ${state.manage.packetCount} pack(s) of ${updated.name}`, 'success');
}
// Return to manager station
resetTabletManageStation();
await loadStats();
await loadItems();
} else {
showToast('Failed to update packet', 'error');
}
} else {
// Amount / Grams mode
const newQty = state.manage.amountValue;
const res = await fetch(`/api/items/${item.id}/quantity`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newQuantity: newQty, markOpened: true })
});
if (res.ok) {
const updated = await res.json();
playBeepSound();
if (updated.deleted || updated.is_empty || updated.quantity <= 0) {
showToast(`✓ Finished ${updated.name} (0 ${updated.unit}): removed from stock`, 'success');
} else {
showToast(`✓ Set ${updated.name} to ${updated.quantity} ${updated.unit}`, 'success');
}
// Return to manager station
resetTabletManageStation();
await loadStats();
await loadItems();
} else {
showToast('Failed to update amount', 'error');
}
}
} catch (err) {
console.error('Action error:', err);
showToast('Network error while updating item', 'error');
}
}
// ----------------------------------------------------
// Add Product Manually Modal
// ----------------------------------------------------
function openItemModal(itemId = null) {
const form = document.getElementById('item-form');
form.reset();
document.getElementById('modal-image-container').classList.add('hidden');
document.getElementById('modal-image-url').value = '';
if (itemId) {
const item = state.items.find(i => i.id === itemId);
if (!item) return;
document.getElementById('modal-item-id').value = item.id;
document.getElementById('item-modal-title').textContent = 'Edit Product';
document.getElementById('modal-barcode').value = item.barcode || '';
document.getElementById('modal-name').value = item.name || '';
document.getElementById('modal-brand').value = item.brand || '';
document.getElementById('modal-quantity').value = item.quantity;
document.getElementById('modal-initial-quantity').value = item.initial_quantity || item.quantity;
document.getElementById('modal-unit').value = item.unit || 'g';
document.getElementById('modal-category').value = item.category || 'General';
document.getElementById('modal-notes').value = item.notes || '';
if (item.image_url) {
document.getElementById('modal-image-url').value = item.image_url;
document.getElementById('modal-image-preview').src = item.image_url;
document.getElementById('modal-image-label').textContent = item.name;
document.getElementById('modal-image-container').classList.remove('hidden');
}
} else {
document.getElementById('modal-item-id').value = '';
document.getElementById('item-modal-title').textContent = 'Add Product Manually';
document.getElementById('modal-unit').value = 'g';
document.getElementById('modal-quantity').value = '';
document.getElementById('modal-initial-quantity').value = '';
}
document.getElementById('item-modal').classList.remove('hidden');
initLucide();
}
function closeItemModal() {
document.getElementById('item-modal').classList.add('hidden');
}
async function lookupCurrentBarcode() {
const code = document.getElementById('modal-barcode').value.trim();
if (!code) {
showToast('Please enter a barcode number first', 'error');
return;
}
const btn = document.getElementById('modal-barcode-lookup-btn');
btn.textContent = 'Searching...';
btn.disabled = true;
try {
const res = await fetch(`/api/barcode/${encodeURIComponent(code)}`);
if (res.ok) {
const data = await res.json();
applyProductDataToForm(data);
showToast(`Found: ${data.name}`, 'success');
} else {
showToast('Product not found in Open Food Facts', 'info');
}
} catch (err) {
console.error(err);
showToast('Barcode lookup failed', 'error');
} finally {
btn.textContent = 'Lookup';
btn.disabled = false;
}
}
function applyProductDataToForm(data) {
if (data.name) document.getElementById('modal-name').value = data.name;
if (data.brand) document.getElementById('modal-brand').value = data.brand;
if (data.quantity) {
document.getElementById('modal-quantity').value = data.quantity;
document.getElementById('modal-initial-quantity').value = data.quantity;
}
if (data.unit) document.getElementById('modal-unit').value = data.unit;
if (data.category) document.getElementById('modal-category').value = data.category;
if (data.barcode) document.getElementById('modal-barcode').value = data.barcode;
if (data.image_url) {
document.getElementById('modal-image-url').value = data.image_url;
document.getElementById('modal-image-preview').src = data.image_url;
document.getElementById('modal-image-label').textContent = data.name;
document.getElementById('modal-image-container').classList.remove('hidden');
}
}
async function handleItemFormSubmit(event) {
event.preventDefault();
const itemId = document.getElementById('modal-item-id').value;
const qty = parseFloat(document.getElementById('modal-quantity').value);
const initQty = parseFloat(document.getElementById('modal-initial-quantity').value) || qty;
const payload = {
name: document.getElementById('modal-name').value.trim(),
brand: document.getElementById('modal-brand').value.trim(),
barcode: document.getElementById('modal-barcode').value.trim(),
quantity: qty,
initial_quantity: initQty,
unit: document.getElementById('modal-unit').value,
category: document.getElementById('modal-category').value,
notes: document.getElementById('modal-notes').value.trim(),
image_url: document.getElementById('modal-image-url').value || null
};
try {
const url = itemId ? `/api/items/${itemId}` : '/api/items';
const method = itemId ? 'PUT' : 'POST';
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
const saved = await res.json();
showToast(itemId ? 'Product updated' : 'Product added', 'success');
closeItemModal();
await loadStats();
await loadItems();
// Forward directly to action screen for this new product on /manage!
setRoute('/manage');
forwardToProductAction(saved);
} else {
const err = await res.json();
showToast(err.error || 'Failed to save product', 'error');
}
} catch (err) {
console.error(err);
showToast('Network error while saving product', 'error');
}
}
function populateModalSelects() {
const catSelect = document.getElementById('modal-category');
if (catSelect && state.meta.categories) {
catSelect.innerHTML = state.meta.categories.map(c => `
<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>
`).join('');
}
}
// ----------------------------------------------------
// Tablet Live Camera Barcode Scanner
// ----------------------------------------------------
function openScannerModal() {
document.getElementById('scanner-modal').classList.remove('hidden');
document.getElementById('scanner-manual-input').value = '';
document.getElementById('scanner-status').textContent = 'Initializing camera...';
startCameraScanner();
initLucide();
}
function closeScannerModal() {
stopCameraScanner();
document.getElementById('scanner-modal').classList.add('hidden');
}
async function startCameraScanner() {
if (!window.Html5Qrcode) {
document.getElementById('scanner-status').textContent = 'Scanner library loading...';
return;
}
try {
if (state.html5QrCode) {
await state.html5QrCode.stop().catch(() => {});
}
state.html5QrCode = new Html5Qrcode("barcode-reader");
const config = {
fps: 10,
qrbox: { width: 260, height: 160 },
aspectRatio: 1.333
};
await state.html5QrCode.start(
{ facingMode: "environment" },
config,
onBarcodeScannedSuccess,
onBarcodeScanError
);
state.isScanning = true;
document.getElementById('scanner-status').textContent = 'Point tablet camera at barcode.';
} catch (err) {
console.warn('Camera start error:', err);
document.getElementById('scanner-status').textContent = 'Camera unavailable. Please enter barcode number below.';
}
}
async function stopCameraScanner() {
if (state.html5QrCode && state.isScanning) {
try {
await state.html5QrCode.stop();
state.isScanning = false;
} catch (e) {
console.warn(e);
}
}
}
async function onBarcodeScannedSuccess(decodedText) {
playBeepSound();
stopCameraScanner();
closeScannerModal();
// If item exists in stock -> forward directly to product action screen!
const existing = state.items.find(i => i.barcode === decodedText);
if (existing) {
setRoute('/manage');
forwardToProductAction(existing);
showToast(`Scanned: ${existing.name}`, 'success');
return;
}
// Not yet in stock -> lookup in Open Food Facts and open modal to confirm
try {
const res = await fetch(`/api/barcode/${encodeURIComponent(decodedText)}`);
if (res.ok) {
const data = await res.json();
openItemModal();
applyProductDataToForm(data);
showToast(`Scanned: ${data.name}. Click Save to add to stock!`, 'success');
} else {
openItemModal();
document.getElementById('modal-barcode').value = decodedText;
showToast(`Scanned barcode: ${decodedText}. Enter product name.`, 'info');
}
} catch (err) {
console.error(err);
openItemModal();
document.getElementById('modal-barcode').value = decodedText;
}
}
function onBarcodeScanError() {}
function handleManualBarcodeSubmit() {
const code = document.getElementById('scanner-manual-input').value.trim();
if (!code) return;
onBarcodeScannedSuccess(code);
}
function playBeepSound() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(880, ctx.currentTime);
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.15);
} catch (e) {}
}
function getCategoryIcon(category) {
const cat = state.meta.categories.find(c => c.id === category);
return cat ? cat.icon : 'package';
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
const colors = {
success: 'bg-teal-600 text-white border-teal-500',
error: 'bg-rose-600 text-white border-rose-500',
info: 'bg-slate-800 text-slate-100 border-slate-700'
};
toast.className = `p-3.5 rounded-2xl border shadow-2xl text-xs sm:text-sm font-semibold flex items-center gap-2 transform transition-all duration-300 translate-y-2 opacity-0 pointer-events-auto ${colors[type] || colors.info}`;
toast.innerHTML = `<span>${escapeHtml(message)}</span>`;
container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.remove('translate-y-2', 'opacity-0');
});
setTimeout(() => {
toast.classList.add('translate-y-2', 'opacity-0');
setTimeout(() => toast.remove(), 300);
}, 3000);
}