feat(ui): simplify to 3-button manage layout and remove drawer grouping
Build and Push Fridger Container / Build & Push Fridger Image (push) Successful in 1m36s
Build and Push Fridger Container / Build & Push Fridger Image (push) Successful in 1m36s
This commit is contained in:
@@ -266,6 +266,66 @@ function adjustQuantity(id, { newQuantity, delta, markOpened }) {
|
|||||||
return getItemById(id);
|
return getItemById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addPacket(id, count = 1) {
|
||||||
|
const item = getItemById(id);
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
const packSize = item.initial_quantity > 0 ? item.initial_quantity : 1;
|
||||||
|
const addedAmount = packSize * count;
|
||||||
|
const newQty = item.quantity + addedAmount;
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE items SET
|
||||||
|
quantity = ?,
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
stmt.run(newQty, id);
|
||||||
|
|
||||||
|
logActivity({
|
||||||
|
item_id: id,
|
||||||
|
item_name: item.name,
|
||||||
|
action: 'add_packet',
|
||||||
|
amount_change: addedAmount,
|
||||||
|
unit: item.unit,
|
||||||
|
note: `Added ${count} packet(s) (+${addedAmount} ${item.unit})`
|
||||||
|
});
|
||||||
|
|
||||||
|
return getItemById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePacket(id, count = 1) {
|
||||||
|
const item = getItemById(id);
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
const packSize = item.initial_quantity > 0 ? item.initial_quantity : 1;
|
||||||
|
const removeAmount = packSize * count;
|
||||||
|
let newQty = item.quantity - removeAmount;
|
||||||
|
|
||||||
|
if (newQty <= 0) {
|
||||||
|
newQty = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE items SET
|
||||||
|
quantity = ?,
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
stmt.run(newQty, id);
|
||||||
|
|
||||||
|
logActivity({
|
||||||
|
item_id: id,
|
||||||
|
item_name: item.name,
|
||||||
|
action: 'remove_packet',
|
||||||
|
amount_change: -removeAmount,
|
||||||
|
unit: item.unit,
|
||||||
|
note: `Removed ${count} packet(s) (-${removeAmount} ${item.unit})`
|
||||||
|
});
|
||||||
|
|
||||||
|
return getItemById(id);
|
||||||
|
}
|
||||||
|
|
||||||
function deleteItem(id) {
|
function deleteItem(id) {
|
||||||
const item = getItemById(id);
|
const item = getItemById(id);
|
||||||
if (!item) return false;
|
if (!item) return false;
|
||||||
@@ -347,6 +407,8 @@ module.exports = {
|
|||||||
createItem,
|
createItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
adjustQuantity,
|
adjustQuantity,
|
||||||
|
addPacket,
|
||||||
|
removePacket,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
getActivityLogs,
|
getActivityLogs,
|
||||||
getStats
|
getStats
|
||||||
|
|||||||
+206
-329
@@ -1,12 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Fridger Frontend Application
|
* Fridger Frontend Application
|
||||||
* Handles dual view/manage modes, live barcode scanning, grams adjustment, and item management.
|
* Simplified UI: Fridge View (drawer-free) and 3 primary actions: Manage Grams, Add Pack, Remove Pack.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Application State
|
|
||||||
const state = {
|
const state = {
|
||||||
currentMode: 'view', // 'view' or 'manage'
|
currentMode: 'view', // 'view' or 'manage'
|
||||||
viewGrouping: 'shelf', // 'shelf', 'category', 'grid'
|
|
||||||
items: [],
|
items: [],
|
||||||
meta: {
|
meta: {
|
||||||
categories: [],
|
categories: [],
|
||||||
@@ -16,8 +14,7 @@ const state = {
|
|||||||
filters: {
|
filters: {
|
||||||
search: '',
|
search: '',
|
||||||
category: 'All',
|
category: 'All',
|
||||||
status: 'all',
|
status: 'all'
|
||||||
location: 'All'
|
|
||||||
},
|
},
|
||||||
activeGramsItem: null,
|
activeGramsItem: null,
|
||||||
activeGramsValue: 0,
|
activeGramsValue: 0,
|
||||||
@@ -77,7 +74,6 @@ async function loadItems() {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (state.filters.search) params.append('search', state.filters.search);
|
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.category && state.filters.category !== 'All') params.append('category', state.filters.category);
|
||||||
if (state.filters.location && state.filters.location !== 'All') params.append('location', state.filters.location);
|
|
||||||
if (state.filters.status && state.filters.status !== 'all') params.append('status', state.filters.status);
|
if (state.filters.status && state.filters.status !== 'all') params.append('status', state.filters.status);
|
||||||
|
|
||||||
const res = await fetch(`/api/items?${params.toString()}`);
|
const res = await fetch(`/api/items?${params.toString()}`);
|
||||||
@@ -104,13 +100,13 @@ function switchMode(mode) {
|
|||||||
const manageContainer = document.getElementById('manage-mode-container');
|
const manageContainer = document.getElementById('manage-mode-container');
|
||||||
|
|
||||||
if (mode === 'view') {
|
if (mode === 'view') {
|
||||||
viewBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 bg-teal-500 text-white shadow-md';
|
viewBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition bg-teal-500 text-white shadow-md';
|
||||||
manageBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 text-slate-300 hover:text-white';
|
manageBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition text-slate-300 hover:text-white';
|
||||||
viewContainer.classList.remove('hidden');
|
viewContainer.classList.remove('hidden');
|
||||||
manageContainer.classList.add('hidden');
|
manageContainer.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
manageBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 bg-teal-500 text-white shadow-md';
|
manageBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition bg-teal-500 text-white shadow-md';
|
||||||
viewBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 text-slate-300 hover:text-white';
|
viewBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition text-slate-300 hover:text-white';
|
||||||
manageContainer.classList.remove('hidden');
|
manageContainer.classList.remove('hidden');
|
||||||
viewContainer.classList.add('hidden');
|
viewContainer.classList.add('hidden');
|
||||||
}
|
}
|
||||||
@@ -118,23 +114,6 @@ function switchMode(mode) {
|
|||||||
renderApp();
|
renderApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setViewGrouping(grouping) {
|
|
||||||
state.viewGrouping = grouping;
|
|
||||||
|
|
||||||
const btnShelf = document.getElementById('grp-shelf-btn');
|
|
||||||
const btnCat = document.getElementById('grp-cat-btn');
|
|
||||||
const btnGrid = document.getElementById('grp-grid-btn');
|
|
||||||
|
|
||||||
const activeClass = 'px-2.5 py-1 rounded bg-teal-500/20 text-teal-300 border border-teal-500/30 font-medium';
|
|
||||||
const inactiveClass = 'px-2.5 py-1 rounded bg-slate-800 text-slate-400 border border-slate-700 font-medium';
|
|
||||||
|
|
||||||
btnShelf.className = grouping === 'shelf' ? activeClass : inactiveClass;
|
|
||||||
btnCat.className = grouping === 'category' ? activeClass : inactiveClass;
|
|
||||||
btnGrid.className = grouping === 'grid' ? activeClass : inactiveClass;
|
|
||||||
|
|
||||||
renderFridgeView();
|
|
||||||
}
|
|
||||||
|
|
||||||
let searchDebounceTimer = null;
|
let searchDebounceTimer = null;
|
||||||
function handleSearch(val) {
|
function handleSearch(val) {
|
||||||
state.filters.search = val.trim();
|
state.filters.search = val.trim();
|
||||||
@@ -148,7 +127,7 @@ function handleSearch(val) {
|
|||||||
clearTimeout(searchDebounceTimer);
|
clearTimeout(searchDebounceTimer);
|
||||||
searchDebounceTimer = setTimeout(() => {
|
searchDebounceTimer = setTimeout(() => {
|
||||||
loadItems();
|
loadItems();
|
||||||
}, 250);
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSearch() {
|
function clearSearch() {
|
||||||
@@ -162,9 +141,9 @@ function setStatusFilter(status) {
|
|||||||
state.filters.status = status;
|
state.filters.status = status;
|
||||||
document.querySelectorAll('[data-status-btn]').forEach(btn => {
|
document.querySelectorAll('[data-status-btn]').forEach(btn => {
|
||||||
if (btn.getAttribute('data-status-btn') === status) {
|
if (btn.getAttribute('data-status-btn') === status) {
|
||||||
btn.className = 'status-chip active px-3 py-2 rounded-lg bg-slate-800 border border-slate-700 text-white whitespace-nowrap transition font-semibold';
|
btn.className = 'status-chip active px-3 py-1.5 rounded-lg bg-slate-800 border border-slate-700 text-white font-semibold';
|
||||||
} else {
|
} else {
|
||||||
btn.className = 'status-chip px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200 whitespace-nowrap transition';
|
btn.className = 'status-chip px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
loadItems();
|
loadItems();
|
||||||
@@ -181,7 +160,7 @@ function renderCategoryFilters() {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
let html = `
|
let html = `
|
||||||
<button onclick="setCategoryFilter('All')" class="px-3 py-1.5 rounded-lg border whitespace-nowrap transition ${
|
<button onclick="setCategoryFilter('All')" class="px-2.5 py-1 rounded-lg border whitespace-nowrap transition text-xs ${
|
||||||
state.filters.category === 'All'
|
state.filters.category === 'All'
|
||||||
? 'bg-teal-500/20 text-teal-300 border-teal-500/40 font-semibold'
|
? 'bg-teal-500/20 text-teal-300 border-teal-500/40 font-semibold'
|
||||||
: 'bg-slate-900 text-slate-400 border-slate-800 hover:text-slate-200'
|
: 'bg-slate-900 text-slate-400 border-slate-800 hover:text-slate-200'
|
||||||
@@ -193,7 +172,7 @@ function renderCategoryFilters() {
|
|||||||
state.meta.categories.forEach(cat => {
|
state.meta.categories.forEach(cat => {
|
||||||
const isSelected = state.filters.category === cat.id;
|
const isSelected = state.filters.category === cat.id;
|
||||||
html += `
|
html += `
|
||||||
<button onclick="setCategoryFilter('${escapeHtml(cat.id)}')" class="px-3 py-1.5 rounded-lg border whitespace-nowrap transition ${
|
<button onclick="setCategoryFilter('${escapeHtml(cat.id)}')" class="px-2.5 py-1 rounded-lg border whitespace-nowrap transition text-xs ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-teal-500/20 text-teal-300 border-teal-500/40 font-semibold'
|
? 'bg-teal-500/20 text-teal-300 border-teal-500/40 font-semibold'
|
||||||
: 'bg-slate-900 text-slate-400 border-slate-800 hover:text-slate-200'
|
: 'bg-slate-900 text-slate-400 border-slate-800 hover:text-slate-200'
|
||||||
@@ -208,32 +187,19 @@ function renderCategoryFilters() {
|
|||||||
|
|
||||||
function populateModalSelects() {
|
function populateModalSelects() {
|
||||||
const catSelect = document.getElementById('modal-category');
|
const catSelect = document.getElementById('modal-category');
|
||||||
const locSelect = document.getElementById('modal-location');
|
|
||||||
|
|
||||||
if (catSelect && state.meta.categories) {
|
if (catSelect && state.meta.categories) {
|
||||||
catSelect.innerHTML = state.meta.categories.map(c => `
|
catSelect.innerHTML = state.meta.categories.map(c => `
|
||||||
<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>
|
<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (locSelect && state.meta.locations) {
|
|
||||||
locSelect.innerHTML = state.meta.locations.map(l => `
|
|
||||||
<option value="${escapeHtml(l)}">${escapeHtml(l)}</option>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// Rendering
|
// Rendering UI
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
|
||||||
function renderApp() {
|
function renderApp() {
|
||||||
const emptyState = document.getElementById('empty-state');
|
const emptyState = document.getElementById('empty-state');
|
||||||
const countLabel = document.getElementById('item-count-label');
|
|
||||||
|
|
||||||
if (countLabel) {
|
|
||||||
countLabel.textContent = `Showing ${state.items.length} item${state.items.length === 1 ? '' : 's'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.items.length === 0) {
|
if (state.items.length === 0) {
|
||||||
emptyState.classList.remove('hidden');
|
emptyState.classList.remove('hidden');
|
||||||
@@ -253,259 +219,218 @@ function renderApp() {
|
|||||||
initLucide();
|
initLucide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MODE 1: Fridge View Mode (No drawers/shelves - clean grid)
|
||||||
function renderFridgeView() {
|
function renderFridgeView() {
|
||||||
const container = document.getElementById('fridge-view-content');
|
const container = document.getElementById('fridge-view-content');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (state.viewGrouping === 'grid') {
|
container.innerHTML = state.items.map(item => {
|
||||||
container.innerHTML = `
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
${state.items.map(item => renderFridgeItemCard(item)).join('')}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by Shelf or Category
|
|
||||||
const isShelf = state.viewGrouping === 'shelf';
|
|
||||||
const groups = {};
|
|
||||||
|
|
||||||
if (isShelf) {
|
|
||||||
state.meta.locations.forEach(loc => groups[loc] = []);
|
|
||||||
groups['Other Location'] = [];
|
|
||||||
} else {
|
|
||||||
state.meta.categories.forEach(cat => groups[cat.id] = []);
|
|
||||||
groups['Other'] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
state.items.forEach(item => {
|
|
||||||
const key = isShelf ? (item.location || 'Other Location') : (item.category || 'Other');
|
|
||||||
if (!groups[key]) groups[key] = [];
|
|
||||||
groups[key].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
for (const [groupName, groupItems] of Object.entries(groups)) {
|
|
||||||
if (groupItems.length === 0) continue;
|
|
||||||
|
|
||||||
const iconName = isShelf ? 'refrigerator' : getCategoryIcon(groupName);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="bg-slate-900/60 rounded-2xl border border-slate-800/80 p-4 sm:p-5 shadow-lg space-y-4">
|
|
||||||
<div class="flex items-center justify-between pb-2 border-b border-slate-800/60">
|
|
||||||
<div class="flex items-center gap-2.5">
|
|
||||||
<div class="w-7 h-7 rounded-lg bg-teal-500/10 text-teal-400 flex items-center justify-center">
|
|
||||||
<i data-lucide="${iconName}" class="w-4 h-4"></i>
|
|
||||||
</div>
|
|
||||||
<h3 class="font-bold text-slate-200 text-sm sm:text-base">${escapeHtml(groupName)}</h3>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-slate-400 font-medium">${groupItems.length} item${groupItems.length === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3.5">
|
|
||||||
${groupItems.map(item => renderFridgeItemCard(item)).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFridgeItemCard(item) {
|
|
||||||
const expiryInfo = getExpiryStatus(item.expiry_date);
|
|
||||||
const percent = item.initial_quantity > 0
|
|
||||||
? Math.min(100, Math.max(0, Math.round((item.quantity / item.initial_quantity) * 100)))
|
|
||||||
: 100;
|
|
||||||
|
|
||||||
const isWeight = item.unit === 'g' || item.unit === 'ml' || item.unit === 'kg' || item.unit === 'l';
|
|
||||||
const isLow = percent <= 25;
|
|
||||||
|
|
||||||
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 p-4 flex flex-col justify-between transition-all duration-200 hover:-translate-y-0.5 relative group">
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<!-- Top row: Category/Location & Expiry badge -->
|
|
||||||
<div class="flex items-start justify-between gap-2 mb-2">
|
|
||||||
<span class="text-[11px] font-semibold text-teal-400/90 bg-teal-500/10 px-2 py-0.5 rounded-md truncate max-w-[130px]">
|
|
||||||
${escapeHtml(item.brand || item.category)}
|
|
||||||
</span>
|
|
||||||
<span class="text-[11px] font-semibold px-2 py-0.5 rounded-md flex items-center gap-1 shrink-0 ${expiryInfo.badgeClass}">
|
|
||||||
<i data-lucide="${expiryInfo.icon}" class="w-3 h-3"></i>
|
|
||||||
<span>${expiryInfo.label}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Product Image & Name -->
|
|
||||||
<div class="flex items-start gap-3 my-2">
|
|
||||||
${item.image_url ? `
|
|
||||||
<img src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.name)}" class="w-12 h-12 object-contain rounded-xl bg-white/5 p-1 border border-slate-700/60 shrink-0">
|
|
||||||
` : `
|
|
||||||
<div class="w-12 h-12 rounded-xl bg-slate-800/80 border border-slate-700/60 text-slate-400 flex items-center justify-center shrink-0">
|
|
||||||
<i data-lucide="${getCategoryIcon(item.category)}" class="w-6 h-6 text-slate-400"></i>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<h4 class="font-bold text-slate-100 text-sm 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>
|
|
||||||
|
|
||||||
<!-- Grams / Quantity Progress Indicator -->
|
|
||||||
<div class="mt-3 bg-slate-950/60 rounded-xl p-2.5 border border-slate-800/60">
|
|
||||||
<div class="flex items-center justify-between text-xs mb-1.5">
|
|
||||||
<span class="font-bold text-white flex items-center gap-1">
|
|
||||||
<span class="text-sm font-black text-teal-300">${item.quantity}</span>
|
|
||||||
<span class="text-slate-400 font-normal">${item.unit}</span>
|
|
||||||
</span>
|
|
||||||
<span class="text-[11px] text-slate-400">
|
|
||||||
of ${item.initial_quantity} ${item.unit} (${percent}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress Bar -->
|
|
||||||
<div class="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden">
|
|
||||||
<div class="${progressBarColor} h-full rounded-full transition-all duration-300" style="width: ${percent}%;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Action Buttons -->
|
|
||||||
<div class="mt-3.5 pt-2.5 border-t border-slate-800/60 flex items-center justify-between gap-1.5">
|
|
||||||
<button onclick="openGramsModal(${item.id})" class="flex-1 flex items-center justify-center gap-1.5 py-1.5 px-2 rounded-xl bg-teal-500/10 hover:bg-teal-500/20 text-teal-300 border border-teal-500/30 text-xs font-semibold transition active:scale-95">
|
|
||||||
<i data-lucide="scale" class="w-3.5 h-3.5"></i>
|
|
||||||
<span>${isWeight ? 'Adjust Amount' : 'Adjust'}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
${isWeight ? `
|
|
||||||
<button onclick="quickDelta(${item.id}, -50)" class="p-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 text-xs font-semibold" title="Use 50${item.unit}">
|
|
||||||
-50
|
|
||||||
</button>
|
|
||||||
` : `
|
|
||||||
<button onclick="quickDelta(${item.id}, -1)" class="p-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 text-xs font-semibold" title="Use 1">
|
|
||||||
-1
|
|
||||||
</button>
|
|
||||||
`}
|
|
||||||
|
|
||||||
<button onclick="openItemModal(${item.id})" class="p-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white border border-slate-700 text-xs" title="Edit Item">
|
|
||||||
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderManageView() {
|
|
||||||
const container = document.getElementById('manage-items-list');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
state.items.forEach(item => {
|
|
||||||
const expiryInfo = getExpiryStatus(item.expiry_date);
|
const expiryInfo = getExpiryStatus(item.expiry_date);
|
||||||
const percent = item.initial_quantity > 0
|
const percent = item.initial_quantity > 0
|
||||||
? Math.min(100, Math.max(0, Math.round((item.quantity / item.initial_quantity) * 100)))
|
? Math.min(100, Math.max(0, Math.round((item.quantity / item.initial_quantity) * 100)))
|
||||||
: 100;
|
: 100;
|
||||||
|
|
||||||
const isWeight = item.unit === 'g' || item.unit === 'ml' || item.unit === 'kg' || item.unit === 'l';
|
let progressBarColor = 'bg-teal-500';
|
||||||
|
if (percent <= 20) progressBarColor = 'bg-rose-500';
|
||||||
|
else if (percent <= 50) progressBarColor = 'bg-amber-500';
|
||||||
|
|
||||||
html += `
|
return `
|
||||||
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-4 shadow-md transition hover:border-slate-700 space-y-3">
|
<div class="shelf-card rounded-2xl p-4 flex flex-col justify-between transition hover:border-teal-500/40 space-y-3">
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
<div>
|
||||||
|
<!-- Header: Brand & Expiry -->
|
||||||
<!-- Item info & Thumbnail -->
|
<div class="flex items-center justify-between gap-2 mb-2">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<span class="text-[11px] font-semibold text-teal-400 bg-teal-500/10 px-2 py-0.5 rounded-md truncate max-w-[130px]">
|
||||||
|
${escapeHtml(item.brand || item.category)}
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] font-semibold px-2 py-0.5 rounded-md flex items-center gap-1 shrink-0 ${expiryInfo.badgeClass}">
|
||||||
|
<i data-lucide="${expiryInfo.icon}" class="w-3 h-3"></i>
|
||||||
|
<span>${expiryInfo.label}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Details & Image -->
|
||||||
|
<div class="flex items-start gap-3 my-1">
|
||||||
${item.image_url ? `
|
${item.image_url ? `
|
||||||
<img src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.name)}" class="w-12 h-12 object-contain rounded-xl bg-white/5 p-1 border border-slate-800 shrink-0">
|
<img src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.name)}" class="w-12 h-12 object-contain rounded-xl bg-white/5 p-1 border border-slate-700/60 shrink-0">
|
||||||
` : `
|
` : `
|
||||||
<div class="w-12 h-12 rounded-xl bg-slate-800 border border-slate-700 text-teal-400 flex items-center justify-center shrink-0">
|
<div class="w-12 h-12 rounded-xl 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-6 h-6"></i>
|
<i data-lucide="${getCategoryIcon(item.category)}" class="w-6 h-6"></i>
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
|
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<h4 class="font-bold text-slate-100 text-sm leading-snug">${escapeHtml(item.name)}</h4>
|
||||||
<h4 class="font-bold text-white text-sm sm:text-base truncate">${escapeHtml(item.name)}</h4>
|
${item.notes ? `<p class="text-xs text-slate-400 italic mt-0.5 truncate">${escapeHtml(item.notes)}</p>` : ''}
|
||||||
${item.brand ? `<span class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-300">${escapeHtml(item.brand)}</span>` : ''}
|
|
||||||
${item.is_opened ? `<span class="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-300 font-semibold">Opened</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-slate-400 flex items-center gap-2 mt-0.5 flex-wrap">
|
|
||||||
<span>📍 ${escapeHtml(item.location || 'Fridge')}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span class="${expiryInfo.badgeClass} px-1.5 py-0.2 rounded font-medium">${expiryInfo.label}</span>
|
|
||||||
${item.barcode ? `<span>•</span><span class="font-mono text-[11px] text-slate-500">${escapeHtml(item.barcode)}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Grams / Quantity Progress -->
|
||||||
<div class="flex items-center gap-2 self-end sm:self-center shrink-0">
|
<div class="mt-2.5 bg-slate-950/70 rounded-xl p-2.5 border border-slate-800/80">
|
||||||
<button onclick="openItemModal(${item.id})" class="px-3 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold flex items-center gap-1.5 border border-slate-700 transition">
|
<div class="flex items-center justify-between text-xs mb-1">
|
||||||
<i data-lucide="edit-3" class="w-3.5 h-3.5"></i>
|
<span class="font-bold text-white flex items-center gap-1">
|
||||||
<span>Edit</span>
|
<span class="text-base font-black text-teal-300">${item.quantity}</span>
|
||||||
</button>
|
<span class="text-slate-400 font-normal">${item.unit}</span>
|
||||||
<button onclick="confirmDeleteItem(${item.id}, '${escapeHtml(item.name)}')" class="p-1.5 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/20 text-xs transition" title="Delete Item">
|
</span>
|
||||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
<span class="text-[11px] text-slate-400">
|
||||||
</button>
|
Pack: ${item.initial_quantity} ${item.unit} (${percent}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden">
|
||||||
|
<div class="${progressBarColor} h-full rounded-full transition-all duration-300" style="width: ${percent}%;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Interactive Grams / Quantity Adjuster Strip -->
|
<!-- 3 Primary Action Buttons: Add Pack, Remove Pack, Manage Grams -->
|
||||||
<div class="bg-slate-950/70 p-3 rounded-xl border border-slate-800/80 flex flex-col md:flex-row items-center justify-between gap-3">
|
<div class="grid grid-cols-3 gap-1.5 pt-2 border-t border-slate-800/80">
|
||||||
|
<button onclick="addWholePacket(${item.id})" class="flex items-center justify-center gap-1 py-1.5 px-2 rounded-xl bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 text-xs font-semibold transition active:scale-95" title="Add 1 whole packet (+${item.initial_quantity}${item.unit})">
|
||||||
|
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Add</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="removeWholePacket(${item.id})" class="flex items-center justify-center gap-1 py-1.5 px-2 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-300 border border-rose-500/30 text-xs font-semibold transition active:scale-95" title="Remove 1 whole packet (-${item.initial_quantity}${item.unit})">
|
||||||
|
<i data-lucide="minus" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Remove</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="openGramsModal(${item.id})" class="flex items-center justify-center gap-1 py-1.5 px-2 rounded-xl bg-teal-500/20 hover:bg-teal-500/30 text-teal-300 border border-teal-500/40 text-xs font-semibold transition active:scale-95" title="Adjust grams when not used up">
|
||||||
|
<i data-lucide="sliders" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Manage</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// MODE 2: Manage Mode (Simplified list with the 3 main buttons)
|
||||||
|
function renderManageView() {
|
||||||
|
const container = document.getElementById('manage-items-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = state.items.map(item => {
|
||||||
|
const expiryInfo = getExpiryStatus(item.expiry_date);
|
||||||
|
const percent = item.initial_quantity > 0
|
||||||
|
? Math.min(100, Math.max(0, Math.round((item.quantity / item.initial_quantity) * 100)))
|
||||||
|
: 100;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-3.5 shadow-md transition hover:border-slate-700 space-y-3">
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
|
|
||||||
<!-- Slider & Current remaining -->
|
<!-- Item info -->
|
||||||
<div class="flex-1 w-full flex items-center gap-3">
|
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<div class="text-left shrink-0">
|
${item.image_url ? `
|
||||||
<div class="text-xs text-slate-400">Remaining:</div>
|
<img src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.name)}" class="w-11 h-11 object-contain rounded-xl bg-white/5 p-1 border border-slate-800 shrink-0">
|
||||||
<div class="text-base font-black text-teal-300">
|
` : `
|
||||||
${item.quantity} <span class="text-xs font-normal text-slate-400">${item.unit} (${percent}%)</span>
|
<div class="w-11 h-11 rounded-xl bg-slate-800 border border-slate-700 text-teal-400 flex items-center justify-center shrink-0">
|
||||||
|
<i data-lucide="${getCategoryIcon(item.category)}" class="w-5 h-5"></i>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<h4 class="font-bold text-white text-sm truncate">${escapeHtml(item.name)}</h4>
|
||||||
|
${item.brand ? `<span class="text-[11px] px-1.5 py-0.5 rounded bg-slate-800 text-slate-300">${escapeHtml(item.brand)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-400 flex items-center gap-2 mt-0.5 flex-wrap">
|
||||||
|
<span class="font-semibold text-teal-300">${item.quantity} ${item.unit}</span>
|
||||||
|
<span>(Pack size: ${item.initial_quantity} ${item.unit})</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span class="${expiryInfo.badgeClass} px-1.5 py-0.2 rounded font-medium text-[11px]">${expiryInfo.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="${item.initial_quantity || 100}"
|
|
||||||
step="${item.unit === 'g' || item.unit === 'ml' ? 5 : 1}"
|
|
||||||
value="${item.quantity}"
|
|
||||||
onchange="onManageSliderCommit(${item.id}, this.value)"
|
|
||||||
class="w-full accent-teal-500 cursor-pointer h-2 bg-slate-800 rounded-lg"
|
|
||||||
title="Drag to change remaining quantity"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Delta Chips -->
|
<!-- The 3 Core Action Buttons: Manage, Add Pack, Remove Pack (+ Edit/Delete icons) -->
|
||||||
<div class="flex items-center gap-1.5 flex-wrap shrink-0">
|
<div class="flex items-center gap-2 w-full sm:w-auto justify-end">
|
||||||
${isWeight ? `
|
|
||||||
<button onclick="quickDelta(${item.id}, -25)" class="px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-300 font-medium border border-slate-700">-25g</button>
|
<!-- 1. Add Whole Packet -->
|
||||||
<button onclick="quickDelta(${item.id}, -50)" class="px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-300 font-medium border border-slate-700">-50g</button>
|
<button onclick="addWholePacket(${item.id})" class="px-3 py-1.5 rounded-xl bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 border border-emerald-500/30 text-xs font-semibold flex items-center gap-1 transition active:scale-95" title="Add 1 Whole Packet">
|
||||||
<button onclick="quickDelta(${item.id}, -100)" class="px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-300 font-medium border border-slate-700">-100g</button>
|
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
|
||||||
` : `
|
<span>Add</span>
|
||||||
<button onclick="quickDelta(${item.id}, -1)" class="px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-300 font-medium border border-slate-700">-1</button>
|
</button>
|
||||||
<button onclick="quickDelta(${item.id}, 1)" class="px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-300 font-medium border border-slate-700">+1</button>
|
|
||||||
`}
|
<!-- 2. Remove Whole Packet -->
|
||||||
<button onclick="openGramsModal(${item.id})" class="px-2.5 py-1 rounded-lg bg-teal-500/20 hover:bg-teal-500/30 text-teal-300 text-xs font-semibold border border-teal-500/40">Custom</button>
|
<button onclick="removeWholePacket(${item.id})" class="px-3 py-1.5 rounded-xl bg-rose-500/15 hover:bg-rose-500/25 text-rose-300 border border-rose-500/30 text-xs font-semibold flex items-center gap-1 transition active:scale-95" title="Remove 1 Whole Packet">
|
||||||
|
<i data-lucide="minus" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Remove</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 3. Manage Grams / Partial Amount -->
|
||||||
|
<button onclick="openGramsModal(${item.id})" class="px-3.5 py-1.5 rounded-xl bg-teal-500 hover:bg-teal-400 text-white text-xs font-semibold flex items-center gap-1 shadow-sm transition active:scale-95" title="Adjust grams when partially used">
|
||||||
|
<i data-lucide="sliders" class="w-3.5 h-3.5"></i>
|
||||||
|
<span>Manage</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Secondary: Edit / Delete -->
|
||||||
|
<button onclick="openItemModal(${item.id})" class="p-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white border border-slate-700 text-xs ml-1" title="Edit Item Details">
|
||||||
|
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
|
||||||
|
</button>
|
||||||
|
<button onclick="confirmDeleteItem(${item.id}, '${escapeHtml(item.name)}')" class="p-1.5 rounded-xl bg-slate-800 hover:bg-rose-900/40 text-slate-400 hover:text-rose-300 border border-slate-700 text-xs" title="Delete">
|
||||||
|
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
}).join('');
|
||||||
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// Grams & Quantity Adjustments
|
// 3 Primary Action Handlers: Add Pack, Remove Pack, Manage Grams
|
||||||
|
// ----------------------------------------------------
|
||||||
|
|
||||||
|
async function addWholePacket(itemId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/items/${itemId}/add-packet`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ count: 1 })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = await res.json();
|
||||||
|
showToast(`Added 1 packet of ${updated.name} (Now: ${updated.quantity} ${updated.unit})`, 'success');
|
||||||
|
await loadStats();
|
||||||
|
await loadItems();
|
||||||
|
} else {
|
||||||
|
showToast('Failed to add packet', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast('Network error', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeWholePacket(itemId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/items/${itemId}/remove-packet`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ count: 1 })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = await res.json();
|
||||||
|
showToast(`Removed 1 packet of ${updated.name} (Remaining: ${updated.quantity} ${updated.unit})`, 'info');
|
||||||
|
await loadStats();
|
||||||
|
await loadItems();
|
||||||
|
} else {
|
||||||
|
showToast('Failed to remove packet', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast('Network error', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------
|
||||||
|
// Manage Grams Modal Logic
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
|
||||||
function openGramsModal(itemId) {
|
function openGramsModal(itemId) {
|
||||||
@@ -516,13 +441,13 @@ function openGramsModal(itemId) {
|
|||||||
state.activeGramsValue = item.quantity;
|
state.activeGramsValue = item.quantity;
|
||||||
|
|
||||||
document.getElementById('grams-modal-title').textContent = item.name;
|
document.getElementById('grams-modal-title').textContent = item.name;
|
||||||
document.getElementById('grams-modal-sub').textContent = `Package Size: ${item.initial_quantity} ${item.unit} • Location: ${item.location}`;
|
document.getElementById('grams-modal-sub').textContent = `1 Full Packet = ${item.initial_quantity} ${item.unit}`;
|
||||||
document.getElementById('grams-modal-unit').textContent = item.unit;
|
document.getElementById('grams-modal-unit').textContent = item.unit;
|
||||||
document.getElementById('grams-current-val').textContent = item.quantity;
|
document.getElementById('grams-current-val').textContent = item.quantity;
|
||||||
document.getElementById('grams-exact-input').value = item.quantity;
|
document.getElementById('grams-exact-input').value = item.quantity;
|
||||||
|
|
||||||
const slider = document.getElementById('grams-slider');
|
const slider = document.getElementById('grams-slider');
|
||||||
slider.max = item.initial_quantity || 1000;
|
slider.max = Math.max(item.initial_quantity * 2, item.quantity, 100);
|
||||||
slider.step = (item.unit === 'g' || item.unit === 'ml') ? '5' : '1';
|
slider.step = (item.unit === 'g' || item.unit === 'ml') ? '5' : '1';
|
||||||
slider.value = item.quantity;
|
slider.value = item.quantity;
|
||||||
|
|
||||||
@@ -570,7 +495,7 @@ function updateGramsGauge() {
|
|||||||
document.getElementById('grams-current-val').textContent = state.activeGramsValue;
|
document.getElementById('grams-current-val').textContent = state.activeGramsValue;
|
||||||
if (state.activeGramsItem && state.activeGramsItem.initial_quantity > 0) {
|
if (state.activeGramsItem && state.activeGramsItem.initial_quantity > 0) {
|
||||||
const pct = Math.round((state.activeGramsValue / state.activeGramsItem.initial_quantity) * 100);
|
const pct = Math.round((state.activeGramsValue / state.activeGramsItem.initial_quantity) * 100);
|
||||||
document.getElementById('grams-percent-text').textContent = `${pct}% remaining of original ${state.activeGramsItem.initial_quantity}${state.activeGramsItem.unit}`;
|
document.getElementById('grams-percent-text').textContent = `${pct}% remaining of a full ${state.activeGramsItem.initial_quantity}${state.activeGramsItem.unit} packet`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +520,7 @@ async function saveGramsAdjustment() {
|
|||||||
showToast('Failed to update quantity', 'error');
|
showToast('Failed to update quantity', 'error');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Save grams error:', err);
|
console.error(err);
|
||||||
showToast('Network error while updating quantity', 'error');
|
showToast('Network error while updating quantity', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,7 +533,7 @@ async function consumeAllAndRemove() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/items/${itemId}`, { method: 'DELETE' });
|
const res = await fetch(`/api/items/${itemId}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast('Item finished & removed', 'info');
|
showToast('Item removed', 'info');
|
||||||
closeGramsModal();
|
closeGramsModal();
|
||||||
await loadStats();
|
await loadStats();
|
||||||
await loadItems();
|
await loadItems();
|
||||||
@@ -619,44 +544,6 @@ async function consumeAllAndRemove() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function quickDelta(itemId, delta) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/items/${itemId}/quantity`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ delta, markOpened: true })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const updated = await res.json();
|
|
||||||
showToast(`${updated.name}: ${delta > 0 ? '+' : ''}${delta} ${updated.unit} (Now: ${updated.quantity} ${updated.unit})`, 'success');
|
|
||||||
await loadStats();
|
|
||||||
await loadItems();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
showToast('Failed to adjust amount', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onManageSliderCommit(itemId, newQty) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/items/${itemId}/quantity`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ newQuantity: parseFloat(newQty), markOpened: true })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
showToast(`Updated quantity`, 'success');
|
|
||||||
await loadStats();
|
|
||||||
await loadItems();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// Item Add / Edit Modal
|
// Item Add / Edit Modal
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
@@ -672,7 +559,7 @@ function openItemModal(itemId = null) {
|
|||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
document.getElementById('modal-item-id').value = item.id;
|
document.getElementById('modal-item-id').value = item.id;
|
||||||
document.getElementById('item-modal-title').textContent = 'Edit Fridge Item';
|
document.getElementById('item-modal-title').textContent = 'Edit Item';
|
||||||
document.getElementById('modal-barcode').value = item.barcode || '';
|
document.getElementById('modal-barcode').value = item.barcode || '';
|
||||||
document.getElementById('modal-name').value = item.name || '';
|
document.getElementById('modal-name').value = item.name || '';
|
||||||
document.getElementById('modal-brand').value = item.brand || '';
|
document.getElementById('modal-brand').value = item.brand || '';
|
||||||
@@ -680,9 +567,7 @@ function openItemModal(itemId = null) {
|
|||||||
document.getElementById('modal-initial-quantity').value = item.initial_quantity || item.quantity;
|
document.getElementById('modal-initial-quantity').value = item.initial_quantity || item.quantity;
|
||||||
document.getElementById('modal-unit').value = item.unit || 'g';
|
document.getElementById('modal-unit').value = item.unit || 'g';
|
||||||
document.getElementById('modal-category').value = item.category || 'General';
|
document.getElementById('modal-category').value = item.category || 'General';
|
||||||
document.getElementById('modal-location').value = item.location || 'Fridge - Middle Shelf';
|
|
||||||
document.getElementById('modal-expiry').value = item.expiry_date || '';
|
document.getElementById('modal-expiry').value = item.expiry_date || '';
|
||||||
document.getElementById('modal-opened').checked = !!item.is_opened;
|
|
||||||
document.getElementById('modal-notes').value = item.notes || '';
|
document.getElementById('modal-notes').value = item.notes || '';
|
||||||
|
|
||||||
if (item.image_url) {
|
if (item.image_url) {
|
||||||
@@ -693,11 +578,10 @@ function openItemModal(itemId = null) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('modal-item-id').value = '';
|
document.getElementById('modal-item-id').value = '';
|
||||||
document.getElementById('item-modal-title').textContent = 'Add New Fridge Item';
|
document.getElementById('item-modal-title').textContent = 'Add Item';
|
||||||
document.getElementById('modal-unit').value = 'g';
|
document.getElementById('modal-unit').value = 'g';
|
||||||
document.getElementById('modal-quantity').value = '';
|
document.getElementById('modal-quantity').value = '';
|
||||||
document.getElementById('modal-initial-quantity').value = '';
|
document.getElementById('modal-initial-quantity').value = '';
|
||||||
document.getElementById('modal-location').value = 'Fridge - Middle Shelf';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('item-modal').classList.remove('hidden');
|
document.getElementById('item-modal').classList.remove('hidden');
|
||||||
@@ -746,7 +630,6 @@ function applyProductDataToForm(data) {
|
|||||||
}
|
}
|
||||||
if (data.unit) document.getElementById('modal-unit').value = data.unit;
|
if (data.unit) document.getElementById('modal-unit').value = data.unit;
|
||||||
if (data.category) document.getElementById('modal-category').value = data.category;
|
if (data.category) document.getElementById('modal-category').value = data.category;
|
||||||
if (data.location) document.getElementById('modal-location').value = data.location;
|
|
||||||
if (data.barcode) document.getElementById('modal-barcode').value = data.barcode;
|
if (data.barcode) document.getElementById('modal-barcode').value = data.barcode;
|
||||||
|
|
||||||
if (data.image_url) {
|
if (data.image_url) {
|
||||||
@@ -777,9 +660,7 @@ async function handleItemFormSubmit(event) {
|
|||||||
initial_quantity: initQty,
|
initial_quantity: initQty,
|
||||||
unit: document.getElementById('modal-unit').value,
|
unit: document.getElementById('modal-unit').value,
|
||||||
category: document.getElementById('modal-category').value,
|
category: document.getElementById('modal-category').value,
|
||||||
location: document.getElementById('modal-location').value,
|
|
||||||
expiry_date: document.getElementById('modal-expiry').value || null,
|
expiry_date: document.getElementById('modal-expiry').value || null,
|
||||||
is_opened: document.getElementById('modal-opened').checked ? 1 : 0,
|
|
||||||
notes: document.getElementById('modal-notes').value.trim(),
|
notes: document.getElementById('modal-notes').value.trim(),
|
||||||
image_url: document.getElementById('modal-image-url').value || null
|
image_url: document.getElementById('modal-image-url').value || null
|
||||||
};
|
};
|
||||||
@@ -810,7 +691,7 @@ async function handleItemFormSubmit(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDeleteItem(itemId, name) {
|
async function confirmDeleteItem(itemId, name) {
|
||||||
if (confirm(`Are you sure you want to remove "${name}" from your fridge?`)) {
|
if (confirm(`Remove "${name}" from fridge?`)) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/items/${itemId}`, { method: 'DELETE' });
|
const res = await fetch(`/api/items/${itemId}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -858,7 +739,7 @@ async function startCameraScanner() {
|
|||||||
state.html5QrCode = new Html5Qrcode("barcode-reader");
|
state.html5QrCode = new Html5Qrcode("barcode-reader");
|
||||||
const config = {
|
const config = {
|
||||||
fps: 10,
|
fps: 10,
|
||||||
qrbox: { width: 250, height: 160 },
|
qrbox: { width: 240, height: 150 },
|
||||||
aspectRatio: 1.333
|
aspectRatio: 1.333
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -870,7 +751,7 @@ async function startCameraScanner() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
state.isScanning = true;
|
state.isScanning = true;
|
||||||
document.getElementById('scanner-status').textContent = 'Camera active. Point at barcode.';
|
document.getElementById('scanner-status').textContent = 'Point camera at barcode.';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Camera start error:', err);
|
console.warn('Camera start error:', err);
|
||||||
document.getElementById('scanner-status').textContent = 'Camera unavailable. Please enter barcode number below.';
|
document.getElementById('scanner-status').textContent = 'Camera unavailable. Please enter barcode number below.';
|
||||||
@@ -889,11 +770,9 @@ async function stopCameraScanner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onBarcodeScannedSuccess(decodedText) {
|
async function onBarcodeScannedSuccess(decodedText) {
|
||||||
// Beep sound feedback
|
|
||||||
playBeepSound();
|
playBeepSound();
|
||||||
|
|
||||||
stopCameraScanner();
|
stopCameraScanner();
|
||||||
document.getElementById('scanner-status').textContent = `Scanned ${decodedText}! Fetching info...`;
|
document.getElementById('scanner-status').textContent = `Scanned ${decodedText}!`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/barcode/${encodeURIComponent(decodedText)}`);
|
const res = await fetch(`/api/barcode/${encodeURIComponent(decodedText)}`);
|
||||||
@@ -907,7 +786,7 @@ async function onBarcodeScannedSuccess(decodedText) {
|
|||||||
} else {
|
} else {
|
||||||
openItemModal();
|
openItemModal();
|
||||||
document.getElementById('modal-barcode').value = decodedText;
|
document.getElementById('modal-barcode').value = decodedText;
|
||||||
showToast(`Scanned barcode: ${decodedText}. Please enter details.`, 'info');
|
showToast(`Scanned barcode: ${decodedText}. Enter details.`, 'info');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -917,9 +796,7 @@ async function onBarcodeScannedSuccess(decodedText) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBarcodeScanError(errorMessage) {
|
function onBarcodeScanError() {}
|
||||||
// Continuous scanning errors are expected until barcode is in frame
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleManualBarcodeSubmit() {
|
function handleManualBarcodeSubmit() {
|
||||||
const code = document.getElementById('scanner-manual-input').value.trim();
|
const code = document.getElementById('scanner-manual-input').value.trim();
|
||||||
@@ -944,12 +821,12 @@ function playBeepSound() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// Utilities & Helpers
|
// Helpers
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
|
||||||
function getExpiryStatus(expiryDateStr) {
|
function getExpiryStatus(expiryDateStr) {
|
||||||
if (!expiryDateStr) {
|
if (!expiryDateStr) {
|
||||||
return { label: 'No Expiry Set', badgeClass: 'bg-slate-800 text-slate-400', icon: 'calendar' };
|
return { label: 'No Expiry', badgeClass: 'bg-slate-800 text-slate-400', icon: 'calendar' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -1021,7 +898,7 @@ function showToast(message, type = 'info') {
|
|||||||
info: 'bg-slate-800 text-slate-100 border-slate-700'
|
info: 'bg-slate-800 text-slate-100 border-slate-700'
|
||||||
};
|
};
|
||||||
|
|
||||||
toast.className = `p-3.5 rounded-xl border shadow-xl text-xs font-medium flex items-center gap-2 transform transition-all duration-300 translate-y-2 opacity-0 pointer-events-auto ${colors[type] || colors.info}`;
|
toast.className = `p-3 rounded-xl border shadow-xl text-xs font-medium 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>`;
|
toast.innerHTML = `<span>${escapeHtml(message)}</span>`;
|
||||||
|
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
@@ -1033,14 +910,14 @@ function showToast(message, type = 'info') {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.classList.add('translate-y-2', 'opacity-0');
|
toast.classList.add('translate-y-2', 'opacity-0');
|
||||||
setTimeout(() => toast.remove(), 300);
|
setTimeout(() => toast.remove(), 300);
|
||||||
}, 3500);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedDemoData() {
|
async function seedDemoData() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/seed?force=true', { method: 'POST' });
|
const res = await fetch('/api/seed?force=true', { method: 'POST' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast('Loaded sample fridge items!', 'success');
|
showToast('Loaded sample items!', 'success');
|
||||||
await loadStats();
|
await loadStats();
|
||||||
await loadItems();
|
await loadItems();
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-203
@@ -1,9 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="h-full bg-slate-900 text-slate-100">
|
<html lang="en" class="h-full bg-slate-950 text-slate-100">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>Fridger - Smart Fridge Inventory</title>
|
<title>Fridger - Smart Fridge Manager</title>
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
<!-- Tailwind CSS -->
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
@@ -14,12 +14,9 @@
|
|||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
brand: {
|
||||||
50: '#f0fdfa',
|
|
||||||
100: '#ccfbf1',
|
|
||||||
400: '#2dd4bf',
|
400: '#2dd4bf',
|
||||||
500: '#14b8a6',
|
500: '#14b8a6',
|
||||||
600: '#0d9488',
|
600: '#0d9488',
|
||||||
700: '#0f766e',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,42 +35,42 @@
|
|||||||
<body class="h-full flex flex-col bg-slate-950 text-slate-100 antialiased selection:bg-teal-500 selection:text-white">
|
<body class="h-full flex flex-col bg-slate-950 text-slate-100 antialiased selection:bg-teal-500 selection:text-white">
|
||||||
|
|
||||||
<!-- TOP APP BAR -->
|
<!-- TOP APP BAR -->
|
||||||
<header class="sticky top-0 z-30 bg-slate-900/90 backdrop-blur-md border-b border-slate-800">
|
<header class="sticky top-0 z-30 bg-slate-900/95 backdrop-blur-md border-b border-slate-800">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||||
<div class="flex items-center justify-between h-16 gap-4">
|
<div class="flex items-center justify-between h-16 gap-3">
|
||||||
|
|
||||||
<!-- Brand Logo -->
|
<!-- Brand -->
|
||||||
<div class="flex items-center gap-3 shrink-0">
|
<div class="flex items-center gap-2.5 shrink-0">
|
||||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-teal-600 to-cyan-500 flex items-center justify-center shadow-lg shadow-teal-500/20 text-white font-black text-xl">
|
<div class="w-9 h-9 rounded-xl bg-gradient-to-tr from-teal-500 to-cyan-400 flex items-center justify-center shadow-md shadow-teal-500/20 text-white font-black text-lg">
|
||||||
🧊
|
🧊
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="font-bold text-lg leading-tight tracking-tight bg-gradient-to-r from-teal-400 to-cyan-300 bg-clip-text text-transparent">Fridger</h1>
|
<h1 class="font-bold text-lg leading-none bg-gradient-to-r from-teal-300 to-cyan-300 bg-clip-text text-transparent">Fridger</h1>
|
||||||
<p class="text-xs text-slate-400 hidden sm:block">Fridge Inventory & Grams Tracker</p>
|
<p class="text-[11px] text-slate-400 font-medium">Fridge Manager</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TWO-MODE SWITCHER -->
|
<!-- 2 MODES: VIEW & MANAGE -->
|
||||||
<div class="flex items-center bg-slate-800/80 p-1 rounded-xl border border-slate-700/80 shadow-inner">
|
<div class="flex items-center bg-slate-950/80 p-1 rounded-xl border border-slate-800 shadow-inner">
|
||||||
<button id="mode-view-btn" onclick="switchMode('view')" class="flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 bg-teal-500 text-white shadow-md">
|
<button id="mode-view-btn" onclick="switchMode('view')" class="flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition bg-teal-500 text-white shadow-md">
|
||||||
<i data-lucide="eye" class="w-4 h-4"></i>
|
<i data-lucide="eye" class="w-4 h-4"></i>
|
||||||
<span>Fridge View</span>
|
<span>Fridge View</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="mode-manage-btn" onclick="switchMode('manage')" class="flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 text-slate-300 hover:text-white">
|
<button id="mode-manage-btn" onclick="switchMode('manage')" class="flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition text-slate-300 hover:text-white">
|
||||||
<i data-lucide="sliders-horizontal" class="w-4 h-4"></i>
|
<i data-lucide="sliders" class="w-4 h-4"></i>
|
||||||
<span>Manage Fridge</span>
|
<span>Manage</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- QUICK SCAN / ADD ACTIONS -->
|
<!-- QUICK SCAN / ADD -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button onclick="openScannerModal()" class="flex items-center gap-2 px-3 sm:px-4 py-2 rounded-xl bg-gradient-to-r from-teal-500 to-emerald-600 hover:from-teal-400 hover:to-emerald-500 text-white text-sm font-medium shadow-lg shadow-teal-500/25 transition-transform active:scale-95" title="Scan Barcode">
|
<button onclick="openScannerModal()" class="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-teal-500 hover:bg-teal-400 text-white text-xs sm:text-sm font-semibold shadow-md transition active:scale-95">
|
||||||
<i data-lucide="scan-barcode" class="w-4 h-4"></i>
|
<i data-lucide="scan-barcode" class="w-4 h-4"></i>
|
||||||
<span class="hidden md:inline">Scan Barcode</span>
|
<span class="hidden sm:inline">Scan</span>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="openItemModal()" class="flex items-center gap-2 px-3 sm:px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-200 text-sm font-medium transition active:scale-95" title="Add Manually">
|
<button onclick="openItemModal()" class="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-200 text-xs sm:text-sm font-medium transition active:scale-95">
|
||||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||||
<span class="hidden md:inline">Add Item</span>
|
<span class="hidden sm:inline">Add</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -81,56 +78,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- STATS BAR -->
|
<!-- SUMMARY STATS -->
|
||||||
<section class="bg-slate-900/50 border-b border-slate-800/60 py-3">
|
<section class="bg-slate-900/40 border-b border-slate-800/60 py-2.5">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
<div class="flex items-center justify-between text-xs text-slate-400 overflow-x-auto gap-4 scrollbar-none">
|
||||||
<div class="bg-slate-800/40 rounded-xl p-3 border border-slate-800 flex items-center gap-3">
|
<div class="flex items-center gap-1.5 shrink-0">
|
||||||
<div class="w-9 h-9 rounded-lg bg-teal-500/10 text-teal-400 flex items-center justify-center shrink-0">
|
<span class="w-2 h-2 rounded-full bg-teal-400"></span>
|
||||||
<i data-lucide="refrigerator" class="w-5 h-5"></i>
|
<span>In Fridge: <strong id="stat-total" class="text-white font-bold">0</strong></span>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div id="stat-total" class="text-lg font-bold text-white leading-none">0</div>
|
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Total In Fridge</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 shrink-0">
|
||||||
<div class="bg-slate-800/40 rounded-xl p-3 border border-slate-800 flex items-center gap-3">
|
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
|
||||||
<div class="w-9 h-9 rounded-lg bg-emerald-500/10 text-emerald-400 flex items-center justify-center shrink-0">
|
<span>Fresh: <strong id="stat-fresh" class="text-emerald-300 font-bold">0</strong></span>
|
||||||
<i data-lucide="check-circle-2" class="w-5 h-5"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div id="stat-fresh" class="text-lg font-bold text-emerald-400 leading-none">0</div>
|
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Fresh & Good</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 shrink-0">
|
||||||
<div class="bg-slate-800/40 rounded-xl p-3 border border-slate-800 flex items-center gap-3">
|
<span class="w-2 h-2 rounded-full bg-amber-400"></span>
|
||||||
<div class="w-9 h-9 rounded-lg bg-amber-500/10 text-amber-400 flex items-center justify-center shrink-0">
|
<span>Expiring Soon: <strong id="stat-expiring" class="text-amber-300 font-bold">0</strong></span>
|
||||||
<i data-lucide="clock" class="w-5 h-5"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div id="stat-expiring" class="text-lg font-bold text-amber-400 leading-none">0</div>
|
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Expiring ≤ 3 Days</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 shrink-0">
|
||||||
<div class="bg-slate-800/40 rounded-xl p-3 border border-slate-800 flex items-center gap-3">
|
<span class="w-2 h-2 rounded-full bg-indigo-400"></span>
|
||||||
<div class="w-9 h-9 rounded-lg bg-indigo-500/10 text-indigo-400 flex items-center justify-center shrink-0">
|
<span>Opened: <strong id="stat-opened" class="text-indigo-300 font-bold">0</strong></span>
|
||||||
<i data-lucide="pie-chart" class="w-5 h-5"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div id="stat-opened" class="text-lg font-bold text-indigo-300 leading-none">0</div>
|
|
||||||
<div class="text-xs text-slate-400 mt-0.5">Opened / Partial</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- SEARCH & FILTER CONTROLS -->
|
<!-- SEARCH & STATUS BAR -->
|
||||||
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-5 pb-3 w-full">
|
<section class="max-w-6xl mx-auto px-4 sm:px-6 pt-4 pb-2 w-full space-y-3">
|
||||||
<div class="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
|
<div class="flex flex-col sm:flex-row gap-2.5 items-stretch sm:items-center justify-between">
|
||||||
|
|
||||||
<!-- Search Input -->
|
<!-- Search Input -->
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
@@ -138,9 +112,9 @@
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="search-input"
|
id="search-input"
|
||||||
placeholder="Search items, brands, barcodes, or notes..."
|
placeholder="Search items by name, barcode or brand..."
|
||||||
oninput="handleSearch(this.value)"
|
oninput="handleSearch(this.value)"
|
||||||
class="w-full bg-slate-900 border border-slate-800 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 rounded-xl pl-10 pr-10 py-2.5 text-sm text-slate-100 placeholder-slate-500 transition outline-none"
|
class="w-full bg-slate-900 border border-slate-800 focus:border-teal-500 rounded-xl pl-9 pr-8 py-2 text-sm text-slate-100 placeholder-slate-500 outline-none transition"
|
||||||
>
|
>
|
||||||
<button id="clear-search" onclick="clearSearch()" class="hidden absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white">
|
<button id="clear-search" onclick="clearSearch()" class="hidden absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white">
|
||||||
<i data-lucide="x" class="w-4 h-4"></i>
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
@@ -148,121 +122,81 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Status Filters -->
|
<!-- Quick Status Filters -->
|
||||||
<div class="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0 scrollbar-none text-xs font-medium">
|
<div class="flex items-center gap-1 overflow-x-auto pb-0.5 text-xs font-medium scrollbar-none">
|
||||||
<button onclick="setStatusFilter('all')" data-status-btn="all" class="status-chip active px-3 py-2 rounded-lg bg-slate-800 border border-slate-700 text-white whitespace-nowrap transition">All</button>
|
<button onclick="setStatusFilter('all')" data-status-btn="all" class="status-chip active px-3 py-1.5 rounded-lg bg-slate-800 border border-slate-700 text-white font-semibold">All</button>
|
||||||
<button onclick="setStatusFilter('fresh')" data-status-btn="fresh" class="status-chip px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200 whitespace-nowrap transition">Fresh</button>
|
<button onclick="setStatusFilter('fresh')" data-status-btn="fresh" class="status-chip px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200">Fresh</button>
|
||||||
<button onclick="setStatusFilter('expiring_soon')" data-status-btn="expiring_soon" class="status-chip px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-amber-400/80 hover:text-amber-300 whitespace-nowrap transition">Expiring Soon</button>
|
<button onclick="setStatusFilter('expiring_soon')" data-status-btn="expiring_soon" class="status-chip px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-amber-400 hover:text-amber-300">Expiring</button>
|
||||||
<button onclick="setStatusFilter('low')" data-status-btn="low" class="status-chip px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-indigo-400/80 hover:text-indigo-300 whitespace-nowrap transition">Low ≤ 25%</button>
|
<button onclick="setStatusFilter('low')" data-status-btn="low" class="status-chip px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-indigo-400 hover:text-indigo-300">≤25% Left</button>
|
||||||
<button onclick="setStatusFilter('expired')" data-status-btn="expired" class="status-chip px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-rose-400/80 hover:text-rose-300 whitespace-nowrap transition">Expired</button>
|
<button onclick="setStatusFilter('expired')" data-status-btn="expired" class="status-chip px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-rose-400 hover:text-rose-300">Expired</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category Filter Bar -->
|
<!-- Category Filter Bar -->
|
||||||
<div id="category-filter-bar" class="flex items-center gap-2 overflow-x-auto pt-3 pb-1 scrollbar-none text-xs font-medium">
|
<div id="category-filter-bar" class="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none text-xs font-medium">
|
||||||
<!-- Injected by JS -->
|
<!-- Injected by JS -->
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- MAIN CONTENT AREA -->
|
<!-- MAIN CONTENT AREA -->
|
||||||
<main class="flex-1 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 w-full">
|
<main class="flex-1 max-w-6xl mx-auto px-4 sm:px-6 py-3 w-full">
|
||||||
|
|
||||||
<!-- MODE 1: FRIDGE VIEW CONTAINER -->
|
<!-- MODE 1: FRIDGE VIEW (Visual cards without drawers) -->
|
||||||
<div id="view-mode-container" class="space-y-6">
|
<div id="view-mode-container">
|
||||||
<!-- View controls: Group by Shelf or Group by Category or Card Grid -->
|
<div id="fridge-view-content" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div class="flex items-center justify-between pb-2 border-b border-slate-800 text-xs text-slate-400">
|
<!-- Rendered items -->
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium text-slate-300" id="item-count-label">Showing all items</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="hidden sm:inline">Group by:</span>
|
|
||||||
<button onclick="setViewGrouping('shelf')" id="grp-shelf-btn" class="px-2.5 py-1 rounded bg-teal-500/20 text-teal-300 border border-teal-500/30 font-medium">Shelves / Location</button>
|
|
||||||
<button onclick="setViewGrouping('category')" id="grp-cat-btn" class="px-2.5 py-1 rounded bg-slate-800 text-slate-400 border border-slate-700 font-medium">Category</button>
|
|
||||||
<button onclick="setViewGrouping('grid')" id="grp-grid-btn" class="px-2.5 py-1 rounded bg-slate-800 text-slate-400 border border-slate-700 font-medium">Grid</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fridge Visual Display Area -->
|
|
||||||
<div id="fridge-view-content" class="space-y-6">
|
|
||||||
<!-- Rendered items grouped by shelf/location or category -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MODE 2: MANAGE INVENTORY CONTAINER -->
|
<!-- MODE 2: MANAGE INVENTORY (Simpler list with 3 primary buttons: Manage Grams, Add Pack, Remove Pack) -->
|
||||||
<div id="manage-mode-container" class="hidden space-y-6">
|
<div id="manage-mode-container" class="hidden space-y-3">
|
||||||
|
|
||||||
<!-- Top Action Ribbon for Management -->
|
|
||||||
<div class="bg-gradient-to-r from-slate-900 to-slate-850 p-4 rounded-2xl border border-slate-800 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-lg">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-10 h-10 rounded-xl bg-teal-500/10 border border-teal-500/20 text-teal-400 flex items-center justify-center">
|
|
||||||
<i data-lucide="package-plus" class="w-5 h-5"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-white text-base">Inventory Management</h3>
|
|
||||||
<p class="text-xs text-slate-400">Add products, adjust grams after cooking, and manage stock</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 w-full sm:w-auto">
|
|
||||||
<button onclick="openScannerModal()" class="flex-1 sm:flex-initial flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-teal-500 hover:bg-teal-400 text-white text-sm font-semibold shadow-md shadow-teal-500/20 transition">
|
|
||||||
<i data-lucide="scan" class="w-4 h-4"></i>
|
|
||||||
<span>Scan Barcode</span>
|
|
||||||
</button>
|
|
||||||
<button onclick="openItemModal()" class="flex-1 sm:flex-initial flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-200 text-sm font-semibold transition">
|
|
||||||
<i data-lucide="plus-circle" class="w-4 h-4"></i>
|
|
||||||
<span>Add Item</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manage Items List -->
|
|
||||||
<div id="manage-items-list" class="space-y-3">
|
<div id="manage-items-list" class="space-y-3">
|
||||||
<!-- Dynamic list of management rows with quick grams adjustment sliders and +/- buttons -->
|
<!-- Rendered management items -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- EMPTY STATE -->
|
<!-- EMPTY STATE -->
|
||||||
<div id="empty-state" class="hidden text-center py-16 px-4">
|
<div id="empty-state" class="hidden text-center py-16 px-4">
|
||||||
<div class="w-16 h-16 rounded-2xl bg-slate-800 text-slate-400 flex items-center justify-center mx-auto mb-4 border border-slate-700">
|
<div class="w-16 h-16 rounded-2xl bg-slate-900 text-slate-500 flex items-center justify-center mx-auto mb-3 border border-slate-800 text-2xl">
|
||||||
<i data-lucide="refrigerator" class="w-8 h-8"></i>
|
🧊
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-bold text-white mb-1">No items found</h3>
|
<h3 class="text-base font-bold text-white mb-1">Fridge is empty</h3>
|
||||||
<p class="text-sm text-slate-400 max-w-sm mx-auto mb-6">Your fridge is either empty or matches no search filters. Scan a barcode or add an item to get started!</p>
|
<p class="text-xs text-slate-400 max-w-xs mx-auto mb-5">Scan a barcode or add an item to start tracking what's inside.</p>
|
||||||
<div class="flex items-center justify-center gap-3">
|
<div class="flex items-center justify-center gap-2">
|
||||||
<button onclick="openScannerModal()" class="px-4 py-2 rounded-xl bg-teal-500 hover:bg-teal-400 text-white text-sm font-medium flex items-center gap-2">
|
<button onclick="openScannerModal()" class="px-4 py-2 rounded-xl bg-teal-500 hover:bg-teal-400 text-white text-xs font-semibold flex items-center gap-1.5">
|
||||||
<i data-lucide="scan-barcode" class="w-4 h-4"></i>
|
<i data-lucide="scan-barcode" class="w-4 h-4"></i>
|
||||||
<span>Scan Product</span>
|
<span>Scan Barcode</span>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="seedDemoData()" class="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-300 text-sm font-medium">
|
<button onclick="seedDemoData()" class="px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-300 text-xs font-medium">
|
||||||
Load Sample Fridge
|
Load Sample Data
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- QUICK GRAMS / QUANTITY ADJUSTMENT MODAL -->
|
<!-- MANAGE GRAMS / WEIGHT ADJUSTMENT MODAL -->
|
||||||
<div id="grams-modal" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm hidden flex items-center justify-center p-4">
|
<div id="grams-modal" class="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm hidden flex items-center justify-center p-4">
|
||||||
<div class="bg-slate-900 border border-slate-800 rounded-2xl max-w-md w-full p-6 shadow-2xl space-y-5 animate-scale-up">
|
<div class="bg-slate-900 border border-slate-800 rounded-2xl max-w-md w-full p-5 shadow-2xl space-y-4 animate-scale-up">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs uppercase tracking-wider font-bold text-teal-400">Adjust Amount</span>
|
<span class="text-[11px] uppercase tracking-wider font-bold text-teal-400">Manage Amount (Grams / Partial)</span>
|
||||||
<h3 id="grams-modal-title" class="text-lg font-bold text-white mt-0.5">Item Name</h3>
|
<h3 id="grams-modal-title" class="text-base font-bold text-white mt-0.5">Item Name</h3>
|
||||||
<p id="grams-modal-sub" class="text-xs text-slate-400">Initial: 500g • Opened 2 days ago</p>
|
<p id="grams-modal-sub" class="text-xs text-slate-400">1 Full Packet = 250g</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="closeGramsModal()" class="text-slate-400 hover:text-white p-1">
|
<button onclick="closeGramsModal()" class="text-slate-400 hover:text-white p-1">
|
||||||
<i data-lucide="x" class="w-5 h-5"></i>
|
<i data-lucide="x" class="w-5 h-5"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Remaining Grams Gauge / Slider -->
|
<!-- Current Remaining Display & Slider -->
|
||||||
<div class="bg-slate-950/60 p-4 rounded-xl border border-slate-800/80 space-y-4 text-center">
|
<div class="bg-slate-950 p-4 rounded-xl border border-slate-800/80 space-y-3.5 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-3xl font-black text-white flex items-center justify-center gap-1.5">
|
<div class="text-3xl font-black text-white flex items-center justify-center gap-1">
|
||||||
<span id="grams-current-val">250</span>
|
<span id="grams-current-val">250</span>
|
||||||
<span id="grams-modal-unit" class="text-lg font-normal text-teal-400">g</span>
|
<span id="grams-modal-unit" class="text-sm font-normal text-teal-400">g</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="grams-percent-text" class="text-xs text-slate-400 mt-1">50% remaining</div>
|
<div id="grams-percent-text" class="text-xs text-slate-400 mt-0.5">Remaining in open pack</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -277,19 +211,19 @@
|
|||||||
>
|
>
|
||||||
|
|
||||||
<!-- Quick Decrement Buttons -->
|
<!-- Quick Decrement Buttons -->
|
||||||
<div class="flex items-center justify-center gap-2 pt-1 flex-wrap">
|
<div class="flex items-center justify-center gap-1.5 pt-1 flex-wrap">
|
||||||
<button onclick="applyGramsDelta(-10)" class="px-2.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-200 border border-slate-700 font-medium">-10</button>
|
<button onclick="applyGramsDelta(-10)" class="px-2.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-200 border border-slate-700 font-medium">-10</button>
|
||||||
<button onclick="applyGramsDelta(-25)" class="px-2.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-200 border border-slate-700 font-medium">-25</button>
|
<button onclick="applyGramsDelta(-25)" class="px-2.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-200 border border-slate-700 font-medium">-25</button>
|
||||||
<button onclick="applyGramsDelta(-50)" class="px-2.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-200 border border-slate-700 font-medium">-50</button>
|
<button onclick="applyGramsDelta(-50)" class="px-2.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-200 border border-slate-700 font-medium">-50</button>
|
||||||
<button onclick="applyGramsDelta(-100)" class="px-2.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-200 border border-slate-700 font-medium">-100</button>
|
<button onclick="applyGramsDelta(-100)" class="px-2.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-200 border border-slate-700 font-medium">-100</button>
|
||||||
<button onclick="setGramsPercent(0.5)" class="px-2.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-200 border border-slate-700 font-medium">Half</button>
|
<button onclick="setGramsPercent(0.5)" class="px-2.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-200 border border-slate-700 font-medium">Half</button>
|
||||||
<button onclick="setGramsPercent(0)" class="px-2.5 py-1.5 rounded-lg bg-rose-950/40 hover:bg-rose-900/50 text-xs text-rose-300 border border-rose-800/40 font-medium">Empty (0)</button>
|
<button onclick="setGramsPercent(0)" class="px-2.5 py-1.5 rounded-lg bg-rose-950/40 hover:bg-rose-900 text-xs text-rose-300 border border-rose-800 font-medium">Empty (0)</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Direct Numeric Input Option -->
|
<!-- Exact Input -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<label class="text-xs text-slate-400 shrink-0">Exact remaining:</label>
|
<label class="text-xs text-slate-400 shrink-0">Exact amount left:</label>
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -303,39 +237,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between pt-2">
|
<div class="flex items-center justify-between pt-2">
|
||||||
<button onclick="consumeAllAndRemove()" class="text-xs text-rose-400 hover:text-rose-300 flex items-center gap-1.5">
|
<button onclick="consumeAllAndRemove()" class="text-xs text-rose-400 hover:text-rose-300 flex items-center gap-1">
|
||||||
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
|
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
|
||||||
<span>Consumed all / Remove</span>
|
<span>Finished / Remove</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button onclick="closeGramsModal()" class="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm font-medium">Cancel</button>
|
<button onclick="closeGramsModal()" class="px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium">Cancel</button>
|
||||||
<button onclick="saveGramsAdjustment()" class="px-5 py-2 rounded-xl bg-teal-500 hover:bg-teal-400 text-white text-sm font-semibold shadow-md">Save Amount</button>
|
<button onclick="saveGramsAdjustment()" class="px-5 py-2 rounded-xl bg-teal-500 hover:bg-teal-400 text-white text-xs font-semibold shadow-md">Save Grams</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ADD / EDIT ITEM MODAL -->
|
<!-- ADD / EDIT ITEM MODAL -->
|
||||||
<div id="item-modal" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm hidden flex items-center justify-center p-4 overflow-y-auto">
|
<div id="item-modal" class="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm hidden flex items-center justify-center p-4 overflow-y-auto">
|
||||||
<div class="bg-slate-900 border border-slate-800 rounded-2xl max-w-lg w-full p-6 shadow-2xl space-y-4 my-8 animate-scale-up">
|
<div class="bg-slate-900 border border-slate-800 rounded-2xl max-w-lg w-full p-5 shadow-2xl space-y-4 my-8 animate-scale-up">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 id="item-modal-title" class="text-lg font-bold text-white">Add New Fridge Item</h3>
|
<h3 id="item-modal-title" class="text-base font-bold text-white">Add Item</h3>
|
||||||
<p class="text-xs text-slate-400">Scan barcode or type item details below</p>
|
<p class="text-xs text-slate-400">Enter product or scan barcode</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="closeItemModal()" class="text-slate-400 hover:text-white p-1">
|
<button onclick="closeItemModal()" class="text-slate-400 hover:text-white p-1">
|
||||||
<i data-lucide="x" class="w-5 h-5"></i>
|
<i data-lucide="x" class="w-5 h-5"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Barcode Search & Scan quick strip -->
|
<!-- Barcode Search strip -->
|
||||||
<div class="bg-slate-950/60 p-3 rounded-xl border border-slate-800/80 flex items-center gap-2">
|
<div class="bg-slate-950 p-2.5 rounded-xl border border-slate-800 flex items-center gap-2">
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
<i data-lucide="barcode" class="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2"></i>
|
<i data-lucide="barcode" class="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2"></i>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="modal-barcode"
|
id="modal-barcode"
|
||||||
placeholder="Barcode (e.g. 4008452023451)"
|
placeholder="Barcode number (e.g. 4008452023451)"
|
||||||
class="w-full bg-slate-900 border border-slate-800 focus:border-teal-500 rounded-lg pl-9 pr-3 py-1.5 text-xs text-white outline-none"
|
class="w-full bg-slate-900 border border-slate-800 focus:border-teal-500 rounded-lg pl-9 pr-3 py-1.5 text-xs text-white outline-none"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,75 +281,67 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="item-form" onsubmit="handleItemFormSubmit(event)" class="space-y-3.5 text-sm">
|
<form id="item-form" onsubmit="handleItemFormSubmit(event)" class="space-y-3 text-xs">
|
||||||
<input type="hidden" id="modal-item-id">
|
<input type="hidden" id="modal-item-id">
|
||||||
<input type="hidden" id="modal-image-url">
|
<input type="hidden" id="modal-image-url">
|
||||||
|
|
||||||
<!-- Product Image Preview if found -->
|
<!-- Product Image Preview if found -->
|
||||||
<div id="modal-image-container" class="hidden items-center gap-3 p-2 bg-slate-950 rounded-xl border border-slate-800">
|
<div id="modal-image-container" class="hidden items-center gap-3 p-2 bg-slate-950 rounded-xl border border-slate-800">
|
||||||
<img id="modal-image-preview" src="" alt="Product" class="w-12 h-12 object-contain rounded-lg bg-white/5">
|
<img id="modal-image-preview" src="" alt="Product" class="w-10 h-10 object-contain rounded-lg bg-white/5">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<span class="text-xs text-teal-400 font-medium">Product photo matched</span>
|
<span class="text-[11px] text-teal-400 font-semibold">Matched product image</span>
|
||||||
<p id="modal-image-label" class="text-xs text-slate-400 truncate"></p>
|
<p id="modal-image-label" class="text-xs text-slate-400 truncate"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Name & Brand -->
|
<!-- Name & Brand -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<label class="block text-xs text-slate-300 font-medium mb-1">Item Name *</label>
|
<label class="block text-slate-300 font-medium mb-1">Item Name *</label>
|
||||||
<input type="text" id="modal-name" required placeholder="e.g. Gouda Cheese" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
<input type="text" id="modal-name" required placeholder="e.g. Gouda Cheese" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-slate-300 font-medium mb-1">Brand</label>
|
<label class="block text-slate-300 font-medium mb-1">Brand</label>
|
||||||
<input type="text" id="modal-brand" placeholder="e.g. Milbona" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
<input type="text" id="modal-brand" placeholder="e.g. Milbona" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quantity & Unit -->
|
<!-- Quantity & Unit (Whole Packet Size & Current Amount) -->
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-2.5">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-slate-300 font-medium mb-1">Current Amount *</label>
|
<label class="block text-slate-300 font-medium mb-1">Current Amount *</label>
|
||||||
<input type="number" id="modal-quantity" step="any" min="0" required placeholder="250" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
<input type="number" id="modal-quantity" step="any" min="0" required placeholder="250" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-slate-300 font-medium mb-1">Package Total</label>
|
<label class="block text-slate-300 font-medium mb-1">1 Full Packet Size *</label>
|
||||||
<input type="number" id="modal-initial-quantity" step="any" min="0" placeholder="250" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
<input type="number" id="modal-initial-quantity" step="any" min="0" required placeholder="250" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-slate-300 font-medium mb-1">Unit</label>
|
<label class="block text-slate-300 font-medium mb-1">Unit</label>
|
||||||
<select id="modal-unit" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
<select id="modal-unit" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
||||||
<option value="g">Grams (g)</option>
|
<option value="g">Grams (g)</option>
|
||||||
<option value="ml">Milliliters (ml)</option>
|
<option value="ml">Milliliters (ml)</option>
|
||||||
<option value="pcs">Pieces (Stk)</option>
|
<option value="pcs">Pieces (Stk)</option>
|
||||||
<option value="kg">Kilograms (kg)</option>
|
<option value="kg">Kilograms (kg)</option>
|
||||||
<option value="l">Liters (L)</option>
|
<option value="l">Liters (L)</option>
|
||||||
<option value="pack">Pack / Packung</option>
|
<option value="pack">Pack</option>
|
||||||
<option value="slices">Slices</option>
|
<option value="slices">Slices</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category & Location -->
|
<!-- Category -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div>
|
||||||
<div>
|
<label class="block text-slate-300 font-medium mb-1">Category</label>
|
||||||
<label class="block text-xs text-slate-300 font-medium mb-1">Category</label>
|
<select id="modal-category" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
||||||
<select id="modal-category" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
<!-- Populated dynamically -->
|
||||||
<!-- Options populated by meta -->
|
</select>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-slate-300 font-medium mb-1">Location in Fridge</label>
|
|
||||||
<select id="modal-location" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
|
||||||
<!-- Options populated by meta -->
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expiration Date & Helpers -->
|
<!-- Expiration Date & Helpers -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<label class="block text-xs text-slate-300 font-medium">Expiration Date (MHD)</label>
|
<label class="block text-slate-300 font-medium">Expiration Date (MHD)</label>
|
||||||
<div class="flex items-center gap-1.5 text-[11px]">
|
<div class="flex items-center gap-1.5 text-[11px]">
|
||||||
<button type="button" onclick="setModalExpiryDays(3)" class="text-slate-400 hover:text-teal-400">+3d</button>
|
<button type="button" onclick="setModalExpiryDays(3)" class="text-slate-400 hover:text-teal-400">+3d</button>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
@@ -429,20 +355,15 @@
|
|||||||
<input type="date" id="modal-expiry" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
<input type="date" id="modal-expiry" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Opened Status & Notes -->
|
<!-- Notes -->
|
||||||
<div class="flex items-center gap-2 pt-1">
|
|
||||||
<input type="checkbox" id="modal-opened" class="w-4 h-4 accent-teal-500 rounded bg-slate-950 border-slate-800">
|
|
||||||
<label for="modal-opened" class="text-xs text-slate-300 cursor-pointer">Already opened pack / container</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-slate-300 font-medium mb-1">Notes (Optional)</label>
|
<label class="block text-slate-300 font-medium mb-1">Notes (Optional)</label>
|
||||||
<input type="text" id="modal-notes" placeholder="e.g. Left half for pasta tonight" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
<input type="text" id="modal-notes" placeholder="e.g. Opened for dinner" class="w-full bg-slate-950 border border-slate-800 focus:border-teal-500 rounded-xl px-3 py-2 text-white outline-none">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3 pt-3 border-t border-slate-800">
|
<div class="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800">
|
||||||
<button type="button" onclick="closeItemModal()" class="px-4 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm font-medium">Cancel</button>
|
<button type="button" onclick="closeItemModal()" class="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium">Cancel</button>
|
||||||
<button type="submit" id="modal-save-btn" class="px-6 py-2.5 rounded-xl bg-teal-500 hover:bg-teal-400 text-white text-sm font-semibold shadow-lg shadow-teal-500/20">Save Item</button>
|
<button type="submit" class="px-5 py-2 rounded-xl bg-teal-500 hover:bg-teal-400 text-white text-xs font-semibold shadow-md">Save Item</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,9 +373,9 @@
|
|||||||
<div id="scanner-modal" class="fixed inset-0 z-50 bg-black/85 backdrop-blur-md hidden flex items-center justify-center p-4">
|
<div id="scanner-modal" class="fixed inset-0 z-50 bg-black/85 backdrop-blur-md hidden flex items-center justify-center p-4">
|
||||||
<div class="bg-slate-900 border border-slate-800 rounded-3xl max-w-md w-full overflow-hidden shadow-2xl flex flex-col animate-scale-up">
|
<div class="bg-slate-900 border border-slate-800 rounded-3xl max-w-md w-full overflow-hidden shadow-2xl flex flex-col animate-scale-up">
|
||||||
|
|
||||||
<div class="p-4 bg-slate-850 border-b border-slate-800 flex items-center justify-between">
|
<div class="p-3.5 bg-slate-850 border-b border-slate-800 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="w-8 h-8 rounded-lg bg-teal-500/10 text-teal-400 flex items-center justify-center">
|
<div class="w-7 h-7 rounded-lg bg-teal-500/10 text-teal-400 flex items-center justify-center">
|
||||||
<i data-lucide="scan-barcode" class="w-4 h-4"></i>
|
<i data-lucide="scan-barcode" class="w-4 h-4"></i>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-bold text-white text-sm">Scan Product Barcode</span>
|
<span class="font-bold text-white text-sm">Scan Product Barcode</span>
|
||||||
@@ -465,7 +386,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Camera Preview Box -->
|
<!-- Camera Preview Box -->
|
||||||
<div class="relative bg-black flex items-center justify-center min-h-[300px] w-full overflow-hidden">
|
<div class="relative bg-black flex items-center justify-center min-h-[280px] w-full overflow-hidden">
|
||||||
<div id="barcode-reader" class="w-full"></div>
|
<div id="barcode-reader" class="w-full"></div>
|
||||||
<div id="scanner-overlay" class="absolute inset-0 pointer-events-none border-2 border-teal-400/40 rounded-2xl m-8 flex items-center justify-center">
|
<div id="scanner-overlay" class="absolute inset-0 pointer-events-none border-2 border-teal-400/40 rounded-2xl m-8 flex items-center justify-center">
|
||||||
<div class="w-full h-0.5 bg-red-500/80 animate-pulse shadow-lg shadow-red-500"></div>
|
<div class="w-full h-0.5 bg-red-500/80 animate-pulse shadow-lg shadow-red-500"></div>
|
||||||
@@ -473,9 +394,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scanner Status / Manual Fallback -->
|
<!-- Scanner Status / Manual Fallback -->
|
||||||
<div class="p-4 bg-slate-900 space-y-3">
|
<div class="p-3.5 bg-slate-900 space-y-2.5">
|
||||||
<div id="scanner-status" class="text-xs text-center text-slate-400">
|
<div id="scanner-status" class="text-xs text-center text-slate-400">
|
||||||
Point camera at standard EAN / UPC product barcode
|
Point camera at barcode
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -498,7 +419,7 @@
|
|||||||
<!-- TOAST NOTIFICATION CONTAINER -->
|
<!-- TOAST NOTIFICATION CONTAINER -->
|
||||||
<div id="toast-container" class="fixed bottom-5 right-5 z-50 flex flex-col gap-2 max-w-sm pointer-events-none"></div>
|
<div id="toast-container" class="fixed bottom-5 right-5 z-50 flex flex-col gap-2 max-w-sm pointer-events-none"></div>
|
||||||
|
|
||||||
<!-- MAIN SCRIPT -->
|
<!-- MAIN CLIENT SCRIPT -->
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -188,6 +188,30 @@ app.patch('/api/items/:id/quantity', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add whole packet (+1 full pack size)
|
||||||
|
app.post('/api/items/:id/add-packet', (req, res) => {
|
||||||
|
try {
|
||||||
|
const count = parseInt(req.body.count, 10) || 1;
|
||||||
|
const item = db.addPacket(req.params.id, count);
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||||
|
res.json(item);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove whole packet (-1 full pack size)
|
||||||
|
app.post('/api/items/:id/remove-packet', (req, res) => {
|
||||||
|
try {
|
||||||
|
const count = parseInt(req.body.count, 10) || 1;
|
||||||
|
const item = db.removePacket(req.params.id, count);
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||||
|
res.json(item);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Delete item
|
// Delete item
|
||||||
app.delete('/api/items/:id', (req, res) => {
|
app.delete('/api/items/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -47,6 +47,24 @@ assert.strictEqual(newItem.name, 'Test Yogurt');
|
|||||||
assert.strictEqual(newItem.quantity, 500);
|
assert.strictEqual(newItem.quantity, 500);
|
||||||
assert.strictEqual(newItem.unit, 'g');
|
assert.strictEqual(newItem.unit, 'g');
|
||||||
|
|
||||||
|
// Test Whole Packets: addPacket & removePacket
|
||||||
|
console.log('Testing packet addition (+1 whole packet of 500g)...');
|
||||||
|
const testPack = db.createItem({
|
||||||
|
name: 'Test Milk Pack',
|
||||||
|
quantity: 1000,
|
||||||
|
initial_quantity: 1000,
|
||||||
|
unit: 'ml'
|
||||||
|
});
|
||||||
|
const packAdded = db.addPacket(testPack.id, 1);
|
||||||
|
assert.strictEqual(packAdded.quantity, 2000, 'Adding 1 packet should add 1000ml');
|
||||||
|
|
||||||
|
console.log('Testing packet removal (-1 whole packet)...');
|
||||||
|
const packRemoved = db.removePacket(testPack.id, 1);
|
||||||
|
assert.strictEqual(packRemoved.quantity, 1000, 'Removing 1 packet should leave 1000ml');
|
||||||
|
|
||||||
|
db.deleteItem(testPack.id);
|
||||||
|
console.log('✓ Whole packet tests passed!');
|
||||||
|
|
||||||
// Adjust grams: e.g. consumed 150g -> remaining 350g
|
// Adjust grams: e.g. consumed 150g -> remaining 350g
|
||||||
console.log('Testing partial consumption (-150g)...');
|
console.log('Testing partial consumption (-150g)...');
|
||||||
const adjusted = db.adjustQuantity(newItem.id, { delta: -150 });
|
const adjusted = db.adjustQuantity(newItem.id, { delta: -150 });
|
||||||
|
|||||||
Reference in New Issue
Block a user