Files
recipe-mockup/js/views/ShoppingList.js
ulfrxdev 71b91b50b4
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m16s
Keep checked items in place within their category
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 00:04:07 +02:00

286 lines
11 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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();
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;
}
const groups = groupItemsByCategory(items);
const html = groups.map(({ cat, items: catItems }) => {
const icon = CATEGORY_ICONS[cat] || 'fa-jar';
const uncheckedCount = catItems.filter((i) => !i.checked).length;
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;">${uncheckedCount}/${catItems.length}</span>
</div>
${catItems.map((item) => itemRowHtml(item)).join('')}
</section>`;
}).join('');
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;
}