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 (
);
};
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}`}
)}
);
})}
{/* 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 */}
{/* 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"/>
))}
))}
)}
);
}