From e8fafaf5f47e831338539807989051b30a878b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 18 Mar 2026 13:59:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[subscription]=20=EA=B5=AC=EB=8F=85?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20+?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EB=9F=89=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구독관리 UI/로직 대폭 개선 - 사용량 페이지 신규 추가 - 입고관리 액션 정리 --- .../(protected)/subscription/page.tsx | 105 +++--- src/app/[locale]/(protected)/usage/page.tsx | 16 + .../ReceivingManagement/ReceivingDetail.tsx | 1 + .../material/ReceivingManagement/actions.ts | 52 --- .../SubscriptionClient.tsx | 191 ++++------- .../SubscriptionManagement.tsx | 324 ++++++++++++------ .../SubscriptionManagement/actions.ts | 12 +- .../settings/SubscriptionManagement/types.ts | 94 +++-- .../settings/SubscriptionManagement/utils.ts | 95 +++-- 9 files changed, 494 insertions(+), 396 deletions(-) create mode 100644 src/app/[locale]/(protected)/usage/page.tsx diff --git a/src/app/[locale]/(protected)/subscription/page.tsx b/src/app/[locale]/(protected)/subscription/page.tsx index 30d7733b..11cf6b5f 100644 --- a/src/app/[locale]/(protected)/subscription/page.tsx +++ b/src/app/[locale]/(protected)/subscription/page.tsx @@ -4,68 +4,69 @@ import { useEffect, useState } from 'react'; import { CreditCard } from 'lucide-react'; import { SubscriptionManagement } from '@/components/settings/SubscriptionManagement'; import { getSubscriptionData } from '@/components/settings/SubscriptionManagement/actions'; +import type { SubscriptionInfo } from '@/components/settings/SubscriptionManagement/types'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; +function SubscriptionSkeleton() { + return ( + + +
+
+ {[1, 2, 3].map((i) => ( + + + + + + + ))} +
+ + + +
+ {[1, 2].map((i) => ( +
+
+ + +
+ +
+ ))} +
+
+
+ + + + + + + +
+
+ ); +} + export default function SubscriptionPage() { - const [data, setData] = useState>['data']>(); + const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { getSubscriptionData() - .then(result => { - setData(result.data); - }) + .then(result => setData(result.data)) .finally(() => setIsLoading(false)); }, []); - if (isLoading) { - return ( - - - {/* 헤더 액션 버튼 스켈레톤 */} -
- - -
-
- {/* 구독 정보 카드 그리드 스켈레톤 */} -
- {[1, 2, 3].map((i) => ( - - - - - - - ))} -
- {/* 구독 정보 카드 스켈레톤 */} - - - - -
- {[1, 2, 3].map((i) => ( -
- - - -
- ))} -
-
-
-
-
- ); - } - - return ; -} \ No newline at end of file + if (isLoading) return ; + return ; +} diff --git a/src/app/[locale]/(protected)/usage/page.tsx b/src/app/[locale]/(protected)/usage/page.tsx new file mode 100644 index 00000000..fb22e2ca --- /dev/null +++ b/src/app/[locale]/(protected)/usage/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +/** + * /usage → /subscription 리다이렉트 + */ + +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; + +export default function UsageRedirect() { + const router = useRouter(); + useEffect(() => { + router.replace('/subscription'); + }, [router]); + return null; +} diff --git a/src/components/material/ReceivingManagement/ReceivingDetail.tsx b/src/components/material/ReceivingManagement/ReceivingDetail.tsx index 1e323744..f5dc2a9f 100644 --- a/src/components/material/ReceivingManagement/ReceivingDetail.tsx +++ b/src/components/material/ReceivingManagement/ReceivingDetail.tsx @@ -787,6 +787,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) { specification: item.specification || '', })); }} + itemType="RM,SM,CS" /> { - if (USE_MOCK_DATA) { - if (!query) return { success: true, data: MOCK_ITEMS }; - const q = query.toLowerCase(); - const filtered = MOCK_ITEMS.filter( - (item) => - item.value.toLowerCase().includes(q) || - item.itemName.toLowerCase().includes(q) - ); - return { success: true, data: filtered }; - } - - interface ItemApiData { data: Array> } - const result = await executeServerAction({ - url: buildApiUrl('/api/v1/items', { search: query, per_page: 200, item_type: 'RM,SM,CS' }), - transform: (d) => (d.data || []).map((item) => ({ - value: item.item_code, - label: item.item_code, - description: `${item.item_name} (${item.specification || '-'})`, - itemName: item.item_name, - specification: item.specification || '', - unit: item.unit || 'EA', - })), - errorMessage: '품목 검색에 실패했습니다.', - }); - return { success: result.success, data: result.data || [] }; -} - // ===== 발주처 검색 (입고 등록용) ===== export interface SupplierOption { value: string; diff --git a/src/components/settings/SubscriptionManagement/SubscriptionClient.tsx b/src/components/settings/SubscriptionManagement/SubscriptionClient.tsx index e59e583b..5a26f73d 100644 --- a/src/components/settings/SubscriptionManagement/SubscriptionClient.tsx +++ b/src/components/settings/SubscriptionManagement/SubscriptionClient.tsx @@ -1,10 +1,14 @@ 'use client'; +/** + * SubscriptionClient — 대체 구독관리 컴포넌트 (SubscriptionManagement.tsx 사용 권장) + * 기존 호환성 유지를 위해 보존 + */ + import { useState, useCallback } from 'react'; import { CreditCard, Download, AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { Progress } from '@/components/ui/progress'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { PageLayout } from '@/components/organisms/PageLayout'; @@ -13,25 +17,31 @@ import { toast } from 'sonner'; import { usePermission } from '@/hooks/usePermission'; import { cancelSubscription, requestDataExport } from './actions'; import type { SubscriptionInfo } from './types'; -import { PLAN_LABELS, SUBSCRIPTION_STATUS_LABELS } from './types'; +import { SUBSCRIPTION_STATUS_LABELS } from './types'; +import { formatKrw, getProgressColor } from './utils'; import { formatAmountWon as formatCurrency } from '@/lib/utils/amount'; -// ===== Props 타입 ===== interface SubscriptionClientProps { initialData: SubscriptionInfo; } -// ===== 날짜 포맷 함수 ===== -const formatDate = (dateStr: string): string => { +const formatDate = (dateStr: string | null): string => { if (!dateStr) return '-'; const date = new Date(dateStr); if (isNaN(date.getTime())) return '-'; - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - return `${year}년 ${month}월 ${day}일`; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; }; +function ColoredProgress({ value }: { value: number }) { + const color = getProgressColor(value); + const clamped = Math.min(value, 100); + return ( +
+
+
+ ); +} + export function SubscriptionClient({ initialData }: SubscriptionClientProps) { const { canExport } = usePermission(); const [subscription, setSubscription] = useState(initialData); @@ -39,72 +49,40 @@ export function SubscriptionClient({ initialData }: SubscriptionClientProps) { const [isExporting, setIsExporting] = useState(false); const [isCancelling, setIsCancelling] = useState(false); - // ===== 자료 내보내기 ===== const handleExportData = useCallback(async () => { setIsExporting(true); try { const result = await requestDataExport('all'); - if (result.success) { - toast.success('내보내기 요청이 등록되었습니다. 완료되면 알림을 보내드립니다.'); - } else { - toast.error(result.error || '내보내기 요청에 실패했습니다.'); - } - } catch (_error) { - toast.error('서버 오류가 발생했습니다.'); - } finally { - setIsExporting(false); - } + if (result.success) toast.success('내보내기 요청이 등록되었습니다.'); + else toast.error(result.error || '내보내기 요청에 실패했습니다.'); + } catch { toast.error('서버 오류가 발생했습니다.'); } + finally { setIsExporting(false); } }, []); - // ===== 서비스 해지 ===== const handleCancelService = useCallback(async () => { - if (!subscription.id) { - toast.error('구독 정보를 찾을 수 없습니다.'); - setShowCancelDialog(false); - return; - } - + if (!subscription.id) { toast.error('구독 정보를 찾을 수 없습니다.'); setShowCancelDialog(false); return; } setIsCancelling(true); try { const result = await cancelSubscription(subscription.id, '사용자 요청'); - if (result.success) { - toast.success('서비스가 해지되었습니다.'); - setSubscription(prev => ({ ...prev, status: 'cancelled' })); - } else { - toast.error(result.error || '서비스 해지에 실패했습니다.'); - } - } catch { - toast.error('서버 오류가 발생했습니다.'); - } finally { - setIsCancelling(false); - setShowCancelDialog(false); - } + if (result.success) { toast.success('서비스가 해지되었습니다.'); setSubscription(prev => ({ ...prev, status: 'cancelled' })); } + else toast.error(result.error || '서비스 해지에 실패했습니다.'); + } catch { toast.error('서버 오류가 발생했습니다.'); } + finally { setIsCancelling(false); setShowCancelDialog(false); } }, [subscription.id]); - // ===== Progress 계산 ===== - const storageProgress = subscription.storageLimit > 0 - ? (subscription.storageUsed / subscription.storageLimit) * 100 - : 0; - const userProgress = subscription.userLimit - ? (subscription.userCount / subscription.userLimit) * 100 - : 30; // 무제한일 경우 30%로 표시 + const userPercentage = subscription.userLimit ? (subscription.userCount / subscription.userLimit) * 100 : 30; return ( <> - {/* ===== 페이지 헤더 ===== */} {canExport && ( - @@ -122,85 +100,62 @@ export function SubscriptionClient({ initialData }: SubscriptionClientProps) { />
- {/* ===== 구독 정보 카드 영역 ===== */}
- {/* 최근 결제일시 */} -
최근 결제일시
-
- {formatDate(subscription.lastPaymentDate)} -
+
요금제
+
{subscription.planName}
+
시작: {formatDate(subscription.startedAt)}
- - {/* 다음 결제일시 */} -
다음 결제일시
-
- {formatDate(subscription.nextPaymentDate)} -
+
구독 상태
+ + {SUBSCRIPTION_STATUS_LABELS[subscription.status] || subscription.status} + {subscription.remainingDays != null && subscription.remainingDays > 0 && ( -
- ({subscription.remainingDays}일 남음) -
+
남은 일: {subscription.remainingDays}일
)}
- - {/* 구독금액 */} -
구독금액
-
- {formatCurrency(subscription.subscriptionAmount)} -
+
구독 금액
+
{formatCurrency(subscription.monthlyFee)}/월
+
종료: {formatDate(subscription.endedAt)}
- {/* ===== 구독 정보 영역 ===== */} -
-
구독 정보
- - {(subscription.status && SUBSCRIPTION_STATUS_LABELS[subscription.status]) || subscription.status} - -
- - {/* 플랜명 */} -

- {subscription.planName || PLAN_LABELS[subscription.plan]} -

- - {/* 사용량 정보 */} -
- {/* 사용자 수 */} -
-
- 사용자 수 -
-
- -
-
- {subscription.userCount}명 / {subscription.userLimit ? `${subscription.userLimit}명` : '무제한'} +
리소스 사용량
+
+
+
+ 사용자 + + {subscription.userCount}명 / {subscription.userLimit ? `${subscription.userLimit}명` : '무제한'} +
+
- - {/* 저장 공간 */} -
-
- 저장 공간 +
+
+ 저장 공간 + + {subscription.storageUsedFormatted} / {subscription.storageLimitFormatted} +
-
- -
-
- {subscription.storageUsedFormatted} / {subscription.storageLimitFormatted} + +
+
+
+ AI 토큰 + {formatKrw(subscription.aiTokens.costKrw)}
+
@@ -208,27 +163,13 @@ export function SubscriptionClient({ initialData }: SubscriptionClientProps) {
- {/* ===== 서비스 해지 확인 다이얼로그 ===== */} - - 서비스 해지 - - } - description={ - <> - 모든 데이터가 삭제되며 복구할 수 없습니다. -
- - 정말 서비스를 해지하시겠습니까? - - - } + title={서비스 해지} + description={<>모든 데이터가 삭제되며 복구할 수 없습니다.
정말 서비스를 해지하시겠습니까?} confirmText="확인" loading={isCancelling} /> diff --git a/src/components/settings/SubscriptionManagement/SubscriptionManagement.tsx b/src/components/settings/SubscriptionManagement/SubscriptionManagement.tsx index 73fef0b7..8c85ea13 100644 --- a/src/components/settings/SubscriptionManagement/SubscriptionManagement.tsx +++ b/src/components/settings/SubscriptionManagement/SubscriptionManagement.tsx @@ -1,47 +1,85 @@ 'use client'; +/** + * 구독관리 (구독관리 통합) 페이지 + * + * 4섹션: 구독정보 카드 / 리소스 사용량 / AI 토큰 사용량 / 서비스 관리 + */ + import { useState, useCallback } from 'react'; -import { CreditCard, Download, AlertTriangle } from 'lucide-react'; +import { CreditCard, Download, AlertTriangle, Cpu } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import type { SubscriptionInfo } from './types'; -import { PLAN_LABELS } from './types'; +import { SUBSCRIPTION_STATUS_LABELS } from './types'; import { requestDataExport, cancelSubscription } from './actions'; -import { formatAmountWon as formatCurrency, formatNumber } from '@/lib/utils/amount'; +import { formatTokenCount, formatKrw, formatPeriod, getProgressColor } from './utils'; +import { formatAmountWon as formatCurrency } from '@/lib/utils/amount'; -// ===== 기본 저장공간 (10GB) ===== -const DEFAULT_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024; // 10GB in bytes +// ===== 날짜 포맷 ===== +const formatDate = (dateStr: string | null): string => { + if (!dateStr) return '-'; + const date = new Date(dateStr); + if (isNaN(date.getTime())) return '-'; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +}; -// ===== 기본값 (API 실패시 사용) ===== +// ===== 기본값 ===== const defaultSubscription: SubscriptionInfo = { - lastPaymentDate: '', - nextPaymentDate: '', - subscriptionAmount: 0, plan: 'free', + planName: '무료', + monthlyFee: 0, + status: 'pending', + startedAt: null, + endedAt: null, + remainingDays: null, userCount: 0, userLimit: null, storageUsed: 0, - storageLimit: DEFAULT_STORAGE_LIMIT, + storageLimit: 107_374_182_400, storageUsedFormatted: '0 B', - storageLimitFormatted: '10 GB', - apiCallsUsed: 0, - apiCallsLimit: 10000, + storageLimitFormatted: '100 GB', + storagePercentage: 0, + aiTokens: { + period: '', + totalTokens: 0, + limit: 1_000_000, + percentage: 0, + costKrw: 0, + warningThreshold: 80, + isOverLimit: false, + byModel: [], + }, }; -// ===== 날짜 포맷 함수 ===== -const formatDate = (dateStr: string): string => { - if (!dateStr) return '-'; - const date = new Date(dateStr); - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - return `${year}년 ${month}월 ${day}일`; -}; +// ===== 색상이 적용된 Progress Bar ===== +function ColoredProgress({ value, className = '' }: { value: number; className?: string }) { + const color = getProgressColor(value); + const clampedValue = Math.min(value, 100); + + return ( +
+
+
+ ); +} interface SubscriptionManagementProps { initialData: SubscriptionInfo | null; @@ -53,6 +91,8 @@ export function SubscriptionManagement({ initialData }: SubscriptionManagementPr const [isExporting, setIsExporting] = useState(false); const [isCancelling, setIsCancelling] = useState(false); + const { aiTokens } = subscription; + // ===== 자료 내보내기 ===== const handleExportData = useCallback(async () => { setIsExporting(true); @@ -96,115 +136,207 @@ export function SubscriptionManagement({ initialData }: SubscriptionManagementPr }, [subscription.id]); // ===== Progress 계산 ===== - const storageProgress = subscription.storageLimit ? (subscription.storageUsed / subscription.storageLimit) * 100 : 0; - const apiCallsUsed = subscription.apiCallsUsed ?? 0; - const apiCallsLimit = subscription.apiCallsLimit ?? 0; - const apiProgress = apiCallsLimit > 0 ? (apiCallsUsed / apiCallsLimit) * 100 : 0; + const userPercentage = subscription.userLimit + ? (subscription.userCount / subscription.userLimit) * 100 + : 30; return ( <> - {/* ===== 페이지 헤더 ===== */} - {/* ===== 헤더 액션 버튼 ===== */} -
- - -
-
- {/* ===== 구독 정보 카드 영역 ===== */} -
- {/* 최근 결제일시 */} + + {/* ===== 섹션 1: 구독 정보 카드 ===== */} + {subscription.planName ? ( +
+ {/* 요금제 */} + + +
요금제
+
{subscription.planName}
+
+ 시작: {formatDate(subscription.startedAt)} +
+
+
+ + {/* 구독 상태 */} + + +
구독 상태
+
+ + {SUBSCRIPTION_STATUS_LABELS[subscription.status] || subscription.status} + +
+ {subscription.remainingDays != null && subscription.remainingDays > 0 && ( +
+ 남은 일: {subscription.remainingDays}일 +
+ )} +
+
+ + {/* 구독 금액 */} + + +
구독 금액
+
+ {formatCurrency(subscription.monthlyFee)}/월 +
+
+ 종료: {formatDate(subscription.endedAt)} +
+
+
+
+ ) : ( - -
최근 결제일시
-
- {formatDate(subscription.lastPaymentDate)} -
+ + 구독 정보가 없습니다. 관리자에게 문의하세요.
+ )} - {/* 다음 결제일시 */} - - -
다음 결제일시
-
- {formatDate(subscription.nextPaymentDate)} -
-
-
- - {/* 구독금액 */} - - -
구독금액
-
- {formatCurrency(subscription.subscriptionAmount)} -
-
-
-
- - {/* ===== 구독 정보 영역 ===== */} + {/* ===== 섹션 2: 리소스 사용량 ===== */} - -
구독 정보
- - {/* 플랜명 */} -

- {PLAN_LABELS[subscription.plan]} -

- - {/* 사용량 정보 */} + + 리소스 사용량 + +
- {/* 사용자 수 */} + {/* 사용자 */}
- 사용자 수 - + 사용자 + {subscription.userCount}명 / {subscription.userLimit ? `${subscription.userLimit}명` : '무제한'}
- +
{/* 저장 공간 */}
저장 공간 - + {subscription.storageUsedFormatted} / {subscription.storageLimitFormatted}
- +
+
+
+
- {/* AI API 호출 */} + {/* ===== 섹션 3: AI 토큰 사용량 ===== */} + + +
+ + + AI 토큰 사용량 + {aiTokens.period && ( + + — {formatPeriod(aiTokens.period)} + + )} + +
+ {aiTokens.isOverLimit && ( + 한도 초과 — 초과분 실비 과금 + )} + {!aiTokens.isOverLimit && aiTokens.percentage >= aiTokens.warningThreshold && ( + + 기본 제공량의 {aiTokens.percentage.toFixed(0)}% 사용 중 + + )} +
+
+
+ +
+ {/* 토큰 사용량 Progress */}
- AI API 호출 - - {formatNumber(apiCallsUsed)} / {formatNumber(apiCallsLimit)} + + {formatTokenCount(aiTokens.totalTokens)} / {formatTokenCount(aiTokens.limit)} + + + {aiTokens.percentage.toFixed(1)}%
- +
+ + {/* 총 비용 */} +
+ 총 비용: {formatKrw(aiTokens.costKrw)} +
+ + {/* 모델별 사용량 테이블 */} + {aiTokens.byModel.length > 0 && ( +
+
모델별 사용량
+
+ + + + 모델 + 호출수 + 토큰 + 비용 + + + + {aiTokens.byModel.map((m) => ( + + {m.model} + {m.requests.toLocaleString()} + {formatTokenCount(m.total_tokens)} + {formatKrw(m.cost_krw)} + + ))} + +
+
+
+ )} + + {/* 안내 문구 */} +
+

※ 기본 제공: 월 {formatTokenCount(aiTokens.limit)} 토큰. 초과 시 실비 과금

+

※ 매월 1일 리셋, 잔여 토큰 이월 불가

+
+
+
+
+ + {/* ===== 섹션 4: 서비스 관리 ===== */} + + + 서비스 관리 + + +
+ +
@@ -237,4 +369,4 @@ export function SubscriptionManagement({ initialData }: SubscriptionManagementPr /> ); -} \ No newline at end of file +} diff --git a/src/components/settings/SubscriptionManagement/actions.ts b/src/components/settings/SubscriptionManagement/actions.ts index 5edd2c51..247e677c 100644 --- a/src/components/settings/SubscriptionManagement/actions.ts +++ b/src/components/settings/SubscriptionManagement/actions.ts @@ -1,17 +1,15 @@ 'use server'; - import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action'; +import { buildApiUrl } from '@/lib/api/query-params'; import type { SubscriptionApiData, UsageApiData, SubscriptionInfo } from './types'; import { transformApiToFrontend } from './utils'; -const API_URL = process.env.NEXT_PUBLIC_API_URL; - // ===== 현재 활성 구독 조회 ===== export async function getCurrentSubscription(): Promise> { return executeServerAction({ - url: `${API_URL}/api/v1/subscriptions/current`, + url: buildApiUrl('/api/v1/subscriptions/current'), errorMessage: '구독 정보를 불러오는데 실패했습니다.', }); } @@ -19,7 +17,7 @@ export async function getCurrentSubscription(): Promise> { return executeServerAction({ - url: `${API_URL}/api/v1/subscriptions/usage`, + url: buildApiUrl('/api/v1/subscriptions/usage'), errorMessage: '사용량 정보를 불러오는데 실패했습니다.', }); } @@ -30,7 +28,7 @@ export async function cancelSubscription( reason?: string ): Promise { return executeServerAction({ - url: `${API_URL}/api/v1/subscriptions/${id}/cancel`, + url: buildApiUrl(`/api/v1/subscriptions/${id}/cancel`), method: 'POST', body: { reason }, errorMessage: '구독 취소에 실패했습니다.', @@ -42,7 +40,7 @@ export async function requestDataExport( exportType: string = 'all' ): Promise> { return executeServerAction({ - url: `${API_URL}/api/v1/subscriptions/export`, + url: buildApiUrl('/api/v1/subscriptions/export'), method: 'POST', body: { export_type: exportType }, transform: (data: { id: number; status: string }) => ({ id: data.id, status: data.status }), diff --git a/src/components/settings/SubscriptionManagement/types.ts b/src/components/settings/SubscriptionManagement/types.ts index 584138d3..3e941a26 100644 --- a/src/components/settings/SubscriptionManagement/types.ts +++ b/src/components/settings/SubscriptionManagement/types.ts @@ -4,7 +4,7 @@ export type SubscriptionStatus = 'active' | 'pending' | 'expired' | 'cancelled' // ===== 플랜 타입 ===== export type PlanType = 'free' | 'basic' | 'premium' | 'enterprise'; -// ===== API 응답 타입 ===== +// ===== API 응답 타입: 구독 정보 ===== export interface SubscriptionApiData { id: number; tenant_id: number; @@ -30,25 +30,50 @@ export interface SubscriptionApiData { }>; } +// ===== API 응답 타입: 사용량 (ai_tokens 포함, api_calls 제거) ===== export interface UsageApiData { - subscription?: { - remaining_days: number | null; - }; users?: { used: number; limit: number; - }; - storage?: { - used: number; - limit: number; - used_formatted: string; - limit_formatted: string; - }; - api_calls?: { - used: number; - limit: number; percentage: number; }; + storage?: { + used: number; + used_formatted: string; + limit: number; + limit_formatted: string; + percentage: number; + }; + ai_tokens?: { + period: string; + total_requests: number; + total_tokens: number; + prompt_tokens: number; + completion_tokens: number; + limit: number; + percentage: number; + cost_usd: number; + cost_krw: number; + warning_threshold: number; + is_over_limit: boolean; + by_model: AiTokenByModel[]; + }; + subscription?: { + plan: string | null; + monthly_fee: number; + status: string; + started_at: string | null; + ended_at: string | null; + remaining_days: number | null; + }; +} + +// ===== AI 토큰 모델별 사용량 ===== +export interface AiTokenByModel { + model: string; + requests: number; + total_tokens: number; + cost_krw: number; } // ===== 플랜 코드 → 타입 변환 ===== @@ -68,26 +93,37 @@ export interface SubscriptionInfo { // 구독 ID (취소 시 필요) id?: number; - // 결제 정보 - lastPaymentDate: string; - nextPaymentDate: string; - subscriptionAmount: number; - // 구독 플랜 정보 plan: PlanType; - planName?: string; - status?: SubscriptionStatus; - remainingDays?: number | null; + planName: string; + monthlyFee: number; + status: SubscriptionStatus; + startedAt: string | null; + endedAt: string | null; + remainingDays: number | null; - // 사용량 정보 + // 사용자 userCount: number; - userLimit: number | null; // null = 무제한 + userLimit: number | null; + + // 저장공간 storageUsed: number; storageLimit: number; - storageUsedFormatted?: string; - storageLimitFormatted?: string; - apiCallsUsed?: number; - apiCallsLimit?: number; + storageUsedFormatted: string; + storageLimitFormatted: string; + storagePercentage: number; + + // AI 토큰 + aiTokens: { + period: string; + totalTokens: number; + limit: number; + percentage: number; + costKrw: number; + warningThreshold: number; + isOverLimit: boolean; + byModel: AiTokenByModel[]; + }; } export const PLAN_LABELS: Record = { @@ -110,4 +146,4 @@ export const PLAN_COLORS: Record = { basic: 'bg-blue-100 text-blue-800', premium: 'bg-purple-100 text-purple-800', enterprise: 'bg-amber-100 text-amber-800', -}; \ No newline at end of file +}; diff --git a/src/components/settings/SubscriptionManagement/utils.ts b/src/components/settings/SubscriptionManagement/utils.ts index d29ab0c3..78e0bae9 100644 --- a/src/components/settings/SubscriptionManagement/utils.ts +++ b/src/components/settings/SubscriptionManagement/utils.ts @@ -6,9 +6,6 @@ import type { } from './types'; import { mapPlanCodeToType } from './types'; -// ===== 기본 저장공간 제한 (10GB in bytes) ===== -const DEFAULT_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024; // 10GB - // ===== 바이트 → 읽기 쉬운 단위 변환 ===== export function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; @@ -18,63 +15,91 @@ export function formatBytes(bytes: number): string { const i = Math.floor(Math.log(bytes) / Math.log(k)); const value = bytes / Math.pow(k, i); - // 소수점 처리: 정수면 소수점 없이, 아니면 최대 2자리 const formatted = value % 1 === 0 ? value.toString() : value.toFixed(2).replace(/\.?0+$/, ''); return `${formatted} ${units[i]}`; } +// ===== 토큰 수 포맷: 1,000,000 → "1.0M", 496,000 → "496K" ===== +export function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1_000) return `${Math.round(tokens / 1_000).toLocaleString()}K`; + return tokens.toLocaleString(); +} + +// ===== 원화 포맷: 1234 → "₩1,234" ===== +export function formatKrw(amount: number): string { + return `₩${Math.round(amount).toLocaleString()}`; +} + +// ===== 월 표시: "2026-03" → "2026년 3월" ===== +export function formatPeriod(period: string): string { + const [year, month] = period.split('-'); + return `${year}년 ${parseInt(month)}월`; +} + +// ===== Progress Bar 색상 결정 ===== +export function getProgressColor(percentage: number): string { + if (percentage > 100) return 'bg-red-500'; + if (percentage >= 80) return 'bg-orange-500'; + if (percentage >= 60) return 'bg-yellow-500'; + return 'bg-blue-500'; +} + // ===== API → Frontend 변환 ===== export function transformApiToFrontend( subscriptionData: SubscriptionApiData | null, usageData: UsageApiData | null ): SubscriptionInfo { const plan = subscriptionData?.plan; - const payments = subscriptionData?.payments || []; - const lastPayment = payments.find(p => p.status === 'completed'); - - // 플랜 코드 → 타입 변환 const planType = mapPlanCodeToType(plan?.code || null); - // 다음 결제일 (ended_at이 다음 결제일) - const nextPaymentDate = subscriptionData?.ended_at?.split('T')[0] || ''; - - // 마지막 결제일 - const lastPaymentDate = lastPayment?.paid_at?.split('T')[0] || ''; - - // 구독 금액 const price = plan?.price; - const subscriptionAmount = typeof price === 'string' ? parseFloat(price) : (price || 0); + const monthlyFee = typeof price === 'string' ? parseFloat(price) : (price || 0); - // 상태 매핑 const status = (subscriptionData?.status || 'pending') as SubscriptionStatus; - return { - // 구독 ID - id: subscriptionData?.id, + // usage API의 subscription 데이터 (통합 응답) + const usageSub = usageData?.subscription; - // 결제 정보 - lastPaymentDate, - nextPaymentDate, - subscriptionAmount, + // AI 토큰 기본값 + const aiTokensRaw = usageData?.ai_tokens; + const aiTokens = { + period: aiTokensRaw?.period || '', + totalTokens: aiTokensRaw?.total_tokens || 0, + limit: aiTokensRaw?.limit || 1_000_000, + percentage: aiTokensRaw?.percentage || 0, + costKrw: aiTokensRaw?.cost_krw || 0, + warningThreshold: aiTokensRaw?.warning_threshold || 80, + isOverLimit: aiTokensRaw?.is_over_limit || false, + byModel: aiTokensRaw?.by_model || [], + }; + + return { + id: subscriptionData?.id, // 구독 플랜 정보 plan: planType, - planName: plan?.name || PLAN_LABELS_LOCAL[planType], - status, - remainingDays: usageData?.subscription?.remaining_days ?? null, + planName: usageSub?.plan || plan?.name || PLAN_LABELS_LOCAL[planType], + monthlyFee: usageSub?.monthly_fee ?? monthlyFee, + status: (usageSub?.status as SubscriptionStatus) || status, + startedAt: usageSub?.started_at || subscriptionData?.started_at || null, + endedAt: usageSub?.ended_at || subscriptionData?.ended_at || null, + remainingDays: usageSub?.remaining_days ?? null, - // 사용량 정보 + // 사용자 userCount: usageData?.users?.used ?? 0, userLimit: usageData?.users?.limit === 0 ? null : (usageData?.users?.limit ?? null), - storageUsed: usageData?.storage?.used ?? 0, - storageLimit: usageData?.storage?.limit || DEFAULT_STORAGE_LIMIT, - storageUsedFormatted: usageData?.storage?.used_formatted || formatBytes(usageData?.storage?.used ?? 0), - storageLimitFormatted: usageData?.storage?.limit_formatted || formatBytes(usageData?.storage?.limit || DEFAULT_STORAGE_LIMIT), - // API 호출 사용량 (일간) - apiCallsUsed: usageData?.api_calls?.used ?? 0, - apiCallsLimit: usageData?.api_calls?.limit ?? 10000, + // 저장공간 + storageUsed: usageData?.storage?.used ?? 0, + storageLimit: usageData?.storage?.limit || 107_374_182_400, + storageUsedFormatted: usageData?.storage?.used_formatted || formatBytes(usageData?.storage?.used ?? 0), + storageLimitFormatted: usageData?.storage?.limit_formatted || '100 GB', + storagePercentage: usageData?.storage?.percentage ?? 0, + + // AI 토큰 + aiTokens, }; }