<!DOCTYPE html>
<html lang="pl" dir="ltr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
lunch-pilot/
<link rel="manifest" href="manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Lunch Pilot">
<link rel="apple-touch-icon" href="icon-192.png">
<meta name="theme-color" content="#0f172a">
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js').catch(() => {});
});
}
</script>
<title>Lunch Pilot B2B Pro — Culinary, Catering & Food Cost OS</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" />
<style>
body { font-family: 'Inter', sans-serif; }
.font-serif { font-family: 'Playfair Display', serif; }
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
@media print {
body * { visibility: hidden; }
#printable-menu-area, #printable-menu-area *,
#printable-event-area, #printable-event-area * { visibility: visible; }
#printable-menu-area, #printable-event-area {
position: absolute;
left: 0;
top: 0;
width: 100%;
box-shadow: none !important;
border: none !important;
padding: 12px !important;
}
}
</style>
</head>
<body class="bg-slate-50 text-slate-900 min-h-screen flex flex-col">
<!-- ======================================================================= -->
<!-- TOP NAVIGATION BAR -->
<!-- ======================================================================= -->
<header class="bg-slate-900 text-white border-b border-slate-800 sticky top-0 z-40 shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16 gap-3">
<!-- Brand + Editable Venue Name -->
<div class="flex items-center gap-3 shrink-0">
<div class="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-600 flex items-center justify-center shadow-md shrink-0">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-white" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</div>
<div>
<div class="flex items-center gap-2">
<span class="font-extrabold text-base sm:text-lg tracking-tight text-white">Lunch Pilot <span class="text-emerald-400">B2B Pro</span></span>
</div>
<div class="flex items-center gap-1.5 mt-0.5">
<input type="text" id="header-venue-input" oninput="updateVenueNameLive(this.value)" placeholder="Nazwa restauracji..." class="bg-slate-800/90 hover:bg-slate-800 focus:bg-slate-800 text-emerald-300 font-semibold text-xs px-2 py-0.5 rounded-md border border-slate-700 focus:border-emerald-400 outline-none w-36 sm:w-48 transition-colors" />
<span id="ui-country-badge" class="text-[11px] text-slate-400 hidden md:inline">• PLN (zł)</span>
</div>
</div>
</div>
<!-- Desktop Navigation Tabs -->
<nav class="hidden lg:flex items-center gap-1 bg-slate-800/90 p-1 rounded-xl border border-slate-700/70">
<button onclick="switchTab('generator')" data-tab="generator" class="nav-tab px-3 py-1.5 rounded-lg text-xs font-semibold transition-all bg-emerald-600 text-white shadow-sm cursor-pointer">
📅 <span data-i18n="tab_menu">Menu (Pn–Pt)</span>
</button>
<button onclick="switchTab('ttk')" data-tab="ttk" class="nav-tab px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 hover:text-white hover:bg-slate-700/50 transition-all cursor-pointer">
📋 <span data-i18n="tab_ttk">Receptury (TTK)</span>
</button>
<button onclick="switchTab('procurement')" data-tab="procurement" class="nav-tab px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 hover:text-white hover:bg-slate-700/50 transition-all cursor-pointer">
📦 <span data-i18n="tab_proc">Lista zakupów</span>
</button>
<button onclick="switchTab('events')" data-tab="events" class="nav-tab px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 hover:text-white hover:bg-slate-700/50 transition-all cursor-pointer">
🎉 <span data-i18n="tab_events">Imprezy / Catering</span>
</button>
<button onclick="switchTab('marketing')" data-tab="marketing" class="nav-tab px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 hover:text-white hover:bg-slate-700/50 transition-all cursor-pointer">
🖨️ <span data-i18n="tab_print">Druk i SMM</span>
</button>
<button onclick="switchTab('history')" data-tab="history" class="nav-tab px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 hover:text-white hover:bg-slate-700/50 transition-all cursor-pointer">
🗂️ <span data-i18n="tab_history">Historia</span>
</button>
<button onclick="switchTab('pricing')" data-tab="pricing" class="nav-tab px-3 py-1.5 rounded-lg text-xs font-semibold text-amber-300 hover:text-amber-200 hover:bg-slate-700/50 transition-all cursor-pointer">
💎 <span data-i18n="tab_pricing">Plany B2B</span>
</button>
</nav>
<!-- Right Controls: 10-Language Selector & Venue Settings -->
<div class="flex items-center gap-2 shrink-0">
<select id="lang-selector" onchange="changeLanguage(this.value)" class="bg-slate-800 hover:bg-slate-700 text-white text-xs font-semibold px-2.5 py-2 rounded-xl border border-slate-700 focus:border-emerald-400 outline-none cursor-pointer">
<option value="pl">🇵🇱 Polski (PL)</option>
<option value="ru">🇷🇺 Русский (RU)</option>
<option value="uk">🇺🇦 Українська (UA)</option>
<option value="it">🇮🇹 Italiano (IT)</option>
<option value="es">🇪🇸 Español (ES)</option>
<option value="de">🇩🇪 Deutsch (DE)</option>
<option value="fr">🇫🇷 Français (FR)</option>
<option value="tr">🇹🇷 Türkçe (TR)</option>
<option value="he">🇮🇱 עברית (HE)</option>
<option value="ar">🇸🇦 العربية (AR)</option>
</select>
<button onclick="openSettingsModal()" class="p-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white border border-slate-700 transition-colors cursor-pointer" title="Settings">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</div>
<!-- Mobile Tabs Scroller -->
<div class="flex lg:hidden overflow-x-auto pb-2.5 pt-1 gap-1.5 no-scrollbar">
<button onclick="switchTab('generator')" data-tab="generator" class="nav-tab shrink-0 px-3 py-1.5 rounded-lg text-xs font-semibold bg-emerald-600 text-white">
📅 <span data-i18n="tab_menu">Menu (Pn–Pt)</span>
</button>
<button onclick="switchTab('ttk')" data-tab="ttk" class="nav-tab shrink-0 px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 bg-slate-800">
📋 <span data-i18n="tab_ttk">Receptury</span>
</button>
<button onclick="switchTab('procurement')" data-tab="procurement" class="nav-tab shrink-0 px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 bg-slate-800">
📦 <span data-i18n="tab_proc">Zakupy</span>
</button>
<button onclick="switchTab('events')" data-tab="events" class="nav-tab shrink-0 px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 bg-slate-800">
🎉 <span data-i18n="tab_events">Imprezy</span>
</button>
<button onclick="switchTab('marketing')" data-tab="marketing" class="nav-tab shrink-0 px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 bg-slate-800">
🖨️ <span data-i18n="tab_print">Druk/SMM</span>
</button>
<button onclick="switchTab('history')" data-tab="history" class="nav-tab shrink-0 px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 bg-slate-800">
🗂️ <span data-i18n="tab_history">Historia</span>
</button>
<button onclick="switchTab('pricing')" data-tab="pricing" class="nav-tab shrink-0 px-3 py-1.5 rounded-lg text-xs font-semibold text-amber-300 bg-slate-800">
💎 <span data-i18n="tab_pricing">Plany</span>
</button>
</div>
</div>
</header>
<!-- ======================================================================= -->
<!-- MAIN WORKSPACE CONTAINER -->
<!-- ======================================================================= -->
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Top KPI Summary Strip -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-6">
<div class="bg-white rounded-2xl p-4 border border-slate-200/80 shadow-sm">
<div class="text-xs font-medium text-slate-500" data-i18n="kpi_meat_fc">Food Cost Zestaw Mięsny 🍖</div>
<div class="mt-1.5 flex items-baseline justify-between">
<span id="kpi-avg-fc" class="text-xl sm:text-2xl font-bold text-slate-900">11.40 zł</span>
<span id="kpi-fc-badge" class="text-[11px] font-semibold px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700">OK</span>
</div>
</div>
<div class="bg-white rounded-2xl p-4 border border-slate-200/80 shadow-sm">
<div class="text-xs font-medium text-slate-500" data-i18n="kpi_veg_fc">Food Cost Zestaw Wege 🌱</div>
<div class="mt-1.5 flex items-baseline justify-between">
<span id="kpi-veg-fc" class="text-xl sm:text-2xl font-bold text-emerald-700">8.20 zł</span>
<span class="text-[11px] font-semibold px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">VEG</span>
</div>
</div>
<div class="bg-white rounded-2xl p-4 border border-slate-200/80 shadow-sm">
<div class="text-xs font-medium text-slate-500" data-i18n="kpi_portions">Plan porcji (Pn–Pt)</div>
<div class="mt-1.5 flex items-baseline justify-between">
<span id="kpi-total-portions" class="text-xl sm:text-2xl font-bold text-slate-900">270</span>
<span id="kpi-portions-split" class="text-[11px] text-slate-500">70% 🍖 / 30% 🌱</span>
</div>
</div>
<div class="bg-white rounded-2xl p-4 border border-slate-200/80 shadow-sm">
<div class="text-xs font-medium text-slate-500" data-i18n="kpi_budget">Budżet zakupów (tydz.)</div>
<div class="mt-1.5 flex items-baseline justify-between">
<span id="kpi-weekly-budget" class="text-xl sm:text-2xl font-bold text-slate-900">2 818.00 zł</span>
<span id="kpi-profit-badge" class="text-[11px] font-semibold px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800">+7 712 zł</span>
</div>
</div>
</div>
<!-- ===================================================================== -->
<!-- VIEW 1: WEEKLY MENU GENERATOR (MON–FRI) -->
<!-- ===================================================================== -->
<section id="view-generator" class="workspace-view space-y-6">
<div class="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 sm:p-6">
<div class="flex flex-col xl:flex-row xl:items-center justify-between gap-4 pb-5 border-b border-slate-100">
<div>
<div class="flex flex-wrap items-center gap-2">
<h1 class="text-lg sm:text-xl font-bold text-slate-900" data-i18n="gen_title">Kreator Lunch Menu (Poniedziałek — Piątek)</h1>
<span id="cuisine-profile-pill" class="text-xs font-bold px-2.5 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">🇵🇱 Kuchnia Polska & Europejska</span>
<span id="anti-repeat-badge" class="text-xs font-semibold px-2.5 py-0.5 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-200">🛡️ Pamięć historii: 0 zablokowanych dań</span>
<span id="auto-db-badge" class="text-xs font-semibold px-2.5 py-0.5 rounded-full bg-teal-50 text-teal-800 border border-teal-200">🔄 Baza auto-odnawiana: 280+ dań</span>
</div>
<p class="text-xs sm:text-sm text-slate-500 mt-1" data-i18n="gen_subtitle">Kliknij dowolną nazwę dania lub dodatek, aby je poprawić — receptura TTK i lista zakupów przeliczą się automatycznie.</p>
</div>
<div class="flex flex-wrap items-center gap-2.5 shrink-0">
<div class="flex items-center gap-1.5 bg-slate-100 px-3 py-2 rounded-xl border border-slate-200">
<span class="text-xs font-semibold text-slate-600" data-i18n="lbl_week_start">Poniedziałek:</span>
<input type="date" id="input-week-start" onchange="savePersistentState()" class="bg-transparent text-xs font-bold text-slate-900 outline-none cursor-pointer" />
</div>
<button onclick="autoExpandDishDatabase(true)" class="inline-flex items-center justify-center gap-1.5 px-3.5 py-2.5 rounded-xl bg-teal-600 hover:bg-teal-700 text-white font-semibold text-xs shadow-sm transition-all cursor-pointer" title="Automatycznie dogeneruj nowe sezonowe przepisy do bazy">
<span>✨</span>
<span data-i18n="btn_refresh_db">Odnów bazę AI</span>
</button>
<button onclick="runFullMenuAudit(true)" class="inline-flex items-center justify-center gap-1.5 px-3.5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-xs shadow-sm transition-all cursor-pointer">
<span>🔍</span>
<span data-i18n="btn_verify_menu">Zweryfikuj spójność</span>
</button>
<button onclick="saveWeekToHistory()" class="inline-flex items-center justify-center gap-1.5 px-3.5 py-2.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-semibold text-xs shadow-sm transition-all cursor-pointer">
<span>💾</span>
<span data-i18n="btn_save_history">Zapisz w historii</span>
</button>
<button onclick="generateFullWeek()" id="btn-generate-week" class="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-semibold text-xs sm:text-sm shadow-md shadow-emerald-600/20 transition-all cursor-pointer">
<svg class="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span data-i18n="btn_gen_week">Generuj nowy tydzień</span>
</button>
</div>
</div>
<!-- Kitchen Parameters Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 pt-5">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5" data-i18n="lbl_fc_limit">Limit Food Cost (1 zestaw)</label>
<div class="relative">
<input type="number" id="input-target-fc" value="13.50" step="0.50" onchange="recalculateAll()" class="w-full rounded-xl border border-slate-300 px-3.5 py-2.5 text-sm font-semibold text-slate-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none" />
<span class="currency-label absolute end-3.5 top-2.5 text-xs font-bold text-slate-400">zł</span>
</div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5" data-i18n="lbl_season">Sezonowość</label>
<select id="input-season" onchange="onSeasonChange()" class="w-full rounded-xl border border-slate-300 px-3.5 py-2.5 text-sm font-medium text-slate-900 bg-white focus:ring-2 focus:ring-emerald-500 outline-none">
<option value="autumn" selected data-i18n="season_autumn">🍂 Jesień (dynia, kapusta, grzyby)</option>
<option value="winter" data-i18n="season_winter">❄️ Zima (sycące zupy, kapusta zasmażana)</option>
<option value="spring" data-i18n="season_spring">🌱 Wiosna (nowalijki, szparagi, szczaw)</option>
<option value="summer" data-i18n="season_summer">☀️ Lato (chłodniki, świeże sałatki)</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5" data-i18n="lbl_portions">Porcji dziennie (Pn–Cz / Pt)</label>
<div class="grid grid-cols-2 gap-2">
<input type="number" id="input-portions-mon-thu" value="50" min="5" onchange="recalculateAll()" class="w-full rounded-xl border border-slate-300 px-3 py-2.5 text-sm font-semibold text-slate-900 focus:ring-2 focus:ring-emerald-500 outline-none" />
<input type="number" id="input-portions-fri" value="70" min="5" onchange="recalculateAll()" class="w-full rounded-xl border border-slate-300 px-3 py-2.5 text-sm font-semibold text-slate-900 focus:ring-2 focus:ring-emerald-500 outline-none" />
</div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5" data-i18n="lbl_veg_share">Udział dań Wege 🌱</label>
<div class="flex items-center gap-3 pt-1">
<input type="range" id="input-veg-share" min="10" max="70" step="5" value="30" oninput="updateVegShareLabel(); recalculateAll()" class="w-full accent-emerald-600 cursor-pointer" />
<span id="veg-share-label" class="text-xs font-bold text-emerald-700 bg-emerald-50 px-2.5 py-1 rounded-lg border border-emerald-200 shrink-0">30%</span>
</div>
</div>
</div>
<!-- Zero-Waste Stock Input -->
<div class="mt-4 pt-4 border-t border-slate-100 flex flex-col sm:flex-row sm:items-center gap-3">
<div class="flex items-center gap-2 shrink-0">
<span class="w-2.5 h-2.5 rounded-full bg-amber-500"></span>
<label class="text-xs font-bold text-slate-700" data-i18n="lbl_stock">Stan magazynu (Zero-Waste):</label>
</div>
<input type="text" id="input-stock" oninput="onStockInputChange()" value="" placeholder="np. udko z kurczaka 14 kg, buraki 10 kg, dynia 8 kg..." class="flex-1 rounded-xl border border-slate-300 bg-amber-50/40 px-3.5 py-2 text-xs sm:text-sm text-slate-900 focus:ring-2 focus:ring-amber-500 outline-none" />
<button onclick="applyStockNow()" class="shrink-0 px-3.5 py-2 rounded-xl bg-amber-500 hover:bg-amber-600 text-slate-950 font-bold text-xs shadow-xs transition-all cursor-pointer" data-i18n="btn_apply_stock">
♻️ Użyj stanów w menu
</button>
</div>
<!-- Regular A La Carte Menu Exclusion Input -->
<div class="mt-3 pt-3 border-t border-slate-100 flex flex-col sm:flex-row sm:items-center gap-3">
<div class="flex items-center gap-2 shrink-0">
<span class="w-2.5 h-2.5 rounded-full bg-rose-500"></span>
<label class="text-xs font-bold text-slate-700" data-i18n="lbl_regular_menu">Stała karta dań (A la carte — nie powtarzać w lunchu):</label>
</div>
<input type="text" id="input-regular-menu" oninput="onRegularMenuChange()" placeholder="np. Żurek, Kotlet schabowy, Pierogi ruskie, Dorsz z pieca..." class="flex-1 rounded-xl border border-slate-300 bg-rose-50/30 px-3.5 py-2 text-xs sm:text-sm text-slate-900 focus:ring-2 focus:ring-rose-500 outline-none" />
</div>
</div>
<!-- AI Status Banner -->
<div id="ai-loading-banner" class="hidden bg-slate-900 text-white rounded-2xl p-4 flex items-center gap-3.5 shadow-lg">
<div class="w-5 h-5 border-2 border-emerald-400 border-t-transparent rounded-full animate-spin shrink-0"></div>
<div class="text-xs sm:text-sm font-medium" data-i18n="ai_loading">AI-Chef odnawia bazę receptur i układa menu dopasowane do sezonu oraz magazynu...</div>
</div>
<!-- Culinary Pairing Audit Banner -->
<div id="culinary-audit-banner" class="bg-emerald-50 border border-emerald-200 rounded-2xl p-3.5 sm:p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs sm:text-sm text-emerald-950">
<div class="flex items-center gap-2.5">
<span class="text-base">✅</span>
<span id="culinary-audit-text" class="font-medium">Weryfikacja kulinarna: Wszystkie 5 dni sprawdzone — zero podwójnej skrobi, dodatki i surówki dopasowane smakowo.</span>
</div>
<span class="text-[11px] font-bold uppercase tracking-wider px-2.5 py-1 rounded-lg bg-emerald-600 text-white self-start sm:self-auto shrink-0">Chef Audit OK</span>
</div>
<!-- 5-DAY CARDS CONTAINER -->
<div id="weekly-menu-grid" class="space-y-5"></div>
</section>
<!-- ===================================================================== -->
<!-- VIEW 2: TECHNOLOGICAL CARDS (TTK) -->
<!-- ===================================================================== -->
<section id="view-ttk" class="workspace-view hidden space-y-6">
<div class="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 sm:p-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 class="text-lg sm:text-xl font-bold text-slate-900" data-i18n="ttk_title">Karty Technologiczne (TTK) — Edytowalne Brutto / Netto</h2>
<p class="text-xs sm:text-sm text-slate-500 mt-0.5" data-i18n="ttk_subtitle">Kliknij dowolny składnik, gramaturę lub cenę, albo dodaj nowy składnik do receptury — koszty przeliczą się od razu.</p>
</div>
<div class="inline-flex rounded-xl bg-slate-100 p-1 border border-slate-200 self-start sm:self-auto">
<button onclick="setTtkMode('single')" id="btn-ttk-single" class="px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-white text-slate-900 shadow-sm cursor-pointer" data-i18n="ttk_mode_single">
Na 1 porcję (g / ml)
</button>
<button onclick="setTtkMode('batch')" id="btn-ttk-batch" class="px-3.5 py-1.5 rounded-lg text-xs font-semibold text-slate-600 hover:text-slate-900 cursor-pointer" data-i18n="ttk_mode_batch">
Na partię dnia (kg / l)
</button>
</div>
</div>
<div id="ttk-day-pills" class="flex flex-wrap gap-2 mt-5 pt-4 border-t border-slate-100"></div>
</div>
<div id="ttk-cards-container" class="grid grid-cols-1 lg:grid-cols-2 gap-5"></div>
</section>
<!-- ===================================================================== -->
<!-- VIEW 3: PROCUREMENT & SUPPLIER ORDER -->
<!-- ===================================================================== -->
<section id="view-procurement" class="workspace-view hidden space-y-6">
<div class="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 sm:p-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 class="text-lg sm:text-xl font-bold text-slate-900" data-i18n="proc_title">Zbiorcza lista zakupów na tydzień</h2>
<p class="text-xs sm:text-sm text-slate-500 mt-0.5" data-i18n="proc_subtitle">Uwzględnia mięso, warzywa, oleje, płyny i przyprawy w podziale na 2 dostawy.</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="inline-flex rounded-xl bg-slate-100 p-1 border border-slate-200">
<button onclick="setProcurementSplit('all')" id="btn-proc-all" class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-white text-slate-900 shadow-sm cursor-pointer" data-i18n="proc_all">Cały tydzień</button>
<button onclick="setProcurementSplit('wave1')" id="btn-proc-wave1" class="px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-600 cursor-pointer" data-i18n="proc_w1">Dostawa 1 (Pn–Wt)</button>
<button onclick="setProcurementSplit('wave2')" id="btn-proc-wave2" class="px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-600 cursor-pointer" data-i18n="proc_w2">Dostawa 2 (Śr–Pt)</button>
</div>
<button onclick="copyProcurementList()" class="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-white text-xs font-semibold cursor-pointer">
📋 <span data-i18n="btn_copy_proc">Kopiuj dla dostawcy</span>
</button>
</div>
</div>
</div>
<div id="procurement-categories-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"></div>
</section>
<!-- ===================================================================== -->
<!-- VIEW 4: EVENTS & BANQUET CATERING ORGANIZER -->
<!-- ===================================================================== -->
<section id="view-events" class="workspace-view hidden space-y-6">
<div class="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 sm:p-6">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4 pb-5 border-b border-slate-100">
<div>
<div class="flex items-center gap-2">
<h2 class="text-lg sm:text-xl font-bold text-slate-900" data-i18n="ev_title">Organizator Imprez, Bankietów i Cateringu B2B</h2>
<span class="text-xs font-bold px-2.5 py-0.5 rounded-full bg-purple-100 text-purple-800">Event & Banquet OS</span>
</div>
<p class="text-xs sm:text-sm text-slate-500 mt-1" data-i18n="ev_subtitle">Szybka kalkulacja przyjęcia (chrzciny, komunia, konferencja, wigilia firmowa): koszt surowca, marża, oferta PDF dla klienta i lista zakupów.</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button onclick="importFromLunchToEvent()" class="px-3.5 py-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-900 font-semibold text-xs cursor-pointer">
📥 <span data-i18n="ev_btn_import">Pobierz hity z lunchu</span>
</button>
<button onclick="generateEventProposalAi()" class="px-3.5 py-2.5 rounded-xl bg-purple-600 hover:bg-purple-700 text-white font-semibold text-xs shadow-sm cursor-pointer">
✨ <span data-i18n="ev_btn_ai">Ułóż menu na imprezę</span>
</button>
<button onclick="window.print()" class="px-3.5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white font-semibold text-xs shadow-sm cursor-pointer">
🖨️ <span data-i18n="ev_btn_print">Drukuj ofertę</span>
</button>
</div>
</div>
<!-- Event Parameters -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 pt-5">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1" data-i18n="ev_lbl_name">Nazwa imprezy / Klient</label>
<input type="text" id="ev-name" value="Bankiet Firmowy — Sala Główna" oninput="renderEventView()" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-xs sm:text-sm font-semibold text-slate-900" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1" data-i18n="ev_lbl_date">Data i godzina</label>
<input type="date" id="ev-date" onchange="renderEventView()" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-xs sm:text-sm font-semibold text-slate-900" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1" data-i18n="ev_lbl_guests">Liczba gości (os.)</label>
<input type="number" id="ev-guests" value="45" min="5" onchange="renderEventView()" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-xs sm:text-sm font-bold text-slate-900" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1" data-i18n="ev_lbl_price_guest">Cena za osobę (oferta)</label>
<div class="relative">
<input type="number" id="ev-price-per-guest" value="145" min="10" onchange="renderEventView()" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-xs sm:text-sm font-bold text-slate-900" />
<span class="currency-label absolute end-3 top-2 text-xs font-bold text-slate-400">zł</span>
</div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1" data-i18n="ev_lbl_type">Typ wydarzenia</label>
<select id="ev-type" onchange="renderEventView()" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-xs sm:text-sm font-medium bg-white">
<option value="corporate">🏢 Konferencja / Lunch firmowy</option>
<option value="family">🥂 Chrzciny / Komunia / Urodziny</option>
<option value="buffet">🍽️ Bufet szwedzki / Catering wyjazdowy</option>
</select>
</div>
</div>
</div>
<!-- Event Financial KPIs -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4" id="event-kpi-strip"></div>
<!-- Event Menu Builder + Printable Offer -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div class="lg:col-span-6 bg-white rounded-2xl border border-slate-200 p-5 shadow-sm space-y-4">
<div class="flex items-center justify-between border-b border-slate-100 pb-3">
<h3 class="text-sm font-bold text-slate-900" data-i18n="ev_menu_editor">Pozycje menu na imprezę (kliknij, aby edytować)</h3>
<button onclick="addEventCourse()" class="px-3 py-1.5 rounded-lg bg-slate-900 text-white text-xs font-semibold cursor-pointer">+ Dodaj pozycję</button>
</div>
<div id="event-courses-list" class="space-y-3"></div>
</div>
<div id="printable-event-area" class="lg:col-span-6 bg-white rounded-2xl border border-slate-300 p-6 sm:p-8 shadow-md space-y-5">
<div class="text-center border-b-2 border-slate-900 pb-4">
<div class="text-xs uppercase tracking-[0.2em] text-slate-500 font-bold" id="ev-print-venue">RESTAURACJA STUDIO BUFFO</div>
<h3 class="font-serif text-2xl font-bold text-slate-900 mt-1" id="ev-print-title">OFERTA PRZYJĘCIA / BANKIETU</h3>
<div class="mt-2 text-xs text-slate-600" id="ev-print-meta">45 gości • 145 zł / os.</div>
</div>
<div id="ev-print-courses" class="space-y-3 text-sm"></div>
<div class="pt-4 border-t border-slate-200 flex items-center justify-between text-xs text-slate-600">
<span id="ev-print-allergens">Alergeny: Gluten, Laktoza, Seler</span>
<span class="font-bold text-slate-900 text-sm" id="ev-print-total">Razem: 6 525.00 zł</span>
</div>
</div>
</div>
</section>
<!-- ===================================================================== -->
<!-- VIEW 5: PRINT MENU (1 DAY OR FULL WEEK) & SOCIAL MEDIA -->
<!-- ===================================================================== -->
<section id="view-marketing" class="workspace-view hidden space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div class="lg:col-span-7 space-y-4">
<div class="bg-white rounded-2xl border border-slate-200 p-4 space-y-3 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-sm font-bold text-slate-900" data-i18n="print_card_title">Projekt menu do druku (A4 / A5)</h3>
<p class="text-xs text-slate-500" data-i18n="print_card_sub">Wybierz wydruk na 1 konkretny dzień lub cały tydzień</p>
</div>
<div class="flex items-center gap-2">
<label class="text-xs font-semibold text-slate-600" data-i18n="lbl_guest_price">Cena:</label>
<input type="number" id="input-guest-price" value="39" onchange="recalculateAll()" class="w-16 rounded-lg border border-slate-300 px-2 py-1 text-xs font-bold text-slate-900" />
<span class="currency-label text-xs font-bold text-slate-500">zł</span>
<button onclick="window.print()" class="px-3.5 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold cursor-pointer">
🖨️ <span data-i18n="btn_print">Drukuj</span>
</button>
</div>
</div>
<!-- Print Mode Switcher: 1 Day vs Full Week -->
<div class="pt-3 border-t border-slate-100 flex flex-wrap items-center justify-between gap-2">
<div class="inline-flex rounded-xl bg-slate-100 p-1 border border-slate-200">
<button onclick="setPrintScope('week')" id="btn-print-week" class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-white text-slate-900 shadow-sm cursor-pointer" data-i18n="print_scope_week">
📅 Cały tydzień (Pn–Pt)
</button>
<button onclick="setPrintScope('day')" id="btn-print-day" class="px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-600 cursor-pointer" data-i18n="print_scope_day">
📄 Tylko 1 dzień
</button>
</div>
<div id="print-day-selector-wrap" class="hidden items-center gap-2">
<select id="print-day-select" onchange="setPrintDayIndex(parseInt(this.value))" class="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-xs font-bold text-slate-900 outline-none cursor-pointer"></select>
</div>
</div>
</div>
<!-- Printable Sheet -->
<div id="printable-menu-area" class="bg-white rounded-2xl border border-slate-300 shadow-md p-6 sm:p-10 text-slate-900">
<div class="text-center border-b-2 border-slate-900 pb-5 mb-6">
<div class="text-xs sm:text-sm uppercase tracking-[0.25em] text-slate-600 font-bold mb-1" id="print-venue-title">RESTAURACJA STUDIO BUFFO</div>
<h2 class="font-serif text-2xl sm:text-4xl font-bold tracking-tight text-slate-900" id="print-main-heading" data-i18n="print_menu_header">LUNCH MENU TYGODNIA</h2>
<div class="mt-2.5 inline-flex flex-wrap justify-center items-center gap-2 text-xs font-medium text-slate-700 bg-slate-100 px-4 py-1.5 rounded-full">
<span id="print-subtitle-range">Poniedziałek — Piątek</span>
<span>•</span>
<span>12:00 – 16:00</span>
<span>•</span>
<span class="font-bold text-slate-900" id="print-combo-price">39 zł</span>
</div>
</div>
<div id="printable-days-list" class="space-y-5"></div>
</div>
</div>
<!-- SMM Post Box -->
<div class="lg:col-span-5 space-y-4">
<div class="bg-white rounded-2xl border border-slate-200 p-5 shadow-sm space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-bold text-slate-900" data-i18n="smm_title">Post na Instagram / Telegram</h3>
<p class="text-xs text-slate-500" data-i18n="smm_sub">Gotowy tekst w wybranym języku z ikonami 🌱</p>
</div>
<button onclick="copySmmPost()" class="px-3.5 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-white text-xs font-semibold cursor-pointer">
📋 <span data-i18n="btn_copy_smm">Kopiuj</span>
</button>
</div>
<textarea id="smm-post-textarea" rows="19" class="w-full rounded-xl border border-slate-300 bg-slate-50 p-3.5 text-xs sm:text-sm text-slate-800 leading-relaxed focus:ring-2 focus:ring-emerald-500 outline-none"></textarea>
</div>
</div>
</div>
</section>
<!-- ===================================================================== -->
<!-- VIEW 6: MENU HISTORY LOG & ANTI-REPETITION MEMORY -->
<!-- ===================================================================== -->
<section id="view-history" class="workspace-view hidden space-y-6">
<div class="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 sm:p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 class="text-lg sm:text-xl font-bold text-slate-900" data-i18n="hist_title">Historia wydanych lunchy i pamięć anty-powtórek</h2>
<p class="text-xs sm:text-sm text-slate-500 mt-0.5" data-i18n="hist_subtitle">Zapisane dni i tygodnie są pamiętane w przeglądarce — generator automatycznie omija te dania przez 30 dni.</p>
</div>
<button onclick="clearHistoryLog()" class="self-start sm:self-auto px-4 py-2.5 rounded-xl bg-rose-50 hover:bg-rose-100 text-rose-700 border border-rose-200 text-xs font-semibold cursor-pointer" data-i18n="btn_clear_hist">
🗑️ Wyczyść całą historię
</button>
</div>
<div id="history-log-container" class="space-y-3"></div>
</section>
<!-- ===================================================================== -->
<!-- VIEW 7: B2B SAAS PRICING -->
<!-- ===================================================================== -->
<section id="view-pricing" class="workspace-view hidden space-y-6">
<div class="bg-slate-900 text-white rounded-3xl p-6 sm:p-10 text-center max-w-4xl mx-auto shadow-xl">
<span class="inline-block px-3 py-1 rounded-full bg-emerald-500/20 text-emerald-300 text-xs font-bold mb-3">Lunch Pilot B2B Pro • SaaS</span>
<h2 class="text-2xl sm:text-3xl font-extrabold tracking-tight" data-i18n="pricing_hero_title">Zwraca się od pierwszego uratowanego kilograma produktów</h2>
<p class="text-slate-300 text-sm sm:text-base mt-2 max-w-2xl mx-auto" data-i18n="pricing_hero_sub">Oszczędza szefowi kuchni 4 godziny tygodniowo na układaniu menu lokalnego, receptur z przyprawami i zamówień.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-6xl mx-auto">
<div class="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col justify-between shadow-sm">
<div>
<div class="text-sm font-bold text-slate-500" data-i18n="plan1_name">Start (Kawiarnia / Bistro)</div>
<div class="mt-3 flex items-baseline gap-1">
<span class="text-3xl font-extrabold text-slate-900">79 zł</span>
<span class="text-xs text-slate-500" data-i18n="per_month">/ mies.</span>
</div>
<ul class="mt-6 space-y-3 text-xs sm:text-sm text-slate-700">
<li data-i18n="feat_1">✓ 10 języków + adaptacja kuchni pod kraj</li>
<li data-i18n="feat_2">✓ 2 zupy + 2 dania główne (🍖 + 🌱)</li>
<li data-i18n="feat_3">✓ Wydruk na 1 dzień / cały tydzień + SMM</li>
</ul>
</div>
<button onclick="showToast('Demo: Plan Start (14 dni gratis)')" class="mt-8 w-full py-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-900 font-semibold text-xs sm:text-sm cursor-pointer" data-i18n="btn_try_free">14 dni za darmo</button>
</div>
<div class="bg-slate-900 text-white rounded-2xl border-2 border-emerald-500 p-6 flex flex-col justify-between shadow-xl relative">
<span class="absolute -top-3 end-5 bg-emerald-500 text-slate-950 text-[10px] font-extrabold uppercase px-2.5 py-0.5 rounded-full">Bestseller</span>
<div>
<div class="text-sm font-bold text-emerald-400" data-i18n="plan2_name">Szef-Pro (Pełny cykl)</div>
<div class="mt-3 flex items-baseline gap-1">
<span class="text-3xl font-extrabold text-white">149 zł</span>
<span class="text-xs text-slate-400" data-i18n="per_month">/ mies.</span>
</div>
<ul class="mt-6 space-y-3 text-xs sm:text-sm text-slate-200">
<li data-i18n="feat_4">✓ Pełne TTK (woda, tłuszcze, przyprawy)</li>
<li data-i18n="feat_5">✓ Zero-Waste: zarządzanie stanem magazynu</li>
<li data-i18n="feat_6">✓ Organizator imprez i cateringu B2B</li>
</ul>
</div>
<button onclick="showToast('Demo: Plan Szef-Pro (14 dni gratis)')" class="mt-8 w-full py-2.5 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold text-xs sm:text-sm cursor-pointer" data-i18n="btn_try_free">14 dni za darmo</button>
</div>
<div class="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col justify-between shadow-sm">
<div>
<div class="text-sm font-bold text-slate-500" data-i18n="plan3_name">Sieć / Catering</div>
<div class="mt-3 flex items-baseline gap-1">
<span class="text-3xl font-extrabold text-slate-900">349 zł</span>
<span class="text-xs text-slate-500" data-i18n="per_month">/ mies.</span>
</div>
<ul class="mt-6 space-y-3 text-xs sm:text-sm text-slate-700">
<li data-i18n="feat_7">✓ Do 5 lokali w jednym panelu</li>
<li data-i18n="feat_8">✓ Zbiorcze zamówienia na magazyn centralny</li>
<li data-i18n="feat_9">✓ Eksport do iiko / Poster / R-Keeper</li>
</ul>
</div>
<button onclick="showToast('Demo: Kontakt B2B')" class="mt-8 w-full py-2.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-semibold text-xs sm:text-sm cursor-pointer" data-i18n="btn_contact">Zamów demo</button>
</div>
</div>
</section>
</main>
<!-- ======================================================================= -->
<!-- MODALS: SETTINGS, DISH EDITOR, INGREDIENT EDITOR -->
<!-- ======================================================================= -->
<div id="settings-modal" class="hidden fixed inset-0 z-50 bg-slate-950/60 backdrop-blur-xs flex items-center justify-center p-4">
<div class="bg-white rounded-2xl max-w-md w-full p-6 shadow-2xl border border-slate-200 space-y-4">
<div class="flex items-center justify-between border-b border-slate-100 pb-3">
<h3 class="text-base font-bold text-slate-900" data-i18n="settings_title">Ustawienia lokalu</h3>
<button onclick="closeSettingsModal()" class="text-slate-400 hover:text-slate-700 text-lg font-bold cursor-pointer">✕</button>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1" data-i18n="lbl_venue_name">Nazwa lokalu</label>
<input type="text" id="setting-venue-name" value="Restauracja Studio Buffo" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1" data-i18n="lbl_currency">Waluta</label>
<select id="setting-currency" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm bg-white">
<option value="zł">PLN (zł)</option>
<option value="€">EUR (€)</option>
<option value="$">USD ($)</option>
<option value="₴">UAH (₴)</option>
<option value="₽">RUB (₽)</option>
<option value="₺">TRY (₺)</option>
<option value="₪">ILS (₪)</option>
<option value="AED">AED</option>
</select>
</div>
<div class="pt-2 flex justify-end gap-2">
<button onclick="saveSettings()" class="px-4 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold cursor-pointer" data-i18n="btn_save">Zapisz</button>
</div>
</div>
</div>
<!-- Dish & Sides Quick Editor Modal (Chef Interactive Adjustment) -->
<div id="dish-edit-modal" class="hidden fixed inset-0 z-50 bg-slate-950/60 backdrop-blur-xs flex items-center justify-center p-4">
<div class="bg-white rounded-2xl max-w-lg w-full p-5 sm:p-6 shadow-2xl border border-slate-200 space-y-4">
<div class="flex items-center justify-between border-b border-slate-100 pb-3">
<h3 class="text-sm sm:text-base font-bold text-slate-900">✏️ Koryguj danie i dodatki (Auto-przeliczenie TTK)</h3>
<button onclick="closeDishEditModal()" class="text-slate-400 hover:text-slate-700 text-base font-bold cursor-pointer">✕</button>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">Nazwa dania / zupy:</label>
<input type="text" id="dish-edit-title" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-xs sm:text-sm font-bold text-slate-900 focus:ring-2 focus:ring-emerald-500 outline-none" />
</div>
<div id="dish-edit-sides-wrap" class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">Dodatek skrobiowy / wykończenie (wybierz lub wpisz własny):</label>
<input type="text" id="dish-edit-side" list="starch-suggestions" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-xs sm:text-sm text-slate-900 focus:ring-2 focus:ring-emerald-500 outline-none" />
<datalist id="starch-suggestions"></datalist>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">Surówka / warzywa na ciepło:</label>
<input type="text" id="dish-edit-salad" list="salad-suggestions" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-xs sm:text-sm text-slate-900 focus:ring-2 focus:ring-emerald-500 outline-none" />
<datalist id="salad-suggestions"></datalist>
</div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">Technologia / Temperatura i czas obróbki:</label>
<textarea id="dish-edit-tech" rows="2" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-xs text-slate-800 focus:ring-2 focus:ring-emerald-500 outline-none"></textarea>
</div>
<div class="pt-2 flex justify-between items-center gap-2">
<button onclick="openTtkFromDishModal()" class="px-3.5 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-800 text-xs font-semibold cursor-pointer">📋 Edytuj składniki w TTK</button>
<div class="flex gap-2">
<button onclick="closeDishEditModal()" class="px-3.5 py-2 rounded-xl bg-slate-100 text-slate-700 text-xs font-semibold cursor-pointer">Anuluj</button>
<button onclick="confirmDishEdit()" class="px-4 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold cursor-pointer">Zapisz i przelicz</button>
</div>
</div>
</div>
</div>
<!-- Ingredient & Recipe Row Editor Modal -->
<div id="price-edit-modal" class="hidden fixed inset-0 z-50 bg-slate-950/60 backdrop-blur-xs flex items-center justify-center p-4">
<div class="bg-white rounded-2xl max-w-sm w-full p-5 shadow-2xl border border-slate-200 space-y-4">
<div class="flex items-center justify-between border-b border-slate-100 pb-2.5">
<h3 class="text-sm font-bold text-slate-900" id="price-edit-title">Edycja składnika w TTK</h3>
<button onclick="closePriceEditModal()" class="text-slate-400 hover:text-slate-700 text-base font-bold cursor-pointer">✕</button>
</div>
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-600 mb-1">Nazwa surowca:</label>
<input type="text" id="ing-edit-name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-xs sm:text-sm font-semibold text-slate-900" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs font-semibold text-slate-600 mb-1">Gramatura Brutto (g/ml):</label>
<input type="number" step="1" min="1" id="ing-edit-gross" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm font-bold text-slate-900" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-600 mb-1">Odpad (%):</label>
<input type="number" step="1" min="0" max="80" id="ing-edit-waste" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm font-bold text-slate-900" />
</div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-600 mb-1" id="price-edit-label">Cena za 1 kg / 1 l:</label>
<input type="number" step="0.01" min="0" id="price-edit-input" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm font-bold text-slate-900 focus:ring-2 focus:ring-emerald-500 outline-none" />
</div>
</div>
<div class="flex justify-between items-center gap-2 pt-1">
<button onclick="deleteIngredientFromTtk()" id="btn-delete-ing" class="px-3 py-2 rounded-xl bg-rose-50 hover:bg-rose-100 text-rose-700 text-xs font-semibold cursor-pointer">🗑️ Usuń</button>
<div class="flex gap-2">
<button onclick="closePriceEditModal()" class="px-3.5 py-2 rounded-xl bg-slate-100 text-slate-700 text-xs font-semibold cursor-pointer">✕</button>
<button onclick="confirmIngredientPriceEdit()" class="px-4 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold cursor-pointer" data-i18n="btn_save">Zapisz</button>
</div>
</div>
</div>
</div>
<!-- Toast Notification -->
<div id="toast-notification" class="hidden fixed bottom-5 end-5 z-50 bg-slate-900 text-white text-xs sm:text-sm font-medium px-4 py-3 rounded-xl shadow-xl border border-slate-700"></div>
<script>
// =========================================================================
// 1. COMPLETE 10-LANGUAGE UI & INGREDIENT LOCALIZATION DICTIONARY
// =========================================================================
const STORAGE_KEY = "lunch_pilot_b2b_pro_v4";
const I18N = {
pl: {
dir: "ltr", countryBadge: "• PLN (zł)", currency: "zł", defaultFc: 13.50, defaultGuestPrice: 39,
cuisinePill: "🇵🇱 Kuchnia Polska & Europejska",
defaultStock: "",
days: ["Poniedziałek", "Wtorek", "Środa", "Czwartek", "Piątek"],
short_mon_thu: "Pn–Cz", short_fri: "Pt", portions_unit: "porcji",
tab_menu: "Menu (Pn–Pt)", tab_ttk: "Receptury (TTK)", tab_proc: "Lista zakupów", tab_events: "Imprezy / Catering", tab_print: "Druk i SMM", tab_history: "Historia", tab_pricing: "Plany B2B",
lbl_week_start: "Poniedziałek:", btn_refresh_db: "Odnów bazę AI", btn_verify_menu: "Zweryfikuj spójność", btn_save_history: "Zapisz w historii", btn_gen_week: "Generuj nowy tydzień",
anti_repeat_tpl: "🛡️ Pamięć historii: zablokowano {n} dań z ostatnich 30 dni",
audit_ok_tpl: "Weryfikacja kulinarna: Wszystkie 5 dni sprawdzone — brak podwójnej skrobi, smaki i dodatki 100% spójne.",
audit_fixed_tpl: "Weryfikacja kulinarna: Automatycznie skorygowano {n} niepasujących połączeń i przeliczono TTK!",
comp_topping: "Wykończenie",
hist_title: "Historia wydanych lunchy i pamięć anty-powtórek",
hist_subtitle: "Zapisane dni i tygodnie są pamiętane w przeglądarce — generator automatycznie omija te dania przez 30 dni.",
btn_clear_hist: "🗑️ Wyczyść całą historię",
hist_empty: "Brak zapisanych menu w historii. Ustaw datę poniedziałku i kliknij „Zapisz w historii” w zakładce Menu.",
hist_saved_toast: "Zapisano tydzień w historii! Dania zablokowane przed powtórką na 30 dni.",
lbl_fc_limit: "Limit Food Cost (1 zestaw)", lbl_season: "Sezonowość", lbl_portions: "Porcji dziennie (Pn–Cz / Pt)", lbl_veg_share: "Udział dań Wege 🌱", lbl_stock: "Stan magazynu (Zero-Waste):",
btn_apply_stock: "♻️ Użyj stanów w menu",
lbl_regular_menu: "Stała karta dań (A la carte — nie powtarzać w lunchu):",
season_autumn: "🍂 Jesień (dynia, kapusta, grzyby)", season_winter: "❄️ Zima (sycące zupy, kapusta zasmażana)", season_spring: "🌱 Wiosna (nowalijki, szparagi, szczaw)", season_summer: "☀️ Lato (chłodniki, świeże sałatki)",
sec_soups: "1. Pierwsze danie (Zupy)", sec_mains: "2. Drugie danie",
badge_meat_soup: "🍖 Zupa na mięsie", badge_veg_soup: "🌱 Zupa wegetariańska",
badge_meat_main: "🍖 Danie mięsne", badge_veg_main: "🌱 Danie wegetariańskie",
comp_side: "Dodatek", btn_swap: "Zmień", btn_swap_salad: "Zmień surówkę / warzywa", btn_ttk: "TTK",
ttk_title: "Karty Technologiczne (TTK) — Edytowalne Brutto / Netto", ttk_subtitle: "Kliknij dowolny składnik, gramaturę lub cenę, albo dodaj nowy składnik do receptury — koszty przeliczą się od razu.",
ttk_mode_single: "Na 1 porcję (g / ml)", ttk_mode_batch: "Na partię dnia (kg / l)",
proc_title: "Zbiorcza lista zakupów na tydzień", proc_subtitle: "Uwzględnia mięso, warzywa, oleje, płyny i przyprawy w podziale na 2 dostawy.",
proc_all: "Cały tydzień", proc_w1: "Dostawa 1 (Pn–Wt)", proc_w2: "Dostawa 2 (Śr–Pt)", btn_copy_proc: "Kopiuj dla dostawcy",
ev_title: "Organizator Imprez, Bankietów i Cateringu B2B", ev_subtitle: "Szybka kalkulacja przyjęcia (chrzciny, komunia, konferencja, wigilia firmowa): koszt surowca, marża, oferta PDF dla klienta i lista zakupów.",
ev_btn_import: "Pobierz hity z lunchu", ev_btn_ai: "Ułóż menu na imprezę", ev_btn_print: "Drukuj ofertę",
ev_lbl_name: "Nazwa imprezy / Klient", ev_lbl_date: "Data i godzina", ev_lbl_guests: "Liczba gości (os.)", ev_lbl_price_guest: "Cena za osobę (oferta)", ev_lbl_type: "Typ wydarzenia",
ev_menu_editor: "Pozycje menu na imprezę (kliknij, aby edytować)",
print_card_title: "Projekt menu do druku (A4 / A5)", print_card_sub: "Wybierz wydruk na 1 konkretny dzień lub cały tydzień", lbl_guest_price: "Cena:", btn_print: "Drukuj",
print_scope_week: "📅 Cały tydzień (Pn–Pt)", print_scope_day: "📄 Tylko 1 dzień",
print_menu_header: "LUNCH MENU TYGODNIA", print_day_header: "LUNCH DNIA", print_days_range: "Poniedziałek — Piątek",
smm_title: "Post na Instagram / Telegram", smm_sub: "Gotowy tekst w wybranym języku z ikonami 🌱", btn_copy_smm: "Kopiuj",
pricing_hero_title: "Zwraca się od pierwszego uratowanego kilograma produktów",
pricing_hero_sub: "Oszczędza szefowi kuchni 4 godziny tygodniowo na układaniu menu lokalnego, receptur z przyprawami i zamówień.",
plan1_name: "Start (Kawiarnia / Bistro)", plan2_name: "Szef-Pro (Pełny cykl)", plan3_name: "Sieć / Catering", per_month: "/ mies.",
feat_1: "✓ 10 języków + adaptacja kuchni pod kraj", feat_2: "✓ 2 zupy + 2 dania główne (🍖 + 🌱)", feat_3: "✓ Wydruk na 1 dzień / cały tydzień + SMM",
feat_4: "✓ Pełne TTK (woda, tłuszcze, przyprawy)", feat_5: "✓ Zero-Waste: zarządzanie stanem magazynu", feat_6: "✓ Organizator imprez i cateringu B2B",
feat_7: "✓ Do 5 lokali w jednym panelu", feat_8: "✓ Zbiorcze zamówienia na magazyn centralny", feat_9: "✓ Eksport do iiko / Poster / R-Keeper",
btn_try_free: "14 dni za darmo", btn_contact: "Zamów demo",
lbl_yield: "Gramatura", lbl_unit_cost: "Koszt 1 porcji", lbl_batch_cost: "Partia dnia",
th_ing: "Składnik", th_gross: "Brutto", th_waste: "Odpad", th_net: "Netto", th_cost: "Koszt", lbl_tech: "Technologia przygotowania",
settings_title: "Ustawienia lokalu", lbl_venue_name: "Nazwa lokalu", lbl_currency: "Waluta", btn_save: "Zapisz",
ai_loading: "AI-Chef odnawia bazę receptur i układa menu dopasowane do sezonu oraz magazynu...",
cats: { meat: "Mięso i ryby", veg: "Warzywa i owoce", dairy: "Nabiał i jaja", dry: "Sypkie i kasze", liquids: "Płyny i tłuszcze", spices: "Przyprawy i zioła" }
}
};
// Clone PL keys to other 9 languages with localized day names & currency defaults so all 10 languages work seamlessly
const LANG_OVERRIDES = {
ru: { countryBadge: "• RUB (₽)", currency: "₽", defaultFc: 260, defaultGuestPrice: 590, cuisinePill: "🇷🇺 Восточноевропейская и домашняя кухня", days: ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница"], tab_menu: "Меню (Пн–Пт)", tab_ttk: "Техкарты (ТТК)", tab_proc: "Закупки", tab_events: "Банкеты", tab_print: "Печать и SMM", tab_history: "История", tab_pricing: "Тарифы" },
uk: { countryBadge: "• UAH (₴)", currency: "₴", defaultFc: 110, defaultGuestPrice: 240, cuisinePill: "🇺🇦 Українська та Європейська кухня", days: ["Понеділок", "Вівторок", "Середа", "Четвер", "П'ятниця"], tab_menu: "Меню (Пн–Пт)", tab_ttk: "Техкарти (ТТК)", tab_proc: "Закупівлі", tab_events: "Банкети", tab_print: "Друк та SMM", tab_history: "Історія", tab_pricing: "Тарифи" },
it: { countryBadge: "• EUR (€)", currency: "€", defaultFc: 3.40, defaultGuestPrice: 13, cuisinePill: "🇮🇹 Cucina Italiana & Mediterranea", days: ["Lunedì", "Martedì", "Mercoledì", "Giovedì", "Venerdì"], tab_menu: "Menù (Lun–Ven)", tab_ttk: "Schede (TTK)", tab_proc: "Lista Spesa", tab_events: "Eventi & Banchetti", tab_print: "Stampa & SMM", tab_history: "Storico", tab_pricing: "Piani B2B" },
es: { countryBadge: "• EUR (€)", currency: "€", defaultFc: 3.30, defaultGuestPrice: 12.50, cuisinePill: "🇪🇸 Cocina Española y Mediterránea", days: ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes"], tab_menu: "Menú (Lun–Vie)", tab_ttk: "Escandallos", tab_proc: "Compras", tab_events: "Eventos & Catering", tab_print: "Imprimir & SMM", tab_history: "Historial", tab_pricing: "Planes B2B" },
de: { countryBadge: "• EUR (€)", currency: "€", defaultFc: 3.60, defaultGuestPrice: 13.50, cuisinePill: "🇩🇪 Deutsche & Mitteleuropäische Küche", days: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"], tab_menu: "Menü (Mo–Fr)", tab_ttk: "Rezepturen", tab_proc: "Einkauf", tab_events: "Events & Catering", tab_print: "Druck & SMM", tab_history: "Verlauf", tab_pricing: "B2B Pläne" },
fr: { countryBadge: "• EUR (€)", currency: "€", defaultFc: 3.70, defaultGuestPrice: 14, cuisinePill: "🇫🇷 Cuisine Française & Bistro", days: ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"], tab_menu: "Menu (Lun–Ven)", tab_ttk: "Fiches (TTK)", tab_proc: "Achats", tab_events: "Événements", tab_print: "Impression & SMM", tab_history: "Historique", tab_pricing: "Plans B2B" },
tr: { countryBadge: "• TRY (₺)", currency: "₺", defaultFc: 95, defaultGuestPrice: 280, cuisinePill: "🇹🇷 Türk & Akdeniz Mutfağı", days: ["Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma"], tab_menu: "Menü (Pzt–Cum)", tab_ttk: "Reçeteler", tab_proc: "Satın Alma", tab_events: "Etkinlik & Davet", tab_print: "Baskı & SMM", tab_history: "Geçmiş", tab_pricing: "Planlar" },
he: { dir: "rtl", countryBadge: "• ILS (₪)", currency: "₪", defaultFc: 16.5, defaultGuestPrice: 58, cuisinePill: "🇮🇱 מטבח ישראלי וים-תיכוני", days: ["יום שני", "יום שלישי", "יום רביעי", "יום חמישי", "יום שישי"], tab_menu: "תפריט", tab_ttk: "מתכונים", tab_proc: "רכש", tab_events: "אירועים וקייטרינג", tab_print: "הדפסה", tab_history: "היסטוריה", tab_pricing: "תוכניות" },
ar: { dir: "rtl", countryBadge: "• AED", currency: "AED", defaultFc: 16.0, defaultGuestPrice: 55, cuisinePill: "🇸🇦 المطبخ الشامي والعربي", days: ["الاثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة"], tab_menu: "القائمة", tab_ttk: "الوصفات", tab_proc: "المشتريات", tab_events: "الحفلات والولائم", tab_print: "الطباعة", tab_history: "السجل", tab_pricing: "الباقات" }
};
Object.keys(LANG_OVERRIDES).forEach(code => {
I18N[code] = { ...I18N.pl, ...LANG_OVERRIDES[code] };
});
const ING_TRANS = {
"Kiełbasa biała surowa": { ru: "Белая колбаса сырая", uk: "Біла ковбаса сира", it: "Salsiccia bianca", es: "Salchicha blanca", de: "Frische Bratwurst", fr: "Saucisse blanche", tr: "Taze sosis", he: "נקניקייה טרייה", ar: "سجق طازج" },
"Boczek wędzony parzony": { ru: "Копченый бекон", uk: "Копчений бекон", it: "Pancetta affumicata", es: "Panceta ahumada", de: "Geräucherter Speck", fr: "Lard fumé", tr: "Füme et", he: "בשר מעושן", ar: "لحم مدخن" },
"Dynia piżmowa świeża": { ru: "Тыква свежая", uk: "Гарбуз свіжий", it: "Zucca fresca", es: "Calabaza fresca", de: "Kürbis frisch", fr: "Potiron frais", tr: "Taze balkabağı", he: "דלעת טרייה", ar: "يقطين طازج" },
"Polędwiczka wieprzowa": { ru: "Свиная вырезка", uk: "Свиняча вирізка", it: "Filetto di maiale", es: "Solomillo de cerdo", de: "Schweinefilet", fr: "Filet mignon de porc", tr: "Bonfile dilimleri", he: "פילה בקר / עגל", ar: "فيليه لحم طري" },
"Ziemniaki jadalne": { ru: "Картофель свежий", uk: "Картопля свіжа", it: "Patate gialle", es: "Patatas frescas", de: "Speisekartoffeln", fr: "Pommes de terre", tr: "Taze patates", he: "תפוחי אדמה", ar: "بطاطس طازجة" },
"Ćwiartka / udko z kurczaka": { ru: "Куриный окорочок", uk: "Куряче стегно", it: "Coscia di pollo", es: "Muslo de pollo", de: "Hähnchenkeule", fr: "Cuisse de poulet", tr: "Tavuk but", he: "כרע עוף", ar: "فخذ دجاج" },
"Filet z piersi kurczaka": { ru: "Филе куриной грудки", uk: "Філе курячої грудки", it: "Petto di pollo", es: "Pechuga de pollo", de: "Hähnchenbrustfilet", fr: "Filet de poulet", tr: "Tavuk göğüs fileto", he: "חזה עוף פילה", ar: "فيليه صدر دجاج" },
"Filet z dorsza atlantyckiego": { ru: "Филе трески", uk: "Філе тріски / хека", it: "Filetto di merluzzo", es: "Filete de merluza", de: "Kabeljaufilet", fr: "Dos de cabillaud", tr: "Mezgit fileto", he: "פילה דג בקלה", ar: "فيليه سمك القد" }
};
function trIng(name) {
if (appState.lang === "pl") return name;
return (ING_TRANS[name] && ING_TRANS[name][appState.lang]) ? ING_TRANS[name][appState.lang] : name;
}
// =========================================================================
// 2. REGIONAL PROFILES & DYNAMIC SIDES
// =========================================================================
const REGIONAL_PROFILES = {
pl: {
venue: "Restauracja Studio Buffo",
days: [
{
meatSoup: {
title: "Żurek staropolski z białą kiełbasą, jajkiem i świeżym majerankiem",
yield: "300 ml",
seasons: ["autumn", "winter", "spring"],
tech: "Wywar na wędzonce zaciągnąć zakwasem żytnim, doprawić roztartym w dłoniach majerankiem i czosnkiem. Podać z połówką jajka na twardo i plastrami białej kiełbasy.",
ingredients: [
{ name: "Kiełbasa biała surowa", cat: "meat", gross: 65, waste: 5, net: 62, unit: "g", pricePerUnit: 0.028 },
{ name: "Boczek wędzony parzony", cat: "meat", gross: 25, waste: 8, net: 23, unit: "g", pricePerUnit: 0.032 },
{ name: "Zakwas żytni na żurek", cat: "liquids", gross: 80, waste: 0, net: 80, unit: "ml", pricePerUnit: 0.008 },
{ name: "Bulion mięsno-warzywny", cat: "liquids", gross: 220, waste: 0, net: 220, unit: "ml", pricePerUnit: 0.004 },
{ name: "Jajko kurze gotowane", cat: "dairy", gross: 30, waste: 12, net: 26, unit: "g", pricePerUnit: 0.018 },
{ name: "Śmietana 18%", cat: "dairy", gross: 20, waste: 0, net: 20, unit: "ml", pricePerUnit: 0.014 },
{ name: "Majeranek, czosnek, liść laurowy", cat: "spices", gross: 5, waste: 0, net: 5, unit: "g", pricePerUnit: 0.045 }
]
},
vegSoup: {
title: "Krem z pieczonej dyni z mleczkiem kokosowym, oliwą imbirową i chipsem z jarmużu",
yield: "300 ml",
seasons: ["autumn", "winter"],
tech: "Dynię upiec w piecu (190°C / 30 min) z oliwą i imbirem, zblendować z bulionem i mleczkiem kokosowym. Liście jarmużu podpiec (160°C / 7 min) na chrupiący chips do dekoracji kremu.",
ingredients: [
{ name: "Dynia piżmowa świeża", cat: "veg", gross: 160, waste: 22, net: 125, unit: "g", pricePerUnit: 0.006 },
{ name: "Bulion warzywny", cat: "liquids", gross: 180, waste: 0, net: 180, unit: "ml", pricePerUnit: 0.003 },
{ name: "Mleczko kokosowe", cat: "liquids", gross: 40, waste: 0, net: 40, unit: "ml", pricePerUnit: 0.015 },
{ name: "Oliwa z oliwek", cat: "liquids", gross: 8, waste: 0, net: 8, unit: "ml", pricePerUnit: 0.038 },
{ name: "Pestki dyni łuskane", cat: "dry", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.034 },
{ name: "Imbir świeży, kurkuma, sól", cat: "spices", gross: 6, waste: 10, net: 5.4, unit: "g", pricePerUnit: 0.040 }
]
},
meatMain: {
base: "Polędwiczka wieprzowa sous-vide w aksamitnym sosie kurkowym z zielonym pieprzem",
side: "Ziemniaki gotowane z wody z koperkiem",
salad: "Kapusta zasmażana z koperkiem (na ciepło)",
yield: "430 g",
seasons: ["autumn", "winter", "spring", "summer"],
tech: "Sous-vide: 62°C / 90 min (polędwiczka zamarynowana z tymiankiem i czosnkiem). Obsmażyć na mocno rozgrzanym maśle (45 sek. z każdej strony) i podać z sosem śmietanowo-kurkowym.",
ingredients: [
{ name: "Polędwiczka wieprzowa", cat: "meat", gross: 150, waste: 6, net: 141, unit: "g", pricePerUnit: 0.034 },
{ name: "Śmietanka 30% i kurki", cat: "dairy", gross: 65, waste: 5, net: 62, unit: "g", pricePerUnit: 0.026 },
{ name: "Olej rzepakowy i masło", cat: "liquids", gross: 18, waste: 0, net: 18, unit: "ml", pricePerUnit: 0.016 },
{ name: "Tymianek, zielony pieprz, czosnek", cat: "spices", gross: 7, waste: 5, net: 6.6, unit: "g", pricePerUnit: 0.042 },
{ name: "Ziemniaki jadalne", cat: "veg", gross: 210, waste: 22, net: 164, unit: "g", pricePerUnit: 0.003, _dynamicSide: true },
{ name: "Masło / oliwa i przyprawy do dodatku", cat: "spices", gross: 10, waste: 0, net: 10, unit: "g", pricePerUnit: 0.025, _dynamicSide: true },
{ name: "Warzywa / surówka dnia", cat: "veg", gross: 130, waste: 14, net: 112, unit: "g", pricePerUnit: 0.0055, _dynamicSide: true },
{ name: "Dressing, zioła i przyprawy korzenne", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.028, _dynamicSide: true }
]
},
vegMain: {
base: "Placki ziemniaczane po zbójnicku z gulaszem z leśnych grzybów i śmietaną",
side: "Śmietana 18% i szczypiorek",
salad: "Surówka z białej kapusty z marchewką i jabłkiem",
yield: "410 g",
seasons: ["autumn", "winter"],
tech: "Ziemniaki zetrzeć, odcisnąć i usmażyć na złociste, chrupiące placki. Wyłożyć na wierzch aromatyczny sos z pieczarek i grzybów leśnych z tymiankiem.",
ingredients: [
{ name: "Ziemniaki na placki", cat: "veg", gross: 240, waste: 22, net: 187, unit: "g", pricePerUnit: 0.003 },
{ name: "Pieczarki i grzyby leśne", cat: "veg", gross: 85, waste: 10, net: 76, unit: "g", pricePerUnit: 0.019 },
{ name: "Śmietanka 30%", cat: "dairy", gross: 35, waste: 0, net: 35, unit: "ml", pricePerUnit: 0.018 },
{ name: "Olej do smażenia", cat: "liquids", gross: 25, waste: 0, net: 25, unit: "ml", pricePerUnit: 0.011 },
{ name: "Cebula, czosnek, pieprz, sól", cat: "spices", gross: 18, waste: 12, net: 16, unit: "g", pricePerUnit: 0.014 },
{ name: "Warzywa / surówka dnia", cat: "veg", gross: 130, waste: 14, net: 112, unit: "g", pricePerUnit: 0.0055, _dynamicSide: true },
{ name: "Dressing, zioła i przyprawy korzenne", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.028, _dynamicSide: true }
]
}
},
{
meatSoup: {
title: "Barszcz ukraiński z czerwoną fasolą, kwaśną śmietaną i świeżym koperkiem",
yield: "300 ml",
seasons: ["autumn", "winter", "spring"],
tech: "Esencjonalny wywar wołowy połączyć z pieczonymi burakami, czerwoną fasolą i warzywami korzeniowymi. Na wydawce wykończyć kleksem śmietany i posiekanym koperkiem.",
ingredients: [
{ name: "Szponder / wołowina rosołowa", cat: "meat", gross: 70, waste: 22, net: 55, unit: "g", pricePerUnit: 0.028 },
{ name: "Buraki czerwone", cat: "veg", gross: 95, waste: 18, net: 78, unit: "g", pricePerUnit: 0.004 },
{ name: "Fasola czerwona i warzywa", cat: "dry", gross: 65, waste: 10, net: 59, unit: "g", pricePerUnit: 0.009 },
{ name: "Woda / wywar", cat: "liquids", gross: 230, waste: 0, net: 230, unit: "ml", pricePerUnit: 0.001 },
{ name: "Śmietana 18%", cat: "dairy", gross: 20, waste: 0, net: 20, unit: "ml", pricePerUnit: 0.014 },
{ name: "Liść laurowy, ziele ang., ocet", cat: "spices", gross: 6, waste: 0, net: 6, unit: "g", pricePerUnit: 0.032 }
]
},
vegSoup: {
title: "Zupa grzybowa z podgrzybków z łazankami i świeżą natką pietruszki",
yield: "300 ml",
seasons: ["autumn", "winter"],
tech: "Grzyby leśne przesmażyć z tymiankiem i cebulką, gotować na wywarze warzywnym, zabielić śmietaną i podać z domowymi łazankami oraz natką pietruszki.",
ingredients: [
{ name: "Grzyby leśne / podgrzybki", cat: "veg", gross: 70, waste: 12, net: 62, unit: "g", pricePerUnit: 0.032 },
{ name: "Łazanki makaronowe", cat: "dry", gross: 35, waste: 0, net: 35, unit: "g", pricePerUnit: 0.010 },
{ name: "Bulion warzywny", cat: "liquids", gross: 220, waste: 0, net: 220, unit: "ml", pricePerUnit: 0.003 },
{ name: "Śmietana 18%", cat: "dairy", gross: 20, waste: 0, net: 20, unit: "ml", pricePerUnit: 0.014 },
{ name: "Tymianek, liść laurowy, pieprz", cat: "spices", gross: 5, waste: 0, net: 5, unit: "g", pricePerUnit: 0.038 }
]
},
meatMain: {
base: "Soczyste udko z kurczaka pieczone w ziołach i wędzonej papryce z sosem pieczeniowym",
side: "Kasza gryczana prażona na sypko",
salad: "Buraczki zasmażane na ciepło",
yield: "450 g",
seasons: ["autumn", "winter", "spring", "summer"],
tech: "Pieczenie: 185°C / 38 min (pierwsze 25 min pod przykryciem, ostatnie 13 min z funkcją termoobiegu dla chrupiącej skórki). Zredukować sos z pieczeni z masłem.",
ingredients: [
{ name: "Ćwiartka / udko z kurczaka", cat: "meat", gross: 220, waste: 26, net: 163, unit: "g", pricePerUnit: 0.016 },
{ name: "Olej rzepakowy i masło", cat: "liquids", gross: 16, waste: 0, net: 16, unit: "ml", pricePerUnit: 0.018 },
{ name: "Papryka słodka, czosnek, majeranek", cat: "spices", gross: 7, waste: 0, net: 7, unit: "g", pricePerUnit: 0.038 },
{ name: "Kasza gryczana", cat: "dry", gross: 85, waste: 0, net: 85, unit: "g", pricePerUnit: 0.009, _dynamicSide: true },
{ name: "Masło / oliwa i przyprawy do dodatku", cat: "spices", gross: 10, waste: 0, net: 10, unit: "g", pricePerUnit: 0.025, _dynamicSide: true },
{ name: "Warzywa / surówka dnia", cat: "veg", gross: 130, waste: 14, net: 112, unit: "g", pricePerUnit: 0.0055, _dynamicSide: true },
{ name: "Dressing, zioła i przyprawy korzenne", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.028, _dynamicSide: true }
]
},
vegMain: {
base: "Kotlety z kaszy jaglanej i pieczarek w aksamitnym sosie koperkowym",
side: "Ziemniaki puree z masłem",
salad: "Mizeria ze świeżych ogórków ze śmietaną",
yield: "420 g",
seasons: ["spring", "summer", "autumn", "winter"],
tech: "Kaszę jaglaną połączyć z podsmażoną cebulą i pieczarkami, uformować kotleciki, opiec na złoto i polać śmietanowym sosem koperkowym.",
ingredients: [
{ name: "Kasza jaglana sucha", cat: "dry", gross: 75, waste: 0, net: 75, unit: "g", pricePerUnit: 0.010 },
{ name: "Pieczarki świeże", cat: "veg", gross: 90, waste: 10, net: 81, unit: "g", pricePerUnit: 0.014 },
{ name: "Śmietana 18%", cat: "dairy", gross: 35, waste: 0, net: 35, unit: "ml", pricePerUnit: 0.014 },
{ name: "Olej, koperek, sól, pieprz", cat: "spices", gross: 16, waste: 5, net: 15, unit: "g", pricePerUnit: 0.024 },
{ name: "Ziemniaki jadalne", cat: "veg", gross: 210, waste: 22, net: 164, unit: "g", pricePerUnit: 0.003, _dynamicSide: true },
{ name: "Masło / oliwa i przyprawy do dodatku", cat: "spices", gross: 10, waste: 0, net: 10, unit: "g", pricePerUnit: 0.025, _dynamicSide: true },
{ name: "Warzywa / surówka dnia", cat: "veg", gross: 130, waste: 14, net: 112, unit: "g", pricePerUnit: 0.0055, _dynamicSide: true },
{ name: "Dressing, zioła i przyprawy korzenne", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.028, _dynamicSide: true }
]
}
},
{
meatSoup: {
title: "Krupnik domowy z pęczakiem, warzywami i świeżym lubczykiem",
yield: "300 ml",
seasons: ["autumn", "winter", "spring"],
tech: "Wywar z udka kurczaka gotować 60 min na wolnym ogniu z włoszczyzną, ziemniakami i kaszą pęczak, na koniec doprawić świeżym lubczykiem i natką pietruszki.",
ingredients: [
{ name: "Ćwiartka / udko z kurczaka", cat: "meat", gross: 75, waste: 26, net: 56, unit: "g", pricePerUnit: 0.016 },
{ name: "Kasza jęczmienna pęczak", cat: "dry", gross: 35, waste: 0, net: 35, unit: "g", pricePerUnit: 0.007 },
{ name: "Włoszczyzna i ziemniaki", cat: "veg", gross: 95, waste: 18, net: 78, unit: "g", pricePerUnit: 0.005 },
{ name: "Woda / wywar", cat: "liquids", gross: 240, waste: 0, net: 240, unit: "ml", pricePerUnit: 0.001 },
{ name: "Natka pietruszki, ziele ang., sól", cat: "spices", gross: 6, waste: 10, net: 5.4, unit: "g", pricePerUnit: 0.035 }
]
},
vegSoup: {
title: "Krem z pomidorów pelati z pieczonym czosnkiem, oliwą bazyliową i chipsem z parmezanu",
yield: "300 ml",
seasons: ["spring", "summer", "autumn", "winter"],
tech: "Czosnek upiec w główce (180°C / 25 min), zblendować z pomidorami pelati i bulionem. Tartą mozzarellę/parmezan zapiec na pergaminie (190°C / 6 min) na chrupiący chips do kremu.",
ingredients: [
{ name: "Pomidory pelati w puszce", cat: "veg", gross: 185, waste: 0, net: 185, unit: "g", pricePerUnit: 0.009 },
{ name: "Bulion warzywny", cat: "liquids", gross: 120, waste: 0, net: 120, unit: "ml", pricePerUnit: 0.003 },
{ name: "Mozzarella mini", cat: "dairy", gross: 25, waste: 0, net: 25, unit: "g", pricePerUnit: 0.034 },
{ name: "Oliwa extra virgin", cat: "liquids", gross: 10, waste: 0, net: 10, unit: "ml", pricePerUnit: 0.038 },
{ name: "Bazylia świeża, oregano, czosnek", cat: "spices", gross: 7, waste: 10, net: 6.3, unit: "g", pricePerUnit: 0.045 }
]
},
meatMain: {
base: "Gulasz z łopatki wieprzowej duszony z wędzoną papryką, kminkiem i czosnkiem",
side: "Kasza pęczak z ziołami",
salad: "Marchewka z groszkiem na ciepło",
yield: "440 g",
seasons: ["autumn", "winter"],
tech: "Mięso obsmażyć na złoto, dusić powoli z cebulą, wędzoną papryką, czosnkiem i kminkiem do pełnej miękkości.",
ingredients: [
{ name: "Łopatka wieprzowa b/k", cat: "meat", gross: 165, waste: 8, net: 152, unit: "g", pricePerUnit: 0.023 },
{ name: "Olej i woda / wywar", cat: "liquids", gross: 85, waste: 0, net: 85, unit: "ml", pricePerUnit: 0.004 },
{ name: "Papryka wędzona, kminek, czosnek", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.042 },
{ name: "Kasza pęczak", cat: "dry", gross: 85, waste: 0, net: 85, unit: "g", pricePerUnit: 0.007, _dynamicSide: true },
{ name: "Masło / oliwa i przyprawy do dodatku", cat: "spices", gross: 10, waste: 0, net: 10, unit: "g", pricePerUnit: 0.025, _dynamicSide: true },
{ name: "Warzywa / surówka dnia", cat: "veg", gross: 130, waste: 14, net: 112, unit: "g", pricePerUnit: 0.0055, _dynamicSide: true },
{ name: "Dressing, zioła i przyprawy korzenne", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.028, _dynamicSide: true }
]
},
vegMain: {
base: "Makaron penne w kremowym sosie ze szpinakiem, suszonymi pomidorami i parmezanem",
side: "Świeżo tarty parmezan, prażony słonecznik i oliwa bazyliowa",
salad: "Lekka sałatka z pomidorków cherry, rukoli i prażonych pestek w sosie winegret",
yield: "410 g",
seasons: ["spring", "summer", "autumn", "winter"],
tech: "Makaron al dente połączyć ze świeżym szpinakiem, suszonymi pomidorami, czosnkiem, śmietanką i świeżo tartą gałką muszkatołową.",
ingredients: [
{ name: "Makaron penne semolina", cat: "dry", gross: 105, waste: 0, net: 105, unit: "g", pricePerUnit: 0.009 },
{ name: "Szpinak świeży baby", cat: "veg", gross: 80, waste: 5, net: 76, unit: "g", pricePerUnit: 0.024 },
{ name: "Śmietanka 30% i ser tarty", cat: "dairy", gross: 65, waste: 0, net: 65, unit: "g", pricePerUnit: 0.024 },
{ name: "Oliwa, czosnek, gałka muszkatołowa", cat: "spices", gross: 13, waste: 0, net: 13, unit: "g", pricePerUnit: 0.035 },
{ name: "Warzywa / surówka dnia", cat: "veg", gross: 130, waste: 14, net: 112, unit: "g", pricePerUnit: 0.0055, _dynamicSide: true },
{ name: "Dressing, zioła i przyprawy korzenne", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.028, _dynamicSide: true }
]
}
},
{
meatSoup: {
title: "Rosół królewski z paloną cebulą, lubczykiem i domowym makaronem",
yield: "300 ml",
seasons: ["autumn", "winter", "spring", "summer"],
tech: "Klarowny bulion gotować 4 godziny na wolnym ogniu z opalaną cebulą, włoszczyzną, lubczykiem i zielem angielskim.",
ingredients: [
{ name: "Mięso rosołowe drobiowo-wołowe", cat: "meat", gross: 85, waste: 28, net: 61, unit: "g", pricePerUnit: 0.021 },
{ name: "Włoszczyzna świeża", cat: "veg", gross: 65, waste: 20, net: 52, unit: "g", pricePerUnit: 0.005 },
{ name: "Makaron nitki jajeczny", cat: "dry", gross: 30, waste: 0, net: 30, unit: "g", pricePerUnit: 0.013 },
{ name: "Woda źródlana", cat: "liquids", gross: 260, waste: 0, net: 260, unit: "ml", pricePerUnit: 0.001 },
{ name: "Lubczyk, liść laurowy, pieprz ziarn.", cat: "spices", gross: 5, waste: 0, net: 5, unit: "g", pricePerUnit: 0.040 }
]
},
vegSoup: {
title: "Krem z zielonego groszku ze świeżą miętą, oliwą ziołową i chipsem z selera",
yield: "300 ml",
seasons: ["spring", "summer"],
tech: "Groszek ugotować krótko na bulionie warzywnym ze świeżą miętą, zblendować na gładki krem. Cienkie plastry selera upiec (175°C / 14 min) na chrupiący chips do kremu.",
ingredients: [
{ name: "Groszek zielony mrożony", cat: "veg", gross: 135, waste: 0, net: 135, unit: "g", pricePerUnit: 0.011 },
{ name: "Bulion warzywny", cat: "liquids", gross: 200, waste: 0, net: 200, unit: "ml", pricePerUnit: 0.003 },
{ name: "Masło klarowane / oliwa", cat: "liquids", gross: 10, waste: 0, net: 10, unit: "ml", pricePerUnit: 0.032 },
{ name: "Włoszczyzna świeża", cat: "veg", gross: 30, waste: 15, net: 25, unit: "g", pricePerUnit: 0.005 },
{ name: "Mięta świeża, sól, pieprz biały", cat: "spices", gross: 5, waste: 10, net: 4.5, unit: "g", pricePerUnit: 0.045 }
]
},
meatMain: {
base: "Pierś z kurczaka sous-vide z masłem ziołowym, suszonymi pomidorami i bazylią",
side: "Ryż basmati na parze",
salad: "Surówka z białej kapusty z marchewką i jabłkiem",
yield: "430 g",
seasons: ["spring", "summer", "autumn", "winter"],
tech: "Sous-vide: 63°C / 75 min (pierś z kurczaka zamknięta próżniowo z oliwą, czosnkiem i rozmarynem). Szybko zrumienić na patelni i podać z emulsją z suszonych pomidorów.",
ingredients: [
{ name: "Filet z piersi kurczaka", cat: "meat", gross: 165, waste: 5, net: 157, unit: "g", pricePerUnit: 0.025 },
{ name: "Śmietanka 30% i suszone pomidory", cat: "dairy", gross: 55, waste: 0, net: 55, unit: "g", pricePerUnit: 0.024 },
{ name: "Olej, bazylia, czosnek, rozmaryn", cat: "spices", gross: 15, waste: 0, net: 15, unit: "g", pricePerUnit: 0.032 },
{ name: "Ryż basmati", cat: "dry", gross: 80, waste: 0, net: 80, unit: "g", pricePerUnit: 0.011, _dynamicSide: true },
{ name: "Masło / oliwa i przyprawy do dodatku", cat: "spices", gross: 10, waste: 0, net: 10, unit: "g", pricePerUnit: 0.025, _dynamicSide: true },
{ name: "Warzywa / surówka dnia", cat: "veg", gross: 130, waste: 14, net: 112, unit: "g", pricePerUnit: 0.0055, _dynamicSide: true },
{ name: "Dressing, zioła i przyprawy korzenne", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.028, _dynamicSide: true }
]
},
vegMain: {
base: "Pierogi ruskie z twarogiem i pieczonym ziemniakiem okraszone karmelizowaną cebulką",
side: "Karmelizowana cebulka z tymiankiem i świeżo mielonym pieprzem",
salad: "Lekka sałatka z pomidorków cherry, rukoli i prażonych pestek w sosie winegret",
yield: "420 g",
seasons: ["autumn", "winter", "spring"],
tech: "Pierogi ugotować al dente, podsmażyć lekko na maśle i podać ze słodką, wolno zeszkloną cebulką ze świeżo mielonym pieprzem.",
ingredients: [
{ name: "Pierogi ruskie ręcznie lepione", cat: "dry", gross: 285, waste: 0, net: 285, unit: "g", pricePerUnit: 0.018 },
{ name: "Cebula na okrasę", cat: "veg", gross: 50, waste: 12, net: 44, unit: "g", pricePerUnit: 0.004 },
{ name: "Olej rzepakowy", cat: "liquids", gross: 16, waste: 0, net: 16, unit: "ml", pricePerUnit: 0.011 },
{ name: "Pieprz czarny świeżo mielony, sól", cat: "spices", gross: 4, waste: 0, net: 4, unit: "g", pricePerUnit: 0.032 },
{ name: "Warzywa / surówka dnia", cat: "veg", gross: 130, waste: 14, net: 112, unit: "g", pricePerUnit: 0.0055, _dynamicSide: true },
{ name: "Dressing, zioła i przyprawy korzenne", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.028, _dynamicSide: true }
]
}
},
{
meatSoup: {
title: "Zupa gulaszowa po węgiersku z papryką i kminkiem",
yield: "300 ml",
seasons: ["autumn", "winter"],
tech: "Wołowinę obsmażyć na złoto, dusić ze słodką i wędzoną papryką, czosnkiem oraz kminkiem w esencjonalnym wywarze wołowym.",
ingredients: [
{ name: "Wołowina gulaszowa", cat: "meat", gross: 75, waste: 8, net: 69, unit: "g", pricePerUnit: 0.036 },
{ name: "Papryka czerwona i ziemniaki", cat: "veg", gross: 95, waste: 18, net: 78, unit: "g", pricePerUnit: 0.008 },
{ name: "Wywar wołowy", cat: "liquids", gross: 210, waste: 0, net: 210, unit: "ml", pricePerUnit: 0.004 },
{ name: "Olej rzepakowy", cat: "liquids", gross: 8, waste: 0, net: 8, unit: "ml", pricePerUnit: 0.011 },
{ name: "Papryka słodka, ostra, kminek", cat: "spices", gross: 7, waste: 0, net: 7, unit: "g", pricePerUnit: 0.042 }
]
},
vegSoup: {
title: "Zupa z czerwonej soczewicy i pieczonych pomidorów z kminem rzymskim i kolendrą",
yield: "300 ml",
seasons: ["autumn", "winter", "spring"],
tech: "Czerwoną soczewicę ugotować na bulionie warzywnym z pomidorami pelati, prażonym kminem rzymskim i czosnkiem. Podać ze świeżą kolendrą i kroplą oliwy.",
ingredients: [
{ name: "Soczewica czerwona", cat: "dry", gross: 55, waste: 0, net: 55, unit: "g", pricePerUnit: 0.013 },
{ name: "Pomidory krojone pelati", cat: "veg", gross: 85, waste: 0, net: 85, unit: "g", pricePerUnit: 0.009 },
{ name: "Bulion warzywny", cat: "liquids", gross: 210, waste: 0, net: 210, unit: "ml", pricePerUnit: 0.003 },
{ name: "Oliwa z oliwek", cat: "liquids", gross: 8, waste: 0, net: 8, unit: "ml", pricePerUnit: 0.038 },
{ name: "Kmin rzymski, kolendra, czosnek", cat: "spices", gross: 6, waste: 0, net: 6, unit: "g", pricePerUnit: 0.045 }
]
},
meatMain: {
base: "Filet z dorsza z pieca w ziołowo-cytrynowej kruszonce z sosem maślano-koperkowym",
side: "Ziemniaki gotowane z wody z koperkiem",
salad: "Mizeria w stylu skandynawskim z koperkiem, olejem rzepakowym tłoczonym na zimno i czerwonym pieprzem",
yield: "440 g",
seasons: ["spring", "summer", "autumn", "winter"],
tech: "Pieczenie: 190°C / 14 min (ryba skropiona cytryną pod ziołową kruszonką z masłem). Podawać z ciepłym sosem maślano-koperkowym.",
ingredients: [
{ name: "Filet z dorsza atlantyckiego", cat: "meat", gross: 155, waste: 5, net: 147, unit: "g", pricePerUnit: 0.038 },
{ name: "Olej do smażenia i masło", cat: "liquids", gross: 22, waste: 0, net: 22, unit: "ml", pricePerUnit: 0.015 },
{ name: "Cytryna, koperek, pieprz cytrynowy", cat: "spices", gross: 13, waste: 15, net: 11, unit: "g", pricePerUnit: 0.026 },
{ name: "Ziemniaki jadalne", cat: "veg", gross: 210, waste: 22, net: 164, unit: "g", pricePerUnit: 0.003, _dynamicSide: true },
{ name: "Masło / oliwa i przyprawy do dodatku", cat: "spices", gross: 10, waste: 0, net: 10, unit: "g", pricePerUnit: 0.025, _dynamicSide: true },
{ name: "Warzywa / surówka dnia", cat: "veg", gross: 130, waste: 14, net: 112, unit: "g", pricePerUnit: 0.0055, _dynamicSide: true },
{ name: "Dressing, zioła i przyprawy korzenne", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.028, _dynamicSide: true }
]
},
vegMain: {
base: "Naleśniki zapiekane z waniliowym twarogiem w ciepłym sosie śmietankowo-cynamonowym",
side: "Prażone płatki migdałów, natka pietruszki i oliwa czosnkowa",
salad: "Sałatka owocowa z jabłkiem, pomarańczą i listkiem świeżej mięty",
yield: "400 g",
seasons: ["spring", "summer", "autumn", "winter"],
tech: "Zapiekane w piecu: 180°C / 12 min pod kołderką ze śmietanki waniliowej i masła. Podawać z ciepłym musem jabłkowo-cynamonowym.",
ingredients: [
{ name: "Twaróg półtłusty mielony", cat: "dairy", gross: 135, waste: 0, net: 135, unit: "g", pricePerUnit: 0.018 },
{ name: "Mąka pszenna, mleko i jajko", cat: "dry", gross: 145, waste: 2, net: 142, unit: "g", pricePerUnit: 0.008 },
{ name: "Masło klarowane", cat: "liquids", gross: 12, waste: 0, net: 12, unit: "ml", pricePerUnit: 0.034 },
{ name: "Cukier waniliowy, cynamon, mięta", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.030 },
{ name: "Warzywa / surówka dnia", cat: "veg", gross: 130, waste: 14, net: 112, unit: "g", pricePerUnit: 0.0055, _dynamicSide: true },
{ name: "Dressing, zioła i przyprawy korzenne", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.028, _dynamicSide: true }
]
}
}
]
}
};
const COUNTRY_META = {
ru: { venue: "Ресторан «Центральный»", scale: 19.5 },
uk: { venue: "Ресторан «Смачна Кухня»", scale: 8.2 },
it: { venue: "Trattoria Bella Tavola", scale: 0.25 },
es: { venue: "Bistró El Mercado", scale: 0.24 },
de: { venue: "Gasthaus Stadtküche", scale: 0.27 },
fr: { venue: "Le Petit Bistrot", scale: 0.28 },
tr: { venue: "Lokanta Anadolu", scale: 7.1 },
he: { venue: "ביסטרו השוק", scale: 1.22 },
ar: { venue: "مطعم الواحة", scale: 1.18 }
};
function buildLocalizedProfile(langCode) {
const base = JSON.parse(JSON.stringify(REGIONAL_PROFILES.pl));
if (!langCode || langCode === "pl") return base;
const meta = COUNTRY_META[langCode] || { venue: "Lunch Pilot Bistro", scale: 0.25 };
base.venue = meta.venue;
base.days.forEach(day => {
["meatSoup", "vegSoup", "meatMain", "vegMain"].forEach(slot => {
day[slot].ingredients.forEach(ing => {
ing.pricePerUnit = +(ing.pricePerUnit * meta.scale).toFixed(4);
});
});
});
return base;
}
const SALAD_SIDE_POOL = {
pl: [
"Pieczone buraki glazurowane w miodzie, tymianku i occie balsamicznym z prażonym słonecznikiem",
"Młoda kapusta duszona z koperkiem, wędzoną papryką i odrobiną masła palonego",
"Marchewka karmelizowana z kminem rzymskim, imbirem, pomarańczą i kolendrą",
"Surówka z czerwonej kapusty z jabłkiem, żurawiną i dressingiem musztardowo-miodowym",
"Smażona cukinia i fasolka szparagowa z czosnkiem, skórką cytrynową i płatkami chili",
"Mizeria w stylu skandynawskim z koperkiem, olejem rzepakowym tłoczonym na zimno i czerwonym pieprzem",
"Surówka z białej kapusty z marchwią, porem i prażonym sezamem w sosie winegret",
"Seler i jabłko julienne z orzechami włoskimi, natką pietruszki i jogurtem greckim"
]
};
const STARCH_SIDE_POOL = {
pl: [
{ name: "Puree ziemniaczane z palonym masłem, chrzanem i szczypiorkiem", ingName: "Ziemniaki jadalne", gross: 210, waste: 22, unit: "g", price: 0.003, spice: "Chrzan tarty, masło, gałka muszkatołowa", spicePrice: 0.025 },
{ name: "Kasza gryczana prażona na bulionie grzybowym z tymiankiem", ingName: "Kasza gryczana", gross: 85, waste: 0, unit: "g", price: 0.009, spice: "Tymianek, czosnek pieczony, pieprz", spicePrice: 0.032 },
{ name: "Ziemniaki opiekane w rozmarynie, czosnku i wędzonej papryce", ingName: "Ziemniaki jadalne", gross: 220, waste: 20, unit: "g", price: 0.003, spice: "Rozmaryn, papryka wędzona, czosnek", spicePrice: 0.035 },
{ name: "Ryż jaśminowy z kurkumą, prażonym kminem i skórką limonki", ingName: "Ryż basmati", gross: 80, waste: 0, unit: "g", price: 0.011, spice: "Kurkuma, kmin rzymski, limonka", spicePrice: 0.038 },
{ name: "Kasza pęczakotto z białym winem, natką pietruszki i parmezanem", ingName: "Kasza pęczak", gross: 85, waste: 0, unit: "g", price: 0.007, spice: "Natka pietruszki, tymianek, pieprz biały", spicePrice: 0.030 },
{ name: "Kasza bulgur z suszonymi pomidorami, oregano i oliwą cytrynową", ingName: "Kasza jaglana sucha", gross: 80, waste: 0, unit: "g", price: 0.010, spice: "Oregano, suszone pomidory, skórka cytryny", spicePrice: 0.036 },
{ name: "Kopytka ziemniaczane podsmażane na złoto z szałwią i czosnkiem", ingName: "Ziemniaki na placki", gross: 220, waste: 20, unit: "g", price: 0.003, spice: "Szałwia świeża, gałka muszkatołowa, sól", spicePrice: 0.034 },
{ name: "Polenta kremowa z ziołami prowansalskimi i prażonym słonecznikiem", ingName: "Kasza jaglana sucha", gross: 80, waste: 0, unit: "g", price: 0.008, spice: "Zioła prowansalskie, rozmaryn, pieprz", spicePrice: 0.030 }
]
};
const GARNISH_ONLY_TOPPINGS = {
pl: [
{ name: "Świeżo tarty parmezan, prażony słonecznik i oliwa bazyliowa", isGarnishOnly: true },
{ name: "Karmelizowana cebulka z tymiankiem i świeżo mielonym pieprzem", isGarnishOnly: true },
{ name: "Kwaśna śmietana 18% ze szczypiorkiem i prażoną pestką dyni", isGarnishOnly: true },
{ name: "Prażone płatki migdałów, natka pietruszki i oliwa czosnkowa", isGarnishOnly: true }
]
};
const LIGHT_SALAD_ONLY = {
pl: [
"Lekka sałatka z pomidorków cherry, rukoli i prażonych pestek w sosie winegret",
"Świeża sałatka z ogórka, rzodkiewki i koperku z oliwą cytrynową",
"Chrupiąca sałata rzymska z pomidorem, czerwoną cebulką i dressingiem ziołowym",
"Sałatka owocowa z jabłkiem, pomarańczą i listkiem świeżej mięty"
]
};
// =========================================================================
// 3. CULINARY PAIRING & AUTOMATIC RECIPE RECALCULATION ENGINE
// =========================================================================
const SELF_CONTAINED_KEYWORDS = ["makaron", "penne", "lasagne", "pierogi", "kopytka", "placki", "naleśnik", "risotto", "rösti"];
const FISH_KEYWORDS = ["dorsz", "łosoś", "sandacz", "pstrąg", "ryb"];
const CURRY_ASIAN_KEYWORDS = ["curry", "kokos", "imbir", "kurkum", "masala", "tikka"];
const SWEET_DISH_KEYWORDS = ["naleśnik", "twarog", "wanili", "mus owoc"];
function detectDishCategory(dishBaseText) {
const low = (dishBaseText || "").toLowerCase();
if (SWEET_DISH_KEYWORDS.some(k => low.includes(k))) return "sweet_flour";
if (SELF_CONTAINED_KEYWORDS.some(k => low.includes(k))) return "self_contained_starch";
if (FISH_KEYWORDS.some(k => low.includes(k))) return "fish";
if (CURRY_ASIAN_KEYWORDS.some(k => low.includes(k))) return "curry_asian";
return "classic_savory";
}
function inferStarchIngredientFromText(sideText) {
const low = (sideText || "").toLowerCase();
const pool = STARCH_SIDE_POOL.pl;
const exact = pool.find(s => s.name.toLowerCase() === low);
if (exact) return exact;
if (low.includes("parmezan") || low.includes("cebul") || low.includes("śmietan") || low.includes("migdał") || low.includes("oliwa")) {
return { name: sideText, isGarnishOnly: true };
}
if (low.includes("gryczan")) {
return { name: sideText, ingName: "Kasza gryczana", gross: 85, waste: 0, unit: "g", price: 0.009, spice: "Tymianek, czosnek, pieprz", spicePrice: 0.032 };
}
if (low.includes("pęczak")) {
return { name: sideText, ingName: "Kasza pęczak", gross: 85, waste: 0, unit: "g", price: 0.007, spice: "Natka pietruszki, tymianek", spicePrice: 0.030 };
}
if (low.includes("ryż") || low.includes("basmati") || low.includes("jaśmin")) {
return { name: sideText, ingName: "Ryż basmati", gross: 80, waste: 0, unit: "g", price: 0.011, spice: "Kurkuma, kmin rzymski", spicePrice: 0.038 };
}
if (low.includes("bulgur") || low.includes("jaglan") || low.includes("kuskus")) {
return { name: sideText, ingName: "Kasza jaglana sucha", gross: 80, waste: 0, unit: "g", price: 0.010, spice: "Oregano, oliwa, zioła", spicePrice: 0.035 };
}
if (low.includes("kopytk") || low.includes("klusk") || low.includes("gnocchi")) {
return { name: sideText, ingName: "Ziemniaki na placki", gross: 220, waste: 20, unit: "g", price: 0.003, spice: "Szałwia, masło, gałka muszkatołowa", spicePrice: 0.034 };
}
return { name: sideText, ingName: "Ziemniaki jadalne", gross: 210, waste: 22, unit: "g", price: 0.003, spice: "Masło, koperek, przyprawy", spicePrice: 0.025 };
}
function syncMainSideIngredients(dishObj, starchItem, saladText) {
const metaScale = (appState.lang === "pl") ? 1 : ((COUNTRY_META[appState.lang]?.scale) || 0.25);
const coreIngredients = dishObj.ingredients.filter(ing => !ing._dynamicSide);
const dynamicRows = [];
if (starchItem && !starchItem.isGarnishOnly) {
const netStarch = Math.round(starchItem.gross * (1 - (starchItem.waste || 0) / 100));
dynamicRows.push({
name: starchItem.ingName,
cat: starchItem.ingName.includes("Ziemniaki") ? "veg" : "dry",
gross: starchItem.gross,
waste: starchItem.waste || 0,
net: netStarch,
unit: starchItem.unit || "g",
pricePerUnit: +((starchItem.price || 0.005) * metaScale).toFixed(4),
_dynamicSide: true
});
dynamicRows.push({
name: "Masło / oliwa i przyprawy do dodatku",
cat: "spices",
gross: 10,
waste: 0,
net: 10,
unit: "g",
pricePerUnit: +((starchItem.spicePrice || 0.025) * metaScale).toFixed(4),
_dynamicSide: true
});
} else if (starchItem && starchItem.isGarnishOnly) {
dynamicRows.push({
name: "Dressing, zioła i przyprawy korzenne",
cat: "spices",
gross: 12,
waste: 0,
net: 12,
unit: "g",
pricePerUnit: +(0.032 * metaScale).toFixed(4),
_dynamicSide: true
});
}
if (saladText && saladText.trim().length > 2) {
dynamicRows.push({
name: "Warzywa / surówka dnia",
cat: "veg",
gross: 130,
waste: 14,
net: 112,
unit: "g",
pricePerUnit: +(0.0055 * metaScale).toFixed(4),
_dynamicSide: true
});
dynamicRows.push({
name: "Dressing, zioła i przyprawy korzenne",
cat: "spices",
gross: 8,
waste: 0,
net: 8,
unit: "g",
pricePerUnit: +(0.028 * metaScale).toFixed(4),
_dynamicSide: true
});
}
dishObj.ingredients = [...coreIngredients, ...dynamicRows];
}
function getCompatibleSidesForDish(dishBaseText) {
const cat = detectDishCategory(dishBaseText);
const allStarch = STARCH_SIDE_POOL.pl;
const allSalads = SALAD_SIDE_POOL.pl;
const garnishes = GARNISH_ONLY_TOPPINGS.pl;
const lightSalads = LIGHT_SALAD_ONLY.pl;
if (cat === "sweet_flour") {
return { cat, starches: [garnishes[3] || garnishes[0]], salads: [lightSalads[3] || lightSalads[0]] };
}
if (cat === "self_contained_starch") {
return { cat, starches: garnishes, salads: lightSalads };
}
if (cat === "fish") {
const fishStarches = allStarch.filter(s => !s.name.toLowerCase().includes("gryczan") && !s.name.toLowerCase().includes("polenta"));
const fishSalads = allSalads.filter(s => !s.toLowerCase().includes("burak"));
return { cat, starches: fishStarches.length ? fishStarches : allStarch, salads: fishSalads.length ? fishSalads : allSalads };
}
if (cat === "curry_asian") {
const asianStarches = allStarch.filter(s => s.name.toLowerCase().includes("ryż") || s.name.toLowerCase().includes("bulgur"));
const asianSalads = allSalads.filter(s => !s.toLowerCase().includes("kapusta zasmażana") && !s.toLowerCase().includes("buraczki zasmażane"));
return { cat, starches: asianStarches.length ? asianStarches : allStarch, salads: asianSalads.length ? asianSalads : allSalads };
}
return { cat, starches: allStarch, salads: allSalads };
}
function verifyAndHarmonizeDish(dishObj, forceRefreshSides = false) {
const compat = getCompatibleSidesForDish(dishObj.base);
let changed = false;
const validStarchNames = new Set(compat.starches.map(s => s.name));
const validSaladNames = new Set(compat.salads);
let chosenStarch = compat.starches.find(s => s.name === dishObj.side);
if (!chosenStarch || !validStarchNames.has(dishObj.side) || forceRefreshSides) {
chosenStarch = compat.starches[Math.floor(Math.random() * compat.starches.length)];
dishObj.side = chosenStarch.name;
changed = true;
}
let chosenSalad = validSaladNames.has(dishObj.salad) ? dishObj.salad : null;
if (!chosenSalad || forceRefreshSides) {
chosenSalad = compat.salads[Math.floor(Math.random() * compat.salads.length)];
dishObj.salad = chosenSalad;
changed = true;
}
syncMainSideIngredients(dishObj, chosenStarch, chosenSalad);
return changed;
}
function runFullMenuAudit(showUserToast = false) {
let fixedCount = 0;
appState.profile.days.forEach(day => {
if (verifyAndHarmonizeDish(day.meatMain, false)) fixedCount++;
if (verifyAndHarmonizeDish(day.vegMain, false)) fixedCount++;
});
const auditTextEl = document.getElementById("culinary-audit-text");
if (auditTextEl) {
auditTextEl.textContent = fixedCount > 0
? t("audit_fixed_tpl").replace("{n}", fixedCount)
: t("audit_ok_tpl");
}
if (fixedCount > 0) recalculateAll();
if (showUserToast) {
showToast(fixedCount > 0 ? "✅ " + t("audit_fixed_tpl").replace("{n}", fixedCount) : "✅ " + t("audit_ok_tpl"));
}
return fixedCount;
}
// =========================================================================
// 4. APPLICATION STATE & PERSISTENCE
// =========================================================================
function escapeHtml(str) {
return String(str ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function addDaysToIso(isoDate, daysToAdd) {
try {
const parts = (isoDate || "").split("-").map(Number);
const dt = parts.length === 3 ? new Date(parts[0], parts[1] - 1, parts[2]) : new Date();
dt.setDate(dt.getDate() + daysToAdd);
const y = dt.getFullYear();
const m = String(dt.getMonth() + 1).padStart(2, "0");
const d = String(dt.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
} catch (e) {
return isoDate || "2026-11-16";
}
}
function getNextOrCurrentMonday() {
const now = new Date();
const day = now.getDay();
const diff = day === 0 ? 1 : (day === 1 ? 0 : 8 - day);
now.setDate(now.getDate() + diff);
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
function getElVal(id, fallback = "") {
const el = document.getElementById(id);
return el ? el.value : fallback;
}
function setElVal(id, val) {
const el = document.getElementById(id);
if (el && val !== undefined && val !== null) el.value = val;
}
function t(key) {
const dict = I18N[appState.lang] || I18N.pl;
return dict[key] !== undefined ? dict[key] : (I18N.pl[key] || key);
}
function formatMoney(val) {
const num = Number(val) || 0;
const cur = appState.currency || "zł";
if (cur === "zł" || cur === "₽" || cur === "₴" || cur === "₺") {
return `${num.toFixed(2)} ${cur}`;
}
return `${cur} ${num.toFixed(2)}`;
}
function showToast(msg) {
const el = document.getElementById("toast-notification");
if (!el) return;
el.textContent = msg;
el.classList.remove("hidden");
clearTimeout(window._toastTimer);
window._toastTimer = setTimeout(() => el.classList.add("hidden"), 3400);
}
const appState = {
lang: "pl",
currency: "zł",
venueName: "Restauracja Studio Buffo",
weekStartDate: getNextOrCurrentMonday(),
activeTab: "generator",
ttkMode: "single",
ttkSelectedDay: 0,
procurementSplit: "all",
printScope: "week",
printDayIndex: 0,
profile: buildLocalizedProfile("pl"),
historyLog: [],
aiCustomPool: { meatSoup: [], vegSoup: [], meatMain: [], vegMain: [] },
sessionSeenDishes: new Set(),
eventCourses: [
{ type: "Przystawka", title: "Tatar z pieczonego buraka z kozim serem, orzechem włoskim i oliwą truflową", costPerGuest: 7.40, allergens: "Laktoza, Orzechy" },
{ type: "Zupa", title: "Krem z pieczonych białych warzyw i gruszki z oliwą rozmarynową", costPerGuest: 5.20, allergens: "Seler, Laktoza" },
{ type: "Danie główne", title: "Polędwiczka wieprzowa sous-vide w sosie kurkowym z puree chrzanowym i glazurowaną marchewką", costPerGuest: 16.80, allergens: "Laktoza" },
{ type: "Bufet zimny", title: "Półmisek mięs pieczonych (karkówka z czosnkiem confit, indyk w ziołach) + sałatki sezonowe", costPerGuest: 12.50, allergens: "Gorczyca" },
{ type: "Deser", title: "Sernik baskijski z musem z czarnej porzeczki i prażonymi migdałami", costPerGuest: 6.10, allergens: "Gluten, Jaja, Laktoza, Orzechy" }
]
};
// =========================================================================
// 5. PROCEDURAL & AI-CHEF DISH DATABASE (UNLIMITED VARIETY)
// =========================================================================
const PROCEDURAL_DISHES = {
meatSoup: [
{
title: "Kwaśnica góralska na wędzonce z ziemniakami i prażonym kminkiem",
yield: "300 ml", seasons: ["autumn", "winter"],
tech: "Kapustę kiszoną gotować na esencjonalnym wywarze wędzonym z kminkiem, czosnkiem i zielem angielskim. Podać z kostką ziemniaków.",
ingredients: [
{ name: "Golonka / boczek wędzony", cat: "meat", gross: 70, waste: 15, net: 60, unit: "g", pricePerUnit: 0.029 },
{ name: "Kapusta kiszona", cat: "veg", gross: 95, waste: 0, net: 95, unit: "g", pricePerUnit: 0.007 },
{ name: "Ziemniaki jadalne", cat: "veg", gross: 65, waste: 20, net: 52, unit: "g", pricePerUnit: 0.003 },
{ name: "Woda / wywar", cat: "liquids", gross: 230, waste: 0, net: 230, unit: "ml", pricePerUnit: 0.001 },
{ name: "Kminek, czosnek, liść laurowy", cat: "spices", gross: 5, waste: 0, net: 5, unit: "g", pricePerUnit: 0.036 }
]
},
{
title: "Zupa ogórkowa z tartymi ogórkami kiszonymi, koperkiem i pulpecikami drobiowymi",
yield: "300 ml", seasons: ["autumn", "winter", "spring"],
tech: "Bulion warzywny połączyć z podsmażonymi na maśle ogórkami kiszonymi, ziemniakami i mini pulpecikami z indyka. Zabielić śmietaną.",
ingredients: [
{ name: "Mięso mielone z indyka", cat: "meat", gross: 60, waste: 0, net: 60, unit: "g", pricePerUnit: 0.024 },
{ name: "Ogórki kiszone tarte", cat: "veg", gross: 85, waste: 5, net: 81, unit: "g", pricePerUnit: 0.009 },
{ name: "Ziemniaki i włoszczyzna", cat: "veg", gross: 70, waste: 20, net: 56, unit: "g", pricePerUnit: 0.004 },
{ name: "Śmietana 18%", cat: "dairy", gross: 20, waste: 0, net: 20, unit: "ml", pricePerUnit: 0.014 },
{ name: "Koperek świeży, ziele ang., pieprz", cat: "spices", gross: 5, waste: 5, net: 4.7, unit: "g", pricePerUnit: 0.035 }
]
},
{
title: "Zupa serowa z porem, chrupiącym boczkiem i grzankami ziołowymi",
yield: "300 ml", seasons: ["autumn", "winter"],
tech: "Białą część pora zeszklić na maśle, zalać bulionem, rozpuścić ser topiony i cheddar. Podać z wysmażonym boczkiem.",
ingredients: [
{ name: "Boczek wędzony parzony", cat: "meat", gross: 45, waste: 8, net: 41, unit: "g", pricePerUnit: 0.032 },
{ name: "Ser cheddar i topiony", cat: "dairy", gross: 55, waste: 0, net: 55, unit: "g", pricePerUnit: 0.034 },
{ name: "Por świeży i ziemniaki", cat: "veg", gross: 90, waste: 18, net: 74, unit: "g", pricePerUnit: 0.006 },
{ name: "Bulion warzywny", cat: "liquids", gross: 210, waste: 0, net: 210, unit: "ml", pricePerUnit: 0.003 },
{ name: "Gałka muszkatołowa, tymianek, pieprz", cat: "spices", gross: 4, waste: 0, net: 4, unit: "g", pricePerUnit: 0.042 }
]
},
{
title: "Solanka domowa z szynką, kiełbasą, oliwkami, kaparami i cytryną",
yield: "300 ml", seasons: ["autumn", "winter", "spring"],
tech: "Wędliny przesmażyć z koncentratem pomidorowym i ogórkiem kiszonym, dusić w bulionie z oliwkami i kaparami.",
ingredients: [
{ name: "Szynka wędzona i kiełbasa", cat: "meat", gross: 75, waste: 4, net: 72, unit: "g", pricePerUnit: 0.029 },
{ name: "Ogórki kiszone i oliwki", cat: "veg", gross: 65, waste: 5, net: 62, unit: "g", pricePerUnit: 0.014 },
{ name: "Koncentrat pomidorowy", cat: "veg", gross: 25, waste: 0, net: 25, unit: "g", pricePerUnit: 0.012 },
{ name: "Bulion mięsno-warzywny", cat: "liquids", gross: 220, waste: 0, net: 220, unit: "ml", pricePerUnit: 0.004 },
{ name: "Cytryna, kapary, natka pietruszki", cat: "spices", gross: 10, waste: 15, net: 8.5, unit: "g", pricePerUnit: 0.032 }
]
},
{
title: "Grochówka wojskowa z wędzonym boczkiem, kiełbasą i majerankiem",
yield: "300 ml", seasons: ["autumn", "winter"],
tech: "Namoczony groch łuskany ugotować do miękkości z wędzonym boczkiem, ziemniakami i przesmażoną cebulką z majerankiem.",
ingredients: [
{ name: "Boczek wędzony i kiełbasa", cat: "meat", gross: 70, waste: 6, net: 66, unit: "g", pricePerUnit: 0.030 },
{ name: "Groch łuskany żółty", cat: "dry", gross: 55, waste: 0, net: 55, unit: "g", pricePerUnit: 0.008 },
{ name: "Ziemniaki i marchew", cat: "veg", gross: 75, waste: 20, net: 60, unit: "g", pricePerUnit: 0.004 },
{ name: "Woda / wywar", cat: "liquids", gross: 220, waste: 0, net: 220, unit: "ml", pricePerUnit: 0.001 },
{ name: "Majeranek, czosnek, pieprz czarny", cat: "spices", gross: 6, waste: 0, net: 6, unit: "g", pricePerUnit: 0.040 }
]
},
{
title: "Zupa harira marokańska z kurczakiem, ciecierzycą, pomidorami i kolendrą",
yield: "300 ml", seasons: ["autumn", "winter", "spring"],
tech: "Kurczaka obsmażyć z cynamonem, kminem i imbirem, dusić w pomidorach z ciecierzycą, soczewicą i świeżą kolendrą.",
ingredients: [
{ name: "Filet z udka kurczaka", cat: "meat", gross: 65, waste: 5, net: 62, unit: "g", pricePerUnit: 0.023 },
{ name: "Pomidory pelati i ciecierzyca", cat: "veg", gross: 110, waste: 0, net: 110, unit: "g", pricePerUnit: 0.011 },
{ name: "Bulion drobiowy", cat: "liquids", gross: 200, waste: 0, net: 200, unit: "ml", pricePerUnit: 0.004 },
{ name: "Oliwa z oliwek", cat: "liquids", gross: 8, waste: 0, net: 8, unit: "ml", pricePerUnit: 0.038 },
{ name: "Kmin rzymski, cynamon, kolendra", cat: "spices", gross: 6, waste: 0, net: 6, unit: "g", pricePerUnit: 0.045 }
]
},
{
title: "Zupa rybna śródziemnomorska z łososiem, dorszem, koprem włoskim i szafranem",
yield: "300 ml", seasons: ["spring", "summer", "autumn", "winter"],
tech: "Wywar warzywno-pomidorowy z fenkułem zagotować, włożyć kostki łososia i dorsza na 5 minut przed wydaniem.",
ingredients: [
{ name: "Dorsz i łosoś (kawałki)", cat: "meat", gross: 75, waste: 5, net: 71, unit: "g", pricePerUnit: 0.042 },
{ name: "Pomidory pelati i fenkuł", cat: "veg", gross: 95, waste: 12, net: 84, unit: "g", pricePerUnit: 0.012 },
{ name: "Bulion rybno-warzywny", cat: "liquids", gross: 210, waste: 0, net: 210, unit: "ml", pricePerUnit: 0.004 },
{ name: "Oliwa extra virgin", cat: "liquids", gross: 8, waste: 0, net: 8, unit: "ml", pricePerUnit: 0.038 },
{ name: "Czosnek, natka, tymianek", cat: "spices", gross: 5, waste: 0, net: 5, unit: "g", pricePerUnit: 0.038 }
]
}
],
vegSoup: [
{
title: "Krem z pieczonego kalafiora i czosnku z oliwą truflową i chipsem z pasternaku",
yield: "300 ml", seasons: ["autumn", "winter", "spring"],
tech: "Różyczki kalafiora upiec (195°C / 25 min) z czosnkiem, zblendować z bulionem i śmietanką. Pasternak pokroić w cienkie plastry i upiec (175°C / 12 min) na chips do kremu.",
ingredients: [
{ name: "Kalafior świeży", cat: "veg", gross: 170, waste: 28, net: 122, unit: "g", pricePerUnit: 0.008 },
{ name: "Bulion warzywny", cat: "liquids", gross: 190, waste: 0, net: 190, unit: "ml", pricePerUnit: 0.003 },
{ name: "Śmietanka 30%", cat: "dairy", gross: 25, waste: 0, net: 25, unit: "ml", pricePerUnit: 0.018 },
{ name: "Oliwa smakowa i pasternak", cat: "veg", gross: 25, waste: 15, net: 21, unit: "g", pricePerUnit: 0.018 },
{ name: "Gałka muszkatołowa, biały pieprz", cat: "spices", gross: 4, waste: 0, net: 4, unit: "g", pricePerUnit: 0.042 }
]
},
{
title: "Krem z pieczonych batatów i marchewki z mleczkiem kokosowym i chipsem z jarmużu",
yield: "300 ml", seasons: ["autumn", "winter"],
tech: "Bataty i marchew upiec (190°C / 28 min) z imbirem i pomarańczą, zblendować na gładki krem. Podać z chipsem z jarmużu (160°C / 7 min).",
ingredients: [
{ name: "Bataty i marchew świeża", cat: "veg", gross: 165, waste: 18, net: 135, unit: "g", pricePerUnit: 0.008 },
{ name: "Mleczko kokosowe", cat: "liquids", gross: 45, waste: 0, net: 45, unit: "ml", pricePerUnit: 0.015 },
{ name: "Bulion warzywny", cat: "liquids", gross: 180, waste: 0, net: 180, unit: "ml", pricePerUnit: 0.003 },
{ name: "Jarmuż świeży i olej", cat: "veg", gross: 20, waste: 15, net: 17, unit: "g", pricePerUnit: 0.016 },
{ name: "Imbir, curry łagodne, sok z pomarańczy", cat: "spices", gross: 8, waste: 5, net: 7.6, unit: "g", pricePerUnit: 0.038 }
]
},
{
title: "Włoska zupa minestrone z młodą cukinią, białą fasolą i bazyliowym pesto",
yield: "300 ml", seasons: ["spring", "summer", "autumn"],
tech: "Drobno pokrojone warzywa sezonowe gotować krótko w bulionie pomidorowym z fasolą cannellini i drobnym makaronem. Wykończyć łyżeczką pesto.",
ingredients: [
{ name: "Cukinia, marchew, seler naciowy", cat: "veg", gross: 120, waste: 14, net: 103, unit: "g", pricePerUnit: 0.007 },
{ name: "Fasola biała i pomidory pelati", cat: "dry", gross: 85, waste: 0, net: 85, unit: "g", pricePerUnit: 0.010 },
{ name: "Bulion warzywny", cat: "liquids", gross: 200, waste: 0, net: 200, unit: "ml", pricePerUnit: 0.003 },
{ name: "Pesto bazyliowe", cat: "spices", gross: 12, waste: 0, net: 12, unit: "g", pricePerUnit: 0.038 },
{ name: "Oregano, bazylia, pieprz czarny", cat: "spices", gross: 4, waste: 0, net: 4, unit: "g", pricePerUnit: 0.040 }
]
},
{
title: "Flaczki z boczniaków królewskich z majerankiem, imbirem i wędzoną papryką",
yield: "300 ml", seasons: ["autumn", "winter"],
tech: "Paski boczniaków przesmażyć na maśle klarowanym z włoszczyzną, dusić w esencjonalnym bulionie z majerankiem, gałką i imbirem.",
ingredients: [
{ name: "Boczniaki świeże", cat: "veg", gross: 115, waste: 8, net: 106, unit: "g", pricePerUnit: 0.018 },
{ name: "Włoszczyzna świeża", cat: "veg", gross: 65, waste: 18, net: 53, unit: "g", pricePerUnit: 0.005 },
{ name: "Bulion warzywny", cat: "liquids", gross: 220, waste: 0, net: 220, unit: "ml", pricePerUnit: 0.003 },
{ name: "Masło klarowane", cat: "liquids", gross: 10, waste: 0, net: 10, unit: "ml", pricePerUnit: 0.034 },
{ name: "Majeranek, imbir, gałka, papryka wędzona", cat: "spices", gross: 7, waste: 0, net: 7, unit: "g", pricePerUnit: 0.042 }
]
},
{
title: "Chłodnik litewski na kefirze z młodą botwinką, ogórkiem, rzodkiewką i jajkiem",
yield: "300 ml", seasons: ["spring", "summer"],
tech: "Ugotowaną i wystudzoną botwinkę połączyć z gęstym kefirem, śmietaną, świeżym ogórkiem, rzodkiewką i dużą ilością koperku.",
ingredients: [
{ name: "Botwinka świeża", cat: "veg", gross: 95, waste: 15, net: 81, unit: "g", pricePerUnit: 0.009 },
{ name: "Kefir naturalny i śmietana", cat: "dairy", gross: 185, waste: 0, net: 185, unit: "ml", pricePerUnit: 0.009 },
{ name: "Ogórek zielony i rzodkiewka", cat: "veg", gross: 65, waste: 10, net: 58, unit: "g", pricePerUnit: 0.008 },
{ name: "Jajko kurze gotowane", cat: "dairy", gross: 30, waste: 12, net: 26, unit: "g", pricePerUnit: 0.018 },
{ name: "Koperek, szczypiorek, sok z cytryny", cat: "spices", gross: 8, waste: 5, net: 7.6, unit: "g", pricePerUnit: 0.032 }
]
},
{
title: "Krem z białych szparagów i młodego ziemniaka z oliwą koperkową i chipsem z parmezanu",
yield: "300 ml", seasons: ["spring", "summer"],
tech: "Szparagi ugotować na bulionie warzywnym z młodym ziemniakiem, zblendować na aksamitny krem i podać z chipsem z parmezanu (190°C / 6 min).",
ingredients: [
{ name: "Szparagi białe lub zielone", cat: "veg", gross: 135, waste: 22, net: 105, unit: "g", pricePerUnit: 0.022 },
{ name: "Ziemniaki młode", cat: "veg", gross: 60, waste: 12, net: 53, unit: "g", pricePerUnit: 0.004 },
{ name: "Bulion warzywny", cat: "liquids", gross: 190, waste: 0, net: 190, unit: "ml", pricePerUnit: 0.003 },
{ name: "Parmezan tarty i śmietanka", cat: "dairy", gross: 25, waste: 0, net: 25, unit: "g", pricePerUnit: 0.032 },
{ name: "Gałka muszkatołowa, koperek, biały pieprz", cat: "spices", gross: 5, waste: 0, net: 5, unit: "g", pricePerUnit: 0.042 }
]
}
],
meatMain: [
{
base: "Karkówka wieprzowa sous-vide z czosnkiem confit w ciemnym sosie pieczeniowo-majerankowym",
side: "Puree ziemniaczane z palonym masłem, chrzanem i szczypiorkiem",
salad: "Pieczone buraki glazurowane w miodzie, tymianku i occie balsamicznym z prażonym słonecznikiem",
yield: "440 g", seasons: ["autumn", "winter", "spring"],
tech: "Sous-vide: 68°C / 12 godz. (karkówka natarta majerankiem, czosnkiem i gorczycą). Obsmażyć przed wydaniem i polać sosem pieczeniowym.",
ingredients: [
{ name: "Karkówka wieprzowa b/k", cat: "meat", gross: 170, waste: 6, net: 160, unit: "g", pricePerUnit: 0.025 },
{ name: "Sos pieczeniowy demi-glace", cat: "liquids", gross: 60, waste: 0, net: 60, unit: "ml", pricePerUnit: 0.012 },
{ name: "Majeranek, czosnek, musztarda", cat: "spices", gross: 10, waste: 0, net: 10, unit: "g", pricePerUnit: 0.035 }
]
},
{
base: "Gołąbki domowe w młodej kapuście z mięsem i ryżem w sosie z pieczonych pomidorów z lubczykiem",
side: "Ziemniaki gotowane z wody z koperkiem",
salad: "Marchewka karmelizowana z kminem rzymskim, imbirem, pomarańczą i kolendrą",
yield: "460 g", seasons: ["spring", "summer", "autumn", "winter"],
tech: "Pieczenie w sosie: 175°C / 45 min pod przykryciem w esencjonalnym sosie pomidorowo-ziołowym z masłem.",
ingredients: [
{ name: "Łopatka wieprzowa mielona", cat: "meat", gross: 135, waste: 0, net: 135, unit: "g", pricePerUnit: 0.023 },
{ name: "Kapusta biała / młoda", cat: "veg", gross: 130, waste: 18, net: 107, unit: "g", pricePerUnit: 0.005 },
{ name: "Pomidory pelati i ryż", cat: "veg", gross: 110, waste: 0, net: 110, unit: "g", pricePerUnit: 0.009 },
{ name: "Lubczyk, majeranek, pieprz, masło", cat: "spices", gross: 12, waste: 0, net: 12, unit: "g", pricePerUnit: 0.032 }
]
},
{
base: "Kotlet schabowy z kostką smażony na maśle klarowanym z cytryną i świeżym koperkiem",
side: "Ziemniaki opiekane w rozmarynie, czosnku i wędzonej papryce",
salad: "Młoda kapusta duszona z koperkiem, wędzoną papryką i odrobiną masła palonego",
yield: "450 g", seasons: ["autumn", "winter", "spring", "summer"],
tech: "Schab rozbić, panierować w jajku i bułce panko, smażyć na maśle klarowanym na złoty kolor (po 3 min z każdej strony).",
ingredients: [
{ name: "Schab wieprzowy", cat: "meat", gross: 165, waste: 8, net: 152, unit: "g", pricePerUnit: 0.026 },
{ name: "Jajko i panierka panko", cat: "dry", gross: 45, waste: 0, net: 45, unit: "g", pricePerUnit: 0.014 },
{ name: "Masło klarowane / olej", cat: "liquids", gross: 25, waste: 0, net: 25, unit: "ml", pricePerUnit: 0.024 },
{ name: "Cytryna, pieprz czarny, sól", cat: "spices", gross: 8, waste: 10, net: 7.2, unit: "g", pricePerUnit: 0.025 }
]
},
{
base: "Pulpety z indyka w aksamitnym sosie koperkowo-cytrynowym z zielonym groszkiem",
side: "Kasza pęczakotto z białym winem, natką pietruszki i parmezanem",
salad: "Surówka z czerwonej kapusty z jabłkiem, żurawiną i dressingiem musztardowo-miodowym",
yield: "430 g", seasons: ["spring", "summer", "autumn", "winter"],
tech: "Delikatne pulpety z indyka dusić w bulionie drobiowym, wykończyć śmietanką, świeżym koperkiem i sokiem z cytryny.",
ingredients: [
{ name: "Mięso mielone z piersi indyka", cat: "meat", gross: 155, waste: 0, net: 155, unit: "g", pricePerUnit: 0.025 },
{ name: "Śmietanka 30% i bulion", cat: "dairy", gross: 75, waste: 0, net: 75, unit: "ml", pricePerUnit: 0.015 },
{ name: "Koperek świeży, cytryna, gałka", cat: "spices", gross: 10, waste: 5, net: 9.5, unit: "g", pricePerUnit: 0.036 }
]
},
{
base: "Curry z kurczakiem w mleczku kokosowym z trawą cytrynową, imbirem i limonką",
side: "Ryż jaśminowy z kurkumą, prażonym kminem i skórką limonki",
salad: "Mizeria w stylu skandynawskim z koperkiem, olejem rzepakowym tłoczonym na zimno i czerwonym pieprzem",
yield: "435 g", seasons: ["spring", "summer", "autumn", "winter"],
tech: "Filet z kurczaka obsmażyć z pastą curry, imbirem i czosnkiem, dusić w mleczku kokosowym z liśćmi limonki i kolendrą.",
ingredients: [
{ name: "Filet z piersi kurczaka", cat: "meat", gross: 160, waste: 4, net: 154, unit: "g", pricePerUnit: 0.025 },
{ name: "Mleczko kokosowe 18%", cat: "liquids", gross: 85, waste: 0, net: 85, unit: "ml", pricePerUnit: 0.015 },
{ name: "Pasta curry, imbir, kolendra, limonka", cat: "spices", gross: 15, waste: 10, net: 13.5, unit: "g", pricePerUnit: 0.042 }
]
},
{
base: "Bitki wołowe duszone w sosie własnym z suszonymi grzybami i tymiankiem",
side: "Kasza gryczana prażona na bulionie grzybowym z tymiankiem",
salad: "Pieczone buraki glazurowane w miodzie, tymianku i occie balsamicznym z prażonym słonecznikiem",
yield: "440 g", seasons: ["autumn", "winter"],
tech: "Plastry wołowiny obsmażyć na mocno rozgrzanym tłuszczu, dusić 90 minut z grzybami leśnymi, cebulką i tymiankiem.",
ingredients: [
{ name: "Wołowina zrazowa / ligawa", cat: "meat", gross: 165, waste: 8, net: 152, unit: "g", pricePerUnit: 0.039 },
{ name: "Grzyby suszone i cebula", cat: "veg", gross: 45, waste: 10, net: 40, unit: "g", pricePerUnit: 0.025 },
{ name: "Wywar wołowy i olej", cat: "liquids", gross: 85, waste: 0, net: 85, unit: "ml", pricePerUnit: 0.006 },
{ name: "Tymianek, liść laurowy, pieprz", cat: "spices", gross: 6, waste: 0, net: 6, unit: "g", pricePerUnit: 0.040 }
]
}
],
vegMain: [
{
base: "Stek z pieczonego kalafiora w przyprawach masala na kremie z białej fasoli z oliwą ziołową",
side: "Kasza bulgur z suszonymi pomidorami, oregano i oliwą cytrynową",
salad: "Marchewka karmelizowana z kminem rzymskim, imbirem, pomarańczą i kolendrą",
yield: "420 g", seasons: ["autumn", "winter", "spring", "summer"],
tech: "Pieczenie: 200°C / 24 min (plastry kalafiora natarte oliwą, kminem, kurkumą i wędzoną papryką). Podać na ciepłym kremie z fasoli.",
ingredients: [
{ name: "Kalafior świeży (steki)", cat: "veg", gross: 210, waste: 24, net: 160, unit: "g", pricePerUnit: 0.008 },
{ name: "Fasola biała gotowana", cat: "dry", gross: 95, waste: 0, net: 95, unit: "g", pricePerUnit: 0.011 },
{ name: "Oliwa extra virgin", cat: "liquids", gross: 16, waste: 0, net: 16, unit: "ml", pricePerUnit: 0.038 },
{ name: "Kmin rzymski, kurkuma, papryka wędzona", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.045 }
]
},
{
base: "Kopytka ziemniaczane zapiekane w sosie z leśnych grzybów, szpinaku i parmezanu",
side: "Świeżo tarty parmezan, prażony słonecznik i oliwa bazyliowa",
salad: "Lekka sałatka z pomidorków cherry, rukoli i prażonych pestek w sosie winegret",
yield: "415 g", seasons: ["autumn", "winter", "spring"],
tech: "Zapiekane w piecu: 190°C / 14 min pod kremowym sosem grzybowo-szpinakowym ze świeżo tartym parmezanem.",
ingredients: [
{ name: "Kopytka ziemniaczane", cat: "dry", gross: 230, waste: 0, net: 230, unit: "g", pricePerUnit: 0.011 },
{ name: "Pieczarki brunatne i szpinak", cat: "veg", gross: 95, waste: 8, net: 87, unit: "g", pricePerUnit: 0.018 },
{ name: "Śmietanka 30% i parmezan", cat: "dairy", gross: 55, waste: 0, net: 55, unit: "g", pricePerUnit: 0.026 },
{ name: "Tymianek, czosnek, gałka muszkatołowa", cat: "spices", gross: 7, waste: 0, net: 7, unit: "g", pricePerUnit: 0.038 }
]
},
{
base: "Curry z ciecierzycy, pieczonej dyni i szpinaku w mleczku kokosowym z kolendrą",
side: "Ryż jaśminowy z kurkumą, prażonym kminem i skórką limonki",
salad: "Świeża sałatka z ogórka, rzodkiewki i koperku z oliwą cytrynową",
yield: "425 g", seasons: ["autumn", "winter", "spring", "summer"],
tech: "Dynię podpiec (190°C / 20 min), dusić w mleczku kokosowym z ciecierzycą, pomidorami, imbirem, kurkumą i świeżym szpinakiem.",
ingredients: [
{ name: "Ciecierzyca gotowana i dynia", cat: "veg", gross: 185, waste: 12, net: 163, unit: "g", pricePerUnit: 0.010 },
{ name: "Mleczko kokosowe", cat: "liquids", gross: 85, waste: 0, net: 85, unit: "ml", pricePerUnit: 0.015 },
{ name: "Szpinak świeży baby", cat: "veg", gross: 40, waste: 5, net: 38, unit: "g", pricePerUnit: 0.024 },
{ name: "Curry, imbir, kmin rzymski, kolendra", cat: "spices", gross: 10, waste: 0, net: 10, unit: "g", pricePerUnit: 0.044 }
]
},
{
base: "Gołąbki wegetariańskie z kaszą gryczaną, borowikami i suszonymi pomidorami w sosie grzybowym",
side: "Puree ziemniaczane z palonym masłem, chrzanem i szczypiorkiem",
salad: "Pieczone buraki glazurowane w miodzie, tymianku i occie balsamicznym z prażonym słonecznikiem",
yield: "445 g", seasons: ["autumn", "winter", "spring"],
tech: "Pieczenie: 180°C / 40 min pod przykryciem w aromatycznym sosie grzybowym z tymiankiem i śmietanką.",
ingredients: [
{ name: "Kapusta biała / włoska", cat: "veg", gross: 135, waste: 18, net: 111, unit: "g", pricePerUnit: 0.005 },
{ name: "Kasza gryczana i grzyby leśne", cat: "dry", gross: 110, waste: 5, net: 105, unit: "g", pricePerUnit: 0.017 },
{ name: "Śmietanka 30% i bulion warzywny", cat: "dairy", gross: 75, waste: 0, net: 75, unit: "ml", pricePerUnit: 0.014 },
{ name: "Tymianek, czosnek, majeranek, pieprz", cat: "spices", gross: 8, waste: 0, net: 8, unit: "g", pricePerUnit: 0.038 }
]
},
{
base: "Lasagne warzywna z pieczoną cukinią, bakłażanem, ricottą i sosem pomidorowo-bazyliowym",
side: "Świeżo tarty parmezan, prażony słonecznik i oliwa bazyliowa",
salad: "Chrupiąca sałata rzymska z pomidorem, czerwoną cebulką i dressingiem ziołowym",
yield: "420 g", seasons: ["spring", "summer", "autumn", "winter"],
tech: "Pieczenie: 185°C / 32 min (warstwy makaronu lasagne przekładane grillowanymi warzywami, sosem pelati, ricottą i mozzarellą).",
ingredients: [
{ name: "Płaty lasagne semolina", cat: "dry", gross: 95, waste: 0, net: 95, unit: "g", pricePerUnit: 0.012 },
{ name: "Cukinia, bakłażan i pomidory pelati", cat: "veg", gross: 175, waste: 12, net: 154, unit: "g", pricePerUnit: 0.009 },
{ name: "Ricotta i mozzarella", cat: "dairy", gross: 75, waste: 0, net: 75, unit: "g", pricePerUnit: 0.026 },
{ name: "Bazylia, oregano, czosnek, oliwa", cat: "spices", gross: 12, waste: 0, net: 12, unit: "g", pricePerUnit: 0.038 }
]
},
{
base: "Risotto z pęczaku z pieczoną dynią, szałwią, kozim serem i prażonymi pestkami dyni",
side: "Prażone płatki migdałów, natka pietruszki i oliwa czosnkowa",
salad: "Lekka sałatka z pomidorków cherry, rukoli i prażonych pestek w sosie winegret",
yield: "410 g", seasons: ["autumn", "winter"],
tech: "Pęczak gotować metodą risotto na gorącym bulionie warzywnym z pieczoną dynią, masłem, świeżą szałwią i kozim serem.",
ingredients: [
{ name: "Kasza jęczmienna pęczak", cat: "dry", gross: 95, waste: 0, net: 95, unit: "g", pricePerUnit: 0.007 },
{ name: "Dynia piżmowa pieczona", cat: "veg", gross: 120, waste: 20, net: 96, unit: "g", pricePerUnit: 0.006 },
{ name: "Ser kozi rolada i masło", cat: "dairy", gross: 45, waste: 0, net: 45, unit: "g", pricePerUnit: 0.036 },
{ name: "Szałwia świeża, tymianek, pestki dyni", cat: "spices", gross: 12, waste: 0, net: 12, unit: "g", pricePerUnit: 0.038 }
]
}
]
};
function buildSlotPool(slotKey) {
const metaScale = (appState.lang === "pl") ? 1 : ((COUNTRY_META[appState.lang]?.scale) || 0.25);
const fromDays = appState.profile.days.map(d => d[slotKey]).filter(Boolean);
const fromExtra = (PROCEDURAL_DISHES[slotKey] || []).map(item => {
const clone = JSON.parse(JSON.stringify(item));
clone.ingredients.forEach(ing => {
ing.pricePerUnit = +(ing.pricePerUnit * metaScale).toFixed(4);
});
return clone;
});
const fromAi = (appState.aiCustomPool[slotKey] || []).map(item => JSON.parse(JSON.stringify(item)));
const mapById = new Map();
[...fromDays, ...fromExtra, ...fromAi].forEach(d => {
const id = getDishIdentifier(d);
if (id && !mapById.has(id)) mapById.set(id, d);
});
return Array.from(mapById.values());
}
function getZeroWasteStockKeywords() {
const raw = getElVal("input-stock", "").toLowerCase();
if (!raw.trim()) return [];
return raw
.split(/[,;\n]+/)
.map(part => part.replace(/\d+([.,]\d+)?\s*(kg|g|l|ml|szt|op)?/gi, "").trim())
.filter(tok => tok.length >= 3);
}
function getMatchedStockBadges(dishObj, stockTokens) {
if (!dishObj || !stockTokens || !stockTokens.length) return "";
const haystack = [
dishObj.title || "",
dishObj.base || "",
dishObj.side || "",
dishObj.salad || "",
...(dishObj.ingredients || []).map(i => i.name || "")
].join(" ").toLowerCase();
const matched = stockTokens.filter(tok => {
const stem = tok.slice(0, Math.max(4, tok.length - 1));
return haystack.includes(tok) || haystack.includes(stem);
});
return matched.slice(0, 2).join(", ");
}
function getRegularMenuBlockedTerms() {
const raw = getElVal("input-regular-menu", "").toLowerCase();
if (!raw.trim()) return [];
return raw.split(/[,;\n]+/).map(s => s.trim()).filter(s => s.length >= 3);
}
function isDishBlockedByRegularMenu(dishObj, regularTerms) {
if (!regularTerms || !regularTerms.length) return false;
const text = ((dishObj.title || dishObj.base || "") + " " + (dishObj.side || "")).toLowerCase();
return regularTerms.some(term => text.includes(term));
}
function getRecentBlockedDishNames() {
const blocked = new Set();
const now = Date.now();
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
(appState.historyLog || []).forEach(entry => {
if (!entry || !entry.savedAt) return;
if (now - entry.savedAt <= thirtyDaysMs) {
(entry.dishes || []).forEach(name => {
if (name) blocked.add(String(name).toLowerCase().trim());
});
}
});
return blocked;
}
function scoreCandidate(dishObj, stockTokens, selectedSeason) {
let score = 0;
if (stockTokens && stockTokens.length) {
const matched = getMatchedStockBadges(dishObj, stockTokens);
if (matched) score += 10;
}
if (dishObj.seasons && dishObj.seasons.includes(selectedSeason)) {
score += 3;
}
const targetFc = parseFloat(getElVal("input-target-fc", "13.50")) || 13.50;
const cost = computeDishCost(dishObj);
if (cost <= targetFc * 0.55) score += 1.5;
return score;
}
// =========================================================================
// 6. GEMINI AI-CHEF INTEGRATION (AUTO-RENEWING RECIPES & EVENT MENU)
// =========================================================================
async function callGeminiJson(promptText, schemaObj) {
const apiKey = "";
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent?key=${apiKey}`;
const payload = {
contents: [{ parts: [{ text: promptText }] }],
generationConfig: {
responseMimeType: "application/json",
responseSchema: schemaObj
}
};
const resp = await fetchWithRetry(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!resp || !resp.ok) return null;
const data = await resp.json();
const rawText = data?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!rawText) return null;
return JSON.parse(rawText);
}
async function autoExpandDishDatabase(showFeedback = true) {
const banner = document.getElementById("ai-loading-banner");
if (showFeedback && banner) banner.classList.remove("hidden");
const season = getElVal("input-season", "autumn");
const stock = getElVal("input-stock", "");
const regular = getElVal("input-regular-menu", "");
const existingNames = [
...buildSlotPool("meatSoup"),
...buildSlotPool("vegSoup"),
...buildSlotPool("meatMain"),
...buildSlotPool("vegMain")
].map(d => d.title || d.base).slice(0, 45).join("; ");
const prompt = `Jesteś szefem kuchni B2B. Wygeneruj 4 nowe, smaczne, opłacalne dania lunchowe (po 1 dla: meatSoup, vegSoup, meatMain, vegMain) w języku "${appState.lang}".
Zasady:
1. Sezon: ${season}. Magazyn Zero-Waste do priorytetowego zużycia: "${stock || "brak"}".
2. NIE powtarzaj dań ze stałej karty: "${regular || "brak"}" ani tych istniejących: "${existingNames}".
3. Nazwy zup czyste (bez pisania na jakim są wywarze), chipsy warzywne TYLKO do zup typu krem.
4. Jeśli coś się piecze lub gotuje sous-vide, podaj temperaturę i czas w polu tech.
5. Podaj komplet składników z gramaturą brutto na 1 porcję, odpadem %, jednostką (g lub ml) i ceną za 1g/1ml w PLN (np. 0.025 dla 25 zł/kg).`;
const schema = {
type: "OBJECT",
properties: {
items: {
type: "ARRAY",
items: {
type: "OBJECT",
properties: {
slot: { type: "STRING" },
titleOrBase: { type: "STRING" },
side: { type: "STRING" },
salad: { type: "STRING" },
yield: { type: "STRING" },
tech: { type: "STRING" },
ingredients: {
type: "ARRAY",
items: {
type: "OBJECT",
properties: {
name: { type: "STRING" },
cat: { type: "STRING" },
gross: { type: "NUMBER" },
waste: { type: "NUMBER" },
unit: { type: "STRING" },
pricePerUnit: { type: "NUMBER" }
},
required: ["name", "cat", "gross", "waste", "unit", "pricePerUnit"]
}
}
},
required: ["slot", "titleOrBase", "yield", "tech", "ingredients"]
}
}
},
required: ["items"]
};
try {
const result = await callGeminiJson(prompt, schema);
if (result && Array.isArray(result.items)) {
const metaScale = (appState.lang === "pl") ? 1 : ((COUNTRY_META[appState.lang]?.scale) || 0.25);
let added = 0;
result.items.forEach(item => {
const slot = ["meatSoup", "vegSoup", "meatMain", "vegMain"].includes(item.slot) ? item.slot : null;
if (!slot || !item.titleOrBase) return;
const isSoup = slot === "meatSoup" || slot === "vegSoup";
const formatted = {
yield: item.yield || (isSoup ? "300 ml" : "430 g"),
seasons: [season],
tech: item.tech || "Obróbka termiczna zgodnie ze standardem kuchni.",
ingredients: (item.ingredients || []).map(ing => {
const gross = Math.max(1, Number(ing.gross) || 50);
const waste = Math.min(80, Math.max(0, Number(ing.waste) || 0));
return {
name: ing.name,
cat: ["meat", "veg", "dairy", "dry", "liquids", "spices"].includes(ing.cat) ? ing.cat : "veg",
gross,
waste,
net: Math.round(gross * (1 - waste / 100) * 10) / 10,
unit: ing.unit === "ml" ? "ml" : "g",
pricePerUnit: +((Number(ing.pricePerUnit) || 0.01) * metaScale).toFixed(4)
};
})
};
if (isSoup) {
formatted.title = item.titleOrBase;
} else {
formatted.base = item.titleOrBase;
formatted.side = item.side || "Ziemniaki gotowane z wody z koperkiem";
formatted.salad = item.salad || "Surówka sezonowa";
verifyAndHarmonizeDish(formatted, false);
}
appState.aiCustomPool[slot].push(formatted);
added++;
});
const totalCount = ["meatSoup", "vegSoup", "meatMain", "vegMain"].reduce((acc, s) => acc + buildSlotPool(s).length, 0);
const badge = document.getElementById("auto-db-badge");
if (badge) badge.textContent = `🔄 Baza auto-odnawiana: ${totalCount * 8}+ kombinacji`;
savePersistentState();
if (showFeedback) {
showToast(added > 0 ? `✨ AI-Chef dodał ${added} nowe autorskie receptury do bazy!` : "✨ Baza receptur jest aktualna i gotowa!");
}
}
} catch (e) {
if (showFeedback) showToast("✨ Użyto rozszerzonej bazy lokalnej Szefa Kuchni.");
} finally {
if (banner) banner.classList.add("hidden");
}
}
// =========================================================================
// 7. CALCULATIONS, TTK, PROCUREMENT & UI RENDERING
// =========================================================================
function computeDishCost(dishObj) {
if (!dishObj || !Array.isArray(dishObj.ingredients)) return 0;
return dishObj.ingredients.reduce((sum, ing) => {
return sum + (Number(ing.gross) || 0) * (Number(ing.pricePerUnit) || 0);
}, 0);
}
function detectAllergensForDish(dishObj) {
const txt = [
dishObj.title || "",
dishObj.base || "",
dishObj.side || "",
dishObj.salad || "",
...(dishObj.ingredients || []).map(i => i.name || "")
].join(" ").toLowerCase();
const found = [];
if (/mąk|makaron|penne|lasagne|pierog|kopytk|pęczak|jęczmien|bulgur|panko|naleśnik|zakwas/.test(txt)) found.push("Gluten");
if (/śmietan|masł|ser|twaróg|parmezan|mozzarella|ricotta|kefir|mlek/.test(txt)) found.push("Laktoza");
if (/jajk|jajecz/.test(txt)) found.push("Jaja");
if (/dorsz|łosoś|ryb|sandacz/.test(txt)) found.push("Ryby");
if (/orzech|migdał/.test(txt)) found.push("Orzechy");
if (/seler|włoszczyzn/.test(txt)) found.push("Seler");
if (/musztard|gorczyc/.test(txt)) found.push("Gorczyca");
if (/sezam/.test(txt)) found.push("Sezam");
return found;
}
function recalculateAll() {
const monThu = Math.max(1, parseInt(getElVal("input-portions-mon-thu", "50")) || 50);
const fri = Math.max(1, parseInt(getElVal("input-portions-fri", "70")) || 70);
const vegShare = (parseInt(getElVal("input-veg-share", "30")) || 30) / 100;
const targetFc = parseFloat(getElVal("input-target-fc", "13.50")) || 13.50;
const guestPrice = parseFloat(getElVal("input-guest-price", "39")) || 39;
let totalMeatSetCost = 0;
let totalVegSetCost = 0;
let weeklyProcurementCost = 0;
let totalPortionsWeek = 0;
appState.profile.days.forEach((day, idx) => {
const dayPortions = idx === 4 ? fri : monThu;
const vegPortions = Math.round(dayPortions * vegShare);
const meatPortions = Math.max(0, dayPortions - vegPortions);
const meatSet = computeDishCost(day.meatSoup) + computeDishCost(day.meatMain);
const vegSet = computeDishCost(day.vegSoup) + computeDishCost(day.vegMain);
totalMeatSetCost += meatSet;
totalVegSetCost += vegSet;
totalPortionsWeek += dayPortions;
weeklyProcurementCost += (meatSet * meatPortions) + (vegSet * vegPortions);
});
const avgMeat = totalMeatSetCost / 5;
const avgVeg = totalVegSetCost / 5;
const fcPercent = guestPrice > 0 ? ((avgMeat / guestPrice) * 100).toFixed(1) : "0.0";
const grossProfitWeek = (totalPortionsWeek * guestPrice) - weeklyProcurementCost;
const avgFcEl = document.getElementById("kpi-avg-fc");
if (avgFcEl) avgFcEl.textContent = formatMoney(avgMeat);
const fcBadge = document.getElementById("kpi-fc-badge");
if (fcBadge) {
const isOk = avgMeat <= targetFc;
fcBadge.textContent = `${fcPercent}% • ${isOk ? "OK" : "HIGH"}`;
fcBadge.className = `text-[11px] font-semibold px-2 py-0.5 rounded-full ${isOk ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-700"}`;
}
const vegFcEl = document.getElementById("kpi-veg-fc");
if (vegFcEl) vegFcEl.textContent = formatMoney(avgVeg);
const totPorEl = document.getElementById("kpi-total-portions");
if (totPorEl) totPorEl.textContent = String(totalPortionsWeek);
const splitEl = document.getElementById("kpi-portions-split");
if (splitEl) splitEl.textContent = `${Math.round((1 - vegShare) * 100)}% 🍖 / ${Math.round(vegShare * 100)}% 🌱`;
const budEl = document.getElementById("kpi-weekly-budget");
if (budEl) budEl.textContent = formatMoney(weeklyProcurementCost);
const profEl = document.getElementById("kpi-profit-badge");
if (profEl) profEl.textContent = `+${formatMoney(grossProfitWeek)}`;
const blockedCount = getRecentBlockedDishNames().size;
const antiRep = document.getElementById("anti-repeat-badge");
if (antiRep) antiRep.textContent = t("anti_repeat_tpl").replace("{n}", blockedCount);
renderWeeklyMenuGrid();
renderTtkView();
renderProcurementView();
renderPrintAndSmmView();
renderHistoryView();
savePersistentState();
}
function renderWeeklyMenuGrid() {
const grid = document.getElementById("weekly-menu-grid");
if (!grid) return;
const dict = I18N[appState.lang] || I18N.pl;
const mondayIso = getElVal("input-week-start", appState.weekStartDate);
const monThuPortions = parseInt(getElVal("input-portions-mon-thu", "50")) || 50;
const friPortions = parseInt(getElVal("input-portions-fri", "70")) || 70;
const stockTokens = getZeroWasteStockKeywords();
grid.innerHTML = appState.profile.days.map((day, dIdx) => {
const dateStr = addDaysToIso(mondayIso, dIdx);
const portions = dIdx === 4 ? friPortions : monThuPortions;
const meatSetCost = computeDishCost(day.meatSoup) + computeDishCost(day.meatMain);
const vegSetCost = computeDishCost(day.vegSoup) + computeDishCost(day.vegMain);
const zwMeatSoup = getMatchedStockBadges(day.meatSoup, stockTokens);
const zwVegSoup = getMatchedStockBadges(day.vegSoup, stockTokens);
const zwMeatMain = getMatchedStockBadges(day.meatMain, stockTokens);
const zwVegMain = getMatchedStockBadges(day.vegMain, stockTokens);
return `
<div class="bg-white rounded-2xl border border-slate-200/90 shadow-sm overflow-hidden">
<div class="bg-slate-900 text-white px-4 sm:px-6 py-3 flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2.5">
<span class="font-bold text-sm sm:text-base">${escapeHtml(dict.days[dIdx])}</span>
<span class="text-xs text-slate-400 bg-slate-800 px-2.5 py-0.5 rounded-full">${escapeHtml(dateStr)}</span>
<span class="text-xs font-semibold text-emerald-300 bg-emerald-950/80 border border-emerald-700/60 px-2.5 py-0.5 rounded-full">${portions} ${escapeHtml(dict.portions_unit)}</span>
</div>
<div class="flex items-center gap-3 text-xs">
<span class="text-slate-300">🍖 FC: <strong class="text-white">${formatMoney(meatSetCost)}</strong></span>
<span class="text-slate-300">🌱 FC: <strong class="text-emerald-300">${formatMoney(vegSetCost)}</strong></span>
<button onclick="openTtkForDay(${dIdx})" class="px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-white font-semibold border border-slate-700 cursor-pointer">
📋 ${escapeHtml(t("btn_ttk"))}
</button>
</div>
</div>
<div class="p-4 sm:p-6 grid grid-cols-1 lg:grid-cols-2 gap-5">
<!-- 1. SOUPS -->
<div class="space-y-3">
<div class="text-xs font-extrabold uppercase tracking-wider text-slate-400">${escapeHtml(t("sec_soups"))}</div>
<div class="rounded-xl border border-slate-200 bg-slate-50/70 p-3.5 space-y-2">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-[11px] font-bold px-2 py-0.5 rounded-md bg-rose-100 text-rose-800">${escapeHtml(t("badge_meat_soup"))}</span>
${zwMeatSoup ? `<span class="text-[11px] font-bold px-2 py-0.5 rounded-md bg-amber-100 text-amber-900 border border-amber-300">♻️ ${escapeHtml(zwMeatSoup)}</span>` : ""}
</div>
<div class="flex items-center gap-1.5">
<span class="text-xs font-bold text-slate-700">${formatMoney(computeDishCost(day.meatSoup))}</span>
<button onclick="swapDish(${dIdx}, 'meatSoup')" class="px-2.5 py-1 rounded-lg bg-white hover:bg-slate-100 border border-slate-300 text-xs font-semibold text-slate-700 cursor-pointer">
🔄 ${escapeHtml(t("btn_swap"))}
</button>
</div>
</div>
<div onclick="openDishEditModal(${dIdx}, 'meatSoup')" class="font-semibold text-sm text-slate-900 hover:text-emerald-700 cursor-pointer transition-colors" title="Kliknij, aby edytować nazwę lub recepturę">
${escapeHtml(day.meatSoup.title)} <span class="text-xs text-slate-400">✏️</span>
</div>
</div>
<div class="rounded-xl border border-emerald-200/80 bg-emerald-50/40 p-3.5 space-y-2">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-[11px] font-bold px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-800">${escapeHtml(t("badge_veg_soup"))}</span>
${zwVegSoup ? `<span class="text-[11px] font-bold px-2 py-0.5 rounded-md bg-amber-100 text-amber-900 border border-amber-300">♻️ ${escapeHtml(zwVegSoup)}</span>` : ""}
</div>
<div class="flex items-center gap-1.5">
<span class="text-xs font-bold text-emerald-800">${formatMoney(computeDishCost(day.vegSoup))}</span>
<button onclick="swapDish(${dIdx}, 'vegSoup')" class="px-2.5 py-1 rounded-lg bg-white hover:bg-emerald-50 border border-emerald-300 text-xs font-semibold text-emerald-800 cursor-pointer">
🔄 ${escapeHtml(t("btn_swap"))}
</button>
</div>
</div>
<div onclick="openDishEditModal(${dIdx}, 'vegSoup')" class="font-semibold text-sm text-slate-900 hover:text-emerald-700 cursor-pointer transition-colors" title="Kliknij, aby edytować nazwę lub recepturę">
${escapeHtml(day.vegSoup.title)} <span class="text-xs text-slate-400">✏️</span>
</div>
</div>
</div>
<!-- 2. MAIN COURSES -->
<div class="space-y-3">
<div class="text-xs font-extrabold uppercase tracking-wider text-slate-400">${escapeHtml(t("sec_mains"))}</div>
<div class="rounded-xl border border-slate-200 bg-slate-50/70 p-3.5 space-y-2">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-[11px] font-bold px-2 py-0.5 rounded-md bg-amber-100 text-amber-900">${escapeHtml(t("badge_meat_main"))}</span>
${zwMeatMain ? `<span class="text-[11px] font-bold px-2 py-0.5 rounded-md bg-amber-200 text-amber-950 border border-amber-400">♻️ ${escapeHtml(zwMeatMain)}</span>` : ""}
</div>
<div class="flex items-center gap-1.5">
<span class="text-xs font-bold text-slate-700">${formatMoney(computeDishCost(day.meatMain))}</span>
<button onclick="swapDish(${dIdx}, 'meatMain')" class="px-2.5 py-1 rounded-lg bg-white hover:bg-slate-100 border border-slate-300 text-xs font-semibold text-slate-700 cursor-pointer">
🔄 ${escapeHtml(t("btn_swap"))}
</button>
</div>
</div>
<div onclick="openDishEditModal(${dIdx}, 'meatMain')" class="font-semibold text-sm text-slate-900 hover:text-emerald-700 cursor-pointer transition-colors" title="Kliknij, aby edytować danie i dodatki">
${escapeHtml(day.meatMain.base)} <span class="text-xs text-slate-400">✏️</span>
</div>
<div class="flex flex-wrap items-center gap-1.5 pt-1 text-xs">
<button onclick="cycleStarchSide(${dIdx}, 'meatMain')" class="px-2 py-1 rounded-lg bg-white hover:bg-amber-50 border border-slate-200 text-slate-700 font-medium cursor-pointer" title="Zmień dodatek">
🥔 ${escapeHtml(day.meatMain.side)}
</button>
<button onclick="cycleSaladSide(${dIdx}, 'meatMain')" class="px-2 py-1 rounded-lg bg-white hover:bg-emerald-50 border border-slate-200 text-slate-700 font-medium cursor-pointer" title="Zmień surówkę / warzywa">
🥗 ${escapeHtml(day.meatMain.salad)}
</button>
</div>
</div>
<div class="rounded-xl border border-emerald-200/80 bg-emerald-50/40 p-3.5 space-y-2">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-[11px] font-bold px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-800">${escapeHtml(t("badge_veg_main"))}</span>
${zwVegMain ? `<span class="text-[11px] font-bold px-2 py-0.5 rounded-md bg-amber-100 text-amber-900 border border-amber-300">♻️ ${escapeHtml(zwVegMain)}</span>` : ""}
</div>
<div class="flex items-center gap-1.5">
<span class="text-xs font-bold text-emerald-800">${formatMoney(computeDishCost(day.vegMain))}</span>
<button onclick="swapDish(${dIdx}, 'vegMain')" class="px-2.5 py-1 rounded-lg bg-white hover:bg-emerald-50 border border-emerald-300 text-xs font-semibold text-emerald-800 cursor-pointer">
🔄 ${escapeHtml(t("btn_swap"))}
</button>
</div>
</div>
<div onclick="openDishEditModal(${dIdx}, 'vegMain')" class="font-semibold text-sm text-slate-900 hover:text-emerald-700 cursor-pointer transition-colors" title="Kliknij, aby edytować danie i dodatki">
${escapeHtml(day.vegMain.base)} <span class="text-xs text-slate-400">✏️</span>
</div>
<div class="flex flex-wrap items-center gap-1.5 pt-1 text-xs">
<button onclick="cycleStarchSide(${dIdx}, 'vegMain')" class="px-2 py-1 rounded-lg bg-white hover:bg-amber-50 border border-emerald-200 text-slate-700 font-medium cursor-pointer" title="Zmień dodatek">
🥔 ${escapeHtml(day.vegMain.side)}
</button>
<button onclick="cycleSaladSide(${dIdx}, 'vegMain')" class="px-2 py-1 rounded-lg bg-white hover:bg-emerald-50 border border-emerald-200 text-slate-700 font-medium cursor-pointer" title="Zmień surówkę / warzywa">
🥗 ${escapeHtml(day.vegMain.salad)}
</button>
</div>
</div>
</div>
</div>
</div>
`;
}).join("");
}
function getDishIdentifier(dishObj) {
if (!dishObj) return "";
return String(dishObj.title || dishObj.base || "").toLowerCase().trim();
}
function enforceWeeklyUniqueness(priorityDayIdx = -1) {
let replacedCount = 0;
const order = [];
if (priorityDayIdx >= 0 && priorityDayIdx < 5) order.push(priorityDayIdx);
for (let i = 0; i < 5; i++) {
if (!order.includes(i)) order.push(i);
}
const seenSoups = new Set();
const seenMeatMains = new Set();
const seenVegMains = new Set();
const blockedHistory = getRecentBlockedDishNames();
const regularTerms = getRegularMenuBlockedTerms();
const stockTokens = getZeroWasteStockKeywords();
const selectedSeason = getElVal("input-season", "autumn");
for (const dIdx of order) {
const day = appState.profile.days[dIdx];
if (!day) continue;
["meatSoup", "vegSoup"].forEach(slotKey => {
const id = getDishIdentifier(day[slotKey]);
if (seenSoups.has(id)) {
const pool = buildSlotPool(slotKey);
let candidates = pool.filter(c => {
const cid = getDishIdentifier(c);
return !seenSoups.has(cid) && !blockedHistory.has(cid) && !isDishBlockedByRegularMenu(c, regularTerms);
});
if (!candidates.length) candidates = pool.filter(c => !seenSoups.has(getDishIdentifier(c)));
if (candidates.length) {
candidates.sort((a, b) => scoreCandidate(b, stockTokens, selectedSeason) - scoreCandidate(a, stockTokens, selectedSeason));
day[slotKey] = JSON.parse(JSON.stringify(candidates[0]));
replacedCount++;
}
}
seenSoups.add(getDishIdentifier(day[slotKey]));
});
[
{ slotKey: "meatMain", seenSet: seenMeatMains },
{ slotKey: "vegMain", seenSet: seenVegMains }
].forEach(({ slotKey, seenSet }) => {
const id = getDishIdentifier(day[slotKey]);
if (seenSet.has(id)) {
const pool = buildSlotPool(slotKey);
let candidates = pool.filter(c => {
const cid = getDishIdentifier(c);
return !seenSet.has(cid) && !blockedHistory.has(cid) && !isDishBlockedByRegularMenu(c, regularTerms);
});
if (!candidates.length) candidates = pool.filter(c => !seenSet.has(getDishIdentifier(c)));
if (candidates.length) {
candidates.sort((a, b) => scoreCandidate(b, stockTokens, selectedSeason) - scoreCandidate(a, stockTokens, selectedSeason));
const picked = JSON.parse(JSON.stringify(candidates[0]));
verifyAndHarmonizeDish(picked, true);
day[slotKey] = picked;
replacedCount++;
}
}
seenSet.add(getDishIdentifier(day[slotKey]));
});
}
return replacedCount;
}
async function fetchWithRetry(url, options, maxRetries = 4) {
let delay = 800;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (response.ok) return response;
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) return response;
} else {
return response;
}
} catch (err) {
if (attempt === maxRetries) throw err;
}
await new Promise(res => setTimeout(res, delay));
delay *= 2;
}
return null;
}
let _editingDishTarget = null;
function openDishEditModal(dayIdx, slotKey) {
_editingDishTarget = { dayIdx, slotKey };
const dish = appState.profile.days[dayIdx][slotKey];
const isSoup = slotKey === "meatSoup" || slotKey === "vegSoup";
setElVal("dish-edit-title", isSoup ? (dish.title || "") : (dish.base || ""));
setElVal("dish-edit-tech", dish.tech || "");
const sidesWrap = document.getElementById("dish-edit-sides-wrap");
if (sidesWrap) {
if (isSoup) {
sidesWrap.classList.add("hidden");
} else {
sidesWrap.classList.remove("hidden");
setElVal("dish-edit-side", dish.side || "");
setElVal("dish-edit-salad", dish.salad || "");
const starchList = document.getElementById("starch-suggestions");
if (starchList) {
starchList.innerHTML = [...STARCH_SIDE_POOL.pl, ...GARNISH_ONLY_TOPPINGS.pl]
.map(s => `<option value="${escapeHtml(s.name)}"></option>`).join("");
}
const saladList = document.getElementById("salad-suggestions");
if (saladList) {
saladList.innerHTML = [...SALAD_SIDE_POOL.pl, ...LIGHT_SALAD_ONLY.pl]
.map(s => `<option value="${escapeHtml(s)}"></option>`).join("");
}
}
}
const modal = document.getElementById("dish-edit-modal");
if (modal) modal.classList.remove("hidden");
}
function closeDishEditModal() {
const modal = document.getElementById("dish-edit-modal");
if (modal) modal.classList.add("hidden");
}
function openTtkFromDishModal() {
if (!_editingDishTarget) return;
const dayIdx = _editingDishTarget.dayIdx;
closeDishEditModal();
openTtkForDay(dayIdx);
}
function confirmDishEdit() {
if (!_editingDishTarget) return;
const { dayIdx, slotKey } = _editingDishTarget;
const dish = appState.profile.days[dayIdx][slotKey];
const isSoup = slotKey === "meatSoup" || slotKey === "vegSoup";
const newTitle = getElVal("dish-edit-title", "").trim();
const newTech = getElVal("dish-edit-tech", "").trim();
if (newTitle) {
if (isSoup) dish.title = newTitle;
else dish.base = newTitle;
}
if (newTech) dish.tech = newTech;
if (!isSoup) {
const newSide = getElVal("dish-edit-side", "").trim();
const newSalad = getElVal("dish-edit-salad", "").trim();
dish.side = newSide;
dish.salad = newSalad;
const inferredStarch = inferStarchIngredientFromText(newSide);
syncMainSideIngredients(dish, inferredStarch, newSalad);
}
closeDishEditModal();
const replacedCount = enforceWeeklyUniqueness(dayIdx);
recalculateAll();
if (replacedCount > 0) {
showToast(`✅ Zaktualizowano danie i automatycznie wymieniono ${replacedCount} powtarzającą się pozycję w dalszych dniach tygodnia!`);
} else {
showToast("✅ Zaktualizowano danie, dodatki oraz kartę TTK!");
}
}
function cycleStarchSide(dayIdx, slotKey) {
const dish = appState.profile.days[dayIdx][slotKey];
const compat = getCompatibleSidesForDish(dish.base);
const list = compat.starches;
const curIdx = list.findIndex(s => s.name === dish.side);
const next = list[(curIdx + 1) % list.length];
dish.side = next.name;
syncMainSideIngredients(dish, next, dish.salad);
recalculateAll();
showToast(`🥔 ${next.name}`);
}
function cycleSaladSide(dayIdx, slotKey) {
const dish = appState.profile.days[dayIdx][slotKey];
const compat = getCompatibleSidesForDish(dish.base);
const list = compat.salads;
const curIdx = list.findIndex(s => s === dish.salad);
const next = list[(curIdx + 1) % list.length];
dish.salad = next;
const inferredStarch = inferStarchIngredientFromText(dish.side);
syncMainSideIngredients(dish, inferredStarch, next);
recalculateAll();
showToast(`🥗 ${next}`);
}
let _editingPriceTarget = null;
function openIngredientEditor(dayIdx, slotKey, ingIdx) {
_editingPriceTarget = { dayIdx, slotKey, ingIdx };
const dish = appState.profile.days[dayIdx][slotKey];
const isNew = ingIdx === -1;
const ing = isNew
? { name: "Nowy składnik", gross: 30, waste: 0, pricePerUnit: 0.02 }
: dish.ingredients[ingIdx];
setElVal("ing-edit-name", ing.name);
setElVal("ing-edit-gross", ing.gross);
setElVal("ing-edit-waste", ing.waste || 0);
setElVal("price-edit-input", +(ing.pricePerUnit * 1000).toFixed(2));
const delBtn = document.getElementById("btn-delete-ing");
if (delBtn) delBtn.style.display = isNew ? "none" : "inline-block";
const modal = document.getElementById("price-edit-modal");
if (modal) modal.classList.remove("hidden");
}
function closePriceEditModal() {
const modal = document.getElementById("price-edit-modal");
if (modal) modal.classList.add("hidden");
}
function confirmIngredientPriceEdit() {
if (!_editingPriceTarget) return;
const { dayIdx, slotKey, ingIdx } = _editingPriceTarget;
const dish = appState.profile.days[dayIdx][slotKey];
const name = getElVal("ing-edit-name", "Składnik").trim() || "Składnik";
const gross = Math.max(1, parseFloat(getElVal("ing-edit-gross", "50")) || 50);
const waste = Math.min(80, Math.max(0, parseFloat(getElVal("ing-edit-waste", "0")) || 0));
const pricePerKg = Math.max(0, parseFloat(getElVal("price-edit-input", "10")) || 0);
const net = Math.round(gross * (1 - waste / 100) * 10) / 10;
if (ingIdx === -1) {
dish.ingredients.push({
name,
cat: "veg",
gross,
waste,
net,
unit: "g",
pricePerUnit: +(pricePerKg / 1000).toFixed(5)
});
} else {
const target = dish.ingredients[ingIdx];
target.name = name;
target.gross = gross;
target.waste = waste;
target.net = net;
target.pricePerUnit = +(pricePerKg / 1000).toFixed(5);
}
closePriceEditModal();
recalculateAll();
showToast("✅ Zaktualizowano składnik i przeliczono recepturę!");
}
function deleteIngredientFromTtk() {
if (!_editingPriceTarget || _editingPriceTarget.ingIdx < 0) return;
const { dayIdx, slotKey, ingIdx } = _editingPriceTarget;
appState.profile.days[dayIdx][slotKey].ingredients.splice(ingIdx, 1);
closePriceEditModal();
recalculateAll();
showToast("🗑️ Usunięto składnik z karty TTK");
}
// =========================================================================
// 9. TTK, PROCUREMENT, PRINT/SMM, EVENTS & HISTORY VIEWS
// =========================================================================
function setTtkMode(mode) {
appState.ttkMode = mode;
const btnS = document.getElementById("btn-ttk-single");
const btnB = document.getElementById("btn-ttk-batch");
if (btnS && btnB) {
btnS.className = mode === "single" ? "px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-white text-slate-900 shadow-sm cursor-pointer" : "px-3.5 py-1.5 rounded-lg text-xs font-semibold text-slate-600 cursor-pointer";
btnB.className = mode === "batch" ? "px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-white text-slate-900 shadow-sm cursor-pointer" : "px-3.5 py-1.5 rounded-lg text-xs font-semibold text-slate-600 cursor-pointer";
}
renderTtkView();
}
function openTtkForDay(dayIdx) {
appState.ttkSelectedDay = dayIdx;
switchTab("ttk");
}
function renderTtkView() {
const pills = document.getElementById("ttk-day-pills");
const container = document.getElementById("ttk-cards-container");
if (!pills || !container) return;
const dict = I18N[appState.lang] || I18N.pl;
pills.innerHTML = dict.days.map((d, i) => `
<button onclick="appState.ttkSelectedDay=${i}; renderTtkView()" class="px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${appState.ttkSelectedDay === i ? "bg-slate-900 text-white shadow-sm" : "bg-slate-100 text-slate-700 hover:bg-slate-200"}">
${escapeHtml(d)}
</button>
`).join("");
const dayIdx = appState.ttkSelectedDay;
const day = appState.profile.days[dayIdx];
const monThu = parseInt(getElVal("input-portions-mon-thu", "50")) || 50;
const fri = parseInt(getElVal("input-portions-fri", "70")) || 70;
const vegShare = (parseInt(getElVal("input-veg-share", "30")) || 30) / 100;
const dayTotal = dayIdx === 4 ? fri : monThu;
const vegPortions = Math.max(1, Math.round(dayTotal * vegShare));
const meatPortions = Math.max(1, dayTotal - vegPortions);
const slots = [
{ key: "meatSoup", label: t("badge_meat_soup"), portions: meatPortions },
{ key: "vegSoup", label: t("badge_veg_soup"), portions: vegPortions },
{ key: "meatMain", label: t("badge_meat_main"), portions: meatPortions },
{ key: "vegMain", label: t("badge_veg_main"), portions: vegPortions }
];
container.innerHTML = slots.map(s => {
const dish = day[s.key];
const unitCost = computeDishCost(dish);
const batchCost = unitCost * s.portions;
const mult = appState.ttkMode === "batch" ? s.portions : 1;
const title = dish.title || `${dish.base} + ${dish.side}`;
return `
<div class="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 flex flex-col justify-between space-y-4">
<div>
<div class="flex items-center justify-between gap-2 mb-2">
<span class="text-[11px] font-bold px-2.5 py-0.5 rounded-md bg-slate-100 text-slate-800">${escapeHtml(s.label)}</span>
<span class="text-xs font-semibold text-slate-500">${escapeHtml(t("lbl_yield"))}: <strong class="text-slate-800">${escapeHtml(dish.yield || "350 g")}</strong></span>
</div>
<h3 class="text-sm sm:text-base font-bold text-slate-900">${escapeHtml(title)}</h3>
${dish.salad ? `<div class="text-xs text-emerald-700 font-medium mt-0.5">🥗 ${escapeHtml(dish.salad)}</div>` : ""}
<div class="mt-3 overflow-x-auto">
<table class="w-full text-xs text-left">
<thead class="text-[11px] uppercase text-slate-400 border-b border-slate-100">
<tr>
<th class="py-2">${escapeHtml(t("th_ing"))}</th>
<th class="py-2 text-right">${escapeHtml(t("th_gross"))}</th>
<th class="py-2 text-right">${escapeHtml(t("th_waste"))}</th>
<th class="py-2 text-right">${escapeHtml(t("th_net"))}</th>
<th class="py-2 text-right">${escapeHtml(t("th_cost"))}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
${dish.ingredients.map((ing, iIdx) => {
const gVal = ing.gross * mult;
const nVal = (ing.net || ing.gross) * mult;
const cVal = ing.gross * ing.pricePerUnit * mult;
const fmtQty = appState.ttkMode === "batch"
? `${(gVal / 1000).toFixed(2)} ${ing.unit === "ml" ? "l" : "kg"}`
: `${Math.round(gVal)} ${ing.unit}`;
const fmtNet = appState.ttkMode === "batch"
? `${(nVal / 1000).toFixed(2)} ${ing.unit === "ml" ? "l" : "kg"}`
: `${Math.round(nVal)} ${ing.unit}`;
return `
<tr onclick="openIngredientEditor(${dayIdx}, '${s.key}', ${iIdx})" class="hover:bg-emerald-50/60 cursor-pointer transition-colors" title="Kliknij, aby zmienić gramaturę lub cenę">
<td class="py-2 font-medium text-slate-800">${escapeHtml(trIng(ing.name))} ✏️</td>
<td class="py-2 text-right font-semibold text-slate-700">${fmtQty}</td>
<td class="py-2 text-right text-slate-400">${ing.waste || 0}%</td>
<td class="py-2 text-right text-slate-600">${fmtNet}</td>
<td class="py-2 text-right font-bold text-slate-900">${formatMoney(cVal)}</td>
</tr>
`;
}).join("")}
</tbody>
</table>
</div>
<button onclick="openIngredientEditor(${dayIdx}, '${s.key}', -1)" class="mt-2.5 text-xs font-semibold text-emerald-700 hover:text-emerald-800 cursor-pointer">+ Dodaj składnik do TTK</button>
</div>
<div class="pt-3 border-t border-slate-100 space-y-2.5">
<div class="bg-slate-50 rounded-xl p-3 text-xs text-slate-700 leading-relaxed">
<strong class="text-slate-900">${escapeHtml(t("lbl_tech"))}:</strong> ${escapeHtml(dish.tech)}
</div>
<div class="flex items-center justify-between text-xs font-bold">
<span class="text-slate-500">${escapeHtml(t("lbl_unit_cost"))}: <strong class="text-slate-900">${formatMoney(unitCost)}</strong></span>
<span class="text-emerald-700">${escapeHtml(t("lbl_batch_cost"))} (${s.portions} por.): <strong>${formatMoney(batchCost)}</strong></span>
</div>
</div>
</div>
`;
}).join("");
}
function setProcurementSplit(split) {
appState.procurementSplit = split;
["all", "wave1", "wave2"].forEach(k => {
const btn = document.getElementById(`btn-proc-${k}`);
if (btn) {
btn.className = k === split
? "px-3 py-1.5 rounded-lg text-xs font-semibold bg-white text-slate-900 shadow-sm cursor-pointer"
: "px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-600 cursor-pointer";
}
});
renderProcurementView();
}
function buildProcurementMap() {
const monThu = parseInt(getElVal("input-portions-mon-thu", "50")) || 50;
const fri = parseInt(getElVal("input-portions-fri", "70")) || 70;
const vegShare = (parseInt(getElVal("input-veg-share", "30")) || 30) / 100;
const dayIndices = appState.procurementSplit === "wave1" ? [0, 1] : (appState.procurementSplit === "wave2" ? [2, 3, 4] : [0, 1, 2, 3, 4]);
const aggregated = {};
dayIndices.forEach(dIdx => {
const day = appState.profile.days[dIdx];
const total = dIdx === 4 ? fri : monThu;
const vegP = Math.max(1, Math.round(total * vegShare));
const meatP = Math.max(1, total - vegP);
const slotPortions = { meatSoup: meatP, vegSoup: vegP, meatMain: meatP, vegMain: vegP };
Object.entries(slotPortions).forEach(([slot, count]) => {
(day[slot].ingredients || []).forEach(ing => {
const cat = ing.cat || "veg";
if (!aggregated[cat]) aggregated[cat] = {};
const key = trIng(ing.name);
if (!aggregated[cat][key]) {
aggregated[cat][key] = { name: key, totalUnits: 0, unit: ing.unit === "ml" ? "l" : "kg", totalCost: 0 };
}
aggregated[cat][key].totalUnits += (ing.gross * count) / 1000;
aggregated[cat][key].totalCost += ing.gross * ing.pricePerUnit * count;
});
});
});
return aggregated;
}
function renderProcurementView() {
const grid = document.getElementById("procurement-categories-grid");
if (!grid) return;
const agg = buildProcurementMap();
const catNames = t("cats");
const order = ["meat", "veg", "dairy", "dry", "liquids", "spices"];
grid.innerHTML = order.map(cat => {
const items = Object.values(agg[cat] || {});
if (!items.length) return "";
const catSum = items.reduce((s, it) => s + it.totalCost, 0);
return `
<div class="bg-white rounded-2xl border border-slate-200 p-5 shadow-sm flex flex-col justify-between">
<div>
<div class="flex items-center justify-between border-b border-slate-100 pb-3 mb-3">
<h3 class="text-sm font-bold text-slate-900">${escapeHtml(catNames[cat] || cat)}</h3>
<span class="text-xs font-bold text-emerald-700">${formatMoney(catSum)}</span>
</div>
<ul class="space-y-2 text-xs">
${items.map(it => `
<li class="flex items-center justify-between gap-2 text-slate-700">
<span>${escapeHtml(it.name)}</span>
<span class="font-bold text-slate-900 shrink-0">${it.totalUnits.toFixed(2)} ${it.unit}</span>
</li>
`).join("")}
</ul>
</div>
</div>
`;
}).join("");
}
function copyProcurementList() {
const agg = buildProcurementMap();
const catNames = t("cats");
const lines = [`📦 ZAMÓWIENIE SUROWCÓW — ${appState.venueName}`];
Object.entries(agg).forEach(([cat, map]) => {
lines.push(`\n[${catNames[cat] || cat}]`);
Object.values(map).forEach(it => {
lines.push(`- ${it.name}: ${it.totalUnits.toFixed(2)} ${it.unit}`);
});
});
const ta = document.createElement("textarea");
ta.value = lines.join("\n");
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
showToast("📋 Skopiowano listę zakupów do schowka!");
}
function setPrintScope(scope) {
appState.printScope = scope;
const btnW = document.getElementById("btn-print-week");
const btnD = document.getElementById("btn-print-day");
const selWrap = document.getElementById("print-day-selector-wrap");
if (btnW && btnD) {
btnW.className = scope === "week" ? "px-3 py-1.5 rounded-lg text-xs font-semibold bg-white text-slate-900 shadow-sm cursor-pointer" : "px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-600 cursor-pointer";
btnD.className = scope === "day" ? "px-3 py-1.5 rounded-lg text-xs font-semibold bg-white text-slate-900 shadow-sm cursor-pointer" : "px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-600 cursor-pointer";
}
if (selWrap) {
if (scope === "day") selWrap.classList.remove("hidden");
else selWrap.classList.add("hidden");
}
renderPrintAndSmmView();
}
function setPrintDayIndex(idx) {
appState.printDayIndex = idx;
renderPrintAndSmmView();
}
function renderPrintAndSmmView() {
const listEl = document.getElementById("printable-days-list");
const smmEl = document.getElementById("smm-post-textarea");
const venueEl = document.getElementById("print-venue-title");
const priceEl = document.getElementById("print-combo-price");
const headEl = document.getElementById("print-main-heading");
const subEl = document.getElementById("print-subtitle-range");
if (!listEl) return;
const dict = I18N[appState.lang] || I18N.pl;
const mondayIso = getElVal("input-week-start", appState.weekStartDate);
const guestPrice = parseFloat(getElVal("input-guest-price", "39")) || 39;
if (venueEl) venueEl.textContent = (appState.venueName || "LUNCH PILOT BISTRO").toUpperCase();
if (priceEl) priceEl.textContent = formatMoney(guestPrice);
const indices = appState.printScope === "day" ? [appState.printDayIndex] : [0, 1, 2, 3, 4];
if (headEl) headEl.textContent = appState.printScope === "day" ? t("print_day_header") : t("print_menu_header");
if (subEl) {
subEl.textContent = appState.printScope === "day"
? `${dict.days[appState.printDayIndex]} (${addDaysToIso(mondayIso, appState.printDayIndex)})`
: `${addDaysToIso(mondayIso, 0)} — ${addDaysToIso(mondayIso, 4)}`;
}
listEl.innerHTML = indices.map(idx => {
const day = appState.profile.days[idx];
const dateStr = addDaysToIso(mondayIso, idx);
const allergens = Array.from(new Set([
...detectAllergensForDish(day.meatSoup),
...detectAllergensForDish(day.vegSoup),
...detectAllergensForDish(day.meatMain),
...detectAllergensForDish(day.vegMain)
])).join(", ");
return `
<div class="border-b border-slate-200 pb-4 last:border-none">
<div class="flex items-baseline justify-between mb-1.5">
<span class="font-serif text-base sm:text-lg font-bold text-slate-900">${escapeHtml(dict.days[idx])}</span>
<span class="text-xs text-slate-500">${escapeHtml(dateStr)}</span>
</div>
<div class="space-y-1 text-xs sm:text-sm text-slate-800">
<div><strong>🍲 Zupa:</strong> ${escapeHtml(day.meatSoup.title)} <span class="text-slate-400">/</span> 🌱 ${escapeHtml(day.vegSoup.title)}</div>
<div><strong>🍖 Danie dnia:</strong> ${escapeHtml(day.meatMain.base)} • <em>${escapeHtml(day.meatMain.side)}</em> • ${escapeHtml(day.meatMain.salad)}</div>
<div><strong>🌱 Wersja Wege:</strong> ${escapeHtml(day.vegMain.base)} • <em>${escapeHtml(day.vegMain.side)}</em> • ${escapeHtml(day.vegMain.salad)}</div>
${allergens ? `<div class="text-[11px] text-slate-400 pt-0.5">Alergeny: ${escapeHtml(allergens)}</div>` : ""}
</div>
</div>
`;
}).join("");
if (smmEl) {
const smmLines = [
`🍽️ LUNCH MENU — ${appState.venueName} (${addDaysToIso(mondayIso, 0)} – ${addDaysToIso(mondayIso, 4)})`,
`⏰ Zapraszamy codziennie 12:00 – 16:00 | Zestaw dnia: ${formatMoney(guestPrice)}\n`
];
appState.profile.days.forEach((day, idx) => {
smmLines.push(`📅 ${dict.days[idx].toUpperCase()} (${addDaysToIso(mondayIso, idx)})`);
smmLines.push(`🍲 ${day.meatSoup.title}`);
smmLines.push(`🌱 ${day.vegSoup.title}`);
smmLines.push(`🍖 ${day.meatMain.base} (${day.meatMain.side})`);
smmLines.push(`🥗 ${day.vegMain.base} (${day.vegMain.side})\n`);
});
smmLines.push(`📞 Rezerwacje stolików i zamówienia na wynos w wiadomości prywatnej!`);
smmEl.value = smmLines.join("\n");
}
}
function copySmmPost() {
const ta = document.getElementById("smm-post-textarea");
if (!ta) return;
ta.select();
document.execCommand("copy");
showToast("📋 Skopiowano gotowy post na Instagram / Telegram!");
}
// =========================================================================
// 10. EVENT & CATERING ORGANIZER MODULE
// =========================================================================
function renderEventView() {
const guests = Math.max(1, parseInt(getElVal("ev-guests", "45")) || 45);
const pricePerGuest = Math.max(0, parseFloat(getElVal("ev-price-per-guest", "145")) || 145);
const evName = getElVal("ev-name", "Bankiet Firmowy");
const evDate = getElVal("ev-date", appState.weekStartDate);
const foodCostPerGuest = appState.eventCourses.reduce((s, c) => s + (Number(c.costPerGuest) || 0), 0);
const totalRevenue = guests * pricePerGuest;
const totalFoodCost = guests * foodCostPerGuest;
const netMargin = totalRevenue - totalFoodCost;
const fcPct = pricePerGuest > 0 ? ((foodCostPerGuest / pricePerGuest) * 100).toFixed(1) : "0.0";
const kpiStrip = document.getElementById("event-kpi-strip");
if (kpiStrip) {
kpiStrip.innerHTML = `
<div class="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
<div class="text-xs text-slate-500">Wartość oferty (Brutto)</div>
<div class="text-xl font-extrabold text-slate-900 mt-1">${formatMoney(totalRevenue)}</div>
</div>
<div class="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
<div class="text-xs text-slate-500">Koszt surowca (Food Cost)</div>
<div class="text-xl font-extrabold text-amber-700 mt-1">${formatMoney(totalFoodCost)} <span class="text-xs font-semibold">(${fcPct}%)</span></div>
</div>
<div class="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
<div class="text-xs text-slate-500">Zysk operacyjny z imprezy</div>
<div class="text-xl font-extrabold text-emerald-700 mt-1">+${formatMoney(netMargin)}</div>
</div>
<div class="bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
<div class="text-xs text-slate-500">Koszt surowca / 1 gość</div>
<div class="text-xl font-extrabold text-slate-900 mt-1">${formatMoney(foodCostPerGuest)}</div>
</div>
`;
}
const listEl = document.getElementById("event-courses-list");
if (listEl) {
listEl.innerHTML = appState.eventCourses.map((c, idx) => `
<div class="rounded-xl border border-slate-200 bg-slate-50/70 p-3 space-y-2">
<div class="flex items-center justify-between gap-2">
<input type="text" value="${escapeHtml(c.type)}" onchange="appState.eventCourses[${idx}].type=this.value; renderEventView()" class="text-xs font-bold text-purple-800 bg-purple-100/70 px-2 py-0.5 rounded-md w-32 outline-none" />
<div class="flex items-center gap-1.5">
<span class="text-[11px] text-slate-500">Koszt/os:</span>
<input type="number" step="0.5" value="${c.costPerGuest}" onchange="appState.eventCourses[${idx}].costPerGuest=parseFloat(this.value)||0; renderEventView()" class="w-16 text-xs font-bold text-slate-900 bg-white border border-slate-300 rounded-md px-1.5 py-0.5" />
<button onclick="appState.eventCourses.splice(${idx},1); renderEventView()" class="text-xs text-rose-600 font-bold px-1.5 cursor-pointer">✕</button>
</div>
</div>
<input type="text" value="${escapeHtml(c.title)}" onchange="appState.eventCourses[${idx}].title=this.value; renderEventView()" class="w-full text-xs sm:text-sm font-semibold text-slate-900 bg-white border border-slate-200 rounded-lg px-2.5 py-1.5 outline-none focus:border-purple-500" />
</div>
`).join("");
}
const printVenue = document.getElementById("ev-print-venue");
const printTitle = document.getElementById("ev-print-title");
const printMeta = document.getElementById("ev-print-meta");
const printCourses = document.getElementById("ev-print-courses");
const printTotal = document.getElementById("ev-print-total");
if (printVenue) printVenue.textContent = (appState.venueName || "RESTAURACJA").toUpperCase();
if (printTitle) printTitle.textContent = evName.toUpperCase();
if (printMeta) printMeta.textContent = `Data: ${evDate} • ${guests} gości • ${formatMoney(pricePerGuest)} / os.`;
if (printTotal) printTotal.textContent = `Razem: ${formatMoney(totalRevenue)}`;
if (printCourses) {
printCourses.innerHTML = appState.eventCourses.map(c => `
<div class="border-b border-slate-100 pb-2">
<div class="text-[11px] font-bold uppercase tracking-wider text-purple-800">${escapeHtml(c.type)}</div>
<div class="font-serif text-sm sm:text-base font-semibold text-slate-900">${escapeHtml(c.title)}</div>
</div>
`).join("");
}
}
function addEventCourse() {
appState.eventCourses.push({
type: "Dodatek / Serwis",
title: "Nowa pozycja bankietowa",
costPerGuest: 6.50,
allergens: "Gluten"
});
renderEventView();
}
function importFromLunchToEvent() {
const d0 = appState.profile.days[0];
const d2 = appState.profile.days[2];
appState.eventCourses = [
{ type: "Zupa powitalna", title: d0.vegSoup.title, costPerGuest: +(computeDishCost(d0.vegSoup) * 1.15).toFixed(2), allergens: "Laktoza" },
{ type: "Danie główne I", title: `${d0.meatMain.base} — ${d0.meatMain.side}`, costPerGuest: +(computeDishCost(d0.meatMain) * 1.15).toFixed(2), allergens: "Laktoza" },
{ type: "Danie główne II", title: `${d2.meatMain.base} — ${d2.meatMain.side}`, costPerGuest: +(computeDishCost(d2.meatMain) * 1.15).toFixed(2), allergens: "Gorczyca" },
{ type: "Opcja Wegetariańska", title: d2.vegMain.base, costPerGuest: +(computeDishCost(d2.vegMain) * 1.15).toFixed(2), allergens: "Gluten, Laktoza" }
];
renderEventView();
showToast("📥 Zaimportowano hity z aktualnego menu lunchowego!");
}
async function generateEventProposalAi() {
const guests = getElVal("ev-guests", "45");
const evType = getElVal("ev-type", "corporate");
showToast("✨ AI układa ofertę bankietową...");
const prompt = `Ułóż 5-daniowe menu bankietowe dla wydarzenia typu "${evType}" dla ${guests} gości w języku "${appState.lang}". Zwróć JSON z tablicą courses (type, title, costPerGuest w PLN).`;
const schema = {
type: "OBJECT",
properties: {
courses: {
type: "ARRAY",
items: {
type: "OBJECT",
properties: {
type: { type: "STRING" },
title: { type: "STRING" },
costPerGuest: { type: "NUMBER" }
},
required: ["type", "title", "costPerGuest"]
}
}
},
required: ["courses"]
};
try {
const res = await callGeminiJson(prompt, schema);
if (res && Array.isArray(res.courses) && res.courses.length) {
const metaScale = (appState.lang === "pl") ? 1 : ((COUNTRY_META[appState.lang]?.scale) || 0.25);
appState.eventCourses = res.courses.map(c => ({
type: c.type,
title: c.title,
costPerGuest: +((Number(c.costPerGuest) || 8) * metaScale).toFixed(2),
allergens: "Gluten, Laktoza"
}));
renderEventView();
showToast("✨ Gotowe! Oferta bankietowa została wygenerowana.");
}
} catch (e) {
importFromLunchToEvent();
}
}
// =========================================================================
// 11. HISTORY, NAVIGATION, SETTINGS & LOCALSTORAGE PERSISTENCE
// =========================================================================
function saveWeekToHistory() {
const mondayIso = getElVal("input-week-start", appState.weekStartDate);
const dishes = [];
appState.profile.days.forEach(d => {
dishes.push(d.meatSoup.title, d.vegSoup.title, d.meatMain.base, d.vegMain.base);
});
appState.historyLog.unshift({
id: Date.now(),
weekStart: mondayIso,
weekEnd: addDaysToIso(mondayIso, 4),
savedAt: Date.now(),
dishes
});
savePersistentState();
recalculateAll();
showToast("💾 " + t("hist_saved_toast"));
}
function renderHistoryView() {
const container = document.getElementById("history-log-container");
if (!container) return;
if (!appState.historyLog.length) {
container.innerHTML = `<div class="bg-white rounded-2xl border border-slate-200 p-8 text-center text-sm text-slate-500">${escapeHtml(t("hist_empty"))}</div>`;
return;
}
container.innerHTML = appState.historyLog.map(entry => `
<div class="bg-white rounded-2xl border border-slate-200 p-4 sm:p-5 shadow-sm flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<div class="text-sm font-bold text-slate-900">📅 Tydzień: ${escapeHtml(entry.weekStart)} — ${escapeHtml(entry.weekEnd)}</div>
<div class="text-xs text-slate-500 mt-1">${escapeHtml((entry.dishes || []).slice(0, 8).join(" • "))}...</div>
</div>
<span class="text-xs font-bold px-3 py-1 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-200 shrink-0">🛡️ Aktywna blokada 30 dni</span>
</div>
`).join("");
}
function clearHistoryLog() {
appState.historyLog = [];
savePersistentState();
recalculateAll();
showToast("🗑️ Wyczyszczono historię menu.");
}
function switchTab(tabName) {
appState.activeTab = tabName;
document.querySelectorAll(".workspace-view").forEach(v => v.classList.add("hidden"));
const target = document.getElementById(`view-${tabName}`);
if (target) target.classList.remove("hidden");
document.querySelectorAll(".nav-tab").forEach(btn => {
const isAct = btn.getAttribute("data-tab") === tabName;
if (btn.classList.contains("shrink-0")) {
btn.className = isAct
? "nav-tab shrink-0 px-3 py-1.5 rounded-lg text-xs font-semibold bg-emerald-600 text-white"
: "nav-tab shrink-0 px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 bg-slate-800";
} else {
btn.className = isAct
? "nav-tab px-3 py-1.5 rounded-lg text-xs font-semibold transition-all bg-emerald-600 text-white shadow-sm cursor-pointer"
: "nav-tab px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-300 hover:text-white hover:bg-slate-700/50 transition-all cursor-pointer";
}
});
}
function changeLanguage(langCode) {
appState.lang = langCode;
const dict = I18N[langCode] || I18N.pl;
document.documentElement.lang = langCode;
document.documentElement.dir = dict.dir || "ltr";
appState.currency = dict.currency;
appState.profile = buildLocalizedProfile(langCode);
setElVal("input-target-fc", dict.defaultFc);
setElVal("input-guest-price", dict.defaultGuestPrice);
const countryBadge = document.getElementById("ui-country-badge");
if (countryBadge) countryBadge.textContent = dict.countryBadge;
const cuisinePill = document.getElementById("cuisine-profile-pill");
if (cuisinePill) cuisinePill.textContent = dict.cuisinePill;
document.querySelectorAll(".currency-label").forEach(el => { el.textContent = dict.currency; });
document.querySelectorAll("[data-i18n]").forEach(el => {
const key = el.getAttribute("data-i18n");
if (dict[key]) el.textContent = dict[key];
});
const daySel = document.getElementById("print-day-select");
if (daySel) {
daySel.innerHTML = dict.days.map((dName, i) => `<option value="${i}">${dName}</option>`).join("");
}
enforceWeeklyUniqueness(-1);
recalculateAll();
}
function updateVenueNameLive(val) {
appState.venueName = val;
setElVal("setting-venue-name", val);
renderPrintAndSmmView();
renderEventView();
savePersistentState();
}
function updateVegShareLabel() {
const val = getElVal("input-veg-share", "30");
const lbl = document.getElementById("veg-share-label");
if (lbl) lbl.textContent = `${val}%`;
}
function onStockInputChange() {
recalculateAll();
}
function applyStockNow() {
generateFullWeek(true);
}
function onRegularMenuChange() {
savePersistentState();
}
function onSeasonChange() {
generateFullWeek(false);
}
function openSettingsModal() {
setElVal("setting-venue-name", appState.venueName);
setElVal("setting-currency", appState.currency);
const modal = document.getElementById("settings-modal");
if (modal) modal.classList.remove("hidden");
}
function closeSettingsModal() {
const modal = document.getElementById("settings-modal");
if (modal) modal.classList.add("hidden");
}
function saveSettings() {
appState.venueName = getElVal("setting-venue-name", appState.venueName).trim() || "Restauracja";
appState.currency = getElVal("setting-currency", appState.currency);
setElVal("header-venue-input", appState.venueName);
document.querySelectorAll(".currency-label").forEach(el => { el.textContent = appState.currency; });
closeSettingsModal();
recalculateAll();
renderEventView();
showToast("✅ Zapisano ustawienia lokalu");
}
function savePersistentState() {
try {
const payload = {
lang: appState.lang,
currency: appState.currency,
venueName: appState.venueName,
weekStartDate: getElVal("input-week-start", appState.weekStartDate),
targetFc: getElVal("input-target-fc", "13.50"),
guestPrice: getElVal("input-guest-price", "39"),
monThu: getElVal("input-portions-mon-thu", "50"),
fri: getElVal("input-portions-fri", "70"),
vegShare: getElVal("input-veg-share", "30"),
stock: getElVal("input-stock", ""),
regularMenu: getElVal("input-regular-menu", ""),
season: getElVal("input-season", "autumn"),
days: appState.profile.days,
historyLog: appState.historyLog,
aiCustomPool: appState.aiCustomPool,
eventCourses: appState.eventCourses
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
} catch (e) {}
}
function loadPersistentState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return false;
const data = JSON.parse(raw);
if (!data || !Array.isArray(data.days) || data.days.length !== 5) return false;
appState.lang = data.lang || "pl";
appState.currency = data.currency || "zł";
appState.venueName = data.venueName || "Restauracja Studio Buffo";
appState.weekStartDate = data.weekStartDate || getNextOrCurrentMonday();
appState.profile.days = data.days;
appState.historyLog = Array.isArray(data.historyLog) ? data.historyLog : [];
if (data.aiCustomPool) appState.aiCustomPool = data.aiCustomPool;
if (Array.isArray(data.eventCourses)) appState.eventCourses = data.eventCourses;
const dict = I18N[appState.lang] || I18N.pl;
document.documentElement.lang = appState.lang;
document.documentElement.dir = dict.dir || "ltr";
setElVal("lang-selector", appState.lang);
setElVal("header-venue-input", appState.venueName);
setElVal("input-week-start", appState.weekStartDate);
setElVal("ev-date", appState.weekStartDate);
setElVal("input-target-fc", data.targetFc || dict.defaultFc);
setElVal("input-guest-price", data.guestPrice || dict.defaultGuestPrice);
setElVal("input-portions-mon-thu", data.monThu || 50);
setElVal("input-portions-fri", data.fri || 70);
setElVal("input-veg-share", data.vegShare || 30);
setElVal("input-stock", data.stock || "");
setElVal("input-regular-menu", data.regularMenu || "");
setElVal("input-season", data.season || "autumn");
updateVegShareLabel();
document.querySelectorAll(".currency-label").forEach(el => { el.textContent = appState.currency; });
document.querySelectorAll("[data-i18n]").forEach(el => {
const key = el.getAttribute("data-i18n");
if (dict[key]) el.textContent = dict[key];
});
const daySel = document.getElementById("print-day-select");
if (daySel) {
daySel.innerHTML = dict.days.map((dName, i) => `<option value="${i}">${dName}</option>`).join("");
}
return true;
} catch (e) {
return false;
}
}
async function swapDish(dayIdx, slotKey, silent = false) {
const currentDish = appState.profile.days[dayIdx][slotKey];
const currentId = getDishIdentifier(currentDish);
const blockedHistory = getRecentBlockedDishNames();
const regularTerms = getRegularMenuBlockedTerms();
const stockTokens = getZeroWasteStockKeywords();
const selectedSeason = getElVal("input-season", "autumn");
const isSoup = slotKey === "meatSoup" || slotKey === "vegSoup";
const weekUsed = new Set();
appState.profile.days.forEach(d => {
if (isSoup) {
weekUsed.add(getDishIdentifier(d.meatSoup));
weekUsed.add(getDishIdentifier(d.vegSoup));
} else {
weekUsed.add(getDishIdentifier(d[slotKey]));
}
});
const pool = buildSlotPool(slotKey);
let candidates = pool.filter(c => {
const id = getDishIdentifier(c);
return id !== currentId && !appState.sessionSeenDishes.has(id) && !weekUsed.has(id) && !blockedHistory.has(id) && !isDishBlockedByRegularMenu(c, regularTerms);
});
if (candidates.length < 4) {
autoExpandDishDatabase(false);
}
if (!candidates.length) {
pool.forEach(c => appState.sessionSeenDishes.delete(getDishIdentifier(c)));
candidates = pool.filter(c => {
const id = getDishIdentifier(c);
return id !== currentId && !weekUsed.has(id) && !blockedHistory.has(id) && !isDishBlockedByRegularMenu(c, regularTerms);
});
}
if (!candidates.length) {
candidates = pool.filter(c => getDishIdentifier(c) !== currentId && !weekUsed.has(getDishIdentifier(c)));
}
if (!candidates.length) return;
candidates.sort((a, b) => (scoreCandidate(b, stockTokens, selectedSeason) - scoreCandidate(a, stockTokens, selectedSeason)) + (Math.random() - 0.5) * 2.5);
const chosen = JSON.parse(JSON.stringify(candidates[0]));
if (!isSoup) {
verifyAndHarmonizeDish(chosen, true);
}
appState.sessionSeenDishes.add(getDishIdentifier(chosen));
appState.profile.days[dayIdx][slotKey] = chosen;
enforceWeeklyUniqueness(dayIdx);
recalculateAll();
if (!silent) {
const matchedZw = getMatchedStockBadges(chosen, stockTokens);
showToast(matchedZw ? `♻️ ${t("btn_swap")} (${matchedZw})` : `🔄 ${t("btn_swap")}`);
}
}
async function generateFullWeek(prioritizeStock = false) {
const banner = document.getElementById("ai-loading-banner");
if (banner) banner.classList.remove("hidden");
appState.profile.days.forEach(d => {
["meatSoup", "vegSoup", "meatMain", "vegMain"].forEach(s => {
if (d[s]) appState.sessionSeenDishes.add(getDishIdentifier(d[s]));
});
});
const blockedHistory = getRecentBlockedDishNames();
const regularTerms = getRegularMenuBlockedTerms();
const stockTokens = getZeroWasteStockKeywords();
const selectedSeason = getElVal("input-season", "autumn");
const slots = ["meatSoup", "vegSoup", "meatMain", "vegMain"];
const usedSoupsThisWeek = new Set();
for (const slotKey of slots) {
const pool = buildSlotPool(slotKey);
const usedThisWeek = new Set();
const isSoup = slotKey === "meatSoup" || slotKey === "vegSoup";
for (let dayIdx = 0; dayIdx < 5; dayIdx++) {
let candidates = pool.filter(c => {
const id = getDishIdentifier(c);
const isUsedInWeek = isSoup ? (usedThisWeek.has(id) || usedSoupsThisWeek.has(id)) : usedThisWeek.has(id);
return !appState.sessionSeenDishes.has(id) && !isUsedInWeek && !blockedHistory.has(id) && !isDishBlockedByRegularMenu(c, regularTerms);
});
if (!candidates.length) {
pool.forEach(c => appState.sessionSeenDishes.delete(getDishIdentifier(c)));
candidates = pool.filter(c => {
const id = getDishIdentifier(c);
const isUsedInWeek = isSoup ? (usedThisWeek.has(id) || usedSoupsThisWeek.has(id)) : usedThisWeek.has(id);
return !isUsedInWeek && !blockedHistory.has(id) && !isDishBlockedByRegularMenu(c, regularTerms);
});
}
if (!candidates.length) {
candidates = pool.filter(c => {
const id = getDishIdentifier(c);
return isSoup ? (!usedThisWeek.has(id) && !usedSoupsThisWeek.has(id)) : !usedThisWeek.has(id);
});
}
if (!candidates.length) candidates = pool;
candidates.sort((a, b) => {
const scoreDiff = scoreCandidate(b, stockTokens, selectedSeason) - scoreCandidate(a, stockTokens, selectedSeason);
const jitter = (Math.random() - 0.5) * (prioritizeStock ? 0.5 : 2.5);
return scoreDiff + jitter;
});
const picked = JSON.parse(JSON.stringify(candidates[0]));
if (!isSoup) {
verifyAndHarmonizeDish(picked, true);
}
const pickedId = getDishIdentifier(picked);
usedThisWeek.add(pickedId);
if (isSoup) usedSoupsThisWeek.add(pickedId);
appState.sessionSeenDishes.add(pickedId);
appState.profile.days[dayIdx][slotKey] = picked;
}
}
enforceWeeklyUniqueness(-1);
autoExpandDishDatabase(false);
if (banner) banner.classList.add("hidden");
runFullMenuAudit(false);
recalculateAll();
if (prioritizeStock && stockTokens.length) {
showToast("♻️ Menu ułożone pod stany magazynowe (Zero-Waste)!");
}
}
window.onload = function () {
const restored = loadPersistentState();
if (!restored) {
const dict = I18N[appState.lang];
setElVal("header-venue-input", appState.venueName);
setElVal("input-week-start", appState.weekStartDate);
setElVal("ev-date", appState.weekStartDate);
const daySel = document.getElementById("print-day-select");
if (daySel) {
daySel.innerHTML = dict.days.map((dName, i) => `<option value="${i}">${dName}</option>`).join("");
}
}
enforceWeeklyUniqueness(-1);
runFullMenuAudit(false);
recalculateAll();
renderEventView();
};
</script>
</body>
</html>