- 디자인 시스템: #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 성공
231 lines
6.5 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|