- " 로 이스케이프된 SVG HTML을 component prop에 직접 전달하면 sanitizeComponentAttribute()가 이중 인코딩하여 SVG 파서 에러 발생 - @php 블록에서 변수로 정의 후 prop 전달 방식으로 변경 - 영향 파일: settings, etax, hometax 바로빌 페이지 3개
2605 lines
152 KiB
PHP
2605 lines
152 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '홈택스 매출/매입')
|
|
|
|
@section('content')
|
|
<!-- 현재 테넌트 정보 카드 (React 외부) -->
|
|
@php
|
|
$headerIcon = '<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>';
|
|
@endphp
|
|
<x-barobill-tenant-header
|
|
:currentTenant="$currentTenant"
|
|
:barobillMember="$barobillMember"
|
|
:isTestMode="$isTestMode"
|
|
gradientFrom="#7c3aed"
|
|
gradientTo="#8b5cf6"
|
|
:icon="$headerIcon"
|
|
/>
|
|
|
|
<div id="hometax-root"></div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
@include('partials.react-cdn')
|
|
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useCallback, useRef } = 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") }}',
|
|
autoSync: '{{ route("barobill.hometax.auto-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") }}',
|
|
manualStore: '{{ route("barobill.hometax.manual-store") }}',
|
|
manualUpdate: '{{ route("barobill.hometax.manual-update", ["id" => "__ID__"]) }}',
|
|
manualDestroy: '{{ route("barobill.hometax.manual-destroy", ["id" => "__ID__"]) }}',
|
|
createJournalEntry: '{{ route("barobill.hometax.create-journal-entry") }}',
|
|
journals: '{{ route("barobill.hometax.journals") }}',
|
|
deleteJournals: '{{ route("barobill.hometax.journals.delete") }}',
|
|
cardTransactions: '{{ route("barobill.hometax.card-transactions") }}',
|
|
accountCodes: '{{ route("barobill.ecard.account-codes") }}',
|
|
};
|
|
|
|
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,
|
|
onJournalEntry,
|
|
onEditManual,
|
|
onDeleteManual
|
|
}) => {
|
|
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] border-r border-[#dee2e6] whitespace-nowrap">상태</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="15" 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]">
|
|
{inv.ntsConfirmNum && inv.ntsConfirmNum.startsWith('MAN-') ? (
|
|
<span className="text-[#7c3aed] text-xs font-medium">수동</span>
|
|
) : (
|
|
<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 border-r border-[#dee2e6]">
|
|
{inv.ntsConfirmNum && inv.ntsConfirmNum.startsWith('MAN-') ? (
|
|
<span className="text-[#7c3aed] text-xs font-medium">수동입력</span>
|
|
) : (
|
|
<span className="text-[#198754] text-xs font-medium">전송완료</span>
|
|
)}
|
|
</td>
|
|
{/* 액션 */}
|
|
<td className="px-2 py-2.5 text-center">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<button
|
|
onClick={() => onJournalEntry && onJournalEntry(inv)}
|
|
className={`px-2 py-1 rounded text-xs transition-colors ${inv.hasJournal ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100'}`}
|
|
title={inv.hasJournal ? "분개 수정" : "분개 생성"}
|
|
>{inv.hasJournal ? '분개완료' : '분개'}</button>
|
|
{inv.ntsConfirmNum && inv.ntsConfirmNum.startsWith('MAN-') && (
|
|
<>
|
|
<button
|
|
onClick={() => onEditManual && onEditManual(inv)}
|
|
className="px-2 py-1 bg-blue-50 text-blue-700 rounded text-xs hover:bg-blue-100 transition-colors"
|
|
title="수정"
|
|
>수정</button>
|
|
<button
|
|
onClick={() => onDeleteManual && onDeleteManual(inv)}
|
|
className="px-2 py-1 bg-red-50 text-red-700 rounded text-xs hover:bg-red-100 transition-colors"
|
|
title="삭제"
|
|
>삭제</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</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="5" 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 [autoSyncing, setAutoSyncing] = 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);
|
|
|
|
// 수동입력 모달
|
|
const [showManualModal, setShowManualModal] = useState(false);
|
|
const [manualEditData, setManualEditData] = useState(null);
|
|
|
|
// 거래처 관련
|
|
const [tradingPartners, setTradingPartners] = useState([]);
|
|
const [showAddPartnerModal, setShowAddPartnerModal] = useState(false);
|
|
|
|
// 분개 모달
|
|
const [showJournalModal, setShowJournalModal] = useState(false);
|
|
const [journalInvoice, setJournalInvoice] = useState(null);
|
|
|
|
// 계정과목 목록
|
|
const [accountCodes, setAccountCodes] = useState([]);
|
|
|
|
// 초기 로드 (매출만 먼저 조회)
|
|
useEffect(() => {
|
|
loadSalesData();
|
|
loadCollectStatus();
|
|
fetchTradingPartners();
|
|
loadAccountCodes();
|
|
// 초기 로드 후 자동 동기화 (매출)
|
|
triggerAutoSync('sales');
|
|
}, []);
|
|
|
|
// 탭 변경 시 해당 탭 데이터 로드 (아직 로드되지 않은 경우)
|
|
useEffect(() => {
|
|
if (activeTab === 'sales' && !salesData.loaded) {
|
|
loadSalesData();
|
|
} else if (activeTab === 'purchase' && !purchaseData.loaded) {
|
|
loadPurchaseData();
|
|
}
|
|
// 탭 전환 시 해당 탭 자동 동기화
|
|
triggerAutoSync(activeTab === 'sales' ? 'sales' : 'purchase');
|
|
}, [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
|
|
});
|
|
|
|
try {
|
|
const res = await fetch(`${API.localSales}?${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
|
|
});
|
|
|
|
try {
|
|
const res = await fetch(`${API.localPurchases}?${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();
|
|
}
|
|
};
|
|
|
|
// 자동 증분 동기화
|
|
const triggerAutoSync = async (type) => {
|
|
setAutoSyncing(true);
|
|
try {
|
|
const res = await fetch(API.autoSync, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': CSRF_TOKEN
|
|
},
|
|
body: JSON.stringify({ type })
|
|
});
|
|
const json = await res.json();
|
|
|
|
if (json.success && !json.skipped && json.hasNewData) {
|
|
// 신규 데이터가 있으면 화면 갱신
|
|
if (type === 'sales') {
|
|
loadSalesData();
|
|
} else {
|
|
loadPurchaseData();
|
|
}
|
|
notify(`${json.data.inserted}건 추가, ${json.data.updated}건 갱신`, 'success');
|
|
}
|
|
|
|
// 마지막 동기화 시간 업데이트
|
|
if (json.lastSyncAt) {
|
|
setLastSyncAt(prev => ({ ...prev, [type]: json.lastSyncAt }));
|
|
}
|
|
} catch (err) {
|
|
console.error('자동 동기화 오류:', err);
|
|
// 자동동기화 실패는 사용자에게 알리지 않음
|
|
} finally {
|
|
setAutoSyncing(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 fetchTradingPartners = async () => {
|
|
try {
|
|
const res = await fetch('/finance/journal-entries/trading-partners');
|
|
const data = await res.json();
|
|
if (data.success) setTradingPartners(data.data);
|
|
} catch (err) {
|
|
console.error('거래처 목록 조회 오류:', err);
|
|
}
|
|
};
|
|
|
|
const loadAccountCodes = async () => {
|
|
try {
|
|
const res = await fetch(API.accountCodes);
|
|
const data = await res.json();
|
|
if (data.success) setAccountCodes(data.data || []);
|
|
} catch (err) {
|
|
console.error('계정과목 목록 로드 오류:', err);
|
|
}
|
|
};
|
|
|
|
const handleRequestCollect = async () => {
|
|
if (!confirm('홈택스 데이터 수집을 요청하시겠습니까?\n수집에는 시간이 걸릴 수 있습니다.')) return;
|
|
|
|
try {
|
|
// 1단계: 바로빌에 홈택스 스크래핑 요청
|
|
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('홈택스 수집 요청 완료. 3초 후 동기화를 시작합니다...', 'success');
|
|
loadCollectStatus();
|
|
|
|
// 2단계: 3초 대기 후 바로빌 → 로컬 DB 동기화
|
|
await new Promise(r => setTimeout(r, 3000));
|
|
|
|
const syncRes = await fetch(API.sync, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': CSRF_TOKEN
|
|
},
|
|
body: JSON.stringify({
|
|
type: activeTab === 'sales' ? 'sales' : 'purchase',
|
|
startDate: dateFrom.replace(/-/g, ''),
|
|
endDate: dateTo.replace(/-/g, ''),
|
|
dateType: 3, // 전송일자 기준 (신규 전송건 누락 방지)
|
|
})
|
|
});
|
|
const syncData = await syncRes.json();
|
|
if (syncData.success) {
|
|
notify(syncData.message || '동기화 완료', 'success');
|
|
}
|
|
|
|
// 3단계: 로컬 DB 데이터 새로고침
|
|
loadCurrentTabData();
|
|
} 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 handleDeleteManual = async (inv) => {
|
|
if (!confirm(`"${inv.invoicerCorpName || inv.invoiceeCorpName}" 세금계산서를 삭제하시겠습니까?`)) return;
|
|
try {
|
|
const url = API.manualDestroy.replace('__ID__', inv.id);
|
|
const res = await fetch(url, {
|
|
method: 'DELETE',
|
|
headers: { 'X-CSRF-TOKEN': CSRF_TOKEN }
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
notify('삭제되었습니다.', 'success');
|
|
loadCurrentTabData();
|
|
} else {
|
|
notify(data.error || '삭제 실패', 'error');
|
|
}
|
|
} catch (err) {
|
|
notify('삭제 오류: ' + err.message, 'error');
|
|
}
|
|
};
|
|
|
|
// 수동입력 저장 (생성/수정)
|
|
const handleManualSave = async (formData) => {
|
|
try {
|
|
const isEdit = !!formData.id;
|
|
const url = isEdit
|
|
? API.manualUpdate.replace('__ID__', formData.id)
|
|
: API.manualStore;
|
|
const method = isEdit ? 'PUT' : 'POST';
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': CSRF_TOKEN
|
|
},
|
|
body: JSON.stringify(formData)
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
notify(data.message, 'success');
|
|
setShowManualModal(false);
|
|
loadCurrentTabData();
|
|
} else {
|
|
notify(data.error || '저장 실패', 'error');
|
|
}
|
|
} catch (err) {
|
|
notify('저장 오류: ' + err.message, 'error');
|
|
}
|
|
};
|
|
|
|
// 분개 저장
|
|
const handleJournalSave = async (invoiceId, lines) => {
|
|
try {
|
|
const res = await fetch(API.createJournalEntry, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': CSRF_TOKEN
|
|
},
|
|
body: JSON.stringify({ invoice_id: invoiceId, lines })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
notify(data.message || '분개가 저장되었습니다.', 'success');
|
|
setShowJournalModal(false);
|
|
setJournalInvoice(null);
|
|
loadCurrentTabData();
|
|
} else {
|
|
const errorMsg = data.error || '분개 저장 실패';
|
|
console.error('분개 저장 실패:', data);
|
|
notify(errorMsg, 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('분개 저장 오류:', err);
|
|
notify('분개 저장 오류: ' + err.message, 'error');
|
|
}
|
|
};
|
|
|
|
// 분개 삭제
|
|
const handleJournalDelete = async (invoiceId) => {
|
|
try {
|
|
const res = await fetch(`${API.deleteJournals}?invoice_id=${invoiceId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'X-CSRF-TOKEN': CSRF_TOKEN
|
|
}
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
notify(data.message || '분개가 삭제되었습니다.', 'success');
|
|
setShowJournalModal(false);
|
|
setJournalInvoice(null);
|
|
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);
|
|
};
|
|
|
|
// 거래처 필터링
|
|
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] rounded-r transition-colors ${selectedPeriod === 'q4' ? 'bg-[#0d6efd] text-white border-[#0d6efd]' : 'bg-white hover:bg-[#e9ecef]'}`}
|
|
>4분기</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-3 border-t border-[#dee2e6]">
|
|
<label className="text-sm font-medium text-[#495057] w-20 shrink-0">동기화</label>
|
|
<div className="flex items-center gap-3">
|
|
{autoSyncing && (
|
|
<span className="flex items-center gap-2 text-xs text-[#0d6efd]">
|
|
<div className="animate-spin rounded-full h-3 w-3 border-2 border-[#0d6efd] border-t-transparent"></div>
|
|
바로빌 동기화 중...
|
|
</span>
|
|
)}
|
|
{lastSyncAt[activeTab] && (
|
|
<span className="text-xs text-[#6c757d]">
|
|
마지막 동기화: {lastSyncAt[activeTab]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</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>
|
|
<span className="text-stone-400 mx-1">|</span>
|
|
<span className="text-xs text-stone-400">
|
|
바로빌 경유 수집 (전일까지 데이터 제공, 당일 발행분은 익일 반영)
|
|
</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>
|
|
<button
|
|
onClick={() => {
|
|
setManualEditData(null);
|
|
setShowManualModal(true);
|
|
}}
|
|
className="ml-auto px-4 py-3 text-sm font-medium rounded-lg bg-violet-600 text-white hover:bg-violet-700 transition-colors shadow-lg shadow-violet-200 flex items-center gap-2"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
수동 입력
|
|
</button>
|
|
</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}
|
|
onJournalEntry={(inv) => {
|
|
setJournalInvoice({...inv, invoiceType: activeTab});
|
|
setShowJournalModal(true);
|
|
}}
|
|
onEditManual={(inv) => {
|
|
setManualEditData(inv);
|
|
setShowManualModal(true);
|
|
}}
|
|
onDeleteManual={handleDeleteManual}
|
|
/>
|
|
)}
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* 수동입력 모달 */}
|
|
{showManualModal && (
|
|
<ManualInputModal
|
|
isOpen={showManualModal}
|
|
onClose={() => setShowManualModal(false)}
|
|
onSave={handleManualSave}
|
|
editData={manualEditData}
|
|
invoiceType={activeTab}
|
|
tradingPartners={tradingPartners}
|
|
onAddPartner={() => setShowAddPartnerModal(true)}
|
|
/>
|
|
)}
|
|
|
|
{/* 거래처 추가 모달 */}
|
|
<HometaxAddTradingPartnerModal
|
|
isOpen={showAddPartnerModal}
|
|
onClose={() => setShowAddPartnerModal(false)}
|
|
onSaved={(newPartner) => {
|
|
setTradingPartners(prev => [...prev, newPartner]);
|
|
setShowAddPartnerModal(false);
|
|
}}
|
|
/>
|
|
|
|
{/* 분개 모달 */}
|
|
{showJournalModal && journalInvoice && (
|
|
<JournalEntryModal
|
|
isOpen={showJournalModal}
|
|
onClose={() => { setShowJournalModal(false); setJournalInvoice(null); }}
|
|
onSave={handleJournalSave}
|
|
onDelete={handleJournalDelete}
|
|
invoice={journalInvoice}
|
|
accountCodes={accountCodes}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================
|
|
// HometaxTradingPartnerSelect - 거래처 드롭다운
|
|
// ============================================
|
|
const HometaxTradingPartnerSelect = ({ value, valueName, onChange, tradingPartners, onAddPartner }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [highlightIndex, setHighlightIndex] = useState(-1);
|
|
const containerRef = useRef(null);
|
|
const listRef = useRef(null);
|
|
|
|
const displayText = valueName || '';
|
|
|
|
const filteredPartners = tradingPartners.filter(p => {
|
|
if (!search) return true;
|
|
const s = search.toLowerCase();
|
|
return p.name.toLowerCase().includes(s) || (p.biz_no && p.biz_no.includes(search));
|
|
});
|
|
|
|
useEffect(() => { setHighlightIndex(-1); }, [search]);
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleSelect = (partner) => {
|
|
onChange(partner.id, partner.name, partner.biz_no || '', partner);
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
};
|
|
const handleClear = (e) => { e.stopPropagation(); onChange(null, '', ''); setSearch(''); };
|
|
|
|
const handleKeyDown = (e) => {
|
|
const maxIndex = Math.min(filteredPartners.length, 50) - 1;
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
const ni = highlightIndex < maxIndex ? highlightIndex + 1 : 0;
|
|
setHighlightIndex(ni);
|
|
setTimeout(() => { listRef.current?.children[ni]?.scrollIntoView({ block: 'nearest' }); }, 0);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
const ni = highlightIndex > 0 ? highlightIndex - 1 : maxIndex;
|
|
setHighlightIndex(ni);
|
|
setTimeout(() => { listRef.current?.children[ni]?.scrollIntoView({ block: 'nearest' }); }, 0);
|
|
} else if (e.key === 'Enter' && filteredPartners.length > 0) {
|
|
e.preventDefault();
|
|
handleSelect(filteredPartners[highlightIndex >= 0 ? highlightIndex : 0]);
|
|
} else if (e.key === 'Escape') {
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<div onClick={() => setIsOpen(!isOpen)}
|
|
className={`w-full px-3 py-2 text-sm border rounded-lg cursor-pointer flex items-center justify-between gap-1 ${isOpen ? 'border-violet-500 ring-2 ring-violet-500' : 'border-stone-300'} bg-white`}>
|
|
<span className={displayText ? 'text-stone-900 truncate' : 'text-stone-400'}>{displayText || '거래처 선택'}</span>
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{value && <button onClick={handleClear} className="text-stone-400 hover:text-stone-600">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
|
</button>}
|
|
<svg className={`w-3.5 h-3.5 text-stone-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" /></svg>
|
|
</div>
|
|
</div>
|
|
{isOpen && (
|
|
<div className="absolute z-[60] mt-1 w-72 bg-white border border-stone-200 rounded-lg shadow-lg">
|
|
<div className="p-2 border-b border-stone-100">
|
|
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} onKeyDown={handleKeyDown}
|
|
placeholder="거래처명 또는 사업자번호 검색..." className="w-full px-2.5 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-1 focus:ring-violet-500 outline-none" autoFocus />
|
|
</div>
|
|
<div className="border-b border-stone-100">
|
|
<button onClick={(e) => { e.stopPropagation(); setIsOpen(false); onAddPartner && onAddPartner(); }}
|
|
className="w-full px-3 py-2 text-xs text-violet-600 hover:bg-violet-50 font-medium flex items-center gap-1 justify-center transition-colors">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" /></svg>
|
|
거래처 추가
|
|
</button>
|
|
</div>
|
|
<div ref={listRef} className="max-h-48 overflow-y-auto">
|
|
{filteredPartners.length === 0 ? (
|
|
<div className="px-3 py-2 text-xs text-stone-400 text-center">검색 결과 없음</div>
|
|
) : filteredPartners.slice(0, 50).map((p, index) => (
|
|
<div key={p.id} onClick={() => handleSelect(p)}
|
|
className={`px-3 py-1.5 text-sm cursor-pointer ${index === highlightIndex ? 'bg-violet-600 text-white font-semibold' : value === p.id ? 'bg-violet-100 text-violet-700' : 'text-stone-700 hover:bg-violet-50'}`}>
|
|
<span className="font-medium">{p.name}</span>
|
|
{p.biz_no && <span className={`ml-1 text-xs ${index === highlightIndex ? 'text-violet-100' : 'text-stone-400'}`}>({p.biz_no})</span>}
|
|
</div>
|
|
))}
|
|
{filteredPartners.length > 50 && <div className="px-3 py-1 text-xs text-stone-400 text-center border-t">+{filteredPartners.length - 50}개 더 있음</div>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================
|
|
// HometaxAddTradingPartnerModal - 거래처 추가 모달
|
|
// ============================================
|
|
const HometaxAddTradingPartnerModal = ({ isOpen, onClose, onSaved }) => {
|
|
const [saving, setSaving] = useState(false);
|
|
const [form, setForm] = useState({
|
|
name: '', type: 'vendor', category: '기타', bizNo: '', contact: '', manager: '', memo: '',
|
|
});
|
|
|
|
const categories = ['기타', '제조업', '도소매업', '서비스업', '건설업', 'IT', '금융', '물류'];
|
|
|
|
const handleSave = async () => {
|
|
if (!form.name.trim()) { notify('거래처명을 입력하세요.', 'warning'); return; }
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch('/finance/partners/store', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
|
body: JSON.stringify(form),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
onSaved({ id: data.data.id, name: data.data.name, biz_no: data.data.bizNo, type: data.data.type });
|
|
setForm({ name: '', type: 'vendor', category: '기타', bizNo: '', contact: '', manager: '', memo: '' });
|
|
onClose();
|
|
notify('거래처가 추가되었습니다.', 'success');
|
|
} else {
|
|
notify(data.message || '저장에 실패했습니다.', 'error');
|
|
}
|
|
} catch (err) {
|
|
notify('저장 중 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
|
<div className="absolute inset-0 bg-black/40" />
|
|
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-stone-200">
|
|
<h3 className="text-base font-bold text-stone-800">거래처 추가</h3>
|
|
<button onClick={onClose} className="p-1 text-stone-400 hover:text-stone-600 rounded">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
|
</button>
|
|
</div>
|
|
<div className="px-5 py-4 space-y-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">거래처명 <span className="text-red-500">*</span></label>
|
|
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
placeholder="거래처명을 입력하세요"
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-violet-500 outline-none" autoFocus />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">구분</label>
|
|
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-violet-500 outline-none">
|
|
<option value="vendor">거래처</option>
|
|
<option value="freelancer">프리랜서</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">업종</label>
|
|
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-violet-500 outline-none">
|
|
{categories.map(c => <option key={c} value={c}>{c}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">사업자번호</label>
|
|
<input type="text" value={form.bizNo} onChange={(e) => setForm({ ...form, bizNo: e.target.value })}
|
|
placeholder="000-00-00000"
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-violet-500 outline-none" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">연락처</label>
|
|
<input type="text" value={form.contact} onChange={(e) => setForm({ ...form, contact: e.target.value })}
|
|
placeholder="전화번호"
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-violet-500 outline-none" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">담당자</label>
|
|
<input type="text" value={form.manager} onChange={(e) => setForm({ ...form, manager: e.target.value })}
|
|
placeholder="담당자명"
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-violet-500 outline-none" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">메모</label>
|
|
<input type="text" value={form.memo} onChange={(e) => setForm({ ...form, memo: e.target.value })}
|
|
placeholder="메모"
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-violet-500 outline-none" />
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 px-5 py-4 border-t border-stone-200">
|
|
<button onClick={onClose} className="px-4 py-2 text-sm text-stone-600 bg-stone-100 rounded-lg hover:bg-stone-200 transition-colors">취소</button>
|
|
<button onClick={handleSave} disabled={saving}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-violet-600 rounded-lg hover:bg-violet-700 transition-colors disabled:opacity-50">
|
|
{saving ? '저장 중...' : '저장'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================
|
|
// ManualInputModal - 수동입력 모달
|
|
// ============================================
|
|
const ManualInputModal = ({ isOpen, onClose, onSave, editData, invoiceType, tradingPartners, onAddPartner }) => {
|
|
const [form, setForm] = useState({
|
|
invoice_type: editData?.invoiceType || editData?.invoice_type || invoiceType || 'sales',
|
|
write_date: editData?.writeDateFormatted || editData?.write_date || formatKoreanDate(getKoreanNow()),
|
|
invoicer_corp_name: editData?.invoicerCorpName || editData?.invoicer_corp_name || '',
|
|
invoicer_corp_num: editData?.invoicerCorpNum || editData?.invoicer_corp_num || '',
|
|
invoicer_ceo_name: editData?.invoicer_ceo_name || '',
|
|
invoicer_branch_num: editData?.invoicer_branch_num || '',
|
|
invoicer_address: editData?.invoicer_address || '',
|
|
invoicer_biz_type: editData?.invoicer_biz_type || '',
|
|
invoicer_biz_class: editData?.invoicer_biz_class || '',
|
|
invoicer_email: editData?.invoicer_email || '',
|
|
invoicer_email2: editData?.invoicer_email2 || '',
|
|
invoicee_corp_name: editData?.invoiceeCorpName || editData?.invoicee_corp_name || '',
|
|
invoicee_corp_num: editData?.invoiceeCorpNum || editData?.invoicee_corp_num || '',
|
|
invoicee_ceo_name: editData?.invoicee_ceo_name || '',
|
|
invoicee_branch_num: editData?.invoicee_branch_num || '',
|
|
invoicee_address: editData?.invoicee_address || '',
|
|
invoicee_biz_type: editData?.invoicee_biz_type || '',
|
|
invoicee_biz_class: editData?.invoicee_biz_class || '',
|
|
invoicee_email: editData?.invoicee_email || '',
|
|
invoicee_email2: editData?.invoicee_email2 || '',
|
|
supply_amount: editData?.supplyAmount || editData?.supply_amount || '',
|
|
tax_amount: editData?.taxAmount || editData?.tax_amount || '',
|
|
item_name: editData?.itemName || editData?.item_name || '',
|
|
remark: editData?.remark || '',
|
|
tax_type: editData?.taxType || editData?.tax_type || 1,
|
|
purpose_type: editData?.purposeType || editData?.purpose_type || 1,
|
|
});
|
|
const [showCardPicker, setShowCardPicker] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// 거래처 드롭다운 상태
|
|
const [selectedPartnerId, setSelectedPartnerId] = useState(() => {
|
|
// 수정 모드일 때 기존 거래처명으로 매칭 시도
|
|
if (editData && tradingPartners) {
|
|
const isSalesEdit = (editData.invoiceType || editData.invoice_type || invoiceType) === 'sales';
|
|
const corpName = isSalesEdit
|
|
? (editData.invoiceeCorpName || editData.invoicee_corp_name || '')
|
|
: (editData.invoicerCorpName || editData.invoicer_corp_name || '');
|
|
const matched = tradingPartners.find(p => p.name === corpName);
|
|
return matched ? matched.id : null;
|
|
}
|
|
return null;
|
|
});
|
|
const [selectedPartnerName, setSelectedPartnerName] = useState(() => {
|
|
if (editData) {
|
|
const isSalesEdit = (editData.invoiceType || editData.invoice_type || invoiceType) === 'sales';
|
|
return isSalesEdit
|
|
? (editData.invoiceeCorpName || editData.invoicee_corp_name || '')
|
|
: (editData.invoicerCorpName || editData.invoicer_corp_name || '');
|
|
}
|
|
return '';
|
|
});
|
|
|
|
const handleChange = (field, value) => {
|
|
setForm(prev => ({ ...prev, [field]: value }));
|
|
// 매출/매입 전환 시 거래처 선택 초기화
|
|
if (field === 'invoice_type') {
|
|
setSelectedPartnerId(null);
|
|
setSelectedPartnerName('');
|
|
}
|
|
};
|
|
|
|
const handlePartnerSelect = (partnerId, partnerName, bizNo, partner) => {
|
|
setSelectedPartnerId(partnerId);
|
|
setSelectedPartnerName(partnerName);
|
|
const prefix = form.invoice_type === 'sales' ? 'invoicee' : 'invoicer';
|
|
setForm(prev => ({
|
|
...prev,
|
|
[`${prefix}_corp_name`]: partnerName || '',
|
|
[`${prefix}_corp_num`]: bizNo || prev[`${prefix}_corp_num`],
|
|
[`${prefix}_ceo_name`]: partner?.ceo || '',
|
|
[`${prefix}_address`]: partner?.address || '',
|
|
[`${prefix}_biz_type`]: partner?.type || '',
|
|
[`${prefix}_biz_class`]: partner?.category || '',
|
|
[`${prefix}_email`]: partner?.email || '',
|
|
}));
|
|
};
|
|
|
|
// 거래처 검색 팝업 상태
|
|
const [showPartnerSearch, setShowPartnerSearch] = useState(false);
|
|
const [partnerSearchQuery, setPartnerSearchQuery] = useState('');
|
|
const [partnerSearchResults, setPartnerSearchResults] = useState([]);
|
|
const [partnerSearchLoading, setPartnerSearchLoading] = useState(false);
|
|
const partnerSearchRef = useRef(null);
|
|
const partnerSearchTimerRef = useRef(null);
|
|
const partnerSearchInputRef = useRef(null);
|
|
const partnerSearchBtnRef = useRef(null);
|
|
|
|
// 검색 팝업 열기/닫기
|
|
const openPartnerSearch = () => {
|
|
setShowPartnerSearch(true);
|
|
setPartnerSearchQuery('');
|
|
setPartnerSearchResults(tradingPartners || []);
|
|
};
|
|
const closePartnerSearch = () => {
|
|
setShowPartnerSearch(false);
|
|
setPartnerSearchQuery('');
|
|
setPartnerSearchResults([]);
|
|
};
|
|
|
|
// 검색 팝업 위치 계산 + 포커스 + 외부 클릭
|
|
useEffect(() => {
|
|
if (!showPartnerSearch) return;
|
|
// 위치 계산
|
|
const positionPopup = () => {
|
|
if (!partnerSearchBtnRef.current || !partnerSearchRef.current) return;
|
|
const btnRect = partnerSearchBtnRef.current.getBoundingClientRect();
|
|
const popup = partnerSearchRef.current;
|
|
const popupW = 220, popupMaxH = 360, gap = 6;
|
|
// 가로: 버튼 왼쪽 기준 정렬
|
|
let left = btnRect.left;
|
|
if (left + popupW > window.innerWidth - 8) left = window.innerWidth - popupW - 8;
|
|
if (left < 8) left = 8;
|
|
popup.style.left = left + 'px';
|
|
popup.style.width = popupW + 'px';
|
|
// 세로: 아래 공간 우선, 부족하면 위로
|
|
const spaceBelow = window.innerHeight - btnRect.bottom - gap;
|
|
const spaceAbove = btnRect.top - gap;
|
|
if (spaceBelow >= popupMaxH || spaceBelow >= spaceAbove) {
|
|
popup.style.top = (btnRect.bottom + gap) + 'px';
|
|
popup.style.bottom = 'auto';
|
|
} else {
|
|
popup.style.top = 'auto';
|
|
popup.style.bottom = (window.innerHeight - btnRect.top + gap) + 'px';
|
|
}
|
|
};
|
|
requestAnimationFrame(() => {
|
|
positionPopup();
|
|
partnerSearchInputRef.current?.focus();
|
|
});
|
|
// 외부 클릭
|
|
const handleClickOutside = (e) => {
|
|
if (partnerSearchRef.current && !partnerSearchRef.current.contains(e.target)
|
|
&& partnerSearchBtnRef.current && !partnerSearchBtnRef.current.contains(e.target)) {
|
|
closePartnerSearch();
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [showPartnerSearch]);
|
|
|
|
// 검색어 변경 시 로컬 필터 + API 디바운스
|
|
const handlePartnerSearchChange = (query) => {
|
|
setPartnerSearchQuery(query);
|
|
const q = query.trim().toLowerCase();
|
|
// 로컬 필터
|
|
const localResults = (tradingPartners || []).filter(p => {
|
|
if (!q) return true;
|
|
return p.name.toLowerCase().includes(q)
|
|
|| (p.biz_no && p.biz_no.includes(q))
|
|
|| (p.ceo && p.ceo.toLowerCase().includes(q));
|
|
});
|
|
setPartnerSearchResults(localResults);
|
|
// 디바운스 API 검색 (검색어 2글자 이상일 때)
|
|
if (partnerSearchTimerRef.current) clearTimeout(partnerSearchTimerRef.current);
|
|
if (q.length >= 2) {
|
|
partnerSearchTimerRef.current = setTimeout(async () => {
|
|
setPartnerSearchLoading(true);
|
|
try {
|
|
const res = await fetch(`/finance/journal-entries/trading-partners?search=${encodeURIComponent(q)}`);
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
// 로컬 결과와 API 결과 병합 (중복 제거)
|
|
const localIds = new Set(localResults.map(p => p.id));
|
|
const apiOnly = data.data.filter(p => !localIds.has(p.id));
|
|
setPartnerSearchResults([...localResults, ...apiOnly]);
|
|
}
|
|
} catch (err) { /* 무시 */ }
|
|
finally { setPartnerSearchLoading(false); }
|
|
}, 300);
|
|
}
|
|
};
|
|
|
|
// 검색 결과에서 거래처 선택
|
|
const handlePartnerSearchSelect = (partner) => {
|
|
handlePartnerSelect(partner.id, partner.name, partner.biz_no, partner);
|
|
closePartnerSearch();
|
|
};
|
|
|
|
const totalAmount = (parseFloat(form.supply_amount) || 0) + (parseFloat(form.tax_amount) || 0);
|
|
|
|
const handleSubmit = async () => {
|
|
if (!form.write_date || !form.supply_amount) {
|
|
notify('작성일자와 공급가액은 필수입니다.', 'warning');
|
|
return;
|
|
}
|
|
// 매출이면 invoicee(공급받는자), 매입이면 invoicer(공급자) 필수
|
|
if (form.invoice_type === 'sales' && !form.invoicee_corp_name) {
|
|
notify('공급받는자(거래처)명을 입력해주세요.', 'warning');
|
|
return;
|
|
}
|
|
if (form.invoice_type === 'purchase' && !form.invoicer_corp_name) {
|
|
notify('공급자(거래처)명을 입력해주세요.', 'warning');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
const submitData = { ...form };
|
|
if (editData?.id) submitData.id = editData.id;
|
|
await onSave(submitData);
|
|
setSaving(false);
|
|
};
|
|
|
|
const handleCardSelect = (card) => {
|
|
setForm(prev => ({
|
|
...prev,
|
|
supply_amount: card.supplyAmount || (card.approvalAmount - (card.tax || 0)),
|
|
tax_amount: card.tax || 0,
|
|
invoicer_corp_name: card.merchantName || prev.invoicer_corp_name,
|
|
invoicer_corp_num: card.merchantBizNum || prev.invoicer_corp_num,
|
|
write_date: card.useDate || prev.write_date,
|
|
remark: `카드승인 ${card.approvalNum || ''}`.trim(),
|
|
}));
|
|
// 카드내역 선택 시 거래처 드롭다운도 동기화
|
|
if (card.merchantName) {
|
|
setSelectedPartnerName(card.merchantName);
|
|
const matched = tradingPartners.find(p => p.name === card.merchantName);
|
|
setSelectedPartnerId(matched ? matched.id : null);
|
|
}
|
|
setShowCardPicker(false);
|
|
};
|
|
|
|
const isSales = form.invoice_type === 'sales';
|
|
|
|
return (
|
|
<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-[90vh] 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">
|
|
{editData ? '세금계산서 수정' : '세금계산서 수동 입력'}
|
|
</h3>
|
|
<button onClick={onClose} 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-[65vh] space-y-4">
|
|
{/* 매출/매입 구분 + 작성일자 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1">구분 *</label>
|
|
<select
|
|
value={form.invoice_type}
|
|
onChange={(e) => handleChange('invoice_type', e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
>
|
|
<option value="sales">매출</option>
|
|
<option value="purchase">매입</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1">작성일자 *</label>
|
|
<input
|
|
type="date"
|
|
value={form.write_date}
|
|
onChange={(e) => handleChange('write_date', e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 거래처 정보 */}
|
|
{(() => {
|
|
const prefix = isSales ? 'invoicee' : 'invoicer';
|
|
const label = isSales ? '공급받는자' : '공급자';
|
|
return (
|
|
<div className="bg-stone-50 rounded-xl p-4 space-y-3">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="text-sm font-semibold text-stone-700">{label} 정보</h4>
|
|
{!editData && form.invoice_type === 'purchase' && (
|
|
<button
|
|
onClick={() => setShowCardPicker(true)}
|
|
className="px-3 py-1.5 bg-violet-100 text-violet-700 rounded-lg text-xs font-medium hover:bg-violet-200 transition-colors flex items-center gap-1"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
|
</svg>
|
|
카드내역 불러오기
|
|
</button>
|
|
)}
|
|
</div>
|
|
{/* 등록번호 + 종사업장번호 */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs text-stone-500 mb-1">등록번호 (사업자번호)</label>
|
|
<div className="flex gap-1.5">
|
|
<input
|
|
type="text"
|
|
value={form[`${prefix}_corp_num`]}
|
|
onChange={(e) => handleChange(`${prefix}_corp_num`, e.target.value)}
|
|
className="flex-1 px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
placeholder="000-00-00000"
|
|
/>
|
|
<button
|
|
ref={partnerSearchBtnRef}
|
|
type="button"
|
|
onClick={openPartnerSearch}
|
|
className="px-2.5 py-2 bg-violet-100 text-violet-700 rounded-lg hover:bg-violet-200 transition-colors shrink-0"
|
|
title="거래처 검색"
|
|
>
|
|
<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>
|
|
<div>
|
|
<label className="block text-xs text-stone-500 mb-1">종사업장번호</label>
|
|
<input
|
|
type="text"
|
|
value={form[`${prefix}_branch_num`]}
|
|
onChange={(e) => handleChange(`${prefix}_branch_num`, e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
placeholder=""
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* 상호 + 성명 */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs text-stone-500 mb-1">상호 ({label}명) *</label>
|
|
<HometaxTradingPartnerSelect
|
|
value={selectedPartnerId}
|
|
valueName={form[`${prefix}_corp_name`]}
|
|
onChange={handlePartnerSelect}
|
|
tradingPartners={tradingPartners || []}
|
|
onAddPartner={onAddPartner}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-stone-500 mb-1">성명 (대표자)</label>
|
|
<input
|
|
type="text"
|
|
value={form[`${prefix}_ceo_name`]}
|
|
onChange={(e) => handleChange(`${prefix}_ceo_name`, e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
placeholder="대표자명"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* 사업장 주소 */}
|
|
<div>
|
|
<label className="block text-xs text-stone-500 mb-1">사업장 주소</label>
|
|
<input
|
|
type="text"
|
|
value={form[`${prefix}_address`]}
|
|
onChange={(e) => handleChange(`${prefix}_address`, e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
placeholder="사업장 주소"
|
|
/>
|
|
</div>
|
|
{/* 업태 + 종목 */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs text-stone-500 mb-1">업태</label>
|
|
<input
|
|
type="text"
|
|
value={form[`${prefix}_biz_type`]}
|
|
onChange={(e) => handleChange(`${prefix}_biz_type`, e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
placeholder="업태"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-stone-500 mb-1">종목</label>
|
|
<input
|
|
type="text"
|
|
value={form[`${prefix}_biz_class`]}
|
|
onChange={(e) => handleChange(`${prefix}_biz_class`, e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
placeholder="종목"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* 이메일 */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs text-stone-500 mb-1">이메일</label>
|
|
<input
|
|
type="email"
|
|
value={form[`${prefix}_email`]}
|
|
onChange={(e) => handleChange(`${prefix}_email`, e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
placeholder="이메일"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-stone-500 mb-1">이메일 2</label>
|
|
<input
|
|
type="email"
|
|
value={form[`${prefix}_email2`]}
|
|
onChange={(e) => handleChange(`${prefix}_email2`, e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
placeholder="이메일 2"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* 금액 */}
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1">공급가액 *</label>
|
|
<input
|
|
type="number"
|
|
value={form.supply_amount}
|
|
onChange={(e) => handleChange('supply_amount', e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1">세액</label>
|
|
<input
|
|
type="number"
|
|
value={form.tax_amount}
|
|
onChange={(e) => handleChange('tax_amount', e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1">합계</label>
|
|
<div className="px-3 py-2 bg-stone-100 border border-stone-200 rounded-lg text-sm text-right font-bold text-blue-600">
|
|
{new Intl.NumberFormat('ko-KR').format(totalAmount)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 품목 + 과세유형 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1">품목</label>
|
|
<input
|
|
type="text"
|
|
value={form.item_name}
|
|
onChange={(e) => handleChange('item_name', e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
placeholder="품목명"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1">과세유형</label>
|
|
<select
|
|
value={form.tax_type}
|
|
onChange={(e) => handleChange('tax_type', parseInt(e.target.value))}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
>
|
|
<option value={1}>과세</option>
|
|
<option value={2}>영세</option>
|
|
<option value={3}>면세</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 비고 */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1">비고</label>
|
|
<input
|
|
type="text"
|
|
value={form.remark}
|
|
onChange={(e) => handleChange('remark', e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none"
|
|
placeholder="메모"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="p-4 border-t border-stone-100 flex justify-end gap-3">
|
|
<button onClick={onClose} className="px-4 py-2 bg-stone-100 text-stone-700 rounded-lg text-sm font-medium hover:bg-stone-200 transition-colors">
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={saving}
|
|
className="px-6 py-2 bg-violet-600 text-white rounded-lg text-sm font-medium hover:bg-violet-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
|
>
|
|
{saving && <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>}
|
|
{editData ? '수정' : '저장'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 카드내역 피커 (중첩 모달) */}
|
|
{showCardPicker && (
|
|
<CardTransactionPicker
|
|
onSelect={handleCardSelect}
|
|
onClose={() => setShowCardPicker(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* 거래처 검색 팝업 (Portal → body에 렌더링) */}
|
|
{showPartnerSearch && ReactDOM.createPortal(
|
|
<div ref={partnerSearchRef} className="fixed z-[9999] bg-white rounded-xl shadow-2xl border border-stone-200 overflow-hidden">
|
|
{/* 검색 헤더 */}
|
|
<div className="p-3 border-b border-stone-100 bg-stone-50">
|
|
<div className="flex items-center gap-2">
|
|
<svg className="w-4 h-4 text-stone-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
|
<input
|
|
ref={partnerSearchInputRef}
|
|
type="text"
|
|
value={partnerSearchQuery}
|
|
onChange={(e) => handlePartnerSearchChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') closePartnerSearch();
|
|
if (e.key === 'Enter' && partnerSearchResults.length > 0) {
|
|
e.preventDefault();
|
|
handlePartnerSearchSelect(partnerSearchResults[0]);
|
|
}
|
|
}}
|
|
className="flex-1 min-w-0 text-sm bg-transparent outline-none placeholder-stone-400"
|
|
placeholder="거래처명/사업자번호 검색"
|
|
/>
|
|
{partnerSearchLoading && (
|
|
<svg className="w-4 h-4 animate-spin text-violet-500 shrink-0" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
|
)}
|
|
<button onClick={closePartnerSearch} className="p-0.5 text-stone-400 hover:text-stone-600 shrink-0">
|
|
<svg className="w-4 h-4" 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>
|
|
{/* 검색 결과 */}
|
|
<div className="overflow-y-auto max-h-[300px]">
|
|
{partnerSearchResults.length === 0 ? (
|
|
<div className="px-4 py-8 text-center text-sm text-stone-400">
|
|
{partnerSearchQuery ? '검색 결과가 없습니다' : '등록된 거래처가 없습니다'}
|
|
</div>
|
|
) : (
|
|
partnerSearchResults.slice(0, 50).map((p) => (
|
|
<button
|
|
key={p.id}
|
|
type="button"
|
|
onClick={() => handlePartnerSearchSelect(p)}
|
|
className="w-full text-left px-3 py-2 hover:bg-violet-50 transition-colors border-b border-stone-50 last:border-b-0"
|
|
>
|
|
<div className="text-sm font-medium text-stone-800 truncate">{p.name}</div>
|
|
<div className="text-xs text-stone-400 font-mono">{p.biz_no || '-'}</div>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
{/* 결과 수 표시 */}
|
|
{partnerSearchResults.length > 0 && (
|
|
<div className="px-3 py-1.5 border-t border-stone-100 bg-stone-50 text-xs text-stone-400 text-right">
|
|
{partnerSearchResults.length}건
|
|
</div>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================
|
|
// AccountCodeSelect - 계정과목 검색 드롭다운
|
|
// ============================================
|
|
const AccountCodeSelect = ({ value, onChange, accountCodes }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [highlightIndex, setHighlightIndex] = useState(-1);
|
|
const containerRef = useRef(null);
|
|
const listRef = useRef(null);
|
|
|
|
const selectedItem = accountCodes.find(c => c.code === value);
|
|
const displayText = selectedItem ? `${selectedItem.code} ${selectedItem.name}` : '';
|
|
|
|
const filteredCodes = accountCodes.filter(code => {
|
|
if (!search) return true;
|
|
const s = search.toLowerCase();
|
|
return code.code.toLowerCase().includes(s) || code.name.toLowerCase().includes(s);
|
|
});
|
|
|
|
useEffect(() => { setHighlightIndex(-1); }, [search]);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
setIsOpen(false);
|
|
setSearch('');
|
|
setHighlightIndex(-1);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleSelect = (code) => {
|
|
onChange(code.code, code.name);
|
|
setIsOpen(false);
|
|
setSearch('');
|
|
setHighlightIndex(-1);
|
|
};
|
|
|
|
const handleClear = (e) => {
|
|
e.stopPropagation();
|
|
onChange('', '');
|
|
setSearch('');
|
|
setHighlightIndex(-1);
|
|
};
|
|
|
|
const handleKeyDown = (e) => {
|
|
const maxIndex = Math.min(filteredCodes.length, 50) - 1;
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
const newIndex = highlightIndex < maxIndex ? highlightIndex + 1 : 0;
|
|
setHighlightIndex(newIndex);
|
|
setTimeout(() => { listRef.current?.children[newIndex]?.scrollIntoView({ block: 'nearest' }); }, 0);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
const newIndex = highlightIndex > 0 ? highlightIndex - 1 : maxIndex;
|
|
setHighlightIndex(newIndex);
|
|
setTimeout(() => { listRef.current?.children[newIndex]?.scrollIntoView({ block: 'nearest' }); }, 0);
|
|
} else if (e.key === 'Enter' && filteredCodes.length > 0) {
|
|
e.preventDefault();
|
|
handleSelect(filteredCodes[highlightIndex >= 0 ? highlightIndex : 0]);
|
|
} else if (e.key === 'Escape') {
|
|
setIsOpen(false);
|
|
setSearch('');
|
|
setHighlightIndex(-1);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<div
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={`w-full px-2 py-1 text-xs border rounded cursor-pointer flex items-center justify-between gap-1 ${
|
|
isOpen ? 'border-violet-500 ring-2 ring-violet-500' : 'border-stone-200'
|
|
} bg-white`}
|
|
>
|
|
<span className={displayText ? 'text-stone-900' : 'text-stone-400'}>
|
|
{displayText || '선택'}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
{value && (
|
|
<button onClick={handleClear} className="text-stone-400 hover:text-stone-600">
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<svg className={`w-3 h-3 text-stone-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
{isOpen && (
|
|
<div className="absolute z-[9999] mt-1 w-56 bg-white border border-stone-200 rounded-lg shadow-xl">
|
|
<div className="p-2 border-b border-stone-100">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="코드 또는 이름 검색..."
|
|
className="w-full px-2 py-1 text-xs border border-stone-200 rounded focus:ring-1 focus:ring-violet-500 outline-none"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div ref={listRef} className="max-h-48 overflow-y-auto">
|
|
{filteredCodes.length === 0 ? (
|
|
<div className="px-3 py-2 text-xs text-stone-400 text-center">검색 결과 없음</div>
|
|
) : (
|
|
filteredCodes.slice(0, 50).map((code, index) => (
|
|
<div
|
|
key={code.code}
|
|
onClick={() => handleSelect(code)}
|
|
className={`px-3 py-1.5 text-xs cursor-pointer ${
|
|
index === highlightIndex
|
|
? 'bg-violet-600 text-white font-semibold'
|
|
: value === code.code
|
|
? 'bg-violet-100 text-violet-700'
|
|
: 'text-stone-700 hover:bg-violet-50'
|
|
}`}
|
|
>
|
|
<span className={`font-mono ${index === highlightIndex ? 'text-white' : 'text-violet-600'}`}>{code.code}</span>
|
|
<span className="ml-1">{code.name}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
{filteredCodes.length > 50 && (
|
|
<div className="px-3 py-1 text-xs text-stone-400 text-center border-t">+{filteredCodes.length - 50}개 더 있음</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================
|
|
// JournalEntryModal - 분개 생성/수정 모달
|
|
// ============================================
|
|
const JournalEntryModal = ({ isOpen, onClose, onSave, onDelete, invoice, accountCodes = [] }) => {
|
|
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0);
|
|
const formatAmountInput = (val) => { const n = String(val).replace(/[^\d]/g, ''); return n ? Number(n).toLocaleString() : ''; };
|
|
const parseAmountInput = (val) => parseInt(String(val).replace(/[^\d]/g, ''), 10) || 0;
|
|
const isSales = invoice.invoiceType === 'sales' || invoice.invoice_type === 'sales';
|
|
const supplyAmount = parseFloat(invoice.supplyAmount || invoice.supply_amount || 0);
|
|
const taxAmount = parseFloat(invoice.taxAmount || invoice.tax_amount || 0);
|
|
const totalAmount = parseFloat(invoice.totalAmount || invoice.total_amount || (supplyAmount + taxAmount));
|
|
|
|
// 분개 라인 초기값
|
|
const getDefaultLines = () => {
|
|
if (isSales) {
|
|
return [
|
|
{ dc_type: 'debit', account_code: '108', account_name: '외상매출금', debit_amount: totalAmount, credit_amount: 0, description: '' },
|
|
{ dc_type: 'credit', account_code: '401', account_name: '상품매출', debit_amount: 0, credit_amount: supplyAmount, description: '' },
|
|
{ dc_type: 'credit', account_code: '255', account_name: '부가세예수금', debit_amount: 0, credit_amount: taxAmount, description: '' },
|
|
];
|
|
} else {
|
|
return [
|
|
{ dc_type: 'debit', account_code: '501', account_name: '상품매입', debit_amount: supplyAmount, credit_amount: 0, description: '' },
|
|
{ dc_type: 'debit', account_code: '135', account_name: '부가세대급금', debit_amount: taxAmount, credit_amount: 0, description: '' },
|
|
{ dc_type: 'credit', account_code: '251', account_name: '외상매입금', debit_amount: 0, credit_amount: totalAmount, description: '' },
|
|
];
|
|
}
|
|
};
|
|
|
|
const [lines, setLines] = useState(getDefaultLines());
|
|
const [saving, setSaving] = useState(false);
|
|
const [loadingJournal, setLoadingJournal] = useState(false);
|
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
|
|
// 기존 분개 로드
|
|
useEffect(() => {
|
|
if (invoice.hasJournal) {
|
|
setLoadingJournal(true);
|
|
fetch(`${API.journals}?invoice_id=${invoice.id}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.success && data.data && data.data.length > 0) {
|
|
setLines(data.data);
|
|
setIsEditMode(true);
|
|
}
|
|
})
|
|
.catch(err => console.error('기존 분개 로드 오류:', err))
|
|
.finally(() => setLoadingJournal(false));
|
|
}
|
|
}, []);
|
|
|
|
const updateLine = (idx, field, value) => {
|
|
setLines(prev => prev.map((l, i) => i === idx ? { ...l, [field]: value } : l));
|
|
};
|
|
|
|
const totalDebit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0);
|
|
const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0);
|
|
const isBalanced = Math.abs(totalDebit - totalCredit) < 1;
|
|
|
|
const toggleDcType = (idx) => {
|
|
setLines(prev => prev.map((l, i) => {
|
|
if (i !== idx) return l;
|
|
const newType = l.dc_type === 'debit' ? 'credit' : 'debit';
|
|
return {
|
|
...l,
|
|
dc_type: newType,
|
|
debit_amount: l.credit_amount,
|
|
credit_amount: l.debit_amount,
|
|
};
|
|
}));
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
const emptyLine = lines.find(l => !l.account_code || !l.account_name);
|
|
if (emptyLine) {
|
|
notify('모든 분개 라인의 계정과목을 선택해주세요.', 'warning');
|
|
return;
|
|
}
|
|
if (!isBalanced) {
|
|
notify('차변과 대변의 합계가 일치하지 않습니다.', 'warning');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
await onSave(invoice.id, lines);
|
|
setSaving(false);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!confirm('분개를 삭제하시겠습니까?')) return;
|
|
setSaving(true);
|
|
await onDelete(invoice.id);
|
|
setSaving(false);
|
|
};
|
|
|
|
const tradingPartner = isSales
|
|
? (invoice.invoiceeCorpName || invoice.invoicee_corp_name || '')
|
|
: (invoice.invoicerCorpName || invoice.invoicer_corp_name || '');
|
|
|
|
return (
|
|
<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-3xl mx-4 max-h-[90vh] 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">
|
|
{isEditMode ? '분개 수정' : '분개 생성'}
|
|
</h3>
|
|
<button onClick={onClose} 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-[65vh] space-y-5">
|
|
{/* 세금계산서 정보 */}
|
|
<div className="bg-stone-50 rounded-xl p-4">
|
|
<h4 className="text-sm font-semibold text-stone-700 mb-3">세금계산서 정보</h4>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
|
<div><span className="text-stone-500">구분: </span><span className="font-medium">{isSales ? '매출' : '매입'}</span></div>
|
|
<div><span className="text-stone-500">거래처: </span><span className="font-medium">{tradingPartner}</span></div>
|
|
<div><span className="text-stone-500">공급가액: </span><span className="font-medium">{formatCurrency(supplyAmount)}</span></div>
|
|
<div><span className="text-stone-500">세액: </span><span className="font-medium">{formatCurrency(taxAmount)}</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
{loadingJournal ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-emerald-600 border-t-transparent"></div>
|
|
<span className="ml-2 text-sm text-stone-500">분개 데이터 로딩중...</span>
|
|
</div>
|
|
) : (
|
|
/* 분개 라인 테이블 */
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-stone-700 mb-3">분개 내역</h4>
|
|
<table className="w-full text-sm border border-stone-200 rounded-lg overflow-hidden">
|
|
<thead>
|
|
<tr className="bg-stone-100">
|
|
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">차/대</th>
|
|
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200" colSpan="2">계정과목</th>
|
|
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">차변금액</th>
|
|
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">대변금액</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{lines.map((line, idx) => (
|
|
<tr key={idx} className="border-b border-stone-100">
|
|
<td className="px-3 py-2 text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleDcType(idx)}
|
|
className={`px-2 py-0.5 rounded text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity ${line.dc_type === 'debit' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'}`}
|
|
title="클릭하여 차변/대변 전환"
|
|
>
|
|
{line.dc_type === 'debit' ? '차변' : '대변'}
|
|
</button>
|
|
</td>
|
|
<td className="px-3 py-2" colSpan="2">
|
|
<AccountCodeSelect
|
|
value={line.account_code}
|
|
onChange={(code, name) => {
|
|
setLines(prev => prev.map((l, i) => i === idx ? { ...l, account_code: code, account_name: name } : l));
|
|
}}
|
|
accountCodes={accountCodes}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<input
|
|
type="text"
|
|
value={formatAmountInput(line.debit_amount)}
|
|
onChange={(e) => updateLine(idx, 'debit_amount', parseAmountInput(e.target.value))}
|
|
className="w-full px-2 py-1 border border-stone-200 rounded text-sm text-right focus:ring-1 focus:ring-violet-500 outline-none"
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<input
|
|
type="text"
|
|
value={formatAmountInput(line.credit_amount)}
|
|
onChange={(e) => updateLine(idx, 'credit_amount', parseAmountInput(e.target.value))}
|
|
className="w-full px-2 py-1 border border-stone-200 rounded text-sm text-right focus:ring-1 focus:ring-violet-500 outline-none"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{/* 합계 */}
|
|
<tr className={`font-bold ${isBalanced ? 'bg-green-50' : 'bg-red-50'}`}>
|
|
<td colSpan="3" className="px-3 py-2 text-center text-sm">합계</td>
|
|
<td className="px-3 py-2 text-right text-sm">{formatCurrency(totalDebit)}</td>
|
|
<td className="px-3 py-2 text-right text-sm">{formatCurrency(totalCredit)}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
{!isBalanced && (
|
|
<p className="text-red-500 text-xs mt-2">차변과 대변의 합계가 일치하지 않습니다. (차이: {formatCurrency(Math.abs(totalDebit - totalCredit))})</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="p-4 border-t border-stone-100 flex justify-between">
|
|
<div>
|
|
{isEditMode && (
|
|
<button
|
|
onClick={handleDelete}
|
|
disabled={saving}
|
|
className="px-4 py-2 bg-red-50 text-red-600 rounded-lg text-sm font-medium hover:bg-red-100 transition-colors disabled:opacity-50"
|
|
>
|
|
분개 삭제
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button onClick={onClose} className="px-4 py-2 bg-stone-100 text-stone-700 rounded-lg text-sm font-medium hover:bg-stone-200 transition-colors">
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={saving || !isBalanced || loadingJournal}
|
|
className="px-6 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
|
>
|
|
{saving && <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>}
|
|
{isEditMode ? '분개 수정' : '분개 저장'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================
|
|
// CardTransactionPicker - 카드내역 선택 모달
|
|
// ============================================
|
|
const CardTransactionPicker = ({ onSelect, onClose }) => {
|
|
const [transactions, setTransactions] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [startDate, setStartDate] = useState(formatKoreanDate(new Date(getKoreanNow().setMonth(getKoreanNow().getMonth() - 1))));
|
|
const [endDate, setEndDate] = useState(formatKoreanDate(getKoreanNow()));
|
|
|
|
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0);
|
|
|
|
const loadTransactions = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({ startDate, endDate, search });
|
|
const res = await fetch(`${API.cardTransactions}?${params}`);
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
setTransactions(data.data || []);
|
|
}
|
|
} catch (err) {
|
|
console.error('카드내역 조회 오류:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { loadTransactions(); }, []);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-[60]" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden">
|
|
<div className="p-4 border-b border-stone-100 flex items-center justify-between">
|
|
<h3 className="text-base font-bold text-stone-900">카드내역 불러오기</h3>
|
|
<button onClick={onClose} className="p-1 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-4 border-b border-stone-100">
|
|
<div className="flex items-center gap-2">
|
|
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="px-2 py-1.5 border border-stone-300 rounded text-sm" />
|
|
<span className="text-stone-400">~</span>
|
|
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="px-2 py-1.5 border border-stone-300 rounded text-sm" />
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="가맹점/승인번호"
|
|
className="flex-1 px-2 py-1.5 border border-stone-300 rounded text-sm"
|
|
/>
|
|
<button onClick={loadTransactions} className="px-3 py-1.5 bg-violet-600 text-white rounded text-sm hover:bg-violet-700">조회</button>
|
|
</div>
|
|
</div>
|
|
{/* 목록 */}
|
|
<div className="overflow-y-auto max-h-[50vh]">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-10">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-violet-600"></div>
|
|
<span className="ml-2 text-stone-500 text-sm">조회 중...</span>
|
|
</div>
|
|
) : transactions.length === 0 ? (
|
|
<div className="py-10 text-center text-stone-400 text-sm">카드내역이 없습니다.</div>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-stone-50 sticky top-0">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-stone-600">날짜</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-stone-600">가맹점</th>
|
|
<th className="px-3 py-2 text-right text-xs font-medium text-stone-600">금액</th>
|
|
<th className="px-3 py-2 text-center text-xs font-medium text-stone-600">승인번호</th>
|
|
<th className="px-3 py-2 text-center text-xs font-medium text-stone-600">선택</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{transactions.map((t) => (
|
|
<tr key={t.id} className="border-b border-stone-100 hover:bg-violet-50 cursor-pointer" onClick={() => onSelect(t)}>
|
|
<td className="px-3 py-2 text-xs text-stone-700 whitespace-nowrap">{t.useDate}</td>
|
|
<td className="px-3 py-2 text-xs text-stone-700 truncate max-w-[200px]">{t.merchantName}</td>
|
|
<td className="px-3 py-2 text-xs text-right font-medium text-stone-900">{t.approvalAmountFormatted}</td>
|
|
<td className="px-3 py-2 text-xs text-center text-stone-500 font-mono">{t.approvalNum}</td>
|
|
<td className="px-3 py-2 text-center">
|
|
<button className="px-2 py-1 bg-violet-100 text-violet-700 rounded text-xs hover:bg-violet-200">선택</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('hometax-root'));
|
|
root.render(<App />);
|
|
</script>
|
|
@endpush
|