feat: [subscription] 구독관리 리팩토링 + 사용량 페이지 추가
- 구독관리 UI/로직 대폭 개선 - 사용량 페이지 신규 추가 - 입고관리 액션 정리
This commit is contained in:
@@ -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 (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="구독관리"
|
||||
description="구독 정보와 사용량을 관리합니다"
|
||||
icon={CreditCard}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-4 w-28 mb-4" />
|
||||
<div className="space-y-5">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-4 w-32 mb-4" />
|
||||
<Skeleton className="h-2 w-full mb-4" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getSubscriptionData>>['data']>();
|
||||
const [data, setData] = useState<SubscriptionInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getSubscriptionData()
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
})
|
||||
.then(result => setData(result.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="구독관리"
|
||||
description="구독 정보를 관리합니다"
|
||||
icon={CreditCard}
|
||||
/>
|
||||
{/* 헤더 액션 버튼 스켈레톤 */}
|
||||
<div className="flex justify-end gap-2 mb-4">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{/* 구독 정보 카드 그리드 스켈레톤 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{/* 구독 정보 카드 스켈레톤 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-4 w-20 mb-2" />
|
||||
<Skeleton className="h-6 w-32 mb-6" />
|
||||
<div className="space-y-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-24 flex-shrink-0" />
|
||||
<Skeleton className="h-2 flex-1" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return <SubscriptionManagement initialData={data ?? null} />;
|
||||
}
|
||||
if (isLoading) return <SubscriptionSkeleton />;
|
||||
return <SubscriptionManagement initialData={data} />;
|
||||
}
|
||||
|
||||
16
src/app/[locale]/(protected)/usage/page.tsx
Normal file
16
src/app/[locale]/(protected)/usage/page.tsx
Normal file
@@ -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;
|
||||
}
|
||||
@@ -787,6 +787,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
specification: item.specification || '',
|
||||
}));
|
||||
}}
|
||||
itemType="RM,SM,CS"
|
||||
/>
|
||||
|
||||
<SupplierSearchModal
|
||||
|
||||
@@ -683,58 +683,6 @@ export async function processReceiving(
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 품목 검색 (입고 등록용) =====
|
||||
export interface ItemOption {
|
||||
value: string; // itemCode
|
||||
label: string; // itemCode 표시
|
||||
description?: string; // 품목명 + 규격
|
||||
itemName: string;
|
||||
specification: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
const MOCK_ITEMS: ItemOption[] = [
|
||||
{ value: 'STEEL-001', label: 'STEEL-001', description: 'SUS304 스테인리스 판재 (1000x2000x3T)', itemName: 'SUS304 스테인리스 판재', specification: '1000x2000x3T', unit: 'EA' },
|
||||
{ value: 'STEEL-002', label: 'STEEL-002', description: '알루미늄 프로파일 (40x40x2000L)', itemName: '알루미늄 프로파일', specification: '40x40x2000L', unit: 'EA' },
|
||||
{ value: 'ELEC-002', label: 'ELEC-002', description: 'MCU 컨트롤러 IC (STM32F103C8T6)', itemName: 'MCU 컨트롤러 IC', specification: 'STM32F103C8T6', unit: 'EA' },
|
||||
{ value: 'ELEC-005', label: 'ELEC-005', description: 'DC 모터 24V (24V 100RPM)', itemName: 'DC 모터 24V', specification: '24V 100RPM', unit: 'EA' },
|
||||
{ value: 'ELEC-007', label: 'ELEC-007', description: '커패시터 100uF (100uF 50V)', itemName: '커패시터 100uF', specification: '100uF 50V', unit: 'EA' },
|
||||
{ value: 'PLAS-003', label: 'PLAS-003', description: 'ABS 사출 케이스 (150x100x50)', itemName: 'ABS 사출 케이스', specification: '150x100x50', unit: 'SET' },
|
||||
{ value: 'CHEM-001', label: 'CHEM-001', description: '에폭시 접착제 (500ml)', itemName: '에폭시 접착제', specification: '500ml', unit: 'EA' },
|
||||
{ value: 'BOLT-001', label: 'BOLT-001', description: 'SUS 볼트 M8x30 (M8x30 SUS304)', itemName: 'SUS 볼트 M8x30', specification: 'M8x30 SUS304', unit: 'EA' },
|
||||
];
|
||||
|
||||
export async function searchItems(query?: string): Promise<{
|
||||
success: boolean;
|
||||
data: ItemOption[];
|
||||
}> {
|
||||
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<Record<string, string>> }
|
||||
const result = await executeServerAction<ItemApiData, ItemOption[]>({
|
||||
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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative h-2 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${clamped}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubscriptionClient({ initialData }: SubscriptionClientProps) {
|
||||
const { canExport } = usePermission();
|
||||
const [subscription, setSubscription] = useState<SubscriptionInfo>(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 (
|
||||
<>
|
||||
<PageLayout>
|
||||
{/* ===== 페이지 헤더 ===== */}
|
||||
<PageHeader
|
||||
title="구독관리"
|
||||
description="구독 정보를 관리합니다"
|
||||
description="구독 정보와 사용량을 관리합니다"
|
||||
icon={CreditCard}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{canExport && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExportData}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Button variant="outline" onClick={handleExportData} disabled={isExporting}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isExporting ? '처리 중...' : '자료 내보내기'}
|
||||
</Button>
|
||||
@@ -122,85 +100,62 @@ export function SubscriptionClient({ initialData }: SubscriptionClientProps) {
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* ===== 구독 정보 카드 영역 ===== */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* 최근 결제일시 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">최근 결제일시</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatDate(subscription.lastPaymentDate)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-1">요금제</div>
|
||||
<div className="text-2xl font-bold">{subscription.planName}</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">시작: {formatDate(subscription.startedAt)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 다음 결제일시 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">다음 결제일시</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatDate(subscription.nextPaymentDate)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-1">구독 상태</div>
|
||||
<Badge variant={subscription.status === 'active' ? 'default' : 'secondary'}>
|
||||
{SUBSCRIPTION_STATUS_LABELS[subscription.status] || subscription.status}
|
||||
</Badge>
|
||||
{subscription.remainingDays != null && subscription.remainingDays > 0 && (
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
({subscription.remainingDays}일 남음)
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">남은 일: {subscription.remainingDays}일</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 구독금액 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">구독금액</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(subscription.subscriptionAmount)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-1">구독 금액</div>
|
||||
<div className="text-2xl font-bold">{formatCurrency(subscription.monthlyFee)}/월</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">종료: {formatDate(subscription.endedAt)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ===== 구독 정보 영역 ===== */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-muted-foreground">구독 정보</div>
|
||||
<Badge variant={subscription.status === 'active' ? 'default' : 'secondary'}>
|
||||
{(subscription.status && SUBSCRIPTION_STATUS_LABELS[subscription.status]) || subscription.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 플랜명 */}
|
||||
<h3 className="text-xl font-bold mb-6">
|
||||
{subscription.planName || PLAN_LABELS[subscription.plan]}
|
||||
</h3>
|
||||
|
||||
{/* 사용량 정보 */}
|
||||
<div className="space-y-6">
|
||||
{/* 사용자 수 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-24 text-sm text-muted-foreground flex-shrink-0">
|
||||
사용자 수
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Progress value={userProgress} className="h-2" />
|
||||
</div>
|
||||
<div className="text-sm text-blue-600 min-w-[100px] text-right">
|
||||
{subscription.userCount}명 / {subscription.userLimit ? `${subscription.userLimit}명` : '무제한'}
|
||||
<div className="text-sm text-muted-foreground mb-4">리소스 사용량</div>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">사용자</span>
|
||||
<span className="text-sm font-medium">
|
||||
{subscription.userCount}명 / {subscription.userLimit ? `${subscription.userLimit}명` : '무제한'}
|
||||
</span>
|
||||
</div>
|
||||
<ColoredProgress value={userPercentage} />
|
||||
</div>
|
||||
|
||||
{/* 저장 공간 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-24 text-sm text-muted-foreground flex-shrink-0">
|
||||
저장 공간
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">저장 공간</span>
|
||||
<span className="text-sm font-medium">
|
||||
{subscription.storageUsedFormatted} / {subscription.storageLimitFormatted}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Progress value={storageProgress} className="h-2" />
|
||||
</div>
|
||||
<div className="text-sm text-blue-600 min-w-[100px] text-right">
|
||||
{subscription.storageUsedFormatted} / {subscription.storageLimitFormatted}
|
||||
<ColoredProgress value={subscription.storagePercentage} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">AI 토큰</span>
|
||||
<span className="text-sm font-medium">{formatKrw(subscription.aiTokens.costKrw)}</span>
|
||||
</div>
|
||||
<ColoredProgress value={subscription.aiTokens.percentage} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -208,27 +163,13 @@ export function SubscriptionClient({ initialData }: SubscriptionClientProps) {
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
{/* ===== 서비스 해지 확인 다이얼로그 ===== */}
|
||||
<ConfirmDialog
|
||||
open={showCancelDialog}
|
||||
onOpenChange={setShowCancelDialog}
|
||||
onConfirm={handleCancelService}
|
||||
variant="destructive"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
서비스 해지
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
모든 데이터가 삭제되며 복구할 수 없습니다.
|
||||
<br />
|
||||
<span className="font-medium text-red-600">
|
||||
정말 서비스를 해지하시겠습니까?
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
title={<span className="flex items-center gap-2"><AlertTriangle className="w-5 h-5 text-red-500" />서비스 해지</span>}
|
||||
description={<>모든 데이터가 삭제되며 복구할 수 없습니다.<br /><span className="font-medium text-red-600">정말 서비스를 해지하시겠습니까?</span></>}
|
||||
confirmText="확인"
|
||||
loading={isCancelling}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<div className={`relative h-2 w-full overflow-hidden rounded-full bg-gray-100 ${className}`}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${color}`}
|
||||
style={{ width: `${clampedValue}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<PageLayout>
|
||||
{/* ===== 페이지 헤더 ===== */}
|
||||
<PageHeader
|
||||
title="구독관리"
|
||||
description="구독 정보를 관리합니다"
|
||||
description="구독 정보와 사용량을 관리합니다"
|
||||
icon={CreditCard}
|
||||
/>
|
||||
|
||||
{/* ===== 헤더 액션 버튼 ===== */}
|
||||
<div className="flex justify-end gap-2 mb-4">
|
||||
<Button variant="outline" onClick={handleExportData} disabled={isExporting}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isExporting ? '처리 중...' : '자료 내보내기'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300"
|
||||
onClick={() => setShowCancelDialog(true)}
|
||||
disabled={subscription.status === 'cancelled'}
|
||||
>
|
||||
서비스 해지
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* ===== 구독 정보 카드 영역 ===== */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* 최근 결제일시 */}
|
||||
|
||||
{/* ===== 섹션 1: 구독 정보 카드 ===== */}
|
||||
{subscription.planName ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* 요금제 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">요금제</div>
|
||||
<div className="text-2xl font-bold">{subscription.planName}</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
시작: {formatDate(subscription.startedAt)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 구독 상태 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">구독 상태</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant={subscription.status === 'active' ? 'default' : 'secondary'}>
|
||||
{SUBSCRIPTION_STATUS_LABELS[subscription.status] || subscription.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{subscription.remainingDays != null && subscription.remainingDays > 0 && (
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
남은 일: {subscription.remainingDays}일
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 구독 금액 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">구독 금액</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(subscription.monthlyFee)}/월
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
종료: {formatDate(subscription.endedAt)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">최근 결제일시</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatDate(subscription.lastPaymentDate)}
|
||||
</div>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
구독 정보가 없습니다. 관리자에게 문의하세요.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 다음 결제일시 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">다음 결제일시</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatDate(subscription.nextPaymentDate)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 구독금액 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">구독금액</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(subscription.subscriptionAmount)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ===== 구독 정보 영역 ===== */}
|
||||
{/* ===== 섹션 2: 리소스 사용량 ===== */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-2">구독 정보</div>
|
||||
|
||||
{/* 플랜명 */}
|
||||
<h3 className="text-xl font-bold mb-6">
|
||||
{PLAN_LABELS[subscription.plan]}
|
||||
</h3>
|
||||
|
||||
{/* 사용량 정보 */}
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">리소스 사용량</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-5">
|
||||
{/* 사용자 수 */}
|
||||
{/* 사용자 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">사용자 수</span>
|
||||
<span className="text-sm text-blue-600">
|
||||
<span className="text-sm text-muted-foreground">사용자</span>
|
||||
<span className="text-sm font-medium">
|
||||
{subscription.userCount}명 / {subscription.userLimit ? `${subscription.userLimit}명` : '무제한'}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={subscription.userLimit ? (subscription.userCount / subscription.userLimit) * 100 : 30} className="h-2" />
|
||||
<ColoredProgress value={userPercentage} />
|
||||
</div>
|
||||
|
||||
{/* 저장 공간 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">저장 공간</span>
|
||||
<span className="text-sm text-blue-600">
|
||||
<span className="text-sm font-medium">
|
||||
{subscription.storageUsedFormatted} / {subscription.storageLimitFormatted}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={storageProgress} className="h-2" />
|
||||
<ColoredProgress value={subscription.storagePercentage} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* AI API 호출 */}
|
||||
{/* ===== 섹션 3: AI 토큰 사용량 ===== */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" />
|
||||
AI 토큰 사용량
|
||||
{aiTokens.period && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
— {formatPeriod(aiTokens.period)}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{aiTokens.isOverLimit && (
|
||||
<Badge variant="destructive">한도 초과 — 초과분 실비 과금</Badge>
|
||||
)}
|
||||
{!aiTokens.isOverLimit && aiTokens.percentage >= aiTokens.warningThreshold && (
|
||||
<Badge className="bg-orange-100 text-orange-800 hover:bg-orange-100">
|
||||
기본 제공량의 {aiTokens.percentage.toFixed(0)}% 사용 중
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-5">
|
||||
{/* 토큰 사용량 Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">AI API 호출</span>
|
||||
<span className="text-sm text-blue-600">
|
||||
{formatNumber(apiCallsUsed)} / {formatNumber(apiCallsLimit)}
|
||||
<span className="text-sm font-medium">
|
||||
{formatTokenCount(aiTokens.totalTokens)} / {formatTokenCount(aiTokens.limit)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{aiTokens.percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={apiProgress} className="h-2" />
|
||||
<ColoredProgress value={aiTokens.percentage} />
|
||||
</div>
|
||||
|
||||
{/* 총 비용 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 비용: <span className="font-medium text-foreground">{formatKrw(aiTokens.costKrw)}</span>
|
||||
</div>
|
||||
|
||||
{/* 모델별 사용량 테이블 */}
|
||||
{aiTokens.byModel.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">모델별 사용량</div>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead>모델</TableHead>
|
||||
<TableHead className="text-right w-[80px]">호출수</TableHead>
|
||||
<TableHead className="text-right w-[80px]">토큰</TableHead>
|
||||
<TableHead className="text-right w-[80px]">비용</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{aiTokens.byModel.map((m) => (
|
||||
<TableRow key={m.model}>
|
||||
<TableCell className="font-mono text-sm">{m.model}</TableCell>
|
||||
<TableCell className="text-right">{m.requests.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{formatTokenCount(m.total_tokens)}</TableCell>
|
||||
<TableCell className="text-right">{formatKrw(m.cost_krw)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>※ 기본 제공: 월 {formatTokenCount(aiTokens.limit)} 토큰. 초과 시 실비 과금</p>
|
||||
<p>※ 매월 1일 리셋, 잔여 토큰 이월 불가</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== 섹션 4: 서비스 관리 ===== */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">서비스 관리</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={handleExportData} disabled={isExporting}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isExporting ? '처리 중...' : '자료 내보내기'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300"
|
||||
onClick={() => setShowCancelDialog(true)}
|
||||
disabled={subscription.status === 'cancelled'}
|
||||
>
|
||||
서비스 해지
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -237,4 +369,4 @@ export function SubscriptionManagement({ initialData }: SubscriptionManagementPr
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ActionResult<SubscriptionApiData>> {
|
||||
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<ActionResult<Subscriptio
|
||||
// ===== 사용량 조회 =====
|
||||
export async function getUsage(): Promise<ActionResult<UsageApiData>> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult<{ id: number; status: string }>> {
|
||||
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 }),
|
||||
|
||||
@@ -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<SubscriptionInfo['plan'], string> = {
|
||||
@@ -110,4 +146,4 @@ export const PLAN_COLORS: Record<SubscriptionInfo['plan'], string> = {
|
||||
basic: 'bg-blue-100 text-blue-800',
|
||||
premium: 'bg-purple-100 text-purple-800',
|
||||
enterprise: 'bg-amber-100 text-amber-800',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user