import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Calculator, Package, AlertTriangle, CheckCircle, Lock, RefreshCw, Layers, LayoutGrid, Settings2, Link, Save, Plus, Trash2, Edit3, X, RotateCcw, Cloud } from 'lucide-react'; // Firebase Imports import { initializeApp } from 'firebase/app'; import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth'; import { getFirestore, doc, setDoc, onSnapshot } from 'firebase/firestore'; // --- 初始預設資料 (Factory Defaults) --- const INITIAL_MODELS = [ { id: 'k3', length: 269, name: 'K3' }, { id: 'k4', length: 272, name: 'K4' }, { id: 'k5', length: 249, name: 'K5' }, { id: 'k6', length: 255, name: 'K6' }, { id: 'k9', length: 264, name: 'K9' }, ]; const INITIAL_RULES = { FL: { name: '前輪左側 (FL)', parts: [{ length: 264, qty: 4 }, { length: 269, qty: 4 }] }, FR: { name: '前輪右側 (FR)', parts: [{ length: 269, qty: 4 }, { length: 272, qty: 4 }] }, RL: { name: '後輪左側 (RL)', parts: [{ length: 264, qty: 4 }, { length: 269, qty: 4 }] }, RR: { name: '後輪右側 (RR)', parts: [{ length: 255, qty: 8 }, { length: 249, qty: 8 }] }, }; const INITIAL_INVENTORY = { 249: 100, 255: 100, 264: 100, 269: 100, 272: 100, }; // --- Firebase Initialization (Safe Check) --- let auth, db, appId; try { const firebaseConfig = JSON.parse(__firebase_config); const app = initializeApp(firebaseConfig); auth = getAuth(app); db = getFirestore(app); appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id'; } catch (e) { console.error("Firebase init failed:", e); } // --- Helper Components --- const ConfirmModal = ({ isOpen, title, content, onConfirm, onCancel, confirmText = "確認", confirmColor = "red" }) => { if (!isOpen) return null; return (

{title}

{content}

); }; const PromptModal = ({ isOpen, title, onConfirm, onCancel, defaultValue = '', isText = false, placeholder = '', confirmText = "確認", confirmColor = "blue" }) => { const [value, setValue] = useState(defaultValue); // Reset value when opening useEffect(() => { if(isOpen) setValue(defaultValue); }, [isOpen, defaultValue]); if (!isOpen) return null; return (

{title}

setValue(e.target.value)} placeholder={placeholder || (isText ? "輸入名稱..." : "例如: 280")} />
); }; export default function SpokeCalculator() { // --- 核心資料 State --- const [models, setModels] = useState(INITIAL_MODELS); const [rules, setRules] = useState(INITIAL_RULES); const [inventory, setInventory] = useState(INITIAL_INVENTORY); // Firebase User & Sync State const [user, setUser] = useState(null); const [syncStatus, setSyncStatus] = useState('idle'); // 'idle', 'saving', 'synced' // 使用 Ref 來追蹤「上一次從雲端讀到的資料」,避免本地重複寫入造成迴圈 const lastServerData = useRef({ inventory: null, models: null, rules: null }); // --- 介面狀態 --- const [isConfigOpen, setIsConfigOpen] = useState(false); // Modals State // type: 'reset' | 'deleteModel' | 'addModel' const [modalState, setModalState] = useState({ type: null, data: null }); const [calcMode, setCalcMode] = useState('unified'); const [unifiedSubMode, setUnifiedSubMode] = useState('auto'); const [viewMode, setViewMode] = useState('position'); const [targetSets, setTargetSets] = useState(0); const [customTargets, setCustomTargets] = useState({ FL: 0, FR: 0, RL: 0, RR: 0 }); // --- Firebase Logic --- // 1. Auth useEffect(() => { if (!auth) return; const initAuth = async () => { try { if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) { await signInWithCustomToken(auth, __initial_auth_token); } else { await signInAnonymously(auth); } } catch (e) { console.error("Auth error", e); } }; initAuth(); const unsubscribe = onAuthStateChanged(auth, setUser); return () => unsubscribe(); }, []); // 2. Data Sync (Read) useEffect(() => { if (!user || !db) return; // 使用固定的文件路徑 'main_v2',所有使用者共享 const docRef = doc(db, 'artifacts', appId, 'public', 'data', 'spoke_calc_settings', 'main_v2'); const unsubscribe = onSnapshot(docRef, (snap) => { if (snap.exists()) { const data = snap.data(); // --- 收到雲端資料,更新本地 --- let hasChanges = false; if (data.inventory && JSON.stringify(data.inventory) !== JSON.stringify(lastServerData.current.inventory)) { setInventory(data.inventory); lastServerData.current.inventory = data.inventory; hasChanges = true; } if (data.models && JSON.stringify(data.models) !== JSON.stringify(lastServerData.current.models)) { setModels(data.models); lastServerData.current.models = data.models; hasChanges = true; } if (data.rules && JSON.stringify(data.rules) !== JSON.stringify(lastServerData.current.rules)) { setRules(data.rules); lastServerData.current.rules = data.rules; hasChanges = true; } if(hasChanges) setSyncStatus('synced'); } else { // --- 初始化預設資料 --- saveToFirestore({ models: INITIAL_MODELS, rules: INITIAL_RULES, inventory: INITIAL_INVENTORY }); } }, (error) => { console.error("Sync error:", error); setSyncStatus('error'); }); return () => unsubscribe(); }, [user]); // 3. Save Helper const saveToFirestore = async (dataToSave) => { if (!user || !db) return; setSyncStatus('saving'); try { // 寫入固定的文件路徑 const docRef = doc(db, 'artifacts', appId, 'public', 'data', 'spoke_calc_settings', 'main_v2'); const finalData = { models: dataToSave.models || models, rules: dataToSave.rules || rules, inventory: dataToSave.inventory || inventory }; await setDoc(docRef, finalData); // 更新 Ref if (dataToSave.inventory) lastServerData.current.inventory = finalData.inventory; if (dataToSave.models) lastServerData.current.models = finalData.models; if (dataToSave.rules) lastServerData.current.rules = finalData.rules; setSyncStatus('synced'); setTimeout(() => setSyncStatus('idle'), 2000); } catch (e) { console.error("Save failed", e); setSyncStatus('error'); } }; // 4. Inventory Auto-Save (Debounced) useEffect(() => { if (!user) return; if (JSON.stringify(inventory) === JSON.stringify(lastServerData.current.inventory)) return; const timer = setTimeout(() => { saveToFirestore({ inventory }); }, 1000); return () => clearTimeout(timer); }, [inventory]); // --- Data Logic --- const globalRecipe = useMemo(() => { const recipeMap = {}; models.forEach(m => { recipeMap[m.length] = { ...m, totalReq: 0, details: [] }; }); Object.keys(rules).forEach(pos => { rules[pos].parts.forEach(part => { if (!recipeMap[part.length]) { recipeMap[part.length] = { id: `temp-${part.length}`, length: part.length, name: '未知', totalReq: 0, details: [] }; } recipeMap[part.length].totalReq += part.qty; recipeMap[part.length].details.push(`${pos}:${part.qty}`); }); }); return Object.values(recipeMap).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); }, [models, rules]); const totalRequirements = useMemo(() => { const reqs = {}; globalRecipe.forEach(r => reqs[r.length] = 0); const targets = calcMode === 'unified' ? { FL: targetSets, FR: targetSets, RL: targetSets, RR: targetSets } : customTargets; Object.keys(targets).forEach(pos => { const qty = targets[pos] || 0; rules[pos].parts.forEach(part => { reqs[part.length] = (reqs[part.length] || 0) + (part.qty * qty); }); }); return reqs; }, [calcMode, targetSets, customTargets, rules, globalRecipe]); const calculateMaxSets = () => { const limits = globalRecipe.map(item => { if (item.totalReq === 0) return Infinity; return Math.floor((inventory[item.length] || 0) / item.totalReq); }); return limits.length > 0 ? Math.min(...limits) : 0; }; const calculatePositionMax = (posKey) => { const parts = rules[posKey].parts; if (parts.length === 0) return Infinity; const limits = parts.map(p => Math.floor((inventory[p.length] || 0) / p.qty)); return Math.min(...limits); }; useEffect(() => { if (calcMode === 'unified' && unifiedSubMode === 'auto') { setTargetSets(calculateMaxSets()); } }, [inventory, calcMode, unifiedSubMode, rules, models]); // --- Handlers --- const handleInventoryChange = (length, value) => { const newVal = value === '' ? 0 : parseInt(value); setInventory(prev => ({ ...prev, [length]: isNaN(newVal) ? 0 : newVal })); }; const handleInputFocus = (e) => e.target.select(); const saveConfigChanges = (newModels, newRules, newInventory) => { const m = newModels || models; const r = newRules || rules; const i = newInventory || inventory; setModels(m); setRules(r); setInventory(i); saveToFirestore({ models: m, rules: r, inventory: i }); }; const updateModelName = (id, newName) => { const newModels = models.map(m => m.id === id ? { ...m, name: newName } : m); setModels(newModels); }; const updateModelLength = (id, oldLen, newLenStr) => { const newLen = parseInt(newLenStr); if (!newLen || newLen === oldLen) return; if (models.some(m => m.length === newLen && m.id !== id)) { alert(`長度 ${newLen}mm 已經存在。`); return; } const newModels = models.map(m => m.id === id ? { ...m, length: newLen } : m); const newInv = { ...inventory }; newInv[newLen] = newInv[oldLen] || 0; delete newInv[oldLen]; const newRules = { ...rules }; Object.keys(newRules).forEach(pos => { newRules[pos] = { ...newRules[pos], parts: newRules[pos].parts.map(p => p.length === oldLen ? { ...p, length: newLen } : p) }; }); saveConfigChanges(newModels, newRules, newInv); }; // Modal Actions const openAddModel = () => setModalState({ type: 'addModel' }); const handleAddModelConfirm = (val) => { const len = parseInt(val); if (!len) return; if (models.find(m => m.length === len)) { alert('已存在'); return; } const newModels = [...models, { id: `k-${len}-${Date.now()}`, length: len, name: `New` }] .sort((a,b) => a.name.localeCompare(b.name, undefined, { numeric: true })); const newInv = { ...inventory, [len]: 0 }; saveConfigChanges(newModels, null, newInv); setModalState({ type: null }); }; const openDeleteModel = (id, len) => setModalState({ type: 'deleteModel', data: { id, len } }); const handleDeleteModelConfirm = () => { const { id, len } = modalState.data; const newModels = models.filter(m => m.id !== id); const newInv = { ...inventory }; delete newInv[len]; saveConfigChanges(newModels, null, newInv); setModalState({ type: null }); }; const openReset = () => setModalState({ type: 'reset' }); const handleResetConfirm = () => { saveConfigChanges(INITIAL_MODELS, INITIAL_RULES, INITIAL_INVENTORY); setModalState({ type: null }); }; const updateRulePart = (pos, index, field, value) => { const newRules = { ...rules }; const newParts = [...newRules[pos].parts]; newParts[index] = { ...newParts[index], [field]: parseInt(value) || 0 }; newRules[pos].parts = newParts; setRules(newRules); }; const addPartToRule = (pos) => { const newRules = { ...rules }; newRules[pos] = { ...newRules[pos], parts: [...newRules[pos].parts, { length: models[0]?.length || 250, qty: 1 }] }; saveConfigChanges(null, newRules, null); }; const removePartFromRule = (pos, index) => { const newRules = { ...rules }; const newParts = newRules[pos].parts.filter((_, i) => i !== index); newRules[pos].parts = newParts; saveConfigChanges(null, newRules, null); }; return (
{/* Modals */} setModalState({ type: null })} /> setModalState({ type: null })} /> setModalState({ type: null })} />
{/* Header */}

輻條齊套計算器

{syncStatus === 'saving' && 存檔中...} {syncStatus === 'synced' && 已同步}
{/* Mode & Target Input */}
{calcMode === 'unified' && (
{ setUnifiedSubMode('manual'); setTargetSets(parseInt(e.target.value) || 0); }} onFocus={handleInputFocus} className="w-12 text-center bg-transparent font-black text-xl outline-none" /> {unifiedSubMode === 'manual' && }
)}
{/* Inventory List */}

庫存管理

{globalRecipe.map((item) => { const used = totalRequirements[item.length] || 0; const currentInv = inventory[item.length] || 0; const remaining = currentInv - used; const isShortage = currentInv < used; return (
{/* NEW: Display Remaining or Shortage */} {used > 0 && ( {isShortage ? `缺 ${Math.abs(remaining)}` : `餘 ${remaining}`} )}
handleInventoryChange(item.length, e.target.value)} onFocus={handleInputFocus} className={`w-full p-2 text-right font-mono text-lg border rounded-lg outline-none focus:ring-2 focus:ring-blue-500 ${isShortage ? 'border-red-300 bg-red-50' : 'border-slate-300'}`} /> {currentInv > 0 && (
)}
); })}
{/* Main View */}
{viewMode === 'position' && (
{Object.keys(rules).map(pos => { const data = rules[pos]; const posMax = calculatePositionMax(pos); const isConflict = data.parts.some(p => (inventory[p.length] || 0) < totalRequirements[p.length]); return (

{pos}{data.name}

{calcMode === 'mix' ? ( setCustomTargets(prev => ({...prev, [pos]: parseInt(e.target.value)||0}))} onFocus={handleInputFocus} className="w-16 text-right font-black text-lg bg-transparent border-b border-slate-300 outline-none" /> ) : {targetSets}}
{data.parts.map((part, idx) => { const current = inventory[part.length] || 0; const needed = totalRequirements[part.length]; const isShort = current < needed; const mName = models.find(m => m.length === part.length)?.name; return (
{mName}-{part.length}mm x{part.qty} {isShort ? `缺 ${needed - current}` : '充足'}
) })}
) })}
)} {viewMode === 'table' && (
{globalRecipe.map(item => { const req = totalRequirements[item.length]; const inv = inventory[item.length] || 0; const bal = inv - req; return ( ) })}
型號需求庫存狀態
{item.name}-{item.length}mm {req} {inv} {bal >= 0 ? `餘 ${bal}` : `缺 ${Math.abs(bal)}`}
)}
{/* Config Panel (Overlay) */} {isConfigOpen && (

設定

{/* Models */}

型號定義

{models.map(m => (
updateModelLength(m.id, m.length, e.target.value)} onFocus={handleInputFocus} inputMode="numeric"/> mm updateModelName(m.id, e.target.value)} placeholder="名稱"/>
))}
{/* Rules */}

組裝規則

{Object.keys(rules).map(pos => (
{rules[pos].name}
{rules[pos].parts.map((p, i) => (
updateRulePart(pos, i, 'qty', e.target.value)} onFocus={handleInputFocus} inputMode="numeric"/>
))}
))}
)}
); }