GNU/_PAGE/asset/upbit/data_total.php
<?php
/* ===============================
   1. PHP 로직 (JSON 데이터 반환 및 기본 환경 - 구조 및 코드 유지)
=============================== */
ini_set('display_errors', 0);
date_default_timezone_set('Asia/Seoul');

if (isset($_GET['ajax'])) {
    header('Content-Type: application/json');
    try {
        require_once "/home/www/DB/db_upbit.php";
        require_once "/home/www/DB/key_upbit_trade.php";

        if (isset($db_upbit)) $pdo = $db_upbit;
        if (!isset($pdo)) throw new Exception("DB_ERROR");

        function base64url_encode($data){ return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }
        function jwt_hs256($payload, $secret){
            $header = ['typ'=>'JWT','alg'=>'HS256'];
            $h = base64url_encode(json_encode($header));
            $p = base64url_encode(json_encode($payload));
            $s = base64url_encode(hash_hmac('sha256', "$h.$p", $secret, true));
            return "$h.$p.$s";
        }
        function upbit_accounts($access, $secret){
            $jwt = jwt_hs256(['access_key'=>$access,'nonce'=>uniqid('',true)], $secret);
            $ch = curl_init("https://api.upbit.com/v1/accounts");
            curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_HTTPHEADER=>["Authorization: Bearer {$jwt}"], CURLOPT_TIMEOUT=>2]);
            $res = curl_exec($ch); curl_close($ch);
            return json_decode($res, true);
        }

        $accounts = upbit_accounts($UPBIT_ACCESS_KEY, $UPBIT_SECRET_KEY);
        $hold = []; $total_hold_qty = 0; $krw_balance = 0;
        if (is_array($accounts)) {
            foreach ($accounts as $a) {
                if ($a['currency'] === 'KRW') { $krw_balance = (float)$a['balance']; continue; }
                $qty = (float)$a['balance'] + (float)$a['locked'];
                if ($qty <= 0) continue;
                $market = 'KRW-' . $a['currency'];
                $hold[$market] = ['qty' => $qty, 'avg' => (float)$a['avg_buy_price']];
                $total_hold_qty += $qty;
            }
        }

        $sql = "SELECT t1.* FROM daemon_upbit_Ticker t1 
                JOIN (SELECT market, MAX(collected_at) AS mx FROM daemon_upbit_Ticker GROUP BY market) t2 
                ON t1.market = t2.market AND t1.collected_at = t2.mx";
        $ticker_rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);

        $list = []; $total_buy = 0; $total_eval = 0;
        $top_profit = ['name' => '-', 'val' => -999999999];
        $top_loss = ['name' => '-', 'val' => 999999999];
        $profit_count = 0; $loss_count = 0;

        foreach ($ticker_rows as $r) {
            $m = $r['market'];
            $is_hold = isset($hold[$m]);
            $qty = $is_hold ? $hold[$m]['qty'] : 0;
            $avg = $is_hold ? $hold[$m]['avg'] : 0;
            $buy = $qty * $avg;
            $eval = $qty * (float)$r['trade_price'];
            $pl = $eval - $buy;
            $rate = ($buy > 0) ? ($pl / $buy * 100) : 0;

            if($is_hold) {
                $total_buy += $buy; $total_eval += $eval;
                if($pl > 0) $profit_count++;
                if($pl < 0) $loss_count++;
                if($pl > $top_profit['val']) { $top_profit = ['name' => $r['korean_name'], 'val' => $pl]; }
                if($pl < $top_loss['val']) { $top_loss = ['name' => $r['korean_name'], 'val' => $pl]; }
            }

            $list[] = [
                'market' => $m, 'name' => $r['korean_name'] ?? $m, 'price' => (float)$r['trade_price'],
                'is_hold' => $is_hold, 'qty' => $qty, 'avg' => $avg, 'buy' => $buy, 'eval' => $eval,
                'pl' => $pl, 'rate' => $rate, 'time' => date('H:i:s', strtotime($r['collected_at']))
            ];
        }

        echo json_encode([
            'success' => true,
            'summary' => [
                'total_asset' => $total_eval + $krw_balance,
                'krw_balance' => $krw_balance,
                'total_pl' => $total_eval - $total_buy,
                'total_rate' => ($total_buy > 0) ? (($total_eval - $total_buy) / $total_buy * 100) : 0,
                'total_hold_qty' => $total_hold_qty,
                'top_profit' => $top_profit,
                'top_loss' => $top_loss,
                'profit_count' => $profit_count,
                'loss_count' => $loss_count
            ],
            'list' => $list
        ]);
    } catch (Exception $e) { echo json_encode(['success' => false]); }
    exit;
}
// 헤더 부분 포함
require_once '/home/www/GNU/_PAGE/head.php';
?>
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>UPBIT DASHBOARD</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Noto+Sans+KR:wght@300;500;700&display=swap" rel="stylesheet">
    <style>
        :root {
            --bg-color: #020617;
            --card-bg: rgba(15, 23, 42, 0.6);
            --accent-blue: #38bdf8;
            --accent-purple: #818cf8;
            --text-main: #f1f5f9;
            --text-dim: #94a3b8;
            --glass-border: rgba(255, 255, 255, 0.08);
            --up-color: #fb7185;
            --down-color: #38bdf8;
        }

        /* 요구사항 7: 페이지 접속 시 동적 효과 (Fade In + Up) */
        @keyframes entranceFade {
            from { opacity: 0; transform: translateY(15px); }
            to { opacity: 1; transform: translateY(0); }
        }

        body { background: var(--bg-color); color: var(--text-main); font-family: 'Noto Sans KR', sans-serif; margin: 0; padding: 0 0 20px 0; overflow-x: hidden; }
        #stars-container { position: fixed; top:0; left:0; width:100%; height:100%; z-index:-1; pointer-events: none; }

        .dashboard-container { 
            width: calc(100% - 400px); 
            margin: 0 200px; 
            position: relative; 
            z-index: 1; 
            animation: entranceFade 0.8s ease-out forwards; /* 요구사항 7 */
        }

        header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; }
        .logo { font-family: 'Orbitron'; font-size: 1.4rem; color: var(--accent-blue); font-weight: 700; letter-spacing: 2px; }

        /* 요구사항 4: 상단 항목 호버 효과 및 테두리 효과 */
        .summary-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 12px; margin-bottom: 30px; }
        .summary-card { 
            background: var(--card-bg); 
            border: 1px solid var(--glass-border); 
            padding: 18px 15px; 
            border-radius: 12px; 
            backdrop-filter: blur(10px); 
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 동적 효과용 트랜지션 */
        }
        .summary-card:hover { 
            transform: translateY(-5px); 
            border-color: var(--accent-blue); 
            box-shadow: 0 10px 20px rgba(56, 189, 248, 0.15);
            background: rgba(15, 23, 42, 0.8);
        }
        .summary-card label { display: block; font-size: 0.7rem; color: var(--text-dim); margin-bottom: 8px; font-weight: 500; }
        .summary-card .value { font-size: 1.3rem; font-weight: 700; font-family: 'Orbitron'; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
        .summary-card .sub-val { font-size: 0.8rem; margin-top: 4px; opacity: 0.9; font-family: 'Orbitron'; }

        /* 요구사항 5, 6: 버튼 및 검색 호버 효과 */
        .control-bar { display: flex; align-items: center; gap: 25px; background: var(--card-bg); padding: 15px 20px; border-radius: 12px; border: 1px solid var(--glass-border); margin-bottom: 25px; }
        .filter-row { display: flex; align-items: center; gap: 20px; flex: 1; }
        .filter-group { display: flex; align-items: center; gap: 8px; }
        .filter-group label { font-size: 0.65rem; color: var(--accent-blue); font-family: 'Orbitron'; font-weight: 700; text-transform: uppercase; white-space: nowrap; }
        
        button.btn-filter { 
            background: rgba(255,255,255,0.05); border: 1px solid var(--glass-border); color: var(--text-dim); 
            padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 0.75rem; transition: all 0.2s ease; white-space: nowrap;
        }
        /* 요구사항 5: 버튼 마우스 오버 효과 */
        button.btn-filter:hover { 
            border-color: var(--accent-blue); 
            color: var(--text-main);
            background: rgba(56, 189, 248, 0.1);
        }
        button.btn-filter.active { background: var(--accent-blue); color: #000; border-color: var(--accent-blue); font-weight: 700; }
        
        button.btn-profit { color: var(--up-color); }
        button.btn-profit:hover { border-color: var(--up-color); background: rgba(251, 113, 133, 0.1); }
        button.btn-profit.active { background: var(--up-color); color: #fff; }
        
        button.btn-loss { color: var(--down-color); }
        button.btn-loss:hover { border-color: var(--down-color); background: rgba(56, 189, 248, 0.1); }
        button.btn-loss.active { background: var(--down-color); color: #fff; }

        /* 요구사항 6: 검색 마우스 오버 효과 */
        .search-input { 
            width: 220px; 
            background: rgba(0,0,0,0.2); 
            border: 1px solid var(--glass-border); 
            padding: 10px 15px; 
            border-radius: 8px; 
            color: #fff; 
            outline: none; 
            font-size: 0.85rem; 
            transition: all 0.3s ease;
        }
        .search-input:hover {
            border-color: var(--accent-purple);
            box-shadow: 0 0 10px rgba(129, 140, 248, 0.3);
            background: rgba(0,0,0,0.3);
        }
        .search-input:focus { border-color: var(--accent-blue); }

        /* 테이블 */
        .table-wrap { background: var(--card-bg); border: 1px solid var(--glass-border); border-radius: 16px; overflow: hidden; }
        table { width: 100%; border-collapse: collapse; text-align: right; }
        th { background: rgba(255,255,255,0.03); padding: 15px; font-size: 0.75rem; color: var(--text-dim); cursor: pointer; border-bottom: 1px solid var(--glass-border); }
        td { padding: 14px 15px; border-bottom: 1px solid var(--glass-border); font-size: 0.9rem; }
        .coin-name { text-align: left; font-weight: 700; }
        .coin-name span { display: block; font-size: 0.7rem; color: var(--text-dim); font-family: 'Orbitron'; font-weight: 400; }
        .hold-badge { background: rgba(129, 140, 248, 0.2); color: var(--accent-purple); padding: 1px 5px; border-radius: 4px; font-size: 0.65rem; margin-left: 4px; border: 1px solid var(--accent-purple); }
        
        .up { color: var(--up-color); }
        .down { color: var(--down-color); }
        .font-num { font-family: 'Orbitron', sans-serif; }

        @keyframes flashUp { from { background: rgba(251, 113, 133, 0.2); } to { background: transparent; } }
        @keyframes flashDown { from { background: rgba(56, 189, 248, 0.2); } to { background: transparent; } }
        .flash-up { animation: flashUp 0.5s ease-out; }
        .flash-down { animation: flashDown 0.5s ease-out; }
    </style>
</head>
<body>

<div id="stars-container"></div>

<div class="dashboard-container">
    <header>
        <div class="logo">UPBIT TERMINAL</div>
        <div id="connection" style="font-size:0.65rem; color:#10b981;"><i class="fa-solid fa-bolt-lightning"></i> REAL-TIME ACTIVE</div>
    </header>

    <div class="summary-grid">
        <div class="summary-card">
            <label>총 자산</label>
            <div id="total_asset" class="value">0</div>
            <div id="total_pl" class="sub-val">0</div>
        </div>
        <div class="summary-card">
            <label>현금 / 수익률</label>
            <div id="krw_balance" class="value">0</div>
            <div id="total_rate" class="sub-val">0%</div>
        </div>
        <div class="summary-card">
            <label>총 보유 수량</label>
            <div id="total_hold_qty" class="value">0</div>
            <div class="sub-val" style="opacity:0.5; font-size:0.6rem;">COIN TOTAL</div>
        </div>
        <div class="summary-card">
            <label>최고 수익 종목</label>
            <div id="top_profit_name" class="value" style="color:var(--up-color); font-size:1.1rem;">-</div>
            <div id="top_profit_val" class="sub-val up">0</div>
        </div>
        <div class="summary-card">
            <label>최대 손실 종목</label>
            <div id="top_loss_name" class="value" style="color:var(--down-color); font-size:1.1rem;">-</div>
            <div id="top_loss_val" class="sub-val down">0</div>
        </div>
        <div class="summary-card">
            <label>수익 종목 수</label>
            <div id="profit_count" class="value" style="color:var(--up-color);">0</div>
            <div class="sub-val">ITEMS</div>
        </div>
        <div class="summary-card">
            <label>손실 종목 수</label>
            <div id="loss_count" class="value" style="color:var(--down-color);">0</div>
            <div class="sub-val">ITEMS</div>
        </div>
    </div>

    <div class="control-bar">
        <div class="filter-row">
            <div class="filter-group">
                <label>기본 필터</label>
                <button class="btn-filter active" onclick="setFilter('all', this)">전체</button>
                <button class="btn-filter" onclick="setFilter('hold', this)">보유</button>
                <button class="btn-filter" onclick="setFilter('none', this)">미보유</button>
            </div>
            <div class="filter-group">
                <label>수익 필터</label>
                <button class="btn-filter btn-profit" onclick="setFilter('p5', this)">5%↑</button>
                <button class="btn-filter btn-profit" onclick="setFilter('p10', this)">10%↑</button>
            </div>
            <div class="filter-group">
                <label>손실 필터</label>
                <button class="btn-filter btn-loss" onclick="setFilter('l5', this)">-5%↓</button>
                <button class="btn-filter btn-loss" onclick="setFilter('l10', this)">-10%↓</button>
                <button class="btn-filter btn-loss" onclick="setFilter('l30', this)">-30%↓</button>
            </div>
        </div>
        <input type="text" id="searchInput" class="search-input" placeholder="자산명/심볼 검색..." oninput="setSearch(this.value)">
    </div>

    <div class="table-wrap">
        <table>
            <thead>
                <tr>
                    <th style="text-align:left;" onclick="setSort('name')">자산명</th>
                    <th onclick="setSort('price')">현재가</th>
                    <th onclick="setSort('qty')">보유수량</th>
                    <th onclick="setSort('avg')">평단가</th>
                    <th onclick="setSort('eval')">평가금액</th>
                    <th onclick="setSort('pl')">손익</th>
                    <th onclick="setSort('rate')">수익률</th>
                </tr>
            </thead>
            <tbody id="ticker_body"></tbody>
        </table>
    </div>
</div>

<script>
let rawData = [];
let prevPrices = {};
let filterMode = 'all';
let sortKey = 'eval';
let sortDir = 'desc';
let searchTerm = '';

const nf = (v, d = 0) => new Intl.NumberFormat('ko-KR', { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);

async function fetchData() {
    try {
        const res = await fetch(window.location.pathname + "?ajax=1");
        const data = await res.json();
        if (!data.success) return;

        rawData = data.list;
        const s = data.summary;

        document.getElementById('total_asset').textContent = nf(s.total_asset);
        document.getElementById('krw_balance').textContent = nf(s.krw_balance);
        document.getElementById('total_hold_qty').textContent = nf(s.total_hold_qty, 2);
        
        const plEl = document.getElementById('total_pl');
        plEl.textContent = (s.total_pl >= 0 ? '+' : '') + nf(s.total_pl);
        plEl.className = 'sub-val ' + (s.total_pl >= 0 ? 'up' : 'down');

        const rateEl = document.getElementById('total_rate');
        rateEl.textContent = nf(s.total_rate, 2) + "%";
        rateEl.className = 'sub-val ' + (s.total_rate >= 0 ? 'up' : 'down');

        document.getElementById('top_profit_name').textContent = s.top_profit.name;
        document.getElementById('top_profit_val').textContent = "+" + nf(s.top_profit.val);
        document.getElementById('top_loss_name').textContent = s.top_loss.name;
        document.getElementById('top_loss_val').textContent = nf(s.top_loss.val);

        document.getElementById('profit_count').textContent = s.profit_count;
        document.getElementById('loss_count').textContent = s.loss_count;

        renderTable();
    } catch (e) { }
}

function renderTable() {
    const tbody = document.getElementById('ticker_body');
    
    let filtered = rawData.filter(item => {
        const matchesSearch = item.name.includes(searchTerm) || item.market.toLowerCase().includes(searchTerm.toLowerCase());
        if (!matchesSearch) return false;

        switch(filterMode) {
            case 'hold': return item.is_hold;
            case 'none': return !item.is_hold;
            case 'p5': return item.is_hold && item.rate >= 5;
            case 'p10': return item.is_hold && item.rate >= 10;
            case 'l5': return item.is_hold && item.rate <= -5;
            case 'l10': return item.is_hold && item.rate <= -10;
            case 'l30': return item.is_hold && item.rate <= -30;
            default: return true;
        }
    });

    filtered.sort((a, b) => {
        let v1 = a[sortKey]; let v2 = b[sortKey];
        return sortDir === 'desc' ? (v2 > v1 ? 1 : -1) : (v1 > v2 ? 1 : -1);
    });

    let html = '';
    filtered.forEach(item => {
        const prevPrice = prevPrices[item.market] || item.price;
        let flashClass = '';
        if (item.price > prevPrice) flashClass = 'flash-up';
        else if (item.price < prevPrice) flashClass = 'flash-down';
        prevPrices[item.market] = item.price;

        html += `
            <tr class="${flashClass}">
                <td class="coin-name">${item.name} <span>${item.market} ${item.is_hold ? '<b class="hold-badge">보유</b>' : ''}</span></td>
                <td class="font-num ${item.pl >= 0 ? 'up' : 'down'}">${nf(item.price)}</td>
                <td class="font-num">${item.is_hold ? nf(item.qty, 4) : '-'}</td>
                <td class="font-num">${item.is_hold ? nf(item.avg) : '-'}</td>
                <td class="font-num">${item.is_hold ? nf(item.eval) : '-'}</td>
                <td class="font-num ${item.pl >= 0 ? 'up' : 'down'}">${item.is_hold ? nf(item.pl) : '-'}</td>
                <td class="font-num ${item.pl >= 0 ? 'up' : 'down'}">${item.is_hold ? nf(item.rate, 2) + '%' : '-'}</td>
            </tr>
        `;
    });
    tbody.innerHTML = html;
}

function setFilter(mode, btn) {
    filterMode = mode;
    document.querySelectorAll('.btn-filter').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    renderTable();
}

function setSort(key) {
    if (sortKey === key) sortDir = sortDir === 'desc' ? 'asc' : 'desc';
    else { sortKey = key; sortDir = 'desc'; }
    renderTable();
}

function setSearch(val) { searchTerm = val; renderTable(); }

function initStars() {
    const container = document.getElementById('stars-container');
    if(!container) return;
    for (let i = 0; i < 100; i++) {
        const star = document.createElement('div');
        star.style.position = 'absolute';
        star.style.width = Math.random() * 2 + 'px';
        star.style.height = star.style.width;
        star.style.background = '#fff';
        star.style.left = Math.random() * 100 + '%';
        star.style.top = Math.random() * 100 + '%';
        star.style.opacity = Math.random();
        container.appendChild(star);
    }
}

initStars();
setInterval(fetchData, 300);
fetchData();
</script>
</body>
</html>

<?php require_once '/home/www/GNU/_PAGE/tail.php'; ?>