Refactor UI to light theme, move controls to header, and add collapsible sidebar
This commit is contained in:
105
App.tsx
105
App.tsx
@@ -6,7 +6,7 @@ import { BMSBasicInfo, BMSCellInfo, ConnectionState } from './types';
|
|||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
import Settings from './components/Settings';
|
import Settings from './components/Settings';
|
||||||
import Terminal from './components/Terminal';
|
import Terminal from './components/Terminal';
|
||||||
import { LayoutDashboard, Settings as SettingsIcon, Usb, AlertCircle, RefreshCw } from 'lucide-react';
|
import { LayoutDashboard, Settings as SettingsIcon, Usb, AlertCircle, RefreshCw, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>(ConnectionState.DISCONNECTED);
|
const [connectionState, setConnectionState] = useState<ConnectionState>(ConnectionState.DISCONNECTED);
|
||||||
@@ -15,6 +15,7 @@ const App: React.FC = () => {
|
|||||||
const [hwVersion, setHwVersion] = useState<string | null>(null);
|
const [hwVersion, setHwVersion] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'settings'>('dashboard');
|
const [activeTab, setActiveTab] = useState<'dashboard' | 'settings'>('dashboard');
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [isTerminalOpen, setIsTerminalOpen] = useState(true);
|
||||||
|
|
||||||
// Polling Logic
|
// Polling Logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -131,15 +132,15 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full bg-gray-950 text-gray-100 overflow-hidden">
|
<div className="flex h-screen w-full bg-gray-50 text-gray-900 overflow-hidden">
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="w-20 lg:w-64 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col z-20">
|
<div className="w-20 lg:w-64 flex-shrink-0 bg-white border-r border-gray-200 flex flex-col z-20">
|
||||||
<div className="p-6 flex items-center justify-center lg:justify-start">
|
<div className="p-6 flex items-center justify-center lg:justify-start">
|
||||||
<div className="h-8 w-8 bg-blue-600 rounded-lg flex items-center justify-center font-bold shadow-lg shadow-blue-900/50">
|
<div className="h-8 w-8 bg-blue-600 rounded-lg flex items-center justify-center font-bold shadow-lg shadow-blue-900/50">
|
||||||
J
|
J
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-3 font-bold text-xl hidden lg:block tracking-tight text-gray-100">JBD Tool</span>
|
<span className="ml-3 font-bold text-xl hidden lg:block tracking-tight text-gray-800">JBD Tool</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 px-4 space-y-2 mt-4">
|
<nav className="flex-1 px-4 space-y-2 mt-4">
|
||||||
@@ -147,21 +148,21 @@ const App: React.FC = () => {
|
|||||||
onClick={() => setActiveTab('dashboard')}
|
onClick={() => setActiveTab('dashboard')}
|
||||||
className={`w-full flex items-center p-3 rounded-xl transition-all ${activeTab === 'dashboard'
|
className={`w-full flex items-center p-3 rounded-xl transition-all ${activeTab === 'dashboard'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||||
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LayoutDashboard size={20} />
|
<LayoutDashboard size={20} />
|
||||||
<span className="ml-3 hidden lg:block font-medium">대시보드</span>
|
<span className="ml-3 hidden lg:block font-medium">Dashboard</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('settings')}
|
onClick={() => setActiveTab('settings')}
|
||||||
className={`w-full flex items-center p-3 rounded-xl transition-all ${activeTab === 'settings'
|
className={`w-full flex items-center p-3 rounded-xl transition-all ${activeTab === 'settings'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||||
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<SettingsIcon size={20} />
|
<SettingsIcon size={20} />
|
||||||
<span className="ml-3 hidden lg:block font-medium">설정 (EEPROM)</span>
|
<span className="ml-3 hidden lg:block font-medium">EEPROM</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -179,57 +180,53 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border-t border-gray-800">
|
<div className="p-4 border-t border-gray-200 flex-1 flex flex-col justify-end">
|
||||||
<div className="flex flex-col gap-2">
|
{/* AdSense Area - Expanded */}
|
||||||
{connectionState === ConnectionState.CONNECTED ? (
|
|
||||||
<button
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
className="w-full py-3 px-4 bg-red-900/30 text-red-400 border border-red-900/50 rounded-xl hover:bg-red-900/50 transition-colors flex items-center justify-center font-semibold"
|
|
||||||
>
|
|
||||||
<Usb size={18} className="mr-2" />
|
|
||||||
<span className="hidden lg:inline">연결 해제</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleConnect}
|
|
||||||
disabled={connectionState === ConnectionState.CONNECTING}
|
|
||||||
className="w-full py-3 px-4 bg-green-600 text-white rounded-xl hover:bg-green-500 transition-colors flex items-center justify-center font-semibold shadow-lg shadow-green-900/20"
|
|
||||||
>
|
|
||||||
{connectionState === ConnectionState.CONNECTING ? <RefreshCw className="animate-spin" size={18} /> : <Usb size={18} className="mr-2" />}
|
|
||||||
<span className="hidden lg:inline">BMS 연결</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="h-16 bg-gray-900/50 backdrop-blur border-b border-gray-800 flex items-center justify-between px-6 z-10 flex-shrink-0">
|
<header className="h-16 bg-white/80 backdrop-blur border-b border-gray-200 flex items-center justify-between px-6 z-10 flex-shrink-0">
|
||||||
<h1 className="text-xl font-semibold text-gray-200">
|
<h1 className="text-xl font-semibold text-gray-800">
|
||||||
{activeTab === 'dashboard' ? '개요 (Overview)' : '설정 (Configuration)'}
|
{activeTab === 'dashboard' ? 'Overview' : 'Configuration'}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{connectionState === ConnectionState.CONNECTED && (
|
{connectionState === ConnectionState.CONNECTED ? (
|
||||||
<div className="flex items-center text-xs font-mono text-green-400 bg-green-900/20 px-3 py-1 rounded-full border border-green-900/50">
|
<button
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500 mr-2 animate-pulse"></div>
|
onClick={handleDisconnect}
|
||||||
CONNECTED
|
className="py-1.5 px-3 bg-red-100 text-red-600 border border-red-200 rounded-lg hover:bg-red-200 transition-colors flex items-center text-sm font-semibold"
|
||||||
</div>
|
>
|
||||||
)}
|
<Usb size={16} className="mr-2" />
|
||||||
{connectionState === ConnectionState.DISCONNECTED && (
|
Disconnect
|
||||||
<div className="flex items-center text-xs font-mono text-gray-500 bg-gray-800 px-3 py-1 rounded-full">
|
</button>
|
||||||
<div className="w-2 h-2 rounded-full bg-gray-500 mr-2"></div>
|
) : (
|
||||||
OFFLINE
|
<button
|
||||||
</div>
|
onClick={handleConnect}
|
||||||
|
disabled={connectionState === ConnectionState.CONNECTING}
|
||||||
|
className="py-1.5 px-3 bg-green-600 text-white rounded-lg hover:bg-green-500 transition-colors flex items-center text-sm font-semibold shadow-sm"
|
||||||
|
>
|
||||||
|
{connectionState === ConnectionState.CONNECTING ? <RefreshCw className="animate-spin mr-2" size={16} /> : <Usb size={16} className="mr-2" />}
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsTerminalOpen(!isTerminalOpen)}
|
||||||
|
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
title={isTerminalOpen ? "Hide Terminal" : "Show Terminal"}
|
||||||
|
>
|
||||||
|
{isTerminalOpen ? <PanelRightClose size={20} /> : <PanelRightOpen size={20} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content Body with Right Sidebar */}
|
{/* Content Body with Right Sidebar */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Main View */}
|
{/* Main View */}
|
||||||
<main className="flex-1 overflow-hidden bg-gray-950 relative">
|
<main className="flex-1 overflow-hidden bg-gray-50 relative">
|
||||||
{errorMsg && (
|
{errorMsg && (
|
||||||
<div className="bg-red-900/80 text-white px-6 py-2 flex items-center justify-center text-sm font-medium backdrop-blur absolute top-0 left-0 right-0 z-30 animate-in slide-in-from-top-2">
|
<div className="bg-red-900/80 text-white px-6 py-2 flex items-center justify-center text-sm font-medium backdrop-blur absolute top-0 left-0 right-0 z-30 animate-in slide-in-from-top-2">
|
||||||
<AlertCircle size={16} className="mr-2" />
|
<AlertCircle size={16} className="mr-2" />
|
||||||
@@ -241,27 +238,23 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
{/* Empty State Overlay */}
|
{/* Empty State Overlay */}
|
||||||
{connectionState !== ConnectionState.CONNECTED && (
|
{connectionState !== ConnectionState.CONNECTED && (
|
||||||
<div className="absolute inset-0 bg-gray-950/80 backdrop-blur-sm z-10 flex flex-col items-center justify-center p-6 text-center">
|
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm z-10 flex flex-col items-center justify-center p-6 text-center">
|
||||||
<div className="w-16 h-16 bg-gray-800 rounded-2xl flex items-center justify-center mb-6 shadow-xl border border-gray-700">
|
<div className="w-16 h-16 bg-gray-100 rounded-2xl flex items-center justify-center mb-6 shadow-xl border border-gray-200">
|
||||||
<Usb size={32} className="text-gray-500" />
|
<Usb size={32} className="text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">장치가 연결되지 않았습니다</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">장치가 연결되지 않았습니다</h2>
|
||||||
<p className="text-gray-400 max-w-md mb-8">
|
<p className="text-gray-500 max-w-md mb-8">
|
||||||
JBD BMS를 UART-to-USB 어댑터로 연결하여 실시간 상태를 확인하고 설정을 변경하세요.
|
JBD BMS를 UART-to-USB 어댑터로 연결하여 실시간 상태를 확인하고 설정을 변경하세요.
|
||||||
</p>
|
</p>
|
||||||
<button
|
|
||||||
onClick={handleConnect}
|
|
||||||
className="py-3 px-8 bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-xl transition-all shadow-lg shadow-blue-900/30 flex items-center"
|
|
||||||
>
|
|
||||||
<Usb size={18} className="mr-2" /> 장치 연결하기
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Right Terminal Panel */}
|
{/* Right Terminal Panel */}
|
||||||
<aside className="w-80 lg:w-96 border-l border-gray-800 bg-gray-950 flex flex-col flex-shrink-0 z-20">
|
<aside className={`${isTerminalOpen ? 'w-80 lg:w-96 border-l' : 'w-0 border-l-0'} transition-all duration-300 ease-in-out border-gray-200 bg-white flex flex-col flex-shrink-0 z-20 overflow-hidden`}>
|
||||||
<Terminal />
|
<div className="w-80 lg:w-96 h-full flex flex-col">
|
||||||
|
<Terminal />
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,20 +20,20 @@ const COLORS = [
|
|||||||
|
|
||||||
// Updated MetricCard to accept valueClassName for custom styling (e.g., blinking)
|
// Updated MetricCard to accept valueClassName for custom styling (e.g., blinking)
|
||||||
const MetricCard: React.FC<{ label: string; value: string; unit?: string; icon: React.ReactNode; color?: string; subValue?: string; valueClassName?: string }> = ({ label, value, unit, icon, color = "text-blue-400", subValue, valueClassName }) => (
|
const MetricCard: React.FC<{ label: string; value: string; unit?: string; icon: React.ReactNode; color?: string; subValue?: string; valueClassName?: string }> = ({ label, value, unit, icon, color = "text-blue-400", subValue, valueClassName }) => (
|
||||||
<div className="bg-gray-900 p-4 rounded-xl shadow-lg border border-gray-800 flex flex-col justify-between h-28 relative overflow-hidden group hover:border-gray-700 transition-colors">
|
<div className="bg-white p-4 rounded-xl shadow-lg border border-gray-200 flex flex-col justify-between h-28 relative overflow-hidden group hover:border-gray-300 transition-colors">
|
||||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity transform scale-150 origin-top-right">
|
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity transform scale-150 origin-top-right">
|
||||||
{React.isValidElement(icon) && React.cloneElement(icon as React.ReactElement<any>, { size: 48 })}
|
{React.isValidElement(icon) && React.cloneElement(icon as React.ReactElement<any>, { size: 48 })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-start z-10">
|
<div className="flex justify-between items-start z-10">
|
||||||
<span className="text-gray-400 text-xs font-semibold uppercase tracking-wider">{label}</span>
|
<span className="text-gray-500 text-xs font-semibold uppercase tracking-wider">{label}</span>
|
||||||
<div className={`${color} opacity-80`}>{icon}</div>
|
<div className={`${color} opacity-80`}>{icon}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="z-10 mt-auto">
|
<div className="z-10 mt-auto">
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className={`text-2xl font-bold font-mono tracking-tight ${valueClassName || 'text-gray-100'}`}>{value}</span>
|
<span className={`text-2xl font-bold font-mono tracking-tight ${valueClassName || 'text-gray-900'}`}>{value}</span>
|
||||||
{unit && <span className="text-gray-500 text-xs font-bold">{unit}</span>}
|
{unit && <span className="text-gray-400 text-xs font-bold">{unit}</span>}
|
||||||
</div>
|
</div>
|
||||||
{subValue && <div className="text-gray-500 text-xs mt-1 font-mono">{subValue}</div>}
|
{subValue && <div className="text-gray-400 text-xs mt-1 font-mono">{subValue}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -42,13 +42,11 @@ const MosfetSwitch: React.FC<{ label: string; active: boolean; onClick: () => vo
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`flex flex-col items-center justify-center p-4 rounded-xl border transition-all duration-200 w-full relative overflow-hidden ${
|
className={`flex flex-col items-center justify-center p-4 rounded-xl border transition-all duration-200 w-full relative overflow-hidden ${disabled ? 'opacity-50 cursor-not-allowed grayscale' : 'hover:scale-[1.02] active:scale-[0.98]'
|
||||||
disabled ? 'opacity-50 cursor-not-allowed grayscale' : 'hover:scale-[1.02] active:scale-[0.98]'
|
} ${active
|
||||||
} ${
|
|
||||||
active
|
|
||||||
? 'bg-green-900/20 border-green-500/50 text-green-400 shadow-[0_0_15px_rgba(34,197,94,0.1)]'
|
? 'bg-green-900/20 border-green-500/50 text-green-400 shadow-[0_0_15px_rgba(34,197,94,0.1)]'
|
||||||
: 'bg-red-900/20 border-red-500/50 text-red-400 shadow-[0_0_15px_rgba(239,68,68,0.1)]'
|
: 'bg-red-900/20 border-red-500/50 text-red-400 shadow-[0_0_15px_rgba(239,68,68,0.1)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`absolute inset-0 opacity-10 ${active ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
<div className={`absolute inset-0 opacity-10 ${active ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||||
<span className="font-bold text-2xl mb-1">{active ? 'ON' : 'OFF'}</span>
|
<span className="font-bold text-2xl mb-1">{active ? 'ON' : 'OFF'}</span>
|
||||||
@@ -57,7 +55,7 @@ const MosfetSwitch: React.FC<{ label: string; active: boolean; onClick: () => vo
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ProtectionBadge: React.FC<{ label: string; active: boolean }> = ({ label, active }) => (
|
const ProtectionBadge: React.FC<{ label: string; active: boolean }> = ({ label, active }) => (
|
||||||
<div className={`px-2 py-1 rounded text-[10px] font-bold border transition-colors ${active ? 'bg-red-900/50 border-red-500 text-red-400 animate-pulse' : 'bg-gray-800/50 border-gray-700 text-gray-600'}`}>
|
<div className={`px-2 py-1 rounded text-[10px] font-bold border transition-colors ${active ? 'bg-red-100 border-red-500 text-red-600 animate-pulse' : 'bg-gray-100 border-gray-200 text-gray-500'}`}>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -71,7 +69,7 @@ const Dashboard: React.FC<DashboardProps> = ({ basicInfo, cellInfo, hwVersion, o
|
|||||||
// Initialize/Clear history on connect/disconnect
|
// Initialize/Clear history on connect/disconnect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cellInfo) {
|
if (!cellInfo) {
|
||||||
setVoltageHistory([]);
|
setVoltageHistory([]);
|
||||||
}
|
}
|
||||||
}, [cellInfo === null]);
|
}, [cellInfo === null]);
|
||||||
|
|
||||||
@@ -79,19 +77,19 @@ const Dashboard: React.FC<DashboardProps> = ({ basicInfo, cellInfo, hwVersion, o
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cellInfo) {
|
if (cellInfo) {
|
||||||
setVoltageHistory(prev => {
|
setVoltageHistory(prev => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const timeLabel = now.toLocaleTimeString('en-US', { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
const timeLabel = now.toLocaleTimeString('en-US', { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
|
||||||
const newEntry: any = {
|
const newEntry: any = {
|
||||||
time: timeLabel,
|
time: timeLabel,
|
||||||
...cellInfo.voltages.reduce((acc, v, i) => ({ ...acc, [`cell${i+1}`]: v }), {})
|
...cellInfo.voltages.reduce((acc, v, i) => ({ ...acc, [`cell${i + 1}`]: v }), {})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep approx 60 points (at ~0.5s interval -> ~30s history, user asked for ~1min so increase buffer)
|
// Keep approx 60 points (at ~0.5s interval -> ~30s history, user asked for ~1min so increase buffer)
|
||||||
// If polling is 500ms, 120 points = 60 seconds
|
// If polling is 500ms, 120 points = 60 seconds
|
||||||
const newHistory = [...prev, newEntry];
|
const newHistory = [...prev, newEntry];
|
||||||
if (newHistory.length > 120) return newHistory.slice(newHistory.length - 120);
|
if (newHistory.length > 120) return newHistory.slice(newHistory.length - 120);
|
||||||
return newHistory;
|
return newHistory;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [cellInfo]);
|
}, [cellInfo]);
|
||||||
@@ -185,14 +183,14 @@ const Dashboard: React.FC<DashboardProps> = ({ basicInfo, cellInfo, hwVersion, o
|
|||||||
value={basicInfo ? basicInfo.packVoltage.toFixed(2) : '-.--'}
|
value={basicInfo ? basicInfo.packVoltage.toFixed(2) : '-.--'}
|
||||||
unit="V"
|
unit="V"
|
||||||
icon={<Zap size={20} />}
|
icon={<Zap size={20} />}
|
||||||
color="text-yellow-400"
|
color="text-yellow-600"
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Current"
|
label="Current"
|
||||||
value={basicInfo ? basicInfo.current.toFixed(2) : '-.--'}
|
value={basicInfo ? basicInfo.current.toFixed(2) : '-.--'}
|
||||||
unit="A"
|
unit="A"
|
||||||
icon={<Activity size={20} />}
|
icon={<Activity size={20} />}
|
||||||
color={basicInfo && basicInfo.current > 0 ? "text-green-400" : basicInfo && basicInfo.current < 0 ? "text-red-400" : "text-gray-400"}
|
color={basicInfo && basicInfo.current > 0 ? "text-green-600" : basicInfo && basicInfo.current < 0 ? "text-red-600" : "text-gray-500"}
|
||||||
subValue={power !== 0 ? `${power.toFixed(0)} W` : undefined}
|
subValue={power !== 0 ? `${power.toFixed(0)} W` : undefined}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
@@ -201,9 +199,9 @@ const Dashboard: React.FC<DashboardProps> = ({ basicInfo, cellInfo, hwVersion, o
|
|||||||
unit="%"
|
unit="%"
|
||||||
icon={<Battery size={20} />}
|
icon={<Battery size={20} />}
|
||||||
// If Low SOC: Blink Red Icon, otherwise green/gray
|
// If Low SOC: Blink Red Icon, otherwise green/gray
|
||||||
color={isLowSoc ? "text-red-500 animate-pulse" : (basicInfo ? "text-green-400" : "text-gray-400")}
|
color={isLowSoc ? "text-red-600 animate-pulse" : (basicInfo ? "text-green-600" : "text-gray-500")}
|
||||||
// If Low SOC: Blink Red Value Text, otherwise default white
|
// If Low SOC: Blink Red Value Text, otherwise default white
|
||||||
valueClassName={isLowSoc ? "text-red-500 animate-pulse" : undefined}
|
valueClassName={isLowSoc ? "text-red-600 animate-pulse" : undefined}
|
||||||
subValue={basicInfo ? `${basicInfo.remainingCapacity.toFixed(2)} Ah` : undefined}
|
subValue={basicInfo ? `${basicInfo.remainingCapacity.toFixed(2)} Ah` : undefined}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
@@ -211,21 +209,21 @@ const Dashboard: React.FC<DashboardProps> = ({ basicInfo, cellInfo, hwVersion, o
|
|||||||
value={avgVoltage ? avgVoltage.toFixed(3) : '-.---'}
|
value={avgVoltage ? avgVoltage.toFixed(3) : '-.---'}
|
||||||
unit="V"
|
unit="V"
|
||||||
icon={<Gauge size={20} />}
|
icon={<Gauge size={20} />}
|
||||||
color="text-blue-400"
|
color="text-blue-600"
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Delta"
|
label="Delta"
|
||||||
value={deltaVoltage ? (deltaVoltage * 1000).toFixed(0) : '-'}
|
value={deltaVoltage ? (deltaVoltage * 1000).toFixed(0) : '-'}
|
||||||
unit="mV"
|
unit="mV"
|
||||||
icon={<AlertTriangle size={20} />}
|
icon={<AlertTriangle size={20} />}
|
||||||
color={deltaVoltage > 0.03 ? "text-red-400" : "text-emerald-400"}
|
color={deltaVoltage > 0.03 ? "text-red-600" : "text-emerald-600"}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Cycles"
|
label="Cycles"
|
||||||
value={basicInfo ? basicInfo.cycleCount.toString() : '-'}
|
value={basicInfo ? basicInfo.cycleCount.toString() : '-'}
|
||||||
unit=""
|
unit=""
|
||||||
icon={<RotateCw size={20} />}
|
icon={<RotateCw size={20} />}
|
||||||
color="text-purple-400"
|
color="text-purple-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -234,192 +232,191 @@ const Dashboard: React.FC<DashboardProps> = ({ basicInfo, cellInfo, hwVersion, o
|
|||||||
{/* Left Column: Charts & Status */}
|
{/* Left Column: Charts & Status */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
|
||||||
{/* Combined Cell Voltage Monitor */}
|
{/* Combined Cell Voltage Monitor */}
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 shadow-lg flex flex-col gap-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-lg flex flex-col gap-6">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h3 className="text-gray-200 font-bold flex items-center">
|
<h3 className="text-gray-800 font-bold flex items-center">
|
||||||
<Activity className="mr-2 text-blue-500" size={18}/>
|
<Activity className="mr-2 text-blue-500" size={18} />
|
||||||
Cell Monitor (Instant & Trend)
|
Cell Monitor (Instant & Trend)
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-4 text-xs font-mono bg-gray-950/50 px-3 py-1 rounded-lg border border-gray-800">
|
<div className="flex gap-4 text-xs font-mono bg-gray-50 px-3 py-1 rounded-lg border border-gray-200">
|
||||||
<span className="text-red-400 font-bold">Max: {maxVoltage.toFixed(3)}V</span>
|
<span className="text-red-400 font-bold">Max: {maxVoltage.toFixed(3)}V</span>
|
||||||
<div className="w-px bg-gray-700 mx-1"></div>
|
<div className="w-px bg-gray-300 mx-1"></div>
|
||||||
<span className="text-blue-400 font-bold">Min: {minVoltage.toFixed(3)}V</span>
|
<span className="text-blue-400 font-bold">Min: {minVoltage.toFixed(3)}V</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 1. Bar Chart (Instant) */}
|
|
||||||
<div className="h-40 w-full relative">
|
|
||||||
<div className="absolute top-0 left-0 text-[10px] text-gray-500 font-bold uppercase tracking-wider">Instant Voltage</div>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={cellChartData} margin={{ top: 15, right: 5, left: -20, bottom: 0 }}>
|
|
||||||
<XAxis dataKey="name" stroke="#4b5563" tick={{fontSize: 10}} />
|
|
||||||
<YAxis domain={[minVoltage > 0 ? minVoltage - 0.05 : 2.5, maxVoltage > 0 ? maxVoltage + 0.05 : 4.5]} stroke="#4b5563" tick={{fontSize: 10}} />
|
|
||||||
<Tooltip
|
|
||||||
cursor={{fill: 'rgba(255,255,255,0.05)'}}
|
|
||||||
contentStyle={{ backgroundColor: '#111827', borderColor: '#374151', color: '#f3f4f6' }}
|
|
||||||
itemStyle={{ color: '#93c5fd' }}
|
|
||||||
formatter={(value: number) => [value.toFixed(3) + ' V', 'Voltage']}
|
|
||||||
/>
|
|
||||||
<ReferenceLine y={avgVoltage} stroke="#10b981" strokeDasharray="3 3" />
|
|
||||||
<Bar dataKey="voltage">
|
|
||||||
{cellChartData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.isMax ? '#ef4444' : entry.isMin ? '#3b82f6' : '#6366f1'} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full h-px bg-gray-800"></div>
|
|
||||||
|
|
||||||
{/* 2. Line Chart (Trend) */}
|
|
||||||
<div className="h-48 w-full relative">
|
|
||||||
<div className="absolute top-0 left-0 text-[10px] text-gray-500 font-bold uppercase tracking-wider flex items-center gap-1">
|
|
||||||
<TrendingUp size={10} /> Voltage History (~1 min)
|
|
||||||
</div>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={voltageHistory} margin={{ top: 15, right: 10, left: -20, bottom: 0 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" vertical={false} />
|
|
||||||
{/* Hide XAxis ticks as requested */}
|
|
||||||
<XAxis dataKey="time" hide />
|
|
||||||
<YAxis domain={['auto', 'auto']} stroke="#6b7280" tick={{fontSize: 10}} width={40} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ backgroundColor: '#111827', borderColor: '#374151', color: '#f3f4f6' }}
|
|
||||||
itemStyle={{ padding: 0, fontSize: '11px' }}
|
|
||||||
labelStyle={{ color: '#9ca3af', marginBottom: '0.25rem', fontSize: '11px' }}
|
|
||||||
formatter={(value: number, name: string) => [value.toFixed(3) + ' V', name]}
|
|
||||||
/>
|
|
||||||
{/* Dynamically create thin lines for each cell */}
|
|
||||||
{Array.from({ length: cellCount }).map((_, i) => (
|
|
||||||
<Line
|
|
||||||
key={`line-${i}`}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={`cell${i+1}`}
|
|
||||||
stroke={COLORS[i % COLORS.length]}
|
|
||||||
dot={false}
|
|
||||||
strokeWidth={1}
|
|
||||||
isAnimationActive={false}
|
|
||||||
name={`C${i+1}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Protections & Temps */}
|
{/* 1. Bar Chart (Instant) */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="h-40 w-full relative">
|
||||||
{/* Protections */}
|
<div className="absolute top-0 left-0 text-[10px] text-gray-500 font-bold uppercase tracking-wider">Instant Voltage</div>
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 shadow-lg">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<h3 className="text-gray-300 font-bold text-sm mb-4 flex items-center">
|
<BarChart data={cellChartData} margin={{ top: 15, right: 5, left: -20, bottom: 0 }}>
|
||||||
<AlertTriangle className="mr-2 text-yellow-500" size={16}/>
|
<XAxis dataKey="name" stroke="#9ca3af" tick={{ fontSize: 10 }} />
|
||||||
Protection Status
|
<YAxis domain={[minVoltage > 0 ? minVoltage - 0.05 : 2.5, maxVoltage > 0 ? maxVoltage + 0.05 : 4.5]} stroke="#9ca3af" tick={{ fontSize: 10 }} />
|
||||||
</h3>
|
<Tooltip
|
||||||
<div className="grid grid-cols-4 gap-2">
|
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
|
||||||
{protectionList.map((p) => (
|
contentStyle={{ backgroundColor: '#ffffff', borderColor: '#e5e7eb', color: '#111827' }}
|
||||||
// @ts-ignore
|
itemStyle={{ color: '#3b82f6' }}
|
||||||
<ProtectionBadge key={p.key} label={p.label} active={protections[p.key]} />
|
formatter={(value: number) => [value.toFixed(3) + ' V', 'Voltage']}
|
||||||
))}
|
/>
|
||||||
</div>
|
<ReferenceLine y={avgVoltage} stroke="#10b981" strokeDasharray="3 3" />
|
||||||
</div>
|
<Bar dataKey="voltage">
|
||||||
|
{cellChartData.map((entry, index) => (
|
||||||
{/* Temperatures */}
|
<Cell key={`cell-${index}`} fill={entry.isMax ? '#ef4444' : entry.isMin ? '#3b82f6' : '#6366f1'} />
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 shadow-lg">
|
))}
|
||||||
<h3 className="text-gray-300 font-bold text-sm mb-4 flex items-center">
|
</Bar>
|
||||||
<Thermometer className="mr-2 text-orange-500" size={16}/>
|
</BarChart>
|
||||||
Temperatures
|
</ResponsiveContainer>
|
||||||
</h3>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
{basicInfo && basicInfo.ntcTemps.length > 0 ? basicInfo.ntcTemps.map((t, i) => (
|
|
||||||
<div key={i} className="flex flex-col items-center bg-gray-800/50 p-2 rounded-lg border border-gray-700 min-w-[60px]">
|
|
||||||
<span className="text-xs text-gray-500">NTC {i + 1}</span>
|
|
||||||
<span className="text-lg font-mono font-bold text-gray-200">{t.toFixed(1)}°C</span>
|
|
||||||
</div>
|
|
||||||
)) : (
|
|
||||||
// Placeholder
|
|
||||||
<div className="flex flex-col items-center bg-gray-800/30 p-2 rounded-lg border border-gray-800 min-w-[60px]">
|
|
||||||
<span className="text-xs text-gray-600">NTC 1</span>
|
|
||||||
<span className="text-lg font-mono font-bold text-gray-600">--.-°C</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-px bg-gray-200"></div>
|
||||||
|
|
||||||
|
{/* 2. Line Chart (Trend) */}
|
||||||
|
<div className="h-48 w-full relative">
|
||||||
|
<div className="absolute top-0 left-0 text-[10px] text-gray-500 font-bold uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<TrendingUp size={10} /> Voltage History (~1 min)
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={voltageHistory} margin={{ top: 15, right: 10, left: -20, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
|
||||||
|
{/* Hide XAxis ticks as requested */}
|
||||||
|
<XAxis dataKey="time" hide />
|
||||||
|
<YAxis domain={['auto', 'auto']} stroke="#9ca3af" tick={{ fontSize: 10 }} width={40} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#ffffff', borderColor: '#e5e7eb', color: '#111827' }}
|
||||||
|
itemStyle={{ padding: 0, fontSize: '11px' }}
|
||||||
|
labelStyle={{ color: '#9ca3af', marginBottom: '0.25rem', fontSize: '11px' }}
|
||||||
|
formatter={(value: number, name: string) => [value.toFixed(3) + ' V', name]}
|
||||||
|
/>
|
||||||
|
{/* Dynamically create thin lines for each cell */}
|
||||||
|
{Array.from({ length: cellCount }).map((_, i) => (
|
||||||
|
<Line
|
||||||
|
key={`line-${i}`}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`cell${i + 1}`}
|
||||||
|
stroke={COLORS[i % COLORS.length]}
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={1}
|
||||||
|
isAnimationActive={false}
|
||||||
|
name={`C${i + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Protections & Temps */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Protections */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-lg">
|
||||||
|
<h3 className="text-gray-700 font-bold text-sm mb-4 flex items-center">
|
||||||
|
<AlertTriangle className="mr-2 text-yellow-500" size={16} />
|
||||||
|
Protection Status
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{protectionList.map((p) => (
|
||||||
|
// @ts-ignore
|
||||||
|
<ProtectionBadge key={p.key} label={p.label} active={protections[p.key]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Temperatures */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-lg">
|
||||||
|
<h3 className="text-gray-700 font-bold text-sm mb-4 flex items-center">
|
||||||
|
<Thermometer className="mr-2 text-orange-500" size={16} />
|
||||||
|
Temperatures
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{basicInfo && basicInfo.ntcTemps.length > 0 ? basicInfo.ntcTemps.map((t, i) => (
|
||||||
|
<div key={i} className="flex flex-col items-center bg-gray-100 p-2 rounded-lg border border-gray-200 min-w-[60px]">
|
||||||
|
<span className="text-xs text-gray-500">NTC {i + 1}</span>
|
||||||
|
<span className="text-lg font-mono font-bold text-gray-800">{t.toFixed(1)}°C</span>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
// Placeholder
|
||||||
|
<div className="flex flex-col items-center bg-gray-100 p-2 rounded-lg border border-gray-200 min-w-[60px]">
|
||||||
|
<span className="text-xs text-gray-600">NTC 1</span>
|
||||||
|
<span className="text-lg font-mono font-bold text-gray-400">--.-°C</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Controls & Capacity */}
|
{/* Right Column: Controls & Capacity */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* MOSFET Control */}
|
{/* MOSFET Control */}
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 shadow-lg">
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-lg">
|
||||||
<h3 className="text-gray-200 font-bold mb-4 flex items-center">
|
<h3 className="text-gray-800 font-bold mb-4 flex items-center">
|
||||||
<Zap className="mr-2 text-purple-500" size={18}/>
|
<Zap className="mr-2 text-purple-500" size={18} />
|
||||||
MOSFET Control
|
MOSFET Control
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<MosfetSwitch
|
<MosfetSwitch
|
||||||
label="Charge"
|
label="Charge"
|
||||||
active={basicInfo?.mosfetStatus.charge ?? false}
|
active={basicInfo?.mosfetStatus.charge ?? false}
|
||||||
disabled={!basicInfo}
|
disabled={!basicInfo}
|
||||||
onClick={() => onToggleMosfet('charge', basicInfo?.mosfetStatus.charge ?? false)}
|
onClick={() => onToggleMosfet('charge', basicInfo?.mosfetStatus.charge ?? false)}
|
||||||
/>
|
/>
|
||||||
<MosfetSwitch
|
<MosfetSwitch
|
||||||
label="Discharge"
|
label="Discharge"
|
||||||
active={basicInfo?.mosfetStatus.discharge ?? false}
|
active={basicInfo?.mosfetStatus.discharge ?? false}
|
||||||
disabled={!basicInfo}
|
disabled={!basicInfo}
|
||||||
onClick={() => onToggleMosfet('discharge', basicInfo?.mosfetStatus.discharge ?? false)}
|
onClick={() => onToggleMosfet('discharge', basicInfo?.mosfetStatus.discharge ?? false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Capacity Info */}
|
{/* Capacity Info */}
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 shadow-lg">
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-lg">
|
||||||
<h3 className="text-gray-200 font-bold mb-4 flex items-center">
|
<h3 className="text-gray-800 font-bold mb-4 flex items-center">
|
||||||
<Battery className="mr-2 text-green-500" size={18}/>
|
<Battery className="mr-2 text-green-500" size={18} />
|
||||||
Capacity Info
|
Capacity Info
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center pb-2 border-b border-gray-800">
|
<div className="flex justify-between items-center pb-2 border-b border-gray-200">
|
||||||
<span className="text-gray-400 text-sm">Design Capacity</span>
|
<span className="text-gray-500 text-sm">Design Capacity</span>
|
||||||
<span className="font-mono">{basicInfo ? basicInfo.fullCapacity.toFixed(2) : '-.--'} Ah</span>
|
<span className="font-mono">{basicInfo ? basicInfo.fullCapacity.toFixed(2) : '-.--'} Ah</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center pb-2 border-b border-gray-800">
|
<div className="flex justify-between items-center pb-2 border-b border-gray-200">
|
||||||
<span className="text-gray-400 text-sm">Remaining Cap</span>
|
<span className="text-gray-500 text-sm">Remaining Cap</span>
|
||||||
<span className="font-mono text-green-400">{basicInfo ? basicInfo.remainingCapacity.toFixed(2) : '-.--'} Ah</span>
|
<span className="font-mono text-green-600">{basicInfo ? basicInfo.remainingCapacity.toFixed(2) : '-.--'} Ah</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center pb-2 border-b border-gray-800">
|
<div className="flex justify-between items-center pb-2 border-b border-gray-200">
|
||||||
<span className="text-gray-400 text-sm">Mfg Date</span>
|
<span className="text-gray-500 text-sm">Mfg Date</span>
|
||||||
<span className="font-mono text-xs">{basicInfo ? basicInfo.productionDate : 'YYYY-MM-DD'}</span>
|
<span className="font-mono text-xs">{basicInfo ? basicInfo.productionDate : 'YYYY-MM-DD'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-400 text-sm">Device Ver</span>
|
<span className="text-gray-500 text-sm">Device Ver</span>
|
||||||
<span className="font-mono text-xs text-blue-300">{hwVersion || '--'}</span>
|
<span className="font-mono text-xs text-blue-600">{hwVersion || '--'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Logger */}
|
{/* Logger */}
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 shadow-lg">
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-lg">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-gray-200 font-bold flex items-center">Data Logger</h3>
|
<h3 className="text-gray-800 font-bold flex items-center">Data Logger</h3>
|
||||||
{isRecording && <span className="text-xs text-red-400 animate-pulse">● REC ({logCount})</span>}
|
{isRecording && <span className="text-xs text-red-400 animate-pulse">● REC ({logCount})</span>}
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={toggleRecording}
|
|
||||||
disabled={!basicInfo}
|
|
||||||
className={`w-full py-3 rounded-xl flex items-center justify-center font-bold transition-all ${
|
|
||||||
!basicInfo ? 'bg-gray-800 text-gray-500 cursor-not-allowed' :
|
|
||||||
isRecording
|
|
||||||
? 'bg-red-600 hover:bg-red-500 text-white shadow-lg shadow-red-900/20'
|
|
||||||
: 'bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-900/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isRecording ? <><StopCircle size={18} className="mr-2"/> Stop & Save CSV</> : <><PlayCircle size={18} className="mr-2"/> Start Recording</>}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={toggleRecording}
|
||||||
|
disabled={!basicInfo}
|
||||||
|
className={`w-full py-3 rounded-xl flex items-center justify-center font-bold transition-all ${!basicInfo ? 'bg-gray-100 text-gray-400 cursor-not-allowed' :
|
||||||
|
isRecording
|
||||||
|
? 'bg-red-600 hover:bg-red-500 text-white shadow-lg shadow-red-900/20'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-900/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isRecording ? <><StopCircle size={18} className="mr-2" /> Stop & Save CSV</> : <><PlayCircle size={18} className="mr-2" /> Start Recording</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -163,9 +163,9 @@ const Settings: React.FC = () => {
|
|||||||
|
|
||||||
// 그룹 컴포넌트
|
// 그룹 컴포넌트
|
||||||
const Group: React.FC<{ title: string, children: React.ReactNode }> = ({ title, children }) => (
|
const Group: React.FC<{ title: string, children: React.ReactNode }> = ({ title, children }) => (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 shadow-xl overflow-hidden mb-6">
|
<div className="bg-white rounded-xl border border-gray-200 shadow-xl overflow-hidden mb-6">
|
||||||
<div className="bg-gray-800/50 px-5 py-3 border-b border-gray-700">
|
<div className="bg-gray-50 px-5 py-3 border-b border-gray-200">
|
||||||
<h3 className="text-lg font-bold text-gray-200 flex items-center gap-2">
|
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,10 +226,10 @@ const Settings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-3 bg-gray-950/50 border-b border-gray-800 last:border-0 hover:bg-gray-900 transition-colors">
|
<div className="flex items-center justify-between p-3 bg-white border-b border-gray-200 last:border-0 hover:bg-gray-50 transition-colors">
|
||||||
<div className="flex-1 pr-4">
|
<div className="flex-1 pr-4">
|
||||||
<div className="text-sm text-gray-300 font-medium">{label}</div>
|
<div className="text-sm text-gray-700 font-medium">{label}</div>
|
||||||
<div className="text-xs text-gray-600 font-mono">ADDR: 0x{reg.toString(16).toUpperCase().padStart(2, '0')}</div>
|
<div className="text-xs text-gray-400 font-mono">ADDR: 0x{reg.toString(16).toUpperCase().padStart(2, '0')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -240,16 +240,16 @@ const Settings: React.FC = () => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={val === undefined ? "?" : ""}
|
placeholder={val === undefined ? "?" : ""}
|
||||||
disabled={isBusy || globalLoading}
|
disabled={isBusy || globalLoading}
|
||||||
className={`bg-gray-900 border ${isDirty ? 'border-yellow-600' : 'border-gray-700'} text-gray-100 text-right px-3 py-1.5 rounded focus:outline-none focus:border-blue-500 font-mono h-9 ${type === 'string' ? 'w-40' : 'w-28'}`}
|
className={`bg-white border ${isDirty ? 'border-yellow-600' : 'border-gray-300'} text-gray-900 text-right px-3 py-1.5 rounded focus:outline-none focus:border-blue-500 font-mono h-9 ${type === 'string' ? 'w-40' : 'w-28'}`}
|
||||||
/>
|
/>
|
||||||
{unit && <span className="absolute right-2 top-2 text-gray-500 text-xs pointer-events-none opacity-50">{type !== 'date' ? unit : ''}</span>}
|
{unit && <span className="absolute right-2 top-2 text-gray-400 text-xs pointer-events-none opacity-50">{type !== 'date' ? unit : ''}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onRead}
|
onClick={onRead}
|
||||||
disabled={isBusy || globalLoading}
|
disabled={isBusy || globalLoading}
|
||||||
title="개별 읽기"
|
title="개별 읽기"
|
||||||
className="p-2 bg-gray-800 text-gray-400 hover:text-blue-400 hover:bg-gray-700 rounded-lg transition-all disabled:opacity-50"
|
className="p-2 bg-gray-100 text-gray-500 hover:text-blue-600 hover:bg-gray-200 rounded-lg transition-all disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<RotateCw size={16} className={isBusy ? 'animate-spin text-blue-500' : ''} />
|
<RotateCw size={16} className={isBusy ? 'animate-spin text-blue-500' : ''} />
|
||||||
</button>
|
</button>
|
||||||
@@ -258,7 +258,7 @@ const Settings: React.FC = () => {
|
|||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={isBusy || globalLoading}
|
disabled={isBusy || globalLoading}
|
||||||
title="개별 저장"
|
title="개별 저장"
|
||||||
className={`p-2 rounded-lg transition-all disabled:opacity-50 ${isDirty ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-gray-800 text-gray-500 hover:text-gray-300'}`}
|
className={`p-2 rounded-lg transition-all disabled:opacity-50 ${isDirty ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-gray-100 text-gray-500 hover:text-gray-700'}`}
|
||||||
>
|
>
|
||||||
<Save size={16} />
|
<Save size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -270,10 +270,10 @@ const Settings: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 pb-24 overflow-y-auto h-full max-w-6xl mx-auto">
|
<div className="p-6 pb-24 overflow-y-auto h-full max-w-6xl mx-auto">
|
||||||
{/* 상단 툴바 */}
|
{/* 상단 툴바 */}
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center mb-6 gap-4 sticky top-0 bg-gray-950/90 backdrop-blur z-20 py-4 border-b border-gray-800">
|
<div className="flex flex-col md:flex-row justify-between items-center mb-6 gap-4 sticky top-0 bg-white/90 backdrop-blur z-20 py-4 border-b border-gray-200">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-200">EEPROM 설정</h2>
|
<h2 className="text-2xl font-bold text-gray-800">EEPROM 설정</h2>
|
||||||
<p className="text-gray-400 text-sm">BMS 내부 파라미터를 개별적으로 읽거나 수정할 수 있습니다.</p>
|
<p className="text-gray-500 text-sm">BMS 내부 파라미터를 개별적으로 읽거나 수정할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const Terminal: React.FC = () => {
|
|||||||
const newLogs = [newLog, ...prev];
|
const newLogs = [newLog, ...prev];
|
||||||
|
|
||||||
if (newLogs.length > MAX_LOGS) {
|
if (newLogs.length > MAX_LOGS) {
|
||||||
return newLogs.slice(0, MAX_LOGS);
|
return newLogs.slice(0, MAX_LOGS);
|
||||||
}
|
}
|
||||||
return newLogs;
|
return newLogs;
|
||||||
});
|
});
|
||||||
@@ -57,75 +57,75 @@ const Terminal: React.FC = () => {
|
|||||||
const hexStr = input.replace(/\s+/g, '');
|
const hexStr = input.replace(/\s+/g, '');
|
||||||
if (!/^[0-9A-Fa-f]+$/.test(hexStr) || hexStr.length % 2 !== 0) {
|
if (!/^[0-9A-Fa-f]+$/.test(hexStr) || hexStr.length % 2 !== 0) {
|
||||||
// Local error log (prepended)
|
// Local error log (prepended)
|
||||||
setLogs(prev => [{
|
setLogs(prev => [{
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
timestamp: new Date().toLocaleTimeString(),
|
timestamp: new Date().toLocaleTimeString(),
|
||||||
data: '유효하지 않은 HEX 포맷입니다.'
|
data: '유효하지 않은 HEX 포맷입니다.'
|
||||||
}, ...prev]);
|
}, ...prev]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = new Uint8Array(hexStr.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
|
const bytes = new Uint8Array(hexStr.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await serialService.sendRaw(bytes);
|
await serialService.sendRaw(bytes);
|
||||||
// readRaw also triggers callback inside service
|
// readRaw also triggers callback inside service
|
||||||
await serialService.readRaw();
|
await serialService.readRaw();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Errors from service are logged via callback usually
|
// Errors from service are logged via callback usually
|
||||||
}
|
}
|
||||||
setInput('');
|
setInput('');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-gray-950/50">
|
<div className="flex flex-col h-full bg-gray-50">
|
||||||
<div className="flex justify-between items-center p-3 border-b border-gray-800 bg-gray-900/50">
|
<div className="flex justify-between items-center p-3 border-b border-gray-200 bg-white">
|
||||||
<div className="flex items-center gap-2 overflow-hidden">
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
<TerminalIcon size={18} className="text-gray-400 shrink-0" />
|
<TerminalIcon size={18} className="text-gray-500 shrink-0" />
|
||||||
<span className="font-semibold text-gray-300 truncate">터미널 / Logs</span>
|
<span className="font-semibold text-gray-700 truncate">Terminal / Logs</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setAutoScroll(!autoScroll)}
|
onClick={() => setAutoScroll(!autoScroll)}
|
||||||
className={`p-1.5 rounded transition-colors ${autoScroll ? 'text-green-400 bg-green-900/20' : 'text-gray-500 hover:text-gray-300'}`}
|
className={`p-1.5 rounded transition-colors ${autoScroll ? 'text-green-600 bg-green-100' : 'text-gray-400 hover:text-gray-600'}`}
|
||||||
title={autoScroll ? "Auto-scroll: ON (Top)" : "Auto-scroll: OFF"}
|
title={autoScroll ? "Auto-scroll: ON (Top)" : "Auto-scroll: OFF"}
|
||||||
>
|
>
|
||||||
{/* Icon indicates scroll direction focus */}
|
{/* Icon indicates scroll direction focus */}
|
||||||
<ArrowUp size={16} className={autoScroll ? "" : "opacity-50"} />
|
<ArrowUp size={16} className={autoScroll ? "" : "opacity-50"} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setLogs([])} className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-900/20 rounded transition-colors" title="Clear Logs">
|
<button onClick={() => setLogs([])} className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-100 rounded transition-colors" title="Clear Logs">
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 bg-black/50 overflow-hidden flex flex-col font-mono text-xs shadow-inner">
|
<div className="flex-1 bg-white overflow-hidden flex flex-col font-mono text-xs shadow-inner">
|
||||||
{/* Input Area (Top) or Bottom? Keeping input at bottom is standard, logs flow down from top */}
|
{/* Input Area (Top) or Bottom? Keeping input at bottom is standard, logs flow down from top */}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={logsContainerRef}
|
ref={logsContainerRef}
|
||||||
className="flex-1 overflow-y-auto p-2 space-y-1 scroll-smooth"
|
className="flex-1 overflow-y-auto p-2 space-y-1 scroll-smooth"
|
||||||
>
|
>
|
||||||
{logs.length === 0 && <div className="text-gray-700 text-center mt-4 italic">대기 중... (Logs)</div>}
|
{logs.length === 0 && <div className="text-gray-500 text-center mt-4 italic">대기 중... (Logs)</div>}
|
||||||
|
|
||||||
{logs.map(log => (
|
{logs.map(log => (
|
||||||
<div key={log.id} className="flex gap-2 animate-in fade-in slide-in-from-top-1 hover:bg-gray-900/30 p-0.5 rounded border-b border-gray-900/50 last:border-0">
|
<div key={log.id} className="flex gap-2 animate-in fade-in slide-in-from-top-1 hover:bg-gray-100 p-0.5 rounded border-b border-gray-100 last:border-0">
|
||||||
<span className="text-gray-600 shrink-0 select-none">[{log.timestamp}]</span>
|
<span className="text-gray-500 shrink-0 select-none">[{log.timestamp}]</span>
|
||||||
<div className="break-all flex-1">
|
<div className="break-all flex-1">
|
||||||
{log.type === 'tx' && <span className="text-blue-400 font-bold mr-1">TX</span>}
|
{log.type === 'tx' && <span className="text-blue-600 font-bold mr-1">TX</span>}
|
||||||
{log.type === 'rx' && <span className="text-green-400 font-bold mr-1">RX</span>}
|
{log.type === 'rx' && <span className="text-green-600 font-bold mr-1">RX</span>}
|
||||||
{log.type === 'error' && <span className="text-red-500 font-bold mr-1">ERR</span>}
|
{log.type === 'error' && <span className="text-red-500 font-bold mr-1">ERR</span>}
|
||||||
{log.type === 'info' && <span className="text-gray-400 font-bold mr-1">INF</span>}
|
{log.type === 'info' && <span className="text-gray-500 font-bold mr-1">INF</span>}
|
||||||
<span className={`${log.type === 'error' ? 'text-red-400' : 'text-gray-300'}`}>
|
<span className={`${log.type === 'error' ? 'text-red-600' : 'text-gray-800'}`}>
|
||||||
{log.data}
|
{log.data}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-2 border-t border-gray-800 bg-gray-900/30">
|
<div className="p-2 border-t border-gray-200 bg-gray-50">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -133,7 +133,7 @@ const Terminal: React.FC = () => {
|
|||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||||
placeholder="HEX (e.g. DD A5 03...)"
|
placeholder="HEX (e.g. DD A5 03...)"
|
||||||
className="flex-1 bg-gray-950 border border-gray-700 rounded px-2 py-1.5 text-gray-200 focus:outline-none focus:border-blue-500 font-mono text-xs"
|
className="flex-1 bg-white border border-gray-300 rounded px-2 py-1.5 text-gray-900 focus:outline-none focus:border-blue-500 font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
|
|||||||
Reference in New Issue
Block a user