🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1091 lines
62 KiB
PHP
1091 lines
62 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',
|
|
foreground: '#ffffff',
|
|
},
|
|
},
|
|
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>
|
|
|
|
<!-- Icons: Lucide -->
|
|
<script src="https://unpkg.com/lucide@latest"></script>
|
|
</head>
|
|
<body class="bg-background text-slate-800 antialiased">
|
|
<div id="root"></div>
|
|
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useRef } = React;
|
|
|
|
// --- Components ---
|
|
|
|
// 1. Header Component
|
|
const Header = ({ onOpenHelp }) => {
|
|
return (
|
|
<header className="bg-white border-b border-gray-100 sticky top-0 z-50 transition-all">
|
|
<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-blue-100 rounded-lg flex items-center justify-center text-blue-600 font-bold shadow-sm">
|
|
📋
|
|
</div>
|
|
<h1 className="text-lg font-semibold text-slate-900">전자세금계산서 솔루션</h1>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm text-slate-500 font-medium">
|
|
<a href="../eaccount/index.php" className="hover:text-blue-600 flex items-center gap-1 bg-slate-50 hover:bg-slate-100 px-3 py-1.5 rounded-lg transition-colors border border-slate-100">
|
|
<i data-lucide="wallet" className="w-4 h-4 text-blue-500"></i> 계좌조회
|
|
</a>
|
|
<a href="../ecard/index.php" className="hover:text-blue-600 flex items-center gap-1 bg-slate-50 hover:bg-slate-100 px-3 py-1.5 rounded-lg transition-colors border border-slate-100">
|
|
<i data-lucide="credit-card" className="w-4 h-4 text-purple-500"></i> 카드내역
|
|
</a>
|
|
<a href="../tenant/index.php" className="hover:text-blue-600 flex items-center gap-1 bg-slate-50 hover:bg-slate-100 px-3 py-1.5 rounded-lg transition-colors border border-slate-100">
|
|
<i data-lucide="building" className="w-4 h-4 text-blue-600"></i> 테넌트관리
|
|
</a>
|
|
<a href="../barobill_registration/index.php" className="hover:text-blue-600 flex items-center gap-1 bg-slate-50 hover:bg-slate-100 px-3 py-1.5 rounded-lg transition-colors border border-slate-100">
|
|
<i data-lucide="users" className="w-4 h-4 text-teal-500"></i> 회원관리
|
|
</a>
|
|
<a href="barobill_api_info.php" className="hover:text-blue-600 flex items-center gap-1 bg-slate-50 hover:bg-slate-100 px-3 py-1.5 rounded-lg transition-colors border border-slate-100">
|
|
<i data-lucide="book-open" className="w-4 h-4 text-orange-500"></i> API정보
|
|
</a>
|
|
|
|
<div className="h-4 w-px bg-slate-200 mx-1"></div>
|
|
|
|
<a href="../etax/index.php" className="text-blue-600 flex items-center gap-1 bg-blue-50 px-3 py-1.5 rounded-lg border border-blue-100 cursor-default font-bold">
|
|
<i data-lucide="file-text" className="w-4 h-4"></i> 세금계산서
|
|
</a>
|
|
<a href="../index.php" className="hover:text-blue-700 flex items-center gap-1">
|
|
<i data-lucide="home" className="w-4 h-4"></i> 홈
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
};
|
|
|
|
// 2. StatCard Component
|
|
const StatCard = ({ title, value, subtext, icon, trend }) => (
|
|
<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-blue-50 rounded-lg text-blue-600">
|
|
{icon}
|
|
</div>
|
|
</div>
|
|
<div className="text-2xl font-bold text-slate-900 mb-1">{value}</div>
|
|
{subtext && (
|
|
<div className={`text-xs flex items-center gap-1 ${
|
|
trend === 'up' ? 'text-green-600' : trend === 'down' ? 'text-red-600' : 'text-slate-400'
|
|
}`}>
|
|
{trend === 'up' && <i data-lucide="trending-up" className="w-3 h-3"></i>}
|
|
{trend === 'down' && <i data-lucide="trending-down" className="w-3 h-3"></i>}
|
|
{subtext}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// 3. Invoice Issue Form Component
|
|
const IssueForm = ({ onIssue }) => {
|
|
// 수취인 사업자 정보 목록 (사업자번호, 상호, 대표자명, 주소, 담당자이름, 이메일이 일치하는 실제 정보)
|
|
const RECIPIENT_COMPANIES = [
|
|
{
|
|
bizno: '311-46-00378',
|
|
name: '김인태',
|
|
ceo: '김인태',
|
|
addr: '인천광역시 부평구 안남로 272, 107동 1704호(청천동, 금호타운)',
|
|
contact: '',
|
|
email: 'n-kitf@nate.com'
|
|
},
|
|
{
|
|
bizno: '107-81-78114',
|
|
name: '(주)이상네트웍스',
|
|
ceo: '조원표',
|
|
addr: '서울특별시 마포구 월드컵북로58길 9 (상암동) ES타워',
|
|
contact: '송덕화 매니져',
|
|
email: 'n-kitf@nate.com'
|
|
},
|
|
{
|
|
bizno: '843-22-01859',
|
|
name: '조은지게차',
|
|
ceo: '유영주',
|
|
addr: '경기도 김포시 사우중로 5(사우동)',
|
|
contact: '',
|
|
email: 'kwa5201@naver.com'
|
|
},
|
|
{
|
|
bizno: '406-05-25709',
|
|
name: '스카이익스프레스',
|
|
ceo: '안옥현',
|
|
addr: '인천광역시 연수구 능허대로79번길 65 (옥련동, 현대3차아파트) 304-702',
|
|
contact: '',
|
|
email: 'kwa5201@naver.com'
|
|
}
|
|
];
|
|
|
|
// 랜덤 테스트 데이터 생성 함수
|
|
const generateRandomData = () => {
|
|
// 랜덤 사업자번호 생성 (XXX-XX-XXXXX 형식) - 공급자용
|
|
const generateBizno = () => {
|
|
const part1 = String(Math.floor(Math.random() * 900) + 100);
|
|
const part2 = String(Math.floor(Math.random() * 90) + 10);
|
|
const part3 = String(Math.floor(Math.random() * 90000) + 10000);
|
|
return `${part1}-${part2}-${part3}`;
|
|
};
|
|
|
|
// 수취인 정보는 실제 사업자 정보 목록에서 랜덤 선택
|
|
const randomRecipient = RECIPIENT_COMPANIES[Math.floor(Math.random() * RECIPIENT_COMPANIES.length)];
|
|
|
|
// 랜덤 품목명 생성
|
|
const itemNames = [
|
|
'시멘트 50kg', '철근 10mm', '타일 30x30', '도배지', '접착제',
|
|
'페인트 18L', '유리 5mm', '목재 합판', '단열재', '방수재',
|
|
'전선 2.5SQ', '스위치', '콘센트', '조명기구', '배관자재',
|
|
'수도꼭지', '싱크대', '변기', '욕조', '샤워기'
|
|
];
|
|
const randomItem = itemNames[Math.floor(Math.random() * itemNames.length)];
|
|
|
|
// 랜덤 수량 (1~100)
|
|
const randomQty = Math.floor(Math.random() * 100) + 1;
|
|
|
|
// 랜덤 단가 (1,000원 ~ 500,000원)
|
|
const randomUnitPrice = Math.floor(Math.random() * 499000) + 1000;
|
|
|
|
// 랜덤 부가세 유형
|
|
const vatTypes = ['vat'];
|
|
const randomVatType = vatTypes[Math.floor(Math.random() * vatTypes.length)];
|
|
|
|
// 랜덤 품목 개수 (1~3개)
|
|
const itemCount = Math.floor(Math.random() * 3) + 1;
|
|
const items = [];
|
|
for (let i = 0; i < itemCount; i++) {
|
|
const itemName = itemNames[Math.floor(Math.random() * itemNames.length)];
|
|
const qty = Math.floor(Math.random() * 100) + 1;
|
|
const unitPrice = Math.floor(Math.random() * 499000) + 1000;
|
|
const vatType = vatTypes[Math.floor(Math.random() * vatTypes.length)];
|
|
items.push({
|
|
name: itemName,
|
|
qty: qty,
|
|
unitPrice: unitPrice,
|
|
vatType: vatType
|
|
});
|
|
}
|
|
|
|
// 랜덤 공급일자 (오늘부터 30일 전까지)
|
|
const randomDaysAgo = Math.floor(Math.random() * 30);
|
|
const supplyDate = new Date();
|
|
supplyDate.setDate(supplyDate.getDate() - randomDaysAgo);
|
|
const formattedDate = supplyDate.toISOString().split('T')[0];
|
|
|
|
// 랜덤 비고
|
|
const memos = [
|
|
'정기 납품', '긴급 납품', '계약 납품', '추가 납품', '교체 납품',
|
|
'A/S 납품', '샘플 납품', '시공 납품', '보수 납품', ''
|
|
];
|
|
const randomMemo = memos[Math.floor(Math.random() * memos.length)];
|
|
|
|
return {
|
|
supplierBizno: '664-86-03713', // 고정값
|
|
supplierName: '(주)코드브릿지엑스', // 고정값
|
|
supplierCeo: '이의찬', // 고정값
|
|
supplierAddr: '서울 강서구 양천로 583 (염창동, 우림블루나인비즈니스센터)B동 1602호', // 고정값
|
|
supplierContact: '전진선', // 고정값
|
|
supplierEmail: 'admin@codebridge-x.com', // 고정값
|
|
recipientBizno: randomRecipient.bizno,
|
|
recipientName: randomRecipient.name,
|
|
recipientCeo: randomRecipient.ceo,
|
|
recipientAddr: randomRecipient.addr,
|
|
recipientContact: randomRecipient.contact || '홍길동', // 담당자가 없으면 '홍길동'으로 설정
|
|
recipientEmail: randomRecipient.email || '',
|
|
supplyDate: formattedDate,
|
|
items: items,
|
|
memo: randomMemo
|
|
};
|
|
};
|
|
|
|
// 공급자 고정 정보
|
|
const FIXED_SUPPLIER = {
|
|
bizno: '664-86-03713',
|
|
name: '(주)코드브릿지엑스',
|
|
ceo: '이의찬',
|
|
addr: '서울 강서구 양천로 583 (염창동, 우림블루나인비즈니스센터)B동 1602호',
|
|
contact: '전진선',
|
|
email: 'admin@codebridge-x.com'
|
|
};
|
|
|
|
const [formData, setFormData] = useState({
|
|
supplierBizno: FIXED_SUPPLIER.bizno,
|
|
supplierName: FIXED_SUPPLIER.name,
|
|
supplierCeo: FIXED_SUPPLIER.ceo,
|
|
supplierAddr: FIXED_SUPPLIER.addr,
|
|
supplierContact: FIXED_SUPPLIER.contact,
|
|
supplierEmail: FIXED_SUPPLIER.email,
|
|
recipientBizno: '',
|
|
recipientName: '',
|
|
recipientCeo: '',
|
|
recipientAddr: '',
|
|
recipientContact: '',
|
|
recipientEmail: '',
|
|
supplyDate: new Date().toISOString().split('T')[0],
|
|
items: [{ name: '', qty: 1, unitPrice: 0, vatType: 'vat' }],
|
|
memo: ''
|
|
});
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
// 컴포넌트 마운트 시 랜덤 데이터 자동 채우기
|
|
useEffect(() => {
|
|
const randomData = generateRandomData();
|
|
setFormData(randomData);
|
|
}, []);
|
|
|
|
const handleAddItem = () => {
|
|
setFormData({
|
|
...formData,
|
|
items: [...formData.items, { name: '', qty: 1, unitPrice: 0, vatType: 'vat' }]
|
|
});
|
|
};
|
|
|
|
const handleItemChange = (index, field, value) => {
|
|
const newItems = [...formData.items];
|
|
newItems[index][field] = value;
|
|
setFormData({ ...formData, items: newItems });
|
|
};
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setIsSubmitting(true);
|
|
|
|
// 수취인 담당자가 비어있으면 '홍길동'으로 설정
|
|
const finalFormData = {
|
|
...formData,
|
|
recipientContact: (formData.recipientContact || '').trim() || '홍길동'
|
|
};
|
|
|
|
// Calculate totals
|
|
const items = finalFormData.items.map(item => {
|
|
const supplyAmt = item.qty * item.unitPrice;
|
|
const vat = item.vatType === 'vat' ? Math.floor(supplyAmt * 0.1) : 0;
|
|
return {
|
|
...item,
|
|
supplyAmt,
|
|
vat,
|
|
total: supplyAmt + vat
|
|
};
|
|
});
|
|
|
|
const totalSupplyAmt = items.reduce((sum, item) => sum + item.supplyAmt, 0);
|
|
const totalVat = items.reduce((sum, item) => sum + item.vat, 0);
|
|
const total = totalSupplyAmt + totalVat;
|
|
|
|
const invoiceData = {
|
|
...finalFormData,
|
|
items,
|
|
totalSupplyAmt,
|
|
totalVat,
|
|
total
|
|
};
|
|
|
|
await onIssue(invoiceData);
|
|
setIsSubmitting(false);
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">공급자 사업자번호</label>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.supplierBizno}
|
|
onChange={(e) => setFormData({ ...formData, supplierBizno: e.target.value })}
|
|
placeholder="664-86-03713"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">공급자 상호</label>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.supplierName}
|
|
onChange={(e) => setFormData({ ...formData, supplierName: e.target.value })}
|
|
placeholder="(주)코드브릿지엑스"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">공급자 대표자명 <span className="text-red-500">*</span></label>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.supplierCeo}
|
|
onChange={(e) => setFormData({ ...formData, supplierCeo: e.target.value })}
|
|
placeholder="이의찬"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">공급자 주소 <span className="text-red-500">*</span></label>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.supplierAddr}
|
|
onChange={(e) => setFormData({ ...formData, supplierAddr: e.target.value })}
|
|
placeholder="서울 강서구 양천로 583 (염창동, 우림블루나인비즈니스센터)B동 1602호"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">공급자 담당자명 <span className="text-red-500">*</span></label>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.supplierContact}
|
|
onChange={(e) => setFormData({ ...formData, supplierContact: e.target.value })}
|
|
placeholder="전진선"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">공급자 이메일 <span className="text-red-500">*</span></label>
|
|
<input
|
|
type="email"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.supplierEmail}
|
|
onChange={(e) => setFormData({ ...formData, supplierEmail: e.target.value })}
|
|
placeholder="admin@codebridge-x.com"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">수취자 사업자번호</label>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.recipientBizno}
|
|
onChange={(e) => setFormData({ ...formData, recipientBizno: e.target.value })}
|
|
placeholder="123-45-67890"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">수취자 상호</label>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.recipientName}
|
|
onChange={(e) => setFormData({ ...formData, recipientName: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">수취자 대표자명</label>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.recipientCeo}
|
|
onChange={(e) => setFormData({ ...formData, recipientCeo: e.target.value })}
|
|
placeholder="김철수"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">수취자 주소 <span className="text-red-500">*</span></label>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.recipientAddr}
|
|
onChange={(e) => setFormData({ ...formData, recipientAddr: e.target.value })}
|
|
placeholder="인천광역시 부평구 안남로 272, 107동 1704호(청천동, 금호타운)"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">수취자 담당자명</label>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.recipientContact}
|
|
onChange={(e) => setFormData({ ...formData, recipientContact: e.target.value })}
|
|
placeholder="담당자"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">수취자 이메일 <span className="text-red-500">*</span></label>
|
|
<input
|
|
type="email"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.recipientEmail}
|
|
onChange={(e) => setFormData({ ...formData, recipientEmail: e.target.value })}
|
|
placeholder="예시) kkk@naver.com"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">공급일자</label>
|
|
<input
|
|
type="date"
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
value={formData.supplyDate}
|
|
onChange={(e) => setFormData({ ...formData, supplyDate: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<label className="block text-sm font-medium text-slate-700">품목 정보</label>
|
|
<button
|
|
type="button"
|
|
onClick={handleAddItem}
|
|
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
|
>
|
|
+ 품목 추가
|
|
</button>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{formData.items.map((item, index) => (
|
|
<div key={index} className="grid grid-cols-12 gap-2 items-end p-3 bg-slate-50 rounded-lg">
|
|
<div className="col-span-4">
|
|
<input
|
|
type="text"
|
|
className="w-full rounded-lg border-slate-200 border p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
|
placeholder="품목명"
|
|
value={item.name}
|
|
onChange={(e) => handleItemChange(index, 'name', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<input
|
|
type="number"
|
|
className="w-full rounded-lg border-slate-200 border p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
|
placeholder="수량"
|
|
value={item.qty}
|
|
onChange={(e) => handleItemChange(index, 'qty', parseFloat(e.target.value) || 0)}
|
|
min="0"
|
|
step="0.01"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-span-3">
|
|
<input
|
|
type="number"
|
|
className="w-full rounded-lg border-slate-200 border p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
|
placeholder="단가"
|
|
value={item.unitPrice}
|
|
onChange={(e) => handleItemChange(index, 'unitPrice', parseFloat(e.target.value) || 0)}
|
|
min="0"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<select
|
|
className="w-full rounded-lg border-slate-200 border p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
|
value={item.vatType}
|
|
onChange={(e) => handleItemChange(index, 'vatType', e.target.value)}
|
|
>
|
|
<option value="vat">과세</option>
|
|
<option value="zero">영세</option>
|
|
<option value="exempt">면세</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-span-1 flex items-end">
|
|
{formData.items.length > 1 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setFormData({
|
|
...formData,
|
|
items: formData.items.filter((_, i) => i !== index)
|
|
})}
|
|
className="w-full p-2.5 bg-red-50 hover:bg-red-100 text-red-600 rounded-lg border border-red-200 transition-colors flex items-center justify-center"
|
|
title="삭제"
|
|
>
|
|
<i data-lucide="trash-2" className="w-5 h-5"></i>
|
|
</button>
|
|
) : (
|
|
<div className="w-full p-2.5"></div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">비고</label>
|
|
<textarea
|
|
className="w-full rounded-lg border-slate-200 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
|
rows="3"
|
|
value={formData.memo}
|
|
onChange={(e) => setFormData({ ...formData, memo: e.target.value })}
|
|
placeholder="추가 메모사항"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
발행 중...
|
|
</>
|
|
) : (
|
|
<>
|
|
<i data-lucide="send" className="w-4 h-4"></i>
|
|
세금계산서 발행
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
// 4. Invoice List Component
|
|
const InvoiceList = ({ invoices, onViewDetail, onCheckStatus, onDelete }) => {
|
|
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
|
|
const formatDate = (date) => new Date(date).toLocaleDateString('ko-KR');
|
|
|
|
// 아이콘 업데이트
|
|
useEffect(() => {
|
|
setTimeout(() => {
|
|
lucide.createIcons();
|
|
}, 100);
|
|
}, [invoices]);
|
|
|
|
const getStatusBadge = (status) => {
|
|
const statusConfig = {
|
|
'draft': { bg: 'bg-slate-100', text: 'text-slate-800', label: '작성중' },
|
|
'issued': { bg: 'bg-blue-100', text: 'text-blue-800', label: '발행완료' },
|
|
'sent': { bg: 'bg-green-100', text: 'text-green-800', label: '국세청 전송완료' },
|
|
'cancelled': { bg: 'bg-red-100', text: 'text-red-800', label: '취소됨' }
|
|
};
|
|
const config = statusConfig[status] || statusConfig['draft'];
|
|
return (
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>
|
|
{config.label}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div 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>
|
|
<span className="text-sm text-slate-500">총 {invoices.length}건</span>
|
|
</div>
|
|
<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-6 py-4">발행번호</th>
|
|
<th className="px-6 py-4">수취자</th>
|
|
<th className="px-6 py-4">공급일자</th>
|
|
<th className="px-6 py-4">공급가액</th>
|
|
<th className="px-6 py-4">부가세</th>
|
|
<th className="px-6 py-4">합계</th>
|
|
<th className="px-6 py-4">상태</th>
|
|
<th className="px-6 py-4 text-right">작업</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{invoices.length === 0 ? (
|
|
<tr>
|
|
<td colSpan="8" className="px-6 py-8 text-center text-slate-400">
|
|
발행된 세금계산서가 없습니다.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
invoices.map((invoice) => (
|
|
<tr
|
|
key={invoice.id}
|
|
className="hover:bg-slate-50 transition-colors cursor-pointer"
|
|
onClick={() => onViewDetail(invoice)}
|
|
>
|
|
<td className="px-6 py-4 font-medium text-slate-900">{invoice.issueKey || invoice.id}</td>
|
|
<td className="px-6 py-4">{invoice.recipientName}</td>
|
|
<td className="px-6 py-4">{formatDate(invoice.supplyDate)}</td>
|
|
<td className="px-6 py-4">{formatCurrency(invoice.totalSupplyAmt)}</td>
|
|
<td className="px-6 py-4">{formatCurrency(invoice.totalVat)}</td>
|
|
<td className="px-6 py-4 font-bold text-slate-900">{formatCurrency(invoice.total)}</td>
|
|
<td className="px-6 py-4">{getStatusBadge(invoice.status)}</td>
|
|
<td className="px-6 py-4 text-right" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex items-center justify-end gap-2">
|
|
{invoice.status === 'issued' && (
|
|
<button
|
|
onClick={() => onCheckStatus(invoice.id)}
|
|
className="text-blue-600 hover:text-blue-700 text-xs font-medium px-2 py-1 rounded hover:bg-blue-50 transition-colors"
|
|
>
|
|
전송
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => onViewDetail(invoice)}
|
|
className="text-slate-400 hover:text-blue-600 p-1.5 rounded hover:bg-slate-100 transition-colors"
|
|
title="상세보기"
|
|
>
|
|
<i data-lucide="eye" className="w-4 h-4"></i>
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(invoice.id)}
|
|
className="text-red-400 hover:text-red-600 p-1.5 rounded hover:bg-red-50 transition-colors"
|
|
title="삭제"
|
|
>
|
|
<i data-lucide="trash-2" className="w-4 h-4"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 5. Invoice Detail Modal
|
|
const InvoiceDetailModal = ({ invoice, onClose }) => {
|
|
if (!invoice) return null;
|
|
|
|
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
|
|
const formatDate = (date) => new Date(date).toLocaleDateString('ko-KR');
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[90vh]" onClick={e => e.stopPropagation()}>
|
|
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 shrink-0">
|
|
<div>
|
|
<h3 className="text-xl font-bold text-slate-900">세금계산서 상세</h3>
|
|
<p className="text-sm text-slate-500">발행번호: {invoice.issueKey || invoice.id}</p>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
|
|
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-6 overflow-y-auto">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">공급자</label>
|
|
<div className="font-medium text-slate-900">{invoice.supplierName}</div>
|
|
<div className="text-sm text-slate-500">{invoice.supplierBizno}</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">수취자</label>
|
|
<div className="font-medium text-slate-900">{invoice.recipientName}</div>
|
|
<div className="text-sm text-slate-500">{invoice.recipientBizno}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">공급일자</label>
|
|
<div className="font-medium text-slate-900">{formatDate(invoice.supplyDate)}</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-2 block">품목 내역</label>
|
|
<div className="border border-slate-200 rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-50">
|
|
<tr>
|
|
<th className="px-4 py-2 text-left">품목명</th>
|
|
<th className="px-4 py-2 text-right">수량</th>
|
|
<th className="px-4 py-2 text-right">단가</th>
|
|
<th className="px-4 py-2 text-right">공급가액</th>
|
|
<th className="px-4 py-2 text-right">부가세</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{invoice.items?.map((item, index) => (
|
|
<tr key={index}>
|
|
<td className="px-4 py-2">{item.name}</td>
|
|
<td className="px-4 py-2 text-right">{item.qty}</td>
|
|
<td className="px-4 py-2 text-right">{formatCurrency(item.unitPrice)}</td>
|
|
<td className="px-4 py-2 text-right">{formatCurrency(item.supplyAmt)}</td>
|
|
<td className="px-4 py-2 text-right">{formatCurrency(item.vat)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot className="bg-slate-50 border-t-2 border-slate-200">
|
|
<tr>
|
|
<td colSpan="3" className="px-4 py-2 font-bold text-right">합계</td>
|
|
<td className="px-4 py-2 font-bold text-right">{formatCurrency(invoice.totalSupplyAmt)}</td>
|
|
<td className="px-4 py-2 font-bold text-right">{formatCurrency(invoice.totalVat)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td colSpan="4" className="px-4 py-2 font-bold text-right">총 합계</td>
|
|
<td className="px-4 py-2 font-bold text-right text-blue-600">{formatCurrency(invoice.total)}</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{invoice.ntsReceiptNo && (
|
|
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
|
<div className="text-sm font-medium text-green-800">국세청 접수번호</div>
|
|
<div className="text-lg font-bold text-green-900 mt-1">{invoice.ntsReceiptNo}</div>
|
|
</div>
|
|
)}
|
|
|
|
{invoice.memo && (
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">비고</label>
|
|
<div className="text-sm text-slate-700">{invoice.memo}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end shrink-0">
|
|
<button onClick={onClose} className="px-4 py-2 bg-white border border-slate-200 rounded-lg text-slate-700 hover:bg-slate-50 font-medium transition-colors">
|
|
닫기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 6. Main App Component
|
|
const App = () => {
|
|
const [loading, setLoading] = useState(true);
|
|
const [invoices, setInvoices] = useState([]);
|
|
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
|
const [showIssueForm, setShowIssueForm] = useState(false);
|
|
const [apiLogs, setApiLogs] = useState([]);
|
|
|
|
useEffect(() => {
|
|
loadInvoices();
|
|
}, []);
|
|
|
|
const loadInvoices = async () => {
|
|
try {
|
|
const response = await fetch('api/invoices.php');
|
|
const data = await response.json();
|
|
setInvoices(data.invoices || []);
|
|
setLoading(false);
|
|
} catch (err) {
|
|
console.error("Failed to load invoices:", err);
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const addApiLog = (type, message, data = null) => {
|
|
const log = {
|
|
id: Date.now() + Math.random(), // 고유 ID 보장
|
|
type,
|
|
message,
|
|
data: data ? (typeof data === 'string' ? data : JSON.parse(JSON.stringify(data))) : null, // 깊은 복사
|
|
timestamp: new Date().toLocaleTimeString('ko-KR')
|
|
};
|
|
setApiLogs(prev => [log, ...prev].slice(0, 20)); // Keep last 20 logs
|
|
};
|
|
|
|
const handleIssue = async (invoiceData) => {
|
|
addApiLog('request', '바로빌 API 호출: 세금계산서 발행', invoiceData);
|
|
|
|
try {
|
|
const response = await fetch('api/issue.php', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(invoiceData)
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
addApiLog('response', '바로빌 API 응답: 발행 완료', result);
|
|
setShowIssueForm(false);
|
|
await loadInvoices();
|
|
} else {
|
|
addApiLog('error', '바로빌 API 오류: ' + (result.error || '알 수 없는 오류'), result);
|
|
}
|
|
} catch (err) {
|
|
const errorDetail = {
|
|
message: err.message,
|
|
stack: err.stack,
|
|
name: err.name
|
|
};
|
|
addApiLog('error', '바로빌 API 오류: ' + err.message, errorDetail);
|
|
}
|
|
};
|
|
|
|
const handleCheckStatus = async (invoiceId) => {
|
|
addApiLog('request', '바로빌 API 호출: 국세청 전송', { invoiceId });
|
|
|
|
try {
|
|
const response = await fetch('api/status.php', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ invoiceId })
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
addApiLog('response', '바로빌 API 응답: 전송 완료', result);
|
|
await loadInvoices();
|
|
} else {
|
|
addApiLog('error', '바로빌 API 오류: ' + (result.error || '알 수 없는 오류'), result);
|
|
}
|
|
} catch (err) {
|
|
const errorDetail = {
|
|
message: err.message,
|
|
stack: err.stack,
|
|
name: err.name
|
|
};
|
|
addApiLog('error', '바로빌 API 오류: ' + err.message, errorDetail);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (invoiceId) => {
|
|
// 삭제 확인
|
|
const invoice = invoices.find(inv => inv.id === invoiceId);
|
|
|
|
// 디버깅: invoiceId와 invoice 정보 확인
|
|
console.log('Delete request - invoiceId:', invoiceId, 'Type:', typeof invoiceId);
|
|
console.log('Delete request - Found invoice:', invoice);
|
|
console.log('Delete request - All invoice IDs:', invoices.map(inv => ({ id: inv.id, type: typeof inv.id })));
|
|
|
|
const confirmMessage = invoice
|
|
? `세금계산서를 삭제하시겠습니까?\n\n발행번호: ${invoice.issueKey || invoice.id}\n수취자: ${invoice.recipientName}\n금액: ${new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(invoice.total || 0)}`
|
|
: '세금계산서를 삭제하시겠습니까?';
|
|
|
|
if (!window.confirm(confirmMessage)) {
|
|
return;
|
|
}
|
|
|
|
addApiLog('request', '세금계산서 삭제 요청', { invoiceId, invoiceIdType: typeof invoiceId, invoice });
|
|
|
|
try {
|
|
const response = await fetch('api/delete.php', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ invoiceId: String(invoiceId) }) // 문자열로 변환하여 전송
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
addApiLog('response', '세금계산서 삭제 완료', result);
|
|
await loadInvoices();
|
|
} else {
|
|
addApiLog('error', '삭제 오류: ' + (result.error || '알 수 없는 오류'), result);
|
|
const errorMsg = result.error || '알 수 없는 오류';
|
|
const debugInfo = result.debug ? `\n\n디버그 정보:\n- invoiceId: ${result.debug.invoiceId}\n- 타입: ${result.debug.invoiceIdType}\n- 총 건수: ${result.debug.totalInvoices}\n- 사용 가능한 ID: ${result.debug.availableIds?.join(', ') || '없음'}` : '';
|
|
alert('삭제에 실패했습니다: ' + errorMsg + debugInfo);
|
|
}
|
|
} catch (err) {
|
|
const errorDetail = {
|
|
message: err.message,
|
|
stack: err.stack,
|
|
name: err.name
|
|
};
|
|
addApiLog('error', '삭제 오류: ' + err.message, errorDetail);
|
|
alert('삭제 중 오류가 발생했습니다: ' + err.message);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const stats = {
|
|
total: invoices.length,
|
|
issued: invoices.filter(i => i.status === 'issued' || i.status === 'sent').length,
|
|
sent: invoices.filter(i => i.status === 'sent').length,
|
|
totalAmount: invoices.reduce((sum, i) => sum + (i.total || 0), 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">
|
|
{/* Dashboard 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={stats.total.toLocaleString()}
|
|
subtext="전체 세금계산서"
|
|
icon={<i data-lucide="file-text" className="w-5 h-5"></i>}
|
|
/>
|
|
<StatCard
|
|
title="발행 완료"
|
|
value={stats.issued.toLocaleString()}
|
|
subtext="국세청 전송 대기/완료"
|
|
icon={<i data-lucide="check-circle" className="w-5 h-5"></i>}
|
|
/>
|
|
<StatCard
|
|
title="국세청 전송 완료"
|
|
value={stats.sent.toLocaleString()}
|
|
subtext="전송 성공"
|
|
icon={<i data-lucide="send" className="w-5 h-5"></i>}
|
|
/>
|
|
<StatCard
|
|
title="총 발행 금액"
|
|
value={new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(stats.totalAmount)}
|
|
subtext="전체 합계"
|
|
icon={<i data-lucide="dollar-sign" className="w-5 h-5"></i>}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Issue Form Section */}
|
|
<section className="bg-white rounded-card shadow-sm border border-slate-100 p-6">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
|
<i data-lucide="file-plus" className="w-5 h-5 text-blue-600"></i>
|
|
전자세금계산서 발행
|
|
</h2>
|
|
{!showIssueForm && (
|
|
<button
|
|
onClick={() => setShowIssueForm(true)}
|
|
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"
|
|
>
|
|
<i data-lucide="sparkles" className="w-4 h-4"></i>
|
|
새로 발행 (랜덤 데이터)
|
|
</button>
|
|
)}
|
|
</div>
|
|
{showIssueForm && (
|
|
<div>
|
|
<IssueForm onIssue={handleIssue} key={Date.now()} />
|
|
<div className="flex justify-between items-center mt-4">
|
|
<button
|
|
onClick={() => setShowIssueForm(false)}
|
|
className="text-sm text-slate-500 hover:text-slate-900"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
// 폼을 닫았다가 다시 열어서 랜덤 데이터 재생성
|
|
setShowIssueForm(false);
|
|
setTimeout(() => setShowIssueForm(true), 100);
|
|
}}
|
|
className="text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
|
|
>
|
|
<i data-lucide="refresh-cw" className="w-4 h-4"></i>
|
|
랜덤 데이터 다시 생성
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Invoice List Section */}
|
|
<InvoiceList
|
|
invoices={invoices}
|
|
onViewDetail={setSelectedInvoice}
|
|
onCheckStatus={handleCheckStatus}
|
|
onDelete={handleDelete}
|
|
/>
|
|
|
|
{/* API Logs Section */}
|
|
{apiLogs.length > 0 && (
|
|
<section className="bg-white rounded-card shadow-sm border border-slate-100 p-6">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
|
<i data-lucide="activity" className="w-5 h-5 text-blue-600"></i>
|
|
API 통신 로그
|
|
</h2>
|
|
<button
|
|
onClick={() => setApiLogs([])}
|
|
className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1"
|
|
>
|
|
<i data-lucide="trash-2" className="w-4 h-4"></i>
|
|
로그 지우기
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
{apiLogs.map(log => (
|
|
<div key={log.id} className={`p-4 rounded-lg text-sm border ${
|
|
log.type === 'error' ? 'bg-red-50 text-red-900 border-red-200' :
|
|
log.type === 'response' ? 'bg-green-50 text-green-900 border-green-200' :
|
|
'bg-blue-50 text-blue-900 border-blue-200'
|
|
}`}>
|
|
<div className="flex justify-between items-start mb-2">
|
|
<div className="font-semibold">{log.message}</div>
|
|
<span className="text-xs opacity-75 whitespace-nowrap ml-4">{log.timestamp}</span>
|
|
</div>
|
|
{log.data && (
|
|
<details className="mt-2">
|
|
<summary className="cursor-pointer text-xs font-medium opacity-75 hover:opacity-100">
|
|
데이터 보기/숨기기
|
|
</summary>
|
|
<pre className="mt-2 p-3 bg-white/50 rounded border text-xs overflow-x-auto font-mono whitespace-pre-wrap break-words">
|
|
{typeof log.data === 'string'
|
|
? log.data
|
|
: JSON.stringify(log.data, null, 2)
|
|
}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</main>
|
|
|
|
{/* Detail Modal */}
|
|
{selectedInvoice && (
|
|
<InvoiceDetailModal
|
|
invoice={selectedInvoice}
|
|
onClose={() => setSelectedInvoice(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<App />);
|
|
|
|
// Initialize Lucide Icons after render
|
|
setTimeout(() => {
|
|
lucide.createIcons();
|
|
}, 100);
|
|
</script>
|
|
</body>
|
|
</html>
|