Files
sam-manage/resources/views/system/ai-token-usage/index.blade.php
김보곤 55f604ce6f feat:AI 토큰 사용량 관리 화면 추가
- AiTokenUsageController (index, list) 생성
- AiTokenUsage 모델 생성
- React 기반 토큰 사용량 조회 페이지 (필터, 통계, 페이지네이션)
- 라우트 추가 (system/ai-token-usage)
- AiTokenUsageMenuSeeder 메뉴 시더 생성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 09:57:25 +09:00

370 lines
15 KiB
PHP

@extends('layouts.app')
@section('title', 'AI 토큰 사용량')
@push('styles')
<style>
.stat-card {
background: white;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-card .label { font-size: 13px; color: #6b7280; margin-bottom: 4px; }
.stat-card .value { font-size: 22px; font-weight: 700; color: #111827; }
.stat-card .sub { font-size: 12px; color: #9ca3af; margin-top: 2px; }
.filter-bar {
background: white;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
display: flex;
gap: 12px;
align-items: end;
flex-wrap: wrap;
}
.filter-bar label { display: block; font-size: 12px; color: #6b7280; margin-bottom: 4px; font-weight: 500; }
.filter-bar select,
.filter-bar input {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 6px 10px;
font-size: 13px;
min-width: 140px;
}
.filter-bar button {
padding: 7px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
background: #f9fafb;
font-size: 12px;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.data-table td {
padding: 10px 12px;
font-size: 13px;
color: #374151;
border-bottom: 1px solid #f3f4f6;
}
.data-table tr:hover td { background: #f9fafb; }
.data-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.pagination-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
}
.pagination-bar button {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.pagination-bar button:disabled { opacity: 0.4; cursor: not-allowed; }
.pagination-bar button:not(:disabled):hover { background: #f3f4f6; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.badge-blue { background: #dbeafe; color: #1d4ed8; }
.badge-green { background: #dcfce7; color: #16a34a; }
.badge-purple { background: #f3e8ff; color: #7c3aed; }
.empty-state {
text-align: center;
padding: 60px 20px;
color: #9ca3af;
}
.empty-state svg { margin: 0 auto 12px; }
</style>
@endpush
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="ai-token-usage-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useEffect, useRef } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const BrainCircuit = createIcon('brain-circuit');
const Search = createIcon('search');
const RotateCcw = createIcon('rotate-ccw');
const ChevronLeft = createIcon('chevron-left');
const ChevronRight = createIcon('chevron-right');
const Zap = createIcon('zap');
const DollarSign = createIcon('dollar-sign');
const Hash = createIcon('hash');
const ArrowUpDown = createIcon('arrow-up-down');
const fmt = (n) => n != null ? Number(n).toLocaleString() : '0';
const fmtUsd = (n) => n != null ? '$' + Number(n).toFixed(4) : '$0.0000';
const fmtKrw = (n) => n != null ? Number(n).toLocaleString('ko-KR', { maximumFractionDigits: 0 }) + '원' : '0원';
function AiTokenUsageApp() {
const [records, setRecords] = useState([]);
const [stats, setStats] = useState({});
const [filters, setFilters] = useState({ menu_names: [], tenants: [] });
const [pagination, setPagination] = useState({ current_page: 1, last_page: 1, per_page: 20, total: 0 });
const [loading, setLoading] = useState(true);
// 필터 상태
const today = new Date();
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
const [startDate, setStartDate] = useState(firstDay.toISOString().split('T')[0]);
const [endDate, setEndDate] = useState(today.toISOString().split('T')[0]);
const [tenantId, setTenantId] = useState('');
const [menuName, setMenuName] = useState('');
const [page, setPage] = useState(1);
const fetchData = async (p = 1) => {
setLoading(true);
try {
const params = new URLSearchParams();
if (startDate) params.set('start_date', startDate);
if (endDate) params.set('end_date', endDate);
if (tenantId) params.set('tenant_id', tenantId);
if (menuName) params.set('menu_name', menuName);
params.set('page', p);
params.set('per_page', 20);
const res = await fetch(`/system/ai-token-usage/list?${params}`);
const json = await res.json();
if (json.success) {
setRecords(json.data);
setStats(json.stats);
setFilters(json.filters);
setPagination(json.pagination);
setPage(json.pagination.current_page);
}
} catch (err) {
console.error('데이터 조회 실패:', err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchData(); }, []);
const handleSearch = () => { fetchData(1); };
const handleReset = () => {
setStartDate(firstDay.toISOString().split('T')[0]);
setEndDate(today.toISOString().split('T')[0]);
setTenantId('');
setMenuName('');
setTimeout(() => fetchData(1), 0);
};
const goPage = (p) => { fetchData(p); };
return (
<div className="space-y-4">
{/* 페이지 헤더 */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
<BrainCircuit className="w-7 h-7" />
AI 토큰 사용량
</h1>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div className="stat-card">
<div className="label"> 호출 </div>
<div className="value">{fmt(stats.total_count)}</div>
<div className="sub"></div>
</div>
<div className="stat-card">
<div className="label">입력 토큰</div>
<div className="value">{fmt(stats.total_prompt_tokens)}</div>
<div className="sub">tokens</div>
</div>
<div className="stat-card">
<div className="label">출력 토큰</div>
<div className="value">{fmt(stats.total_completion_tokens)}</div>
<div className="sub">tokens</div>
</div>
<div className="stat-card">
<div className="label">전체 토큰</div>
<div className="value">{fmt(stats.total_total_tokens)}</div>
<div className="sub">tokens</div>
</div>
<div className="stat-card">
<div className="label"> 비용 (USD)</div>
<div className="value">{fmtUsd(stats.total_cost_usd)}</div>
<div className="sub">달러</div>
</div>
<div className="stat-card">
<div className="label"> 비용 (KRW)</div>
<div className="value">{fmtKrw(stats.total_cost_krw)}</div>
<div className="sub"></div>
</div>
</div>
{/* 필터 바 */}
<div className="filter-bar">
<div>
<label>시작일</label>
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
</div>
<div>
<label>종료일</label>
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
</div>
<div>
<label>테넌트</label>
<select value={tenantId} onChange={e => setTenantId(e.target.value)}>
<option value="">전체</option>
{filters.tenants.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<div>
<label>호출 메뉴</label>
<select value={menuName} onChange={e => setMenuName(e.target.value)}>
<option value="">전체</option>
{filters.menu_names.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<button
onClick={handleSearch}
className="bg-blue-600 text-white hover:bg-blue-700 flex items-center gap-1"
>
<Search className="w-4 h-4" /> 조회
</button>
<button
onClick={handleReset}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 flex items-center gap-1"
>
<RotateCcw className="w-4 h-4" /> 초기화
</button>
</div>
{/* 데이터 테이블 */}
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
{loading ? (
<div className="text-center py-16 text-gray-400">
<div className="animate-spin inline-block w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mb-3"></div>
<p>데이터를 불러오는 ...</p>
</div>
) : records.length === 0 ? (
<div className="empty-state">
<BrainCircuit className="w-12 h-12 text-gray-300" />
<p className="text-lg font-medium text-gray-500 mt-2">사용 내역이 없습니다</p>
<p className="text-sm">AI 기능을 사용하면 토큰 사용량이 기록됩니다.</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>테넌트</th>
<th>사용일시</th>
<th>호출메뉴</th>
<th>모델</th>
<th className="num">입력토큰</th>
<th className="num">출력토큰</th>
<th className="num">전체토큰</th>
<th className="num">비용(USD)</th>
<th className="num">비용(KRW)</th>
</tr>
</thead>
<tbody>
{records.map(r => (
<tr key={r.id}>
<td>
<span className="badge badge-blue">{r.tenant_id}</span>
{' '}{r.tenant_name}
</td>
<td>{r.created_at}</td>
<td><span className="badge badge-green">{r.menu_name}</span></td>
<td><span className="badge badge-purple">{r.model}</span></td>
<td className="num">{fmt(r.prompt_tokens)}</td>
<td className="num">{fmt(r.completion_tokens)}</td>
<td className="num font-semibold">{fmt(r.total_tokens)}</td>
<td className="num">{fmtUsd(r.cost_usd)}</td>
<td className="num font-semibold">{fmtKrw(r.cost_krw)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<div className="pagination-bar px-4">
<span className="text-sm text-gray-500">
{fmt(pagination.total)} (페이지 {pagination.current_page}/{pagination.last_page})
</span>
<div className="flex gap-2">
<button
disabled={pagination.current_page <= 1}
onClick={() => goPage(pagination.current_page - 1)}
className="flex items-center gap-1"
>
<ChevronLeft className="w-4 h-4" /> 이전
</button>
<button
disabled={pagination.current_page >= pagination.last_page}
onClick={() => goPage(pagination.current_page + 1)}
className="flex items-center gap-1"
>
다음 <ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</>
)}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('ai-token-usage-root')).render(<AiTokenUsageApp />);
</script>
@endverbatim
@endpush