Files
sam-manage/resources/views/finance/receivables.blade.php
김보곤 5f0ef5dbb9 refactor: [receivables] 수동관리 탭 제거
- ManualTab 컴포넌트 및 관련 유틸 함수 삭제
- 미사용 아이콘 선언 정리
- 외상매출금 원장 / 거래처별 요약 2탭 구조로 변경
2026-02-23 15:07:26 +09:00

434 lines
25 KiB
PHP

@extends('layouts.app')
@section('title', '미수금 관리')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="receivables-root"></div>
@endsection
@push('scripts')
@include('partials.react-cdn')
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
const _def=((n)=>{const a={'check-circle':'CircleCheck','alert-circle':'CircleAlert','alert-triangle':'TriangleAlert','clipboard-check':'ClipboardCheck'};if(a[n]&&lucide[a[n]])return lucide[a[n]];const p=n.split('-').map(w=>w.charAt(0).toUpperCase()+w.slice(1)).join('');return lucide[p]||(lucide.icons&&lucide.icons[n])||null;})(name);
if (ref.current && _def) {
ref.current.innerHTML = '';
const svg = lucide.createElement(_def);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const Receipt = createIcon('receipt');
const Search = createIcon('search');
const Download = createIcon('download');
const Clock = createIcon('clock');
const BookOpen = createIcon('book-open');
const Building = createIcon('building-2');
const TrendingUp = createIcon('trending-up');
const TrendingDown = createIcon('trending-down');
// ==================== 포맷 유틸 ====================
const formatCurrency = (num) => num ? Number(num).toLocaleString() : '0';
// ==================== 로딩 스피너 ====================
function LoadingSpinner() {
return (
<tr><td colSpan="10" className="px-6 py-12 text-center text-gray-400">
<div className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
불러오는 ...
</div>
</td></tr>
);
}
// ==================== 탭 1: 외상매출금 원장 ====================
function LedgerTab({ initialPartner = '' }) {
const [items, setItems] = useState([]);
const [stats, setStats] = useState({ totalDebit: 0, totalCredit: 0, balance: 0, partnerCount: 0 });
const [loading, setLoading] = useState(true);
const today = new Date().toISOString().split('T')[0];
const firstDayOfMonth = today.substring(0, 8) + '01';
const [startDate, setStartDate] = useState(firstDayOfMonth);
const [endDate, setEndDate] = useState(today);
const [partner, setPartner] = useState(initialPartner);
const [source, setSource] = useState('all');
const fetchLedger = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (startDate) params.append('startDate', startDate);
if (endDate) params.append('endDate', endDate);
if (partner) params.append('partner', partner);
if (source !== 'all') params.append('source', source);
const res = await fetch(`/finance/receivables/ledger?${params}`);
const data = await res.json();
if (data.success) {
setItems(data.data);
setStats(data.stats);
}
} catch (err) {
console.error('원장 조회 실패:', err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchLedger(); }, []);
const handleSearch = (e) => {
e.preventDefault();
fetchLedger();
};
const handleDownload = () => {
const rows = [
['외상매출금 원장', `${startDate} ~ ${endDate}`],
[],
['일자', '출처', '참조번호', '거래처', '적요', '차변(발생)', '대변(회수)', '잔액'],
...items.map(item => [item.date, item.sourceLabel, item.refNo, item.tradingPartnerName, item.description, item.debitAmount, item.creditAmount, item.balance]),
[],
['', '', '', '', '합계', stats.totalDebit, stats.totalCredit, stats.balance]
];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `외상매출금원장_${startDate}_${endDate}.csv`;
link.click();
};
return (
<div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">발생액 (차변)</span><TrendingUp className="w-5 h-5 text-blue-400" /></div>
<p className="text-xl font-bold text-blue-600">{formatCurrency(stats.totalDebit)}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">회수액 (대변)</span><TrendingDown className="w-5 h-5 text-emerald-400" /></div>
<p className="text-xl font-bold text-emerald-600">{formatCurrency(stats.totalCredit)}</p>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-5 bg-amber-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">미수잔액</span><Clock className="w-5 h-5 text-amber-500" /></div>
<p className="text-xl font-bold text-amber-600">{formatCurrency(stats.balance)}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">거래처 </span><Building className="w-5 h-5 text-gray-400" /></div>
<p className="text-xl font-bold text-gray-900">{stats.partnerCount}</p>
</div>
</div>
{/* 필터 */}
<form onSubmit={handleSearch} className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="flex flex-wrap items-end gap-4">
<div style={{flex: '0 0 auto'}}>
<label className="block text-xs font-medium text-gray-500 mb-1">시작일</label>
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
<div style={{flex: '0 0 auto'}}>
<label className="block text-xs font-medium text-gray-500 mb-1">종료일</label>
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
<div style={{flex: '1 1 180px', maxWidth: '280px'}}>
<label className="block text-xs font-medium text-gray-500 mb-1">거래처</label>
<input type="text" value={partner} onChange={(e) => setPartner(e.target.value)} placeholder="거래처명 검색" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
<div style={{flex: '0 0 auto'}}>
<label className="block text-xs font-medium text-gray-500 mb-1">출처</label>
<select value={source} onChange={(e) => setSource(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="all">전체</option>
<option value="hometax">홈택스</option>
<option value="journal">일반전표</option>
</select>
</div>
<div style={{flex: '0 0 auto'}} className="flex gap-2">
<button type="submit" className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
<Search className="w-4 h-4" /><span>조회</span>
</button>
<button type="button" onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 border border-gray-300 rounded-lg text-sm">
<Download className="w-4 h-4" /><span>Excel</span>
</button>
</div>
</div>
</form>
{/* 테이블 */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">일자</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">출처</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">참조번호</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">거래처</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">적요</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">차변(발생)</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">대변(회수)</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">잔액</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? <LoadingSpinner /> : items.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">
외상매출금 데이터가 없습니다.
<p className="text-xs mt-1">홈택스 매출세금계산서 분개 또는 일반전표에서 계정코드 108(외상매출금) 사용된 데이터를 표시합니다.</p>
</td></tr>
) : items.map((item, idx) => (
<tr key={idx} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">{item.date}</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-1 rounded text-xs font-medium ${item.source === 'hometax' ? 'bg-indigo-100 text-indigo-700' : 'bg-gray-100 text-gray-700'}`}>
{item.source === 'hometax' ? '홈택스' : '전표'}
</span>
</td>
<td className="px-4 py-3 text-xs text-gray-500 font-mono" style={{maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}} title={item.refNo}>{item.refNo}</td>
<td className="px-4 py-3 text-sm font-medium text-gray-900">{item.tradingPartnerName}</td>
<td className="px-4 py-3 text-sm text-gray-600">{item.description}</td>
<td className="px-4 py-3 text-sm text-right font-medium text-blue-600">{item.debitAmount > 0 ? formatCurrency(item.debitAmount) : ''}</td>
<td className="px-4 py-3 text-sm text-right font-medium text-emerald-600">{item.creditAmount > 0 ? formatCurrency(item.creditAmount) : ''}</td>
<td className="px-4 py-3 text-sm text-right font-bold text-gray-900">{formatCurrency(item.balance)}</td>
</tr>
))}
</tbody>
{!loading && items.length > 0 && (
<tfoot className="bg-gray-50 border-t-2 border-gray-300">
<tr>
<td colSpan="5" className="px-4 py-3 text-sm font-bold text-gray-700 text-right">합계</td>
<td className="px-4 py-3 text-sm text-right font-bold text-blue-700">{formatCurrency(stats.totalDebit)}</td>
<td className="px-4 py-3 text-sm text-right font-bold text-emerald-700">{formatCurrency(stats.totalCredit)}</td>
<td className="px-4 py-3 text-sm text-right font-bold text-amber-700">{formatCurrency(stats.balance)}</td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
</div>
);
}
// ==================== 탭 2: 거래처별 요약 ====================
function SummaryTab({ onSelectPartner }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const today = new Date().toISOString().split('T')[0];
const firstDayOfMonth = today.substring(0, 8) + '01';
const [startDate, setStartDate] = useState(firstDayOfMonth);
const [endDate, setEndDate] = useState(today);
const fetchSummary = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (startDate) params.append('startDate', startDate);
if (endDate) params.append('endDate', endDate);
const res = await fetch(`/finance/receivables/summary?${params}`);
const data = await res.json();
if (data.success) {
setItems(data.data);
}
} catch (err) {
console.error('요약 조회 실패:', err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchSummary(); }, []);
const handleSearch = (e) => {
e.preventDefault();
fetchSummary();
};
const totalDebit = items.reduce((sum, item) => sum + item.totalDebit, 0);
const totalCredit = items.reduce((sum, item) => sum + item.totalCredit, 0);
const totalBalance = items.reduce((sum, item) => sum + item.balance, 0);
const handleDownload = () => {
const rows = [
['거래처별 외상매출금 요약', `${startDate} ~ ${endDate}`],
[],
['거래처', '발생액(차변)', '회수액(대변)', '미수잔액', '최종거래일', '거래건수'],
...items.map(item => [item.tradingPartnerName, item.totalDebit, item.totalCredit, item.balance, item.lastTransactionDate, item.transactionCount]),
[],
['합계', totalDebit, totalCredit, totalBalance, '', items.reduce((sum, item) => sum + item.transactionCount, 0)]
];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `거래처별미수금요약_${startDate}_${endDate}.csv`;
link.click();
};
return (
<div>
{/* 필터 */}
<form onSubmit={handleSearch} className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="flex flex-wrap items-end gap-4">
<div style={{flex: '0 0 auto'}}>
<label className="block text-xs font-medium text-gray-500 mb-1">시작일</label>
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
<div style={{flex: '0 0 auto'}}>
<label className="block text-xs font-medium text-gray-500 mb-1">종료일</label>
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
<div style={{flex: '0 0 auto'}} className="flex gap-2">
<button type="submit" className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
<Search className="w-4 h-4" /><span>조회</span>
</button>
<button type="button" onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 border border-gray-300 rounded-lg text-sm">
<Download className="w-4 h-4" /><span>Excel</span>
</button>
</div>
</div>
</form>
{/* 테이블 */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">거래처</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">발생액(차변)</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">회수액(대변)</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">미수잔액</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">최종거래일</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">거래건수</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? <LoadingSpinner /> : items.length === 0 ? (
<tr><td colSpan="6" className="px-6 py-12 text-center text-gray-400">
거래처별 외상매출금 데이터가 없습니다.
</td></tr>
) : items.map((item, idx) => (
<tr key={idx}
onClick={() => onSelectPartner(item.tradingPartnerName)}
className="hover:bg-blue-50 cursor-pointer">
<td className="px-6 py-4">
<p className="text-sm font-medium text-gray-900">{item.tradingPartnerName}</p>
</td>
<td className="px-6 py-4 text-sm text-right font-medium text-blue-600">{formatCurrency(item.totalDebit)}</td>
<td className="px-6 py-4 text-sm text-right font-medium text-emerald-600">{formatCurrency(item.totalCredit)}</td>
<td className="px-6 py-4 text-sm text-right font-bold">
<span className={item.balance > 0 ? 'text-amber-600' : item.balance < 0 ? 'text-red-600' : 'text-gray-400'}>
{formatCurrency(item.balance)}
</span>
</td>
<td className="px-6 py-4 text-sm text-center text-gray-600">{item.lastTransactionDate || '-'}</td>
<td className="px-6 py-4 text-sm text-center text-gray-600">{item.transactionCount}</td>
</tr>
))}
</tbody>
{!loading && items.length > 0 && (
<tfoot className="bg-gray-50 border-t-2 border-gray-300">
<tr>
<td className="px-6 py-3 text-sm font-bold text-gray-700">합계 ({items.length} 거래처)</td>
<td className="px-6 py-3 text-sm text-right font-bold text-blue-700">{formatCurrency(totalDebit)}</td>
<td className="px-6 py-3 text-sm text-right font-bold text-emerald-700">{formatCurrency(totalCredit)}</td>
<td className="px-6 py-3 text-sm text-right font-bold text-amber-700">{formatCurrency(totalBalance)}</td>
<td colSpan="2"></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
<p className="text-xs text-gray-400 mt-3">* 행을 클릭하면 해당 거래처의 원장 상세를 조회합니다.</p>
</div>
);
}
// ==================== 메인 컴포넌트 ====================
function ReceivablesManagement() {
const [activeTab, setActiveTab] = useState('ledger');
const [ledgerPartnerFilter, setLedgerPartnerFilter] = useState('');
const handleSelectPartner = (partnerName) => {
setLedgerPartnerFilter(partnerName);
setActiveTab('ledger');
};
const tabs = [
{ id: 'ledger', label: '외상매출금 원장', icon: BookOpen },
{ id: 'summary', label: '거래처별 요약', icon: Building },
];
return (
<div className="bg-gray-50 min-h-screen">
{/* 헤더 */}
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-blue-100 rounded-xl"><Receipt className="w-6 h-6 text-blue-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">미수금 관리</h1><p className="text-sm text-gray-500">Accounts Receivable</p></div>
</div>
</div>
{/* 탭 네비게이션 */}
<div className="px-6 flex gap-1">
{tabs.map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => { setActiveTab(tab.id); if (tab.id !== 'ledger') setLedgerPartnerFilter(''); }}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
</header>
{/* 탭 콘텐츠 */}
{activeTab === 'ledger' && <LedgerTab key={ledgerPartnerFilter} initialPartner={ledgerPartnerFilter} />}
{activeTab === 'summary' && <SummaryTab onSelectPartner={handleSelectPartner} />}
</div>
);
}
const rootElement = document.getElementById('receivables-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<ReceivablesManagement />); }
</script>
@endverbatim
@endpush