feat(WEB): 회계/설정/카드 관리 페이지 대규모 기능 추가 및 리팩토링
- 일반전표입력, 상품권관리, 세금계산서 발행/조회 신규 페이지 추가 - 바로빌 연동 설정 페이지 추가 - 카드관리/계좌관리 리스트 UniversalListPage 공통 구조로 전환 - 카드거래조회/은행거래조회 리팩토링 (모달 분리, 액션 확장) - 계좌 상세 폼(AccountDetailForm) 신규 구현 - 카드 상세(CardDetail) 신규 구현 + CardNumberInput 적용 - DateRangeSelector, StatCards, IntegratedListTemplateV2 공통 컴포넌트 개선 - 레거시 파일 정리 (CardManagementUnified, cardConfig, _legacy 등) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,12 +51,13 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<AccountFormData>({
|
||||
category: account?.category || 'bank_account',
|
||||
accountType: account?.accountType || 'savings',
|
||||
bankCode: account?.bankCode || '',
|
||||
bankName: account?.bankName || '',
|
||||
accountNumber: account?.accountNumber || '',
|
||||
accountName: account?.accountName || '',
|
||||
accountHolder: account?.accountHolder || '',
|
||||
accountPassword: '',
|
||||
status: account?.status || 'active',
|
||||
});
|
||||
|
||||
@@ -121,11 +122,12 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
|
||||
// 원래 데이터로 복원
|
||||
if (account) {
|
||||
setFormData({
|
||||
category: account.category || 'bank_account',
|
||||
accountType: account.accountType || 'savings',
|
||||
bankCode: account.bankCode, bankName: account.bankName,
|
||||
accountNumber: account.accountNumber,
|
||||
accountName: account.accountName,
|
||||
accountHolder: account.accountHolder,
|
||||
accountPassword: '',
|
||||
status: account.status,
|
||||
});
|
||||
}
|
||||
@@ -292,15 +294,13 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accountPassword">
|
||||
계좌 비밀번호 (빠른 조회 서비스)
|
||||
</Label>
|
||||
<Label htmlFor="accountName">계좌명</Label>
|
||||
<Input
|
||||
id="accountPassword"
|
||||
type="password"
|
||||
value={formData.accountPassword}
|
||||
onChange={(e) => handleChange('accountPassword', e.target.value)}
|
||||
placeholder="****"
|
||||
id="accountName2"
|
||||
value={formData.accountName}
|
||||
onChange={(e) => handleChange('accountName', e.target.value)}
|
||||
placeholder="계좌명"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
814
src/components/settings/AccountManagement/AccountDetailForm.tsx
Normal file
814
src/components/settings/AccountManagement/AccountDetailForm.tsx
Normal file
@@ -0,0 +1,814 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck - Legacy file, not in use
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
@@ -97,7 +97,6 @@ export const accountConfig: DetailConfig<Account> = {
|
||||
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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { executeServerAction, type ActionResult } from '@/lib/api/execute-server
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { Account, AccountFormData, AccountStatus } from './types';
|
||||
import type { Account, AccountCategory, AccountFormData, AccountStatus } from './types';
|
||||
import { BANK_LABELS } from './types';
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
@@ -21,6 +21,35 @@ interface BankAccountApiData {
|
||||
assigned_user_id?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
// 신규 필드
|
||||
category?: AccountCategory;
|
||||
account_type?: string;
|
||||
is_manual?: boolean;
|
||||
contract_amount?: number;
|
||||
interest_rate?: number;
|
||||
start_date?: string;
|
||||
maturity_date?: string;
|
||||
carryover_balance?: number;
|
||||
loan_amount?: number;
|
||||
loan_balance?: number;
|
||||
interest_payment_cycle?: string;
|
||||
repayment_method?: string;
|
||||
grace_period?: string;
|
||||
monthly_repayment?: number;
|
||||
collateral?: string;
|
||||
investment_amount?: number;
|
||||
return_rate?: number;
|
||||
evaluation_amount?: number;
|
||||
surrender_value?: number;
|
||||
policy_number?: string;
|
||||
payment_cycle?: string;
|
||||
premium_per_cycle?: number;
|
||||
premium_per_person?: number;
|
||||
enrolled_count?: number;
|
||||
insured_property?: string;
|
||||
property_address?: string;
|
||||
beneficiary?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type BankAccountPaginatedResponse = PaginatedApiResponse<BankAccountApiData>;
|
||||
@@ -39,23 +68,83 @@ function transformApiToFrontend(apiData: BankAccountApiData): Account {
|
||||
assignedUserId: apiData.assigned_user_id,
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
category: apiData.category || 'bank_account',
|
||||
accountType: apiData.account_type || 'savings',
|
||||
isManual: apiData.is_manual ?? true,
|
||||
contractAmount: apiData.contract_amount,
|
||||
interestRate: apiData.interest_rate,
|
||||
startDate: apiData.start_date,
|
||||
maturityDate: apiData.maturity_date,
|
||||
carryoverBalance: apiData.carryover_balance,
|
||||
loanAmount: apiData.loan_amount,
|
||||
loanBalance: apiData.loan_balance,
|
||||
interestPaymentCycle: apiData.interest_payment_cycle,
|
||||
repaymentMethod: apiData.repayment_method,
|
||||
gracePeriod: apiData.grace_period,
|
||||
monthlyRepayment: apiData.monthly_repayment,
|
||||
collateral: apiData.collateral,
|
||||
investmentAmount: apiData.investment_amount,
|
||||
returnRate: apiData.return_rate,
|
||||
evaluationAmount: apiData.evaluation_amount,
|
||||
surrenderValue: apiData.surrender_value,
|
||||
policyNumber: apiData.policy_number,
|
||||
paymentCycle: apiData.payment_cycle,
|
||||
premiumPerCycle: apiData.premium_per_cycle,
|
||||
premiumPerPerson: apiData.premium_per_person,
|
||||
enrolledCount: apiData.enrolled_count,
|
||||
insuredProperty: apiData.insured_property,
|
||||
propertyAddress: apiData.property_address,
|
||||
beneficiary: apiData.beneficiary,
|
||||
note: apiData.note,
|
||||
};
|
||||
}
|
||||
|
||||
function transformFrontendToApi(data: Partial<AccountFormData>): Record<string, unknown> {
|
||||
return {
|
||||
// 공통
|
||||
category: data.category,
|
||||
account_type: data.accountType,
|
||||
bank_code: data.bankCode,
|
||||
bank_name: data.bankName || BANK_LABELS[data.bankCode || ''] || data.bankCode,
|
||||
account_number: data.accountNumber,
|
||||
account_holder: data.accountHolder,
|
||||
account_name: data.accountName,
|
||||
status: data.status,
|
||||
// 은행계좌
|
||||
contract_amount: data.contractAmount,
|
||||
interest_rate: data.interestRate,
|
||||
start_date: data.startDate,
|
||||
maturity_date: data.maturityDate,
|
||||
carryover_balance: data.carryoverBalance,
|
||||
// 대출계좌
|
||||
loan_amount: data.loanAmount,
|
||||
loan_balance: data.loanBalance,
|
||||
interest_payment_cycle: data.interestPaymentCycle,
|
||||
repayment_method: data.repaymentMethod,
|
||||
grace_period: data.gracePeriod,
|
||||
monthly_repayment: data.monthlyRepayment,
|
||||
collateral: data.collateral,
|
||||
// 증권계좌
|
||||
investment_amount: data.investmentAmount,
|
||||
return_rate: data.returnRate,
|
||||
evaluation_amount: data.evaluationAmount,
|
||||
// 보험계좌
|
||||
surrender_value: data.surrenderValue,
|
||||
policy_number: data.policyNumber,
|
||||
payment_cycle: data.paymentCycle,
|
||||
premium_per_cycle: data.premiumPerCycle,
|
||||
premium_per_person: data.premiumPerPerson,
|
||||
enrolled_count: data.enrolledCount,
|
||||
insured_property: data.insuredProperty,
|
||||
property_address: data.propertyAddress,
|
||||
beneficiary: data.beneficiary,
|
||||
note: data.note,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 계좌 목록 조회 =====
|
||||
export async function getBankAccounts(params?: {
|
||||
page?: number; perPage?: number; search?: string;
|
||||
page?: number; perPage?: number; search?: string; category?: string;
|
||||
}): Promise<{
|
||||
success: boolean; data?: Account[]; meta?: { currentPage: number; lastPage: number; perPage: number; total: number };
|
||||
error?: string; __authError?: boolean;
|
||||
@@ -65,6 +154,7 @@ export async function getBankAccounts(params?: {
|
||||
page: params?.page,
|
||||
per_page: params?.perPage,
|
||||
search: params?.search,
|
||||
category: params?.category && params.category !== 'all' ? params.category : undefined,
|
||||
}),
|
||||
transform: (data: BankAccountPaginatedResponse) => ({
|
||||
accounts: (data?.data || []).map(transformApiToFrontend),
|
||||
@@ -156,3 +246,28 @@ export async function deleteBankAccounts(ids: number[]): Promise<{
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 계좌 통계 (클라이언트 사이드 집계) =====
|
||||
export async function getAccountSummary(): Promise<ActionResult<{
|
||||
total: number;
|
||||
bankAccount: number;
|
||||
loanAccount: number;
|
||||
securitiesAccount: number;
|
||||
insuranceAccount: number;
|
||||
}>> {
|
||||
const result = await getBankAccounts({ perPage: 9999 });
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
const accounts = result.data;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: accounts.length,
|
||||
bankAccount: accounts.filter(a => a.category === 'bank_account').length,
|
||||
loanAccount: accounts.filter(a => a.category === 'loan_account').length,
|
||||
securitiesAccount: accounts.filter(a => a.category === 'securities_account').length,
|
||||
insuranceAccount: accounts.filter(a => a.category === 'insurance_account').length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,174 +1,153 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 계좌관리 - UniversalListPage 마이그레이션
|
||||
* 계좌관리 - 종합 계좌 관리 목록 페이지
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 클라이언트 사이드 필터링 (전체 데이터 로드 후 필터)
|
||||
* - 삭제/일괄삭제 다이얼로그
|
||||
* - 통계카드 5개 (전체/은행/대출/증권/보험)
|
||||
* - 구분/금융기관 필터
|
||||
* - 수기 계좌 등록 버튼
|
||||
* - 범례 (수기/연동)
|
||||
* - 체크박스 없음
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import {
|
||||
Landmark,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Building2,
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type ListParams,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import type { Account } from './types';
|
||||
import type { Account, AccountCategory } from './types';
|
||||
import {
|
||||
BANK_LABELS,
|
||||
ACCOUNT_CATEGORY_LABELS,
|
||||
ACCOUNT_CATEGORY_FILTER_OPTIONS,
|
||||
ACCOUNT_TYPE_LABELS,
|
||||
ACCOUNT_STATUS_LABELS,
|
||||
ACCOUNT_STATUS_COLORS,
|
||||
ALL_FINANCIAL_INSTITUTION_OPTIONS,
|
||||
} from './types';
|
||||
import { getBankAccounts, deleteBankAccount, deleteBankAccounts } from './actions';
|
||||
import { getBankAccounts } from './actions';
|
||||
|
||||
// ===== 계좌번호 마스킹 함수 =====
|
||||
// ===== 계좌번호 마스킹 =====
|
||||
const maskAccountNumber = (accountNumber: string): string => {
|
||||
if (accountNumber.length <= 8) return accountNumber;
|
||||
if (!accountNumber || accountNumber.length <= 8) return accountNumber || '';
|
||||
const parts = accountNumber.split('-');
|
||||
if (parts.length >= 3) {
|
||||
// 1234-****-****-1234 형태
|
||||
return parts.map((part, idx) => {
|
||||
if (idx === 0 || idx === parts.length - 1) return part;
|
||||
return '****';
|
||||
}).join('-');
|
||||
}
|
||||
// 단순 형태: 앞 4자리-****-뒤 4자리
|
||||
const first = accountNumber.slice(0, 4);
|
||||
const last = accountNumber.slice(-4);
|
||||
return `${first}-****-****-${last}`;
|
||||
return `${first}-****-${last}`;
|
||||
};
|
||||
|
||||
export function AccountManagement() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 로딩 상태
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// ===== 날짜 범위 상태 =====
|
||||
const today = new Date();
|
||||
const [startDate, setStartDate] = useState(() => format(startOfMonth(today), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(() => format(endOfMonth(today), 'yyyy-MM-dd'));
|
||||
|
||||
// 삭제 다이얼로그
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
|
||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
|
||||
// ===== 필터 상태 =====
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [institutionFilter, setInstitutionFilter] = useState<string>('all');
|
||||
|
||||
// ===== 액션 핸들러 =====
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback((item: Account) => {
|
||||
router.push(`/ko/settings/accounts/${item.id}?mode=view`);
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback((item: Account) => {
|
||||
router.push(`/ko/settings/accounts/${item.id}?mode=edit`);
|
||||
}, [router]);
|
||||
|
||||
const handleDeleteClick = useCallback((id: string) => {
|
||||
setDeleteTargetId(id);
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteBankAccount(Number(deleteTargetId));
|
||||
if (result.success) {
|
||||
toast.success('계좌가 삭제되었습니다.');
|
||||
// 페이지 새로고침으로 데이터 갱신
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || '계좌 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
|
||||
const handleBulkDelete = useCallback((selectedIds: string[]) => {
|
||||
if (selectedIds.length > 0) {
|
||||
setBulkDeleteIds(selectedIds);
|
||||
setShowBulkDeleteDialog(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConfirmBulkDelete = useCallback(async () => {
|
||||
const ids = bulkDeleteIds.map(id => Number(id));
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteBankAccounts(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개의 계좌가 삭제되었습니다.`);
|
||||
if (result.error) {
|
||||
toast.warning(result.error);
|
||||
}
|
||||
// 페이지 새로고침으로 데이터 갱신
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || '계좌 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowBulkDeleteDialog(false);
|
||||
setBulkDeleteIds([]);
|
||||
}
|
||||
}, [bulkDeleteIds]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/settings/accounts?mode=new');
|
||||
}, [router]);
|
||||
|
||||
// ===== 금융기관 필터 옵션 =====
|
||||
const institutionFilterOptions = useMemo(() => [
|
||||
{ value: 'all', label: '전체' },
|
||||
...ALL_FINANCIAL_INSTITUTION_OPTIONS,
|
||||
], []);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Account> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '계좌관리',
|
||||
title: '계좌 관리',
|
||||
description: '계좌 목록을 관리합니다',
|
||||
icon: Landmark,
|
||||
basePath: '/settings/accounts',
|
||||
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
getItemId: (item: Account) => String(item.id),
|
||||
|
||||
// 체크박스 없음
|
||||
showCheckbox: false,
|
||||
|
||||
// 날짜 범위 선택기 + 프리셋 버튼
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
presets: ['thisMonth', 'lastMonth', 'twoMonthsAgo', 'threeMonthsAgo', 'fourMonthsAgo', 'fiveMonthsAgo'],
|
||||
presetLabels: {
|
||||
thisMonth: '이번달',
|
||||
lastMonth: '지난달',
|
||||
},
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
const result = await getBankAccounts();
|
||||
if (result.success && result.data) {
|
||||
// 클라이언트 사이드 검색 필터링
|
||||
let filteredData = result.data;
|
||||
|
||||
// 구분 필터
|
||||
if (categoryFilter && categoryFilter !== 'all') {
|
||||
filteredData = filteredData.filter(item => item.category === categoryFilter);
|
||||
}
|
||||
// 금융기관 필터
|
||||
if (institutionFilter && institutionFilter !== 'all') {
|
||||
filteredData = filteredData.filter(item => item.bankCode === institutionFilter);
|
||||
}
|
||||
// 검색 필터
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
filteredData = result.data.filter(item =>
|
||||
item.accountName.toLowerCase().includes(search) ||
|
||||
item.accountNumber.includes(search) ||
|
||||
item.accountHolder.toLowerCase().includes(search) ||
|
||||
BANK_LABELS[item.bankCode]?.toLowerCase().includes(search)
|
||||
const s = params.search.toLowerCase();
|
||||
filteredData = filteredData.filter(item =>
|
||||
item.accountName?.toLowerCase().includes(s) ||
|
||||
item.accountNumber?.includes(s) ||
|
||||
item.accountHolder?.toLowerCase().includes(s) ||
|
||||
BANK_LABELS[item.bankCode]?.toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,23 +165,67 @@ export function AccountManagement() {
|
||||
},
|
||||
},
|
||||
|
||||
// 통계카드
|
||||
computeStats: (data: Account[], totalCount: number): StatCard[] => {
|
||||
// 전체 데이터 기준 (필터 무관하게)
|
||||
return [
|
||||
{
|
||||
label: '전체계좌',
|
||||
value: totalCount,
|
||||
icon: Landmark,
|
||||
iconColor: 'text-blue-500',
|
||||
onClick: () => setCategoryFilter('all'),
|
||||
isActive: categoryFilter === 'all',
|
||||
},
|
||||
{
|
||||
label: '은행계좌',
|
||||
value: data.filter(a => a.category === 'bank_account').length,
|
||||
icon: Building2,
|
||||
iconColor: 'text-green-500',
|
||||
onClick: () => setCategoryFilter('bank_account'),
|
||||
isActive: categoryFilter === 'bank_account',
|
||||
},
|
||||
{
|
||||
label: '대출계좌',
|
||||
value: data.filter(a => a.category === 'loan_account').length,
|
||||
icon: CreditCard,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setCategoryFilter('loan_account'),
|
||||
isActive: categoryFilter === 'loan_account',
|
||||
},
|
||||
{
|
||||
label: '증권계좌',
|
||||
value: data.filter(a => a.category === 'securities_account').length,
|
||||
icon: TrendingUp,
|
||||
iconColor: 'text-purple-500',
|
||||
onClick: () => setCategoryFilter('securities_account'),
|
||||
isActive: categoryFilter === 'securities_account',
|
||||
},
|
||||
{
|
||||
label: '보험계좌',
|
||||
value: data.filter(a => a.category === 'insurance_account').length,
|
||||
icon: Shield,
|
||||
iconColor: 'text-red-500',
|
||||
onClick: () => setCategoryFilter('insurance_account'),
|
||||
isActive: categoryFilter === 'insurance_account',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||
{ key: 'bank', label: '은행', className: 'min-w-[100px]' },
|
||||
{ key: 'no', label: 'No.', className: 'text-center w-[60px]' },
|
||||
{ key: 'category', label: '구분', className: 'min-w-[80px]' },
|
||||
{ key: 'accountType', label: '유형', className: 'min-w-[80px]' },
|
||||
{ key: 'institution', label: '금융기관', className: 'min-w-[100px]' },
|
||||
{ key: 'accountNumber', label: '계좌번호', className: 'min-w-[160px]' },
|
||||
{ key: 'accountName', label: '계좌명', className: 'min-w-[120px]' },
|
||||
{ key: 'accountHolder', label: '예금주', className: 'min-w-[80px]' },
|
||||
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[100px] text-right' },
|
||||
{ key: 'status', label: '상태', className: 'min-w-[70px]' },
|
||||
],
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage,
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: '은행명, 계좌번호, 계좌명, 예금주 검색...',
|
||||
searchPlaceholder: '금융기관, 계좌번호, 계좌명 검색...',
|
||||
searchFilter: (item: Account, search: string) => {
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
@@ -214,21 +237,47 @@ export function AccountManagement() {
|
||||
);
|
||||
},
|
||||
|
||||
// 헤더 액션
|
||||
// 헤더 액션 - 수기 계좌 등록 버튼
|
||||
headerActions: () => (
|
||||
<Button className="ml-auto" onClick={handleCreate}>
|
||||
<Button
|
||||
className="ml-auto bg-orange-500 hover:bg-orange-600 text-white"
|
||||
onClick={handleCreate}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
계좌 등록
|
||||
수기 계좌 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 일괄 삭제 핸들러
|
||||
onBulkDelete: handleBulkDelete,
|
||||
// 테이블 카드 내부 필터 (구분, 금융기관 Select) - "총 N건" 옆에 배치
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[130px] h-9">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_CATEGORY_FILTER_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={institutionFilter} onValueChange={setInstitutionFilter}>
|
||||
<SelectTrigger className="w-[150px] h-9">
|
||||
<SelectValue placeholder="금융기관" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{institutionFilterOptions.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: Account,
|
||||
index: number,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Account>
|
||||
) => {
|
||||
@@ -238,49 +287,40 @@ export function AccountManagement() {
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-center">{globalIndex}</TableCell>
|
||||
<TableCell>{BANK_LABELS[item.bankCode] || item.bankCode}</TableCell>
|
||||
<TableCell className="font-mono">{maskAccountNumber(item.accountNumber)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{ACCOUNT_CATEGORY_LABELS[item.category] || item.category}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{ACCOUNT_TYPE_LABELS[item.accountType] || item.accountType || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
item.isManual ? 'bg-orange-400' : 'bg-blue-400'
|
||||
}`}
|
||||
/>
|
||||
{BANK_LABELS[item.bankCode] || item.bankCode}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{maskAccountNumber(item.accountNumber)}</TableCell>
|
||||
<TableCell>{item.accountName}</TableCell>
|
||||
<TableCell>{item.accountHolder}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={ACCOUNT_STATUS_COLORS[item.status]}>
|
||||
{ACCOUNT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
{handlers.isSelected && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(item)}
|
||||
title="수정"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteClick(String(item.id))}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
// 모바일 카드
|
||||
renderMobileCard: (
|
||||
item: Account,
|
||||
index: number,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Account>
|
||||
) => {
|
||||
@@ -292,7 +332,7 @@ export function AccountManagement() {
|
||||
headerBadges={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
{ACCOUNT_CATEGORY_LABELS[item.category]}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{BANK_LABELS[item.bankCode] || item.bankCode}
|
||||
@@ -304,85 +344,39 @@ export function AccountManagement() {
|
||||
{ACCOUNT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
isSelected={false}
|
||||
onToggleSelection={() => {}}
|
||||
onClick={() => handleRowClick(item)}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="유형" value={ACCOUNT_TYPE_LABELS[item.accountType] || '-'} />
|
||||
<InfoField label="계좌번호" value={maskAccountNumber(item.accountNumber)} />
|
||||
<InfoField label="예금주" value={item.accountHolder} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
handlers.isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteClick(String(item.id)); }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
// 테이블 카드 내부 하단 - 범례 (수기/연동)
|
||||
tableFooter: (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="border-0">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-orange-400" />
|
||||
수기 계좌
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-blue-400" />
|
||||
연동 계좌
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
}),
|
||||
[handleCreate, handleRowClick, handleEdit, handleDeleteClick, handleBulkDelete]
|
||||
[handleCreate, handleRowClick, categoryFilter, institutionFilter, institutionFilterOptions, startDate, endDate]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} />
|
||||
|
||||
{/* 단일 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="계좌 삭제"
|
||||
description={
|
||||
<>
|
||||
계좌를 정말 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
삭제된 계좌의 과거 사용 내역은 보존됩니다.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
|
||||
{/* 다중 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
open={showBulkDeleteDialog}
|
||||
onOpenChange={setShowBulkDeleteDialog}
|
||||
onConfirm={handleConfirmBulkDelete}
|
||||
title="계좌 삭제"
|
||||
description={
|
||||
<>
|
||||
선택하신 <strong>{bulkDeleteIds.length}개</strong>의 계좌를 정말 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
삭제된 계좌의 과거 사용 내역은 보존됩니다.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <UniversalListPage config={config} />;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,65 @@ export const ACCOUNT_STATUS_COLORS: Record<AccountStatus, string> = {
|
||||
inactive: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
// ===== 은행 목록 =====
|
||||
// ===== 계좌 구분 =====
|
||||
export type AccountCategory = 'bank_account' | 'loan_account' | 'securities_account' | 'insurance_account';
|
||||
|
||||
export const ACCOUNT_CATEGORY_LABELS: Record<AccountCategory, string> = {
|
||||
bank_account: '은행계좌',
|
||||
loan_account: '대출계좌',
|
||||
securities_account: '증권계좌',
|
||||
insurance_account: '보험계좌',
|
||||
};
|
||||
|
||||
export const ACCOUNT_CATEGORY_OPTIONS = [
|
||||
{ value: 'bank_account', label: '은행계좌' },
|
||||
{ value: 'loan_account', label: '대출계좌' },
|
||||
{ value: 'securities_account', label: '증권계좌' },
|
||||
{ value: 'insurance_account', label: '보험계좌' },
|
||||
];
|
||||
|
||||
export const ACCOUNT_CATEGORY_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
...ACCOUNT_CATEGORY_OPTIONS,
|
||||
];
|
||||
|
||||
// ===== 계좌 유형 (구분별) =====
|
||||
export type BankAccountType = 'savings' | 'fixed_deposit' | 'installment_savings' | 'foreign_currency' | 'other';
|
||||
export type LoanAccountType = 'facility_fund' | 'operating_fund' | 'other';
|
||||
export type SecuritiesAccountType = 'direct_investment' | 'fund' | 'trust' | 'other';
|
||||
export type InsuranceAccountType = 'group_insurance' | 'fire_insurance' | 'ceo_insurance';
|
||||
|
||||
export const ACCOUNT_TYPE_OPTIONS_BY_CATEGORY: Record<AccountCategory, { value: string; label: string }[]> = {
|
||||
bank_account: [
|
||||
{ value: 'savings', label: '보통예금' },
|
||||
{ value: 'fixed_deposit', label: '정기예금' },
|
||||
{ value: 'installment_savings', label: '적금' },
|
||||
{ value: 'foreign_currency', label: '외화예금' },
|
||||
{ value: 'other', label: '기타' },
|
||||
],
|
||||
loan_account: [
|
||||
{ value: 'facility_fund', label: '시설자금' },
|
||||
{ value: 'operating_fund', label: '운전자금' },
|
||||
{ value: 'other', label: '기타' },
|
||||
],
|
||||
securities_account: [
|
||||
{ value: 'direct_investment', label: '직접투자' },
|
||||
{ value: 'fund', label: '펀드' },
|
||||
{ value: 'trust', label: '신탁' },
|
||||
{ value: 'other', label: '기타' },
|
||||
],
|
||||
insurance_account: [
|
||||
{ value: 'group_insurance', label: '단체보험' },
|
||||
{ value: 'fire_insurance', label: '화재보험' },
|
||||
{ value: 'ceo_insurance', label: 'CEO보험' },
|
||||
],
|
||||
};
|
||||
|
||||
export const ACCOUNT_TYPE_LABELS: Record<string, string> = Object.values(ACCOUNT_TYPE_OPTIONS_BY_CATEGORY)
|
||||
.flat()
|
||||
.reduce((acc, opt) => ({ ...acc, [opt.value]: opt.label }), {} as Record<string, string>);
|
||||
|
||||
// ===== 금융기관 목록 (은행 + 증권 + 보험) =====
|
||||
export const BANK_OPTIONS = [
|
||||
{ value: 'shinhan', label: '신한은행' },
|
||||
{ value: 'kb', label: 'KB국민은행' },
|
||||
@@ -41,7 +99,44 @@ export const BANK_OPTIONS = [
|
||||
{ value: 'shinhyup', label: '신협' },
|
||||
];
|
||||
|
||||
export const BANK_LABELS: Record<string, string> = BANK_OPTIONS.reduce(
|
||||
export const SECURITIES_OPTIONS = [
|
||||
{ value: 'mirae', label: '미래에셋증권' },
|
||||
{ value: 'samsung', label: '삼성증권' },
|
||||
{ value: 'kb_securities', label: 'KB증권' },
|
||||
{ value: 'nh_securities', label: 'NH투자증권' },
|
||||
{ value: 'hana_securities', label: '하나증권' },
|
||||
{ value: 'shinhan_securities', label: '신한투자증권' },
|
||||
{ value: 'korea_invest', label: '한국투자증권' },
|
||||
{ value: 'kiwoom', label: '키움증권' },
|
||||
{ value: 'daishin', label: '대신증권' },
|
||||
];
|
||||
|
||||
export const INSURANCE_OPTIONS = [
|
||||
{ value: 'samsung_life', label: '삼성생명' },
|
||||
{ value: 'samsung_fire', label: '삼성화재' },
|
||||
{ value: 'hanwha_life', label: '한화생명' },
|
||||
{ value: 'kyobo', label: '교보생명' },
|
||||
{ value: 'db_insurance', label: 'DB손해보험' },
|
||||
{ value: 'hyundai_marine', label: '현대해상' },
|
||||
{ value: 'kb_insurance', label: 'KB손해보험' },
|
||||
{ value: 'meritz_fire', label: '메리츠화재' },
|
||||
{ value: 'nh_life', label: 'NH농협생명' },
|
||||
];
|
||||
|
||||
export const FINANCIAL_INSTITUTION_OPTIONS_BY_CATEGORY: Record<AccountCategory, { value: string; label: string }[]> = {
|
||||
bank_account: BANK_OPTIONS,
|
||||
loan_account: BANK_OPTIONS,
|
||||
securities_account: SECURITIES_OPTIONS,
|
||||
insurance_account: INSURANCE_OPTIONS,
|
||||
};
|
||||
|
||||
export const ALL_FINANCIAL_INSTITUTION_OPTIONS = [
|
||||
...BANK_OPTIONS,
|
||||
...SECURITIES_OPTIONS,
|
||||
...INSURANCE_OPTIONS,
|
||||
];
|
||||
|
||||
export const BANK_LABELS: Record<string, string> = ALL_FINANCIAL_INSTITUTION_OPTIONS.reduce(
|
||||
(acc, opt) => ({ ...acc, [opt.value]: opt.label }),
|
||||
{}
|
||||
);
|
||||
@@ -59,25 +154,123 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
// ===== 계좌 인터페이스 =====
|
||||
export interface Account {
|
||||
id: number;
|
||||
bankCode: string; // 은행 코드
|
||||
bankName: string; // 은행명
|
||||
accountNumber: string; // 계좌번호
|
||||
accountName: string; // 계좌명
|
||||
accountHolder: string; // 예금주
|
||||
status: AccountStatus; // 상태 (사용/정지)
|
||||
isPrimary: boolean; // 대표 계좌 여부
|
||||
assignedUserId?: number; // 담당자 ID
|
||||
bankCode: string;
|
||||
bankName: string;
|
||||
accountNumber: string;
|
||||
accountName: string;
|
||||
accountHolder: string;
|
||||
status: AccountStatus;
|
||||
isPrimary: boolean;
|
||||
assignedUserId?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
// 신규 공통
|
||||
category: AccountCategory;
|
||||
accountType: string;
|
||||
isManual: boolean;
|
||||
|
||||
// 은행계좌 정보
|
||||
contractAmount?: number;
|
||||
interestRate?: number;
|
||||
startDate?: string;
|
||||
maturityDate?: string;
|
||||
carryoverBalance?: number;
|
||||
|
||||
// 대출계좌 정보
|
||||
loanAmount?: number;
|
||||
loanBalance?: number;
|
||||
interestPaymentCycle?: string;
|
||||
repaymentMethod?: string;
|
||||
gracePeriod?: string;
|
||||
monthlyRepayment?: number;
|
||||
collateral?: string;
|
||||
|
||||
// 증권계좌 정보
|
||||
investmentAmount?: number;
|
||||
returnRate?: number;
|
||||
evaluationAmount?: number;
|
||||
|
||||
// 보험계좌 공통
|
||||
surrenderValue?: number;
|
||||
policyNumber?: string;
|
||||
paymentCycle?: string;
|
||||
premiumPerCycle?: number;
|
||||
// 단체보험
|
||||
premiumPerPerson?: number;
|
||||
enrolledCount?: number;
|
||||
// 화재보험
|
||||
insuredProperty?: string;
|
||||
propertyAddress?: string;
|
||||
// CEO보험
|
||||
beneficiary?: string;
|
||||
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// ===== 계좌 폼 데이터 =====
|
||||
export interface AccountFormData {
|
||||
// 공통
|
||||
category: AccountCategory;
|
||||
accountType: string;
|
||||
bankCode: string;
|
||||
bankName: string; // 은행명 (bank_code에서 매핑)
|
||||
bankName: string;
|
||||
accountNumber: string;
|
||||
accountName: string;
|
||||
accountHolder: string;
|
||||
accountPassword: string; // 빠른 조회 서비스용 (클라이언트 전용, API 미전송)
|
||||
status: AccountStatus;
|
||||
|
||||
// 은행계좌
|
||||
contractAmount?: number;
|
||||
interestRate?: number;
|
||||
startDate?: string;
|
||||
maturityDate?: string;
|
||||
carryoverBalance?: number;
|
||||
|
||||
// 대출계좌
|
||||
loanAmount?: number;
|
||||
loanBalance?: number;
|
||||
interestPaymentCycle?: string;
|
||||
repaymentMethod?: string;
|
||||
gracePeriod?: string;
|
||||
monthlyRepayment?: number;
|
||||
collateral?: string;
|
||||
|
||||
// 증권계좌
|
||||
investmentAmount?: number;
|
||||
returnRate?: number;
|
||||
evaluationAmount?: number;
|
||||
|
||||
// 보험계좌 공통
|
||||
surrenderValue?: number;
|
||||
policyNumber?: string;
|
||||
paymentCycle?: string;
|
||||
premiumPerCycle?: number;
|
||||
// 단체보험
|
||||
premiumPerPerson?: number;
|
||||
enrolledCount?: number;
|
||||
// 화재보험
|
||||
insuredProperty?: string;
|
||||
propertyAddress?: string;
|
||||
// CEO보험
|
||||
beneficiary?: string;
|
||||
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// ===== 통계 요약 =====
|
||||
export interface AccountSummary {
|
||||
total: number;
|
||||
bankAccount: number;
|
||||
loanAccount: number;
|
||||
securitiesAccount: number;
|
||||
insuranceAccount: number;
|
||||
}
|
||||
|
||||
// ===== 예금주/계약자/피보험자 라벨 =====
|
||||
export const HOLDER_LABEL_BY_CATEGORY: Record<AccountCategory, string> = {
|
||||
bank_account: '예금주',
|
||||
loan_account: '차입자',
|
||||
securities_account: '명의자',
|
||||
insurance_account: '피보험자',
|
||||
};
|
||||
|
||||
132
src/components/settings/BarobillIntegration/BankServiceModal.tsx
Normal file
132
src/components/settings/BarobillIntegration/BankServiceModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { BANK_OPTIONS, ACCOUNT_TYPE_OPTIONS } from './types';
|
||||
import type { BankServiceFormData } from './types';
|
||||
import { getBankServiceUrl } from './actions';
|
||||
|
||||
interface BankServiceModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function BankServiceModal({ open, onOpenChange }: BankServiceModalProps) {
|
||||
const [formData, setFormData] = useState<BankServiceFormData>({
|
||||
bankCode: BANK_OPTIONS[0].value,
|
||||
accountType: ACCOUNT_TYPE_OPTIONS[0].value,
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
setFormData({
|
||||
bankCode: BANK_OPTIONS[0].value,
|
||||
accountType: ACCOUNT_TYPE_OPTIONS[0].value,
|
||||
});
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await getBankServiceUrl(formData);
|
||||
if (result.success && result.data?.url) {
|
||||
window.open(result.data.url, '_blank');
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(result.error || '서비스 URL 조회에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('서비스 URL 조회 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[450px]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>은행 빠른조회 서비스 등록</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 은행 Select */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium">
|
||||
은행 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.bankCode}
|
||||
onValueChange={(v) => setFormData(prev => ({ ...prev, bankCode: v }))}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BANK_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 구분 Select */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium">
|
||||
구분 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.accountType}
|
||||
onValueChange={(v) => setFormData(prev => ({ ...prev, accountType: v }))}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_TYPE_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-3">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
조회 중...
|
||||
</>
|
||||
) : (
|
||||
'바로가기'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
111
src/components/settings/BarobillIntegration/LoginModal.tsx
Normal file
111
src/components/settings/BarobillIntegration/LoginModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { BarobillLoginFormData } from './types';
|
||||
import { registerBarobillLogin } from './actions';
|
||||
|
||||
interface LoginModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const initialFormData: BarobillLoginFormData = {
|
||||
barobillId: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
export function LoginModal({ open, onOpenChange, onSuccess }: LoginModalProps) {
|
||||
const [formData, setFormData] = useState<BarobillLoginFormData>(initialFormData);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (isOpen) setFormData(initialFormData);
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const handleChange = useCallback((key: keyof BarobillLoginFormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!formData.barobillId) {
|
||||
toast.error('바로빌 아이디를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.password) {
|
||||
toast.error('비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await registerBarobillLogin(formData);
|
||||
if (result.success) {
|
||||
toast.success('바로빌 로그인 정보가 등록되었습니다.');
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} else {
|
||||
toast.error(result.error || '등록에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('등록 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, onOpenChange, onSuccess]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[450px]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>바로빌 로그인 정보 등록</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<FormField
|
||||
label="바로빌 아이디"
|
||||
required
|
||||
value={formData.barobillId}
|
||||
onChange={(v) => handleChange('barobillId', v)}
|
||||
placeholder="Barobill_id"
|
||||
/>
|
||||
<FormField
|
||||
type="password"
|
||||
label="비밀번호"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(v) => handleChange('password', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-3">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
등록 중...
|
||||
</>
|
||||
) : (
|
||||
'등록하기'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
210
src/components/settings/BarobillIntegration/SignupModal.tsx
Normal file
210
src/components/settings/BarobillIntegration/SignupModal.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { BarobillSignupFormData } from './types';
|
||||
import { registerBarobillSignup } from './actions';
|
||||
|
||||
interface SignupModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const initialFormData: BarobillSignupFormData = {
|
||||
businessNumber: '',
|
||||
companyName: '',
|
||||
ceoName: '',
|
||||
businessType: '',
|
||||
businessCategory: '',
|
||||
address: '',
|
||||
barobillId: '',
|
||||
password: '',
|
||||
managerName: '',
|
||||
managerPhone: '',
|
||||
managerEmail: '',
|
||||
};
|
||||
|
||||
export function SignupModal({ open, onOpenChange, onSuccess }: SignupModalProps) {
|
||||
const [formData, setFormData] = useState<BarobillSignupFormData>(initialFormData);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (isOpen) setFormData(initialFormData);
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const handleChange = useCallback((key: keyof BarobillSignupFormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!formData.businessNumber) {
|
||||
toast.error('사업자등록번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.companyName) {
|
||||
toast.error('상호명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.ceoName) {
|
||||
toast.error('대표자명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.barobillId) {
|
||||
toast.error('바로빌 아이디를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.password) {
|
||||
toast.error('비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await registerBarobillSignup(formData);
|
||||
if (result.success) {
|
||||
toast.success('바로빌 회원가입 정보가 등록되었습니다.');
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} else {
|
||||
toast.error(result.error || '등록에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('등록 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, onOpenChange, onSuccess]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[560px] max-h-[90vh] overflow-y-auto" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>바로빌 회원가입 정보 등록</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 사업자등록번호 + 상호명 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="businessNumber"
|
||||
label="사업자등록번호"
|
||||
required
|
||||
value={formData.businessNumber}
|
||||
onChange={(v) => handleChange('businessNumber', v)}
|
||||
placeholder="123-12-12345"
|
||||
/>
|
||||
<FormField
|
||||
label="상호명"
|
||||
required
|
||||
value={formData.companyName}
|
||||
onChange={(v) => handleChange('companyName', v)}
|
||||
placeholder="(주)회사명"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 대표자명 + 업태 + 업종 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField
|
||||
label="대표자명"
|
||||
required
|
||||
value={formData.ceoName}
|
||||
onChange={(v) => handleChange('ceoName', v)}
|
||||
placeholder="홍길동"
|
||||
/>
|
||||
<FormField
|
||||
label="업태"
|
||||
value={formData.businessType}
|
||||
onChange={(v) => handleChange('businessType', v)}
|
||||
placeholder="업태"
|
||||
/>
|
||||
<FormField
|
||||
label="업종"
|
||||
value={formData.businessCategory}
|
||||
onChange={(v) => handleChange('businessCategory', v)}
|
||||
placeholder="업종"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 주소 */}
|
||||
<FormField
|
||||
label="주소"
|
||||
value={formData.address}
|
||||
onChange={(v) => handleChange('address', v)}
|
||||
placeholder="서울특별시 강남구"
|
||||
/>
|
||||
|
||||
{/* 바로빌 아이디 + 비밀번호 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="바로빌 아이디"
|
||||
required
|
||||
value={formData.barobillId}
|
||||
onChange={(v) => handleChange('barobillId', v)}
|
||||
placeholder="Barobill_id"
|
||||
/>
|
||||
<FormField
|
||||
type="password"
|
||||
label="비밀번호"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(v) => handleChange('password', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 담당자명 + 담당자 연락처 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="담당자명"
|
||||
value={formData.managerName}
|
||||
onChange={(v) => handleChange('managerName', v)}
|
||||
placeholder="홍길동"
|
||||
/>
|
||||
<FormField
|
||||
type="phone"
|
||||
label="담당자 연락처"
|
||||
value={formData.managerPhone}
|
||||
onChange={(v) => handleChange('managerPhone', v)}
|
||||
placeholder="010-1234-1234"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 담당자 이메일 */}
|
||||
<FormField
|
||||
label="담당자 이메일"
|
||||
value={formData.managerEmail}
|
||||
onChange={(v) => handleChange('managerEmail', v)}
|
||||
placeholder="manager@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-3">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
등록 중...
|
||||
</>
|
||||
) : (
|
||||
'등록하기'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
102
src/components/settings/BarobillIntegration/actions.ts
Normal file
102
src/components/settings/BarobillIntegration/actions.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
'use server';
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type {
|
||||
BarobillLoginFormData,
|
||||
BarobillSignupFormData,
|
||||
BankServiceFormData,
|
||||
IntegrationStatus,
|
||||
} from './types';
|
||||
|
||||
// ===== 바로빌 로그인 정보 등록 =====
|
||||
export async function registerBarobillLogin(
|
||||
data: BarobillLoginFormData
|
||||
): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/barobill/login'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
barobill_id: data.barobillId,
|
||||
password: data.password,
|
||||
},
|
||||
errorMessage: '바로빌 로그인 등록에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 바로빌 회원가입 정보 등록 =====
|
||||
export async function registerBarobillSignup(
|
||||
data: BarobillSignupFormData
|
||||
): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/barobill/signup'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
business_number: data.businessNumber,
|
||||
company_name: data.companyName,
|
||||
ceo_name: data.ceoName,
|
||||
business_type: data.businessType || undefined,
|
||||
business_category: data.businessCategory || undefined,
|
||||
address: data.address || undefined,
|
||||
barobill_id: data.barobillId,
|
||||
password: data.password,
|
||||
manager_name: data.managerName || undefined,
|
||||
manager_phone: data.managerPhone || undefined,
|
||||
manager_email: data.managerEmail || undefined,
|
||||
},
|
||||
errorMessage: '바로빌 회원가입 등록에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 은행 빠른조회 서비스 URL 조회 =====
|
||||
export async function getBankServiceUrl(
|
||||
data: BankServiceFormData
|
||||
): Promise<ActionResult<{ url: string }>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/barobill/bank-service-url', {
|
||||
bank_code: data.bankCode,
|
||||
account_type: data.accountType,
|
||||
}),
|
||||
transform: (resp: { url: string }) => ({ url: resp.url }),
|
||||
errorMessage: '은행 빠른조회 서비스 URL 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 연동 현황 조회 =====
|
||||
export async function getIntegrationStatus(): Promise<ActionResult<IntegrationStatus>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/barobill/status'),
|
||||
transform: (data: { bank_service_count?: number; account_link_count?: number }) => ({
|
||||
bankServiceCount: data.bank_service_count ?? 0,
|
||||
accountLinkCount: data.account_link_count ?? 0,
|
||||
}),
|
||||
errorMessage: '연동 현황 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계좌 연동 등록 URL 조회 =====
|
||||
export async function getAccountLinkUrl(): Promise<ActionResult<{ url: string }>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/barobill/account-link-url'),
|
||||
transform: (resp: { url: string }) => ({ url: resp.url }),
|
||||
errorMessage: '계좌 연동 페이지 URL 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 카드 연동 등록 URL 조회 =====
|
||||
export async function getCardLinkUrl(): Promise<ActionResult<{ url: string }>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/barobill/card-link-url'),
|
||||
transform: (resp: { url: string }) => ({ url: resp.url }),
|
||||
errorMessage: '카드 연동 페이지 URL 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 공인인증서 등록 URL 조회 =====
|
||||
export async function getCertificateUrl(): Promise<ActionResult<{ url: string }>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/barobill/certificate-url'),
|
||||
transform: (resp: { url: string }) => ({ url: resp.url }),
|
||||
errorMessage: '공인인증서 등록 페이지 URL 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
241
src/components/settings/BarobillIntegration/index.tsx
Normal file
241
src/components/settings/BarobillIntegration/index.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Link2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { LoginModal } from './LoginModal';
|
||||
import { SignupModal } from './SignupModal';
|
||||
import { BankServiceModal } from './BankServiceModal';
|
||||
import {
|
||||
getIntegrationStatus,
|
||||
getAccountLinkUrl,
|
||||
getCardLinkUrl,
|
||||
getCertificateUrl,
|
||||
} from './actions';
|
||||
import type { IntegrationStatus } from './types';
|
||||
|
||||
export function BarobillIntegration() {
|
||||
const [status, setStatus] = useState<IntegrationStatus | null>(null);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [signupOpen, setSignupOpen] = useState(false);
|
||||
const [bankServiceOpen, setBankServiceOpen] = useState(false);
|
||||
const [loadingAction, setLoadingAction] = useState<string | null>(null);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const result = await getIntegrationStatus();
|
||||
if (result.success && result.data) {
|
||||
setStatus(result.data);
|
||||
}
|
||||
} catch {
|
||||
// 상태 조회 실패 시 무시 (페이지 렌더링에 영향 없음)
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
const handleExternalLink = useCallback(async (
|
||||
type: 'account' | 'card' | 'certificate',
|
||||
) => {
|
||||
setLoadingAction(type);
|
||||
try {
|
||||
const actionMap = {
|
||||
account: getAccountLinkUrl,
|
||||
card: getCardLinkUrl,
|
||||
certificate: getCertificateUrl,
|
||||
};
|
||||
const result = await actionMap[type]();
|
||||
if (result.success && result.data?.url) {
|
||||
window.open(result.data.url, '_blank');
|
||||
} else {
|
||||
toast.error(result.error || '서비스 페이지 URL 조회에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('서비스 페이지 URL 조회 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="바로빌 연동 관리"
|
||||
description="바로빌 청구, 장부를 연동합니다."
|
||||
icon={Link2}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 바로빌 연동 */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">바로빌 연동</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 바로빌 로그인 정보 등록 */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h3 className="font-semibold mb-2">바로빌 회원이신가요?</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
바로빌 로그인 발급 정보 등록
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => setLoginOpen(true)}
|
||||
>
|
||||
바로빌 로그인 정보 등록
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 바로빌 회원가입 등록 */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h3 className="font-semibold mb-2">바로빌 회원이 아니신가요?</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
바로빌 회원가입 발급 등록 가능
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => setSignupOpen(true)}
|
||||
>
|
||||
바로빌 회원가입 등록
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 계좌 연동 */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">계좌 연동</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 은행 빠른조회 서비스 등록 */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-semibold">은행 빠른조회 서비스 등록</h3>
|
||||
{status && status.bankServiceCount > 0 && (
|
||||
<Badge variant="secondary">{status.bankServiceCount}개</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ul className="text-sm text-muted-foreground mb-4 space-y-1">
|
||||
<li>— 전 은행 인터넷뱅킹가입 필수</li>
|
||||
<li>— 빠른조회/공과서비스 가입</li>
|
||||
<li>— 조회용 계좌 등록</li>
|
||||
</ul>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => setBankServiceOpen(true)}
|
||||
>
|
||||
은행 빠른조회 서비스 등록
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 계좌 연동 등록 */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-semibold">계좌 연동 등록</h3>
|
||||
{status && status.accountLinkCount > 0 && (
|
||||
<Badge variant="secondary">{status.accountLinkCount}개</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ul className="text-sm text-muted-foreground mb-4 space-y-1">
|
||||
<li>— 바로빌에 연동되어있는 계좌 등록</li>
|
||||
<li>— 조회 가능 (실시간/주기적)</li>
|
||||
</ul>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => handleExternalLink('account')}
|
||||
disabled={loadingAction === 'account'}
|
||||
>
|
||||
{loadingAction === 'account' ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />이동 중...</>
|
||||
) : '계좌 연동 등록'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 카드 연동 & 공인인증서 등록 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 카드 연동 */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">카드 연동</h2>
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h3 className="font-semibold mb-2">카드 연동 등록</h3>
|
||||
<ul className="text-sm text-muted-foreground mb-4 space-y-1">
|
||||
<li>— 바로빌에 연동 카드 정보 등록</li>
|
||||
<li>— 카드사/비밀디카번/번호 입력</li>
|
||||
</ul>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => handleExternalLink('card')}
|
||||
disabled={loadingAction === 'card'}
|
||||
>
|
||||
{loadingAction === 'card' ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />이동 중...</>
|
||||
) : '카드 연동 등록'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* 공인인증서 등록 */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">공인인증서 등록</h2>
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h3 className="font-semibold mb-2">세금계산서용 공인인증서 등록</h3>
|
||||
<ul className="text-sm text-muted-foreground mb-4 space-y-1">
|
||||
<li>— 팩스 세금계산서 연동 등록</li>
|
||||
<li>— 범용 공인인증서만 등록 가능 (전자발급용으로도 가능)</li>
|
||||
</ul>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => handleExternalLink('certificate')}
|
||||
disabled={loadingAction === 'certificate'}
|
||||
>
|
||||
{loadingAction === 'certificate' ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />이동 중...</>
|
||||
) : '공인인증서 등록'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 */}
|
||||
<LoginModal
|
||||
open={loginOpen}
|
||||
onOpenChange={setLoginOpen}
|
||||
onSuccess={loadStatus}
|
||||
/>
|
||||
<SignupModal
|
||||
open={signupOpen}
|
||||
onOpenChange={setSignupOpen}
|
||||
onSuccess={loadStatus}
|
||||
/>
|
||||
<BankServiceModal
|
||||
open={bankServiceOpen}
|
||||
onOpenChange={setBankServiceOpen}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
57
src/components/settings/BarobillIntegration/types.ts
Normal file
57
src/components/settings/BarobillIntegration/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// ===== 바로빌 로그인 정보 등록 =====
|
||||
export interface BarobillLoginFormData {
|
||||
barobillId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// ===== 바로빌 회원가입 정보 등록 =====
|
||||
export interface BarobillSignupFormData {
|
||||
businessNumber: string;
|
||||
companyName: string;
|
||||
ceoName: string;
|
||||
businessType: string;
|
||||
businessCategory: string;
|
||||
address: string;
|
||||
barobillId: string;
|
||||
password: string;
|
||||
managerName: string;
|
||||
managerPhone: string;
|
||||
managerEmail: string;
|
||||
}
|
||||
|
||||
// ===== 은행 빠른조회 서비스 등록 =====
|
||||
export interface BankServiceFormData {
|
||||
bankCode: string;
|
||||
accountType: string;
|
||||
}
|
||||
|
||||
// ===== 은행 목록 옵션 =====
|
||||
export const BANK_OPTIONS = [
|
||||
{ value: 'kookmin', label: '국민은행' },
|
||||
{ value: 'shinhan', label: '신한은행' },
|
||||
{ value: 'woori', label: '우리은행' },
|
||||
{ value: 'hana', label: '하나은행' },
|
||||
{ value: 'nonghyup', label: '농협은행' },
|
||||
{ value: 'ibk', label: '기업은행' },
|
||||
{ value: 'kdb', label: '산업은행' },
|
||||
{ value: 'sc', label: 'SC제일은행' },
|
||||
{ value: 'citi', label: '씨티은행' },
|
||||
{ value: 'daegu', label: '대구은행' },
|
||||
{ value: 'busan', label: '부산은행' },
|
||||
{ value: 'kwangju', label: '광주은행' },
|
||||
{ value: 'jeju', label: '제주은행' },
|
||||
{ value: 'jeonbuk', label: '전북은행' },
|
||||
{ value: 'kyongnam', label: '경남은행' },
|
||||
{ value: 'suhyup', label: '수협은행' },
|
||||
] as const;
|
||||
|
||||
export const ACCOUNT_TYPE_OPTIONS = [
|
||||
{ value: 'corporate', label: '기업' },
|
||||
{ value: 'personal', label: '개인' },
|
||||
] as const;
|
||||
|
||||
// ===== 연동 현황 =====
|
||||
export interface IntegrationStatus {
|
||||
bankServiceCount: number;
|
||||
accountLinkCount: number;
|
||||
}
|
||||
Reference in New Issue
Block a user