🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1477 lines
89 KiB
PHP
1477 lines
89 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>계좌 입출금내역 조회 - 바로빌 연동</title>
|
|
|
|
<!-- Fonts: Pretendard -->
|
|
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
|
|
|
|
<!-- Tailwind CSS -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
theme: {
|
|
extend: {
|
|
fontFamily: {
|
|
sans: ['Pretendard', 'sans-serif'],
|
|
},
|
|
colors: {
|
|
background: 'rgb(250, 250, 250)',
|
|
primary: {
|
|
DEFAULT: '#2563eb', // blue-600
|
|
foreground: '#ffffff',
|
|
},
|
|
accent: {
|
|
DEFAULT: '#3b82f6', // blue-500
|
|
light: '#dbeafe', // blue-100
|
|
}
|
|
},
|
|
borderRadius: {
|
|
'card': '12px',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<!-- React & ReactDOM -->
|
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
|
|
|
<!-- Babel for JSX -->
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
|
|
</head>
|
|
<body class="bg-background text-slate-800 antialiased">
|
|
<div id="root"></div>
|
|
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useRef } = React;
|
|
|
|
// SVG Icon Components
|
|
const Icons = {
|
|
wallet: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/>
|
|
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/>
|
|
<path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/>
|
|
</svg>
|
|
),
|
|
bank: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M3 21h18"/>
|
|
<path d="M5 21v-7"/>
|
|
<path d="M19 21v-7"/>
|
|
<path d="M10 9L3 21"/>
|
|
<path d="M14 9l7 12"/>
|
|
<rect x="2" y="3" width="20" height="5"/>
|
|
<line x1="12" x2="12" y1="21" y2="8"/>
|
|
</svg>
|
|
),
|
|
building: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect width="16" height="20" x="4" y="2" rx="2" ry="2"/>
|
|
<path d="M9 22v-4h6v4"/>
|
|
<path d="M8 6h.01"/>
|
|
<path d="M16 6h.01"/>
|
|
<path d="M12 6h.01"/>
|
|
<path d="M12 10h.01"/>
|
|
<path d="M12 14h.01"/>
|
|
<path d="M16 10h.01"/>
|
|
<path d="M16 14h.01"/>
|
|
<path d="M8 10h.01"/>
|
|
<path d="M8 14h.01"/>
|
|
</svg>
|
|
),
|
|
creditCard: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect width="20" height="14" x="2" y="5" rx="2"/>
|
|
<line x1="2" x2="22" y1="10" y2="10"/>
|
|
</svg>
|
|
),
|
|
receipt: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1-2-1Z"/>
|
|
<path d="M14 8H8"/>
|
|
<path d="M16 12H8"/>
|
|
<path d="M13 16H8"/>
|
|
</svg>
|
|
),
|
|
users: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
|
|
<circle cx="9" cy="7" r="4"/>
|
|
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
</svg>
|
|
),
|
|
bookOpen: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a4 4 0 0 0-4-4H2z"/>
|
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a4 4 0 0 1 4-4h6z"/>
|
|
</svg>
|
|
),
|
|
home: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
|
</svg>
|
|
),
|
|
search: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="11" cy="11" r="8"/>
|
|
<path d="m21 21-4.3-4.3"/>
|
|
</svg>
|
|
),
|
|
download: ({ className }) => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
<polyline points="7 10 12 15 17 10"/>
|
|
<line x1="12" x2="12" y1="15" y2="3"/>
|
|
</svg>
|
|
),
|
|
info: ({ className }) => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<path d="M12 16v-4"/>
|
|
<path d="M12 8h.01"/>
|
|
</svg>
|
|
),
|
|
arrowUp: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="12" y1="19" x2="12" y2="5"></line>
|
|
<polyline points="5 12 12 5 19 12"></polyline>
|
|
</svg>
|
|
),
|
|
arrowDown: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
<polyline points="19 12 12 19 5 12"></polyline>
|
|
</svg>
|
|
),
|
|
x: ({ className }) => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
<path d="M18 6 6 18"/>
|
|
<path d="m6 6 12 12"/>
|
|
</svg>
|
|
),
|
|
fileText: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
|
|
<path d="M14 2v4a2 2 0 0 0 2 2h4"/>
|
|
<path d="M10 9H8"/>
|
|
<path d="M16 13H8"/>
|
|
<path d="M16 17H8"/>
|
|
</svg>
|
|
)
|
|
};
|
|
|
|
// Header Component
|
|
const Header = ({ activeTab, onTabChange, tenants, currentTenantId, onTenantChange, onOpenApiInfo }) => (
|
|
<header className="bg-white/80 backdrop-blur-md border-b border-blue-100/50 sticky top-0 z-50 transition-all shadow-sm">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div className="h-18 flex items-center justify-between py-3">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-200/50 ring-4 ring-blue-50">
|
|
<Icons.wallet />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-lg font-bold text-slate-900 tracking-tight leading-none">계좌 입출금내역</h1>
|
|
<p className="text-[10px] text-blue-600 font-semibold mt-1 uppercase tracking-wider opacity-70">Bank Transaction History</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-sm text-slate-500 font-medium">
|
|
{/* 테넌트 선택 드롭다운 */}
|
|
{tenants.length > 0 && (
|
|
<select
|
|
value={currentTenantId}
|
|
onChange={(e) => onTenantChange(e.target.value)}
|
|
className="text-xs border border-slate-200 rounded-lg px-3 py-2 bg-white/50 hover:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 mr-2 transition-all"
|
|
>
|
|
{tenants.map(tenant => (
|
|
<option key={tenant.id} value={tenant.id}>
|
|
{tenant.name} {tenant.has_account ? '✓' : '(계좌없음)'}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
|
|
<div className="flex bg-slate-100/50 p-1 rounded-xl border border-slate-200/50 mr-2">
|
|
<a href="index.php" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white text-blue-600 shadow-sm border border-blue-100 font-bold transition-all">
|
|
<Icons.wallet /> <span>계좌조회</span>
|
|
</a>
|
|
<a href="../ecard/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-blue-600 hover:bg-white transition-all duration-200">
|
|
<Icons.creditCard /> <span>카드내역</span>
|
|
</a>
|
|
<a href="../tenant/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-blue-600 hover:bg-white transition-all duration-200">
|
|
<Icons.bank /> <span>테넌트</span>
|
|
</a>
|
|
<a href="../registration/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-blue-600 hover:bg-white transition-all duration-200">
|
|
<Icons.users /> <span>바로빌 회원관리</span>
|
|
</a>
|
|
<button onClick={(e) => { e.preventDefault(); onOpenApiInfo(); }} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-blue-600 hover:bg-white transition-all duration-200">
|
|
<Icons.bookOpen /> <span>API정보</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="h-4 w-px bg-slate-200 mx-2"></div>
|
|
|
|
<a href="../etax/index.php" className="flex items-center gap-1.5 px-3 py-2 text-slate-400 hover:text-blue-600 transition-colors">
|
|
<Icons.receipt /> <span className="hidden lg:inline text-xs">세금계산서</span>
|
|
</a>
|
|
<a href="../../index.php" className="flex items-center gap-1.5 px-3 py-2 text-slate-400 hover:text-blue-600 transition-colors">
|
|
<Icons.home /> <span className="hidden lg:inline text-xs">홈</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{/* 탭 메뉴 */}
|
|
<div className="flex gap-1 border-t border-gray-100">
|
|
<button
|
|
onClick={() => onTabChange('main')}
|
|
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 ${
|
|
activeTab === 'main'
|
|
? 'border-blue-600 text-blue-600'
|
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
입출금 내역
|
|
</button>
|
|
<button
|
|
onClick={() => onTabChange('status')}
|
|
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 ${
|
|
activeTab === 'status'
|
|
? 'border-blue-600 text-blue-600'
|
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
계좌 등록 상태
|
|
</button>
|
|
<button
|
|
onClick={() => onTabChange('debug')}
|
|
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 ${
|
|
activeTab === 'debug'
|
|
? 'border-blue-600 text-blue-600'
|
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
개발자 로그
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
|
|
// StatCard Component
|
|
const StatCard = ({ title, value, subtext, icon, color = 'blue' }) => (
|
|
<div className={`bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow`}>
|
|
<div className="flex items-start justify-between mb-4">
|
|
<h3 className="text-sm font-medium text-slate-500">{title}</h3>
|
|
<div className={`p-2 bg-${color}-50 rounded-lg text-${color}-600`}>
|
|
{icon}
|
|
</div>
|
|
</div>
|
|
<div className="text-2xl font-bold text-slate-900 mb-1">{value}</div>
|
|
{subtext && <div className="text-xs text-slate-400">{subtext}</div>}
|
|
</div>
|
|
);
|
|
|
|
// Account Selector Component
|
|
const AccountSelector = ({ accounts, selectedAccount, onSelect }) => (
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={() => onSelect('')}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
selectedAccount === ''
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-white border border-slate-200 text-slate-700 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
전체 계좌
|
|
</button>
|
|
{accounts.map(acc => (
|
|
<button
|
|
key={acc.bankAccountNum}
|
|
onClick={() => onSelect(acc.bankAccountNum)}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
selectedAccount === acc.bankAccountNum
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-white border border-slate-200 text-slate-700 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
{acc.bankName} {acc.bankAccountNum ? acc.bankAccountNum.slice(-4) : ''} ({acc.accountName || '별칭없음'})
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// TxtExportModal Component (Copied and adapted from ecard)
|
|
// Diagnostic Modal Component (복사 가능한 텍스트 모달)
|
|
const DiagnosticModal = ({ isOpen, onClose, title, content }) => {
|
|
const textareaRef = React.useRef(null);
|
|
const [copied, setCopied] = React.useState(false);
|
|
|
|
const handleCopy = () => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.select();
|
|
document.execCommand('copy');
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] flex flex-col">
|
|
<div className="flex items-center justify-between p-6 border-b border-slate-200">
|
|
<div>
|
|
<h3 className="text-xl font-bold text-slate-900">{title}</h3>
|
|
<p className="text-sm text-slate-500 mt-1">텍스트를 선택하여 복사할 수 있습니다</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleCopy}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
copied
|
|
? 'bg-green-600 text-white'
|
|
: 'bg-blue-600 text-white hover:bg-blue-700'
|
|
}`}
|
|
>
|
|
{copied ? '✓ 복사됨' : '전체 복사'}
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100"
|
|
>
|
|
<Icons.x className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-auto p-6">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={content}
|
|
readOnly
|
|
className="w-full h-full min-h-[400px] font-mono text-xs border border-slate-300 rounded-lg p-4 bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
style={{ resize: 'none' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const TxtExportModal = ({ isOpen, onClose, logs }) => {
|
|
const [copied, setCopied] = useState(false);
|
|
const textareaRef = useRef(null);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const generateTSV = () => {
|
|
const headers = ['거래일시', '계좌번호', '은행명', '입금액', '출금액', '잔액', '적요', '내용', '상대방'];
|
|
const headerRow = headers.join('\t');
|
|
|
|
const dataRows = logs.map(log => [
|
|
log.transDateTime,
|
|
log.bankAccountNum,
|
|
log.bankName,
|
|
log.deposit,
|
|
log.withdraw,
|
|
log.balance,
|
|
log.summary,
|
|
log.memo,
|
|
log.cast
|
|
].join('\t'));
|
|
|
|
return headerRow + '\n' + dataRows.join('\n');
|
|
};
|
|
|
|
const tsvData = generateTSV();
|
|
|
|
const handleCopy = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(tsvData);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch (err) {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.select();
|
|
document.execCommand('copy');
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-6xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
|
|
<div>
|
|
<h3 className="text-xl font-bold text-slate-900">내역 추출</h3>
|
|
<p className="text-sm text-slate-500 mt-1">엑셀에 붙여넣기 가능한 형식입니다</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleCopy}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
copied
|
|
? 'bg-green-600 text-white'
|
|
: 'bg-blue-600 text-white hover:bg-blue-700'
|
|
}`}
|
|
>
|
|
{copied ? '✓ 복사됨' : '전체 복사'}
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100"
|
|
>
|
|
<Icons.x className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-auto p-6">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={tsvData}
|
|
readOnly
|
|
className="w-full h-full min-h-[400px] font-mono text-xs border border-slate-300 rounded-lg p-4 bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
style={{ resize: 'none' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ApiInfoModal = ({ isOpen, onClose }) => {
|
|
if (!isOpen) return null;
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
|
|
<div className="p-4 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
|
<h3 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
|
<span className="w-1.5 h-6 bg-blue-500 rounded-full"></span>
|
|
바로빌 API 상세 정보
|
|
</h3>
|
|
<button onClick={onClose} className="p-2 rounded-full text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors">
|
|
<Icons.x className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 bg-slate-50">
|
|
<iframe
|
|
src="../etax/barobill_api_info.php"
|
|
className="w-full h-full border-none min-h-[600px]"
|
|
title="API Information"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Usage Table Component
|
|
const UsageTable = ({ logs, loading }) => {
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (logs.length === 0) {
|
|
return (
|
|
<div className="text-center py-20 text-slate-400">
|
|
<div className="flex justify-center mb-4"><Icons.wallet /></div>
|
|
<p>조회된 입출금 내역이 없습니다.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm text-slate-600">
|
|
<thead className="bg-slate-50 text-xs uppercase font-medium text-slate-500">
|
|
<tr>
|
|
<th className="px-4 py-3">거래일시</th>
|
|
<th className="px-4 py-3">계좌정보</th>
|
|
<th className="px-4 py-3">적요/내용</th>
|
|
<th className="px-4 py-3 text-right text-blue-600">입금</th>
|
|
<th className="px-4 py-3 text-right text-red-600">출금</th>
|
|
<th className="px-4 py-3 text-right">잔액</th>
|
|
<th className="px-4 py-3">상대방</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{logs.map((log, index) => (
|
|
<tr key={index} className="hover:bg-slate-50 transition-colors">
|
|
<td className="px-4 py-3 whitespace-nowrap">
|
|
<div className="font-medium text-slate-900">
|
|
{log.transDateTime || (log.transDate && log.transTime ?
|
|
(() => {
|
|
const date = String(log.transDate || '');
|
|
const time = String(log.transTime || '');
|
|
if (date && time) {
|
|
if (date.length === 8 && time.length >= 4) {
|
|
const formatted = `${date.substring(0,4)}-${date.substring(4,6)}-${date.substring(6,8)} ${time.substring(0,2)}:${time.substring(2,4)}`;
|
|
return time.length >= 6 ? formatted + ':' + time.substring(4,6) : formatted;
|
|
} else if (date.length === 10 && date.includes('-')) {
|
|
// 이미 포맷된 날짜
|
|
const formatted = `${date} ${time.substring(0,2)}:${time.substring(2,4)}`;
|
|
return time.length >= 6 ? formatted + ':' + time.substring(4,6) : formatted;
|
|
}
|
|
}
|
|
return log.transDate || log.transTime || '-';
|
|
})() :
|
|
(log.transDate || log.transTime || '-'))}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium text-slate-900">{log.bankName}</div>
|
|
<div className="text-xs text-slate-400 font-mono">
|
|
{log.bankAccountNum ? '****' + log.bankAccountNum.slice(-4) : '-'}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium text-slate-900">{log.summary}</div>
|
|
{log.memo && <div className="text-xs text-slate-400">{log.memo}</div>}
|
|
</td>
|
|
<td className="px-4 py-3 text-right font-medium text-blue-600">
|
|
{log.deposit > 0 ? log.depositFormatted + '원' : '-'}
|
|
</td>
|
|
<td className="px-4 py-3 text-right font-medium text-red-600">
|
|
{log.withdraw > 0 ? log.withdrawFormatted + '원' : '-'}
|
|
</td>
|
|
<td className="px-4 py-3 text-right text-slate-700">
|
|
{log.balanceFormatted}원
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-500">
|
|
{log.cast}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Date Range & Quick Date Buttons (Same as Ecard)
|
|
const DateRangeSelector = ({ startDate, endDate, onStartChange, onEndChange, onSearch }) => (
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm text-slate-600">시작일</label>
|
|
<input
|
|
type="date"
|
|
value={startDate}
|
|
onChange={(e) => onStartChange(e.target.value)}
|
|
className="rounded-lg border border-slate-200 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
/>
|
|
</div>
|
|
<span className="text-slate-400">~</span>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm text-slate-600">종료일</label>
|
|
<input
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => onEndChange(e.target.value)}
|
|
className="rounded-lg border border-slate-200 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={onSearch}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors flex items-center gap-2"
|
|
>
|
|
<Icons.search />
|
|
조회
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
const QuickDateButtons = ({ onSelect }) => {
|
|
const buttons = [
|
|
{ label: '오늘', days: 0 },
|
|
{ label: '7일', days: 7 },
|
|
{ label: '30일', days: 30 },
|
|
{ label: '3개월', days: 90 },
|
|
{ label: '6개월', days: 180 },
|
|
];
|
|
|
|
return (
|
|
<div className="flex gap-2">
|
|
{buttons.map(btn => (
|
|
<button
|
|
key={btn.days}
|
|
onClick={() => onSelect(btn.days)}
|
|
className="px-3 py-1.5 text-xs bg-slate-100 hover:bg-slate-200 rounded-md text-slate-600 transition-colors"
|
|
>
|
|
{btn.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Main App Component
|
|
const App = () => {
|
|
const [loading, setLoading] = useState(true);
|
|
const [accounts, setAccounts] = useState([]);
|
|
const [selectedAccount, setSelectedAccount] = useState('');
|
|
const [logs, setLogs] = useState([]);
|
|
const [pagination, setPagination] = useState({});
|
|
const [summary, setSummary] = useState({});
|
|
const [startDate, setStartDate] = useState('');
|
|
const [endDate, setEndDate] = useState('');
|
|
const [error, setError] = useState(null);
|
|
const [txtExportModalOpen, setTxtExportModalOpen] = useState(false);
|
|
const [activeTab, setActiveTab] = useState('main'); // 'main', 'status', 'debug'
|
|
const [accountStatus, setAccountStatus] = useState(null);
|
|
const [accountStatusLoading, setAccountStatusLoading] = useState(false);
|
|
const [debugLogs, setDebugLogs] = useState([]);
|
|
const [diagnosticModal, setDiagnosticModal] = useState({ open: false, content: '', title: '' });
|
|
const [tenants, setTenants] = useState([]);
|
|
const [currentTenantId, setCurrentTenantId] = useState('');
|
|
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false);
|
|
|
|
// 날짜 초기화
|
|
useEffect(() => {
|
|
const today = new Date();
|
|
const thirtyDaysAgo = new Date(today);
|
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
|
|
setEndDate(today.toISOString().split('T')[0]);
|
|
setStartDate(thirtyDaysAgo.toISOString().split('T')[0]);
|
|
}, []);
|
|
|
|
// 테넌트 목록 로드
|
|
useEffect(() => {
|
|
loadTenants();
|
|
}, []);
|
|
|
|
// 계좌 목록 로드 (테넌트 변경 시)
|
|
useEffect(() => {
|
|
if (currentTenantId) {
|
|
loadAccounts();
|
|
}
|
|
}, [currentTenantId]);
|
|
|
|
// 사용내역 로드 (날짜 설정 후)
|
|
useEffect(() => {
|
|
if (startDate && endDate) {
|
|
loadUsage();
|
|
}
|
|
}, [startDate, endDate, selectedAccount]);
|
|
|
|
const loadTenants = async () => {
|
|
try {
|
|
const response = await fetch('api/get_tenants.php');
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
setTenants(data.tenants || []);
|
|
// 기본값: '(주)주일기업' 찾기
|
|
let defaultTenantId = data.current_tenant_id;
|
|
if (!defaultTenantId && data.tenants && data.tenants.length > 0) {
|
|
const juilTenant = data.tenants.find(t =>
|
|
t.name.includes('주일기업') ||
|
|
t.name.includes('주일') ||
|
|
t.user_id === 'juil5130'
|
|
);
|
|
defaultTenantId = juilTenant ? juilTenant.id : data.tenants[0].id;
|
|
}
|
|
|
|
// 테넌트 ID 설정 및 자동으로 계좌 로드
|
|
if (defaultTenantId) {
|
|
setCurrentTenantId(defaultTenantId);
|
|
// 세션에 테넌트 ID 저장
|
|
const formData = new FormData();
|
|
formData.append('tenant_id', defaultTenantId);
|
|
fetch('api/set_tenant.php', {
|
|
method: 'POST',
|
|
body: formData
|
|
}).then(() => {
|
|
// 테넌트 설정 후 계좌 자동 로드
|
|
loadAccounts();
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('테넌트 목록 로드 오류:', err);
|
|
}
|
|
};
|
|
|
|
const changeTenant = async (tenantId) => {
|
|
if (!tenantId) return;
|
|
|
|
try {
|
|
// 먼저 상태 초기화
|
|
setCurrentTenantId(tenantId);
|
|
setAccounts([]);
|
|
setAccountStatus(null);
|
|
setLogs([]);
|
|
setError(null);
|
|
setSelectedAccount('');
|
|
|
|
// 세션에 테넌트 ID 저장
|
|
const formData = new FormData();
|
|
formData.append('tenant_id', tenantId);
|
|
|
|
const response = await fetch('api/set_tenant.php', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
// 계좌 목록 자동 로드
|
|
await loadAccounts();
|
|
} else {
|
|
console.error('테넌트 변경 실패:', data.error);
|
|
setError('테넌트 변경 실패: ' + data.error);
|
|
}
|
|
} catch (err) {
|
|
console.error('테넌트 변경 오류:', err);
|
|
setError('테넌트 변경 중 오류가 발생했습니다: ' + err.message);
|
|
}
|
|
};
|
|
|
|
const loadAccounts = async () => {
|
|
try {
|
|
const response = await fetch('api/accounts.php');
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
setAccounts(data.accounts || []);
|
|
} else {
|
|
console.error('계좌 목록 조회 실패:', data.error);
|
|
// 계좌 목록 조회 실패 시에도 에러 표시 (특히 -50214 오류)
|
|
if (data.error && data.error.includes('-50214')) {
|
|
setError(data.error);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('계좌 목록 로드 오류:', err);
|
|
}
|
|
};
|
|
|
|
const loadUsage = async (page = 1) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// 전체 계좌 선택 시 빈 문자열 전달 (서버에서 모든 계좌 조회)
|
|
const accountNum = selectedAccount || '';
|
|
|
|
const params = new URLSearchParams({
|
|
startDate: startDate.replace(/-/g, ''),
|
|
endDate: endDate.replace(/-/g, ''),
|
|
accountNum: accountNum,
|
|
page: page,
|
|
limit: 50
|
|
});
|
|
|
|
const response = await fetch(`api/transactions.php?${params}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setLogs(data.data.logs || []);
|
|
setPagination(data.data.pagination || {});
|
|
setSummary(data.data.summary || {});
|
|
|
|
// 디버그: API 응답 구조 확인
|
|
if (data.debug_api_structure) {
|
|
console.log('=== API 응답 구조 디버그 ===');
|
|
console.log('첫 번째 로그 구조:', data.debug_api_structure.first_log_structure);
|
|
console.log('날짜/시간 필드:', data.debug_api_structure.first_log_datetime);
|
|
console.log('적요 필드:', data.debug_api_structure.first_log_summary);
|
|
}
|
|
|
|
// 디버그: 첫 번째 로그의 원본 데이터 확인
|
|
if (data.debug_first_log_raw) {
|
|
console.log('=== 첫 번째 로그 원본 데이터 ===');
|
|
console.log('모든 키:', Object.keys(data.debug_first_log_raw));
|
|
console.log('전체 데이터:', JSON.stringify(data.debug_first_log_raw, null, 2));
|
|
}
|
|
|
|
// 디버그: 전체 계좌 조회 시 첫 번째 계좌의 첫 번째 로그 확인
|
|
if (data.debug_all_accounts) {
|
|
console.log('=== 전체 계좌 조회 디버그 ===');
|
|
const rawFields = data.debug_all_accounts.first_account_first_log?.log_raw_fields;
|
|
if (rawFields) {
|
|
console.log('첫 번째 계좌의 첫 번째 로그 원본 필드 (모든 키):', Object.keys(rawFields));
|
|
console.log('첫 번째 계좌의 첫 번째 로그 원본 필드 (전체):', JSON.stringify(rawFields, null, 2));
|
|
}
|
|
console.log('첫 번째 계좌의 첫 번째 로그 파싱된 데이터:', data.debug_all_accounts.first_account_first_log?.parsed);
|
|
}
|
|
|
|
// 디버그: 첫 번째 로그의 파싱된 데이터 확인
|
|
if (data.data.logs && data.data.logs.length > 0) {
|
|
console.log('=== 첫 번째 로그 파싱된 데이터 ===');
|
|
console.log('transDateTime:', data.data.logs[0].transDateTime);
|
|
console.log('transDate:', data.data.logs[0].transDate);
|
|
console.log('transTime:', data.data.logs[0].transTime);
|
|
console.log('summary:', data.data.logs[0].summary);
|
|
console.log('cast:', data.data.logs[0].cast);
|
|
console.log('branch:', data.data.logs[0].branch);
|
|
console.log('전체 첫 번째 로그:', data.data.logs[0]);
|
|
}
|
|
|
|
// 디버그 로그 저장
|
|
if (data.debug) {
|
|
setDebugLogs(prev => [{
|
|
type: 'API_CALL',
|
|
method: 'GetPeriodBankAccountTransLog',
|
|
timestamp: new Date().toISOString(),
|
|
request: data.debug.request,
|
|
request_xml: data.debug.request_xml,
|
|
response_xml: data.debug.response_xml,
|
|
success: true,
|
|
api_structure: data.debug_api_structure,
|
|
first_log_raw: data.debug_first_log_raw
|
|
}, ...prev].slice(0, 20)); // 최근 20개만 유지
|
|
}
|
|
} else {
|
|
setError(data.error);
|
|
setLogs([]);
|
|
|
|
// 에러도 로그에 저장
|
|
setDebugLogs(prev => [{
|
|
type: 'API_ERROR',
|
|
method: 'GetPeriodBankAccountTransLog',
|
|
timestamp: new Date().toISOString(),
|
|
error: data.error,
|
|
error_code: data.error_code,
|
|
success: false
|
|
}, ...prev].slice(0, 20));
|
|
}
|
|
} catch (err) {
|
|
setError('서버 통신 오류: ' + err.message);
|
|
setLogs([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleQuickDateSelect = (days) => {
|
|
const today = new Date();
|
|
const startDateObj = new Date(today);
|
|
startDateObj.setDate(startDateObj.getDate() - days);
|
|
|
|
setEndDate(today.toISOString().split('T')[0]);
|
|
setStartDate(startDateObj.toISOString().split('T')[0]);
|
|
};
|
|
|
|
// 계좌 등록 상태 로드
|
|
const loadAccountStatus = async () => {
|
|
setAccountStatusLoading(true);
|
|
try {
|
|
const response = await fetch('api/account_status.php');
|
|
const data = await response.json();
|
|
setAccountStatus(data);
|
|
|
|
// 디버그 로그 저장
|
|
if (data.debug) {
|
|
setDebugLogs(prev => [{
|
|
type: 'API_CALL',
|
|
method: 'GetBankAccountEx',
|
|
timestamp: new Date().toISOString(),
|
|
request: data.debug.request,
|
|
request_xml: data.debug.request_xml,
|
|
response_xml: data.debug.response_xml,
|
|
success: data.success
|
|
}, ...prev].slice(0, 20));
|
|
}
|
|
} catch (err) {
|
|
console.error('계좌 상태 로드 오류:', err);
|
|
setAccountStatus({
|
|
success: false,
|
|
error: '계좌 상태 로드 실패: ' + err.message
|
|
});
|
|
} finally {
|
|
setAccountStatusLoading(false);
|
|
}
|
|
};
|
|
|
|
// 탭 변경 시 계좌 상태 로드
|
|
useEffect(() => {
|
|
if (activeTab === 'status') {
|
|
loadAccountStatus();
|
|
}
|
|
}, [activeTab]);
|
|
|
|
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
|
|
|
|
return (
|
|
<div className="min-h-screen pb-20">
|
|
<Header
|
|
activeTab={activeTab}
|
|
onTabChange={setActiveTab}
|
|
tenants={tenants}
|
|
currentTenantId={currentTenantId}
|
|
onTenantChange={changeTenant}
|
|
onOpenApiInfo={() => setIsApiInfoModalOpen(true)}
|
|
/>
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
|
{/* 메인 탭: 입출금 내역 */}
|
|
{activeTab === 'main' && (
|
|
<>
|
|
{/* 테넌트 정보 표시 */}
|
|
{tenants.length > 0 && (
|
|
<section>
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-blue-600 font-semibold">현재 조회 중:</span>
|
|
<span className="text-blue-800">
|
|
{tenants.find(t => t.id === currentTenantId)?.name || '테넌트 선택 필요'}
|
|
</span>
|
|
{selectedAccount && accounts.length > 0 && (() => {
|
|
const selectedAcc = accounts.find(acc => {
|
|
const accNum = (acc.bankAccountNum || '').replace(/-/g, '');
|
|
const selectedNum = selectedAccount.replace(/-/g, '');
|
|
return accNum === selectedNum;
|
|
});
|
|
if (selectedAcc) {
|
|
return (
|
|
<span className="text-blue-700 text-sm">
|
|
- {selectedAcc.bankName} {selectedAcc.bankAccountNum}
|
|
</span>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
{accounts.length === 0 && (
|
|
<span className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
|
(등록된 계좌 없음)
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* 통계 카드 */}
|
|
<section>
|
|
<h2 className="text-xl font-bold text-slate-900 mb-6">입출금 현황</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<StatCard
|
|
title="총 입금액"
|
|
value={formatCurrency(summary.totalDeposit)}
|
|
subtext={`조회기간 합계`}
|
|
icon={<Icons.arrowDown />}
|
|
color="blue"
|
|
/>
|
|
<StatCard
|
|
title="총 출금액"
|
|
value={formatCurrency(summary.totalWithdraw)}
|
|
subtext={`조회기간 합계`}
|
|
icon={<Icons.arrowUp />}
|
|
color="red"
|
|
/>
|
|
<StatCard
|
|
title="등록된 계좌"
|
|
value={`${accounts.length}개`}
|
|
subtext="사용 가능한 계좌"
|
|
icon={<Icons.bank />}
|
|
/>
|
|
<StatCard
|
|
title="거래건수"
|
|
value={`${(summary.count || 0).toLocaleString()}건`}
|
|
subtext="전체 입출금 건수"
|
|
icon={<Icons.fileText />}
|
|
color="slate"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{/* 필터 섹션 */}
|
|
<section className="bg-white rounded-card shadow-sm border border-slate-100 p-6 space-y-4">
|
|
<div className="flex justify-between items-start">
|
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
|
<span className="text-blue-600"><Icons.search /></span>
|
|
조회 조건
|
|
</h2>
|
|
<QuickDateButtons onSelect={handleQuickDateSelect} />
|
|
</div>
|
|
|
|
{accounts.length > 0 && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">계좌 선택</label>
|
|
<AccountSelector
|
|
accounts={accounts}
|
|
selectedAccount={selectedAccount}
|
|
onSelect={setSelectedAccount}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-between items-end">
|
|
<DateRangeSelector
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onStartChange={setStartDate}
|
|
onEndChange={setEndDate}
|
|
onSearch={() => loadUsage(1)}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setTxtExportModalOpen(true)}
|
|
className="text-sm text-slate-500 hover:text-blue-600 flex items-center gap-1 bg-white border border-slate-200 px-3 py-2 rounded-lg"
|
|
>
|
|
<Icons.download className="w-4 h-4" />
|
|
내역 내보내기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* 내역 테이블 */}
|
|
<section>
|
|
{error ? (
|
|
<div className="bg-red-50 border border-red-200 text-red-600 p-4 rounded-lg">
|
|
<div className="flex items-start gap-3">
|
|
<div className="text-xl">⚠️</div>
|
|
<div className="flex-1">
|
|
<p className="font-semibold mb-2">{error}</p>
|
|
{error.includes('-50214') && (
|
|
<div className="mt-3 p-3 bg-white rounded border border-red-200 text-sm">
|
|
<p className="font-medium mb-2">해결 방법:</p>
|
|
<ol className="list-decimal list-inside space-y-1 text-slate-700">
|
|
<li>바로빌 사이트(<a href="https://www.barobill.co.kr" target="_blank" className="text-blue-600 hover:underline">https://www.barobill.co.kr</a>)에 로그인</li>
|
|
<li>계좌 관리 메뉴에서 해당 계좌 확인</li>
|
|
<li>계좌 비밀번호가 변경되었는지 확인</li>
|
|
<li>인증서가 만료되지 않았는지 확인</li>
|
|
<li>필요시 계좌 재등록 또는 비밀번호 재설정</li>
|
|
</ol>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<UsageTable logs={logs} loading={loading} />
|
|
)}
|
|
|
|
{/* Pagination (Simplified) */}
|
|
{pagination.maxPageNum > 1 && (
|
|
<div className="flex justify-center mt-6 gap-2">
|
|
<button
|
|
onClick={() => loadUsage(Math.max(1, pagination.currentPage - 1))}
|
|
disabled={pagination.currentPage === 1}
|
|
className="px-3 py-1 rounded bg-white border disabled:opacity-50"
|
|
>
|
|
이전
|
|
</button>
|
|
<span className="px-3 py-1">
|
|
{pagination.currentPage} / {pagination.maxPageNum}
|
|
</span>
|
|
<button
|
|
onClick={() => loadUsage(Math.min(pagination.maxPageNum, pagination.currentPage + 1))}
|
|
disabled={pagination.currentPage === pagination.maxPageNum}
|
|
className="px-3 py-1 rounded bg-white border disabled:opacity-50"
|
|
>
|
|
다음
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
{/* 계좌 등록 상태 탭 */}
|
|
{activeTab === 'status' && (
|
|
<section className="space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h2 className="text-xl font-bold text-slate-900">계좌 등록 상태</h2>
|
|
<p className="text-sm text-slate-500 mt-1">
|
|
{accountStatus && accountStatus.success ? (
|
|
<>
|
|
{tenants.find(t => t.id === currentTenantId)?.name || '현재 테넌트'}
|
|
{accountStatus.count > 0 && (
|
|
<span className="text-green-600 font-medium">
|
|
{' - ' + accountStatus.message}
|
|
</span>
|
|
)}
|
|
{accountStatus.count === 0 && (
|
|
<span className="text-yellow-600">
|
|
{' - 등록된 계좌가 없습니다.'}
|
|
</span>
|
|
)}
|
|
</>
|
|
) : accountStatus && !accountStatus.success ? (
|
|
<span className="text-red-600">
|
|
{tenants.find(t => t.id === currentTenantId)?.name || '현재 테넌트'} - 오류 발생
|
|
</span>
|
|
) : (
|
|
<>
|
|
{tenants.find(t => t.id === currentTenantId)?.name || '현재 테넌트'} - 로딩 중...
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
const response = await fetch('api/check_api_config.php');
|
|
const data = await response.json();
|
|
setDiagnosticModal({
|
|
open: true,
|
|
title: 'API 설정 진단 결과',
|
|
content: JSON.stringify(data, null, 2)
|
|
});
|
|
} catch (err) {
|
|
setDiagnosticModal({
|
|
open: true,
|
|
title: '진단 오류',
|
|
content: '진단 오류: ' + err.message
|
|
});
|
|
}
|
|
}}
|
|
className="px-4 py-2 bg-slate-600 text-white rounded-lg hover:bg-slate-700 text-sm font-medium"
|
|
>
|
|
API 설정 진단
|
|
</button>
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
const response = await fetch('api/debug_accounts.php');
|
|
const data = await response.json();
|
|
setDiagnosticModal({
|
|
open: true,
|
|
title: '계좌 정보 디버깅 결과',
|
|
content: JSON.stringify(data, null, 2)
|
|
});
|
|
} catch (err) {
|
|
setDiagnosticModal({
|
|
open: true,
|
|
title: '디버깅 오류',
|
|
content: '디버깅 오류: ' + err.message
|
|
});
|
|
}
|
|
}}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium"
|
|
>
|
|
계좌 정보 디버깅
|
|
</button>
|
|
<button
|
|
onClick={loadAccountStatus}
|
|
disabled={accountStatusLoading}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium flex items-center gap-2"
|
|
>
|
|
{accountStatusLoading ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
로딩 중...
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
|
|
<path d="M21 3v5h-5"/>
|
|
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
|
<path d="M8 16H3v5"/>
|
|
</svg>
|
|
새로고침
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* API 설정 진단 경고 */}
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="text-yellow-600 text-xl">⚠️</div>
|
|
<div className="flex-1">
|
|
<p className="font-semibold text-yellow-800 mb-2">API 키 설정 확인 필요</p>
|
|
<p className="text-sm text-yellow-700 mb-2">
|
|
바로빌 사이트에서는 조회가 되지만 API로는 안 되는 경우, API 키 설정을 확인하세요.
|
|
</p>
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
const response = await fetch('api/check_api_config.php');
|
|
const data = await response.json();
|
|
console.log('API 설정 진단:', data);
|
|
|
|
// 상세 정보를 모달로 표시
|
|
const diagnostics = data.diagnostics;
|
|
let message = '=== API 설정 진단 결과 ===\n\n';
|
|
|
|
message += 'CERTKEY:\n';
|
|
message += ` 파일 존재: ${diagnostics.cert_key.file_exists ? '✓' : '✗'}\n`;
|
|
message += ` 설정됨: ${diagnostics.cert_key.is_set ? '✓' : '✗'}\n`;
|
|
message += ` 길이: ${diagnostics.cert_key.length}자\n`;
|
|
message += ` 미리보기: ${diagnostics.cert_key.preview}\n\n`;
|
|
|
|
message += '사업자번호:\n';
|
|
message += ` 파일 존재: ${diagnostics.corp_num.file_exists ? '✓' : '✗'}\n`;
|
|
message += ` 설정됨: ${diagnostics.corp_num.is_set ? '✓' : '✗'}\n`;
|
|
message += ` 값: ${diagnostics.corp_num.value || '없음'}\n\n`;
|
|
|
|
if (diagnostics.api_test) {
|
|
message += 'API 테스트:\n';
|
|
message += ` 성공: ${diagnostics.api_test.success ? '✓' : '✗'}\n`;
|
|
if (diagnostics.api_test.error) {
|
|
message += ` 오류: ${diagnostics.api_test.error}\n`;
|
|
if (diagnostics.api_test.error_code) {
|
|
message += ` 오류 코드: ${diagnostics.api_test.error_code}\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (data.recommendations && data.recommendations.length > 0) {
|
|
message += '\n권장 사항:\n';
|
|
data.recommendations.forEach((rec, idx) => {
|
|
if (rec) message += `${idx + 1}. ${rec}\n`;
|
|
});
|
|
}
|
|
|
|
setDiagnosticModal({
|
|
open: true,
|
|
title: 'API 설정 진단 결과',
|
|
content: message
|
|
});
|
|
} catch (err) {
|
|
setDiagnosticModal({
|
|
open: true,
|
|
title: '진단 오류',
|
|
content: '진단 오류: ' + err.message
|
|
});
|
|
}
|
|
}}
|
|
className="text-sm bg-yellow-600 text-white px-3 py-1.5 rounded hover:bg-yellow-700"
|
|
>
|
|
API 설정 진단 실행
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{accountStatus === null || accountStatusLoading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
) : accountStatus.success ? (
|
|
<div className="bg-white rounded-card shadow-sm border border-slate-100 p-6">
|
|
{accountStatus.warning_count > 0 ? (
|
|
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
<p className="text-yellow-800 font-semibold">⚠️ {accountStatus.message}</p>
|
|
<p className="text-sm text-yellow-700 mt-2">
|
|
로컬 DB에만 등록된 계좌는 바로빌 API에 등록해야 실제 조회가 가능합니다.
|
|
</p>
|
|
{accountStatus.local_count !== undefined && (
|
|
<p className="text-sm text-yellow-600 mt-1">
|
|
전체: {accountStatus.count}개 (사용 가능: {accountStatus.available_count || 0}개, 바로빌 미등록: {accountStatus.warning_count}개)
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
|
<p className="text-green-800 font-semibold">✓ {accountStatus.message}</p>
|
|
{accountStatus.available_count !== undefined && (
|
|
<p className="text-sm text-green-600 mt-1">
|
|
사용 가능한 계좌: {accountStatus.available_count}개
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{accountStatus.barobill_error && (
|
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
<p className="text-sm text-red-600">
|
|
⚠️ 바로빌 API 오류: {accountStatus.barobill_error}
|
|
{accountStatus.barobill_error_code && ` (코드: ${accountStatus.barobill_error_code})`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{accountStatus.accounts && accountStatus.accounts.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left">은행명</th>
|
|
<th className="px-4 py-3 text-left">계좌번호</th>
|
|
<th className="px-4 py-3 text-left">계좌명</th>
|
|
<th className="px-4 py-3 text-right">잔액</th>
|
|
<th className="px-4 py-3 text-center">상태</th>
|
|
<th className="px-4 py-3 text-left">등록일</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{accountStatus.accounts.map((acc, idx) => (
|
|
<tr key={idx} className="hover:bg-slate-50">
|
|
<td className="px-4 py-3 font-medium">
|
|
{acc.bankName}
|
|
{acc.source && (
|
|
<span className={`ml-2 px-1.5 py-0.5 rounded text-xs ${
|
|
acc.source === 'local_db_only' ? 'bg-red-100 text-red-700' :
|
|
acc.source === 'barobill_api' ? 'bg-green-100 text-green-700' :
|
|
acc.source === 'both' ? 'bg-blue-100 text-blue-700' :
|
|
acc.source === 'barobill_api_error' ? 'bg-orange-100 text-orange-700' :
|
|
acc.source === 'local_db' ? 'bg-yellow-100 text-yellow-700' :
|
|
'bg-slate-100 text-slate-700'
|
|
}`}>
|
|
{acc.source === 'local_db_only' ? '⚠️ 로컬만' :
|
|
acc.source === 'barobill_api' ? '✓ 바로빌' :
|
|
acc.source === 'both' ? '✓ 통합' :
|
|
acc.source === 'barobill_api_error' ? '❓ 상태미확인' :
|
|
acc.source === 'local_db' ? '로컬' : '알 수 없음'}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-slate-600">{acc.bankAccountNum}</td>
|
|
<td className="px-4 py-3">{acc.accountName || '-'}</td>
|
|
<td className="px-4 py-3 text-right font-medium">{formatCurrency(acc.balance)}</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
|
acc.status == 1 ? 'bg-green-100 text-green-700' :
|
|
acc.status == 0 ? 'bg-yellow-100 text-yellow-700' :
|
|
acc.statusText === '바로빌 미등록' ? 'bg-red-100 text-red-700' :
|
|
acc.source === 'local_db_only' ? 'bg-red-100 text-red-700' :
|
|
acc.source === 'barobill_api_error' ? 'bg-orange-100 text-orange-700' :
|
|
'bg-slate-100 text-slate-700'
|
|
}`}>
|
|
{acc.statusText}
|
|
</span>
|
|
{acc.warning && !acc.api_error && (
|
|
<div className="mt-1 text-xs text-red-600">
|
|
⚠️ 바로빌 API에 등록 필요
|
|
</div>
|
|
)}
|
|
{acc.api_error && (
|
|
<div className="mt-1 text-xs text-orange-600">
|
|
⚠️ API 연동 실패
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-500">{acc.issueDate || '-'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12 text-slate-400">
|
|
<p>등록된 계좌가 없습니다.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="bg-red-50 border border-red-200 text-red-600 p-4 rounded-lg">
|
|
<p className="font-semibold">오류: {accountStatus.error}</p>
|
|
{accountStatus.error_code && (
|
|
<p className="text-sm mt-1">오류 코드: {accountStatus.error_code}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* 개발자 로그 탭 */}
|
|
{activeTab === 'debug' && (
|
|
<section className="space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<h2 className="text-xl font-bold text-slate-900">개발자 로그</h2>
|
|
<button
|
|
onClick={() => setDebugLogs([])}
|
|
className="px-4 py-2 bg-slate-600 text-white rounded-lg hover:bg-slate-700 text-sm font-medium"
|
|
>
|
|
로그 초기화
|
|
</button>
|
|
</div>
|
|
|
|
<div className="bg-slate-900 rounded-lg p-4 text-slate-100 font-mono text-xs overflow-x-auto">
|
|
{debugLogs.length === 0 ? (
|
|
<p className="text-slate-400">API 호출 로그가 없습니다. 조회를 실행하면 로그가 표시됩니다.</p>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{debugLogs.map((log, idx) => (
|
|
<div key={idx} className="border-b border-slate-700 pb-4 last:border-0">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className={`px-2 py-1 rounded text-xs font-bold ${
|
|
log.success ? 'bg-green-600' : 'bg-red-600'
|
|
}`}>
|
|
{log.success ? 'SUCCESS' : 'ERROR'}
|
|
</span>
|
|
<span className="text-slate-400">{log.method}</span>
|
|
<span className="text-slate-500">{new Date(log.timestamp).toLocaleString('ko-KR')}</span>
|
|
</div>
|
|
|
|
{log.request && (
|
|
<div className="mb-2">
|
|
<div className="text-slate-400 mb-1">Request:</div>
|
|
<pre className="bg-slate-800 p-2 rounded overflow-x-auto text-xs">
|
|
{JSON.stringify(log.request, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{log.request_xml && (
|
|
<div className="mb-2">
|
|
<div className="text-slate-400 mb-1">SOAP Request XML:</div>
|
|
<pre className="bg-slate-800 p-2 rounded overflow-x-auto text-xs whitespace-pre-wrap">
|
|
{log.request_xml}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{log.response_xml && (
|
|
<div className="mb-2">
|
|
<div className="text-slate-400 mb-1">SOAP Response XML:</div>
|
|
<pre className="bg-slate-800 p-2 rounded overflow-x-auto text-xs whitespace-pre-wrap">
|
|
{log.response_xml}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{log.error && (
|
|
<div className="text-red-400">
|
|
<div className="text-slate-400 mb-1">Error:</div>
|
|
<div>{log.error}</div>
|
|
{log.error_code && (
|
|
<div className="text-xs text-slate-500 mt-1">Error Code: {log.error_code}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</main>
|
|
|
|
<TxtExportModal
|
|
isOpen={txtExportModalOpen}
|
|
onClose={() => setTxtExportModalOpen(false)}
|
|
logs={logs}
|
|
/>
|
|
|
|
<DiagnosticModal
|
|
isOpen={diagnosticModal.open}
|
|
onClose={() => setDiagnosticModal({ open: false, content: '', title: '' })}
|
|
title={diagnosticModal.title}
|
|
content={diagnosticModal.content}
|
|
/>
|
|
|
|
<ApiInfoModal
|
|
isOpen={isApiInfoModalOpen}
|
|
onClose={() => setIsApiInfoModalOpen(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<App />);
|
|
</script>
|
|
</body>
|
|
</html>
|