- New ShoppingList.js view with category-grouped items - Check item = auto-transfer to pantry - "Generate shortfalls" button computes weekly needs vs pantry stock - Badge on shopping tab icon shows unchecked item count - Bottom dock expanded to 5 columns for new tab Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
305 lines
12 KiB
JavaScript
305 lines
12 KiB
JavaScript
import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js?v=8';
|
|
import {
|
|
KITCHEN_LIST_ID,
|
|
loadShoppingState,
|
|
saveShoppingState,
|
|
addOrMergeShoppingLines,
|
|
removeItemFromList,
|
|
clearCheckedInList,
|
|
loadPantry,
|
|
savePantry,
|
|
computeShortfalls,
|
|
categoryLabel,
|
|
} from '../services/pantryShopping.js?v=2';
|
|
import { aggregateWeekIngredientNeed } from '../services/planIngredients.js?v=2';
|
|
import { loadPlans } from '../services/planStore.js?v=2';
|
|
import { startOfWeekMonday } from '../services/dateUtils.js';
|
|
import { showAppToast } from '../ui/toast.js';
|
|
|
|
/* ── helpers ── */
|
|
|
|
function esc(s) {
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function unitLabel(u) {
|
|
return u === 'szt' ? 'szt.' : u;
|
|
}
|
|
|
|
function formatQty(n) {
|
|
const rounded = Math.round((Number(n) || 0) * 10) / 10;
|
|
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/, '');
|
|
}
|
|
|
|
const CATEGORY_ICONS = {
|
|
pieczywo: 'fa-bread-slice',
|
|
nabial: 'fa-cheese',
|
|
mieso_ryby: 'fa-drumstick-bite',
|
|
warzywa: 'fa-carrot',
|
|
owoce: 'fa-apple-whole',
|
|
suche: 'fa-wheat-awn',
|
|
przyprawy: 'fa-leaf',
|
|
inne: 'fa-jar',
|
|
};
|
|
|
|
const CATEGORY_ORDER = ['pieczywo', 'nabial', 'mieso_ryby', 'warzywa', 'owoce', 'suche', 'przyprawy', 'inne'];
|
|
|
|
/* ══════════════════════ HTML SHELL ══════════════════════ */
|
|
|
|
export function getShoppingListHTML() {
|
|
return `
|
|
<div id="shopping-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden z-10" style="background:#2d2e2b !important;">
|
|
|
|
<!-- ── header ── -->
|
|
<div class="shrink-0 px-4 pt-5 pb-2">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h1 class="text-[18px] font-bold" style="color:#f2efe8;">Lista zakupów</h1>
|
|
<button type="button" id="sl-clear-checked" class="text-[11px] font-semibold px-3 py-1.5 rounded-full transition-colors" style="background:#393937; color:#9b978f; border:1px solid #444442;" aria-label="Usuń kupione">
|
|
Wyczyść kupione
|
|
</button>
|
|
</div>
|
|
<button type="button" id="sl-generate" class="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-[13px] font-semibold transition-colors active:scale-[0.98]" style="background:#393937; color:#ddd6ca; border:1px solid #444442;">
|
|
<i class="fas fa-wand-magic-sparkles text-[12px]" style="color:rgb(var(--accent-rgb));"></i>
|
|
Generuj braki z planera
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ── scrollable list ── -->
|
|
<div id="sl-scroll" class="flex-1 overflow-y-auto no-scrollbar px-4 pt-2 pb-24" style="background:#2d2e2b !important;">
|
|
<div id="sl-board"></div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
/* ══════════════════════ RENDERING ══════════════════════ */
|
|
|
|
function getKitchenItems() {
|
|
const state = loadShoppingState();
|
|
const list = state.lists.find((l) => l.id === KITCHEN_LIST_ID && l.type === 'kitchen');
|
|
return list ? /** @type {import('../services/pantryShopping.js').KitchenShoppingItem[]} */ (list.items) : [];
|
|
}
|
|
|
|
function groupItemsByCategory(items) {
|
|
/** @type {Map<string, typeof items>} */
|
|
const groups = new Map();
|
|
for (const item of items) {
|
|
const cat = item.category || 'inne';
|
|
if (!groups.has(cat)) groups.set(cat, []);
|
|
groups.get(cat).push(item);
|
|
}
|
|
return CATEGORY_ORDER
|
|
.filter((cat) => groups.has(cat))
|
|
.map((cat) => ({ cat, items: groups.get(cat) }));
|
|
}
|
|
|
|
function itemRowHtml(item) {
|
|
const def = INGREDIENTS[item.ingredientId];
|
|
const icon = def ? (CATEGORY_ICONS[def.category] || 'fa-jar') : 'fa-jar';
|
|
const image = def?.image;
|
|
const checked = item.checked;
|
|
|
|
const mediaHtml = image
|
|
? `<img src="${esc(image)}" alt="" class="w-8 h-8 rounded-lg object-cover shrink-0">`
|
|
: `<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style="background:#2f2f2d;"><i class="fas ${icon} text-xs" style="color:#8f8b84;"></i></div>`;
|
|
|
|
return `
|
|
<div class="sl-row flex items-center gap-3 py-2.5 px-3 rounded-xl mb-1.5 transition-all duration-200 ${checked ? 'sl-checked' : ''}" style="background:${checked ? '#2f2f2d' : '#393937'}; border:1px solid ${checked ? '#3a3a38' : '#444442'};" data-id="${esc(item.id)}">
|
|
<button type="button" class="sl-check shrink-0 w-6 h-6 rounded-full flex items-center justify-center transition-colors" style="background:${checked ? 'rgb(var(--success-rgb))' : 'transparent'}; border:2px solid ${checked ? 'rgb(var(--success-rgb))' : '#6d6c67'};" aria-label="${checked ? 'Oznacz jako niekupione' : 'Oznacz jako kupione'}">
|
|
${checked ? '<i class="fas fa-check text-[10px]" style="color:#1a1a1a;"></i>' : ''}
|
|
</button>
|
|
${mediaHtml}
|
|
<div class="flex-1 min-w-0 ${checked ? 'opacity-40' : ''}">
|
|
<span class="block text-[13px] font-medium leading-tight truncate ${checked ? 'line-through' : ''}" style="color:#ddd6ca;">${esc(item.name)}</span>
|
|
</div>
|
|
<span class="text-[13px] font-semibold tabular-nums shrink-0 ${checked ? 'opacity-40 line-through' : ''}" style="color:#b7ada1;">${esc(formatQty(item.amount))} ${esc(unitLabel(item.unit))}</span>
|
|
<button type="button" class="sl-remove shrink-0 w-7 h-7 rounded-full flex items-center justify-center transition-colors" style="background:transparent; color:#6d6c67;" aria-label="Usuń">
|
|
<i class="fas fa-xmark text-xs"></i>
|
|
</button>
|
|
</div>`;
|
|
}
|
|
|
|
function renderBoard() {
|
|
const root = document.getElementById('sl-board');
|
|
if (!root) return;
|
|
|
|
const items = getKitchenItems();
|
|
const unchecked = items.filter((i) => !i.checked);
|
|
const checked = items.filter((i) => i.checked);
|
|
|
|
if (items.length === 0) {
|
|
root.innerHTML = `
|
|
<div class="flex flex-col items-center justify-center py-16 text-center">
|
|
<div class="w-14 h-14 rounded-2xl flex items-center justify-center mb-4" style="background:#393937;">
|
|
<i class="fas fa-cart-shopping text-xl" style="color:#6d6c67;"></i>
|
|
</div>
|
|
<p class="text-[14px] font-semibold mb-1" style="color:#ddd6ca;">Lista jest pusta</p>
|
|
<p class="text-[12px] max-w-[14rem]" style="color:#9b978f;">Kliknij „Generuj braki z planera" aby dodać składniki na bieżący tydzień.</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
|
|
// Unchecked items grouped by category
|
|
if (unchecked.length > 0) {
|
|
const groups = groupItemsByCategory(unchecked);
|
|
html += groups.map(({ cat, items: catItems }) => {
|
|
const icon = CATEGORY_ICONS[cat] || 'fa-jar';
|
|
return `
|
|
<section class="mb-4">
|
|
<div class="flex items-center gap-1.5 mb-2 px-1">
|
|
<i class="fas ${icon} text-[10px]" style="color:#9b978f;"></i>
|
|
<p class="text-[10px] font-bold uppercase tracking-wider" style="color:#9b978f;">${esc(categoryLabel(cat))}</p>
|
|
<span class="text-[10px]" style="color:#6d6c67;">${catItems.length}</span>
|
|
</div>
|
|
${catItems.map((item) => itemRowHtml(item)).join('')}
|
|
</section>`;
|
|
}).join('');
|
|
}
|
|
|
|
// Checked items at the bottom
|
|
if (checked.length > 0) {
|
|
html += `
|
|
<section class="mt-4 pt-3" style="border-top:1px solid #3a3a38;">
|
|
<div class="flex items-center gap-1.5 mb-2 px-1">
|
|
<i class="fas fa-check text-[10px]" style="color:#6d6c67;"></i>
|
|
<p class="text-[10px] font-bold uppercase tracking-wider" style="color:#6d6c67;">Kupione</p>
|
|
<span class="text-[10px]" style="color:#6d6c67;">${checked.length}</span>
|
|
</div>
|
|
${checked.map((item) => itemRowHtml(item)).join('')}
|
|
</section>`;
|
|
}
|
|
|
|
root.innerHTML = html;
|
|
bindRowEvents(root);
|
|
}
|
|
|
|
/* ══════════════════════ EVENTS ══════════════════════ */
|
|
|
|
function bindRowEvents(root) {
|
|
root.querySelectorAll('.sl-row').forEach((row) => {
|
|
const id = row.dataset.id;
|
|
|
|
row.querySelector('.sl-check')?.addEventListener('click', () => {
|
|
handleToggle(id);
|
|
});
|
|
|
|
row.querySelector('.sl-remove')?.addEventListener('click', () => {
|
|
removeItemFromList(KITCHEN_LIST_ID, id);
|
|
renderBoard();
|
|
updateBadge();
|
|
});
|
|
});
|
|
}
|
|
|
|
function handleToggle(itemId) {
|
|
const state = loadShoppingState();
|
|
const list = state.lists.find((l) => l.id === KITCHEN_LIST_ID);
|
|
if (!list) return;
|
|
const item = list.items.find((i) => i.id === itemId);
|
|
if (!item) return;
|
|
|
|
const wasChecked = item.checked;
|
|
item.checked = !wasChecked;
|
|
saveShoppingState(state);
|
|
|
|
if (!wasChecked) {
|
|
// Just checked → apply to pantry
|
|
applyItemToPantry(item);
|
|
}
|
|
|
|
renderBoard();
|
|
updateBadge();
|
|
|
|
if (typeof window.refreshPantry === 'function') window.refreshPantry();
|
|
}
|
|
|
|
function applyItemToPantry(item) {
|
|
const def = INGREDIENTS[item.ingredientId];
|
|
if (!def) return;
|
|
|
|
const pantry = loadPantry();
|
|
|
|
if (item.productId) {
|
|
let val = pantry[item.ingredientId];
|
|
if (!val || typeof val === 'number') {
|
|
val = { items: [], _total: 0 };
|
|
pantry[item.ingredientId] = val;
|
|
}
|
|
const idx = val.items.findIndex((i) => i.productId === item.productId);
|
|
if (idx >= 0) val.items[idx].qty = Math.round((val.items[idx].qty + item.amount) * 1000) / 1000;
|
|
else val.items.push({ productId: item.productId, qty: Math.round(item.amount * 1000) / 1000 });
|
|
val._total = Math.round(val.items.reduce((s, i) => s + i.qty, 0) * 1000) / 1000;
|
|
} else {
|
|
const cur = typeof pantry[item.ingredientId] === 'number' ? pantry[item.ingredientId] : 0;
|
|
pantry[item.ingredientId] = Math.round((cur + item.amount) * 1000) / 1000;
|
|
}
|
|
|
|
savePantry(pantry);
|
|
}
|
|
|
|
function handleGenerate() {
|
|
const plans = loadPlans();
|
|
const weekStart = startOfWeekMonday(new Date());
|
|
const needLines = aggregateWeekIngredientNeed(plans, weekStart);
|
|
const pantry = loadPantry();
|
|
const shortfalls = computeShortfalls(needLines, pantry);
|
|
|
|
if (shortfalls.length === 0) {
|
|
showAppToast('Wszystko masz w spiżarni!');
|
|
return;
|
|
}
|
|
|
|
const lines = shortfalls.map((s) => ({
|
|
ingredientId: s.ingredientId,
|
|
amount: s.shortfall,
|
|
unit: s.unit,
|
|
name: s.name,
|
|
category: s.category,
|
|
sourceNote: 'Z planera',
|
|
}));
|
|
|
|
addOrMergeShoppingLines(lines, KITCHEN_LIST_ID);
|
|
renderBoard();
|
|
updateBadge();
|
|
showAppToast(`Dodano ${shortfalls.length} pozycji z planera`);
|
|
}
|
|
|
|
function handleClearChecked() {
|
|
clearCheckedInList(KITCHEN_LIST_ID);
|
|
renderBoard();
|
|
updateBadge();
|
|
}
|
|
|
|
/* ══════════════════════ BADGE ══════════════════════ */
|
|
|
|
function updateBadge() {
|
|
const items = getKitchenItems();
|
|
const uncheckedCount = items.filter((i) => !i.checked).length;
|
|
const badge = document.getElementById('nav-shopping-badge');
|
|
if (!badge) return;
|
|
if (uncheckedCount > 0) {
|
|
badge.textContent = String(uncheckedCount > 99 ? '99+' : uncheckedCount);
|
|
badge.classList.remove('hidden');
|
|
} else {
|
|
badge.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
/* ══════════════════════ PUBLIC API ══════════════════════ */
|
|
|
|
export function refreshShoppingList() {
|
|
renderBoard();
|
|
updateBadge();
|
|
}
|
|
|
|
export function setupShoppingList() {
|
|
renderBoard();
|
|
updateBadge();
|
|
|
|
document.getElementById('sl-generate')?.addEventListener('click', handleGenerate);
|
|
document.getElementById('sl-clear-checked')?.addEventListener('click', handleClearChecked);
|
|
|
|
window.refreshShoppingList = refreshShoppingList;
|
|
}
|