// App.jsx - main bakery recipe calculator // 3 style variants: 'warm' (溫暖麵包坊), 'clean' (乾淨工業), 'kitchen' (廚房超大字) const { useState, useEffect, useMemo, useRef } = React; // ────────────────────────────────────────────────────────── // THEMES // ────────────────────────────────────────────────────────── const THEMES = { warm: { name: '溫暖麵包坊', // 溫暖、烘焙風 - crusty bread tones bg: '#F5EDE0', surface: '#FFFBF4', surfaceAlt: '#EFE4D1', ink: '#2B1D10', inkSoft: '#6B5643', inkMute: '#9D876F', accent: '#B8451E', // crust red-orange accentSoft: '#E8C9A8', divider: 'rgba(43,29,16,0.08)', success: '#5C7A3F', fontFamily: '"Noto Serif TC", "Songti TC", "PingFang TC", serif', fontFamilyUi: '"Noto Sans TC", "PingFang TC", system-ui, sans-serif', fontFamilyNum: '"Fraunces", "Noto Serif TC", serif', titleWeight: 700, numberWeight: 700, cardRadius: 18, scale: 1.0, }, clean: { name: '乾淨工業', // 乾淨、極簡、偏工業設計 bg: '#F4F4F2', surface: '#FFFFFF', surfaceAlt: '#EAEAE7', ink: '#111111', inkSoft: '#555555', inkMute: '#9A9A97', accent: '#111111', accentSoft: '#E3E3E0', divider: 'rgba(0,0,0,0.07)', success: '#2E7D32', fontFamily: '"Inter", "Noto Sans TC", "PingFang TC", system-ui, sans-serif', fontFamilyUi: '"Inter", "Noto Sans TC", "PingFang TC", system-ui, sans-serif', fontFamilyNum: '"JetBrains Mono", "Noto Sans TC", monospace', titleWeight: 600, numberWeight: 500, cardRadius: 4, scale: 1.0, }, kitchen: { name: '廚房超大字', // 廚房實戰 - 手上有粉也看得見, 高對比 bg: '#1A1A1A', surface: '#262626', surfaceAlt: '#333333', ink: '#FFFFFF', inkSoft: '#C9C9C9', inkMute: '#8A8A8A', accent: '#FFD429', // 高對比黃 accentSoft: '#3D3A1F', divider: 'rgba(255,255,255,0.1)', success: '#7DD84C', fontFamily: '"Noto Sans TC", "PingFang TC", system-ui, sans-serif', fontFamilyUi: '"Noto Sans TC", "PingFang TC", system-ui, sans-serif', fontFamilyNum: '"Noto Sans TC", system-ui, sans-serif', titleWeight: 900, numberWeight: 800, cardRadius: 10, scale: 1.15, // 文字整體放大 }, }; // ────────────────────────────────────────────────────────── // APP (one instance per variant) // ────────────────────────────────────────────────────────── function BakeryApp({ themeKey }) { const theme = THEMES[themeKey]; // ── auth ── const [session, setSession] = useState(() => (window.api ? window.api.getSession() : null)); // screen: 'home' | 'recipe' | 'weigh' | 'users' | 'history' const [screen, setScreen] = useState('home'); const [menuOpen, setMenuOpen] = useState(false); const [currentId, setCurrentId] = useState(null); // quantities per recipe id const [qtys, setQtys] = useState({}); // weighing check state: { [recipeId]: { [ingredientKey]: true } } const [checked, setChecked] = useState({}); // active = current + companion (if combo) const activeRecipes = useMemo(() => { const main = getRecipe(currentId); if (!main) return []; if (main.companions && main.companions.length) { return [main, ...main.companions.map(c => getRecipe(c.id)).filter(Boolean)]; } return [main]; }, [currentId]); const openRecipe = (id) => { setCurrentId(id); setScreen('recipe'); setQtys(prev => { const updates = { ...prev }; if (updates[id] == null) updates[id] = 1; const r = getRecipe(id); if (r?.companions) { for (const c of r.companions) { if (updates[c.id] == null) updates[c.id] = 0; } } return updates; }); }; const setQty = (id, val) => { setQtys(prev => ({ ...prev, [id]: val })); }; const startWeighing = async () => { // clear checks setChecked(prev => ({ ...prev, [currentId]: {} })); setScreen('weigh'); // log production if (window.api && session) { try { const breakdown = mergedList.recipeBreakdown || []; const totalPieces = breakdown.reduce((s, b) => s + (b.n || 0), 0); const recipeNames = breakdown.filter(b => b.n > 0).map(b => b.r.name); await window.api.logProduction({ username: session.username, display_name: session.display_name, recipe_names: recipeNames, recipe_payload: { sections: mergedList.sections, breakdown: breakdown.map(b => ({ id: b.r.id, name: b.r.name, qty: b.n })) }, total_pieces: totalPieces, total_flour: mergedList.totalFlour || 0, }); } catch (e) { console.warn('logProduction failed:', e.message); } } }; const toggleCheck = (key) => { setChecked(prev => { const cur = prev[currentId] || {}; return { ...prev, [currentId]: { ...cur, [key]: !cur[key] } }; }); }; // merged ingredients for weighing screen const mergedList = useMemo(() => { if (!activeRecipes.length) return { sections: [], totalFlour: 0 }; // if single recipe, just use it if (activeRecipes.length === 1) { const r = activeRecipes[0]; const n = qtys[r.id] || 0; return { ...calcRecipe(r, n), totalFlour: r.perPieceFlour * n, recipeBreakdown: [{ r, n }] }; } // combo: merge const breakdown = activeRecipes.map(r => ({ r, n: qtys[r.id] || 0 })); const merged = {}; let totalFlour = 0; for (const { r, n } of breakdown) { if (n <= 0) continue; const calc = calcRecipe(r, n); totalFlour += calc.totalFlour || 0; for (const section of calc.sections) { for (const item of section.items) { const k = section.title + '|' + item.name; if (!merged[k]) { merged[k] = { ...item, section: section.title }; } else { merged[k].amount += item.amount; } } } } // re-group by section const sectionMap = {}; for (const v of Object.values(merged)) { if (!sectionMap[v.section]) sectionMap[v.section] = []; sectionMap[v.section].push(v); } const sectionOrder = ['粉類', '液體與酵', '配料', '材料']; const sections = sectionOrder.filter(s => sectionMap[s]).map(s => ({ title: s, items: sectionMap[s], ...(s === '粉類' ? { totalLabel: '總粉量', total: totalFlour } : {}), })); return { totalFlour, sections, recipeBreakdown: breakdown }; }, [activeRecipes, qtys]); const screenKey = screen + (currentId || ''); // require login if (window.api && !session) { return (
setSession(s)} />
); } const handleLogout = () => { if (window.api) window.api.logout(); setSession(null); setMenuOpen(false); setScreen('home'); }; return (
{screen === 'home' && ( setMenuOpen(true)} /> )} {screen === 'users' && session?.role === 'admin' && ( setScreen('home')} /> )} {screen === 'history' && session?.role === 'admin' && ( setScreen('home')} /> )} {menuOpen && session && ( setMenuOpen(false)} onLogout={handleLogout} onUsers={() => { setScreen('users'); setMenuOpen(false); }} onHistory={() => { setScreen('history'); setMenuOpen(false); }} /> )} {screen === 'recipe' && ( setScreen('home')} onGo={startWeighing} /> )} {screen === 'weigh' && ( setScreen('recipe')} onDone={() => { setScreen('home'); }} /> )}
); } Object.assign(window, { BakeryApp, THEMES });