Files
hanarang-dashboard/frontend/app/admin/costs/page.tsx
narang-ai 230d3847ef feat(redesign-v2): 미니멀 터미널 UI 전체 리디자인
- 디자인 시스템: #151515 배경, 1px 보더 카드, CSS vars, mono 포인트
- GlobalStyle/theme.ts v2 (DESIGN-SYSTEM.md 기반)
- 공통 컴포넌트 (ui/base.tsx): LabelMeta, BracketValue, Card, TechBar, Timeline 등
- Sidebar: [대시] 브라켓 네비 + 모바일 하단 탭바 (767px)
- LayoutShell: SidebarContext 제거, 단순화
- 대시보드 (/): SYS 상태 카드 4개 + ONGOING PROJECTS + ACTIVITY FEED
- 활동 로그 (/activities): 로그 테이블 + 필터 + 검색 + 페이지네이션 (신규)
- 설정 (/settings): 4섹션 Toggle/Bracket Input 그리드
- 자매 노드 관리 (/sisters): 3열 수평 레이아웃 (INFO/GRAPH/SYNC)
- 자매 상세 (/sisters/[name]): 간소화 + 터미널 스타일
- 조직도 (/org): 터미널 카드 트리 구조 + 레벨 색상
- 프로젝트 목록 (/projects): 새 라우트
- 프로젝트 상세 (/projects/[id]): Phase Timeline + Audit Log + Checklist
- FE 14 routes build 성공
2026-04-04 13:10:57 +09:00

231 lines
6.5 KiB
TypeScript

'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import BarChart from '@/components/admin/BarChart';
import { adminFetch } from '@/lib/adminFetch';
type Period = 'day' | 'week' | 'month';
interface CostData {
period: string;
since: string;
summary: {
totalTokens: number;
inputTokens: number;
outputTokens: number;
estimatedUsd: number;
recordCount: number;
};
bySister: Array<{ name: string; totalTokens: number; estimatedUsd: number }>;
byModel: Array<{ model: string; totalTokens: number; estimatedUsd: number; count: number }>;
timeline: Array<{ date: string; totalTokens: number; estimatedUsd: number }>;
}
const Controls = styled.div`
display: flex;
gap: 8px;
margin-bottom: 24px;
`;
const PeriodBtn = styled.button<{ $active: boolean }>`
padding: 6px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: ${({ $active }) => $active ? '600' : '400'};
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.15)' : 'transparent'};
border: 1px solid ${({ $active }) => $active ? 'rgba(88,166,255,0.4)' : 'var(--border-color)'};
color: ${({ $active }) => $active ? '#58A6FF' : 'var(--text-secondary)'};
cursor: pointer;
transition: all 0.15s;
&:hover { color: #58A6FF; border-color: rgba(88,166,255,0.3); }
`;
const SummaryGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
margin-bottom: 28px;
`;
const SummaryCard = styled.div`
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 16px 18px;
`;
const SummaryLabel = styled.div`
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
`;
const SummaryValue = styled.div`
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
`;
const SummaryUnit = styled.span`
font-size: 12px;
color: var(--text-secondary);
margin-left: 4px;
`;
const ChartsGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 28px;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
`;
const Card = styled.div`
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px 22px;
`;
const RecordBtn = styled.button`
padding: 8px 16px;
border-radius: 7px;
font-size: 12px;
font-weight: 600;
background: rgba(88,166,255,0.08);
border: 1px solid rgba(88,166,255,0.25);
color: #58A6FF;
cursor: pointer;
transition: background 0.15s;
&:hover { background: rgba(88,166,255,0.18); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
`;
const EmptyNote = styled.div`
padding: 32px;
text-align: center;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
`;
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
export default function CostsPage() {
const [period, setPeriod] = useState<Period>('week');
const [data, setData] = useState<CostData | null>(null);
const [loading, setLoading] = useState(true);
const [recording, setRecording] = useState(false);
const load = async (p: Period) => {
setLoading(true);
try {
const res = await adminFetch(`/api/admin/costs?period=${p}`);
if (res.ok) setData(await res.json());
} catch {
// silent
} finally {
setLoading(false);
}
};
useEffect(() => { load(period); }, [period]);
const recordAll = async () => {
setRecording(true);
try {
await Promise.all(['harang', 'narang', 'darang', 'erang'].map((name) =>
adminFetch(`/api/admin/costs/record/${name}`, { method: 'POST' }),
));
await load(period);
} finally {
setRecording(false);
}
};
const sisterChartData = data?.bySister.map((s) => ({
label: { harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이' }[s.name] ?? s.name,
value: s.estimatedUsd,
})) ?? [];
const modelChartData = data?.byModel.map((m) => ({
label: m.model.split('/').pop() ?? m.model,
value: m.totalTokens,
})) ?? [];
const timelineChartData = data?.timeline.map((t) => ({
label: t.date.slice(5),
value: t.estimatedUsd,
})) ?? [];
return (
<>
<Controls>
{(['day', 'week', 'month'] as Period[]).map((p) => (
<PeriodBtn key={p} $active={period === p} onClick={() => setPeriod(p)}>
{p === 'day' ? '오늘' : p === 'week' ? '7일' : '30일'}
</PeriodBtn>
))}
<RecordBtn onClick={recordAll} disabled={recording} style={{ marginLeft: 'auto' }}>
{recording ? '수집 중...' : '📊 토큰 수집'}
</RecordBtn>
</Controls>
{loading ? (
<div style={{ color: 'var(--text-secondary)', fontSize: '14px' }}> ...</div>
) : !data || data.summary.recordCount === 0 ? (
<EmptyNote>
.<br />
"토큰 수집" .
</EmptyNote>
) : (
<>
<SummaryGrid>
<SummaryCard>
<SummaryLabel> </SummaryLabel>
<SummaryValue>{formatTokens(data.summary.totalTokens)}<SummaryUnit>tokens</SummaryUnit></SummaryValue>
</SummaryCard>
<SummaryCard>
<SummaryLabel> </SummaryLabel>
<SummaryValue>${data.summary.estimatedUsd.toFixed(4)}<SummaryUnit>USD</SummaryUnit></SummaryValue>
</SummaryCard>
<SummaryCard>
<SummaryLabel> </SummaryLabel>
<SummaryValue>{formatTokens(data.summary.inputTokens)}</SummaryValue>
</SummaryCard>
<SummaryCard>
<SummaryLabel> </SummaryLabel>
<SummaryValue>{formatTokens(data.summary.outputTokens)}</SummaryValue>
</SummaryCard>
</SummaryGrid>
<ChartsGrid>
<Card>
<BarChart data={sisterChartData} unit="$" title="자매별 비용 (USD)" />
</Card>
<Card>
<BarChart data={modelChartData} unit=" tok" title="모델별 토큰 사용량" />
</Card>
</ChartsGrid>
{timelineChartData.length > 0 && (
<Card>
<BarChart data={timelineChartData} unit="$" title="일별 비용 추이" />
</Card>
)}
</>
)}
</>
);
}