- EtaxController에 getSupplier/updateSupplier 메서드 추가 - etax 라우트 그룹에 GET/POST /supplier 라우트 추가 - SupplierSettingsModal React 컴포넌트 구현 (톱니바퀴 아이콘) - IssueForm이 supplier state를 props로 참조하도록 변경 - manager_phone → manager_hp 필드명 버그 수정 - FIXED_SUPPLIER → INITIAL_SUPPLIER 상수 리네이밍 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1245 lines
79 KiB
PHP
1245 lines
79 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") }}',
|
|
};
|
|
|
|
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}`;
|
|
};
|
|
|
|
// StatCard Component
|
|
const StatCard = ({ title, value, subtext, icon }) => (
|
|
<div className="bg-white rounded-xl p-6 shadow-sm border border-stone-100 hover:shadow-md transition-shadow">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<h3 className="text-sm font-medium text-stone-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-stone-900 mb-1">{value}</div>
|
|
{subtext && <div className="text-xs text-stone-400">{subtext}</div>}
|
|
</div>
|
|
);
|
|
|
|
// 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;
|
|
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;
|
|
items.push({ name: itemName, qty, unitPrice, vatType: 'vat' });
|
|
}
|
|
const randomDaysAgo = Math.floor(Math.random() * 30);
|
|
supplyDate = new Date();
|
|
supplyDate.setDate(supplyDate.getDate() - randomDaysAgo);
|
|
} else {
|
|
// 운영 모드: 빈 품목 1건
|
|
items = [{ name: '', qty: 1, unitPrice: 0, vatType: 'vat' }];
|
|
supplyDate = new Date();
|
|
}
|
|
|
|
// 공급받는자: 테스트 모드에서만 샘플 데이터, 운영 모드에서는 비움
|
|
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 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 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" 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: '30%'}} />
|
|
<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-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-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="3" 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>
|
|
);
|
|
};
|
|
|
|
// InvoiceList Component
|
|
const InvoiceList = ({ invoices, onViewDetail, onCheckStatus, onDelete, dateFrom, dateTo, onDateFromChange, onDateToChange, onThisMonth, onLastMonth, totalCount, sortColumn, sortDirection, onSort }) => {
|
|
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).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 SortIcon = ({ column }) => {
|
|
if (sortColumn !== column) {
|
|
return (
|
|
<svg className="w-4 h-4 ml-1 text-stone-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
|
</svg>
|
|
);
|
|
}
|
|
return sortDirection === 'asc' ? (
|
|
<svg className="w-4 h-4 ml-1 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 15l7-7 7 7" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-4 h-4 ml-1 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
// 정렬 가능한 헤더 컴포넌트
|
|
const SortableHeader = ({ column, children, className = '' }) => (
|
|
<th
|
|
className={`px-6 py-4 bg-stone-50 cursor-pointer hover:bg-stone-100 transition-colors select-none ${className}`}
|
|
onClick={() => onSort(column)}
|
|
>
|
|
<div className="flex items-center">
|
|
{children}
|
|
<SortIcon column={column} />
|
|
</div>
|
|
</th>
|
|
);
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden">
|
|
<div className="p-6 border-b border-stone-100">
|
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
|
<h2 className="text-lg font-bold text-stone-900">발행 내역</h2>
|
|
{/* 기간 조회 필터 */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm text-stone-500">기간</label>
|
|
<input
|
|
type="date"
|
|
value={dateFrom}
|
|
onChange={(e) => onDateFromChange(e.target.value)}
|
|
className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
|
/>
|
|
<span className="text-stone-400">~</span>
|
|
<input
|
|
type="date"
|
|
value={dateTo}
|
|
onChange={(e) => onDateToChange(e.target.value)}
|
|
className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onThisMonth}
|
|
className="px-3 py-1.5 text-sm bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors font-medium"
|
|
>
|
|
이번 달
|
|
</button>
|
|
<button
|
|
onClick={onLastMonth}
|
|
className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium"
|
|
>
|
|
지난달
|
|
</button>
|
|
</div>
|
|
<span className="text-sm text-stone-500 ml-2">
|
|
조회: <span className="font-semibold text-stone-700">{invoices.length}</span>건
|
|
{totalCount !== invoices.length && (
|
|
<span className="text-stone-400"> / 전체 {totalCount}건</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<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 uppercase font-medium text-stone-500 sticky top-0">
|
|
<tr>
|
|
<th className="px-6 py-4 bg-stone-50">발행번호</th>
|
|
<SortableHeader column="recipientName">공급받는자</SortableHeader>
|
|
<SortableHeader column="supplyDate">작성일자</SortableHeader>
|
|
<th className="px-6 py-4 bg-stone-50">전송일자</th>
|
|
<th className="px-6 py-4 bg-stone-50">공급가액</th>
|
|
<th className="px-6 py-4 bg-stone-50">부가세</th>
|
|
<th className="px-6 py-4 bg-stone-50">합계</th>
|
|
<th className="px-6 py-4 bg-stone-50">상태</th>
|
|
<th className="px-6 py-4 text-right bg-stone-50">작업</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-stone-50 transition-colors cursor-pointer" onClick={() => onViewDetail(invoice)}>
|
|
<td className="px-6 py-4 font-medium text-stone-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">{invoice.sentAt ? formatDate(invoice.sentAt) : <span className="text-stone-300">-</span>}</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-stone-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={() => onDelete(invoice.id)} className="text-red-400 hover:text-red-600 p-1.5 rounded hover:bg-red-50 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>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</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-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-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="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>
|
|
)}
|
|
</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>
|
|
</form>
|
|
<div className="p-5 border-t border-stone-100 bg-stone-50 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} onClick={(e) => { e.target.closest('.bg-stone-50').previousElementSibling.querySelector('form').requestSubmit(); }} 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>
|
|
</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);
|
|
|
|
// 날짜 필터 상태 (기본: 현재 월)
|
|
const currentMonth = getMonthDates(0);
|
|
const [dateFrom, setDateFrom] = useState(currentMonth.from);
|
|
const [dateTo, setDateTo] = useState(currentMonth.to);
|
|
|
|
// 정렬 상태 (기본: 작성일자 내림차순)
|
|
const [sortColumn, setSortColumn] = useState('supplyDate');
|
|
const [sortDirection, setSortDirection] = useState('desc');
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// 정렬 핸들러
|
|
const handleSort = (column) => {
|
|
if (sortColumn === column) {
|
|
// 같은 컬럼 클릭 시 정렬 방향 토글
|
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
// 다른 컬럼 클릭 시 해당 컬럼으로 변경, 내림차순 기본
|
|
setSortColumn(column);
|
|
setSortDirection('desc');
|
|
}
|
|
};
|
|
|
|
// 날짜 필터 적용된 송장 목록
|
|
const filteredInvoices = invoices.filter(invoice => {
|
|
const supplyDate = invoice.supplyDate;
|
|
if (!supplyDate) return true;
|
|
return supplyDate >= dateFrom && supplyDate <= dateTo;
|
|
});
|
|
|
|
// 정렬 적용
|
|
const sortedInvoices = [...filteredInvoices].sort((a, b) => {
|
|
let aVal = a[sortColumn];
|
|
let bVal = b[sortColumn];
|
|
|
|
// null/undefined 처리
|
|
if (aVal == null) aVal = '';
|
|
if (bVal == null) bVal = '';
|
|
|
|
// 문자열 비교
|
|
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
|
const comparison = aVal.localeCompare(bVal, 'ko-KR');
|
|
return sortDirection === 'asc' ? comparison : -comparison;
|
|
}
|
|
|
|
// 숫자 비교
|
|
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
|
|
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
// 이번 달 버튼
|
|
const handleThisMonth = () => {
|
|
const dates = getMonthDates(0);
|
|
setDateFrom(dates.from);
|
|
setDateTo(dates.to);
|
|
};
|
|
|
|
// 지난달 버튼
|
|
const handleLastMonth = () => {
|
|
const dates = getMonthDates(-1);
|
|
setDateFrom(dates.from);
|
|
setDateTo(dates.to);
|
|
};
|
|
|
|
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)
|
|
};
|
|
|
|
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>
|
|
|
|
{/* Dashboard */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<StatCard title="총 발행 건수" value={stats.total.toLocaleString()} subtext="전체 세금계산서" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="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>} />
|
|
<StatCard title="발행 완료" value={stats.issued.toLocaleString()} subtext="국세청 전송 대기/완료" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>} />
|
|
<StatCard title="국세청 전송 완료" value={stats.sent.toLocaleString()} subtext="전송 성공" icon={<svg className="w-5 h-5" 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>} />
|
|
<StatCard title="총 발행 금액" value={new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(stats.totalAmount)} subtext="전체 합계" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>} />
|
|
</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="p-1.5 text-stone-400 hover:text-stone-600 hover:bg-stone-100 rounded-lg transition-colors" title="공급자 기초정보 설정">
|
|
<svg className="w-5 h-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}
|
|
dateFrom={dateFrom}
|
|
dateTo={dateTo}
|
|
onDateFromChange={setDateFrom}
|
|
onDateToChange={setDateTo}
|
|
onThisMonth={handleThisMonth}
|
|
onLastMonth={handleLastMonth}
|
|
totalCount={invoices.length}
|
|
sortColumn={sortColumn}
|
|
sortDirection={sortDirection}
|
|
onSort={handleSort}
|
|
/>
|
|
|
|
{/* 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
|