Files
sam-react-prod/src/components/settings/AccountManagement/AccountDetailForm.tsx
유병철 7f39f3066f feat(WEB): 회계/설정/카드 관리 페이지 대규모 기능 추가 및 리팩토링
- 일반전표입력, 상품권관리, 세금계산서 발행/조회 신규 페이지 추가
- 바로빌 연동 설정 페이지 추가
- 카드관리/계좌관리 리스트 UniversalListPage 공통 구조로 전환
- 카드거래조회/은행거래조회 리팩토링 (모달 분리, 액션 확장)
- 계좌 상세 폼(AccountDetailForm) 신규 구현
- 카드 상세(CardDetail) 신규 구현 + CardNumberInput 적용
- DateRangeSelector, StatCards, IntegratedListTemplateV2 공통 컴포넌트 개선
- 레거시 파일 정리 (CardManagementUnified, cardConfig, _legacy 등)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:18:45 +09:00

815 lines
27 KiB
TypeScript

'use client';
/**
* AccountDetailForm - 계좌 등록/수정/보기 조건부 폼
*
* 구분(category) 선택에 따라 유형(accountType) 옵션과 하단 상세 섹션이 동적 변경됨
* - 은행계좌: 계좌 정보 (계약금액, 이율, 시작일, 만기일, 이월잔액)
* - 대출계좌: 대출 정보 (대출금액, 이율, 상환방식, 거치기간 등)
* - 증권계좌: 증권 정보 (투자금액, 수익율, 평가액)
* - 보험계좌: 보험 정보 (단체/화재/CEO별 다른 필드)
*/
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Landmark, Save, Trash2, ArrowLeft } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { FormField } from '@/components/molecules/FormField';
import type { Account, AccountCategory, AccountFormData } from './types';
import {
ACCOUNT_CATEGORY_OPTIONS,
ACCOUNT_TYPE_OPTIONS_BY_CATEGORY,
ACCOUNT_STATUS_OPTIONS,
FINANCIAL_INSTITUTION_OPTIONS_BY_CATEGORY,
BANK_LABELS,
HOLDER_LABEL_BY_CATEGORY,
} from './types';
// ===== Props =====
interface AccountDetailFormProps {
mode: 'create' | 'edit' | 'view';
initialData?: Account;
onSubmit: (data: AccountFormData) => Promise<{ success: boolean; error?: string }>;
onDelete?: () => Promise<{ success: boolean; error?: string }>;
isLoading?: boolean;
}
// ===== 초기 폼 데이터 생성 =====
function getInitialFormData(initialData?: Account): AccountFormData {
if (initialData) {
return {
category: initialData.category || 'bank_account',
accountType: initialData.accountType || '',
bankCode: initialData.bankCode || '',
bankName: initialData.bankName || '',
accountNumber: initialData.accountNumber || '',
accountName: initialData.accountName || '',
accountHolder: initialData.accountHolder || '',
status: initialData.status || 'active',
contractAmount: initialData.contractAmount,
interestRate: initialData.interestRate,
startDate: initialData.startDate,
maturityDate: initialData.maturityDate,
carryoverBalance: initialData.carryoverBalance,
loanAmount: initialData.loanAmount,
loanBalance: initialData.loanBalance,
interestPaymentCycle: initialData.interestPaymentCycle,
repaymentMethod: initialData.repaymentMethod,
gracePeriod: initialData.gracePeriod,
monthlyRepayment: initialData.monthlyRepayment,
collateral: initialData.collateral,
investmentAmount: initialData.investmentAmount,
returnRate: initialData.returnRate,
evaluationAmount: initialData.evaluationAmount,
surrenderValue: initialData.surrenderValue,
policyNumber: initialData.policyNumber,
paymentCycle: initialData.paymentCycle,
premiumPerCycle: initialData.premiumPerCycle,
premiumPerPerson: initialData.premiumPerPerson,
enrolledCount: initialData.enrolledCount,
insuredProperty: initialData.insuredProperty,
propertyAddress: initialData.propertyAddress,
beneficiary: initialData.beneficiary,
note: initialData.note,
};
}
return {
category: 'bank_account',
accountType: '',
bankCode: '',
bankName: '',
accountNumber: '',
accountName: '',
accountHolder: '',
status: 'active',
};
}
export function AccountDetailForm({
mode: initialMode,
initialData,
onSubmit,
onDelete,
isLoading,
}: AccountDetailFormProps) {
const router = useRouter();
const [mode, setMode] = useState(initialMode);
const [formData, setFormData] = useState<AccountFormData>(() => getInitialFormData(initialData));
const [isSaving, setIsSaving] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const isViewMode = mode === 'view';
const isCreateMode = mode === 'create';
const disabled = isViewMode;
// ===== 구분별 동적 옵션 =====
const typeOptions = useMemo(
() => ACCOUNT_TYPE_OPTIONS_BY_CATEGORY[formData.category] || [],
[formData.category]
);
const institutionOptions = useMemo(
() => FINANCIAL_INSTITUTION_OPTIONS_BY_CATEGORY[formData.category] || [],
[formData.category]
);
const holderLabel = HOLDER_LABEL_BY_CATEGORY[formData.category] || '예금주';
// ===== 핸들러 =====
const handleChange = useCallback((field: keyof AccountFormData, value: string | number | undefined) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
const handleCategoryChange = useCallback((value: string) => {
const category = value as AccountCategory;
setFormData(prev => ({
...prev,
category,
accountType: '',
bankCode: '',
bankName: '',
}));
}, []);
const handleBankCodeChange = useCallback((value: string) => {
setFormData(prev => ({
...prev,
bankCode: value,
bankName: BANK_LABELS[value] || value,
}));
}, []);
const handleSubmit = useCallback(async () => {
if (!formData.category || !formData.bankCode || !formData.accountNumber) {
toast.error('필수 항목을 입력해주세요.');
return;
}
setIsSaving(true);
try {
const result = await onSubmit(formData);
if (result.success) {
toast.success(isCreateMode ? '계좌가 등록되었습니다.' : '계좌가 수정되었습니다.');
router.push('/ko/settings/accounts');
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [formData, onSubmit, isCreateMode, router]);
const handleDelete = useCallback(async () => {
if (!onDelete) return;
setIsSaving(true);
try {
const result = await onDelete();
if (result.success) {
toast.success('계좌가 삭제되었습니다.');
router.push('/ko/settings/accounts');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsSaving(false);
setShowDeleteDialog(false);
}
}, [onDelete, router]);
const handleBack = useCallback(() => {
router.push('/ko/settings/accounts');
}, [router]);
const handleEdit = useCallback(() => {
setMode('edit');
if (initialData?.id) {
router.push(`/ko/settings/accounts/${initialData.id}?mode=edit`);
}
}, [initialData?.id, router]);
// ===== 로딩 =====
if (isLoading) {
return (
<PageLayout>
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-1/3" />
<div className="h-64 bg-muted rounded" />
</div>
</PageLayout>
);
}
// ===== 렌더링 =====
return (
<PageLayout>
<PageHeader
title={isCreateMode ? '수기 계좌 등록' : isViewMode ? '계좌 상세' : '계좌 수정'}
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">
<FormField
label="구분"
type="select"
required
value={formData.category}
onChange={handleCategoryChange}
options={ACCOUNT_CATEGORY_OPTIONS}
selectPlaceholder="구분 선택"
disabled={disabled}
/>
<FormField
key={`type-${formData.category}`}
label="유형"
type="select"
value={formData.accountType}
onChange={(v) => handleChange('accountType', v)}
options={typeOptions}
selectPlaceholder="유형 선택"
disabled={disabled}
/>
</div>
{/* 금융기관 & 계좌번호 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
key={`bank-${formData.category}`}
label="금융기관"
type="select"
required
value={formData.bankCode}
onChange={handleBankCodeChange}
options={institutionOptions}
selectPlaceholder="금융기관 선택"
disabled={disabled}
/>
<FormField
label="계좌번호"
type="text"
required
value={formData.accountNumber}
onChange={(v) => handleChange('accountNumber', v)}
placeholder="계좌번호 입력"
disabled={disabled || mode === 'edit'}
/>
</div>
{/* 계좌명 & 예금주/계약자/피보험자 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="계좌명(상용명)"
type="text"
value={formData.accountName}
onChange={(v) => handleChange('accountName', v)}
placeholder="계좌명 입력"
disabled={disabled}
/>
<FormField
label={holderLabel}
type="text"
value={formData.accountHolder}
onChange={(v) => handleChange('accountHolder', v)}
placeholder={`${holderLabel} 입력`}
disabled={disabled}
/>
</div>
{/* 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="상태"
type="select"
required
value={formData.status}
onChange={(v) => handleChange('status', v)}
options={ACCOUNT_STATUS_OPTIONS}
selectPlaceholder="상태 선택"
disabled={disabled}
/>
</div>
</CardContent>
</Card>
{/* ===== 구분별 상세 정보 ===== */}
{formData.category === 'bank_account' && (
<BankAccountSection formData={formData} onChange={handleChange} disabled={disabled} />
)}
{formData.category === 'loan_account' && (
<LoanAccountSection formData={formData} onChange={handleChange} disabled={disabled} />
)}
{formData.category === 'securities_account' && (
<SecuritiesAccountSection formData={formData} onChange={handleChange} disabled={disabled} />
)}
{formData.category === 'insurance_account' && (
<InsuranceAccountSection formData={formData} onChange={handleChange} disabled={disabled} />
)}
{/* ===== 하단 버튼 ===== */}
<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">
{isViewMode ? (
<>
{onDelete && (
<Button
variant="outline"
onClick={() => setShowDeleteDialog(true)}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
)}
<Button onClick={handleEdit}></Button>
</>
) : (
<>
{!isCreateMode && onDelete && (
<Button
variant="outline"
onClick={() => setShowDeleteDialog(true)}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
)}
<Button onClick={handleSubmit} disabled={isSaving}>
<Save className="w-4 h-4 mr-2" />
{isCreateMode ? '등록' : '저장'}
</Button>
</>
)}
</div>
</div>
</div>
{/* ===== 삭제 확인 ===== */}
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="계좌 삭제"
description="계좌를 정말 삭제하시겠습니까? 삭제된 계좌의 과거 사용 내역은 보존됩니다."
loading={isSaving}
/>
</PageLayout>
);
}
// ============================================
// 구분별 상세 섹션 컴포넌트
// ============================================
interface SectionProps {
formData: AccountFormData;
onChange: (field: keyof AccountFormData, value: string | number | undefined) => void;
disabled: boolean;
}
// ===== 은행계좌 정보 =====
function BankAccountSection({ formData, onChange, disabled }: SectionProps) {
return (
<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">
<FormField
label="계약금액"
type="currency"
value={formData.contractAmount}
onChangeNumber={(v) => onChange('contractAmount', v)}
placeholder="0"
disabled={disabled}
/>
<FormField
label="이율(%)"
type="number"
value={formData.interestRate != null ? String(formData.interestRate) : ''}
onChange={(v) => onChange('interestRate', v ? Number(v) : undefined)}
placeholder="0.00"
disabled={disabled}
allowDecimal
decimalPlaces={2}
suffix="%"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="시작일"
type="date"
value={formData.startDate || ''}
onChange={(v) => onChange('startDate', v)}
disabled={disabled}
/>
<FormField
label="만기일"
type="date"
value={formData.maturityDate || ''}
onChange={(v) => onChange('maturityDate', v)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="이월잔액"
type="currency"
value={formData.carryoverBalance}
onChangeNumber={(v) => onChange('carryoverBalance', v)}
placeholder="0"
disabled={disabled}
/>
<FormField
label="비고"
type="text"
value={formData.note || ''}
onChange={(v) => onChange('note', v)}
placeholder="비고"
disabled={disabled}
/>
</div>
</CardContent>
</Card>
);
}
// ===== 대출계좌 정보 =====
function LoanAccountSection({ formData, onChange, disabled }: SectionProps) {
return (
<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">
<FormField
label="대출금액"
type="currency"
value={formData.loanAmount}
onChangeNumber={(v) => onChange('loanAmount', v)}
placeholder="0"
disabled={disabled}
/>
<FormField
label="이율(%)"
type="number"
value={formData.interestRate != null ? String(formData.interestRate) : ''}
onChange={(v) => onChange('interestRate', v ? Number(v) : undefined)}
placeholder="0.00"
disabled={disabled}
allowDecimal
decimalPlaces={2}
suffix="%"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="시작일"
type="date"
value={formData.startDate || ''}
onChange={(v) => onChange('startDate', v)}
disabled={disabled}
/>
<FormField
label="만기일"
type="date"
value={formData.maturityDate || ''}
onChange={(v) => onChange('maturityDate', v)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="대출잔액"
type="currency"
value={formData.loanBalance}
onChangeNumber={(v) => onChange('loanBalance', v)}
placeholder="0"
disabled={disabled}
/>
<FormField
label="이자 납입 주기"
type="select"
value={formData.interestPaymentCycle || ''}
onChange={(v) => onChange('interestPaymentCycle', v)}
options={[
{ value: 'monthly', label: '월납' },
{ value: 'quarterly', label: '분기납' },
{ value: 'semi_annual', label: '반기납' },
{ value: 'annual', label: '연납' },
]}
selectPlaceholder="주기 선택"
disabled={disabled}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="상환 방식"
type="select"
value={formData.repaymentMethod || ''}
onChange={(v) => onChange('repaymentMethod', v)}
options={[
{ value: 'equal_principal', label: '원금균등' },
{ value: 'equal_installment', label: '원리금균등' },
{ value: 'bullet', label: '만기일시' },
{ value: 'other', label: '기타' },
]}
selectPlaceholder="방식 선택"
disabled={disabled}
/>
<FormField
label="거치 기간"
type="text"
value={formData.gracePeriod || ''}
onChange={(v) => onChange('gracePeriod', v)}
placeholder="예: 6개월"
disabled={disabled}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="월 상환액"
type="currency"
value={formData.monthlyRepayment}
onChangeNumber={(v) => onChange('monthlyRepayment', v)}
placeholder="0"
disabled={disabled}
/>
<FormField
label="담보물"
type="text"
value={formData.collateral || ''}
onChange={(v) => onChange('collateral', v)}
placeholder="담보물 입력"
disabled={disabled}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="비고"
type="text"
value={formData.note || ''}
onChange={(v) => onChange('note', v)}
placeholder="비고"
disabled={disabled}
/>
</div>
</CardContent>
</Card>
);
}
// ===== 증권계좌 정보 =====
function SecuritiesAccountSection({ formData, onChange, disabled }: SectionProps) {
return (
<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">
<FormField
label="투자금액"
type="currency"
value={formData.investmentAmount}
onChangeNumber={(v) => onChange('investmentAmount', v)}
placeholder="0"
disabled={disabled}
/>
<FormField
label="수익율(%)"
type="number"
value={formData.returnRate != null ? String(formData.returnRate) : ''}
onChange={(v) => onChange('returnRate', v ? Number(v) : undefined)}
placeholder="0.00"
disabled={disabled}
allowDecimal
decimalPlaces={2}
suffix="%"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="시작일"
type="date"
value={formData.startDate || ''}
onChange={(v) => onChange('startDate', v)}
disabled={disabled}
/>
<FormField
label="만기일"
type="date"
value={formData.maturityDate || ''}
onChange={(v) => onChange('maturityDate', v)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="평가액"
type="currency"
value={formData.evaluationAmount}
onChangeNumber={(v) => onChange('evaluationAmount', v)}
placeholder="0"
disabled={disabled}
/>
<FormField
label="비고"
type="text"
value={formData.note || ''}
onChange={(v) => onChange('note', v)}
placeholder="비고"
disabled={disabled}
/>
</div>
</CardContent>
</Card>
);
}
// ===== 보험계좌 정보 =====
function InsuranceAccountSection({ formData, onChange, disabled }: SectionProps) {
return (
<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">
<FormField
label="계약금액"
type="currency"
value={formData.contractAmount}
onChangeNumber={(v) => onChange('contractAmount', v)}
placeholder="0"
disabled={disabled}
/>
<FormField
label="이율(%)"
type="number"
value={formData.interestRate != null ? String(formData.interestRate) : ''}
onChange={(v) => onChange('interestRate', v ? Number(v) : undefined)}
placeholder="0.00"
disabled={disabled}
allowDecimal
decimalPlaces={2}
suffix="%"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="시작일"
type="date"
value={formData.startDate || ''}
onChange={(v) => onChange('startDate', v)}
disabled={disabled}
/>
<FormField
label="만기일"
type="date"
value={formData.maturityDate || ''}
onChange={(v) => onChange('maturityDate', v)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="해약환급금"
type="currency"
value={formData.surrenderValue}
onChangeNumber={(v) => onChange('surrenderValue', v)}
placeholder="0"
disabled={disabled}
/>
<FormField
label="증권번호"
type="text"
value={formData.policyNumber || ''}
onChange={(v) => onChange('policyNumber', v)}
placeholder="증권번호"
disabled={disabled}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="납입 주기"
type="select"
value={formData.paymentCycle || ''}
onChange={(v) => onChange('paymentCycle', v)}
options={[
{ value: 'monthly', label: '월납' },
{ value: 'quarterly', label: '분기납' },
{ value: 'semi_annual', label: '반기납' },
{ value: 'annual', label: '연납' },
{ value: 'lump_sum', label: '일시납' },
]}
selectPlaceholder="주기 선택"
disabled={disabled}
/>
<FormField
label="납입 주기당 보험료"
type="currency"
value={formData.premiumPerCycle}
onChangeNumber={(v) => onChange('premiumPerCycle', v)}
placeholder="0"
disabled={disabled}
/>
</div>
{/* 단체보험 전용 */}
{formData.accountType === 'group_insurance' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="1인당 보험료"
type="currency"
value={formData.premiumPerPerson}
onChangeNumber={(v) => onChange('premiumPerPerson', v)}
placeholder="0"
disabled={disabled}
/>
<FormField
label="가입 인원"
type="number"
value={formData.enrolledCount != null ? String(formData.enrolledCount) : ''}
onChange={(v) => onChange('enrolledCount', v ? Number(v) : undefined)}
placeholder="0"
disabled={disabled}
/>
</div>
)}
{/* 화재보험 전용 */}
{formData.accountType === 'fire_insurance' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="보험 대상물"
type="text"
value={formData.insuredProperty || ''}
onChange={(v) => onChange('insuredProperty', v)}
placeholder="보험 대상물 입력"
disabled={disabled}
/>
<FormField
label="대상물 주소"
type="text"
value={formData.propertyAddress || ''}
onChange={(v) => onChange('propertyAddress', v)}
placeholder="주소 입력"
disabled={disabled}
/>
</div>
)}
{/* CEO보험 전용 */}
{formData.accountType === 'ceo_insurance' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="수익자"
type="text"
value={formData.beneficiary || ''}
onChange={(v) => onChange('beneficiary', v)}
placeholder="수익자 입력"
disabled={disabled}
/>
</div>
)}
{/* 비고 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="비고"
type="text"
value={formData.note || ''}
onChange={(v) => onChange('note', v)}
placeholder="비고"
disabled={disabled}
/>
</div>
</CardContent>
</Card>
);
}