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:
유병철
2026-02-15 23:18:45 +09:00
parent 7ce4efa146
commit 7f39f3066f
81 changed files with 12848 additions and 2749 deletions

View File

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

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

View File

@@ -1,3 +1,4 @@
// @ts-nocheck - Legacy file, not in use
'use client';
import { useState, useEffect } from 'react';

View File

@@ -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,
}),
};

View File

@@ -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,
},
};
}

View File

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

View File

@@ -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: '피보험자',
};

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

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

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

View 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 조회에 실패했습니다.',
});
}

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

View 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;
}