Files
sam-manage/resources/views/barobill/etax/index.blade.php
2026-02-05 21:05:47 +09:00

1448 lines
95 KiB
PHP

@extends('layouts.app')
@section('title', '전자세금계산서')
@section('content')
<!-- 현재 테넌트 정보 카드 (React 외부) -->
@if($currentTenant)
<div class="rounded-xl shadow-lg p-5 mb-6" style="background: linear-gradient(to right, #6366f1, #9333ea); color: white;">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div class="flex items-center gap-4">
<div class="p-3 rounded-xl" style="background: rgba(255,255,255,0.2);">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: rgba(255,255,255,0.2);">T-ID: {{ $currentTenant->id }}</span>
@if($currentTenant->id == 1)
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: #facc15; color: #713f12;">파트너사</span>
@endif
</div>
<h2 class="text-xl font-bold">{{ $currentTenant->company_name }}</h2>
</div>
</div>
@if($barobillMember)
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 text-sm">
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
<p class="text-xs" style="color: rgba(255,255,255,0.6);">사업자번호</p>
<p class="font-medium">{{ $barobillMember->biz_no }}</p>
</div>
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
<p class="text-xs" style="color: rgba(255,255,255,0.6);">대표자</p>
<p class="font-medium">{{ $barobillMember->ceo_name ?? '-' }}</p>
</div>
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
<p class="text-xs" style="color: rgba(255,255,255,0.6);">담당자</p>
<p class="font-medium">{{ $barobillMember->manager_name ?? '-' }}</p>
</div>
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
<p class="text-xs" style="color: rgba(255,255,255,0.6);">바로빌 ID</p>
<p class="font-medium">{{ $barobillMember->barobill_id }}</p>
</div>
</div>
@else
<div class="flex items-center gap-2" style="color: #fef08a;">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span class="text-sm">바로빌 회원사 미연동</span>
</div>
@endif
</div>
</div>
@endif
<div id="etax-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// API Routes
const API = {
invoices: '{{ route("barobill.etax.invoices") }}',
issue: '{{ route("barobill.etax.issue") }}',
sendToNts: '{{ route("barobill.etax.send-to-nts") }}',
delete: '{{ route("barobill.etax.delete") }}',
supplier: '{{ route("barobill.etax.supplier") }}',
supplierUpdate: '{{ route("barobill.etax.supplier.update") }}',
searchPartners: '{{ route("barobill.tax-invoice.search-partners") }}',
};
const CSRF_TOKEN = '{{ csrf_token() }}';
// 수취인 사업자 정보 목록 (테스트 모드 전용)
const RECIPIENT_COMPANIES = [
{ bizno: '311-46-00378', name: '김인태', ceo: '김인태', addr: '인천광역시 부평구 안남로 272, 107동 1704호', bizType: '서비스업', bizClass: '운송', contact: '', contactPhone: '', email: 'test@example.com' },
{ bizno: '107-81-78114', name: '(주)이상네트웍스', ceo: '조원표', addr: '서울특별시 마포구 월드컵북로58길 9', bizType: '정보통신업', bizClass: '소프트웨어 개발', contact: '송덕화 매니져', contactPhone: '02-1234-5678', email: 'test@example.com' },
{ bizno: '843-22-01859', name: '조은지게차', ceo: '유영주', addr: '경기도 김포시 사우중로 5(사우동)', bizType: '건설업', bizClass: '중장비 임대', contact: '', contactPhone: '', email: 'test@example.com' },
{ bizno: '406-05-25709', name: '스카이익스프레스', ceo: '안옥현', addr: '인천광역시 연수구 능허대로79번길 65', bizType: '운수업', bizClass: '택배', contact: '', contactPhone: '', email: 'test@example.com' }
];
// 공급자 정보 (현재 테넌트의 바로빌 회원사 정보)
const INITIAL_SUPPLIER = {
bizno: '{{ $barobillMember?->biz_no ?? "" }}',
name: '{{ $barobillMember?->corp_name ?? $currentTenant?->company_name ?? "" }}',
ceo: '{{ $barobillMember?->ceo_name ?? $currentTenant?->ceo_name ?? "" }}',
addr: '{{ $barobillMember?->addr ?? $currentTenant?->address ?? "" }}',
bizType: '{{ $barobillMember?->biz_type ?? "" }}',
bizClass: '{{ $barobillMember?->biz_class ?? "" }}',
contact: '{{ $barobillMember?->manager_name ?? "" }}',
contactPhone: '{{ $barobillMember?->manager_hp ?? "" }}',
email: '{{ $barobillMember?->manager_email ?? $currentTenant?->email ?? "" }}'
};
// 현재 테넌트 정보
const CURRENT_TENANT = {
id: {{ $currentTenant?->id ?? 'null' }},
name: '{{ $currentTenant?->company_name ?? "" }}',
isHeadquarters: {{ ($currentTenant?->id ?? 0) == 1 ? 'true' : 'false' }}
};
// 서버 모드 정보
const IS_TEST_MODE = {{ $isTestMode ? 'true' : 'false' }};
// 로컬 타임존 기준 날짜 포맷 (YYYY-MM-DD) - 한국 시간 기준
const formatLocalDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// IssueForm Component
const IssueForm = ({ onIssue, onCancel, supplier }) => {
const generateRandomData = () => {
let items = [];
let supplyDate;
if (IS_TEST_MODE) {
// 테스트 모드: 랜덤 품목 데이터 생성
const itemNames = ['시멘트 50kg', '철근 10mm', '타일 30x30', '도배지', '접착제', '페인트 18L', '유리 5mm', '목재 합판', '단열재', '방수재'];
const itemCount = Math.floor(Math.random() * 3) + 1;
const randomDaysAgo = Math.floor(Math.random() * 30);
supplyDate = new Date();
supplyDate.setDate(supplyDate.getDate() - randomDaysAgo);
const testMonth = String(supplyDate.getMonth() + 1).padStart(2, '0');
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 randomDay = String(Math.floor(Math.random() * 28) + 1).padStart(2, '0');
items.push({ name: itemName, qty, unitPrice, vatType: 'vat', month: testMonth, day: randomDay });
}
} else {
// 운영 모드: 빈 품목 1건
supplyDate = new Date();
const defaultMonth = String(supplyDate.getMonth() + 1).padStart(2, '0');
items = [{ name: '', qty: 1, unitPrice: 0, vatType: 'vat', month: defaultMonth, day: '' }];
}
// 공급받는자: 테스트 모드에서만 샘플 데이터, 운영 모드에서는 비움
let recipientData = {
recipientBizno: '',
recipientName: '',
recipientCeo: '',
recipientAddr: '',
recipientBizType: '',
recipientBizClass: '',
recipientContact: '',
recipientContactPhone: '',
recipientEmail: '',
};
if (IS_TEST_MODE) {
const randomRecipient = RECIPIENT_COMPANIES[Math.floor(Math.random() * RECIPIENT_COMPANIES.length)];
recipientData = {
recipientBizno: randomRecipient.bizno,
recipientName: randomRecipient.name,
recipientCeo: randomRecipient.ceo,
recipientAddr: randomRecipient.addr,
recipientBizType: randomRecipient.bizType || '',
recipientBizClass: randomRecipient.bizClass || '',
recipientContact: randomRecipient.contact || '홍길동',
recipientContactPhone: randomRecipient.contactPhone || '',
recipientEmail: randomRecipient.email,
};
}
return {
supplierBizno: supplier.bizno,
supplierName: supplier.name,
supplierCeo: supplier.ceo,
supplierAddr: supplier.addr,
supplierBizType: supplier.bizType,
supplierBizClass: supplier.bizClass,
supplierContact: supplier.contact,
supplierContactPhone: supplier.contactPhone,
supplierEmail: supplier.email,
...recipientData,
supplyDate: formatLocalDate(supplyDate),
items,
memo: ''
};
};
const [formData, setFormData] = useState(generateRandomData());
const [isSubmitting, setIsSubmitting] = useState(false);
const [showPartnerModal, setShowPartnerModal] = useState(false);
const [partnerKeyword, setPartnerKeyword] = useState('');
const [partnerResults, setPartnerResults] = useState([]);
const [partnerLoading, setPartnerLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const partnerInputRef = useRef(null);
const debounceRef = useRef(null);
const listRef = useRef(null);
const openPartnerSearch = () => {
setShowPartnerModal(true);
setPartnerKeyword('');
setPartnerResults([]);
setActiveIndex(-1);
setTimeout(() => {
partnerInputRef.current?.focus();
fetchPartners('');
}, 100);
};
const fetchPartners = async (kw) => {
setPartnerLoading(true);
try {
const res = await fetch(`${API.searchPartners}?keyword=${encodeURIComponent(kw || '')}`);
const data = await res.json();
setPartnerResults(data);
setActiveIndex(-1);
} catch (e) {
console.error('거래처 검색 오류:', e);
setPartnerResults([]);
} finally {
setPartnerLoading(false);
}
};
const onPartnerKeywordChange = (val) => {
setPartnerKeyword(val);
setActiveIndex(-1);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => fetchPartners(val), 250);
};
const onPartnerKeyDown = (e) => {
const len = partnerResults.length;
if (!len) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(prev => {
const next = prev < len - 1 ? prev + 1 : 0;
scrollToItem(next);
return next;
});
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(prev => {
const next = prev > 0 ? prev - 1 : len - 1;
scrollToItem(next);
return next;
});
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < len) {
selectPartner(partnerResults[activeIndex]);
}
} else if (e.key === 'Escape') {
setShowPartnerModal(false);
}
};
const scrollToItem = (index) => {
setTimeout(() => {
const el = listRef.current?.querySelector(`[data-index="${index}"]`);
if (el) el.scrollIntoView({ block: 'nearest' });
}, 0);
};
const formatBizNoDisplay = (bizNo) => {
if (!bizNo) return '-';
const digits = bizNo.replace(/[^0-9]/g, '');
if (digits.length === 10) return digits.slice(0,3) + '-' + digits.slice(3,5) + '-' + digits.slice(5);
return bizNo;
};
const selectPartner = (partner) => {
setFormData(prev => ({
...prev,
recipientBizno: formatBizNoDisplay(partner.biz_no || ''),
recipientName: partner.name || '',
recipientContact: partner.manager || '',
recipientContactPhone: partner.manager_phone || partner.contact || '',
recipientEmail: partner.email || '',
}));
setShowPartnerModal(false);
};
const handleAddItem = () => {
const currentMonth = formData.supplyDate ? formData.supplyDate.substring(5, 7) : String(new Date().getMonth() + 1).padStart(2, '0');
setFormData({ ...formData, items: [...formData.items, { name: '', qty: 1, unitPrice: 0, vatType: 'vat', month: currentMonth, day: '' }] });
};
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 items = formData.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 invoiceData = {
...formData,
items,
totalSupplyAmt: items.reduce((sum, item) => sum + item.supplyAmt, 0),
totalVat: items.reduce((sum, item) => sum + item.vat, 0),
total: items.reduce((sum, item) => sum + item.total, 0)
};
await onIssue(invoiceData);
setIsSubmitting(false);
};
const regenerateData = () => {
setFormData(generateRandomData());
};
// 테이블 셀 스타일
const thStyleRed = "px-3 py-2.5 bg-red-50 text-red-600 font-medium text-center text-xs border border-gray-200 whitespace-nowrap";
const thStyleBlue = "px-3 py-2.5 bg-blue-50 text-blue-600 font-medium text-center text-xs border border-gray-200 whitespace-nowrap";
const tdStyle = "px-2 py-1.5 border border-gray-200";
const inputReadonly = "w-full px-2 py-1.5 text-sm bg-gray-50 border-0 outline-none text-gray-700";
const inputEditable = "w-full px-2 py-1.5 text-sm bg-white border-0 outline-none focus:ring-2 focus:ring-blue-400 rounded";
return (<>
<form onSubmit={handleSubmit} className="space-y-5">
{/* 공급자 / 공급받는자 좌우 배치 */}
<div className="border border-gray-300 rounded-lg overflow-hidden">
<div className="grid grid-cols-1 lg:grid-cols-2">
{/* === 공급자 (왼쪽 - 분홍색) === */}
<div className="border-r border-gray-300">
<table className="w-full text-sm" style=@{{tableLayout:'fixed'}}>
<colgroup>
<col style=@{{width:'5%'}} />
<col style=@{{width:'11%'}} />
<col style=@{{width:'39%'}} />
<col style=@{{width:'11%'}} />
<col style=@{{width:'34%'}} />
</colgroup>
<tbody>
{/* 등록번호 / 종사업장 */}
<tr>
<td className="px-1 py-2.5 bg-red-100 text-red-700 font-bold text-center border border-gray-200 align-middle" rowSpan="6" style=@{{writingMode:'vertical-rl', textOrientation:'upright', letterSpacing:'0.15em', fontSize:'13px'}}>
공급자
</td>
<td className={thStyleRed}>등록번호</td>
<td className={tdStyle}>
<input type="text" className={inputReadonly} value={formData.supplierBizno} readOnly />
</td>
<td className={thStyleRed}>종사업장</td>
<td className={tdStyle}>
<input type="text" className={inputReadonly} readOnly />
</td>
</tr>
{/* 상호 / 성명 */}
<tr>
<td className={thStyleRed}>상호</td>
<td className={tdStyle}>
<input type="text" className={inputReadonly} value={formData.supplierName} readOnly />
</td>
<td className={thStyleRed}>성명</td>
<td className={tdStyle}>
<input type="text" className={inputReadonly} value={formData.supplierCeo} readOnly />
</td>
</tr>
{/* 사업장주소 */}
<tr>
<td className={thStyleRed}>사업장<br/>주소</td>
<td colSpan="3" className={tdStyle}>
<input type="text" className={inputReadonly} value={formData.supplierAddr} readOnly />
</td>
</tr>
{/* 업태 / 종목 */}
<tr>
<td className={thStyleRed}>업태</td>
<td className={tdStyle}>
<input type="text" className={inputReadonly} value={formData.supplierBizType} readOnly />
</td>
<td className={thStyleRed}>종목</td>
<td className={tdStyle}>
<input type="text" className={inputReadonly} value={formData.supplierBizClass} readOnly />
</td>
</tr>
{/* 담당자 / 연락처 */}
<tr>
<td className={thStyleRed}>담당자</td>
<td className={tdStyle}>
<input type="text" className={inputReadonly} value={formData.supplierContact} readOnly />
</td>
<td className={thStyleRed}>연락처</td>
<td className={tdStyle}>
<input type="text" className={inputReadonly} value={formData.supplierContactPhone} readOnly />
</td>
</tr>
{/* 이메일 */}
<tr>
<td className={thStyleRed}>이메일</td>
<td colSpan="3" className={tdStyle}>
<input type="email" className={inputReadonly} value={formData.supplierEmail} readOnly />
</td>
</tr>
</tbody>
</table>
</div>
{/* === 공급받는자 (오른쪽 - 파란색) === */}
<div>
<table className="w-full text-sm" style=@{{tableLayout:'fixed'}}>
<colgroup>
<col style=@{{width:'5%'}} />
<col style=@{{width:'11%'}} />
<col style=@{{width:'39%'}} />
<col style=@{{width:'11%'}} />
<col style=@{{width:'34%'}} />
</colgroup>
<tbody>
{/* 등록번호 / 종사업장 */}
<tr>
<td className="px-1 py-2.5 bg-blue-100 text-blue-700 font-bold text-center border border-gray-200 align-middle" rowSpan="6" style=@{{writingMode:'vertical-rl', textOrientation:'upright', letterSpacing:'0.1em', fontSize:'12px'}}>
공급받는자
</td>
<td className={thStyleBlue}>등록번호</td>
<td className={tdStyle}>
<div className="flex gap-1">
<input type="text" className={inputEditable + " flex-1"} placeholder="000-00-00000" value={formData.recipientBizno} onChange={(e) => setFormData({ ...formData, recipientBizno: e.target.value })} required />
<button type="button" onClick={openPartnerSearch} className="px-2 py-1 bg-blue-500 text-white text-xs font-medium rounded hover:bg-blue-600 transition-colors whitespace-nowrap flex-shrink-0">검색</button>
</div>
</td>
<td className={thStyleBlue}>종사업장</td>
<td className={tdStyle}>
<input type="text" className={inputEditable} />
</td>
</tr>
{/* 상호 / 성명 */}
<tr>
<td className={thStyleBlue}>상호</td>
<td className={tdStyle}>
<input type="text" className={inputEditable} value={formData.recipientName} onChange={(e) => setFormData({ ...formData, recipientName: e.target.value })} required />
</td>
<td className={thStyleBlue}>성명</td>
<td className={tdStyle}>
<input type="text" className={inputEditable} value={formData.recipientCeo} onChange={(e) => setFormData({ ...formData, recipientCeo: e.target.value })} />
</td>
</tr>
{/* 사업장주소 */}
<tr>
<td className={thStyleBlue}>사업장<br/>주소</td>
<td colSpan="3" className={tdStyle}>
<input type="text" className={inputEditable} value={formData.recipientAddr} onChange={(e) => setFormData({ ...formData, recipientAddr: e.target.value })} required />
</td>
</tr>
{/* 업태 / 종목 */}
<tr>
<td className={thStyleBlue}>업태</td>
<td className={tdStyle}>
<input type="text" className={inputEditable} value={formData.recipientBizType} onChange={(e) => setFormData({ ...formData, recipientBizType: e.target.value })} />
</td>
<td className={thStyleBlue}>종목</td>
<td className={tdStyle}>
<input type="text" className={inputEditable} value={formData.recipientBizClass} onChange={(e) => setFormData({ ...formData, recipientBizClass: e.target.value })} />
</td>
</tr>
{/* 담당자 / 연락처 */}
<tr>
<td className={thStyleBlue}>담당자</td>
<td className={tdStyle}>
<input type="text" className={inputEditable} value={formData.recipientContact} onChange={(e) => setFormData({ ...formData, recipientContact: e.target.value })} />
</td>
<td className={thStyleBlue}>연락처</td>
<td className={tdStyle}>
<input type="text" className={inputEditable} value={formData.recipientContactPhone || ''} onChange={(e) => setFormData({ ...formData, recipientContactPhone: e.target.value })} />
</td>
</tr>
{/* 이메일 */}
<tr>
<td className={thStyleBlue}>
<div className="flex items-center justify-center gap-0.5">
<svg className="w-3.5 h-3.5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
이메일
</div>
</td>
<td colSpan="3" className={tdStyle}>
<input type="email" className={inputEditable} placeholder="세금계산서 수신 이메일" value={formData.recipientEmail} onChange={(e) => setFormData({ ...formData, recipientEmail: e.target.value })} required />
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{/* 작성일자 */}
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-stone-600">작성일자</label>
<input type="date" className="rounded-lg border-stone-200 border px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={formData.supplyDate} onChange={(e) => setFormData({ ...formData, supplyDate: e.target.value })} required />
</div>
<div>
<div className="flex justify-between items-center mb-3">
<label className="block text-sm font-medium text-stone-700">품목 정보</label>
<button type="button" onClick={handleAddItem} className="px-3 py-1.5 text-sm text-blue-600 hover:text-blue-700 font-medium bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
품목 추가
</button>
</div>
<div className="border border-stone-200 rounded-lg overflow-hidden">
<table className="w-full text-sm" style=@{{tableLayout: 'fixed'}}>
<colgroup>
<col style=@{{width: '45px'}} />
<col style=@{{width: '45px'}} />
<col style=@{{width: '28%'}} />
<col style=@{{width: '60px'}} />
<col style=@{{width: '100px'}} />
<col style=@{{width: '12%'}} />
<col style=@{{width: '10%'}} />
<col style=@{{width: '12%'}} />
<col style=@{{width: '70px'}} />
<col style=@{{width: '40px'}} />
</colgroup>
<thead className="bg-stone-100 border-b border-stone-200">
<tr>
<th className="px-1 py-2.5 text-center font-medium text-stone-700"></th>
<th className="px-1 py-2.5 text-center font-medium text-stone-700"></th>
<th className="px-3 py-2.5 text-left font-medium text-stone-700">품목명</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">수량</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">단가</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">공급가액</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">세액</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">금액</th>
<th className="px-1 py-2.5 text-center font-medium text-stone-700">과세</th>
<th className="px-1 py-2.5 text-center font-medium text-stone-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{formData.items.map((item, index) => {
const supplyAmt = item.qty * item.unitPrice;
const vat = item.vatType === 'vat' ? Math.floor(supplyAmt * 0.1) : 0;
const total = supplyAmt + vat;
// 콤마 형식으로 숫자 표시
const formatWithComma = (num) => num ? num.toLocaleString() : '';
// 콤마 제거하고 숫자로 변환
const parseNumber = (str) => parseFloat(str.replace(/,/g, '')) || 0;
return (
<tr key={index} className="hover:bg-stone-50">
<td className="px-1 py-2">
<input type="text" className="w-full rounded border-stone-200 border px-1 py-1.5 text-sm text-center focus:ring-2 focus:ring-blue-500 outline-none" maxLength={2} placeholder="" value={item.month || ''} onChange={(e) => { const v = e.target.value.replace(/[^0-9]/g, ''); handleItemChange(index, 'month', v); }} />
</td>
<td className="px-1 py-2">
<input type="text" className="w-full rounded border-stone-200 border px-1 py-1.5 text-sm text-center focus:ring-2 focus:ring-blue-500 outline-none" maxLength={2} placeholder="" value={item.day || ''} onChange={(e) => { const v = e.target.value.replace(/[^0-9]/g, ''); handleItemChange(index, 'day', v); }} />
</td>
<td className="px-2 py-2">
<input type="text" className="w-full rounded border-stone-200 border px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none" placeholder="품목명 입력" value={item.name} onChange={(e) => handleItemChange(index, 'name', e.target.value)} required />
</td>
<td className="px-2 py-2">
<input type="text" className="w-full rounded border-stone-200 border px-2 py-1.5 text-sm text-right focus:ring-2 focus:ring-blue-500 outline-none" value={formatWithComma(item.qty)} onChange={(e) => handleItemChange(index, 'qty', parseNumber(e.target.value))} required />
</td>
<td className="px-2 py-2">
<input type="text" className="w-full rounded border-stone-200 border px-2 py-1.5 text-sm text-right focus:ring-2 focus:ring-blue-500 outline-none" value={formatWithComma(item.unitPrice)} onChange={(e) => handleItemChange(index, 'unitPrice', parseNumber(e.target.value))} required />
</td>
<td className="px-3 py-2 text-right font-medium text-stone-800 bg-stone-50">
{supplyAmt.toLocaleString()}
</td>
<td className="px-3 py-2 text-right font-medium text-blue-600 bg-stone-50">
{vat.toLocaleString()}
</td>
<td className="px-3 py-2 text-right font-bold text-stone-900 bg-blue-50">
{total.toLocaleString()}
</td>
<td className="px-2 py-2">
<select className="w-full rounded border-stone-200 border px-1 py-1.5 text-sm text-center 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>
</td>
<td className="px-2 py-2 text-center">
{formData.items.length > 1 && (
<button type="button" onClick={() => setFormData({ ...formData, items: formData.items.filter((_, i) => i !== index) })} className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 rounded transition-colors" title="삭제">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
)}
</td>
</tr>
);
})}
</tbody>
<tfoot className="bg-stone-100 border-t-2 border-stone-300">
<tr>
<td colSpan="5" className="px-3 py-3 text-right font-bold text-stone-700">합계</td>
<td className="px-3 py-3 text-right font-bold text-stone-800">
{formData.items.reduce((sum, item) => sum + (item.qty * item.unitPrice), 0).toLocaleString()}
</td>
<td className="px-3 py-3 text-right font-bold text-blue-600">
{formData.items.reduce((sum, item) => {
const supplyAmt = item.qty * item.unitPrice;
return sum + (item.vatType === 'vat' ? Math.floor(supplyAmt * 0.1) : 0);
}, 0).toLocaleString()}
</td>
<td className="px-3 py-3 text-right font-bold text-blue-700 bg-blue-100">
{formData.items.reduce((sum, item) => {
const supplyAmt = item.qty * item.unitPrice;
const vat = item.vatType === 'vat' ? Math.floor(supplyAmt * 0.1) : 0;
return sum + supplyAmt + vat;
}, 0).toLocaleString()}
</td>
<td colSpan="2"></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-2">비고</label>
<textarea className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" rows="2" value={formData.memo} onChange={(e) => setFormData({ ...formData, memo: e.target.value })} placeholder="추가 메모사항" />
</div>
{/* 운영 모드 경고 */}
{!IS_TEST_MODE && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg mb-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p className="text-sm font-semibold text-red-800">운영 서버 - 실제 국세청 전송</p>
<p className="text-xs text-red-600 mt-1">발행된 세금계산서는 실제 국세청으로 전송됩니다. 입력 정보를 신중히 확인하시기 바랍니다.</p>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between pt-4 border-t">
<div className="flex gap-2">
<button type="button" onClick={onCancel} className="px-4 py-2 text-stone-600 hover:text-stone-800">취소</button>
{/* 랜덤 데이터 재생성 버튼은 테스트 모드에서만 표시 */}
{IS_TEST_MODE && (
<button type="button" onClick={regenerateData} className="px-4 py-2 text-blue-600 hover:text-blue-700 flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
랜덤 데이터 재생성
</button>
)}
</div>
<button type="submit" disabled={isSubmitting} className={`px-6 py-3 text-white rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center gap-2 ${IS_TEST_MODE ? 'bg-blue-600 hover:bg-blue-700' : 'bg-red-600 hover:bg-red-700'}`}>
{isSubmitting ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> 발행 ...</>
) : (
<><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg> 세금계산서 발행</>
)}
</button>
</div>
</form>
{/* 거래처 검색 모달 */}
{showPartnerModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center" style=@{{backgroundColor:'rgba(0,0,0,0.4)'}} onClick={() => setShowPartnerModal(false)}>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg flex flex-col mx-4 overflow-hidden" style=@{{maxHeight:'520px'}} onClick={(e) => e.stopPropagation()}>
{/* 헤더 + 검색 */}
<div className="px-5 pt-5 pb-3">
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-bold text-gray-800">거래처 검색</h3>
<button type="button" onClick={() => setShowPartnerModal(false)} className="text-gray-400 hover:text-gray-600 p-1 rounded-lg hover:bg-gray-100 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input type="text" ref={partnerInputRef} value={partnerKeyword}
onChange={(e) => onPartnerKeywordChange(e.target.value)}
onKeyDown={onPartnerKeyDown}
placeholder="거래처명, 사업자번호, 담당자명 입력..."
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-shadow" />
{partnerLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
</div>
)}
</div>
<p className="text-xs text-gray-400 mt-1.5 ml-1">
{partnerResults.length > 0
? <><span className="text-blue-500 font-medium">{partnerResults.length}</span> · <kbd className="px-1 py-0.5 bg-gray-100 rounded text-[10px] font-mono">↑↓</kbd> 이동 <kbd className="px-1 py-0.5 bg-gray-100 rounded text-[10px] font-mono">Enter</kbd> 선택 <kbd className="px-1 py-0.5 bg-gray-100 rounded text-[10px] font-mono">ESC</kbd> 닫기</>
: '입력하면 자동으로 검색됩니다'
}
</p>
</div>
{/* 결과 목록 */}
<div ref={listRef} className="flex-1 overflow-y-auto border-t" style=@{{maxHeight:'360px'}}>
{!partnerLoading && partnerResults.length === 0 && (
<div className="text-center py-10">
<svg className="w-10 h-10 text-gray-200 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
<p className="text-sm text-gray-400">{partnerKeyword ? '검색 결과가 없습니다' : '등록된 거래처가 표시됩니다'}</p>
</div>
)}
{partnerResults.map((p, i) => (
<div key={p.id} data-index={i}
className={`flex items-center gap-3 px-5 py-3 cursor-pointer border-b border-gray-50 transition-colors ${i === activeIndex ? 'bg-blue-50 border-l-2 border-l-blue-500' : 'hover:bg-gray-50 border-l-2 border-l-transparent'}`}
onClick={() => selectPartner(p)}
onMouseEnter={() => setActiveIndex(i)}>
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-bold">
{(p.name || '?').charAt(0)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800 truncate">{p.name}</span>
<span className="text-xs text-gray-400 flex-shrink-0">{formatBizNoDisplay(p.biz_no)}</span>
</div>
<div className="flex items-center gap-3 mt-0.5">
{p.manager && <span className="text-xs text-gray-500">{p.manager}</span>}
{p.email && <span className="text-xs text-gray-400">{p.email}</span>}
</div>
</div>
{i === activeIndex && (
<kbd className="flex-shrink-0 text-[10px] text-blue-500 bg-blue-50 border border-blue-200 rounded px-1.5 py-0.5 font-mono">Enter </kbd>
)}
</div>
))}
</div>
</div>
</div>
)}
</>);
};
// InvoiceList Component
const InvoiceList = ({ invoices, onViewDetail, onCheckStatus, onDelete, filters, updateFilter, onSearch, totalCount }) => {
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val);
const formatDate = (date) => new Date(date).toLocaleDateString('ko-KR');
const getStatusBadge = (status) => {
const statusConfig = {
'draft': { bg: 'bg-stone-100', text: 'text-stone-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>;
};
// 기간 빠른 설정
const setQuickDate = (offset) => {
const now = new Date();
let from, to;
if (offset === '1w') {
from = new Date(now); from.setDate(from.getDate() - 7);
to = now;
} else if (offset === '1m') {
from = new Date(now.getFullYear(), now.getMonth(), 1);
to = new Date(now.getFullYear(), now.getMonth() + 1, 0);
} else if (offset === '3m') {
from = new Date(now.getFullYear(), now.getMonth() - 2, 1);
to = new Date(now.getFullYear(), now.getMonth() + 1, 0);
}
updateFilter('dateFrom', formatLocalDate(from));
updateFilter('dateTo', formatLocalDate(to));
};
// 스타일
const labelCell = "px-3 py-2.5 bg-stone-50 text-stone-600 text-xs font-semibold whitespace-nowrap border border-stone-200 text-right";
const valueCell = "px-2 py-1.5 border border-stone-200";
const inputSm = "w-full px-2 py-1.5 text-sm border-0 outline-none bg-transparent focus:ring-0";
const selectSm = "px-3 pr-8 py-1.5 text-sm border border-stone-200 rounded outline-none bg-white focus:ring-2 focus:ring-blue-400 appearance-none bg-[url('data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2212%22%20height%3D%2212%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23666%22%20stroke-width%3D%222%22%3E%3Cpath%20d%3D%22M6%209l6%206%206-6%22%2F%3E%3C%2Fsvg%3E')] bg-no-repeat bg-[right_0.5rem_center]";
const quickBtn = "px-2.5 py-1 text-xs font-medium border border-stone-300 rounded hover:bg-stone-100 transition-colors";
return (
<div className="space-y-4">
{/* 검색 조건 패널 */}
<div className="bg-white rounded-lg border border-stone-300 overflow-hidden">
<table className="w-full text-sm">
<tbody>
{/* Row 1: 조회기간 */}
<tr>
<td className={labelCell} style=@{{width:'90px'}}>
<span className="text-red-500 mr-0.5">*</span>조회기간
</td>
<td className={valueCell}>
<div className="flex items-center gap-2 flex-wrap">
<select value={filters.dateType} onChange={e => updateFilter('dateType', e.target.value)} className={selectSm}>
<option value="supplyDate">작성일자</option>
<option value="sentAt">전송일자</option>
</select>
<input type="date" value={filters.dateFrom} onChange={e => updateFilter('dateFrom', e.target.value)} className={selectSm} />
<span className="text-stone-400 text-xs">~</span>
<input type="date" value={filters.dateTo} onChange={e => updateFilter('dateTo', e.target.value)} className={selectSm} />
<button type="button" onClick={() => setQuickDate('1w')} className={quickBtn}>1주일</button>
<button type="button" onClick={() => setQuickDate('1m')} className={quickBtn}>1개월</button>
<button type="button" onClick={() => setQuickDate('3m')} className={quickBtn}>3개월</button>
</div>
</td>
<td rowSpan="3" className="border border-stone-200 px-4 align-middle text-center" style=@{{width:'100px'}}>
<button type="button" onClick={onSearch} className="w-full px-5 py-6 bg-blue-600 text-white text-sm font-bold rounded hover:bg-blue-700 transition-colors whitespace-nowrap">
조회
</button>
</td>
</tr>
{/* Row 2: 사업자등록번호 / 상호 */}
<tr>
<td className={labelCell}>사업자번호</td>
<td className={valueCell}>
<div className="flex items-center gap-3 flex-wrap">
<input type="text" placeholder="사업자등록번호" value={filters.bizNo} onChange={e => updateFilter('bizNo', e.target.value)} className={`${inputSm} max-w-[180px] border border-stone-200 rounded`} />
<span className="text-stone-300">|</span>
<span className="text-xs font-medium text-stone-500">상호</span>
<input type="text" placeholder="상호명 검색" value={filters.companyName} onChange={e => updateFilter('companyName', e.target.value)} className={`${inputSm} max-w-[180px] border border-stone-200 rounded`} />
</div>
</td>
</tr>
{/* Row 3: 상태 / 정렬 / 조회건수 */}
<tr>
<td className={labelCell}>조건</td>
<td className={valueCell}>
<div className="flex items-center gap-3 flex-wrap">
<span className="text-xs font-medium text-stone-500">상태</span>
<select value={filters.status} onChange={e => updateFilter('status', e.target.value)} className={selectSm}>
<option value="">-전체-</option>
<option value="draft">작성중</option>
<option value="issued">발행완료</option>
<option value="sent">국세청 전송완료</option>
<option value="cancelled">취소됨</option>
</select>
<span className="text-stone-300">|</span>
<span className="text-xs font-medium text-stone-500">정렬</span>
<select value={filters.sortColumn} onChange={e => updateFilter('sortColumn', e.target.value)} className={selectSm}>
<option value="supplyDate">작성일자</option>
<option value="sentAt">전송일자</option>
<option value="recipientName">공급받는자</option>
<option value="total">합계금액</option>
</select>
<select value={filters.sortDirection} onChange={e => updateFilter('sortDirection', e.target.value)} className={selectSm}>
<option value="desc">내림차순</option>
<option value="asc">오름차순</option>
</select>
<span className="text-stone-300 ml-auto">|</span>
<span className="text-sm text-stone-500">
조회 <span className="font-bold text-stone-800">{invoices.length}</span>
{totalCount !== invoices.length && <span className="text-stone-400"> / 전체 {totalCount}</span>}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
{/* 테이블 */}
<div className="bg-white rounded-lg border border-stone-200 overflow-hidden">
<div className="overflow-x-auto" style=@{{maxHeight: '500px', overflowY: 'auto'}}>
<table className="w-full text-left text-sm text-stone-600">
<thead className="bg-stone-50 text-xs font-medium text-stone-500 sticky top-0 border-b border-stone-200">
<tr>
<th className="px-4 py-3 bg-stone-50">발행번호</th>
<th className="px-4 py-3 bg-stone-50">공급받는자</th>
<th className="px-4 py-3 bg-stone-50">작성일자</th>
<th className="px-4 py-3 bg-stone-50">전송일자</th>
<th className="px-4 py-3 bg-stone-50 text-right">공급가액</th>
<th className="px-4 py-3 bg-stone-50 text-right">세액</th>
<th className="px-4 py-3 bg-stone-50 text-right">합계금액</th>
<th className="px-4 py-3 bg-stone-50 text-center">상태</th>
<th className="px-4 py-3 bg-stone-50 text-center">작업</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{invoices.length === 0 ? (
<tr><td colSpan="9" className="px-6 py-8 text-center text-stone-400">해당 조건에 맞는 세금계산서가 없습니다.</td></tr>
) : (
invoices.map((invoice) => (
<tr key={invoice.id} className="hover:bg-blue-50/50 transition-colors cursor-pointer" onClick={() => onViewDetail(invoice)}>
<td className="px-4 py-3 font-medium text-stone-900 text-xs">{invoice.issueKey || invoice.id}</td>
<td className="px-4 py-3">{invoice.recipientName}</td>
<td className="px-4 py-3 text-stone-500">{formatDate(invoice.supplyDate)}</td>
<td className="px-4 py-3 text-stone-500">{invoice.sentAt ? formatDate(invoice.sentAt) : <span className="text-stone-300">-</span>}</td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(invoice.totalSupplyAmt)}</td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(invoice.totalVat)}</td>
<td className="px-4 py-3 text-right font-bold text-stone-900 tabular-nums">{formatCurrency(invoice.total)}</td>
<td className="px-4 py-3 text-center">{getStatusBadge(invoice.status)}</td>
<td className="px-4 py-3 text-center" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
{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={() => onDelete(invoice.id)} className="text-red-400 hover:text-red-600 p-1 rounded hover:bg-red-50 transition-colors" title="삭제">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
// InvoiceDetailModal Component
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-[80vh]" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-stone-100 flex justify-between items-center bg-stone-50 shrink-0">
<div>
<h3 className="text-xl font-bold text-stone-900">세금계산서 상세</h3>
<p className="text-sm text-stone-500">발행번호: {invoice.issueKey || invoice.id}</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-stone-200 rounded-full transition-colors">
<svg className="w-5 h-5 text-stone-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"/></svg>
</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-stone-500 mb-1 block">공급자</label>
<div className="font-medium text-stone-900">{invoice.supplierName}</div>
<div className="text-sm text-stone-500">{invoice.supplierBizno}</div>
</div>
<div>
<label className="text-xs text-stone-500 mb-1 block">공급받는자</label>
<div className="font-medium text-stone-900">{invoice.recipientName}</div>
<div className="text-sm text-stone-500">{invoice.recipientBizno}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-stone-500 mb-1 block">작성일자</label>
<div className="font-medium text-stone-900">{formatDate(invoice.supplyDate)}</div>
</div>
<div>
<label className="text-xs text-stone-500 mb-1 block">전송일자</label>
<div className="font-medium text-stone-900">{invoice.sentAt ? formatDate(invoice.sentAt) : <span className="text-stone-400">미전송</span>}</div>
</div>
</div>
<div>
<label className="text-xs text-stone-500 mb-2 block">품목 내역</label>
<div className="border border-stone-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-stone-50">
<tr>
<th className="px-2 py-2 text-center">/</th>
<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-stone-100">
{invoice.items?.map((item, index) => (
<tr key={index}>
<td className="px-2 py-2 text-center text-stone-500">{item.month && item.day ? `${item.month}/${item.day}` : '-'}</td>
<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-stone-50 border-t-2 border-stone-200">
<tr>
<td colSpan="4" 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="5" 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>
)}
</div>
<div className="p-6 border-t border-stone-100 bg-stone-50 flex justify-end shrink-0">
<button onClick={onClose} className="px-4 py-2 bg-white border border-stone-200 rounded-lg text-stone-700 hover:bg-stone-50 font-medium transition-colors">닫기</button>
</div>
</div>
</div>
);
};
// SupplierSettingsModal Component
const SupplierSettingsModal = ({ supplier, onClose, onSaved }) => {
const [form, setForm] = useState({
corp_name: supplier.name || '',
ceo_name: supplier.ceo || '',
addr: supplier.addr || '',
biz_type: supplier.bizType || '',
biz_class: supplier.bizClass || '',
manager_name: supplier.contact || '',
manager_hp: supplier.contactPhone || '',
manager_email: supplier.email || '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const handleChange = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
setError('');
try {
const response = await fetch(API.supplierUpdate, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
body: JSON.stringify(form),
});
const result = await response.json();
if (result.success) {
onSaved(result.supplier);
onClose();
} else {
setError(result.error || '저장에 실패했습니다.');
}
} catch (err) {
setError('저장 중 오류가 발생했습니다: ' + err.message);
} finally {
setSaving(false);
}
};
const labelClass = "block text-sm font-medium text-stone-700 mb-1";
const inputClass = "w-full rounded-lg border border-stone-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none";
const readonlyClass = "w-full rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-500";
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-lg overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="p-5 border-b border-stone-100 flex justify-between items-center bg-stone-50">
<div>
<h3 className="text-lg font-bold text-stone-900">공급자 기초정보 설정</h3>
<p className="text-xs text-stone-500 mt-0.5">세금계산서 발행 사용되는 공급자 정보입니다</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-stone-200 rounded-full transition-colors">
<svg className="w-5 h-5 text-stone-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
<div>
<label className={labelClass}>사업자번호</label>
<input type="text" className={readonlyClass} value={supplier.bizno} readOnly />
<p className="text-xs text-stone-400 mt-1">사업자번호는 변경할 없습니다</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>상호명 <span className="text-red-500">*</span></label>
<input type="text" className={inputClass} value={form.corp_name} onChange={e => handleChange('corp_name', e.target.value)} required />
</div>
<div>
<label className={labelClass}>대표자명 <span className="text-red-500">*</span></label>
<input type="text" className={inputClass} value={form.ceo_name} onChange={e => handleChange('ceo_name', e.target.value)} required />
</div>
</div>
<div>
<label className={labelClass}>주소</label>
<input type="text" className={inputClass} value={form.addr} onChange={e => handleChange('addr', e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>업태</label>
<input type="text" className={inputClass} value={form.biz_type} onChange={e => handleChange('biz_type', e.target.value)} placeholder="예: 정보통신업" />
</div>
<div>
<label className={labelClass}>종목</label>
<input type="text" className={inputClass} value={form.biz_class} onChange={e => handleChange('biz_class', e.target.value)} placeholder="예: 소프트웨어 개발" />
</div>
</div>
<hr className="border-stone-200" />
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>담당자명</label>
<input type="text" className={inputClass} value={form.manager_name} onChange={e => handleChange('manager_name', e.target.value)} />
</div>
<div>
<label className={labelClass}>연락처</label>
<input type="text" className={inputClass} value={form.manager_hp} onChange={e => handleChange('manager_hp', e.target.value)} placeholder="예: 02-1234-5678" />
</div>
</div>
<div>
<label className={labelClass}>이메일</label>
<input type="email" className={inputClass} value={form.manager_email} onChange={e => handleChange('manager_email', e.target.value)} placeholder="예: tax@company.com" />
</div>
<div className="pt-4 border-t border-stone-200 flex justify-end gap-3">
<button type="button" onClick={onClose} className="px-4 py-2 text-stone-600 hover:text-stone-800 font-medium transition-colors">취소</button>
<button type="submit" disabled={saving} className="px-5 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2">
{saving ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> 저장 ...</>
) : '저장'}
</button>
</div>
</form>
</div>
</div>
);
};
// ApiLogs Component
const ApiLogs = ({ logs, onClear }) => {
if (logs.length === 0) return null;
return (
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-bold text-stone-900 flex items-center gap-2">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
API 통신 로그
</h2>
<button onClick={onClear} className="text-sm text-stone-500 hover:text-stone-900 flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
로그 지우기
</button>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{logs.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>
</div>
);
};
// 날짜 유틸리티 함수
const getMonthDates = (offset = 0) => {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + offset;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
return {
from: formatLocalDate(firstDay),
to: formatLocalDate(lastDay)
};
};
// 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 [supplier, setSupplier] = useState(INITIAL_SUPPLIER);
const [showSupplierModal, setShowSupplierModal] = useState(false);
// 검색 필터 상태: filters = 입력용, appliedFilters = 실제 필터링용
const currentMonth = getMonthDates(0);
const defaultFilters = {
dateType: 'supplyDate',
dateFrom: currentMonth.from,
dateTo: currentMonth.to,
bizNo: '',
companyName: '',
status: '',
sortColumn: 'supplyDate',
sortDirection: 'desc',
};
const [filters, setFilters] = useState(defaultFilters);
const [appliedFilters, setAppliedFilters] = useState(defaultFilters);
const updateFilter = (key, value) => setFilters(prev => ({ ...prev, [key]: value }));
const handleSearch = () => setAppliedFilters({ ...filters });
useEffect(() => {
loadInvoices();
}, []);
const loadInvoices = async () => {
try {
const response = await fetch(API.invoices);
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(), type, message, data, timestamp: new Date().toLocaleTimeString('ko-KR') };
setApiLogs(prev => [log, ...prev].slice(0, 20));
};
const handleIssue = async (invoiceData) => {
addApiLog('request', '바로빌 API 호출: 세금계산서 발행', invoiceData);
try {
const response = await fetch(API.issue, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
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) {
addApiLog('error', '바로빌 API 오류: ' + err.message, { message: err.message });
}
};
const handleCheckStatus = async (invoiceId) => {
addApiLog('request', '바로빌 API 호출: 국세청 전송', { invoiceId });
try {
const response = await fetch(API.sendToNts, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
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) {
addApiLog('error', '바로빌 API 오류: ' + err.message, { message: err.message });
}
};
const handleDelete = async (invoiceId) => {
if (!window.confirm('세금계산서를 삭제하시겠습니까?')) return;
addApiLog('request', '세금계산서 삭제 요청', { invoiceId });
try {
const response = await fetch(API.delete, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
body: JSON.stringify({ invoiceId })
});
const result = await response.json();
if (result.success) {
addApiLog('response', '세금계산서 삭제 완료', result);
await loadInvoices();
} else {
addApiLog('error', '삭제 오류: ' + (result.error || '알 수 없는 오류'), result);
alert('삭제에 실패했습니다: ' + (result.error || '알 수 없는 오류'));
}
} catch (err) {
addApiLog('error', '삭제 오류: ' + err.message, { message: err.message });
alert('삭제 중 오류가 발생했습니다: ' + err.message);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
// 필터링 적용 (appliedFilters 기준 - 조회 버튼 클릭 시에만 갱신)
const af = appliedFilters;
const filteredInvoices = invoices.filter(invoice => {
const dateVal = af.dateType === 'sentAt' ? invoice.sentAt : invoice.supplyDate;
if (dateVal) {
if (dateVal < af.dateFrom || dateVal > af.dateTo) return false;
} else if (af.dateType === 'sentAt') {
return false;
}
if (af.bizNo) {
const q = af.bizNo.replace(/-/g, '');
const sBiz = (invoice.supplierBizno || '').replace(/-/g, '');
const rBiz = (invoice.recipientBizno || '').replace(/-/g, '');
if (!sBiz.includes(q) && !rBiz.includes(q)) return false;
}
if (af.companyName) {
const q = af.companyName.toLowerCase();
const sName = (invoice.supplierName || '').toLowerCase();
const rName = (invoice.recipientName || '').toLowerCase();
if (!sName.includes(q) && !rName.includes(q)) return false;
}
if (af.status && invoice.status !== af.status) return false;
return true;
});
// 정렬 적용
const sortedInvoices = [...filteredInvoices].sort((a, b) => {
let aVal = a[af.sortColumn];
let bVal = b[af.sortColumn];
if (aVal == null) aVal = '';
if (bVal == null) bVal = '';
if (typeof aVal === 'string' && typeof bVal === 'string') {
const cmp = aVal.localeCompare(bVal, 'ko-KR');
return af.sortDirection === 'asc' ? cmp : -cmp;
}
if (aVal < bVal) return af.sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return af.sortDirection === 'asc' ? 1 : -1;
return 0;
});
const stats = {
total: filteredInvoices.length,
issued: filteredInvoices.filter(i => i.status === 'issued' || i.status === 'sent').length,
sent: filteredInvoices.filter(i => i.status === 'sent').length,
totalAmount: filteredInvoices.reduce((sum, i) => sum + (i.total || 0), 0),
totalSupplyAmt: filteredInvoices.reduce((sum, i) => sum + (i.totalSupplyAmt || 0), 0),
totalVat: filteredInvoices.reduce((sum, i) => sum + (i.totalVat || 0), 0),
};
return (
<div className="space-y-8">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-stone-900">전자세금계산서</h1>
<p className="text-stone-500 mt-1">바로빌 API를 통한 전자세금계산서 발행 관리</p>
</div>
<div className="flex items-center gap-2">
@if($isTestMode)
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-xs font-medium">테스트 모드</span>
@else
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
운영 모드
</span>
@endif
@if($hasSoapClient)
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">SOAP 연결됨</span>
@else
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">SOAP 미연결</span>
@endif
</div>
</div>
{/* Summary Bar */}
<div className="bg-white rounded-lg border border-stone-200 overflow-hidden">
<table className="w-full text-sm">
<tbody>
<tr className="divide-x divide-stone-200">
<td className="px-4 py-3 bg-stone-50 text-stone-600 font-medium whitespace-nowrap">발행건수</td>
<td className="px-4 py-3 text-right font-bold text-stone-800 whitespace-nowrap">
<span className="text-blue-600">{stats.total.toLocaleString()}</span>
<span className="text-stone-400 font-normal text-xs ml-1"></span>
<span className="text-stone-300 mx-2">|</span>
<span className="text-xs text-stone-500 font-normal">발행 {stats.issued.toLocaleString()}</span>
<span className="text-stone-300 mx-1">/</span>
<span className="text-xs text-stone-500 font-normal">전송 {stats.sent.toLocaleString()}</span>
</td>
<td className="px-4 py-3 bg-stone-50 text-stone-600 font-medium whitespace-nowrap"> 합계금액</td>
<td className="px-5 py-3 text-right font-bold text-stone-900 whitespace-nowrap">{stats.totalAmount.toLocaleString()}</td>
<td className="px-4 py-3 bg-stone-50 text-stone-600 font-medium whitespace-nowrap"> 공급가액</td>
<td className="px-5 py-3 text-right font-bold text-stone-800 whitespace-nowrap">{stats.totalSupplyAmt.toLocaleString()}</td>
<td className="px-4 py-3 bg-stone-50 text-stone-600 font-medium whitespace-nowrap"> 세액</td>
<td className="px-5 py-3 text-right font-bold text-blue-700 whitespace-nowrap">{stats.totalVat.toLocaleString()}</td>
</tr>
</tbody>
</table>
</div>
{/* Issue Form Section */}
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold text-stone-900 flex items-center gap-2">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
전자세금계산서 발행
<button onClick={() => setShowSupplierModal(true)} className="ml-1 px-2.5 py-1 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 border border-blue-200 rounded-lg transition-colors flex items-center gap-1" title="공급자 기초정보 설정">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
공급자 설정
</button>
</h2>
{!showIssueForm && (
<button onClick={() => setShowIssueForm(true)} className={`px-4 py-2 text-white rounded-lg font-medium transition-colors flex items-center gap-2 ${IS_TEST_MODE ? 'bg-blue-600 hover:bg-blue-700' : 'bg-red-600 hover:bg-red-700'}`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
{IS_TEST_MODE ? '새로 발행 (랜덤 데이터)' : '새로 발행'}
</button>
)}
</div>
{showIssueForm && <IssueForm onIssue={handleIssue} onCancel={() => setShowIssueForm(false)} supplier={supplier} />}
</div>
{/* Invoice List */}
<InvoiceList
invoices={sortedInvoices}
onViewDetail={setSelectedInvoice}
onCheckStatus={handleCheckStatus}
onDelete={handleDelete}
filters={filters}
updateFilter={updateFilter}
onSearch={handleSearch}
totalCount={invoices.length}
/>
{/* API Logs */}
<ApiLogs logs={apiLogs} onClear={() => setApiLogs([])} />
{/* Detail Modal */}
{selectedInvoice && <InvoiceDetailModal invoice={selectedInvoice} onClose={() => setSelectedInvoice(null)} />}
{/* Supplier Settings Modal */}
{showSupplierModal && <SupplierSettingsModal supplier={supplier} onClose={() => setShowSupplierModal(false)} onSaved={(updated) => setSupplier(updated)} />}
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('etax-root'));
root.render(<App />);
</script>
@endpush