Unify product cards and edit packaged amounts by pack
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m13s

This commit is contained in:
2026-04-10 15:29:52 +02:00
parent 86d47f126d
commit 527324515a
3 changed files with 101 additions and 22 deletions

View File

@@ -107,6 +107,47 @@ function formatPackAwareAmount(amount, pantryUnit, packSize, packLabel) {
return `${formatQty(qty)} ${unit}`;
}
function normalizeQty(value) {
return Math.max(0, Math.round((Number(value) || 0) * 100) / 100);
}
function formatPreciseQty(n) {
const rounded = Math.round((Number(n) || 0) * 1000) / 1000;
if (Number.isInteger(rounded)) return String(rounded);
return rounded.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
}
function formatPackCount(amount, packSize) {
if (!Number.isFinite(Number(packSize)) || Number(packSize) <= 0) return '';
return `${formatPreciseQty((Number(amount) || 0) / Number(packSize))} opak.`;
}
function getQtyStepMeta(def, product = null) {
const productPackSize = Number(product?.packSize);
if (Number.isFinite(productPackSize) && productPackSize > 0) {
return {
step: productPackSize,
usesPackStep: true,
stepLabel: product?.packLabel || formatQtyWithUnit(productPackSize, def.pantryUnit),
};
}
const ingredientPackSize = Number(def?.purchasePack?.amount);
if (Number.isFinite(ingredientPackSize) && ingredientPackSize > 0) {
return {
step: ingredientPackSize,
usesPackStep: true,
stepLabel: def.purchasePack?.label || formatQtyWithUnit(ingredientPackSize, def.pantryUnit),
};
}
return {
step: pantryQtyStep(def.id),
usesPackStep: false,
stepLabel: '',
};
}
export function getIngredientCardHTML({
idBase,
overlayClass = 'fixed inset-0 z-[70] hidden opacity-0 transition-opacity duration-200 flex items-center justify-center p-5',
@@ -153,6 +194,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
ingredientId: null,
productId: null,
selectedProductId: null,
allowProductSelection: true,
sourceNote: defaultSourceNote,
onProductChange: null,
onAfterChange: null,
@@ -234,7 +276,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
}
if (backBtn) {
backBtn.classList.toggle('hidden', !(hasProducts && state.productId));
backBtn.classList.toggle('hidden', !(hasProducts && state.productId && state.allowProductSelection));
}
}
@@ -250,7 +292,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const hasProducts = ingredientHasProducts(def.id);
const unitScope = def.pantryUnit === 'ml' ? 'na 100 ml' : 'na 100 g';
const hint = product
? 'dokładne dla produktu'
? ''
: hasProducts
? 'orientacyjnie dla składnika'
: '';
@@ -311,12 +353,20 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const qty = product
? (getPantryProducts(state.ingredientId, pantry).find((i) => i.productId === state.productId)?.qty || 0)
: totalQty;
const step = product ? (product.packSize || pantryQtyStep(state.ingredientId)) : pantryQtyStep(state.ingredientId);
const { step, usesPackStep } = getQtyStepMeta(def, product);
const packSize = product?.packSize || def.purchasePack?.amount || 0;
const packLabel = product?.packLabel || def.purchasePack?.label || '';
const draftQty = state.stockEditorOpen
? Math.max(0, Number(state.stockDraftQty ?? qty) || 0)
? normalizeQty(state.stockDraftQty ?? qty)
: qty;
const stockValueLabel = usesPackStep
? formatPackCount(qty, step)
: formatPackAwareAmount(qty, def.pantryUnit, packSize, packLabel);
const stockSubLabel = usesPackStep ? formatQtyWithUnit(qty, def.pantryUnit) : '';
const draftInputValue = usesPackStep
? formatPreciseQty(draftQty / step)
: formatPreciseQty(draftQty);
const draftInputUnit = usesPackStep ? 'opak.' : unit;
const actionLabel = state.stockEditorOpen ? 'Anuluj' : 'Zmień';
wrap.innerHTML = `
@@ -324,7 +374,8 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
<div class="rounded-2xl border px-3 py-3" style="background:#393937; border-color:#444442;">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-[16px] font-bold tabular-nums" style="color:#6ee7b7;">${esc(formatPackAwareAmount(qty, def.pantryUnit, packSize, packLabel))}</p>
<p class="text-[16px] font-bold tabular-nums" style="color:#6ee7b7;">${esc(stockValueLabel)}</p>
${stockSubLabel ? `<p class="text-[11px] mt-1" style="color:#9b978f;">${esc(stockSubLabel)}</p>` : ''}
</div>
<button type="button" class="ingredient-card-stock-toggle inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold shrink-0" style="background:${state.stockEditorOpen ? '#23221e' : '#2f2f2d'}; color:${state.stockEditorOpen ? '#f2efe8' : '#d7d2c8'};">
${esc(actionLabel)}
@@ -337,13 +388,14 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
<i class="fas fa-minus text-xs"></i>
</button>
<label class="flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2" style="background:#2f2f2d;">
<input type="number" min="0" step="${step}" value="${formatQty(draftQty)}" class="ingredient-card-stock-input w-20 bg-transparent text-center text-[14px] font-semibold tabular-nums outline-none appearance-none" style="color:#ddd6ca; background:transparent !important; border:none !important; box-shadow:none !important; -webkit-appearance:none; -moz-appearance:textfield;">
<span class="text-[12px] font-medium shrink-0" style="color:#9b978f;">${esc(unit)}</span>
<input type="number" min="0" step="${usesPackStep ? '1' : step}" value="${draftInputValue}" class="ingredient-card-stock-input w-20 bg-transparent text-center text-[14px] font-semibold tabular-nums outline-none appearance-none" style="color:#ddd6ca; background:transparent !important; border:none !important; box-shadow:none !important; -webkit-appearance:none; -moz-appearance:textfield;">
<span class="text-[12px] font-medium shrink-0" style="color:#9b978f;">${esc(draftInputUnit)}</span>
</label>
<button type="button" class="ingredient-card-stock-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-dir="1" aria-label="Zwiększ szkic zapasu">
<i class="fas fa-plus text-xs"></i>
</button>
</div>
${usesPackStep ? `<p class="text-[10px] mt-2 text-right" style="color:#9b978f;">${esc(formatQtyWithUnit(draftQty, def.pantryUnit))}</p>` : ''}
<div class="flex items-center justify-between gap-3 mt-3">
<button type="button" class="ingredient-card-stock-clear text-[11px] font-semibold" style="color:#9b978f;">Wyzeruj</button>
<button type="button" class="ingredient-card-stock-save inline-flex items-center rounded-full px-3 py-1.5 text-[11px] font-semibold" style="background:#ddd6ca; color:#2d2e2b;">Zapisz</button>
@@ -366,14 +418,16 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
wrap.querySelectorAll('.ingredient-card-stock-step').forEach((btn) => {
btn.addEventListener('click', () => {
const dir = Number(btn.dataset.dir) || 1;
const next = Math.max(0, Math.round(((Number(state.stockDraftQty ?? qty) || 0) + step * dir) * 100) / 100);
const next = normalizeQty((Number(state.stockDraftQty ?? qty) || 0) + step * dir);
state.stockDraftQty = next;
render();
});
});
wrap.querySelector('.ingredient-card-stock-input')?.addEventListener('input', (event) => {
state.stockDraftQty = Math.max(0, Number(event.target.value) || 0);
state.stockDraftQty = usesPackStep
? normalizeQty((Number(event.target.value) || 0) * step)
: normalizeQty(event.target.value);
});
wrap.querySelector('.ingredient-card-stock-clear')?.addEventListener('click', () => {
@@ -383,7 +437,9 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
wrap.querySelector('.ingredient-card-stock-save')?.addEventListener('click', () => {
const input = wrap.querySelector('.ingredient-card-stock-input');
const nextQty = Math.max(0, Math.round((Number(input?.value ?? state.stockDraftQty ?? qty) || 0) * 100) / 100);
const nextQty = usesPackStep
? normalizeQty((Number(input?.value) || 0) * step)
: normalizeQty(input?.value ?? state.stockDraftQty ?? qty);
if (state.productId) {
setPantryProductQty(state.ingredientId, state.productId, nextQty);
} else {
@@ -421,7 +477,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
function renderProducts() {
const wrap = el('products');
if (!wrap || !state.ingredientId) return;
if (!ingredientHasProducts(state.ingredientId)) {
if (!ingredientHasProducts(state.ingredientId) || !state.allowProductSelection) {
wrap.innerHTML = '';
return;
}
@@ -459,18 +515,30 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
const def = INGREDIENTS[state.ingredientId];
if (!def) return;
const hasProducts = ingredientHasProducts(state.ingredientId);
const product = state.productId ? PRODUCTS[state.productId] : null;
const { step, usesPackStep } = getQtyStepMeta(def, product);
const packSize = product?.packSize || def.purchasePack?.amount;
const packLabel = product?.packLabel || def.purchasePack?.label;
const usesPacks = Boolean(packSize && packSize > 0);
const defaultAmount = usesPacks ? packSize : pantryQtyStep(state.ingredientId);
const defaultAmount = step;
const shoppingItem = getCurrentShoppingItem(def);
const hasShoppingItem = Boolean(shoppingItem);
const shoppingAmount = shoppingItem?.amount || 0;
const draftQty = state.shopEditorOpen
? Math.max(0, Number(state.shopDraftQty ?? (shoppingAmount || defaultAmount)) || 0)
? normalizeQty(state.shopDraftQty ?? (shoppingAmount || defaultAmount))
: shoppingAmount;
const shopValueLabel = hasShoppingItem
? usesPackStep
? formatPackCount(shoppingAmount, step)
: formatPackAwareAmount(shoppingAmount, def.pantryUnit, packSize, packLabel)
: 'Brak na liście';
const shopSubLabel = hasShoppingItem && usesPackStep
? formatQtyWithUnit(shoppingAmount, def.pantryUnit)
: '';
const shopInputValue = usesPackStep
? formatPreciseQty(draftQty / step)
: formatPreciseQty(draftQty);
const shopInputUnit = usesPackStep ? 'opak.' : unitLabel(def.pantryUnit);
const actionLabel = state.shopEditorOpen ? 'Anuluj' : 'Zmień';
wrap.innerHTML = `
@@ -478,7 +546,8 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
<div class="rounded-2xl border px-3 py-3" style="background:#393937; border-color:#444442;">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-[16px] font-bold tabular-nums" style="color:${hasShoppingItem ? '#ddd6ca' : '#9b978f'};">${esc(hasShoppingItem ? formatPackAwareAmount(shoppingAmount, def.pantryUnit, packSize, packLabel) : 'Brak na liscie')}</p>
<p class="text-[16px] font-bold tabular-nums" style="color:${hasShoppingItem ? '#ddd6ca' : '#9b978f'};">${esc(shopValueLabel)}</p>
${shopSubLabel ? `<p class="text-[11px] mt-1" style="color:#9b978f;">${esc(shopSubLabel)}</p>` : ''}
</div>
<button type="button" class="ingredient-card-shop-toggle inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold shrink-0" style="background:${state.shopEditorOpen ? '#23221e' : '#2f2f2d'}; color:${state.shopEditorOpen ? '#f2efe8' : '#d7d2c8'};">
${esc(actionLabel)}
@@ -491,13 +560,14 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
<i class="fas fa-minus text-xs"></i>
</button>
<label class="flex-1 rounded-xl px-3 py-2 flex items-center justify-center gap-2" style="background:#2f2f2d;">
<input type="number" min="0" step="${defaultAmount}" value="${formatQty(draftQty)}" class="ingredient-card-shop-input w-20 bg-transparent text-center text-[14px] font-semibold tabular-nums outline-none appearance-none" style="color:#ddd6ca; background:transparent !important; border:none !important; box-shadow:none !important; -webkit-appearance:none; -moz-appearance:textfield;">
<span class="text-[12px] font-medium shrink-0" style="color:#9b978f;">${esc(unitLabel(def.pantryUnit))}</span>
<input type="number" min="0" step="${usesPackStep ? '1' : defaultAmount}" value="${shopInputValue}" class="ingredient-card-shop-input w-20 bg-transparent text-center text-[14px] font-semibold tabular-nums outline-none appearance-none" style="color:#ddd6ca; background:transparent !important; border:none !important; box-shadow:none !important; -webkit-appearance:none; -moz-appearance:textfield;">
<span class="text-[12px] font-medium shrink-0" style="color:#9b978f;">${esc(shopInputUnit)}</span>
</label>
<button type="button" class="ingredient-card-shop-step w-9 h-9 rounded-xl flex items-center justify-center shrink-0" style="background:#2f2f2d; color:#d7d2c8;" data-dir="1" aria-label="Zwiększ ilość na liście">
<i class="fas fa-plus text-xs"></i>
</button>
</div>
${usesPackStep ? `<p class="text-[10px] mt-2 text-right" style="color:#9b978f;">${esc(formatQtyWithUnit(draftQty, def.pantryUnit))}</p>` : ''}
<div class="flex items-center justify-between gap-3 mt-3">
${hasShoppingItem
? '<button type="button" class="ingredient-card-shop-remove text-[11px] font-semibold" style="color:#9b978f;">Usuń z listy</button>'
@@ -522,14 +592,16 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
wrap.querySelectorAll('.ingredient-card-shop-step').forEach((btn) => {
btn.addEventListener('click', () => {
const dir = Number(btn.dataset.dir) || 1;
const next = Math.max(0, Math.round(((Number(state.shopDraftQty ?? (shoppingAmount || defaultAmount)) || 0) + defaultAmount * dir) * 100) / 100);
const next = normalizeQty((Number(state.shopDraftQty ?? (shoppingAmount || defaultAmount)) || 0) + step * dir);
state.shopDraftQty = next;
render();
});
});
wrap.querySelector('.ingredient-card-shop-input')?.addEventListener('input', (event) => {
state.shopDraftQty = Math.max(0, Number(event.target.value) || 0);
state.shopDraftQty = usesPackStep
? normalizeQty((Number(event.target.value) || 0) * step)
: normalizeQty(event.target.value);
});
wrap.querySelector('.ingredient-card-shop-remove')?.addEventListener('click', () => {
@@ -545,7 +617,9 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
wrap.querySelector('.ingredient-card-shop-save')?.addEventListener('click', () => {
const input = wrap.querySelector('.ingredient-card-shop-input');
const nextAmount = Math.max(0, Math.round((Number(input?.value ?? state.shopDraftQty ?? defaultAmount) || 0) * 100) / 100);
const nextAmount = usesPackStep
? normalizeQty((Number(input?.value) || 0) * step)
: normalizeQty(input?.value ?? state.shopDraftQty ?? defaultAmount);
let toastText = null;
if (shoppingItem) {
updateKitchenItemAmount(KITCHEN_LIST_ID, shoppingItem.id, nextAmount);
@@ -593,6 +667,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
ingredientId,
productId = null,
selectedProductId = productId,
allowProductSelection = true,
sourceNote = defaultSourceNote,
onProductChange = null,
onAfterChange = null,
@@ -605,6 +680,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
state.ingredientId = ingredientId;
state.productId = productId && PRODUCTS[productId] ? productId : null;
state.selectedProductId = selectedProductId && PRODUCTS[selectedProductId] ? selectedProductId : state.productId;
state.allowProductSelection = Boolean(allowProductSelection);
state.sourceNote = sourceNote;
state.onProductChange = onProductChange;
state.onAfterChange = onAfterChange;
@@ -634,6 +710,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
state.ingredientId = null;
state.productId = null;
state.selectedProductId = null;
state.allowProductSelection = true;
state.onProductChange = null;
state.onAfterChange = null;
state.sourceNote = defaultSourceNote;
@@ -645,6 +722,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
bound = true;
el('close')?.addEventListener('click', close);
el('back')?.addEventListener('click', () => {
if (!state.allowProductSelection) return;
state.productId = null;
resetInlineEditors();
render();

View File

@@ -26,7 +26,7 @@ import {
renderCalendarGrid,
syncCalendarTodayButton,
} from './mealCalendar.js?v=1';
import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260410-106';
import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260410-107';
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -674,6 +674,7 @@ export function setupMealPlanEditor() {
ingredientId: eid,
productId: cardBtn.dataset.pid || null,
selectedProductId: cardBtn.dataset.pid || null,
allowProductSelection: !cardBtn.dataset.pid,
sourceNote: 'Z planera',
onProductChange: (nextProductId) => {
if (!nextProductId) return;