Files
chpark d16456cb92 사용자별 격리 시스템 + 사용자 관리 + 라이브 PnL%
# 사용자별 격리
- JWT 토큰에 uid 추가 (auth.get_uid 헬퍼)
- PostgreSQL — exchange_credentials/automation_config/trades/signal_events 에 user_id BIGINT
- SQLite user_settings 테이블 신설 (글로벌 settings 는 옛 호환)
- 모든 DB 함수 시그니처에 user_id 인자 추가 — 다른 사용자 데이터 절대 접근 불가
- alert_state — 모든 dict key 가 (user_id, ...) tuple 로 계층화
- core_logic alert_loop — 활성 사용자 순회 + 각자 settings/symbol/텔레그램 적용
- ensure_user_defaults() / ensure_user_automation() — 첫 사용 시 자동 시드

# 사용자 관리 (admin only)
- users_db: delete_user / admin_reset_password / set_role
- /api/users POST DELETE PUT password PUT role (본인 강등 / 마지막 admin 보호)
- /admin/users 페이지 — 등록/삭제/role 토글/비번 reset 모달
- 사이드바 adminOnly 필터 — admin role 만 메뉴 노출

# 대시보드 개선
- 모바일 / 범례 토글 (모바일 60 캔들, 데스크톱 200)
- 트레이드 이력: open 트레이드 실시간 PnL% (Binance ticker 호출 + 방향별 계산)
- 메트릭 카드 분리 (실거래 vs 실시간 open)

# 안정성
- api.ts: error.detail array/object 안전 처리 ([object Object] 방지)
- Chart.tsx: Plotly yaxis title 객체 형태 + 모바일 height 동적 조정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:14:23 +09:00

104 lines
4.3 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import Chart from '@/components/Chart';
import { Banner, Card, PageHeader, Select, Toggle } from '@/components/ui';
import { RefreshCw } from 'lucide-react';
const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT'];
const INTERVALS = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '12h', '1d'];
const CANDLES_DESKTOP = 200;
const CANDLES_MOBILE = 60;
export default function DashboardPage() {
const [symbol, setSymbol] = useState('BTCUSDT');
const [interval, setIntervalV] = useState('5m');
const [auto, setAuto] = useState(true);
const [refresh, setRefresh] = useState(30);
const [showLegend, setShowLegend] = useState(false);
const [mobile, setMobile] = useState(false);
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
// 첫 로드 시 viewport 자동 감지 (모바일이면 mobile 모드 default ON)
useEffect(() => {
if (typeof window !== 'undefined' && window.innerWidth < 768) {
setMobile(true);
}
}, []);
const candleLimit = mobile ? CANDLES_MOBILE : CANDLES_DESKTOP;
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api.get(`/api/market/dashboard?symbol=${symbol}&interval=${interval}&limit=${candleLimit}`)
.then(d => { if (!cancelled) setData(d); })
.catch(e => { if (!cancelled) setError(e.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [symbol, interval, tick, candleLimit]);
useEffect(() => {
if (!auto) return;
const id = setInterval(() => setTick(t => t + 1), refresh * 1000);
return () => clearInterval(id);
}, [auto, refresh]);
const now = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
return (
<div>
<PageHeader
title="💰 돈복사 대시보드"
subtitle={`${symbol} · ${interval} · ${candleLimit} 캔들 · 마지막 갱신 ${now} KST`}
right={
<button onClick={() => setTick(t => t + 1)} className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-md shadow-sm">
<RefreshCw size={14} />
</button>
}
/>
<Card className="mb-4">
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 items-end">
<Select label="심볼" value={symbol} onChange={(e: any) => setSymbol(e.target.value)}>
{SYMBOLS.map(s => <option key={s} value={s}>{s}</option>)}
</Select>
<Select label="시간축" value={interval} onChange={(e: any) => setIntervalV(e.target.value)}>
{INTERVALS.map(s => <option key={s} value={s}>{s}</option>)}
</Select>
<div>
<span className="block text-xs font-medium text-slate-600 mb-1">()</span>
<input type="number" value={refresh} min={10} max={300}
onChange={(e) => setRefresh(parseInt(e.target.value))}
className="w-full px-3 py-2 text-sm rounded-md border border-slate-300 bg-slate-50" />
</div>
<div className="flex items-center"><Toggle checked={auto} onChange={setAuto} label="자동 갱신" /></div>
<div className="flex items-center"><Toggle checked={mobile} onChange={setMobile} label={`모바일 (${mobile ? CANDLES_MOBILE : CANDLES_DESKTOP})`} /></div>
<div className="flex items-center"><Toggle checked={showLegend} onChange={setShowLegend} label="범례" /></div>
</div>
{(loading || data?.last_price) && (
<div className="text-xs text-slate-500 text-right mt-2 pt-2 border-t border-slate-100">
{loading ? '⏳ 로딩 중...' : `현재가: ${data.last_price.toLocaleString()} ${symbol.replace('USDT', '/USDT')}`}
</div>
)}
</Card>
{data?.banner && (
<div className="mb-4">
<Banner level={data.banner.level}>{data.banner.text}</Banner>
</div>
)}
{error && <Banner level="danger">{error}</Banner>}
<Card className="p-2 md:p-3">
<Chart rows={data?.rows || []} lastPrice={data?.last_price} mobile={mobile} showLegend={showLegend} />
</Card>
</div>
);
}