EV 충전 플랫폼 초기 백업
This commit is contained in:
419
dashboard.html
Normal file
419
dashboard.html
Normal file
@@ -0,0 +1,419 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EV 충전 관리 대시보드</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.7/chart.umd.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-0:#06090f;--bg-1:#0c1018;--bg-2:#121824;--bg-3:#1a2236;
|
||||
--bg-card:rgba(18,24,36,0.85);
|
||||
--accent:#00d4ff;--accent-dim:rgba(0,212,255,0.12);--accent-glow:rgba(0,212,255,0.25);
|
||||
--green:#10b981;--green-dim:rgba(16,185,129,0.12);
|
||||
--amber:#f59e0b;--amber-dim:rgba(245,158,11,0.12);
|
||||
--red:#ef4444;--red-dim:rgba(239,68,68,0.12);
|
||||
--purple:#8b5cf6;--purple-dim:rgba(139,92,246,0.12);
|
||||
--text:#e2e8f0;--text-2:#94a3b8;--text-3:#64748b;
|
||||
--border:rgba(255,255,255,0.06);--border-accent:rgba(0,212,255,0.15);
|
||||
--radius:12px;--radius-sm:8px;
|
||||
--font-display:'Outfit',sans-serif;--font-body:'Noto Sans KR',sans-serif;--font-mono:'JetBrains Mono',monospace;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg-0);color:var(--text);font-family:var(--font-body);font-weight:400;line-height:1.6;min-height:100vh}
|
||||
body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse 60% 40% at 15% 5%,rgba(0,212,255,0.04) 0%,transparent 60%),radial-gradient(ellipse 50% 30% at 85% 90%,rgba(139,92,246,0.03) 0%,transparent 60%);pointer-events:none}
|
||||
|
||||
/* ── 로그인 ── */
|
||||
.login-wrap{display:flex;align-items:center;justify-content:center;min-height:100vh;position:relative;z-index:1}
|
||||
.login-card{background:var(--bg-card);border:1px solid var(--border-accent);border-radius:var(--radius);padding:40px;width:360px;backdrop-filter:blur(16px);position:relative;overflow:hidden}
|
||||
.login-card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--accent),transparent)}
|
||||
.login-brand{text-align:center;margin-bottom:32px}
|
||||
.login-brand h1{font-family:var(--font-display);font-size:24px;font-weight:700;color:#fff;letter-spacing:-0.02em}
|
||||
.login-brand small{font-family:var(--font-mono);font-size:10px;color:var(--accent);letter-spacing:0.2em;text-transform:uppercase}
|
||||
.form-group{margin-bottom:18px}
|
||||
.form-label{display:block;font-size:12px;color:var(--text-3);margin-bottom:6px;font-weight:500}
|
||||
.form-input{width:100%;padding:10px 14px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:14px;font-family:var(--font-body);outline:none;transition:border-color 0.15s}
|
||||
.form-input:focus{border-color:var(--accent)}
|
||||
.login-btn{width:100%;padding:12px;background:linear-gradient(135deg,rgba(0,212,255,0.2),rgba(139,92,246,0.15));border:1px solid var(--accent);border-radius:var(--radius-sm);color:var(--accent);font-family:var(--font-display);font-size:14px;font-weight:600;cursor:pointer;transition:all 0.2s;margin-top:8px}
|
||||
.login-btn:hover{background:rgba(0,212,255,0.15);box-shadow:0 0 20px rgba(0,212,255,0.1)}
|
||||
.login-error{color:var(--red);font-size:12px;text-align:center;margin-top:12px;min-height:18px}
|
||||
|
||||
/* ── 레이아웃 ── */
|
||||
.shell{display:grid;grid-template-columns:220px 1fr;min-height:100vh}
|
||||
.sidebar{background:var(--bg-1);border-right:1px solid var(--border);padding:28px 0;display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
|
||||
.sidebar-brand{padding:0 24px 28px;border-bottom:1px solid var(--border);margin-bottom:20px}
|
||||
.sidebar-brand h1{font-family:var(--font-display);font-size:18px;font-weight:700;color:#fff;letter-spacing:-0.02em;line-height:1.2}
|
||||
.sidebar-brand small{font-family:var(--font-mono);font-size:10px;color:var(--accent);letter-spacing:0.15em;text-transform:uppercase;display:block;margin-top:4px}
|
||||
.nav-section{padding:0 12px;margin-bottom:8px}
|
||||
.nav-section-label{font-family:var(--font-mono);font-size:9px;letter-spacing:0.2em;color:var(--text-3);text-transform:uppercase;padding:8px 12px 4px}
|
||||
.nav-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:var(--radius-sm);color:var(--text-2);font-size:13px;cursor:pointer;transition:all 0.15s;border:1px solid transparent}
|
||||
.nav-item:hover{background:var(--bg-2);color:var(--text)}
|
||||
.nav-item.active{background:var(--accent-dim);color:var(--accent);border-color:var(--border-accent)}
|
||||
.nav-icon{width:18px;height:18px;opacity:0.7}
|
||||
.nav-item.active .nav-icon{opacity:1}
|
||||
.sidebar-footer{margin-top:auto;padding:16px 24px;border-top:1px solid var(--border)}
|
||||
.sidebar-user{font-size:12px;color:var(--text-2);margin-bottom:8px}
|
||||
.sidebar-user strong{color:#fff;font-weight:500}
|
||||
.sidebar-user .role-tag{font-family:var(--font-mono);font-size:9px;padding:2px 6px;border-radius:3px;margin-left:4px;background:var(--accent-dim);color:var(--accent)}
|
||||
.logout-btn{display:flex;align-items:center;gap:6px;padding:8px 0;color:var(--text-3);font-size:12px;cursor:pointer;transition:color 0.15s;background:none;border:none;font-family:var(--font-body)}
|
||||
.logout-btn:hover{color:var(--red)}
|
||||
.live-badge{display:inline-flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:10px;color:var(--green);letter-spacing:0.05em;margin-top:8px}
|
||||
.live-dot{width:6px;height:6px;background:var(--green);border-radius:50%;animation:pulse 2s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(16,185,129,0.4)}50%{opacity:0.6;box-shadow:0 0 0 4px rgba(16,185,129,0)}}
|
||||
|
||||
/* ── 메인 ── */
|
||||
.main{padding:32px;max-width:1200px}
|
||||
.page-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:32px}
|
||||
.page-title{font-family:var(--font-display);font-size:26px;font-weight:600;color:#fff;letter-spacing:-0.02em}
|
||||
.page-sub{font-size:13px;color:var(--text-3);margin-top:4px}
|
||||
.header-actions{display:flex;align-items:center;gap:12px}
|
||||
.refresh-btn{display:flex;align-items:center;gap:6px;padding:8px 14px;background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-2);font-family:var(--font-mono);font-size:11px;cursor:pointer;transition:all 0.15s}
|
||||
.refresh-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.refresh-btn.loading svg{animation:spin 0.8s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.last-update{font-family:var(--font-mono);font-size:11px;color:var(--text-3)}
|
||||
|
||||
/* ── 카드 ── */
|
||||
.summary-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:28px}
|
||||
.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;backdrop-filter:blur(12px);position:relative;overflow:hidden;transition:border-color 0.2s}
|
||||
.stat-card:hover{border-color:var(--border-accent)}
|
||||
.stat-card::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--card-accent,var(--accent)),transparent);opacity:0.4}
|
||||
.stat-icon{width:36px;height:36px;border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;margin-bottom:14px;font-size:16px}
|
||||
.stat-card[data-color="cyan"] .stat-icon{background:var(--accent-dim)}
|
||||
.stat-card[data-color="green"] .stat-icon{background:var(--green-dim)}
|
||||
.stat-card[data-color="amber"] .stat-icon{background:var(--amber-dim)}
|
||||
.stat-card[data-color="purple"] .stat-icon{background:var(--purple-dim)}
|
||||
.stat-card[data-color="cyan"]{--card-accent:var(--accent)}
|
||||
.stat-card[data-color="green"]{--card-accent:var(--green)}
|
||||
.stat-card[data-color="amber"]{--card-accent:var(--amber)}
|
||||
.stat-card[data-color="purple"]{--card-accent:var(--purple)}
|
||||
.stat-label{font-size:12px;color:var(--text-3);margin-bottom:6px;font-weight:500}
|
||||
.stat-value{font-family:var(--font-display);font-size:28px;font-weight:700;color:#fff;letter-spacing:-0.03em;line-height:1}
|
||||
.stat-unit{font-family:var(--font-mono);font-size:12px;color:var(--text-3);margin-left:4px;font-weight:400}
|
||||
.stat-sub{font-family:var(--font-mono);font-size:11px;color:var(--text-3);margin-top:8px}
|
||||
.stat-sub .hi{color:var(--green)}
|
||||
|
||||
/* ── 패널 ── */
|
||||
.content-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:28px}
|
||||
.content-grid.full{grid-template-columns:1fr}
|
||||
.panel{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);backdrop-filter:blur(12px);overflow:hidden}
|
||||
.panel-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border)}
|
||||
.panel-title{font-family:var(--font-display);font-size:14px;font-weight:600;color:#fff}
|
||||
.panel-badge{font-family:var(--font-mono);font-size:10px;padding:3px 8px;border-radius:4px;letter-spacing:0.05em}
|
||||
.panel-body{padding:20px}
|
||||
.chart-wrap{height:240px;position:relative}
|
||||
|
||||
/* ── 충전기 ── */
|
||||
.charger-list{display:flex;flex-direction:column;gap:10px}
|
||||
.charger-row{display:flex;align-items:center;gap:14px;padding:14px 16px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius-sm);transition:border-color 0.15s}
|
||||
.charger-row:hover{border-color:var(--border-accent)}
|
||||
.charger-status-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
||||
.charger-status-dot.available{background:var(--green);box-shadow:0 0 8px rgba(16,185,129,0.4)}
|
||||
.charger-status-dot.charging{background:var(--accent);box-shadow:0 0 8px rgba(0,212,255,0.4);animation:pulse 1.5s infinite}
|
||||
.charger-status-dot.faulted{background:var(--red);box-shadow:0 0 8px rgba(239,68,68,0.4)}
|
||||
.charger-status-dot.unavailable{background:var(--text-3)}
|
||||
.charger-info{flex:1;min-width:0}
|
||||
.charger-name{font-size:13px;font-weight:500;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.charger-id{font-family:var(--font-mono);font-size:11px;color:var(--text-3)}
|
||||
.charger-badge{font-family:var(--font-mono);font-size:10px;padding:3px 10px;border-radius:4px;font-weight:500;white-space:nowrap}
|
||||
.charger-badge.available{background:var(--green-dim);color:var(--green)}
|
||||
.charger-badge.charging{background:var(--accent-dim);color:var(--accent)}
|
||||
.charger-badge.faulted{background:var(--red-dim);color:var(--red)}
|
||||
.charger-badge.unavailable{background:rgba(100,116,139,0.15);color:var(--text-3)}
|
||||
.charger-meta{font-family:var(--font-mono);font-size:11px;color:var(--text-3);text-align:right;white-space:nowrap}
|
||||
|
||||
/* ── 테이블 ── */
|
||||
.sessions-table{width:100%;border-collapse:collapse;font-size:12px}
|
||||
.sessions-table th{text-align:left;padding:10px 14px;font-family:var(--font-mono);font-size:10px;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-3);border-bottom:1px solid var(--border);font-weight:500}
|
||||
.sessions-table td{padding:12px 14px;color:var(--text-2);border-bottom:1px solid var(--border);vertical-align:middle}
|
||||
.sessions-table tr:last-child td{border-bottom:none}
|
||||
.sessions-table tr:hover td{background:rgba(0,212,255,0.02)}
|
||||
.sessions-table td:first-child{font-family:var(--font-mono);color:var(--text);font-size:11px}
|
||||
.status-pill{display:inline-block;font-family:var(--font-mono);font-size:10px;padding:2px 8px;border-radius:4px;font-weight:500}
|
||||
.status-pill.completed{background:var(--green-dim);color:var(--green)}
|
||||
.status-pill.charging{background:var(--accent-dim);color:var(--accent)}
|
||||
.status-pill.pending{background:var(--amber-dim);color:var(--amber)}
|
||||
.status-pill.failed{background:var(--red-dim);color:var(--red)}
|
||||
.status-pill.cancelled{background:rgba(100,116,139,0.15);color:var(--text-3)}
|
||||
.status-pill.authorized{background:var(--purple-dim);color:var(--purple)}
|
||||
.amount-cell{font-family:var(--font-mono);font-weight:500;color:#fff}
|
||||
|
||||
/* ── 요금 ── */
|
||||
.rate-bar{display:flex;height:32px;border-radius:6px;overflow:hidden;margin:12px 0}
|
||||
.rate-bar-seg{display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-size:10px;font-weight:500}
|
||||
.rate-legend{display:flex;gap:20px;margin-top:10px}
|
||||
.rate-legend-item{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-2)}
|
||||
.rate-legend-dot{width:8px;height:8px;border-radius:2px}
|
||||
.compare-row{display:flex;gap:16px;margin-top:16px}
|
||||
.compare-card{flex:1;padding:16px;border-radius:var(--radius-sm);text-align:center}
|
||||
.compare-card.ours{background:var(--green-dim);border:1px solid rgba(16,185,129,0.2)}
|
||||
.compare-card.cpo{background:rgba(100,116,139,0.08);border:1px solid var(--border)}
|
||||
.compare-label{font-size:11px;color:var(--text-3);margin-bottom:4px}
|
||||
.compare-value{font-family:var(--font-display);font-size:22px;font-weight:700;line-height:1.2}
|
||||
.compare-card.ours .compare-value{color:var(--green)}
|
||||
.compare-card.cpo .compare-value{color:var(--text-3);text-decoration:line-through}
|
||||
.compare-unit{font-family:var(--font-mono);font-size:11px;font-weight:400}
|
||||
.savings-banner{text-align:center;padding:12px;background:linear-gradient(135deg,var(--green-dim),rgba(0,212,255,0.06));border:1px solid rgba(16,185,129,0.15);border-radius:var(--radius-sm);margin-top:12px;font-family:var(--font-mono);font-size:12px;color:var(--green)}
|
||||
|
||||
/* ── 사용자 관리 ── */
|
||||
.user-row{display:flex;align-items:center;gap:14px;padding:14px 16px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius-sm);margin-bottom:10px}
|
||||
.user-avatar{width:36px;height:36px;border-radius:50%;background:var(--accent-dim);display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-size:14px;font-weight:600;color:var(--accent);flex-shrink:0}
|
||||
.user-info{flex:1}
|
||||
.user-name{font-size:13px;font-weight:500;color:#fff}
|
||||
.user-meta{font-family:var(--font-mono);font-size:11px;color:var(--text-3)}
|
||||
.role-badge{font-family:var(--font-mono);font-size:10px;padding:3px 10px;border-radius:4px;font-weight:500}
|
||||
.role-badge.admin{background:var(--red-dim);color:var(--red)}
|
||||
.role-badge.operator{background:var(--amber-dim);color:var(--amber)}
|
||||
.role-badge.viewer{background:var(--accent-dim);color:var(--accent)}
|
||||
.user-actions{display:flex;gap:8px}
|
||||
.btn-sm{padding:6px 12px;border-radius:var(--radius-sm);font-size:11px;font-family:var(--font-mono);cursor:pointer;border:1px solid var(--border);background:var(--bg-2);color:var(--text-2);transition:all 0.15s}
|
||||
.btn-sm:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.btn-sm.danger:hover{border-color:var(--red);color:var(--red)}
|
||||
.btn-primary{padding:10px 20px;background:var(--accent-dim);border:1px solid var(--accent);border-radius:var(--radius-sm);color:var(--accent);font-family:var(--font-display);font-size:13px;font-weight:600;cursor:pointer;transition:all 0.15s}
|
||||
.btn-primary:hover{background:rgba(0,212,255,0.2)}
|
||||
|
||||
/* ── 모달 ── */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:200;backdrop-filter:blur(4px)}
|
||||
.modal{background:var(--bg-2);border:1px solid var(--border-accent);border-radius:var(--radius);padding:28px;width:400px;position:relative}
|
||||
.modal h3{font-family:var(--font-display);font-size:18px;font-weight:600;color:#fff;margin-bottom:20px}
|
||||
.modal .form-group{margin-bottom:14px}
|
||||
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
|
||||
.btn-cancel{padding:8px 16px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-2);font-size:12px;cursor:pointer}
|
||||
|
||||
.empty-state{text-align:center;padding:40px 20px;color:var(--text-3);font-size:13px}
|
||||
.empty-icon{font-size:32px;margin-bottom:12px;opacity:0.3}
|
||||
|
||||
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
|
||||
.fade-in{animation:fadeUp 0.4s ease-out forwards;opacity:0}
|
||||
.fade-in:nth-child(1){animation-delay:0.05s}.fade-in:nth-child(2){animation-delay:0.1s}.fade-in:nth-child(3){animation-delay:0.15s}.fade-in:nth-child(4){animation-delay:0.2s}
|
||||
.hidden{display:none!important}
|
||||
|
||||
@media(max-width:1024px){.summary-grid{grid-template-columns:repeat(2,1fr)}.content-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:768px){.shell{grid-template-columns:1fr}.sidebar{display:none}.main{padding:20px}.summary-grid{grid-template-columns:1fr 1fr}.stat-value{font-size:22px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 로그인 화면 -->
|
||||
<div id="login-screen" class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<h1>EV Charging</h1>
|
||||
<small>control panel</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">아이디</label>
|
||||
<input class="form-input" id="login-user" type="text" placeholder="admin" autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">비밀번호</label>
|
||||
<input class="form-input" id="login-pass" type="password" placeholder="비밀번호 입력" autocomplete="current-password">
|
||||
</div>
|
||||
<button class="login-btn" onclick="doLogin()">로그인</button>
|
||||
<div class="login-error" id="login-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 대시보드 (로그인 후) -->
|
||||
<div id="app-shell" class="shell hidden">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand"><h1>EV Charging</h1><small>control panel</small></div>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-label">모니터링</div>
|
||||
<div class="nav-item active" data-page="dashboard" onclick="navTo(this)">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
|
||||
대시보드</div>
|
||||
<div class="nav-item" data-page="chargers" onclick="navTo(this)">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
충전기 현황</div>
|
||||
<div class="nav-item" data-page="sessions" onclick="navTo(this)">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 8v4l3 3"/><circle cx="12" cy="12" r="9"/></svg>
|
||||
충전 이력</div>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-label">관리</div>
|
||||
<div class="nav-item" data-page="billing" onclick="navTo(this)">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>
|
||||
요금 / 정산</div>
|
||||
<div class="nav-item" data-page="users" onclick="navTo(this)" id="nav-users">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
||||
사용자 관리</div>
|
||||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-user" id="sidebar-user"></div>
|
||||
<button class="logout-btn" onclick="doLogout()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
로그아웃</button>
|
||||
<div class="live-badge"><span class="live-dot"></span><span id="server-status">연결됨</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main" id="main-content"></main>
|
||||
</div>
|
||||
|
||||
<!-- 모달 컨테이너 -->
|
||||
<div id="modal-root"></div>
|
||||
|
||||
<script>
|
||||
const API='/api/v1';
|
||||
let token=null,currentUser=null,chartInstance=null,refreshTimer=null;
|
||||
let state={summary:null,chargers:[],sessions:[],dailyStats:[],users:[],loading:true,page:'dashboard'};
|
||||
|
||||
// ── 인증 ──
|
||||
function saveAuth(t,u){token=t;currentUser=u;try{sessionStorage.setItem('ev_token',t);sessionStorage.setItem('ev_user',JSON.stringify(u))}catch(e){}}
|
||||
function loadAuth(){try{token=sessionStorage.getItem('ev_token');const u=sessionStorage.getItem('ev_user');if(u)currentUser=JSON.parse(u)}catch(e){}}
|
||||
function clearAuth(){token=null;currentUser=null;try{sessionStorage.removeItem('ev_token');sessionStorage.removeItem('ev_user')}catch(e){}}
|
||||
|
||||
async function doLogin(){
|
||||
const u=document.getElementById('login-user').value.trim();
|
||||
const p=document.getElementById('login-pass').value;
|
||||
const err=document.getElementById('login-error');
|
||||
if(!u||!p){err.textContent='아이디와 비밀번호를 입력하세요';return}
|
||||
err.textContent='';
|
||||
try{
|
||||
const r=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});
|
||||
const d=await r.json();
|
||||
if(!r.ok){err.textContent=d.detail||'로그인 실패';return}
|
||||
saveAuth(d.access_token,d.user);
|
||||
enterApp();
|
||||
}catch(e){err.textContent='서버 연결 실패'}
|
||||
}
|
||||
|
||||
function doLogout(){clearAuth();document.getElementById('app-shell').classList.add('hidden');document.getElementById('login-screen').classList.remove('hidden');document.getElementById('login-pass').value=''}
|
||||
|
||||
function enterApp(){
|
||||
document.getElementById('login-screen').classList.add('hidden');
|
||||
document.getElementById('app-shell').classList.remove('hidden');
|
||||
const su=document.getElementById('sidebar-user');
|
||||
su.innerHTML=`<strong>${currentUser.display_name||currentUser.username}</strong><span class="role-tag">${currentUser.role}</span>`;
|
||||
// 관리자만 사용자관리 표시
|
||||
document.getElementById('nav-users').style.display=currentUser.role==='admin'?'flex':'none';
|
||||
loadData();
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
// Enter 키 로그인
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Enter'&&!document.getElementById('login-screen').classList.contains('hidden'))doLogin()});
|
||||
|
||||
// ── API ──
|
||||
async function api(path,opt={}){
|
||||
try{
|
||||
const headers={'Content-Type':'application/json',...(opt.headers||{})};
|
||||
if(token)headers['Authorization']='Bearer '+token;
|
||||
const r=await fetch(API+path,{...opt,headers});
|
||||
if(r.status===401){doLogout();return null}
|
||||
if(!r.ok){const d=await r.json().catch(()=>({}));throw d}
|
||||
return await r.json();
|
||||
}catch(e){if(e.detail)throw e;console.error('API:',path,e);return null}
|
||||
}
|
||||
|
||||
async function loadData(){
|
||||
const [summary,chargers,sessions,daily]=await Promise.all([
|
||||
api('/dashboard/summary'),api('/chargers/?active_only=true'),
|
||||
api('/dashboard/recent-sessions?limit=20'),api('/dashboard/daily-stats?days=30'),
|
||||
]);
|
||||
state.summary=summary;state.chargers=chargers||[];state.sessions=sessions||[];state.dailyStats=daily||[];state.loading=false;
|
||||
render();
|
||||
}
|
||||
|
||||
// ── 포맷 ──
|
||||
function fN(n){return n==null?'0':n.toLocaleString('ko-KR')}
|
||||
function fT(ts){if(!ts)return'-';const d=new Date(ts);return`${d.getMonth()+1}/${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`}
|
||||
function fDur(a,b){if(!a||!b)return'-';const m=Math.round((new Date(b)-new Date(a))/60000);return m<60?m+'분':Math.floor(m/60)+'시간 '+m%60+'분'}
|
||||
function sKo(s){return{completed:'완료',charging:'충전중',pending:'대기',failed:'실패',cancelled:'취소',authorized:'인증됨',Available:'사용가능',Charging:'충전중',Faulted:'고장',Unavailable:'오프라인'}[s]||s}
|
||||
function sC(s){return(s||'').toLowerCase()}
|
||||
|
||||
// ── 렌더 ──
|
||||
function render(){
|
||||
const m=document.getElementById('main-content');
|
||||
switch(state.page){
|
||||
case 'dashboard':m.innerHTML=renderDash();break;
|
||||
case 'chargers':m.innerHTML=renderChargers();break;
|
||||
case 'sessions':m.innerHTML=renderSessions();break;
|
||||
case 'billing':m.innerHTML=renderBilling();break;
|
||||
case 'users':m.innerHTML=renderUsers();break;
|
||||
}
|
||||
if(state.page==='dashboard'&&state.dailyStats.length>0)renderChart();
|
||||
}
|
||||
|
||||
function navTo(el){state.page=el.dataset.page;document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));el.classList.add('active');render();if(state.page==='users')loadUsers()}
|
||||
|
||||
// ── 대시보드 ──
|
||||
function renderDash(){const s=state.summary||{};const now=new Date().toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit'});
|
||||
return `<div class="page-header"><div><div class="page-title">대시보드</div><div class="page-sub">충전 인프라 실시간 모니터링</div></div><div class="header-actions"><div class="last-update">갱신 ${now}</div><button class="refresh-btn" onclick="doRefresh(this)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/></svg>새로고침</button></div></div>
|
||||
<div class="summary-grid">
|
||||
<div class="stat-card fade-in" data-color="cyan"><div class="stat-icon">⚡</div><div class="stat-label">등록 충전기</div><div class="stat-value">${s.total_chargers||0}<span class="stat-unit">대</span></div><div class="stat-sub">활성 <span class="hi">${s.active_chargers||0}</span> / 충전중 ${s.charging_now||0}</div></div>
|
||||
<div class="stat-card fade-in" data-color="green"><div class="stat-icon">📊</div><div class="stat-label">오늘 충전</div><div class="stat-value">${s.today_sessions||0}<span class="stat-unit">건</span></div><div class="stat-sub">${s.today_kwh||0} kWh</div></div>
|
||||
<div class="stat-card fade-in" data-color="amber"><div class="stat-icon">💰</div><div class="stat-label">오늘 매출</div><div class="stat-value">${fN(s.today_revenue)}<span class="stat-unit">원</span></div><div class="stat-sub">이번달 ${fN(s.month_revenue)}원</div></div>
|
||||
<div class="stat-card fade-in" data-color="purple"><div class="stat-icon">🔋</div><div class="stat-label">이번달 전력량</div><div class="stat-value">${s.month_kwh||0}<span class="stat-unit">kWh</span></div><div class="stat-sub">CPO 절감 ${fN(Math.round((s.month_kwh||0)*180))}원</div></div>
|
||||
</div>
|
||||
<div class="content-grid">
|
||||
<div class="panel fade-in"><div class="panel-header"><div class="panel-title">일별 충전 추이</div><div class="panel-badge" style="background:var(--accent-dim);color:var(--accent)">30일</div></div><div class="panel-body"><div class="chart-wrap"><canvas id="dailyChart"></canvas></div></div></div>
|
||||
<div class="panel fade-in"><div class="panel-header"><div class="panel-title">충전기 상태</div><div class="panel-badge" style="background:var(--green-dim);color:var(--green)">${state.chargers.length}대</div></div><div class="panel-body">${state.chargers.length===0?'<div class="empty-state"><div class="empty-icon">⚡</div>등록된 충전기가 없습니다</div>':'<div class="charger-list">'+state.chargers.map(c=>`<div class="charger-row"><div class="charger-status-dot ${sC(c.status)}"></div><div class="charger-info"><div class="charger-name">${c.name||c.charge_box_id}</div><div class="charger-id">${c.charge_box_id} · ${c.power_kw}kW</div></div><div class="charger-badge ${sC(c.status)}">${sKo(c.status)}</div><div class="charger-meta">${c.last_heartbeat?fT(c.last_heartbeat):'—'}</div></div>`).join('')+'</div>'}</div></div>
|
||||
</div>
|
||||
<div class="content-grid full"><div class="panel fade-in"><div class="panel-header"><div class="panel-title">최근 충전 세션</div></div><div class="panel-body" style="padding:0">${state.sessions.length===0?'<div class="empty-state"><div class="empty-icon">📋</div>충전 기록이 없습니다</div>':sessionTable(state.sessions)}</div></div></div>`}
|
||||
|
||||
function sessionTable(ss){return `<table class="sessions-table"><thead><tr><th>세션 ID</th><th>충전기</th><th>상태</th><th>충전량</th><th>요금</th><th>시작</th><th>종료</th><th>소요</th></tr></thead><tbody>${ss.map(s=>`<tr><td>${s.session_uid||'-'}</td><td style="color:var(--text-2)">#${s.charger_id}</td><td><span class="status-pill ${sC(s.status)}">${sKo(s.status)}</span></td><td style="color:var(--text)">${s.charged_kwh||0} kWh</td><td class="amount-cell">${fN(s.total_bill)}원</td><td style="color:var(--text-3)">${fT(s.started_at)}</td><td style="color:var(--text-3)">${fT(s.stopped_at)}</td><td style="color:var(--text-3)">${fDur(s.started_at,s.stopped_at)}</td></tr>`).join('')}</tbody></table>`}
|
||||
|
||||
function renderChargers(){return `<div class="page-header"><div><div class="page-title">충전기 현황</div><div class="page-sub">등록된 충전기 상세 정보</div></div></div><div class="content-grid full"><div class="panel"><div class="panel-body">${state.chargers.length===0?'<div class="empty-state"><div class="empty-icon">⚡</div>등록된 충전기가 없습니다</div>':'<div class="charger-list">'+state.chargers.map(c=>`<div class="charger-row"><div class="charger-status-dot ${sC(c.status)}"></div><div class="charger-info"><div class="charger-name">${c.name||c.charge_box_id}</div><div class="charger-id">${c.charge_box_id}</div></div><div style="font-size:12px;color:var(--text-2)">${c.location||'-'}</div><div style="font-family:var(--font-mono);font-size:12px;color:var(--text-2)">${c.power_kw}kW · ${c.connector_count}구</div><div class="charger-badge ${sC(c.status)}">${sKo(c.status)}</div></div>`).join('')+'</div>'}</div></div></div>`}
|
||||
|
||||
function renderSessions(){return `<div class="page-header"><div><div class="page-title">충전 이력</div></div></div><div class="content-grid full"><div class="panel"><div class="panel-body" style="padding:0">${state.sessions.length===0?'<div class="empty-state"><div class="empty-icon">📋</div>충전 기록이 없습니다</div>':sessionTable(state.sessions)}</div></div></div>`}
|
||||
|
||||
function renderBilling(){const s=state.summary||{};return `<div class="page-header"><div><div class="page-title">요금 / 정산</div></div></div><div class="content-grid"><div class="panel fade-in"><div class="panel-header"><div class="panel-title">요금 구조</div><div class="panel-badge" style="background:var(--green-dim);color:var(--green)">170원/kWh</div></div><div class="panel-body"><div class="rate-bar"><div class="rate-bar-seg" style="flex:71;background:var(--accent);color:#fff">전기 120원</div><div class="rate-bar-seg" style="flex:29;background:var(--green);color:#fff">서비스 50원</div></div><div class="rate-legend"><div class="rate-legend-item"><div class="rate-legend-dot" style="background:var(--accent)"></div>전기 원가 120원/kWh</div><div class="rate-legend-item"><div class="rate-legend-dot" style="background:var(--green)"></div>서비스 마진 50원/kWh</div></div><div class="compare-row"><div class="compare-card ours"><div class="compare-label">우리 플랫폼</div><div class="compare-value">170<span class="compare-unit">원/kWh</span></div></div><div class="compare-card cpo"><div class="compare-label">CPO 방식</div><div class="compare-value">350<span class="compare-unit">원/kWh</span></div></div></div><div class="savings-banner">kWh당 180원 절감 · 약 51% 저렴</div></div></div><div class="panel fade-in"><div class="panel-header"><div class="panel-title">이번달 정산</div></div><div class="panel-body"><table class="sessions-table"><tr><td>총 충전량</td><td class="amount-cell" style="text-align:right">${s.month_kwh||0} kWh</td></tr><tr><td>전기 원가</td><td style="text-align:right;color:var(--text-2)">${fN(Math.round((s.month_kwh||0)*120))}원</td></tr><tr><td>서비스 수익</td><td style="text-align:right;color:var(--green)">${fN(Math.round((s.month_kwh||0)*50))}원</td></tr><tr><td style="font-weight:500;color:#fff">총 매출</td><td class="amount-cell" style="text-align:right">${fN(s.month_revenue)}원</td></tr><tr><td>CPO 대비 사용자 절감</td><td style="text-align:right;color:var(--amber)">${fN(Math.round((s.month_kwh||0)*180))}원</td></tr></table></div></div></div>`}
|
||||
|
||||
// ── 사용자 관리 ──
|
||||
async function loadUsers(){state.users=await api('/auth/users')||[];render()}
|
||||
|
||||
function renderUsers(){
|
||||
if(currentUser?.role!=='admin')return '<div class="empty-state">관리자 권한이 필요합니다</div>';
|
||||
return `<div class="page-header"><div><div class="page-title">사용자 관리</div><div class="page-sub">대시보드 접근 계정 관리</div></div><div class="header-actions"><button class="btn-primary" onclick="showAddUser()">+ 사용자 추가</button></div></div>
|
||||
<div class="content-grid full"><div class="panel"><div class="panel-body">${state.users.length===0?'<div class="empty-state">등록된 사용자가 없습니다</div>':state.users.map(u=>`<div class="user-row"><div class="user-avatar">${(u.display_name||u.username).charAt(0).toUpperCase()}</div><div class="user-info"><div class="user-name">${u.display_name||u.username}</div><div class="user-meta">@${u.username} · ${u.last_login?fT(u.last_login)+'마지막 로그인':'로그인 기록 없음'}</div></div><span class="role-badge ${u.role}">${u.role}</span><div class="user-actions"><button class="btn-sm" onclick="showEditUser(${u.id},'${u.username}','${u.display_name||''}','${u.role}',${u.is_active})">수정</button>${u.id!==currentUser.id?`<button class="btn-sm danger" onclick="deleteUser(${u.id},'${u.username}')">삭제</button>`:''}</div></div>`).join('')}</div></div></div>`}
|
||||
|
||||
function showAddUser(){
|
||||
document.getElementById('modal-root').innerHTML=`<div class="modal-overlay" onclick="if(event.target===this)closeModal()"><div class="modal"><h3>사용자 추가</h3>
|
||||
<div class="form-group"><label class="form-label">아이디</label><input class="form-input" id="mu-user" type="text" placeholder="영문, 숫자"></div>
|
||||
<div class="form-group"><label class="form-label">비밀번호</label><input class="form-input" id="mu-pass" type="password"></div>
|
||||
<div class="form-group"><label class="form-label">표시 이름</label><input class="form-input" id="mu-name" type="text" placeholder="홍길동"></div>
|
||||
<div class="form-group"><label class="form-label">역할</label><select class="form-input" id="mu-role"><option value="viewer">viewer (조회만)</option><option value="operator">operator (운영)</option><option value="admin">admin (관리자)</option></select></div>
|
||||
<div id="mu-error" style="color:var(--red);font-size:12px;min-height:16px;margin-bottom:8px"></div>
|
||||
<div class="modal-actions"><button class="btn-cancel" onclick="closeModal()">취소</button><button class="btn-primary" onclick="doAddUser()">생성</button></div></div></div>`}
|
||||
|
||||
async function doAddUser(){
|
||||
const u=document.getElementById('mu-user').value.trim(),p=document.getElementById('mu-pass').value,n=document.getElementById('mu-name').value.trim(),r=document.getElementById('mu-role').value;
|
||||
const err=document.getElementById('mu-error');
|
||||
if(!u||!p){err.textContent='아이디와 비밀번호를 입력하세요';return}
|
||||
try{await api('/auth/users',{method:'POST',body:JSON.stringify({username:u,password:p,display_name:n||u,role:r})});closeModal();loadUsers()}catch(e){err.textContent=e.detail||'생성 실패'}}
|
||||
|
||||
function showEditUser(id,username,name,role,active){
|
||||
document.getElementById('modal-root').innerHTML=`<div class="modal-overlay" onclick="if(event.target===this)closeModal()"><div class="modal"><h3>${username} 수정</h3>
|
||||
<div class="form-group"><label class="form-label">표시 이름</label><input class="form-input" id="eu-name" type="text" value="${name}"></div>
|
||||
<div class="form-group"><label class="form-label">역할</label><select class="form-input" id="eu-role"><option value="viewer" ${role==='viewer'?'selected':''}>viewer</option><option value="operator" ${role==='operator'?'selected':''}>operator</option><option value="admin" ${role==='admin'?'selected':''}>admin</option></select></div>
|
||||
<div class="form-group"><label class="form-label">새 비밀번호 (변경 시에만 입력)</label><input class="form-input" id="eu-pass" type="password" placeholder="변경하지 않으려면 비워두세요"></div>
|
||||
<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text-2)"><input type="checkbox" id="eu-active" ${active?'checked':''}>계정 활성화</label></div>
|
||||
<div id="eu-error" style="color:var(--red);font-size:12px;min-height:16px"></div>
|
||||
<div class="modal-actions"><button class="btn-cancel" onclick="closeModal()">취소</button><button class="btn-primary" onclick="doEditUser(${id})">저장</button></div></div></div>`}
|
||||
|
||||
async function doEditUser(id){
|
||||
const body={display_name:document.getElementById('eu-name').value,role:document.getElementById('eu-role').value,is_active:document.getElementById('eu-active').checked};
|
||||
const p=document.getElementById('eu-pass').value;if(p)body.password=p;
|
||||
try{await api('/auth/users/'+id,{method:'PUT',body:JSON.stringify(body)});closeModal();loadUsers()}catch(e){document.getElementById('eu-error').textContent=e.detail||'수정 실패'}}
|
||||
|
||||
async function deleteUser(id,name){if(!confirm(`${name} 계정을 삭제하시겠습니까?`))return;try{await api('/auth/users/'+id,{method:'DELETE'});loadUsers()}catch(e){alert(e.detail||'삭제 실패')}}
|
||||
|
||||
function closeModal(){document.getElementById('modal-root').innerHTML=''}
|
||||
|
||||
// ── 차트 ──
|
||||
function renderChart(){const cv=document.getElementById('dailyChart');if(!cv)return;if(chartInstance)chartInstance.destroy();const d=state.dailyStats;const labels=d.map(x=>{const t=new Date(x.date);return(t.getMonth()+1)+'/'+t.getDate()});
|
||||
chartInstance=new Chart(cv.getContext('2d'),{type:'bar',data:{labels,datasets:[{label:'매출 (원)',data:d.map(x=>x.revenue),backgroundColor:'rgba(0,212,255,0.3)',borderColor:'rgba(0,212,255,0.8)',borderWidth:1,borderRadius:4,yAxisID:'y',order:2},{label:'충전량 (kWh)',data:d.map(x=>x.kwh),type:'line',borderColor:'#10b981',backgroundColor:'rgba(16,185,129,0.1)',borderWidth:2,pointRadius:3,pointBackgroundColor:'#10b981',fill:true,tension:0.3,yAxisID:'y1',order:1}]},options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{display:true,position:'top',align:'end',labels:{color:'#94a3b8',font:{family:"'JetBrains Mono'",size:10},boxWidth:12,padding:16}},tooltip:{backgroundColor:'#1a2236',titleColor:'#e2e8f0',bodyColor:'#94a3b8',borderColor:'rgba(0,212,255,0.2)',borderWidth:1,titleFont:{family:"'JetBrains Mono'",size:11},bodyFont:{family:"'JetBrains Mono'",size:11},padding:10,cornerRadius:6,callbacks:{label:c=>c.dataset.yAxisID==='y'?' 매출: '+fN(c.raw)+'원':' 충전: '+c.raw+'kWh'}}},scales:{x:{grid:{color:'rgba(255,255,255,0.03)'},ticks:{color:'#64748b',font:{family:"'JetBrains Mono'",size:10}}},y:{position:'left',grid:{color:'rgba(255,255,255,0.03)'},ticks:{color:'#64748b',font:{family:"'JetBrains Mono'",size:10},callback:v=>fN(v)+'원'}},y1:{position:'right',grid:{drawOnChartArea:false},ticks:{color:'#10b981',font:{family:"'JetBrains Mono'",size:10},callback:v=>v+'kWh'}}}}})}
|
||||
|
||||
async function doRefresh(btn){if(btn)btn.classList.add('loading');await loadData();if(btn)setTimeout(()=>btn.classList.remove('loading'),500)}
|
||||
function startAutoRefresh(){if(refreshTimer)clearInterval(refreshTimer);refreshTimer=setInterval(()=>loadData(),30000)}
|
||||
|
||||
// ── 초기화 ──
|
||||
document.addEventListener('DOMContentLoaded',()=>{
|
||||
loadAuth();
|
||||
if(token&¤tUser){enterApp()}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user