Files
sam-sales/barobill/etax/index.php
김보곤 afee8b9dcf fix:영업관리 링크를 mng.codebridge-x.com으로 변경
- 메인 페이지 (데스크톱/모바일) 영업관리 링크 수정
- 가격정책 페이지 영업관리 링크 수정
- 바로빌 전자세금계산서 페이지 영업관리 링크 수정
- 새 탭에서 열리도록 target="_blank" 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:15:09 +09:00

1142 lines
65 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" />
<!-- Global Meta & Scripts -->
<?php include_once __DIR__ . '/../../lib/meta_common.php'; ?>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Pretendard', 'Inter', 'Noto Sans KR', 'sans-serif'],
},
colors: {
brand: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
900: '#0c4a6e',
}
},
borderRadius: {
'card': '12px',
}
}
}
}
</script>
</head>
<body class="bg-slate-50 text-slate-800 antialiased">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// --- Components ---
// 1. Header Component
const Header = ({ onOpenApiInfo }) => {
return (
<nav className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-slate-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo Section */}
<div className="flex items-center gap-3 cursor-pointer" onClick={() => window.location.href='../../index.php'}>
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white font-bold text-lg shadow-lg shadow-blue-200">
S
</div>
<span className="text-xl font-bold tracking-tight text-slate-900">CodeBridgeX <span className="text-blue-600">SAM</span></span>
<div className="h-4 w-px bg-slate-200 mx-2 hidden sm:block"></div>
<h1 className="text-sm font-bold text-slate-500 hidden sm:block">전자세금계산서</h1>
</div>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-4">
<div className="flex bg-slate-100/50 p-1 rounded-xl border border-slate-200/50 mr-4">
<a href="../eaccount/index.php" className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-600 hover:text-blue-600 hover:bg-white transition-all duration-200">
<i data-lucide="wallet" className="w-3.5 h-3.5 text-blue-500"></i> <span>계좌조회</span>
</a>
<a href="../ecard/index.php" className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-600 hover:text-blue-600 hover:bg-white transition-all duration-200">
<i data-lucide="credit-card" className="w-3.5 h-3.5 text-purple-500"></i> <span>카드내역</span>
</a>
<a href="../tenant/index.php" className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-600 hover:text-blue-600 hover:bg-white transition-all duration-200">
<i data-lucide="building" className="w-3.5 h-3.5 text-teal-500"></i> <span>테넌트</span>
</a>
<a href="../registration/index.php" className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-600 hover:text-blue-600 hover:bg-white transition-all duration-200">
<i data-lucide="users" className="w-3.5 h-3.5 text-amber-500"></i> <span>바로빌 회원관리</span>
</a>
</div>
<a href="https://mng.codebridge-x.com" target="_blank" className="text-sm font-medium text-slate-600 hover:text-blue-600 transition-colors">영업관리</a>
<a href="../../price/index.php" className="text-sm font-medium text-slate-600 hover:text-blue-600 transition-colors">가격정책</a>
<button
onClick={onOpenApiInfo}
className="px-4 py-2 bg-slate-900 text-white text-sm font-bold rounded-lg hover:bg-slate-800 transition-all shadow-lg shadow-slate-200 flex items-center gap-2"
>
<i data-lucide="code-2" className="w-4 h-4"></i>
API정보
</button>
<a href="../../index.php" className="p-2 rounded-lg text-slate-400 hover:text-blue-600 hover:bg-slate-50 transition-all">
<i data-lucide="home" className="w-5 h-5"></i>
</a>
</div>
{/* Mobile Toggle (Simplified for now) */}
<div className="md:hidden">
<button className="p-2 text-slate-600">
<i data-lucide="menu" className="w-6 h-6"></i>
</button>
</div>
</div>
</div>
</nav>
);
};
// 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([]);
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false);
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 onOpenApiInfo={() => setIsApiInfoModalOpen(true)} />
<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)}
/>
)}
<ApiInfoModal
isOpen={isApiInfoModalOpen}
onClose={() => setIsApiInfoModalOpen(false)}
/>
</div>
);
};
const ApiInfoModal = ({ isOpen, onClose }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
<div className="bg-white rounded-3xl w-full max-w-6xl overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-300 border border-slate-200 flex flex-col h-[85vh]">
<div className="p-4 border-b border-slate-100 flex justify-between items-center bg-white/50 backdrop-blur-sm z-10">
<div className="flex items-center gap-4">
<div className="p-3 bg-slate-900 rounded-xl text-white shadow-lg shadow-slate-200">
<i data-lucide="code-2" className="w-6 h-6"></i>
</div>
<div>
<h3 className="text-xl font-black text-slate-900 tracking-tight">바로빌 API 연동 가이드</h3>
<p className="text-sm text-slate-500 font-medium">SAM 플랫폼의 전자세금계산서 연동 상세 규격 및 정보</p>
</div>
</div>
<button onClick={onClose} className="btn-close-modal group">
<span>✕</span>
</button>
</div>
<div className="flex-1 overflow-auto bg-slate-50 relative">
<iframe
src="barobill_api_info.php"
className="w-full h-full border-none"
title="API Information"
/>
</div>
</div>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
// Initialize Lucide Icons after render
setTimeout(() => {
lucide.createIcons();
}, 100);
</script>
</body>
</html>