- HometaxInvoice 모델 생성 (로컬 DB 조회/저장) - HometaxSyncService 서비스 생성 (API 데이터 동기화) - HometaxController에 로컬 조회/동기화 메서드 추가 - 라우트 추가: local-sales, local-purchases, sync, update-memo, toggle-checked - UI: 데이터소스 선택 (로컬 DB/바로빌 API), 동기화 버튼 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1140 lines
64 KiB
PHP
1140 lines
64 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '홈택스 매입/매출')
|
|
|
|
@section('content')
|
|
<!-- 현재 테넌트 정보 카드 (React 외부) -->
|
|
<div class="rounded-xl shadow-lg p-5 mb-6" style="background: linear-gradient(to right, #7c3aed, #8b5cf6); 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-3 py-1 rounded-full text-sm font-bold" style="background: rgba(255,255,255,0.3);">테넌트 ID: {{ $tenantId }}</span>
|
|
@if($tenantId == 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>
|
|
|
|
<div id="hometax-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 type="text/babel">
|
|
const { useState, useEffect, useCallback } = React;
|
|
|
|
// API Routes
|
|
const API = {
|
|
sales: '{{ route("barobill.hometax.sales") }}',
|
|
purchases: '{{ route("barobill.hometax.purchases") }}',
|
|
localSales: '{{ route("barobill.hometax.local-sales") }}',
|
|
localPurchases: '{{ route("barobill.hometax.local-purchases") }}',
|
|
sync: '{{ route("barobill.hometax.sync") }}',
|
|
updateMemo: '{{ route("barobill.hometax.update-memo") }}',
|
|
toggleChecked: '{{ route("barobill.hometax.toggle-checked") }}',
|
|
requestCollect: '{{ route("barobill.hometax.request-collect") }}',
|
|
collectStatus: '{{ route("barobill.hometax.collect-status") }}',
|
|
export: '{{ route("barobill.hometax.export") }}',
|
|
scrapUrl: '{{ route("barobill.hometax.scrap-url") }}',
|
|
refreshScrap: '{{ route("barobill.hometax.refresh-scrap") }}',
|
|
diagnose: '{{ route("barobill.hometax.diagnose") }}',
|
|
};
|
|
|
|
const CSRF_TOKEN = '{{ csrf_token() }}';
|
|
|
|
// 한국 시간대(Asia/Seoul, UTC+9) 기준 날짜 포맷 (YYYY-MM-DD)
|
|
const formatKoreanDate = (date) => {
|
|
// en-CA 로케일은 YYYY-MM-DD 형식을 반환함
|
|
return date.toLocaleDateString('en-CA', { timeZone: 'Asia/Seoul' });
|
|
};
|
|
|
|
// 한국 시간대 기준 현재 날짜 가져오기
|
|
const getKoreanNow = () => {
|
|
const koreaDateStr = new Date().toLocaleString('en-US', { timeZone: 'Asia/Seoul' });
|
|
return new Date(koreaDateStr);
|
|
};
|
|
|
|
// 날짜 유틸리티 함수 - 한국 시간대(Asia/Seoul) 기준
|
|
const getMonthDates = (offset = 0) => {
|
|
const now = getKoreanNow();
|
|
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: formatKoreanDate(firstDay),
|
|
to: formatKoreanDate(lastDay)
|
|
};
|
|
};
|
|
|
|
// Toast 알림
|
|
const notify = (message, type = 'info') => {
|
|
if (typeof window.showToast === 'function') {
|
|
window.showToast(message, type);
|
|
} else {
|
|
alert(message);
|
|
}
|
|
};
|
|
|
|
// StatCard Component
|
|
const StatCard = ({ title, value, subtext, icon, color = 'purple' }) => {
|
|
const colorClasses = {
|
|
purple: 'bg-purple-50 text-purple-600',
|
|
blue: 'bg-blue-50 text-blue-600',
|
|
green: 'bg-green-50 text-green-600',
|
|
red: 'bg-red-50 text-red-600',
|
|
amber: 'bg-amber-50 text-amber-600',
|
|
stone: 'bg-stone-50 text-stone-600'
|
|
};
|
|
return (
|
|
<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 rounded-lg ${colorClasses[color] || colorClasses.purple}`}>
|
|
{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>
|
|
);
|
|
};
|
|
|
|
// Tab Component
|
|
const TabButton = ({ active, onClick, children, badge }) => (
|
|
<button
|
|
onClick={onClick}
|
|
className={`px-6 py-3 text-sm font-medium rounded-lg transition-all ${
|
|
active
|
|
? 'bg-purple-600 text-white shadow-lg shadow-purple-200'
|
|
: 'bg-white text-stone-600 hover:bg-stone-50 border border-stone-200'
|
|
}`}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
{children}
|
|
{badge !== undefined && (
|
|
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
|
active ? 'bg-white/20 text-white' : 'bg-stone-100 text-stone-600'
|
|
}`}>
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
|
|
// InvoiceTable Component - 홈택스 원본 형태
|
|
const InvoiceTable = ({
|
|
invoices,
|
|
loading,
|
|
type,
|
|
onExport,
|
|
onRequestCollect
|
|
}) => {
|
|
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
|
<span className="ml-3 text-stone-500">홈택스 데이터 조회 중...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isExpense = type === 'purchase';
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-200 overflow-hidden">
|
|
<div className="p-4 border-b border-stone-200 bg-stone-50">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-bold text-stone-800">
|
|
{isExpense ? '매입 세금계산서' : '매출 세금계산서'}
|
|
</h2>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onRequestCollect}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-purple-600 text-white rounded text-sm font-medium hover:bg-purple-700 transition-colors"
|
|
>
|
|
<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>
|
|
<button
|
|
onClick={onExport}
|
|
disabled={invoices.length === 0}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded text-sm font-medium hover:bg-blue-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
|
<span className="text-sm text-stone-500 ml-2">
|
|
<span className="font-semibold text-stone-700">{invoices.length}</span>건
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm border-collapse">
|
|
{/* 홈택스 스타일 테이블 헤더 */}
|
|
<thead>
|
|
<tr className="bg-[#f8f9fa] border-b-2 border-[#dee2e6]">
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] border-r border-[#dee2e6] whitespace-nowrap">구분</th>
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] border-r border-[#dee2e6] whitespace-nowrap">작성일자</th>
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] border-r border-[#dee2e6] whitespace-nowrap">발급일자</th>
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] border-r border-[#dee2e6] whitespace-nowrap">거래처</th>
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] border-r border-[#dee2e6] whitespace-nowrap">사업자번호<br/>(주민번호)</th>
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] border-r border-[#dee2e6] whitespace-nowrap">과세<br/>형태</th>
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] border-r border-[#dee2e6] whitespace-nowrap">공급가액</th>
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] border-r border-[#dee2e6] whitespace-nowrap">세액</th>
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] border-r border-[#dee2e6] whitespace-nowrap">합계</th>
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] border-r border-[#dee2e6] whitespace-nowrap">영수<br/>청구</th>
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] border-r border-[#dee2e6] whitespace-nowrap">문서<br/>형태</th>
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] border-r border-[#dee2e6] whitespace-nowrap">발급<br/>형태</th>
|
|
<th className="px-3 py-3 text-center text-xs font-semibold text-[#495057] whitespace-nowrap">상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{invoices.length === 0 ? (
|
|
<tr>
|
|
<td colSpan="13" className="px-6 py-8 text-center text-stone-400 border-b border-[#dee2e6]">
|
|
해당 기간에 조회된 세금계산서가 없습니다.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
invoices.map((inv, index) => {
|
|
// 발급일자 포맷팅
|
|
const issueDateFormatted = inv.issueDT && inv.issueDT.length >= 8
|
|
? `${inv.issueDT.substring(0,4)}-${inv.issueDT.substring(4,6)}-${inv.issueDT.substring(6,8)}`
|
|
: inv.writeDateFormatted || '-';
|
|
|
|
return (
|
|
<tr key={index} className="hover:bg-[#f1f3f5] border-b border-[#dee2e6]">
|
|
{/* 구분 */}
|
|
<td className="px-3 py-2.5 text-center border-r border-[#dee2e6]">
|
|
<span className="text-[#0d6efd] text-xs font-medium">홈택</span>
|
|
</td>
|
|
{/* 작성일자 */}
|
|
<td className="px-3 py-2.5 text-center text-[#212529] text-xs border-r border-[#dee2e6] whitespace-nowrap">
|
|
{inv.writeDateFormatted || '-'}
|
|
</td>
|
|
{/* 발급일자 */}
|
|
<td className="px-3 py-2.5 text-center text-[#212529] text-xs border-r border-[#dee2e6] whitespace-nowrap">
|
|
{issueDateFormatted}
|
|
</td>
|
|
{/* 거래처 */}
|
|
<td className="px-3 py-2.5 text-left text-[#212529] text-xs border-r border-[#dee2e6] max-w-[200px] truncate">
|
|
{isExpense ? inv.invoicerCorpName : inv.invoiceeCorpName}
|
|
</td>
|
|
{/* 사업자번호 */}
|
|
<td className="px-3 py-2.5 text-center text-[#212529] text-xs border-r border-[#dee2e6] whitespace-nowrap font-mono">
|
|
{isExpense ? inv.invoicerCorpNum : inv.invoiceeCorpNum}
|
|
</td>
|
|
{/* 과세형태 */}
|
|
<td className="px-3 py-2.5 text-center border-r border-[#dee2e6]">
|
|
<span className={`text-xs ${
|
|
inv.taxTypeName === '면세' ? 'text-[#e91e63]' : 'text-[#212529]'
|
|
}`}>
|
|
{inv.taxTypeName || '-'}
|
|
</span>
|
|
</td>
|
|
{/* 공급가액 */}
|
|
<td className="px-3 py-2.5 text-right text-[#212529] text-xs border-r border-[#dee2e6] whitespace-nowrap font-medium">
|
|
{formatCurrency(inv.supplyAmount)}
|
|
</td>
|
|
{/* 세액 */}
|
|
<td className="px-3 py-2.5 text-right text-[#212529] text-xs border-r border-[#dee2e6] whitespace-nowrap">
|
|
{formatCurrency(inv.taxAmount)}
|
|
</td>
|
|
{/* 합계 */}
|
|
<td className="px-3 py-2.5 text-right text-[#0d6efd] text-xs border-r border-[#dee2e6] whitespace-nowrap font-bold">
|
|
{formatCurrency(inv.totalAmount)}
|
|
</td>
|
|
{/* 영수청구 */}
|
|
<td className="px-3 py-2.5 text-center text-[#212529] text-xs border-r border-[#dee2e6]">
|
|
{inv.purposeTypeName || '-'}
|
|
</td>
|
|
{/* 문서형태 */}
|
|
<td className="px-3 py-2.5 text-center text-[#212529] text-xs border-r border-[#dee2e6]">
|
|
일반
|
|
</td>
|
|
{/* 발급형태 */}
|
|
<td className="px-3 py-2.5 text-center text-[#212529] text-xs border-r border-[#dee2e6]">
|
|
정발급
|
|
</td>
|
|
{/* 상태 */}
|
|
<td className="px-3 py-2.5 text-center">
|
|
<span className="text-[#198754] text-xs font-medium">전송완료</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Main App Component
|
|
const App = () => {
|
|
const [activeTab, setActiveTab] = useState('sales'); // sales or purchase
|
|
const [loading, setLoading] = useState(true);
|
|
const [salesData, setSalesData] = useState({ invoices: [], summary: {}, pagination: {}, loaded: false });
|
|
const [purchaseData, setPurchaseData] = useState({ invoices: [], summary: {}, pagination: {}, loaded: false });
|
|
const [error, setError] = useState(null);
|
|
const [collectStatus, setCollectStatus] = useState(null);
|
|
|
|
// 날짜 필터 상태 (기본: 현재 월)
|
|
const currentMonth = getMonthDates(0);
|
|
const [dateFrom, setDateFrom] = useState(currentMonth.from);
|
|
const [dateTo, setDateTo] = useState(currentMonth.to);
|
|
const [dateType, setDateType] = useState('write'); // 'write': 작성일자, 'issue': 발급일자
|
|
const [searchCorpName, setSearchCorpName] = useState(''); // 거래처 검색
|
|
const [dataSource, setDataSource] = useState('local'); // 'local': 로컬 DB, 'api': 바로빌 API
|
|
const [syncing, setSyncing] = useState(false); // 동기화 중 여부
|
|
const [lastSyncAt, setLastSyncAt] = useState({ sales: null, purchase: null }); // 마지막 동기화 시간
|
|
|
|
// 진단 관련 상태
|
|
const [showDiagnoseModal, setShowDiagnoseModal] = useState(false);
|
|
const [diagnoseResult, setDiagnoseResult] = useState(null);
|
|
const [diagnosing, setDiagnosing] = useState(false);
|
|
|
|
// 초기 로드 (매출만 먼저 조회)
|
|
useEffect(() => {
|
|
loadSalesData();
|
|
loadCollectStatus();
|
|
}, []);
|
|
|
|
// 날짜 변경 시 현재 탭 데이터 다시 로드
|
|
useEffect(() => {
|
|
if (dateFrom && dateTo) {
|
|
// 날짜 변경 시 loaded 플래그 초기화
|
|
setSalesData(prev => ({ ...prev, loaded: false }));
|
|
setPurchaseData(prev => ({ ...prev, loaded: false }));
|
|
|
|
if (activeTab === 'sales') {
|
|
loadSalesData();
|
|
} else {
|
|
loadPurchaseData();
|
|
}
|
|
}
|
|
}, [dateFrom, dateTo]);
|
|
|
|
// 탭 변경 시 해당 탭 데이터 로드 (아직 로드되지 않은 경우)
|
|
useEffect(() => {
|
|
if (activeTab === 'sales' && !salesData.loaded) {
|
|
loadSalesData();
|
|
} else if (activeTab === 'purchase' && !purchaseData.loaded) {
|
|
loadPurchaseData();
|
|
}
|
|
}, [activeTab]);
|
|
|
|
// 매출 데이터 로드
|
|
const loadSalesData = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// dateType: 1=작성일자, 2=발급일자, 3=전송일자
|
|
const dateTypeCode = dateType === 'write' ? 1 : 2;
|
|
const params = new URLSearchParams({
|
|
startDate: dateFrom.replace(/-/g, ''),
|
|
endDate: dateTo.replace(/-/g, ''),
|
|
dateType: dateTypeCode,
|
|
searchCorp: searchCorpName,
|
|
limit: 500
|
|
});
|
|
|
|
// 데이터소스에 따라 API 선택
|
|
const apiUrl = dataSource === 'local' ? API.localSales : API.sales;
|
|
|
|
try {
|
|
const res = await fetch(`${apiUrl}?${params}`);
|
|
const json = await res.json();
|
|
|
|
if (json.success) {
|
|
setSalesData({
|
|
invoices: json.data?.invoices || [],
|
|
summary: json.data?.summary || {},
|
|
pagination: json.data?.pagination || {},
|
|
loaded: true
|
|
});
|
|
// 마지막 동기화 시간 업데이트
|
|
if (json.lastSyncAt) {
|
|
setLastSyncAt(prev => ({ ...prev, sales: json.lastSyncAt }));
|
|
}
|
|
// 마지막 수집 시간 갱신
|
|
loadCollectStatus();
|
|
} else {
|
|
setError(json.error || '매출 조회 실패');
|
|
}
|
|
} catch (err) {
|
|
setError('서버 통신 오류: ' + err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// 매입 데이터 로드
|
|
const loadPurchaseData = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// dateType: 1=작성일자, 2=발급일자, 3=전송일자
|
|
const dateTypeCode = dateType === 'write' ? 1 : 2;
|
|
const params = new URLSearchParams({
|
|
startDate: dateFrom.replace(/-/g, ''),
|
|
endDate: dateTo.replace(/-/g, ''),
|
|
dateType: dateTypeCode,
|
|
searchCorp: searchCorpName,
|
|
limit: 500
|
|
});
|
|
|
|
// 데이터소스에 따라 API 선택
|
|
const apiUrl = dataSource === 'local' ? API.localPurchases : API.purchases;
|
|
|
|
try {
|
|
const res = await fetch(`${apiUrl}?${params}`);
|
|
const json = await res.json();
|
|
|
|
if (json.success) {
|
|
setPurchaseData({
|
|
invoices: json.data?.invoices || [],
|
|
summary: json.data?.summary || {},
|
|
pagination: json.data?.pagination || {},
|
|
loaded: true
|
|
});
|
|
// 마지막 동기화 시간 업데이트
|
|
if (json.lastSyncAt) {
|
|
setLastSyncAt(prev => ({ ...prev, purchase: json.lastSyncAt }));
|
|
}
|
|
// 마지막 수집 시간 갱신
|
|
loadCollectStatus();
|
|
} else {
|
|
setError(json.error || '매입 조회 실패');
|
|
}
|
|
} catch (err) {
|
|
setError('서버 통신 오류: ' + err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// 현재 탭 데이터 로드 (조회 버튼용)
|
|
const loadCurrentTabData = () => {
|
|
if (activeTab === 'sales') {
|
|
loadSalesData();
|
|
} else {
|
|
loadPurchaseData();
|
|
}
|
|
};
|
|
|
|
// 바로빌 API에서 로컬 DB로 동기화
|
|
const handleSync = async () => {
|
|
if (!confirm('바로빌에서 데이터를 가져와 로컬 DB에 저장합니다.\n계속하시겠습니까?')) return;
|
|
|
|
setSyncing(true);
|
|
setError(null);
|
|
|
|
const dateTypeCode = dateType === 'write' ? 1 : 2;
|
|
|
|
try {
|
|
const res = await fetch(API.sync, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': CSRF_TOKEN
|
|
},
|
|
body: JSON.stringify({
|
|
type: 'all',
|
|
startDate: dateFrom.replace(/-/g, ''),
|
|
endDate: dateTo.replace(/-/g, ''),
|
|
dateType: dateTypeCode
|
|
})
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
notify(data.message, 'success');
|
|
// 동기화 후 데이터 다시 로드
|
|
setSalesData(prev => ({ ...prev, loaded: false }));
|
|
setPurchaseData(prev => ({ ...prev, loaded: false }));
|
|
loadCurrentTabData();
|
|
} else {
|
|
notify(data.error || '동기화 실패', 'error');
|
|
}
|
|
} catch (err) {
|
|
notify('동기화 오류: ' + err.message, 'error');
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
};
|
|
|
|
const loadCollectStatus = async () => {
|
|
try {
|
|
const res = await fetch(API.collectStatus);
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
setCollectStatus(data.data);
|
|
}
|
|
} catch (err) {
|
|
console.error('수집 상태 조회 오류:', err);
|
|
}
|
|
};
|
|
|
|
const handleRequestCollect = async () => {
|
|
if (!confirm('홈택스 데이터 수집을 요청하시겠습니까?\n수집에는 시간이 걸릴 수 있습니다.')) return;
|
|
|
|
try {
|
|
const res = await fetch(API.requestCollect, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': CSRF_TOKEN
|
|
},
|
|
body: JSON.stringify({ type: 'all' })
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
notify(data.message, 'success');
|
|
loadCollectStatus();
|
|
} else {
|
|
notify(data.error || '수집 요청 실패', 'error');
|
|
}
|
|
} catch (err) {
|
|
notify('수집 요청 오류: ' + err.message, 'error');
|
|
}
|
|
};
|
|
|
|
const handleExport = async () => {
|
|
const invoices = activeTab === 'sales' ? salesData.invoices : purchaseData.invoices;
|
|
if (invoices.length === 0) {
|
|
notify('다운로드할 데이터가 없습니다.', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(API.export, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': CSRF_TOKEN
|
|
},
|
|
body: JSON.stringify({
|
|
type: activeTab,
|
|
invoices: invoices
|
|
})
|
|
});
|
|
|
|
if (res.ok) {
|
|
const blob = await res.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `홈택스_${activeTab === 'sales' ? '매출' : '매입'}_${dateFrom.replace(/-/g, '')}_${dateTo.replace(/-/g, '')}.csv`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
a.remove();
|
|
} else {
|
|
const data = await res.json();
|
|
notify(data.error || '다운로드 실패', 'error');
|
|
}
|
|
} catch (err) {
|
|
notify('다운로드 오류: ' + err.message, 'error');
|
|
}
|
|
};
|
|
|
|
// 서비스 진단
|
|
const handleDiagnose = async () => {
|
|
setShowDiagnoseModal(true);
|
|
setDiagnosing(true);
|
|
setDiagnoseResult(null);
|
|
|
|
try {
|
|
const res = await fetch(API.diagnose);
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
setDiagnoseResult(data.data);
|
|
} else {
|
|
setDiagnoseResult({ error: data.error || '진단 실패' });
|
|
}
|
|
} catch (err) {
|
|
setDiagnoseResult({ error: '서버 통신 오류: ' + err.message });
|
|
} finally {
|
|
setDiagnosing(false);
|
|
}
|
|
};
|
|
|
|
// 홈택스 스크래핑 새로고침
|
|
const handleRefreshScrap = async () => {
|
|
try {
|
|
const res = await fetch(API.refreshScrap, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': CSRF_TOKEN
|
|
}
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
notify(data.message || '홈택스 데이터 수집이 요청되었습니다.', 'success');
|
|
// 수집 완료 후 현재 탭 데이터만 새로고침
|
|
loadCurrentTabData();
|
|
} else {
|
|
notify(data.error || '수집 요청 실패', 'error');
|
|
}
|
|
} catch (err) {
|
|
notify('수집 요청 오류: ' + err.message, 'error');
|
|
}
|
|
};
|
|
|
|
// 이번 달 버튼
|
|
const handleThisMonth = () => {
|
|
const dates = getMonthDates(0);
|
|
setDateFrom(dates.from);
|
|
setDateTo(dates.to);
|
|
};
|
|
|
|
// 지난달 버튼
|
|
const handleLastMonth = () => {
|
|
const dates = getMonthDates(-1);
|
|
setDateFrom(dates.from);
|
|
setDateTo(dates.to);
|
|
};
|
|
|
|
// 분기 계산 (1분기: 1-3월, 2분기: 4-6월, 3분기: 7-9월, 4분기: 10-12월)
|
|
const handleQuarter = (quarter) => {
|
|
const now = getKoreanNow();
|
|
const year = now.getFullYear();
|
|
const startMonth = (quarter - 1) * 3;
|
|
const firstDay = new Date(year, startMonth, 1);
|
|
const lastDay = new Date(year, startMonth + 3, 0);
|
|
setDateFrom(formatKoreanDate(firstDay));
|
|
setDateTo(formatKoreanDate(lastDay));
|
|
};
|
|
|
|
// 기(반기) 계산 (1기: 1-6월, 2기: 7-12월)
|
|
const handleHalf = (half) => {
|
|
const now = getKoreanNow();
|
|
const year = now.getFullYear();
|
|
const startMonth = (half - 1) * 6;
|
|
const firstDay = new Date(year, startMonth, 1);
|
|
const lastDay = new Date(year, startMonth + 6, 0);
|
|
setDateFrom(formatKoreanDate(firstDay));
|
|
setDateTo(formatKoreanDate(lastDay));
|
|
};
|
|
|
|
// 1년 계산
|
|
const handleYear = () => {
|
|
const now = getKoreanNow();
|
|
const year = now.getFullYear();
|
|
const firstDay = new Date(year, 0, 1);
|
|
const lastDay = new Date(year, 11, 31);
|
|
setDateFrom(formatKoreanDate(firstDay));
|
|
setDateTo(formatKoreanDate(lastDay));
|
|
};
|
|
|
|
// 거래처 필터링
|
|
const filterByCorpName = (invoices) => {
|
|
if (!searchCorpName.trim()) return invoices;
|
|
const keyword = searchCorpName.trim().toLowerCase();
|
|
return invoices.filter(inv => {
|
|
const corpName = (activeTab === 'sales' ? inv.invoiceeCorpName : inv.invoicerCorpName) || '';
|
|
const corpNum = (activeTab === 'sales' ? inv.invoiceeCorpNum : inv.invoicerCorpNum) || '';
|
|
return corpName.toLowerCase().includes(keyword) || corpNum.includes(keyword);
|
|
});
|
|
};
|
|
|
|
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
|
|
|
|
const currentData = activeTab === 'sales' ? salesData : purchaseData;
|
|
const filteredInvoices = filterByCorpName(currentData.invoices);
|
|
|
|
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">홈택스에 신고된 세금계산서 매입/매출 내역 조회</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleDiagnose}
|
|
className="px-3 py-1.5 bg-stone-100 text-stone-700 rounded-lg text-xs font-medium hover:bg-stone-200 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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
</svg>
|
|
서비스 진단
|
|
</button>
|
|
@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>
|
|
|
|
{/* 바로빌 스타일 검색 카드 */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-[#dee2e6]">
|
|
{/* 검색기간 행 */}
|
|
<div className="flex flex-wrap items-center gap-3 px-5 py-4 border-b border-[#dee2e6]">
|
|
<label className="text-sm font-medium text-[#495057] w-20 shrink-0">검색기간</label>
|
|
<select
|
|
value={dateType}
|
|
onChange={(e) => setDateType(e.target.value)}
|
|
className="px-3 py-2 text-sm border border-[#ced4da] rounded bg-white focus:border-[#86b7fe] focus:ring-2 focus:ring-[#86b7fe]/25 outline-none"
|
|
>
|
|
<option value="write">작성일자</option>
|
|
<option value="issue">발급일자</option>
|
|
</select>
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="date"
|
|
value={dateFrom}
|
|
onChange={(e) => setDateFrom(e.target.value)}
|
|
className="px-3 py-2 text-sm border border-[#ced4da] rounded bg-white focus:border-[#86b7fe] focus:ring-2 focus:ring-[#86b7fe]/25 outline-none"
|
|
/>
|
|
<span className="text-[#6c757d] px-1">~</span>
|
|
<input
|
|
type="date"
|
|
value={dateTo}
|
|
onChange={(e) => setDateTo(e.target.value)}
|
|
className="px-3 py-2 text-sm border border-[#ced4da] rounded bg-white focus:border-[#86b7fe] focus:ring-2 focus:ring-[#86b7fe]/25 outline-none"
|
|
/>
|
|
</div>
|
|
{/* 분기/기/년 버튼 그룹 */}
|
|
<div className="flex items-center">
|
|
<button onClick={() => handleQuarter(1)} className="px-3 py-2 text-xs border border-[#ced4da] bg-white hover:bg-[#e9ecef] transition-colors rounded-l">1분기</button>
|
|
<button onClick={() => handleQuarter(2)} className="px-3 py-2 text-xs border-t border-b border-[#ced4da] bg-white hover:bg-[#e9ecef] transition-colors">2분기</button>
|
|
<button onClick={() => handleQuarter(3)} className="px-3 py-2 text-xs border-t border-b border-[#ced4da] bg-white hover:bg-[#e9ecef] transition-colors">3분기</button>
|
|
<button onClick={() => handleQuarter(4)} className="px-3 py-2 text-xs border border-[#ced4da] bg-white hover:bg-[#e9ecef] transition-colors">4분기</button>
|
|
<button onClick={() => handleHalf(1)} className="px-3 py-2 text-xs border-t border-b border-r border-[#ced4da] bg-white hover:bg-[#e9ecef] transition-colors">1기</button>
|
|
<button onClick={() => handleHalf(2)} className="px-3 py-2 text-xs border-t border-b border-r border-[#ced4da] bg-white hover:bg-[#e9ecef] transition-colors">2기</button>
|
|
<button onClick={handleYear} className="px-3 py-2 text-xs border border-[#ced4da] bg-white hover:bg-[#e9ecef] transition-colors rounded-r">1년</button>
|
|
</div>
|
|
{/* 검색 버튼 */}
|
|
<button
|
|
onClick={loadCurrentTabData}
|
|
disabled={loading}
|
|
className="ml-auto px-5 py-2 text-sm bg-[#0d6efd] text-white rounded hover:bg-[#0b5ed7] transition-colors font-medium disabled:opacity-50 flex items-center gap-2"
|
|
>
|
|
{loading && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>}
|
|
검색
|
|
</button>
|
|
</div>
|
|
{/* 거래처 행 */}
|
|
<div className="flex flex-wrap items-center gap-3 px-5 py-4 border-b border-[#dee2e6]">
|
|
<label className="text-sm font-medium text-[#495057] w-20 shrink-0">거래처</label>
|
|
<input
|
|
type="text"
|
|
value={searchCorpName}
|
|
onChange={(e) => setSearchCorpName(e.target.value)}
|
|
placeholder="사업자번호 또는 사업자명"
|
|
className="flex-1 max-w-md px-3 py-2 text-sm border border-[#ced4da] rounded bg-white focus:border-[#86b7fe] focus:ring-2 focus:ring-[#86b7fe]/25 outline-none"
|
|
/>
|
|
<span className="text-xs text-[#6c757d]">(사업자번호 또는 사업자명)</span>
|
|
</div>
|
|
{/* 데이터소스 및 동기화 행 */}
|
|
<div className="flex flex-wrap items-center gap-3 px-5 py-4">
|
|
<label className="text-sm font-medium text-[#495057] w-20 shrink-0">데이터</label>
|
|
<div className="flex items-center gap-4">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="dataSource"
|
|
value="local"
|
|
checked={dataSource === 'local'}
|
|
onChange={(e) => setDataSource(e.target.value)}
|
|
className="w-4 h-4 text-[#0d6efd]"
|
|
/>
|
|
<span className="text-sm text-[#495057]">로컬 DB</span>
|
|
<span className="text-xs text-[#198754]">(빠름)</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="dataSource"
|
|
value="api"
|
|
checked={dataSource === 'api'}
|
|
onChange={(e) => setDataSource(e.target.value)}
|
|
className="w-4 h-4 text-[#0d6efd]"
|
|
/>
|
|
<span className="text-sm text-[#495057]">바로빌 API</span>
|
|
<span className="text-xs text-[#6c757d]">(실시간)</span>
|
|
</label>
|
|
</div>
|
|
<button
|
|
onClick={handleSync}
|
|
disabled={syncing}
|
|
className="ml-auto px-4 py-2 text-sm bg-[#198754] text-white rounded hover:bg-[#157347] transition-colors font-medium disabled:opacity-50 flex items-center gap-2"
|
|
>
|
|
{syncing ? (
|
|
<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="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>
|
|
{lastSyncAt[activeTab] && (
|
|
<span className="text-xs text-[#6c757d]">
|
|
마지막 동기화: {lastSyncAt[activeTab]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* 현재 조회 결과 */}
|
|
<div className="px-5 py-3 bg-[#f8f9fa] border-t border-[#dee2e6] flex items-center justify-between text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[#6c757d]">현재 조회 기간:</span>
|
|
<span className="font-semibold text-[#6f42c1]">{dateFrom} ~ {dateTo}</span>
|
|
<span className="text-[#6c757d] ml-2">({dateType === 'write' ? '작성일자' : '발급일자'} 기준)</span>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-[#6c757d]">
|
|
매출 <span className="font-semibold text-[#198754]">{salesData.summary.count || 0}</span>건
|
|
</span>
|
|
<span className="text-[#6c757d]">
|
|
매입 <span className="font-semibold text-[#dc3545]">{purchaseData.summary.count || 0}</span>건
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 수집 상태 안내 */}
|
|
{collectStatus && (
|
|
<div className={`p-4 rounded-xl ${collectStatus.isCollecting ? 'bg-amber-50 border border-amber-200' : 'bg-stone-50 border border-stone-200'}`}>
|
|
<div className="flex items-center gap-3">
|
|
{collectStatus.isCollecting ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-amber-600"></div>
|
|
<span className="text-amber-700 font-medium">홈택스 데이터 수집 중...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span className="text-stone-600">
|
|
마지막 수집: 매출 {collectStatus.salesLastCollectDate || '-'} / 매입 {collectStatus.purchaseLastCollectDate || '-'}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Dashboard */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<StatCard
|
|
title="매출 공급가액"
|
|
value={formatCurrency(salesData.summary.totalAmount)}
|
|
subtext={`${salesData.summary.count || 0}건`}
|
|
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>}
|
|
color="green"
|
|
/>
|
|
<StatCard
|
|
title="매출 세액"
|
|
value={formatCurrency(salesData.summary.totalTax)}
|
|
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 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/></svg>}
|
|
color="blue"
|
|
/>
|
|
<StatCard
|
|
title="매입 공급가액"
|
|
value={formatCurrency(purchaseData.summary.totalAmount)}
|
|
subtext={`${purchaseData.summary.count || 0}건`}
|
|
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/></svg>}
|
|
color="red"
|
|
/>
|
|
<StatCard
|
|
title="매입 세액"
|
|
value={formatCurrency(purchaseData.summary.totalTax)}
|
|
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 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/></svg>}
|
|
color="amber"
|
|
/>
|
|
</div>
|
|
|
|
{/* Tab Buttons */}
|
|
<div className="flex gap-3">
|
|
<TabButton
|
|
active={activeTab === 'sales'}
|
|
onClick={() => setActiveTab('sales')}
|
|
badge={salesData.summary.count || 0}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
|
|
</svg>
|
|
매출
|
|
</TabButton>
|
|
<TabButton
|
|
active={activeTab === 'purchase'}
|
|
onClick={() => setActiveTab('purchase')}
|
|
badge={purchaseData.summary.count || 0}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
|
|
</svg>
|
|
매입
|
|
</TabButton>
|
|
</div>
|
|
|
|
{/* Error Display */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 text-red-600 p-4 rounded-xl">
|
|
<div className="flex items-start gap-3">
|
|
<div className="text-xl">⚠️</div>
|
|
<div className="flex-1">
|
|
<p className="font-semibold mb-2">{error}</p>
|
|
{error.includes('-60002') && (
|
|
<div className="mt-3 p-3 bg-white rounded border border-red-200 text-sm">
|
|
<p className="font-medium mb-2">해결 방법:</p>
|
|
<ol className="list-decimal list-inside space-y-1 text-stone-700">
|
|
<li>바로빌 사이트에 로그인</li>
|
|
<li>홈택스 연동 설정 메뉴 확인</li>
|
|
<li>홈택스 인증서 등록 및 연동 설정</li>
|
|
<li>부서사용자 ID/비밀번호 설정</li>
|
|
</ol>
|
|
</div>
|
|
)}
|
|
{error.includes('-60004') && (
|
|
<div className="mt-3 p-3 bg-white rounded border border-red-200 text-sm">
|
|
<p className="font-medium mb-2">해결 방법:</p>
|
|
<p className="text-stone-700">홈택스에서 부서사용자 아이디를 생성 후 바로빌에 등록해주세요.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Invoice Table */}
|
|
{!error && (
|
|
<InvoiceTable
|
|
invoices={filteredInvoices}
|
|
loading={loading}
|
|
type={activeTab}
|
|
onExport={handleExport}
|
|
onRequestCollect={handleRequestCollect}
|
|
/>
|
|
)}
|
|
|
|
{/* Summary Card */}
|
|
{!error && !loading && (
|
|
<div className="bg-gradient-to-r from-purple-50 to-violet-50 rounded-xl p-6 border border-purple-100">
|
|
<h3 className="text-lg font-bold text-stone-900 mb-4">기간 요약</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div>
|
|
<p className="text-sm text-stone-500 mb-1">매출 합계 (공급가액 + 세액)</p>
|
|
<p className="text-2xl font-bold text-green-600">{formatCurrency(salesData.summary.totalSum)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-stone-500 mb-1">매입 합계 (공급가액 + 세액)</p>
|
|
<p className="text-2xl font-bold text-red-600">{formatCurrency(purchaseData.summary.totalSum)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-stone-500 mb-1">예상 부가세 (매출세액 - 매입세액)</p>
|
|
<p className={`text-2xl font-bold ${
|
|
(salesData.summary.totalTax || 0) - (purchaseData.summary.totalTax || 0) >= 0
|
|
? 'text-purple-600'
|
|
: 'text-blue-600'
|
|
}`}>
|
|
{formatCurrency((salesData.summary.totalTax || 0) - (purchaseData.summary.totalTax || 0))}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 진단 모달 */}
|
|
{showDiagnoseModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden">
|
|
<div className="p-6 border-b border-stone-100 flex items-center justify-between">
|
|
<h3 className="text-lg font-bold text-stone-900">홈택스 서비스 진단</h3>
|
|
<button
|
|
onClick={() => setShowDiagnoseModal(false)}
|
|
className="p-2 hover:bg-stone-100 rounded-lg 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 overflow-y-auto max-h-[60vh]">
|
|
{diagnosing ? (
|
|
<div className="flex items-center justify-center py-10">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
|
<span className="ml-3 text-stone-500">서비스 진단 중...</span>
|
|
</div>
|
|
) : diagnoseResult?.error ? (
|
|
<div className="bg-red-50 text-red-600 p-4 rounded-xl">
|
|
{diagnoseResult.error}
|
|
</div>
|
|
) : diagnoseResult && (
|
|
<div className="space-y-6">
|
|
{/* 설정 정보 */}
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-stone-700 mb-3">API 설정</h4>
|
|
<div className="bg-stone-50 rounded-xl p-4 space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-stone-500">CERTKEY</span>
|
|
<span className="font-mono text-stone-700">{diagnoseResult.config?.certKey}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-stone-500">파트너사업자번호</span>
|
|
<span className="font-mono text-stone-700">{diagnoseResult.config?.corpNum}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-stone-500">테스트모드</span>
|
|
<span className={diagnoseResult.config?.isTestMode ? 'text-amber-600' : 'text-green-600'}>
|
|
{diagnoseResult.config?.isTestMode ? '예' : '아니오'}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-stone-500">API URL</span>
|
|
<span className="font-mono text-xs text-stone-600">{diagnoseResult.config?.baseUrl}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 회원사 정보 */}
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-stone-700 mb-3">회원사 정보</h4>
|
|
<div className="bg-stone-50 rounded-xl p-4 space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-stone-500">바로빌 ID</span>
|
|
<span className="font-mono text-stone-700">{diagnoseResult.member?.userId || '미설정'}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-stone-500">사업자번호</span>
|
|
<span className="font-mono text-stone-700">{diagnoseResult.member?.bizNo || '미설정'}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-stone-500">상호</span>
|
|
<span className="text-stone-700">{diagnoseResult.member?.corpName || '미설정'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* API 테스트 결과 */}
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-stone-700 mb-3">API 테스트 결과</h4>
|
|
<div className="space-y-3">
|
|
{Object.entries(diagnoseResult.tests || {}).map(([key, test]) => (
|
|
<div key={key} className={`rounded-xl p-4 ${test.success ? 'bg-green-50' : 'bg-red-50'}`}>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="font-medium text-stone-700">{test.method}</p>
|
|
<p className={`text-sm mt-1 ${test.success ? 'text-green-600' : 'text-red-600'}`}>
|
|
{test.result}
|
|
</p>
|
|
</div>
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
test.success ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
|
}`}>
|
|
{test.success ? '성공' : '실패'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 해결 방법 안내 */}
|
|
<div className="bg-amber-50 rounded-xl p-4 text-sm">
|
|
<p className="font-medium text-amber-800 mb-2">💡 문제 해결 안내</p>
|
|
<ul className="text-amber-700 space-y-1">
|
|
<li>• API 권한 오류 시: 바로빌 사이트에서 홈택스 연동 서비스 신청 필요</li>
|
|
<li>• 홈택스 연동 미등록 시: 부서사용자 또는 인증서 방식으로 홈택스 연동 등록 필요</li>
|
|
<li>• 데이터 미조회 시: 바로빌에서 발행한 세금계산서만 API로 조회 가능</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="p-4 border-t border-stone-100 flex justify-end gap-3">
|
|
<button
|
|
onClick={handleRefreshScrap}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors"
|
|
>
|
|
홈택스 수집 요청
|
|
</button>
|
|
<button
|
|
onClick={() => setShowDiagnoseModal(false)}
|
|
className="px-4 py-2 bg-stone-100 text-stone-700 rounded-lg text-sm font-medium hover:bg-stone-200 transition-colors"
|
|
>
|
|
닫기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('hometax-root'));
|
|
root.render(<App />);
|
|
</script>
|
|
@endpush
|