feat(WEB): IntegratedDetailTemplate 통합 템플릿 구현 및 Phase 1 마이그레이션

- IntegratedDetailTemplate 컴포넌트 구현 (등록/상세/수정 통합)
- accounts (계좌관리) IntegratedDetailTemplate 마이그레이션
- card-management (카드관리) IntegratedDetailTemplate 마이그레이션
- Skeleton UI 컴포넌트 추가 및 loading.tsx 적용
- 기존 CardDetail.tsx, CardForm.tsx _legacy 폴더로 백업

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-17 15:29:51 +09:00
parent d2f5f3d0b5
commit 1a6cde2d36
19 changed files with 2132 additions and 299 deletions

View File

@@ -0,0 +1,369 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Landmark, Save, Trash2, X, Edit, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type { Account, AccountFormData, AccountStatus } from './types';
import {
BANK_OPTIONS,
BANK_LABELS,
ACCOUNT_STATUS_OPTIONS,
ACCOUNT_STATUS_LABELS,
ACCOUNT_STATUS_COLORS,
} from './types';
import { createBankAccount, updateBankAccount, deleteBankAccount } from './actions';
interface AccountDetailProps {
account?: Account;
mode: 'create' | 'view' | 'edit';
}
export function AccountDetail({ account, mode: initialMode }: AccountDetailProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [mode, setMode] = useState(initialMode);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// URL에서 mode 파라미터 확인
useEffect(() => {
const urlMode = searchParams.get('mode');
if (urlMode === 'edit' && account) {
setMode('edit');
}
}, [searchParams, account]);
// 폼 상태
const [formData, setFormData] = useState<AccountFormData>({
bankCode: account?.bankCode || '',
bankName: account?.bankName || '',
accountNumber: account?.accountNumber || '',
accountName: account?.accountName || '',
accountHolder: account?.accountHolder || '',
accountPassword: '',
status: account?.status || 'active',
});
const isViewMode = mode === 'view';
const isCreateMode = mode === 'create';
const isEditMode = mode === 'edit';
const handleChange = (field: keyof AccountFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleBack = () => {
router.push('/ko/settings/accounts');
};
const handleSubmit = async () => {
const dataToSend = {
...formData,
bankName: BANK_LABELS[formData.bankCode] || formData.bankCode,
};
if (isCreateMode) {
const result = await createBankAccount(dataToSend);
if (result.success) {
toast.success('계좌가 등록되었습니다.');
router.push('/ko/settings/accounts');
} else {
toast.error(result.error || '계좌 등록에 실패했습니다.');
}
} else {
if (!account?.id) return;
const result = await updateBankAccount(account.id, dataToSend);
if (result.success) {
toast.success('계좌가 수정되었습니다.');
router.push('/ko/settings/accounts');
} else {
toast.error(result.error || '계좌 수정에 실패했습니다.');
}
}
};
const handleDelete = () => {
setShowDeleteDialog(true);
};
const handleConfirmDelete = async () => {
if (!account?.id) return;
const result = await deleteBankAccount(account.id);
if (result.success) {
toast.success('계좌가 삭제되었습니다.');
router.push('/ko/settings/accounts');
} else {
toast.error(result.error || '계좌 삭제에 실패했습니다.');
}
};
const handleCancel = () => {
if (isCreateMode) {
router.push('/ko/settings/accounts');
} else {
setMode('view');
// 원래 데이터로 복원
if (account) {
setFormData({
bankCode: account.bankCode, bankName: account.bankName,
accountNumber: account.accountNumber,
accountName: account.accountName,
accountHolder: account.accountHolder,
accountPassword: '',
status: account.status,
});
}
}
};
const handleEdit = () => {
setMode('edit');
};
// 뷰 모드 렌더링
if (isViewMode) {
return (
<PageLayout>
<PageHeader
title="계좌 상세"
description="계좌 정보를 관리합니다"
icon={Landmark}
/>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<Badge className={ACCOUNT_STATUS_COLORS[formData.status]}>
{ACCOUNT_STATUS_LABELS[formData.status]}
</Badge>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{BANK_LABELS[formData.bankCode] || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1 font-mono">{formData.accountNumber || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{formData.accountHolder || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"> </dt>
<dd className="text-sm mt-1">****</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{formData.accountName || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">
<Badge className={ACCOUNT_STATUS_COLORS[formData.status]}>
{ACCOUNT_STATUS_LABELS[formData.status]}
</Badge>
</dd>
</div>
</dl>
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleEdit}>
<Edit className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}
// 생성/수정 모드 렌더링
return (
<PageLayout>
<PageHeader
title={isCreateMode ? '계좌 등록' : '계좌 수정'}
description="계좌 정보를 관리합니다"
icon={Landmark}
/>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 은행 & 계좌번호 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="bankCode"></Label>
<Select
value={formData.bankCode}
onValueChange={(value) => handleChange('bankCode', value)}
>
<SelectTrigger>
<SelectValue placeholder="은행 선택" />
</SelectTrigger>
<SelectContent>
{BANK_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="accountNumber"></Label>
<Input
id="accountNumber"
value={formData.accountNumber}
onChange={(e) => handleChange('accountNumber', e.target.value)}
placeholder="1234-1234-1234-1234"
disabled={isEditMode} // 수정 시 계좌번호는 변경 불가
/>
</div>
</div>
{/* 예금주 & 계좌 비밀번호 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="accountHolder"></Label>
<Input
id="accountHolder"
value={formData.accountHolder}
onChange={(e) => handleChange('accountHolder', e.target.value)}
placeholder="예금주명"
/>
</div>
<div className="space-y-2">
<Label htmlFor="accountPassword">
( )
</Label>
<Input
id="accountPassword"
type="password"
value={formData.accountPassword}
onChange={(e) => handleChange('accountPassword', e.target.value)}
placeholder="****"
/>
</div>
</div>
{/* 계좌명 & 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="accountName"></Label>
<Input
id="accountName"
value={formData.accountName}
onChange={(e) => handleChange('accountName', e.target.value)}
placeholder="계좌명을 입력해주세요"
/>
</div>
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={(value) => handleChange('status', value as AccountStatus)}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleCancel}>
<X className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSubmit}>
<Save className="w-4 h-4 mr-2" />
{isCreateMode ? '등록' : '저장'}
</Button>
</div>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,103 @@
/**
* Account Management - IntegratedDetailTemplate Config
*
* 계좌관리 등록/상세/수정 페이지 설정
*/
import { Landmark } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate';
import type { Account, AccountFormData, AccountStatus } from './types';
import { BANK_OPTIONS, ACCOUNT_STATUS_OPTIONS, BANK_LABELS } from './types';
export const accountConfig: DetailConfig<Account> = {
title: '계좌',
description: '계좌 정보를 관리합니다',
icon: Landmark,
basePath: '/settings/accounts',
// 그리드 2열
gridColumns: 2,
// 필드 정의
fields: [
{
key: 'bankCode',
label: '은행',
type: 'select',
required: true,
options: BANK_OPTIONS,
placeholder: '은행 선택',
},
{
key: 'accountNumber',
label: '계좌번호',
type: 'text',
required: true,
placeholder: '1234-1234-1234-1234',
// 수정 모드에서 비활성화 (계좌번호 변경 불가)
disabled: (mode) => mode === 'edit',
},
{
key: 'accountHolder',
label: '예금주',
type: 'text',
required: true,
placeholder: '예금주명',
},
{
key: 'accountPassword',
label: '계좌 비밀번호 (빠른 조회 서비스)',
type: 'password',
placeholder: '****',
helpText: '빠른 조회 서비스 이용 시 필요합니다',
// view 모드에서는 **** 로 표시됨 (FieldRenderer 기본 동작)
},
{
key: 'accountName',
label: '계좌명',
type: 'text',
placeholder: '계좌명을 입력해주세요',
},
{
key: 'status',
label: '상태',
type: 'select',
required: true,
options: ACCOUNT_STATUS_OPTIONS,
placeholder: '상태 선택',
},
],
// 액션 설정
actions: {
showDelete: true,
showEdit: true,
showBack: true,
deleteConfirmMessage: {
title: '계좌 삭제',
description: '계좌를 정말 삭제하시겠습니까?\n삭제된 계좌의 과거 사용 내역은 보존됩니다.',
},
},
// 초기 데이터 변환 (API 응답 → formData)
transformInitialData: (account: Account): Record<string, unknown> => ({
bankCode: account.bankCode || '',
bankName: account.bankName || '',
accountNumber: account.accountNumber || '',
accountName: account.accountName || '',
accountHolder: account.accountHolder || '',
accountPassword: '', // 비밀번호는 항상 빈 값
status: account.status || 'active',
}),
// 제출 데이터 변환 (formData → API 요청)
transformSubmitData: (formData: Record<string, unknown>): Partial<AccountFormData> => ({
bankCode: formData.bankCode as string,
bankName: BANK_LABELS[formData.bankCode as string] || (formData.bankCode as string),
accountNumber: formData.accountNumber as string,
accountName: formData.accountName as string,
accountHolder: formData.accountHolder as string,
accountPassword: formData.accountPassword as string,
status: formData.status as AccountStatus,
}),
};