Files
sam-react-prod/src/components/settings/SubscriptionManagement/SubscriptionManagement.tsx

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}
/>
</>
);
}