- {/* ===== 구독 정보 카드 영역 ===== */}
-
- {/* 최근 결제일시 */}
+
+ {/* ===== 섹션 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,
};
}