373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 구독관리 (구독관리 통합) 페이지
|
|
*
|
|
* 4섹션: 구독정보 카드 / 리소스 사용량 / AI 토큰 사용량 / 서비스 관리
|
|
*/
|
|
|
|
import { useState, useCallback } from 'react';
|
|
import { CreditCard, Download, AlertTriangle, Cpu } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
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 { SUBSCRIPTION_STATUS_LABELS } from './types';
|
|
import { requestDataExport, cancelSubscription } from './actions';
|
|
import { formatTokenCount, formatKrw, formatPeriod, getProgressColor } from './utils';
|
|
import { formatAmountWon as formatCurrency } from '@/lib/utils/amount';
|
|
|
|
// ===== 날짜 포맷 =====
|
|
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')}`;
|
|
};
|
|
|
|
// ===== 기본값 =====
|
|
const defaultSubscription: SubscriptionInfo = {
|
|
plan: 'free',
|
|
planName: '무료',
|
|
monthlyFee: 0,
|
|
status: 'pending',
|
|
startedAt: null,
|
|
endedAt: null,
|
|
remainingDays: null,
|
|
userCount: 0,
|
|
userLimit: null,
|
|
storageUsed: 0,
|
|
storageLimit: 107_374_182_400,
|
|
storageUsedFormatted: '0 B',
|
|
storageLimitFormatted: '100 GB',
|
|
storagePercentage: 0,
|
|
aiTokens: {
|
|
period: '',
|
|
totalTokens: 0,
|
|
limit: 1_000_000,
|
|
percentage: 0,
|
|
costKrw: 0,
|
|
warningThreshold: 80,
|
|
isOverLimit: false,
|
|
byModel: [],
|
|
},
|
|
};
|
|
|
|
// ===== 색상이 적용된 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;
|
|
}
|
|
|
|
export function SubscriptionManagement({ initialData }: SubscriptionManagementProps) {
|
|
const [subscription, setSubscription] = useState<SubscriptionInfo>(initialData || defaultSubscription);
|
|
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
|
|
const { aiTokens } = subscription;
|
|
|
|
// ===== 자료 내보내기 =====
|
|
const handleExportData = useCallback(async () => {
|
|
setIsExporting(true);
|
|
try {
|
|
const result = await requestDataExport('all');
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}, [subscription.id]);
|
|
|
|
// ===== Progress 계산 =====
|
|
const userPercentage = subscription.userLimit
|
|
? (subscription.userCount / subscription.userLimit) * 100
|
|
: 30;
|
|
|
|
return (
|
|
<>
|
|
<PageLayout>
|
|
<PageHeader
|
|
title="이용현황"
|
|
description="구독 정보와 사용량을 관리합니다"
|
|
icon={CreditCard}
|
|
/>
|
|
|
|
<div className="space-y-6">
|
|
|
|
{/* ===== 섹션 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="py-8 text-center text-muted-foreground">
|
|
구독 정보가 없습니다. 관리자에게 문의하세요.
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* ===== 섹션 2: 리소스 사용량 ===== */}
|
|
<Card>
|
|
<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 font-medium">
|
|
{subscription.userCount}명 / {subscription.userLimit ? `${subscription.userLimit}명` : '무제한'}
|
|
</span>
|
|
</div>
|
|
<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 font-medium">
|
|
{subscription.storageUsedFormatted} / {subscription.storageLimitFormatted}
|
|
</span>
|
|
</div>
|
|
<ColoredProgress value={subscription.storagePercentage} />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* ===== 섹션 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 font-medium">
|
|
{formatTokenCount(aiTokens.totalTokens)} / {formatTokenCount(aiTokens.limit)}
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{aiTokens.percentage.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<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>
|
|
</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>
|
|
</>
|
|
}
|
|
confirmText="확인"
|
|
loading={isCancelling}
|
|
/>
|
|
</>
|
|
);
|
|
}
|