feat: [subscription] 구독관리 리팩토링 + 사용량 페이지 추가

- 구독관리 UI/로직 대폭 개선
- 사용량 페이지 신규 추가
- 입고관리 액션 정리
This commit is contained in:
유병철
2026-03-18 13:59:54 +09:00
parent b3c1ca6a97
commit e8fafaf5f4
9 changed files with 494 additions and 396 deletions

View File

@@ -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}
/>