This commit is contained in:
@@ -383,19 +383,19 @@ export function categoryLabel(cat) {
|
|||||||
* Pantry v2 — hybrydowy format.
|
* Pantry v2 — hybrydowy format.
|
||||||
*
|
*
|
||||||
* Wartość dla składnika może być:
|
* Wartość dla składnika może być:
|
||||||
* number — składnik generyczny (bez zdefiniowanych produktów)
|
* number — składnik bez produktów (generyczny)
|
||||||
* { _total, items: [{productId, qty}], generic } — składnik z produktami
|
* { _total, items: [{productId, qty}] } — składnik z produktami
|
||||||
*
|
*
|
||||||
* _total = generic + sum(items[].qty) (cache, zawsze przeliczany przy zapisie)
|
* Brak pojęcia "generic" — jeśli składnik ma produkty, każda ilość
|
||||||
|
* musi być przypisana do konkretnego produktu.
|
||||||
* ══════════════════════════════════════════════════════════════════════ */
|
* ══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
/** @typedef {{ productId: string, qty: number }} PantryProductItem */
|
/** @typedef {{ productId: string, qty: number }} PantryProductItem */
|
||||||
/** @typedef {{ _total: number, items: PantryProductItem[], generic: number }} PantryProductEntry */
|
/** @typedef {{ _total: number, items: PantryProductItem[] }} PantryProductEntry */
|
||||||
/** @typedef {Record<string, number | PantryProductEntry>} PantryV2 */
|
/** @typedef {Record<string, number | PantryProductEntry>} PantryV2 */
|
||||||
|
|
||||||
function recalcTotal(entry) {
|
function recalcTotal(entry) {
|
||||||
const itemSum = entry.items.reduce((s, i) => s + i.qty, 0);
|
entry._total = Math.round(entry.items.reduce((s, i) => s + i.qty, 0) * 1000) / 1000;
|
||||||
entry._total = Math.round((entry.generic + itemSum) * 1000) / 1000;
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,13 +406,30 @@ function normalizePantryEntry(ingredientId, val) {
|
|||||||
.filter(i => i && typeof i.productId === 'string' && PRODUCTS[i.productId] && Number.isFinite(Number(i.qty)) && Number(i.qty) > 0)
|
.filter(i => i && typeof i.productId === 'string' && PRODUCTS[i.productId] && Number.isFinite(Number(i.qty)) && Number(i.qty) > 0)
|
||||||
.map(i => ({ productId: i.productId, qty: Math.round(Number(i.qty) * 1000) / 1000 }))
|
.map(i => ({ productId: i.productId, qty: Math.round(Number(i.qty) * 1000) / 1000 }))
|
||||||
: [];
|
: [];
|
||||||
|
// Migrate generic stock → first product
|
||||||
const generic = Math.max(0, Number(val.generic) || 0);
|
const generic = Math.max(0, Number(val.generic) || 0);
|
||||||
return recalcTotal({ items, generic, _total: 0 });
|
if (generic > 0 && ingredientHasProducts(ingredientId)) {
|
||||||
|
const products = getProductsForIngredient(ingredientId);
|
||||||
|
if (products.length > 0) {
|
||||||
|
const firstPid = products[0].id;
|
||||||
|
const existing = items.find(i => i.productId === firstPid);
|
||||||
|
if (existing) existing.qty = Math.round((existing.qty + generic) * 1000) / 1000;
|
||||||
|
else items.push({ productId: firstPid, qty: Math.round(generic * 1000) / 1000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recalcTotal({ items, _total: 0 });
|
||||||
}
|
}
|
||||||
const n = Number(val);
|
const n = Number(val);
|
||||||
if (!Number.isFinite(n) || n < 0) return null;
|
if (!Number.isFinite(n) || n < 0) return null;
|
||||||
if (ingredientHasProducts(ingredientId)) {
|
if (ingredientHasProducts(ingredientId)) {
|
||||||
return recalcTotal({ items: [], generic: n, _total: 0 });
|
// Migrate number → first product
|
||||||
|
if (n > 0) {
|
||||||
|
const products = getProductsForIngredient(ingredientId);
|
||||||
|
if (products.length > 0) {
|
||||||
|
return recalcTotal({ items: [{ productId: products[0].id, qty: n }], _total: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recalcTotal({ items: [], _total: 0 });
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
@@ -480,30 +497,18 @@ export function getPantryProducts(ingredientId, pantry) {
|
|||||||
return val.items || [];
|
return val.items || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ilość generyczna (nieprzypisana do produktu). */
|
/** @deprecated No generic stock for ingredients with products. Returns 0 for those. */
|
||||||
export function getPantryGeneric(ingredientId, pantry) {
|
export function getPantryGeneric(ingredientId, pantry) {
|
||||||
const val = pantry[ingredientId];
|
const val = pantry[ingredientId];
|
||||||
if (typeof val === 'number') return val;
|
if (typeof val === 'number') return val;
|
||||||
if (!val) return 0;
|
return 0;
|
||||||
return val.generic || 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ustaw ilość generyczną składnika (bez produktu). */
|
/** Ustaw ilość składnika BEZ produktów. Dla składników z produktami użyj setPantryProductQty. */
|
||||||
export function setPantryQty(ingredientId, qty) {
|
export function setPantryQty(ingredientId, qty) {
|
||||||
const pantry = loadPantry();
|
const pantry = loadPantry();
|
||||||
const val = pantry[ingredientId];
|
|
||||||
if (val && typeof val === 'object') {
|
|
||||||
val.generic = Math.max(0, Math.round(qty * 1000) / 1000);
|
|
||||||
recalcTotal(val);
|
|
||||||
if (val._total <= 0) delete pantry[ingredientId];
|
|
||||||
} else {
|
|
||||||
if (qty <= 0 || !Number.isFinite(qty)) delete pantry[ingredientId];
|
if (qty <= 0 || !Number.isFinite(qty)) delete pantry[ingredientId];
|
||||||
else if (ingredientHasProducts(ingredientId)) {
|
else pantry[ingredientId] = Math.round(qty * 1000) / 1000;
|
||||||
pantry[ingredientId] = recalcTotal({ items: [], generic: Math.round(qty * 1000) / 1000, _total: 0 });
|
|
||||||
} else {
|
|
||||||
pantry[ingredientId] = Math.round(qty * 1000) / 1000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
savePantry(pantry);
|
savePantry(pantry);
|
||||||
return pantry;
|
return pantry;
|
||||||
}
|
}
|
||||||
@@ -513,8 +518,7 @@ export function setPantryProductQty(ingredientId, productId, qty) {
|
|||||||
const pantry = loadPantry();
|
const pantry = loadPantry();
|
||||||
let val = pantry[ingredientId];
|
let val = pantry[ingredientId];
|
||||||
if (!val || typeof val === 'number') {
|
if (!val || typeof val === 'number') {
|
||||||
const generic = typeof val === 'number' ? val : 0;
|
val = { items: [], _total: 0 };
|
||||||
val = { items: [], generic, _total: 0 };
|
|
||||||
pantry[ingredientId] = val;
|
pantry[ingredientId] = val;
|
||||||
}
|
}
|
||||||
const idx = val.items.findIndex(i => i.productId === productId);
|
const idx = val.items.findIndex(i => i.productId === productId);
|
||||||
@@ -545,27 +549,21 @@ export function applyCheckedKitchenListToPantry() {
|
|||||||
const def = INGREDIENTS[it.ingredientId];
|
const def = INGREDIENTS[it.ingredientId];
|
||||||
if (!def) continue;
|
if (!def) continue;
|
||||||
if (normalizeUnitForPantry(it.unit, def.pantryUnit) === null) continue;
|
if (normalizeUnitForPantry(it.unit, def.pantryUnit) === null) continue;
|
||||||
const val = pantry[it.ingredientId];
|
|
||||||
if (val && typeof val === 'object') {
|
|
||||||
if (it.productId && PRODUCTS[it.productId]) {
|
if (it.productId && PRODUCTS[it.productId]) {
|
||||||
|
// Add to specific product
|
||||||
|
let val = pantry[it.ingredientId];
|
||||||
|
if (!val || typeof val === 'number') {
|
||||||
|
val = { items: [], _total: 0 };
|
||||||
|
pantry[it.ingredientId] = val;
|
||||||
|
}
|
||||||
const idx = val.items.findIndex(i => i.productId === it.productId);
|
const idx = val.items.findIndex(i => i.productId === it.productId);
|
||||||
if (idx >= 0) val.items[idx].qty = Math.round((val.items[idx].qty + it.amount) * 1000) / 1000;
|
if (idx >= 0) val.items[idx].qty = Math.round((val.items[idx].qty + it.amount) * 1000) / 1000;
|
||||||
else val.items.push({ productId: it.productId, qty: Math.round(it.amount * 1000) / 1000 });
|
else val.items.push({ productId: it.productId, qty: Math.round(it.amount * 1000) / 1000 });
|
||||||
} else {
|
|
||||||
val.generic = Math.round(((val.generic || 0) + it.amount) * 1000) / 1000;
|
|
||||||
}
|
|
||||||
recalcTotal(val);
|
recalcTotal(val);
|
||||||
} else if (ingredientHasProducts(it.ingredientId)) {
|
|
||||||
const cur = typeof val === 'number' ? val : 0;
|
|
||||||
const entry = { items: [], generic: cur, _total: 0 };
|
|
||||||
if (it.productId && PRODUCTS[it.productId]) {
|
|
||||||
entry.items.push({ productId: it.productId, qty: Math.round(it.amount * 1000) / 1000 });
|
|
||||||
} else {
|
} else {
|
||||||
entry.generic = Math.round((entry.generic + it.amount) * 1000) / 1000;
|
// Generic ingredient (no products)
|
||||||
}
|
const cur = typeof pantry[it.ingredientId] === 'number' ? pantry[it.ingredientId] : 0;
|
||||||
pantry[it.ingredientId] = recalcTotal(entry);
|
|
||||||
} else {
|
|
||||||
const cur = typeof val === 'number' ? val : 0;
|
|
||||||
pantry[it.ingredientId] = Math.round((cur + it.amount) * 1000) / 1000;
|
pantry[it.ingredientId] = Math.round((cur + it.amount) * 1000) / 1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
savePlans,
|
savePlans,
|
||||||
} from '../services/planStore.js?v=2';
|
} from '../services/planStore.js?v=2';
|
||||||
import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=4';
|
import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=4';
|
||||||
import { loadPantry } from '../services/pantryShopping.js?v=2';
|
import { loadPantry, getPantryTotal, getPantryProducts, setPantryQty, setPantryProductQty, addOrMergeShoppingLines } from '../services/pantryShopping.js?v=2';
|
||||||
import { showAppToast } from './toast.js';
|
import { showAppToast } from './toast.js';
|
||||||
import {
|
import {
|
||||||
bindCalendarDayClicks,
|
bindCalendarDayClicks,
|
||||||
@@ -80,6 +80,8 @@ function getProductCardHTML() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="mpe-pc-stock" class="space-y-1.5"></div>
|
||||||
|
<div id="mpe-pc-shop"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -131,10 +133,100 @@ function openProductCard(ingredientId, productId) {
|
|||||||
document.getElementById('mpe-pc-carbs').textContent = nutrition.carbs + 'g';
|
document.getElementById('mpe-pc-carbs').textContent = nutrition.carbs + 'g';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stock section
|
||||||
|
renderPlannerCardStock(ingredientId, productId);
|
||||||
|
|
||||||
|
// Shop section
|
||||||
|
renderPlannerCardShop(ingredientId, productId);
|
||||||
|
|
||||||
overlay.classList.remove('hidden');
|
overlay.classList.remove('hidden');
|
||||||
overlay.style.pointerEvents = 'auto';
|
overlay.style.pointerEvents = 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPlannerCardStock(ingredientId, productId) {
|
||||||
|
const wrap = document.getElementById('mpe-pc-stock');
|
||||||
|
if (!wrap) return;
|
||||||
|
const def = INGREDIENTS[ingredientId];
|
||||||
|
if (!def) return;
|
||||||
|
const u = def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit;
|
||||||
|
const pantry = loadPantry();
|
||||||
|
const product = productId ? PRODUCTS[productId] : null;
|
||||||
|
|
||||||
|
let qty, step, pid;
|
||||||
|
if (product) {
|
||||||
|
const items = getPantryProducts(ingredientId, pantry);
|
||||||
|
qty = items.find(i => i.productId === productId)?.qty || 0;
|
||||||
|
step = product.packSize || 1;
|
||||||
|
pid = productId;
|
||||||
|
} else {
|
||||||
|
qty = getPantryTotal(ingredientId, pantry);
|
||||||
|
step = def.purchasePack?.amount || (def.pantryUnit === 'szt' ? 1 : 10);
|
||||||
|
pid = '_generic';
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5 text-gray-500">Zapas</p>
|
||||||
|
<div class="flex items-center justify-center gap-3 rounded-xl px-3 py-2 bg-[#393937]">
|
||||||
|
<button type="button" class="mpe-pc-stock-btn w-9 h-9 rounded-xl flex items-center justify-center active:scale-95 bg-[#2f2f2d] text-[#d7d2c8]" data-pid="${esc(pid)}" data-step="${step}" data-dir="-1"><i class="fas fa-minus text-xs"></i></button>
|
||||||
|
<span class="text-[17px] font-bold tabular-nums" style="color:#6ee7b7;">${Math.round(qty)} ${esc(u)}</span>
|
||||||
|
<button type="button" class="mpe-pc-stock-btn w-9 h-9 rounded-xl flex items-center justify-center active:scale-95 bg-[#2f2f2d] text-[#d7d2c8]" data-pid="${esc(pid)}" data-step="${step}" data-dir="1"><i class="fas fa-plus text-xs"></i></button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
wrap.querySelectorAll('.mpe-pc-stock-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const bpid = btn.dataset.pid;
|
||||||
|
const bstep = Number(btn.dataset.step) || 1;
|
||||||
|
const dir = Number(btn.dataset.dir);
|
||||||
|
const p = loadPantry();
|
||||||
|
if (bpid === '_generic') {
|
||||||
|
const cur = getPantryTotal(ingredientId, p);
|
||||||
|
setPantryQty(ingredientId, Math.max(0, cur + bstep * dir));
|
||||||
|
} else {
|
||||||
|
const items = getPantryProducts(ingredientId, p);
|
||||||
|
const cur = items.find(i => i.productId === bpid)?.qty || 0;
|
||||||
|
setPantryProductQty(ingredientId, bpid, Math.max(0, cur + bstep * dir));
|
||||||
|
}
|
||||||
|
renderPlannerCardStock(ingredientId, productId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlannerCardShop(ingredientId, productId) {
|
||||||
|
const wrap = document.getElementById('mpe-pc-shop');
|
||||||
|
if (!wrap) return;
|
||||||
|
const def = INGREDIENTS[ingredientId];
|
||||||
|
if (!def) return;
|
||||||
|
const u = def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit;
|
||||||
|
const product = productId ? PRODUCTS[productId] : null;
|
||||||
|
const packSize = product?.packSize || def.purchasePack?.amount;
|
||||||
|
const packLabel = product?.packLabel || def.purchasePack?.label;
|
||||||
|
const usesPacks = Boolean(packSize && packSize > 0);
|
||||||
|
const btnLabel = usesPacks ? `Dodaj na listę (${packLabel || `${packSize} ${u}`})` : 'Dodaj na listę';
|
||||||
|
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<button type="button" id="mpe-pc-add-list" 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:#ddd6ca; color:#2d2e2b;">
|
||||||
|
<i class="fas fa-cart-plus text-[11px]"></i>${esc(btnLabel)}
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
document.getElementById('mpe-pc-add-list')?.addEventListener('click', () => {
|
||||||
|
const amt = usesPacks ? packSize : (def.pantryUnit === 'szt' ? 1 : 10);
|
||||||
|
const line = {
|
||||||
|
ingredientId,
|
||||||
|
amount: amt,
|
||||||
|
unit: u,
|
||||||
|
name: product?.name || def.name,
|
||||||
|
category: def.category,
|
||||||
|
sourceNote: packLabel || 'Z planera',
|
||||||
|
};
|
||||||
|
if (productId) line.productId = productId;
|
||||||
|
addOrMergeShoppingLines([line]);
|
||||||
|
// Quick visual feedback
|
||||||
|
const btn = document.getElementById('mpe-pc-add-list');
|
||||||
|
if (btn) { btn.textContent = '✓ Dodano'; setTimeout(() => { btn.innerHTML = `<i class="fas fa-cart-plus text-[11px]"></i>${esc(btnLabel)}`; }, 1200); }
|
||||||
|
window.refreshShopping?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function closeProductCard() {
|
function closeProductCard() {
|
||||||
const overlay = document.getElementById('mpe-product-card-overlay');
|
const overlay = document.getElementById('mpe-product-card-overlay');
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
getProductsForIngredient,
|
getProductsForIngredient,
|
||||||
ingredientHasProducts,
|
ingredientHasProducts,
|
||||||
} from '../data/catalog.js?v=8';
|
} from '../data/catalog.js?v=8';
|
||||||
import { addIngredientToKitchenList, categoryLabel, loadPantry, setPantryQty, setPantryProductQty, getPantryTotal, getPantryProducts, getPantryGeneric } from '../services/pantryShopping.js?v=2';
|
import { addIngredientToKitchenList, addOrMergeShoppingLines, categoryLabel, loadPantry, setPantryQty, setPantryProductQty, getPantryTotal, getPantryProducts } from '../services/pantryShopping.js?v=2';
|
||||||
import { showAppToast } from '../ui/toast.js';
|
import { showAppToast } from '../ui/toast.js';
|
||||||
|
|
||||||
/* ── helpers ── */
|
/* ── helpers ── */
|
||||||
@@ -225,37 +225,69 @@ function getFilteredIds(searchRaw) {
|
|||||||
}).sort((a, b) => INGREDIENTS[a].name.localeCompare(INGREDIENTS[b].name, 'pl'));
|
}).sort((a, b) => INGREDIENTS[a].name.localeCompare(INGREDIENTS[b].name, 'pl'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowHtml(id, pantry) {
|
function ingredientRowHtml(id, pantry) {
|
||||||
const def = INGREDIENTS[id];
|
const def = INGREDIENTS[id];
|
||||||
const qty = getPantryTotal(id, pantry);
|
|
||||||
const u = unitLabel(def.pantryUnit);
|
|
||||||
const hasStock = qty > 0;
|
|
||||||
const icon = CATEGORY_ICONS[def.category] || 'fa-jar';
|
|
||||||
const products = getProductsForIngredient(id);
|
const products = getProductsForIngredient(id);
|
||||||
const productCount = products.length;
|
const icon = CATEGORY_ICONS[def.category] || 'fa-jar';
|
||||||
|
const u = unitLabel(def.pantryUnit);
|
||||||
|
|
||||||
|
if (products.length === 0) {
|
||||||
|
// Simple row — no products
|
||||||
|
const qty = getPantryTotal(id, pantry);
|
||||||
|
const qtyColor = qty > 0 ? '#6ee7b7' : '#6d6c67';
|
||||||
const avatar = def.image
|
const avatar = def.image
|
||||||
? `<img src="${esc(def.image)}" alt="" class="w-10 h-10 rounded-xl object-cover shrink-0">`
|
? `<img src="${esc(def.image)}" alt="" class="w-10 h-10 rounded-xl object-cover shrink-0">`
|
||||||
: `<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style="background:#2f2f2d;"><i class="fas ${icon} text-sm" style="color:#6d6c67;"></i></div>`;
|
: `<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style="background:#2f2f2d;"><i class="fas ${icon} text-sm" style="color:#6d6c67;"></i></div>`;
|
||||||
|
|
||||||
const qtyColor = hasStock ? '#6ee7b7' : '#6d6c67';
|
|
||||||
const qtyText = hasStock ? `${Math.round(qty)} ${esc(u)}` : `0 ${esc(u)}`;
|
|
||||||
|
|
||||||
let meta = esc(categoryLabel(def.category));
|
|
||||||
if (productCount > 0) meta += ` · ${productCount} ${productCount === 1 ? 'produkt' : productCount < 5 ? 'produkty' : 'produktów'}`;
|
|
||||||
|
|
||||||
return `<button type="button" class="pv2-chip w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors active:scale-[0.99]" style="background:#393937;" data-id="${esc(id)}">
|
return `<button type="button" class="pv2-chip w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors active:scale-[0.99]" style="background:#393937;" data-id="${esc(id)}">
|
||||||
${avatar}
|
${avatar}
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="text-[13px] font-semibold truncate" style="color:#ddd6ca;">${esc(def.name)}</span>
|
<span class="text-[13px] font-semibold truncate" style="color:#ddd6ca;">${esc(def.name)}</span>
|
||||||
<span class="text-[13px] font-bold tabular-nums shrink-0" style="color:${qtyColor};">${qtyText}</span>
|
<span class="text-[13px] font-bold tabular-nums shrink-0" style="color:${qtyColor};">${qty > 0 ? Math.round(qty) : 0} ${esc(u)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[11px] block mt-0.5" style="color:#9b978f;">${meta}</span>
|
<span class="text-[11px] block mt-0.5" style="color:#9b978f;">${esc(categoryLabel(def.category))}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group — ingredient header + product rows
|
||||||
|
const totalQty = getPantryTotal(id, pantry);
|
||||||
|
const pantryItems = getPantryProducts(id, pantry);
|
||||||
|
const totalColor = totalQty > 0 ? '#6ee7b7' : '#6d6c67';
|
||||||
|
|
||||||
|
let html = `<div class="rounded-xl overflow-hidden" style="background:#393937;">`;
|
||||||
|
// Group header
|
||||||
|
html += `<div class="flex items-center gap-3 px-3 py-2">`;
|
||||||
|
const avatar = def.image
|
||||||
|
? `<img src="${esc(def.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:#6d6c67;"></i></div>`;
|
||||||
|
html += avatar;
|
||||||
|
html += `<div class="flex-1 min-w-0">
|
||||||
|
<span class="text-[12px] font-semibold truncate block" style="color:#9b978f;">${esc(def.name)}</span>
|
||||||
|
</div>`;
|
||||||
|
html += `<span class="text-[12px] font-bold tabular-nums shrink-0" style="color:${totalColor};">${totalQty > 0 ? Math.round(totalQty) : 0} ${esc(u)}</span>`;
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Product rows
|
||||||
|
for (const p of products) {
|
||||||
|
const pQty = pantryItems.find(i => i.productId === p.id)?.qty || 0;
|
||||||
|
const pQtyColor = pQty > 0 ? '#ddd6ca' : '#6d6c67';
|
||||||
|
const pAvatar = p.image
|
||||||
|
? `<img src="${esc(p.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:#6d6c67;"></i></div>`;
|
||||||
|
html += `<button type="button" class="pv2-product-row w-full flex items-center gap-3 px-3 py-2 text-left transition-colors active:scale-[0.99]" style="border-top:1px solid #444442;" data-id="${esc(id)}" data-product-id="${esc(p.id)}">
|
||||||
|
${pAvatar}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="text-[13px] font-semibold truncate block" style="color:#ddd6ca;">${esc(p.name)}</span>
|
||||||
|
<span class="text-[11px]" style="color:#9b978f;">${p.packLabel || ''}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-[13px] font-bold tabular-nums shrink-0" style="color:${pQtyColor};">${Math.round(pQty)} ${esc(u)}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
function groupByCategory(ids) {
|
function groupByCategory(ids) {
|
||||||
/** @type {Map<string, string[]>} */
|
/** @type {Map<string, string[]>} */
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
@@ -302,14 +334,17 @@ function renderBoard() {
|
|||||||
<p class="text-[10px] font-bold uppercase tracking-wider mb-2 px-0.5" style="color:#9b978f;">
|
<p class="text-[10px] font-bold uppercase tracking-wider mb-2 px-0.5" style="color:#9b978f;">
|
||||||
<i class="fas ${icon} text-[10px] mr-1"></i>${esc(categoryLabel(cat))}
|
<i class="fas ${icon} text-[10px] mr-1"></i>${esc(categoryLabel(cat))}
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-2">${ids.map(id => rowHtml(id, pantry)).join('')}</div>
|
<div class="space-y-2">${ids.map(id => ingredientRowHtml(id, pantry)).join('')}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
root.innerHTML = html;
|
root.innerHTML = html;
|
||||||
|
|
||||||
root.querySelectorAll('.pv2-chip').forEach(btn => {
|
root.querySelectorAll('.pv2-chip').forEach(btn => {
|
||||||
btn.addEventListener('click', () => openIngredientCard(btn.dataset.id));
|
btn.addEventListener('click', () => openIngredientCard(btn.dataset.id, null));
|
||||||
|
});
|
||||||
|
root.querySelectorAll('.pv2-product-row').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => openIngredientCard(btn.dataset.id, btn.dataset.productId));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,46 +353,51 @@ function renderBoard() {
|
|||||||
|
|
||||||
/* ══════════════════════ INGREDIENT CARD ══════════════════════ */
|
/* ══════════════════════ INGREDIENT CARD ══════════════════════ */
|
||||||
|
|
||||||
function openIngredientCard(ingredientId) {
|
let editingProductId = null;
|
||||||
|
|
||||||
|
function openIngredientCard(ingredientId, productId) {
|
||||||
const def = INGREDIENTS[ingredientId];
|
const def = INGREDIENTS[ingredientId];
|
||||||
if (!def) return;
|
if (!def) return;
|
||||||
editingId = ingredientId;
|
editingId = ingredientId;
|
||||||
|
editingProductId = productId || null;
|
||||||
|
|
||||||
|
const product = editingProductId ? PRODUCTS[editingProductId] : null;
|
||||||
const pantry = loadPantry();
|
const pantry = loadPantry();
|
||||||
const qty = getPantryTotal(ingredientId, pantry);
|
|
||||||
const u = unitLabel(def.pantryUnit);
|
const u = unitLabel(def.pantryUnit);
|
||||||
const icon = CATEGORY_ICONS[def.category] || 'fa-jar';
|
const icon = CATEGORY_ICONS[def.category] || 'fa-jar';
|
||||||
|
|
||||||
// Hero image
|
// Hero image — product image > ingredient image > fallback
|
||||||
|
const image = product?.image || def.image;
|
||||||
const img = document.getElementById('pv2-card-img');
|
const img = document.getElementById('pv2-card-img');
|
||||||
const fallback = document.getElementById('pv2-card-fallback');
|
const fallback = document.getElementById('pv2-card-fallback');
|
||||||
const fallbackIcon = document.getElementById('pv2-card-fallback-icon');
|
const fallbackIcon = document.getElementById('pv2-card-fallback-icon');
|
||||||
if (def.image) {
|
if (image) {
|
||||||
img.src = def.image; img.alt = def.name; img.classList.remove('hidden'); fallback.classList.add('hidden');
|
img.src = image; img.alt = product?.name || def.name; img.classList.remove('hidden'); fallback.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
img.classList.add('hidden'); fallback.classList.remove('hidden');
|
img.classList.add('hidden'); fallback.classList.remove('hidden');
|
||||||
if (fallbackIcon) fallbackIcon.className = `fas ${icon} text-3xl`;
|
if (fallbackIcon) fallbackIcon.className = `fas ${icon} text-3xl`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header
|
// Header — show product info or ingredient info
|
||||||
document.getElementById('pv2-card-category').textContent = categoryLabel(def.category);
|
document.getElementById('pv2-card-category').textContent = product?.brand || categoryLabel(def.category);
|
||||||
document.getElementById('pv2-card-name').textContent = def.name;
|
document.getElementById('pv2-card-name').textContent = product?.name || def.name;
|
||||||
const packEl = document.getElementById('pv2-card-pack');
|
const packEl = document.getElementById('pv2-card-pack');
|
||||||
if (def.purchasePack) {
|
const packLabel = product?.packLabel || def.purchasePack?.label;
|
||||||
packEl.textContent = def.purchasePack.label || `${def.purchasePack.amount} ${u}`;
|
if (packLabel) {
|
||||||
|
packEl.textContent = packLabel;
|
||||||
packEl.classList.remove('hidden');
|
packEl.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
packEl.classList.add('hidden');
|
packEl.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nutrition
|
// Nutrition — use product values if available
|
||||||
renderCardNutrition(def);
|
renderCardNutrition(def, product);
|
||||||
|
|
||||||
// Stock
|
// Stock
|
||||||
renderCardStock(ingredientId, pantry);
|
renderCardStock(ingredientId, editingProductId, pantry);
|
||||||
|
|
||||||
// Shopping
|
// Shopping
|
||||||
renderCardShop(ingredientId);
|
renderCardShop(ingredientId, editingProductId);
|
||||||
|
|
||||||
// Show
|
// Show
|
||||||
const overlay = document.getElementById('pv2-card-overlay');
|
const overlay = document.getElementById('pv2-card-overlay');
|
||||||
@@ -371,10 +411,10 @@ function closeIngredientCard() {
|
|||||||
renderBoard();
|
renderBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCardNutrition(def) {
|
function renderCardNutrition(def, product) {
|
||||||
const wrap = document.getElementById('pv2-card-nutrition');
|
const wrap = document.getElementById('pv2-card-nutrition');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
const n = def.nutritionPer100g;
|
const n = product?.nutritionPer100g || def.nutritionPer100g;
|
||||||
if (!n) { wrap.innerHTML = ''; return; }
|
if (!n) { wrap.innerHTML = ''; return; }
|
||||||
const label = def.pantryUnit === 'ml' ? 'na 100 ml' : 'na 100 g';
|
const label = def.pantryUnit === 'ml' ? 'na 100 ml' : 'na 100 g';
|
||||||
wrap.innerHTML = `
|
wrap.innerHTML = `
|
||||||
@@ -399,43 +439,29 @@ function renderCardNutrition(def) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCardStock(ingredientId, pantry) {
|
function renderCardStock(ingredientId, productId, pantry) {
|
||||||
const wrap = document.getElementById('pv2-card-stock-section');
|
const wrap = document.getElementById('pv2-card-stock-section');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
const def = INGREDIENTS[ingredientId];
|
const def = INGREDIENTS[ingredientId];
|
||||||
if (!def) return;
|
if (!def) return;
|
||||||
const u = unitLabel(def.pantryUnit);
|
const u = unitLabel(def.pantryUnit);
|
||||||
const qty = getPantryTotal(ingredientId, pantry);
|
|
||||||
const products = getProductsForIngredient(ingredientId);
|
|
||||||
const hasProds = products.length > 0;
|
|
||||||
|
|
||||||
let html = `<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Zapas</p>`;
|
let html = `<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Zapas</p>`;
|
||||||
|
|
||||||
if (hasProds) {
|
if (productId) {
|
||||||
const pantryProducts = getPantryProducts(ingredientId, pantry);
|
// Product card — show just this product's stock
|
||||||
const generic = getPantryGeneric(ingredientId, pantry);
|
const product = PRODUCTS[productId];
|
||||||
const productQty = (pid) => pantryProducts.find(i => i.productId === pid)?.qty || 0;
|
const pantryItems = getPantryProducts(ingredientId, pantry);
|
||||||
|
const qty = pantryItems.find(i => i.productId === productId)?.qty || 0;
|
||||||
html += `<div class="rounded-xl px-3 py-2 space-y-1.5" style="background:#393937;">`;
|
const step = product?.packSize || pantryQtyStep(ingredientId);
|
||||||
html += `<div class="flex items-center justify-between"><span class="text-[12px] font-semibold" style="color:#9b978f;">Łącznie</span><span class="text-[13px] font-bold tabular-nums" style="color:#6ee7b7;">${Math.round(qty)} ${esc(u)}</span></div>`;
|
html += `<div class="flex items-center justify-center gap-3 rounded-xl px-3 py-2" style="background:#393937;">
|
||||||
html += `<div style="border-top:1px solid #444442; padding-top:0.375rem;" class="space-y-1">`;
|
<button type="button" class="pv2-stock-btn w-9 h-9 rounded-xl flex items-center justify-center active:scale-95" style="background:#2f2f2d; color:#d7d2c8;" data-pid="${esc(productId)}" data-step="${step}" data-dir="-1"><i class="fas fa-minus text-xs"></i></button>
|
||||||
for (const p of products) {
|
<span class="text-[17px] font-bold tabular-nums" style="color:#6ee7b7;">${Math.round(qty)} ${esc(u)}</span>
|
||||||
const q = Math.round(productQty(p.id));
|
<button type="button" class="pv2-stock-btn w-9 h-9 rounded-xl flex items-center justify-center active:scale-95" style="background:#2f2f2d; color:#d7d2c8;" data-pid="${esc(productId)}" data-step="${step}" data-dir="1"><i class="fas fa-plus text-xs"></i></button>
|
||||||
html += `<div class="flex items-center gap-1.5">
|
|
||||||
<span class="flex-1 text-[12px] truncate" style="color:#d7d2c8;">${esc(p.name)}</span>
|
|
||||||
<button type="button" class="pv2-stock-btn w-7 h-7 rounded-lg flex items-center justify-center active:scale-95 shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-pid="${esc(p.id)}" data-step="${p.packSize}" data-dir="-1"><i class="fas fa-minus text-[9px]"></i></button>
|
|
||||||
<span class="w-12 text-center text-[12px] font-semibold tabular-nums" style="color:#ddd6ca;">${q} ${esc(u)}</span>
|
|
||||||
<button type="button" class="pv2-stock-btn w-7 h-7 rounded-lg flex items-center justify-center active:scale-95 shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-pid="${esc(p.id)}" data-step="${p.packSize}" data-dir="1"><i class="fas fa-plus text-[9px]"></i></button>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
|
||||||
html += `<div class="flex items-center gap-1.5">
|
|
||||||
<span class="flex-1 text-[12px] italic truncate" style="color:#9b978f;">Nieokreślony</span>
|
|
||||||
<button type="button" class="pv2-stock-btn w-7 h-7 rounded-lg flex items-center justify-center active:scale-95 shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-pid="_generic" data-step="${pantryQtyStep(ingredientId)}" data-dir="-1"><i class="fas fa-minus text-[9px]"></i></button>
|
|
||||||
<span class="w-12 text-center text-[12px] font-semibold tabular-nums" style="color:#ddd6ca;">${Math.round(generic)} ${esc(u)}</span>
|
|
||||||
<button type="button" class="pv2-stock-btn w-7 h-7 rounded-lg flex items-center justify-center active:scale-95 shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-pid="_generic" data-step="${pantryQtyStep(ingredientId)}" data-dir="1"><i class="fas fa-plus text-[9px]"></i></button>
|
|
||||||
</div>`;
|
|
||||||
html += `</div></div>`;
|
|
||||||
} else {
|
} else {
|
||||||
|
// Generic ingredient — simple +/-
|
||||||
|
const qty = getPantryTotal(ingredientId, pantry);
|
||||||
const step = pantryQtyStep(ingredientId);
|
const step = pantryQtyStep(ingredientId);
|
||||||
html += `<div class="flex items-center justify-center gap-3 rounded-xl px-3 py-2" style="background:#393937;">
|
html += `<div class="flex items-center justify-center gap-3 rounded-xl px-3 py-2" style="background:#393937;">
|
||||||
<button type="button" class="pv2-stock-btn w-9 h-9 rounded-xl flex items-center justify-center active:scale-95" style="background:#2f2f2d; color:#d7d2c8;" data-pid="_generic" data-step="${step}" data-dir="-1"><i class="fas fa-minus text-xs"></i></button>
|
<button type="button" class="pv2-stock-btn w-9 h-9 rounded-xl flex items-center justify-center active:scale-95" style="background:#2f2f2d; color:#d7d2c8;" data-pid="_generic" data-step="${step}" data-dir="-1"><i class="fas fa-minus text-xs"></i></button>
|
||||||
@@ -446,7 +472,6 @@ function renderCardStock(ingredientId, pantry) {
|
|||||||
|
|
||||||
wrap.innerHTML = html;
|
wrap.innerHTML = html;
|
||||||
|
|
||||||
// Bind stock buttons
|
|
||||||
wrap.querySelectorAll('.pv2-stock-btn').forEach(btn => {
|
wrap.querySelectorAll('.pv2-stock-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
if (!editingId) return;
|
if (!editingId) return;
|
||||||
@@ -455,48 +480,56 @@ function renderCardStock(ingredientId, pantry) {
|
|||||||
const dir = Number(btn.dataset.dir);
|
const dir = Number(btn.dataset.dir);
|
||||||
const p = loadPantry();
|
const p = loadPantry();
|
||||||
if (pid === '_generic') {
|
if (pid === '_generic') {
|
||||||
const cur = getPantryGeneric(editingId, p);
|
const cur = getPantryTotal(editingId, p);
|
||||||
setPantryQty(editingId, Math.max(0, cur + step * dir));
|
setPantryQty(editingId, Math.max(0, cur + step * dir));
|
||||||
} else {
|
} else {
|
||||||
const items = getPantryProducts(editingId, p);
|
const items = getPantryProducts(editingId, p);
|
||||||
const cur = items.find(i => i.productId === pid)?.qty || 0;
|
const cur = items.find(i => i.productId === pid)?.qty || 0;
|
||||||
setPantryProductQty(editingId, pid, Math.max(0, cur + step * dir));
|
setPantryProductQty(editingId, pid, Math.max(0, cur + step * dir));
|
||||||
}
|
}
|
||||||
renderCardStock(editingId, loadPantry());
|
renderCardStock(editingId, editingProductId, loadPantry());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCardShop(ingredientId) {
|
function renderCardShop(ingredientId, productId) {
|
||||||
const wrap = document.getElementById('pv2-card-shop-section');
|
const wrap = document.getElementById('pv2-card-shop-section');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
const def = INGREDIENTS[ingredientId];
|
const def = INGREDIENTS[ingredientId];
|
||||||
if (!def) return;
|
if (!def) return;
|
||||||
const u = unitLabel(def.pantryUnit);
|
const u = unitLabel(def.pantryUnit);
|
||||||
const pack = def.purchasePack;
|
const product = productId ? PRODUCTS[productId] : null;
|
||||||
const usesPacks = Boolean(pack && pack.amount > 0);
|
const packSize = product?.packSize || def.purchasePack?.amount;
|
||||||
const hint = usesPacks ? `1 opak. = ${pack.label || `${pack.amount} ${u}`}` : '';
|
const packLabel = product?.packLabel || def.purchasePack?.label;
|
||||||
|
const usesPacks = Boolean(packSize && packSize > 0);
|
||||||
|
const btnLabel = usesPacks ? `Dodaj na listę (${packLabel || `${packSize} ${u}`})` : 'Dodaj na listę';
|
||||||
|
|
||||||
wrap.innerHTML = `
|
wrap.innerHTML = `
|
||||||
<button type="button" id="pv2-card-add-list" 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:#ddd6ca; color:#2d2e2b;">
|
<button type="button" id="pv2-card-add-list" 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:#ddd6ca; color:#2d2e2b;">
|
||||||
<i class="fas fa-cart-plus text-[11px]"></i>Dodaj na listę${usesPacks ? ' (1 opak.)' : ''}
|
<i class="fas fa-cart-plus text-[11px]"></i>${esc(btnLabel)}
|
||||||
</button>
|
</button>`;
|
||||||
${hint ? `<p class="text-[10px] text-center mt-1" style="color:#9b978f;">${esc(hint)}</p>` : ''}`;
|
|
||||||
|
|
||||||
document.getElementById('pv2-card-add-list')?.addEventListener('click', () => {
|
document.getElementById('pv2-card-add-list')?.addEventListener('click', () => {
|
||||||
if (!editingId) return;
|
if (!editingId) return;
|
||||||
const d = INGREDIENTS[editingId];
|
const d = INGREDIENTS[editingId];
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
const uLabel = unitLabel(d.pantryUnit);
|
const uLabel = unitLabel(d.pantryUnit);
|
||||||
if (usesPacks && d.purchasePack) {
|
const amt = usesPacks ? packSize : pantryQtyStep(editingId);
|
||||||
const amt = d.purchasePack.amount;
|
const note = usesPacks ? (packLabel || `${packSize} ${uLabel}`) : undefined;
|
||||||
addIngredientToKitchenList(editingId, amt, d.purchasePack.label || `${amt} ${uLabel}`);
|
|
||||||
showAppToast(`Dodano 1 opak. na listę.`);
|
// Use addOrMergeShoppingLines to include productId
|
||||||
} else {
|
const line = {
|
||||||
const step = pantryQtyStep(editingId);
|
ingredientId: editingId,
|
||||||
addIngredientToKitchenList(editingId, step);
|
amount: amt,
|
||||||
showAppToast(`Dodano ${step} ${uLabel} na listę.`);
|
unit: uLabel,
|
||||||
}
|
name: product?.name || d.name,
|
||||||
|
category: d.category,
|
||||||
|
sourceNote: note || 'Ze spiżarni',
|
||||||
|
};
|
||||||
|
if (productId) line.productId = productId;
|
||||||
|
addOrMergeShoppingLines([line]);
|
||||||
|
|
||||||
|
showAppToast(`Dodano ${product?.name || d.name} na listę.`);
|
||||||
window.refreshShopping?.();
|
window.refreshShopping?.();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user