feat:AI 토큰 사용량 관리 화면 추가
- AiTokenUsageController (index, list) 생성 - AiTokenUsage 모델 생성 - React 기반 토큰 사용량 조회 페이지 (필터, 통계, 페이지네이션) - 라우트 추가 (system/ai-token-usage) - AiTokenUsageMenuSeeder 메뉴 시더 생성 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
123
app/Http/Controllers/System/AiTokenUsageController.php
Normal file
123
app/Http/Controllers/System/AiTokenUsageController.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\System;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\System\AiTokenUsage;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AiTokenUsageController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('system.ai-token-usage.index'));
|
||||
}
|
||||
|
||||
return view('system.ai-token-usage.index');
|
||||
}
|
||||
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = $request->input('per_page', 20);
|
||||
$startDate = $request->input('start_date');
|
||||
$endDate = $request->input('end_date');
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$menuName = $request->input('menu_name');
|
||||
|
||||
$query = AiTokenUsage::query()
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($tenantId) {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
if ($menuName) {
|
||||
$query->where('menu_name', $menuName);
|
||||
}
|
||||
|
||||
if ($startDate) {
|
||||
$query->whereDate('created_at', '>=', $startDate);
|
||||
}
|
||||
|
||||
if ($endDate) {
|
||||
$query->whereDate('created_at', '<=', $endDate);
|
||||
}
|
||||
|
||||
// 통계 (필터 조건 동일하게 적용)
|
||||
$statsQuery = clone $query;
|
||||
$stats = $statsQuery->selectRaw('
|
||||
COUNT(*) as total_count,
|
||||
SUM(prompt_tokens) as total_prompt_tokens,
|
||||
SUM(completion_tokens) as total_completion_tokens,
|
||||
SUM(total_tokens) as total_total_tokens,
|
||||
SUM(cost_usd) as total_cost_usd,
|
||||
SUM(cost_krw) as total_cost_krw
|
||||
')->first();
|
||||
|
||||
// 페이지네이션
|
||||
$records = $query->paginate($perPage);
|
||||
|
||||
// 테넌트 이름 매핑
|
||||
$tenantIds = $records->pluck('tenant_id')->unique();
|
||||
$tenants = Tenant::whereIn('id', $tenantIds)->pluck('company_name', 'id');
|
||||
|
||||
$data = $records->through(function ($item) use ($tenants) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'tenant_id' => $item->tenant_id,
|
||||
'tenant_name' => $tenants[$item->tenant_id] ?? '-',
|
||||
'model' => $item->model,
|
||||
'menu_name' => $item->menu_name,
|
||||
'prompt_tokens' => $item->prompt_tokens,
|
||||
'completion_tokens' => $item->completion_tokens,
|
||||
'total_tokens' => $item->total_tokens,
|
||||
'cost_usd' => (float) $item->cost_usd,
|
||||
'cost_krw' => (float) $item->cost_krw,
|
||||
'request_id' => $item->request_id,
|
||||
'created_at' => $item->created_at->format('Y-m-d H:i:s'),
|
||||
];
|
||||
});
|
||||
|
||||
// 필터용 메뉴 목록
|
||||
$menuNames = AiTokenUsage::select('menu_name')
|
||||
->distinct()
|
||||
->orderBy('menu_name')
|
||||
->pluck('menu_name');
|
||||
|
||||
// 필터용 테넌트 목록
|
||||
$allTenantIds = AiTokenUsage::select('tenant_id')
|
||||
->distinct()
|
||||
->pluck('tenant_id');
|
||||
$allTenants = Tenant::whereIn('id', $allTenantIds)
|
||||
->orderBy('company_name')
|
||||
->get(['id', 'company_name']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $data->items(),
|
||||
'stats' => [
|
||||
'total_count' => (int) ($stats->total_count ?? 0),
|
||||
'total_prompt_tokens' => (int) ($stats->total_prompt_tokens ?? 0),
|
||||
'total_completion_tokens' => (int) ($stats->total_completion_tokens ?? 0),
|
||||
'total_total_tokens' => (int) ($stats->total_total_tokens ?? 0),
|
||||
'total_cost_usd' => round((float) ($stats->total_cost_usd ?? 0), 6),
|
||||
'total_cost_krw' => round((float) ($stats->total_cost_krw ?? 0), 2),
|
||||
],
|
||||
'filters' => [
|
||||
'menu_names' => $menuNames,
|
||||
'tenants' => $allTenants->map(fn ($t) => ['id' => $t->id, 'name' => $t->company_name]),
|
||||
],
|
||||
'pagination' => [
|
||||
'current_page' => $records->currentPage(),
|
||||
'last_page' => $records->lastPage(),
|
||||
'per_page' => $records->perPage(),
|
||||
'total' => $records->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
app/Models/System/AiTokenUsage.php
Normal file
36
app/Models/System/AiTokenUsage.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AiTokenUsage extends Model
|
||||
{
|
||||
protected $table = 'ai_token_usages';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'model',
|
||||
'menu_name',
|
||||
'prompt_tokens',
|
||||
'completion_tokens',
|
||||
'total_tokens',
|
||||
'cost_usd',
|
||||
'cost_krw',
|
||||
'request_id',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'prompt_tokens' => 'integer',
|
||||
'completion_tokens' => 'integer',
|
||||
'total_tokens' => 'integer',
|
||||
'cost_usd' => 'decimal:6',
|
||||
'cost_krw' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function scopeForTenant($query, int $tenantId)
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
}
|
||||
63
database/seeders/AiTokenUsageMenuSeeder.php
Normal file
63
database/seeders/AiTokenUsageMenuSeeder.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Commons\Menu;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class AiTokenUsageMenuSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$tenantId = 1;
|
||||
|
||||
// 시스템 관리 부모 메뉴 찾기
|
||||
$parentMenu = Menu::where('tenant_id', $tenantId)
|
||||
->where('name', '시스템 관리')
|
||||
->first();
|
||||
|
||||
if (! $parentMenu) {
|
||||
$this->command->error('시스템 관리 메뉴를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 존재하는지 확인
|
||||
$existingMenu = Menu::where('tenant_id', $tenantId)
|
||||
->where('name', 'AI 토큰 사용량')
|
||||
->where('parent_id', $parentMenu->id)
|
||||
->first();
|
||||
|
||||
if ($existingMenu) {
|
||||
$this->command->info('AI 토큰 사용량 메뉴가 이미 존재합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 자식 메뉴 최대 sort_order 확인
|
||||
$maxSort = Menu::where('parent_id', $parentMenu->id)
|
||||
->max('sort_order') ?? 0;
|
||||
|
||||
// 메뉴 생성
|
||||
$menu = Menu::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_id' => $parentMenu->id,
|
||||
'name' => 'AI 토큰 사용량',
|
||||
'url' => '/system/ai-token-usage',
|
||||
'icon' => 'brain-circuit',
|
||||
'sort_order' => $maxSort + 1,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->command->info("메뉴 생성 완료: {$menu->name} (sort_order: {$menu->sort_order})");
|
||||
|
||||
// 하위 메뉴 목록 출력
|
||||
$this->command->info('');
|
||||
$this->command->info('=== 시스템 관리 하위 메뉴 ===');
|
||||
$children = Menu::where('parent_id', $parentMenu->id)
|
||||
->orderBy('sort_order')
|
||||
->get(['name', 'url', 'sort_order']);
|
||||
|
||||
foreach ($children as $child) {
|
||||
$this->command->info("{$child->sort_order}. {$child->name} ({$child->url})");
|
||||
}
|
||||
}
|
||||
}
|
||||
369
resources/views/system/ai-token-usage/index.blade.php
Normal file
369
resources/views/system/ai-token-usage/index.blade.php
Normal file
@@ -0,0 +1,369 @@
|
||||
@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
|
||||
@@ -34,6 +34,7 @@
|
||||
use App\Http\Controllers\RolePermissionController;
|
||||
use App\Http\Controllers\Sales\SalesProductController;
|
||||
use App\Http\Controllers\System\AiConfigController;
|
||||
use App\Http\Controllers\System\AiTokenUsageController;
|
||||
use App\Http\Controllers\System\HolidayController;
|
||||
use App\Http\Controllers\Stats\StatDashboardController;
|
||||
use App\Http\Controllers\System\SystemAlertController;
|
||||
@@ -404,6 +405,12 @@
|
||||
Route::delete('/{id}', [HolidayController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// AI 토큰 사용량 관리
|
||||
Route::prefix('system/ai-token-usage')->name('system.ai-token-usage.')->group(function () {
|
||||
Route::get('/', [AiTokenUsageController::class, 'index'])->name('index');
|
||||
Route::get('/list', [AiTokenUsageController::class, 'list'])->name('list');
|
||||
});
|
||||
|
||||
// 명함 OCR API
|
||||
Route::post('/api/business-card-ocr', [BusinessCardOcrController::class, 'process'])->name('api.business-card-ocr');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user