Files
sam-manage/resources/views/barobill/hometax/index.blade.php
2026-02-06 13:50:13 +09:00

1224 lines
70 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,
summary // 합계 정보
}) => {
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">합계</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="14" 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-left text-[#212529] text-xs border-r border-[#dee2e6] max-w-[150px] truncate" title={inv.itemName || ''}>
{inv.itemName || '-'}
</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>
);
})
)}
{/* 합계행 */}
{invoices.length > 0 && summary && (
<tr className="bg-[#e7f1ff] border-t-2 border-[#0d6efd]">
<td colSpan="7" className="px-3 py-3 text-center text-sm font-bold text-[#0d6efd] border-r border-[#dee2e6]">
합계 ({invoices.length})
</td>
<td className="px-3 py-3 text-right text-sm font-bold text-[#212529] border-r border-[#dee2e6]">
{formatCurrency(summary.supplyAmount)}
</td>
<td className="px-3 py-3 text-right text-sm font-bold text-[#212529] border-r border-[#dee2e6]">
{formatCurrency(summary.taxAmount)}
</td>
<td className="px-3 py-3 text-right text-sm font-bold text-[#0d6efd] border-r border-[#dee2e6]">
{formatCurrency(summary.totalAmount)}
</td>
<td colSpan="4" className="px-3 py-3"></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 [selectedPeriod, setSelectedPeriod] = useState(null); // 선택된 기간 버튼 ('q1', 'q2', 'q3', 'q4', 'h1', 'h2', 'year')
// 진단 관련 상태
const [showDiagnoseModal, setShowDiagnoseModal] = useState(false);
const [diagnoseResult, setDiagnoseResult] = useState(null);
const [diagnosing, setDiagnosing] = useState(false);
// 초기 로드 (매출만 먼저 조회)
useEffect(() => {
loadSalesData();
loadCollectStatus();
}, []);
// 탭 변경 시 해당 탭 데이터 로드 (아직 로드되지 않은 경우)
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 = () => {
// loaded 플래그 초기화 (탭 전환 시에도 새 날짜로 재조회되도록)
setSalesData(prev => ({ ...prev, loaded: false }));
setPurchaseData(prev => ({ ...prev, loaded: false }));
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));
setSelectedPeriod('q' + quarter);
};
// 기(반기) 계산 (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));
setSelectedPeriod('h' + half);
};
// 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));
setSelectedPeriod('year');
};
// 거래처 필터링
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 formatNumber = (val) => new Intl.NumberFormat('ko-KR').format(val || 0);
// 과세/면세 합계 계산
const calculateTaxTypeSummary = (invoices) => {
let taxableAmount = 0; // 과세 공급가액
let exemptAmount = 0; // 면세 공급가액
let taxableCount = 0;
let exemptCount = 0;
invoices.forEach(inv => {
const amount = inv.supplyAmount || 0;
if (inv.taxTypeName === '면세') {
exemptAmount += amount;
exemptCount++;
} else {
taxableAmount += amount;
taxableCount++;
}
});
return { taxableAmount, exemptAmount, taxableCount, exemptCount };
};
const currentData = activeTab === 'sales' ? salesData : purchaseData;
const filteredInvoices = filterByCorpName(currentData.invoices);
// 매입 과세/면세 합계
const purchaseTaxSummary = calculateTaxTypeSummary(purchaseData.invoices);
// 현재 탭 인보이스 합계 (테이블 합계용)
const currentInvoiceSummary = {
supplyAmount: filteredInvoices.reduce((sum, inv) => sum + (inv.supplyAmount || 0), 0),
taxAmount: filteredInvoices.reduce((sum, inv) => sum + (inv.taxAmount || 0), 0),
totalAmount: filteredInvoices.reduce((sum, inv) => sum + (inv.totalAmount || 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">홈택스에 신고된 세금계산서 매입/매출 내역 조회</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); setSelectedPeriod(null); }}
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); setSelectedPeriod(null); }}
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] transition-colors rounded-l ${selectedPeriod === 'q1' ? 'bg-[#0d6efd] text-white border-[#0d6efd]' : 'bg-white hover:bg-[#e9ecef]'}`}
>1분기</button>
<button
onClick={() => handleQuarter(2)}
className={`px-3 py-2 text-xs border-t border-b border-[#ced4da] transition-colors ${selectedPeriod === 'q2' ? 'bg-[#0d6efd] text-white border-[#0d6efd]' : 'bg-white hover:bg-[#e9ecef]'}`}
>2분기</button>
<button
onClick={() => handleQuarter(3)}
className={`px-3 py-2 text-xs border-t border-b border-[#ced4da] transition-colors ${selectedPeriod === 'q3' ? 'bg-[#0d6efd] text-white border-[#0d6efd]' : 'bg-white hover:bg-[#e9ecef]'}`}
>3분기</button>
<button
onClick={() => handleQuarter(4)}
className={`px-3 py-2 text-xs border border-[#ced4da] transition-colors ${selectedPeriod === 'q4' ? 'bg-[#0d6efd] text-white border-[#0d6efd]' : 'bg-white hover:bg-[#e9ecef]'}`}
>4분기</button>
<button
onClick={() => handleHalf(1)}
className={`px-3 py-2 text-xs border-t border-b border-r border-[#ced4da] transition-colors ${selectedPeriod === 'h1' ? 'bg-[#0d6efd] text-white border-[#0d6efd]' : 'bg-white hover:bg-[#e9ecef]'}`}
>1</button>
<button
onClick={() => handleHalf(2)}
className={`px-3 py-2 text-xs border-t border-b border-r border-[#ced4da] transition-colors ${selectedPeriod === 'h2' ? 'bg-[#0d6efd] text-white border-[#0d6efd]' : 'bg-white hover:bg-[#e9ecef]'}`}
>2</button>
<button
onClick={handleYear}
className={`px-3 py-2 text-xs border border-[#ced4da] transition-colors rounded-r ${selectedPeriod === 'year' ? 'bg-[#0d6efd] text-white border-[#0d6efd]' : 'bg-white hover:bg-[#e9ecef]'}`}
>1</button>
</div>
{/* 조회 버튼 */}
<button
onClick={loadCurrentTabData}
disabled={loading}
className="px-4 py-1.5 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 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>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
)}
조회
</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>
{/* 저장 버튼 - 주황색 */}
<button
onClick={handleSync}
disabled={syncing}
className="flex items-center gap-2 text-sm font-semibold rounded-md disabled:opacity-50"
style=@{{ backgroundColor: '#fd7e14', color: '#fff', padding: '6px 16px', border: 'none', boxShadow: '0 2px 4px rgba(253,126,20,0.3)' }}
title="바로빌에서 데이터를 가져와 로컬 DB에 저장합니다"
>
{syncing ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
)}
저장
</button>
</div>
{lastSyncAt[activeTab] && (
<span className="ml-auto 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-5 gap-4">
<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(purchaseTaxSummary.taxableAmount)}
subtext={`${purchaseTaxSummary.taxableCount}건`}
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(purchaseTaxSummary.exemptAmount)}
subtext={`${purchaseTaxSummary.exemptCount}건`}
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>}
color="purple"
/>
<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={currentInvoiceSummary}
/>
)}
{/* 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