- meta_common.php에 favicon 링크 및 로고 컴포넌트 추가 - 모든 index.php 페이지에 favicon 적용 - 일부 페이지 타이틀에서 CodeBridgeExy → CodeBridgeX 수정 - 23개 파일 일괄 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
999 lines
52 KiB
PHP
999 lines
52 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>
|
|
<!-- Favicon -->
|
|
<link rel="apple-touch-icon" sizes="180x180" href="../../img/apple-touch-icon.png">
|
|
<link rel="icon" type="image/png" sizes="32x32" href="../../img/favicon-32x32.png">
|
|
<link rel="icon" type="image/png" sizes="16x16" href="../../img/favicon-16x16.png">
|
|
<link rel="shortcut icon" href="../../img/favicon.png">
|
|
|
|
<!-- 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: '#059669',
|
|
foreground: '#ffffff',
|
|
},
|
|
accent: {
|
|
DEFAULT: '#10b981',
|
|
light: '#d1fae5',
|
|
}
|
|
},
|
|
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 } = React;
|
|
|
|
// SVG Icon Components (React-safe)
|
|
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="20" height="20" 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>
|
|
),
|
|
creditCardLarge: () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="opacity-50">
|
|
<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="20" height="20" 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>
|
|
),
|
|
alertCircle: () => (
|
|
<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">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<line x1="12" x2="12" y1="8" y2="12"/>
|
|
<line x1="12" x2="12.01" y1="16" y2="16"/>
|
|
</svg>
|
|
),
|
|
xCircle: () => (
|
|
<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">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<path d="m15 9-6 6"/>
|
|
<path d="m9 9 6 6"/>
|
|
</svg>
|
|
),
|
|
filter: () => (
|
|
<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">
|
|
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
|
</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>
|
|
),
|
|
chevronsLeft: () => (
|
|
<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="m11 17-5-5 5-5"/>
|
|
<path d="m18 17-5-5 5-5"/>
|
|
</svg>
|
|
),
|
|
chevronLeft: () => (
|
|
<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 18-6-6 6-6"/>
|
|
</svg>
|
|
),
|
|
chevronRight: () => (
|
|
<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="m9 18 6-6-6-6"/>
|
|
</svg>
|
|
),
|
|
chevronsRight: () => (
|
|
<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="m6 17 5-5-5-5"/>
|
|
<path d="m13 17 5-5-5-5"/>
|
|
</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 = ({ onOpenApiInfo }) => (
|
|
<header className="bg-white/80 backdrop-blur-md border-b border-emerald-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 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-emerald-500 to-teal-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-emerald-200/50 ring-4 ring-emerald-50">
|
|
<Icons.creditCard />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-lg font-bold text-slate-900 tracking-tight leading-none">법인카드 내역</h1>
|
|
<p className="text-[10px] text-emerald-600 font-semibold mt-1 uppercase tracking-wider opacity-70">Corporate Card History</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-sm text-slate-500 font-medium">
|
|
<div className="flex bg-slate-100/50 p-1 rounded-xl border border-slate-200/50 mr-2">
|
|
<a href="../eaccount/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-emerald-600 hover:bg-white transition-all duration-200">
|
|
<Icons.wallet /> <span>계좌조회</span>
|
|
</a>
|
|
<a href="index.php" className="flex items-center gap-4 px-4 py-2 rounded-lg bg-white text-emerald-600 shadow-sm border border-emerald-100 font-bold">
|
|
<Icons.creditCard /> <span>카드내역</span>
|
|
</a>
|
|
<a href="../tenant/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-emerald-600 hover:bg-white transition-all duration-200">
|
|
<Icons.building /> <span>테넌트</span>
|
|
</a>
|
|
<a href="../registration/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-emerald-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-emerald-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-emerald-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-emerald-600 transition-colors">
|
|
<Icons.home /> <span className="hidden lg:inline text-xs">홈</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
|
|
// StatCard Component
|
|
const StatCard = ({ title, value, subtext, icon, color = 'emerald' }) => (
|
|
<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>
|
|
);
|
|
|
|
// Card Selector Component
|
|
const CardSelector = ({ cards, selectedCard, onSelect }) => (
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={() => onSelect('')}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
selectedCard === ''
|
|
? 'bg-emerald-600 text-white'
|
|
: 'bg-white border border-slate-200 text-slate-700 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
전체 카드
|
|
</button>
|
|
{cards.map(card => (
|
|
<button
|
|
key={card.cardNum}
|
|
onClick={() => onSelect(card.cardNum)}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
selectedCard === card.cardNum
|
|
? 'bg-emerald-600 text-white'
|
|
: 'bg-white border border-slate-200 text-slate-700 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
{card.cardBrand}[{card.alias}({card.cardNum.slice(-4)})]
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// TXT Export Modal Component
|
|
const TxtExportModal = ({ isOpen, onClose, logs }) => {
|
|
const [copied, setCopied] = React.useState(false);
|
|
const textareaRef = React.useRef(null);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
// TSV 형식으로 변환 (엑셀 붙여넣기 가능)
|
|
const generateTSV = () => {
|
|
const headers = ['승인일시', '카드번호', '가맹점명', '가맹점사업자번호', '금액', '부가세', '봉사료', '할부', '구분', '승인번호', '통화', '메모'];
|
|
const headerRow = headers.join('\t');
|
|
|
|
const dataRows = logs.map(log => [
|
|
log.approvalDateTime,
|
|
log.cardNumFull,
|
|
log.merchantName,
|
|
log.merchantBizNum,
|
|
log.amount,
|
|
log.vat,
|
|
log.serviceCharge,
|
|
log.installmentName,
|
|
log.approvalTypeName,
|
|
log.approvalNum,
|
|
log.currencyCode,
|
|
log.memo
|
|
].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) {
|
|
// 복사 실패 시 textarea 선택
|
|
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-emerald-600 text-white hover:bg-emerald-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-emerald-500"
|
|
style={{ resize: 'none' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* 모달 푸터 */}
|
|
<div className="p-4 border-t border-slate-200 bg-slate-50 text-sm text-slate-600">
|
|
<div className="flex items-center gap-2">
|
|
<Icons.info className="w-4 h-4" />
|
|
<span>총 {logs.length}건의 내역 | Ctrl+A로 전체 선택 후 Ctrl+C로 복사하거나 '전체 복사' 버튼을 사용하세요</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Field Export Modal Component (Input Grid)
|
|
const FieldExportModal = ({ isOpen, onClose, logs }) => {
|
|
const [copied, setCopied] = React.useState(false);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const fields = [
|
|
{ key: 'approvalDateTime', label: '승인일시' },
|
|
{ key: 'cardNumFull', label: '카드번호' },
|
|
{ key: 'merchantName', label: '가맹점명' },
|
|
{ key: 'merchantBizNum', label: '가맹점사업자번호' },
|
|
{ key: 'amount', label: '금액' },
|
|
{ key: 'vat', label: '부가세' },
|
|
{ key: 'serviceCharge', label: '봉사료' },
|
|
{ key: 'installmentName', label: '할부' },
|
|
{ key: 'approvalTypeName', label: '구분' },
|
|
{ key: 'approvalNum', label: '승인번호' },
|
|
{ key: 'currencyCode', label: '통화' },
|
|
{ key: 'memo', label: '메모' }
|
|
];
|
|
|
|
// TSV 형식으로 변환 (복사용)
|
|
const generateTSV = () => {
|
|
const headers = fields.map(f => f.label).join('\t');
|
|
const dataRows = logs.map(log =>
|
|
fields.map(f => log[f.key] ?? '').join('\t')
|
|
);
|
|
return headers + '\n' + dataRows.join('\n');
|
|
};
|
|
|
|
const handleCopyAll = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(generateTSV());
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch (err) {
|
|
alert('복사 실패: ' + err.message);
|
|
}
|
|
};
|
|
|
|
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-7xl 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={handleCopyAll}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
copied
|
|
? 'bg-green-600 text-white'
|
|
: 'bg-emerald-600 text-white hover:bg-emerald-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>
|
|
|
|
{/* 모달 본문 - Input Grid */}
|
|
<div className="flex-1 overflow-auto p-6">
|
|
<div className="border border-slate-300 rounded-lg overflow-hidden">
|
|
{/* 헤더 행 */}
|
|
<div className="grid grid-cols-12 bg-slate-100 border-b border-slate-300">
|
|
{fields.map((field, idx) => (
|
|
<div
|
|
key={field.key}
|
|
className="px-2 py-3 text-xs font-bold text-slate-700 border-r border-slate-300 last:border-r-0 text-center"
|
|
>
|
|
{field.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 데이터 행들 */}
|
|
{logs.map((log, rowIdx) => (
|
|
<div
|
|
key={rowIdx}
|
|
className="grid grid-cols-12 border-b border-slate-200 last:border-b-0 hover:bg-slate-50"
|
|
>
|
|
{fields.map((field, colIdx) => (
|
|
<div
|
|
key={field.key}
|
|
className="border-r border-slate-200 last:border-r-0"
|
|
>
|
|
<input
|
|
type="text"
|
|
value={log[field.key] ?? ''}
|
|
readOnly
|
|
className="w-full px-2 py-2 text-xs border-0 focus:outline-none focus:ring-2 focus:ring-emerald-500 bg-transparent"
|
|
onFocus={e => e.target.select()}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 모달 푸터 */}
|
|
<div className="p-4 border-t border-slate-200 bg-slate-50 text-sm text-slate-600">
|
|
<div className="flex items-center gap-2">
|
|
<Icons.info className="w-4 h-4" />
|
|
<span>총 {logs.length}건의 내역 | 각 필드를 클릭하면 자동 선택됩니다 | '전체 복사' 버튼으로 엑셀 붙여넣기 가능</span>
|
|
</div>
|
|
</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-emerald-100 flex justify-between items-center bg-emerald-50/50">
|
|
<h3 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
|
<span className="w-1.5 h-6 bg-emerald-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>
|
|
);
|
|
};
|
|
|
|
// Date Range Selector Component
|
|
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-emerald-500 focus:border-emerald-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-emerald-500 focus:border-emerald-500 outline-none"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={onSearch}
|
|
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium transition-colors flex items-center gap-2"
|
|
>
|
|
<Icons.search />
|
|
조회
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
// Quick Date Buttons Component
|
|
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>
|
|
);
|
|
};
|
|
|
|
// 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-emerald-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.creditCardLarge /></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">금액</th>
|
|
<th className="px-4 py-3 text-center">할부</th>
|
|
<th className="px-4 py-3 text-center">구분</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.approvalDate}</div>
|
|
<div className="text-xs text-slate-400">{log.approvalTime}</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="font-mono text-xs">{log.cardNum}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium text-slate-900">{log.merchantName}</div>
|
|
{log.merchantBizNum && (
|
|
<div className="text-xs text-slate-400">{log.merchantBizNum}</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<span className={`font-bold ${log.approvalType === '2' ? 'text-red-600' : 'text-slate-900'}`}>
|
|
{log.approvalType === '2' ? '-' : ''}{log.totalAmountFormatted}원
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span className="text-xs">{log.installmentName}</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
log.approvalType === '2'
|
|
? 'bg-red-100 text-red-800'
|
|
: 'bg-emerald-100 text-emerald-800'
|
|
}`}>
|
|
{log.approvalTypeName}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="font-mono text-xs text-slate-500">{log.approvalNum}</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Pagination Component
|
|
const Pagination = ({ currentPage, maxPageNum, onPageChange }) => {
|
|
if (maxPageNum <= 1) return null;
|
|
|
|
const pages = [];
|
|
const start = Math.max(1, currentPage - 2);
|
|
const end = Math.min(maxPageNum, currentPage + 2);
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
pages.push(i);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center justify-center gap-2 mt-6">
|
|
<button
|
|
onClick={() => onPageChange(1)}
|
|
disabled={currentPage === 1}
|
|
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Icons.chevronsLeft />
|
|
</button>
|
|
<button
|
|
onClick={() => onPageChange(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Icons.chevronLeft />
|
|
</button>
|
|
|
|
{pages.map(page => (
|
|
<button
|
|
key={page}
|
|
onClick={() => onPageChange(page)}
|
|
className={`w-10 h-10 rounded-lg font-medium transition-colors ${
|
|
page === currentPage
|
|
? 'bg-emerald-600 text-white'
|
|
: 'hover:bg-slate-100 text-slate-600'
|
|
}`}
|
|
>
|
|
{page}
|
|
</button>
|
|
))}
|
|
|
|
<button
|
|
onClick={() => onPageChange(currentPage + 1)}
|
|
disabled={currentPage === maxPageNum}
|
|
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Icons.chevronRight />
|
|
</button>
|
|
<button
|
|
onClick={() => onPageChange(maxPageNum)}
|
|
disabled={currentPage === maxPageNum}
|
|
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Icons.chevronsRight />
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Main App Component
|
|
const App = () => {
|
|
const [loading, setLoading] = useState(true);
|
|
const [cards, setCards] = useState([]);
|
|
const [selectedCard, setSelectedCard] = 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 [fieldExportModalOpen, setFieldExportModalOpen] = useState(false);
|
|
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(() => {
|
|
loadCards();
|
|
}, []);
|
|
|
|
// 사용내역 로드 (날짜 설정 후)
|
|
useEffect(() => {
|
|
if (startDate && endDate) {
|
|
loadUsage();
|
|
}
|
|
}, [startDate, endDate, selectedCard]);
|
|
|
|
const loadCards = async () => {
|
|
try {
|
|
const response = await fetch('api/cards.php');
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
setCards(data.cards || []);
|
|
} else {
|
|
console.error('카드 목록 조회 실패:', data.error);
|
|
}
|
|
} catch (err) {
|
|
console.error('카드 목록 로드 오류:', err);
|
|
}
|
|
};
|
|
|
|
const loadUsage = async (page = 1) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
type: 'period',
|
|
startDate: startDate.replace(/-/g, ''),
|
|
endDate: endDate.replace(/-/g, ''),
|
|
cardNum: selectedCard,
|
|
page: page,
|
|
limit: 50
|
|
});
|
|
|
|
const response = await fetch(`api/usage.php?${params}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setLogs(data.data.logs || []);
|
|
setPagination(data.data.pagination || {});
|
|
setSummary(data.data.summary || {});
|
|
} else {
|
|
setError(data.error);
|
|
setLogs([]);
|
|
}
|
|
} 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 formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
|
|
|
|
return (
|
|
<div className="min-h-screen pb-20">
|
|
<Header onOpenApiInfo={() => setIsApiInfoModalOpen(true)} />
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
|
{/* 통계 카드 */}
|
|
<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.totalAmount)}
|
|
subtext={`조회기간 합계`}
|
|
icon={<Icons.wallet />}
|
|
/>
|
|
<StatCard
|
|
title="사용건수"
|
|
value={`${(summary.count || 0).toLocaleString()}건`}
|
|
subtext={`승인 ${summary.approvalCount || 0}건`}
|
|
icon={<Icons.receipt />}
|
|
/>
|
|
<StatCard
|
|
title="등록된 카드"
|
|
value={`${cards.length}장`}
|
|
subtext="사용 가능한 카드"
|
|
icon={<Icons.creditCard />}
|
|
/>
|
|
<StatCard
|
|
title="취소건수"
|
|
value={`${(summary.cancelCount || 0).toLocaleString()}건`}
|
|
subtext="취소된 거래"
|
|
icon={<Icons.xCircle />}
|
|
color="red"
|
|
/>
|
|
</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-emerald-600"><Icons.filter /></span>
|
|
조회 조건
|
|
</h2>
|
|
<QuickDateButtons onSelect={handleQuickDateSelect} />
|
|
</div>
|
|
|
|
{cards.length > 0 && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">카드 선택</label>
|
|
<CardSelector
|
|
cards={cards}
|
|
selectedCard={selectedCard}
|
|
onSelect={setSelectedCard}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">조회 기간</label>
|
|
<DateRangeSelector
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onStartChange={setStartDate}
|
|
onEndChange={setEndDate}
|
|
onSearch={() => loadUsage(1)}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{/* 에러 표시 */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 rounded-card p-4 text-red-800">
|
|
<div className="flex items-center gap-2">
|
|
<Icons.alertCircle />
|
|
<span className="font-medium">오류 발생</span>
|
|
</div>
|
|
<p className="mt-1 text-sm">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 사용내역 테이블 */}
|
|
<section className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden">
|
|
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
|
|
<h2 className="text-lg font-bold text-slate-900">사용내역</h2>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm text-slate-500">
|
|
총 {(pagination.totalCount || 0).toLocaleString()}건
|
|
</span>
|
|
{logs.length > 0 && (
|
|
<>
|
|
<button
|
|
onClick={() => setTxtExportModalOpen(true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 transition-colors"
|
|
>
|
|
<Icons.download className="w-4 h-4" />
|
|
txt추출
|
|
</button>
|
|
<button
|
|
onClick={() => setFieldExportModalOpen(true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
|
>
|
|
<Icons.fileText />
|
|
필드 추출
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<UsageTable logs={logs} loading={loading} />
|
|
|
|
<div className="p-4 border-t border-slate-100">
|
|
<Pagination
|
|
currentPage={pagination.currentPage || 1}
|
|
maxPageNum={pagination.maxPageNum || 1}
|
|
onPageChange={loadUsage}
|
|
/>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
{/* txt추출 모달 */}
|
|
<TxtExportModal
|
|
isOpen={txtExportModalOpen}
|
|
onClose={() => setTxtExportModalOpen(false)}
|
|
logs={logs}
|
|
/>
|
|
|
|
{/* 필드 추출 모달 */}
|
|
<FieldExportModal
|
|
isOpen={fieldExportModalOpen}
|
|
onClose={() => setFieldExportModalOpen(false)}
|
|
logs={logs}
|
|
/>
|
|
|
|
<ApiInfoModal
|
|
isOpen={isApiInfoModalOpen}
|
|
onClose={() => setIsApiInfoModalOpen(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<App />);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|