피플라이프 기업분석 추가
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
920
ecard/index.php
Normal file
920
ecard/index.php
Normal file
@@ -0,0 +1,920 @@
|
||||
<!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: '#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>
|
||||
),
|
||||
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 1Z"/>
|
||||
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/>
|
||||
<path d="M12 17.5v-11"/>
|
||||
</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>
|
||||
),
|
||||
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>
|
||||
),
|
||||
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>
|
||||
),
|
||||
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>
|
||||
),
|
||||
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>
|
||||
),
|
||||
bank: () => (
|
||||
<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 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>
|
||||
),
|
||||
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>
|
||||
),
|
||||
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>
|
||||
),
|
||||
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>
|
||||
),
|
||||
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>
|
||||
),
|
||||
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>
|
||||
)
|
||||
};
|
||||
|
||||
// Header Component
|
||||
const Header = () => (
|
||||
<header className="bg-white border-b border-gray-100 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center text-emerald-600 font-bold">
|
||||
💳
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold text-slate-900">법인카드 사용내역 조회</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="../eaccount/index.php" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1 bg-slate-100 hover:bg-slate-200 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<Icons.wallet />
|
||||
계좌내역 조회
|
||||
</a>
|
||||
<a href="../tenant/index.php" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1 bg-slate-100 hover:bg-slate-200 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<Icons.bank />
|
||||
바로빌 테넌트 관리
|
||||
</a>
|
||||
<a href="../etax/index.php" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1 bg-slate-100 hover:bg-slate-200 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<Icons.receipt />
|
||||
전자세금계산서
|
||||
</a>
|
||||
<a href="../index.php" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
|
||||
<Icons.home />
|
||||
홈으로
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
// 날짜 초기화
|
||||
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 />
|
||||
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user