feat: [settings] 바로빌 연동 기능 보강 + 은행/카드 거래 조회 개선

- 바로빌 연동: 액션/타입 확장, UI 보강
- 은행/카드 거래 조회 개선
- 공지 팝업 모달 수정
This commit is contained in:
유병철
2026-03-17 15:52:50 +09:00
parent b5b462d6fa
commit b33f7d9b11
6 changed files with 394 additions and 13 deletions

View File

@@ -54,6 +54,7 @@ import {
type BankTransactionSummaryData,
} from './actions';
import { TransactionFormModal } from './TransactionFormModal';
import { syncBankTransactions } from '@/components/settings/BarobillIntegration/actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { formatNumber } from '@/lib/utils/amount';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
@@ -130,6 +131,7 @@ export function BankTransactionInquiry() {
// 수정 추적 (로컬 변경사항)
const [localChanges, setLocalChanges] = useState<Map<string, Partial<BankTransaction>>>(new Map());
const [isBatchSaving, setIsBatchSaving] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
// ===== 데이터 로드 =====
const loadData = useCallback(async () => {
@@ -241,6 +243,26 @@ export function BankTransactionInquiry() {
}
}, [localChanges, loadData]);
// 바로빌 동기화
const handleSync = useCallback(async () => {
setIsSyncing(true);
try {
const sd = startDate.replace(/-/g, '');
const ed = endDate.replace(/-/g, '');
const result = await syncBankTransactions(sd, ed);
if (result.success && result.data) {
toast.success(`은행 거래 ${result.data.synced}건 동기화 완료`);
loadData();
} else {
toast.error(result.error || '동기화에 실패했습니다.');
}
} catch {
toast.error('동기화 중 오류가 발생했습니다.');
} finally {
setIsSyncing(false);
}
}, [startDate, endDate, loadData]);
// 엑셀 다운로드 (프론트 xlsx 생성)
const handleExcelDownload = useCallback(async () => {
try {
@@ -359,9 +381,22 @@ export function BankTransactionInquiry() {
onEndDateChange: setEndDate,
},
// 헤더 액션: ①저장 + 엑셀 다운로드 + ②수기 입력
// 헤더 액션: 동기화 + ①저장 + 엑셀 다운로드 + ②수기 입력
headerActions: () => (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSync}
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-1" />
)}
{isSyncing ? '동기화 중...' : '동기화'}
</Button>
<Button
size="sm"
onClick={handleBatchSave}
@@ -661,8 +696,10 @@ export function BankTransactionInquiry() {
handleExcelDownload,
handleCreateClick,
handleRowClick,
handleSync,
isRowModified,
isCellModified,
isSyncing,
loadData,
]
);

View File

@@ -53,6 +53,7 @@ import {
} from './actions';
import { ManualInputModal } from './ManualInputModal';
import { JournalEntryModal } from './JournalEntryModal';
import { syncCardTransactions } from '@/components/settings/BarobillIntegration/actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { formatNumber } from '@/lib/utils/amount';
import { filterByEnum } from '@/lib/utils/search';
@@ -124,6 +125,7 @@ export function CardTransactionInquiry() {
const [showJournalEntry, setShowJournalEntry] = useState(false);
const [journalTransaction, setJournalTransaction] = useState<CardTransaction | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
// ===== 데이터 로드 =====
const loadData = useCallback(async () => {
@@ -290,6 +292,26 @@ export function CardTransactionInquiry() {
setShowJournalEntry(true);
}, []);
// 바로빌 동기화
const handleSync = useCallback(async () => {
setIsSyncing(true);
try {
const sd = startDate.replace(/-/g, '');
const ed = endDate.replace(/-/g, '');
const result = await syncCardTransactions(sd, ed);
if (result.success && result.data) {
toast.success(`카드 거래 ${result.data.synced}건 동기화 완료`);
loadData();
} else {
toast.error(result.error || '동기화에 실패했습니다.');
}
} catch {
toast.error('동기화 중 오류가 발생했습니다.');
} finally {
setIsSyncing(false);
}
}, [startDate, endDate, loadData]);
const handleExcelDownload = useCallback(async () => {
try {
toast.info('엑셀 파일 생성 중...');
@@ -385,9 +407,22 @@ export function CardTransactionInquiry() {
onEndDateChange: setEndDate,
},
// 헤더 액션: 숨김보기 + 저장 + 엑셀 다운로드 + 수기 입력
// 헤더 액션: 동기화 + 숨김보기 + 저장 + 엑셀 다운로드 + 수기 입력
headerActions: () => (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSync}
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-1" />
)}
{isSyncing ? '동기화 중...' : '동기화'}
</Button>
<Button
variant="outline"
size="sm"
@@ -767,12 +802,14 @@ export function CardTransactionInquiry() {
showJournalEntry,
journalTransaction,
handleSave,
handleSync,
handleExcelDownload,
handleHide,
handleJournalEntry,
handleUnhide,
handleInlineEdit,
getEditValue,
isSyncing,
loadData,
]
);

View File

@@ -70,15 +70,20 @@ function dismissPopupForToday(popupId: string): void {
export function NoticePopupModal({ popup, open, onOpenChange }: NoticePopupModalProps) {
const [dontShowToday, setDontShowToday] = React.useState(false);
const handleClose = () => {
const handleClose = React.useCallback(() => {
if (dontShowToday) {
dismissPopupForToday(popup.id);
}
onOpenChange(false);
};
}, [dontShowToday, popup.id, onOpenChange]);
// 체크박스 상태를 팝업 전환 시 초기화
React.useEffect(() => {
setDontShowToday(false);
}, [popup.id]);
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Root open={open} onOpenChange={() => handleClose()}>
<DialogPrimitive.Portal>
{/* 오버레이 */}
<DialogPrimitive.Overlay

View File

@@ -7,6 +7,9 @@ import type {
BarobillSignupFormData,
BankServiceFormData,
IntegrationStatus,
RegisteredAccount,
RegisteredCard,
CertificateInfo,
} from './types';
// ===== 바로빌 로그인 정보 등록 =====
@@ -66,12 +69,14 @@ export async function getBankServiceUrl(
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; member?: { barobill_id?: string; biz_no?: string; status?: string; server_mode?: string } }) => ({
transform: (data: { bank_service_count?: number; account_link_count?: number; card_count?: number; member?: { barobill_id?: string; biz_no?: string; corp_name?: string; status?: string; server_mode?: string } }) => ({
bankServiceCount: data.bank_service_count ?? 0,
accountLinkCount: data.account_link_count ?? 0,
cardCount: data.card_count ?? 0,
member: data.member ? {
barobillId: data.member.barobill_id ?? '',
bizNo: data.member.biz_no ?? '',
corpName: data.member.corp_name ?? '',
status: data.member.status ?? '',
serverMode: data.member.server_mode ?? '',
} : undefined,
@@ -106,3 +111,73 @@ export async function getCertificateUrl(): Promise<ActionResult<{ url: string }>
errorMessage: '공인인증서 등록 페이지 URL 조회에 실패했습니다.',
});
}
// ===== 은행 거래 동기화 =====
export async function syncBankTransactions(
startDate?: string,
endDate?: string
): Promise<ActionResult<{ synced: number; accounts: number }>> {
return executeServerAction({
url: buildApiUrl('/api/v1/barobill/sync/bank'),
method: 'POST',
body: {
start_date: startDate,
end_date: endDate,
},
errorMessage: '은행 거래 동기화에 실패했습니다.',
});
}
// ===== 카드 거래 동기화 =====
export async function syncCardTransactions(
startDate?: string,
endDate?: string
): Promise<ActionResult<{ synced: number; cards: number }>> {
return executeServerAction({
url: buildApiUrl('/api/v1/barobill/sync/card'),
method: 'POST',
body: {
start_date: startDate,
end_date: endDate,
},
errorMessage: '카드 거래 동기화에 실패했습니다.',
});
}
// ===== 등록 계좌 목록 =====
export async function getRegisteredAccounts(): Promise<ActionResult<{
accounts: RegisteredAccount[];
}>> {
return executeServerAction({
url: buildApiUrl('/api/v1/barobill/accounts'),
errorMessage: '등록 계좌 조회에 실패했습니다.',
});
}
// ===== 등록 카드 목록 =====
export async function getRegisteredCards(): Promise<ActionResult<{
cards: RegisteredCard[];
}>> {
return executeServerAction({
url: buildApiUrl('/api/v1/barobill/cards'),
errorMessage: '등록 카드 조회에 실패했습니다.',
});
}
// ===== 인증서 상태 =====
export async function getCertificateStatus(): Promise<ActionResult<{
certificate: CertificateInfo;
}>> {
return executeServerAction({
url: buildApiUrl('/api/v1/barobill/certificate'),
errorMessage: '인증서 상태 조회에 실패했습니다.',
});
}
// ===== 충전잔액 =====
export async function getBalance(): Promise<ActionResult<{ balance: number | null }>> {
return executeServerAction({
url: buildApiUrl('/api/v1/barobill/balance'),
errorMessage: '충전잔액 조회에 실패했습니다.',
});
}

View File

@@ -3,7 +3,10 @@
import { useState, useEffect, useCallback } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Link2, Loader2, Edit, Check } from 'lucide-react';
import {
Link2, Loader2, Edit, Check, Building2, CreditCard,
ShieldCheck, ShieldAlert, Wallet, AlertTriangle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@@ -16,11 +19,24 @@ import {
getAccountLinkUrl,
getCardLinkUrl,
getCertificateUrl,
getRegisteredAccounts,
getRegisteredCards,
getCertificateStatus,
getBalance,
} from './actions';
import type { IntegrationStatus } from './types';
import type {
IntegrationStatus,
RegisteredAccount,
RegisteredCard,
CertificateInfo,
} from './types';
export function BarobillIntegration() {
const [status, setStatus] = useState<IntegrationStatus | null>(null);
const [accounts, setAccounts] = useState<RegisteredAccount[]>([]);
const [cards, setCards] = useState<RegisteredCard[]>([]);
const [certificate, setCertificate] = useState<CertificateInfo | null>(null);
const [balance, setBalance] = useState<number | null>(null);
const [loginOpen, setLoginOpen] = useState(false);
const [signupOpen, setSignupOpen] = useState(false);
const [bankServiceOpen, setBankServiceOpen] = useState(false);
@@ -33,13 +49,36 @@ export function BarobillIntegration() {
setStatus(result.data);
}
} catch {
// 상태 조회 실패 시 무시 (페이지 렌더링에 영향 없음)
// 상태 조회 실패 시 무시
}
}, []);
const loadDashboardData = useCallback(async () => {
const [accountsRes, cardsRes, certRes, balanceRes] = await Promise.allSettled([
getRegisteredAccounts(),
getRegisteredCards(),
getCertificateStatus(),
getBalance(),
]);
if (accountsRes.status === 'fulfilled' && accountsRes.value.success && accountsRes.value.data) {
setAccounts(accountsRes.value.data.accounts ?? []);
}
if (cardsRes.status === 'fulfilled' && cardsRes.value.success && cardsRes.value.data) {
setCards(cardsRes.value.data.cards ?? []);
}
if (certRes.status === 'fulfilled' && certRes.value.success && certRes.value.data) {
setCertificate(certRes.value.data.certificate ?? null);
}
if (balanceRes.status === 'fulfilled' && balanceRes.value.success && balanceRes.value.data) {
setBalance(balanceRes.value.data.balance ?? null);
}
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
loadDashboardData();
}, [loadStatus, loadDashboardData]);
const handleExternalLink = useCallback(async (
type: 'account' | 'card' | 'certificate',
@@ -66,6 +105,18 @@ export function BarobillIntegration() {
const hasLogin = !!status?.member?.barobillId;
// 인증서 만료 30일 이내 경고
const certExpiringSoon = (() => {
if (!certificate?.expire_date) return false;
const expireDate = new Date(certificate.expire_date);
const now = new Date();
const diffDays = Math.ceil((expireDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
return diffDays > 0 && diffDays <= 30;
})();
// 잔액 부족 경고 (10,000원 미만)
const balanceLow = balance !== null && balance < 10000;
return (
<PageLayout>
<PageHeader
@@ -75,7 +126,159 @@ export function BarobillIntegration() {
/>
<div className="space-y-6">
{/* 바로빌 연동 */}
{/* ===== 연동 현황 대시보드 ===== */}
{status?.member && (
<section>
<h2 className="text-lg font-semibold mb-3"> </h2>
<Card>
<CardContent className="p-5">
{/* 회원 정보 */}
<div className="flex flex-wrap items-center gap-2 mb-4 text-sm">
<span className="font-medium">{status.member.barobillId}</span>
<span className="text-muted-foreground">|</span>
<span>{status.member.corpName || status.member.bizNo}</span>
{status.member.corpName && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{status.member.bizNo}</span>
</>
)}
<span className="text-muted-foreground">|</span>
<Badge variant={status.member.serverMode === 'production' ? 'default' : 'secondary'}>
{status.member.serverMode === 'production' ? '운영' : '테스트'}
</Badge>
</div>
{/* 통계 카드 4개 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="flex items-center gap-3 rounded-lg border p-3">
<Building2 className="h-5 w-5 text-blue-500 shrink-0" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-semibold">{status.accountLinkCount}</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-lg border p-3">
<CreditCard className="h-5 w-5 text-purple-500 shrink-0" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-semibold">{status.cardCount}</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-lg border p-3">
{certificate?.is_valid ? (
<ShieldCheck className="h-5 w-5 text-green-500 shrink-0" />
) : (
<ShieldAlert className="h-5 w-5 text-red-500 shrink-0" />
)}
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-semibold">
{certificate ? (certificate.is_valid ? '유효' : '만료') : '-'}
</p>
{certificate?.expire_date && (
<p className="text-xs text-muted-foreground">
: {certificate.expire_date}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3 rounded-lg border p-3">
<Wallet className="h-5 w-5 text-amber-500 shrink-0" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-semibold">
{balance !== null ? `${balance.toLocaleString()}` : '-'}
</p>
</div>
</div>
</div>
{/* P2: 경고 표시 */}
{(certExpiringSoon || balanceLow) && (
<div className="mt-4 space-y-2">
{certExpiringSoon && (
<div className="flex items-center gap-2 rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
<span> 30 . (: {certificate?.expire_date})</span>
<Button
variant="link"
size="sm"
className="ml-auto text-amber-600 px-0"
onClick={() => handleExternalLink('certificate')}
>
</Button>
</div>
)}
{balanceLow && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-200 p-3 text-sm">
<AlertTriangle className="h-4 w-4 text-red-500 shrink-0" />
<span> . (: {balance?.toLocaleString()})</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
</section>
)}
{/* ===== 등록된 계좌/카드 목록 ===== */}
{(accounts.length > 0 || cards.length > 0) && (
<section>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 등록된 계좌 */}
{accounts.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3">
<Badge variant="secondary">{accounts.length}</Badge>
</h2>
<Card>
<CardContent className="p-4">
<ul className="space-y-2">
{accounts.map((acc, idx) => (
<li key={idx} className="flex items-center gap-2 text-sm">
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium">{acc.bankName}</span>
<span className="text-muted-foreground">{acc.bankAccountNum}</span>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
{/* 등록된 카드 */}
{cards.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3">
<Badge variant="secondary">{cards.length}</Badge>
</h2>
<Card>
<CardContent className="p-4">
<ul className="space-y-2">
{cards.map((card, idx) => (
<li key={idx} className="flex items-center gap-2 text-sm">
<CreditCard className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium">{card.cardCompanyName}</span>
<span className="text-muted-foreground">{card.cardNum}</span>
{card.alias && (
<span className="text-xs text-muted-foreground">({card.alias})</span>
)}
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)}
</div>
</section>
)}
{/* ===== 바로빌 연동 ===== */}
<section>
<h2 className="text-lg font-semibold mb-3"> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -144,7 +347,7 @@ export function BarobillIntegration() {
</div>
</section>
{/* 계좌 연동 */}
{/* ===== 계좌 연동 ===== */}
<section>
<h2 className="text-lg font-semibold mb-3"> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -200,7 +403,7 @@ export function BarobillIntegration() {
</div>
</section>
{/* 카드 연동 & 공인인증서 등록 */}
{/* ===== 카드 연동 & 공인인증서 등록 ===== */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 카드 연동 */}
<section>

View File

@@ -54,10 +54,34 @@ export const ACCOUNT_TYPE_OPTIONS = [
export interface IntegrationStatus {
bankServiceCount: number;
accountLinkCount: number;
cardCount: number;
member?: {
barobillId: string;
bizNo: string;
corpName: string;
status: string;
serverMode: string;
};
}
// ===== 등록 계좌 =====
export interface RegisteredAccount {
bankAccountNum: string;
bankName: string;
bankCode: string;
}
// ===== 등록 카드 =====
export interface RegisteredCard {
cardNum: string;
cardCompany: string;
cardCompanyName: string;
alias: string;
}
// ===== 인증서 상태 =====
export interface CertificateInfo {
is_valid: boolean;
expire_date: string | null;
regist_date: string | null;
}