- 일반전표 상세 행에 삭제 버튼(휴지통 아이콘) 추가
- DELETE /finance/payables/journal-entry/{id} API 추가
- journal_entry_id 필드를 프론트에 전달하도록 쿼리 수정
- 삭제 후 데이터 자동 새로고침
629 lines
38 KiB
PHP
629 lines
38 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="payables-root"></div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
@include('partials.react-cdn')
|
|
<script src="https://unpkg.com/lucide@0.469.0?v={{ time() }}"></script>
|
|
@verbatim
|
|
<script type="text/babel">
|
|
const { useState, useRef, useEffect, useCallback } = React;
|
|
|
|
const createIcon = (name) => {
|
|
const _def=((n)=>{const a={'check-circle':'CircleCheck','alert-circle':'CircleAlert','alert-triangle':'TriangleAlert','clipboard-check':'ClipboardCheck','arrow-up-circle':'CircleArrowUp','arrow-down-circle':'CircleArrowDown'};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);
|
|
const _c = s => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
return ({ className = "w-5 h-5", ...props }) => {
|
|
if (!_def) return null;
|
|
const [, attrs, children = []] = _def;
|
|
const sp = { className };
|
|
Object.entries(attrs).forEach(([k, v]) => { sp[_c(k)] = v; });
|
|
Object.assign(sp, props);
|
|
return React.createElement("svg", sp, ...children.map(([tag, ca], i) => {
|
|
const cp = { key: i };
|
|
if (ca) Object.entries(ca).forEach(([k, v]) => { cp[_c(k)] = v; });
|
|
return React.createElement(tag, cp);
|
|
}));
|
|
};
|
|
};
|
|
|
|
const CreditCard = createIcon('credit-card');
|
|
const Search = createIcon('search');
|
|
const Banknote = createIcon('banknote');
|
|
const RefreshCw = createIcon('refresh-cw');
|
|
const FileText = createIcon('file-text');
|
|
const ChevronDown = createIcon('chevron-down');
|
|
const ChevronUp = createIcon('chevron-up');
|
|
const Building = createIcon('building');
|
|
const TrendingUp = createIcon('trending-up');
|
|
const TrendingDown = createIcon('trending-down');
|
|
const Layers = createIcon('layers');
|
|
const Trash2 = createIcon('trash-2');
|
|
|
|
const formatCurrency = (num) => num ? Number(num).toLocaleString() : '0';
|
|
const formatInputCurrency = (value) => {
|
|
if (!value && value !== 0) return '';
|
|
const num = String(value).replace(/[^\d]/g, '');
|
|
return num ? Number(num).toLocaleString() : '';
|
|
};
|
|
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
|
|
|
|
// =============================================
|
|
// 통합 잔액 탭
|
|
// =============================================
|
|
function IntegratedTab({ startDate, endDate, account, vendorSearch }) {
|
|
const [data, setData] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [expandedVendor, setExpandedVendor] = useState(null);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({ startDate, endDate, account, vendor: vendorSearch });
|
|
const res = await fetch(`/finance/payables/integrated?${params}`);
|
|
const json = await res.json();
|
|
if (json.success) setData(json.data);
|
|
} catch (err) {
|
|
console.error('통합 잔액 조회 실패:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [startDate, endDate, account, vendorSearch]);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
const handleDeleteJournal = async (journalEntryId, entryNo) => {
|
|
if (!confirm(`전표 ${entryNo}을(를) 삭제하시겠습니까?`)) return;
|
|
try {
|
|
const res = await fetch(`/finance/payables/journal-entry/${journalEntryId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
|
|
});
|
|
const json = await res.json();
|
|
if (json.success) {
|
|
alert(json.message);
|
|
fetchData();
|
|
} else {
|
|
alert('삭제 실패: ' + (json.message || ''));
|
|
}
|
|
} catch (err) {
|
|
alert('삭제 오류: ' + err.message);
|
|
}
|
|
};
|
|
|
|
if (loading) return <LoadingSpinner />;
|
|
if (!data) return <EmptyState message="데이터를 불러올 수 없습니다." />;
|
|
|
|
const { summary, byVendor, hometaxDetails, journalDetails } = data;
|
|
|
|
return (
|
|
<div>
|
|
{/* 통계 카드 */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
|
{summary.totalPriorBalance !== 0 && (
|
|
<div className="bg-white rounded-xl border border-amber-200 p-5">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm text-amber-700">이월잔액</span>
|
|
<FileText className="w-5 h-5 text-amber-500" />
|
|
</div>
|
|
<p className="text-2xl font-bold text-amber-600">{formatCurrency(summary.totalPriorBalance)}원</p>
|
|
<p className="text-xs text-gray-400 mt-1">전월까지 누적 잔액</p>
|
|
</div>
|
|
)}
|
|
<div className="bg-white rounded-xl border border-blue-200 p-5">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm text-blue-700">당기발생</span>
|
|
<TrendingUp className="w-5 h-5 text-blue-500" />
|
|
</div>
|
|
<p className="text-2xl font-bold text-blue-600">{formatCurrency(summary.totalOccurred)}원</p>
|
|
<p className="text-xs text-gray-400 mt-1">홈택스 {summary.hometaxCount}건 + 전표대변</p>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-emerald-200 p-5">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm text-emerald-700">당기상계</span>
|
|
<TrendingDown className="w-5 h-5 text-emerald-500" />
|
|
</div>
|
|
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(summary.totalOffset)}원</p>
|
|
<p className="text-xs text-gray-400 mt-1">일반전표 차변 {summary.journalCount}건</p>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-rose-200 p-5">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm text-rose-700">미지급잔액</span>
|
|
<CreditCard className="w-5 h-5 text-rose-500" />
|
|
</div>
|
|
<p className={`text-2xl font-bold ${summary.totalBalance >= 0 ? 'text-rose-600' : 'text-blue-600'}`}>
|
|
{summary.totalBalance < 0 && '-'}{formatCurrency(Math.abs(summary.totalBalance))}원
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-1">이월 + 발생 - 상계</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-600">거래처수</span>
|
|
<Building className="w-5 h-5 text-gray-400" />
|
|
</div>
|
|
<p className="text-2xl font-bold text-gray-900">{summary.vendorCount}곳</p>
|
|
<p className="text-xs text-gray-400 mt-1">미지급금 발생 거래처</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 거래처별 잔액 목록 */}
|
|
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
|
<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-center 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-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">
|
|
{byVendor.length === 0 ? (
|
|
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
|
|
) : byVendor.map((v, idx) => {
|
|
const vendorKey = `${v.vendorName}|${v.accountCode}`;
|
|
const isExpanded = expandedVendor === vendorKey;
|
|
const vendorHometax = hometaxDetails.filter(h => h.trading_partner_name === v.vendorName && h.account_code === v.accountCode);
|
|
const vendorJournals = journalDetails.filter(j => j.trading_partner_name === v.vendorName && j.account_code === v.accountCode);
|
|
|
|
return (
|
|
<React.Fragment key={idx}>
|
|
<tr className="hover:bg-gray-50 cursor-pointer" onClick={() => setExpandedVendor(isExpanded ? null : vendorKey)}>
|
|
<td className="px-6 py-4">
|
|
<p className="text-sm font-medium text-gray-900">{v.vendorName || '(거래처 미지정)'}</p>
|
|
</td>
|
|
<td className="px-6 py-4 text-center">
|
|
<span className={`px-2 py-1 rounded text-xs font-medium ${v.accountCode === '204' ? 'bg-purple-100 text-purple-700' : 'bg-indigo-100 text-indigo-700'}`}>
|
|
{v.accountCode === '204' ? '미지급금' : '미지급비용'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm font-medium text-right">
|
|
<span className={v.priorBalance > 0 ? 'text-amber-600' : v.priorBalance < 0 ? 'text-blue-600' : 'text-gray-300'}>
|
|
{v.priorBalance !== 0 ? `${formatCurrency(v.priorBalance)}원` : '-'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm font-medium text-right text-blue-600">{formatCurrency(v.occurred)}원</td>
|
|
<td className="px-6 py-4 text-sm font-medium text-right text-emerald-600">{formatCurrency(v.offset)}원</td>
|
|
<td className="px-6 py-4 text-sm font-bold text-right">
|
|
<span className={v.balance > 0 ? 'text-rose-600' : v.balance < 0 ? 'text-blue-600' : 'text-gray-400'}>
|
|
{v.balance < 0 && '-'}{formatCurrency(Math.abs(v.balance))}원
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-center text-xs text-gray-500">
|
|
홈택스 {v.hometaxCount} / 전표 {v.journalDebitCount + v.journalCreditCount}
|
|
</td>
|
|
<td className="px-6 py-4 text-center">
|
|
{isExpanded ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
|
|
</td>
|
|
</tr>
|
|
{isExpanded && (
|
|
<tr>
|
|
<td colSpan="7" className="px-6 py-4 bg-gray-50">
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{/* 홈택스 발생 내역 */}
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-blue-700 mb-2 flex items-center gap-1">
|
|
<FileText className="w-3 h-3" /> 홈택스 발생 ({vendorHometax.length}건)
|
|
</h4>
|
|
{vendorHometax.length === 0 ? (
|
|
<p className="text-xs text-gray-400">내역 없음</p>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{vendorHometax.map((h, i) => (
|
|
<div key={i} className="flex items-center justify-between text-xs bg-white rounded px-3 py-2 border border-gray-100">
|
|
<div>
|
|
<span className="text-gray-500">{h.write_date?.substring(0, 10)}</span>
|
|
{h.description && <span className="text-gray-600 ml-2">{h.description}</span>}
|
|
</div>
|
|
<span className="font-medium text-blue-600">{formatCurrency(h.credit_amount)}원</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* 일반전표 상계 내역 */}
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-emerald-700 mb-2 flex items-center gap-1">
|
|
<Layers className="w-3 h-3" /> 일반전표 ({vendorJournals.length}건)
|
|
</h4>
|
|
{vendorJournals.length === 0 ? (
|
|
<p className="text-xs text-gray-400">내역 없음</p>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{vendorJournals.map((j, i) => (
|
|
<div key={i} className="flex items-center justify-between text-xs bg-white rounded px-3 py-2 border border-gray-100">
|
|
<div>
|
|
<span className="text-gray-500">{j.entry_date?.substring(0, 10)}</span>
|
|
<span className="text-gray-400 ml-1">{j.entry_no}</span>
|
|
<span className={`ml-2 px-1 rounded ${j.dc_type === 'debit' ? 'bg-emerald-50 text-emerald-600' : 'bg-blue-50 text-blue-600'}`}>
|
|
{j.dc_type === 'debit' ? '차변(상계)' : '대변(발생)'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`font-medium ${j.dc_type === 'debit' ? 'text-emerald-600' : 'text-blue-600'}`}>
|
|
{formatCurrency(j.dc_type === 'debit' ? j.debit_amount : j.credit_amount)}원
|
|
</span>
|
|
{j.journal_entry_id && (
|
|
<button onClick={(e) => { e.stopPropagation(); handleDeleteJournal(j.journal_entry_id, j.entry_no); }}
|
|
className="p-1 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded transition-colors" title="전표 삭제">
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// =============================================
|
|
// 홈택스 발생 탭
|
|
// =============================================
|
|
function HometaxTab({ startDate, endDate, account }) {
|
|
const [items, setItems] = useState([]);
|
|
const [summary, setSummary] = useState({ totalCredit: 0, count: 0 });
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({ startDate, endDate, account });
|
|
const res = await fetch(`/finance/payables/hometax?${params}`);
|
|
const json = await res.json();
|
|
if (json.success) {
|
|
setItems(json.data);
|
|
setSummary(json.summary);
|
|
}
|
|
} catch (err) {
|
|
console.error('홈택스 데이터 조회 실패:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [startDate, endDate, account]);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
if (loading) return <LoadingSpinner />;
|
|
|
|
return (
|
|
<div>
|
|
<div className="bg-white rounded-xl border border-blue-200 p-5 mb-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-blue-700 mb-1">홈택스 매입세금계산서 미지급금 발생 합계</p>
|
|
<p className="text-2xl font-bold text-blue-600">{formatCurrency(summary.totalCredit)}원</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-sm text-gray-500">총 건수</p>
|
|
<p className="text-2xl font-bold text-gray-700">{summary.count}건</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
|
<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-left 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-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-left text-xs font-semibold text-gray-600">적요</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{items.length === 0 ? (
|
|
<tr><td colSpan="7" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
|
|
) : items.map((item, idx) => (
|
|
<tr key={idx} className="hover:bg-gray-50">
|
|
<td className="px-6 py-3 text-sm text-gray-600">{item.write_date?.substring(0, 10)}</td>
|
|
<td className="px-6 py-3 text-sm font-medium text-gray-900">{item.trading_partner_name || '-'}</td>
|
|
<td className="px-6 py-3 text-center">
|
|
<span className={`px-2 py-1 rounded text-xs font-medium ${item.account_code === '204' ? 'bg-purple-100 text-purple-700' : 'bg-indigo-100 text-indigo-700'}`}>
|
|
{item.account_name}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-3 text-sm text-right text-gray-600">{formatCurrency(item.supply_amount)}원</td>
|
|
<td className="px-6 py-3 text-sm text-right text-gray-600">{formatCurrency(item.tax_amount)}원</td>
|
|
<td className="px-6 py-3 text-sm font-medium text-right text-blue-600">{formatCurrency(item.credit_amount)}원</td>
|
|
<td className="px-6 py-3 text-sm text-gray-500 truncate" style={{maxWidth: '200px'}}>{item.description || '-'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// =============================================
|
|
// 일반전표 상계 탭
|
|
// =============================================
|
|
function JournalTab({ startDate, endDate, account }) {
|
|
const [items, setItems] = useState([]);
|
|
const [summary, setSummary] = useState({ totalDebit: 0, totalCredit: 0, count: 0 });
|
|
const [loading, setLoading] = useState(true);
|
|
const [dcFilter, setDcFilter] = useState('all');
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({ startDate, endDate, account, dcType: dcFilter });
|
|
const res = await fetch(`/finance/payables/journals?${params}`);
|
|
const json = await res.json();
|
|
if (json.success) {
|
|
setItems(json.data);
|
|
setSummary(json.summary);
|
|
}
|
|
} catch (err) {
|
|
console.error('일반전표 데이터 조회 실패:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [startDate, endDate, account, dcFilter]);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
if (loading) return <LoadingSpinner />;
|
|
|
|
return (
|
|
<div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
|
<div className="bg-white rounded-xl border border-emerald-200 p-5">
|
|
<p className="text-sm text-emerald-700 mb-1">차변 합계 (상계/지급)</p>
|
|
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(summary.totalDebit)}원</p>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-blue-200 p-5">
|
|
<p className="text-sm text-blue-700 mb-1">대변 합계 (전표 발생)</p>
|
|
<p className="text-2xl font-bold text-blue-600">{formatCurrency(summary.totalCredit)}원</p>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
|
<p className="text-sm text-gray-600 mb-1">총 건수</p>
|
|
<p className="text-2xl font-bold text-gray-700">{summary.count}건</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 차변/대변 필터 */}
|
|
<div className="flex gap-2 mb-4">
|
|
{['all', 'debit', 'credit'].map(type => (
|
|
<button key={type} onClick={() => setDcFilter(type)}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium ${dcFilter === type
|
|
? (type === 'debit' ? 'bg-emerald-600 text-white' : type === 'credit' ? 'bg-blue-600 text-white' : 'bg-gray-800 text-white')
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}>
|
|
{type === 'all' ? '전체' : type === 'debit' ? '차변 (상계)' : '대변 (발생)'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
|
<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-left text-xs font-semibold text-gray-600">전표번호</th>
|
|
<th className="px-6 py-3 text-left 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>
|
|
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">금액</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">적요</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{items.length === 0 ? (
|
|
<tr><td colSpan="7" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
|
|
) : items.map((item, idx) => (
|
|
<tr key={idx} className="hover:bg-gray-50">
|
|
<td className="px-6 py-3 text-sm text-gray-600">{item.entry_date?.substring(0, 10)}</td>
|
|
<td className="px-6 py-3 text-sm text-gray-500">{item.entry_no}</td>
|
|
<td className="px-6 py-3 text-sm font-medium text-gray-900">{item.trading_partner_name || '-'}</td>
|
|
<td className="px-6 py-3 text-center">
|
|
<span className={`px-2 py-1 rounded text-xs font-medium ${item.account_code === '204' ? 'bg-purple-100 text-purple-700' : 'bg-indigo-100 text-indigo-700'}`}>
|
|
{item.account_name}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-3 text-center">
|
|
<span className={`px-2 py-1 rounded text-xs font-medium ${item.dc_type === 'debit' ? 'bg-emerald-100 text-emerald-700' : 'bg-blue-100 text-blue-700'}`}>
|
|
{item.dc_type === 'debit' ? '차변' : '대변'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-3 text-sm font-medium text-right">
|
|
<span className={item.dc_type === 'debit' ? 'text-emerald-600' : 'text-blue-600'}>
|
|
{formatCurrency(item.dc_type === 'debit' ? item.debit_amount : item.credit_amount)}원
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-3 text-sm text-gray-500 truncate" style={{maxWidth: '200px'}}>{item.description || '-'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// =============================================
|
|
// 공통 컴포넌트
|
|
// =============================================
|
|
function LoadingSpinner() {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<svg className="animate-spin h-5 w-5 text-gray-400 mr-2" 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>
|
|
<span className="text-gray-400">불러오는 중...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ message }) {
|
|
return <div className="text-center py-12 text-gray-400">{message}</div>;
|
|
}
|
|
|
|
// =============================================
|
|
// 메인 컴포넌트
|
|
// =============================================
|
|
const STORAGE_KEY = 'payables_date_range';
|
|
|
|
function getStoredDates() {
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
if (stored) {
|
|
const { startDate, endDate } = JSON.parse(stored);
|
|
if (startDate && endDate) return { startDate, endDate };
|
|
}
|
|
} catch (e) {}
|
|
const y = new Date().getFullYear();
|
|
return { startDate: `${y}-01-01`, endDate: `${y}-12-31` };
|
|
}
|
|
|
|
function PayablesManagement() {
|
|
const [activeTab, setActiveTab] = useState('integrated');
|
|
const stored = getStoredDates();
|
|
const [startDate, setStartDate] = useState(stored.startDate);
|
|
const [endDate, setEndDate] = useState(stored.endDate);
|
|
const [account, setAccount] = useState('all');
|
|
const [vendorSearch, setVendorSearch] = useState('');
|
|
|
|
// localStorage에 기간 저장
|
|
useEffect(() => {
|
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ startDate, endDate })); } catch (e) {}
|
|
}, [startDate, endDate]);
|
|
|
|
// 월 빠른 선택
|
|
const setMonthRange = (offset) => {
|
|
const now = new Date();
|
|
const target = new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
|
const start = target.toISOString().substring(0, 10);
|
|
const end = offset === 0
|
|
? now.toISOString().substring(0, 10)
|
|
: new Date(target.getFullYear(), target.getMonth() + 1, 0).toISOString().substring(0, 10);
|
|
setStartDate(start);
|
|
setEndDate(end);
|
|
};
|
|
|
|
// 올해 전체
|
|
const setYearRange = () => {
|
|
const y = new Date().getFullYear();
|
|
setStartDate(`${y}-01-01`);
|
|
setEndDate(`${y}-12-31`);
|
|
};
|
|
|
|
const tabs = [
|
|
{ id: 'integrated', label: '통합 잔액', icon: Layers, color: 'rose' },
|
|
{ id: 'hometax', label: '홈택스 발생', icon: FileText, color: 'blue' },
|
|
{ id: 'journal', label: '일반전표 상계', icon: Banknote, color: 'emerald' },
|
|
];
|
|
|
|
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-rose-100 rounded-xl"><CreditCard className="w-6 h-6 text-rose-600" /></div>
|
|
<div><h1 className="text-xl font-bold text-gray-900">미지급금 관리</h1><p className="text-sm text-gray-500">Accounts Payable</p></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 탭 네비게이션 */}
|
|
<div className="px-6 flex gap-1 -mb-px">
|
|
{tabs.map(tab => {
|
|
const Icon = tab.icon;
|
|
const isActive = activeTab === tab.id;
|
|
return (
|
|
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
|
isActive
|
|
? `border-${tab.color}-600 text-${tab.color}-600`
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
style={isActive ? { borderBottomColor: tab.color === 'rose' ? '#e11d48' : tab.color === 'blue' ? '#2563eb' : tab.color === 'emerald' ? '#059669' : '#4b5563', color: tab.color === 'rose' ? '#e11d48' : tab.color === 'blue' ? '#2563eb' : tab.color === 'emerald' ? '#059669' : '#4b5563' } : {}}>
|
|
<Icon className="w-4 h-4" />
|
|
{tab.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</header>
|
|
|
|
{/* 공통 필터 */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<label className="text-sm text-gray-600 whitespace-nowrap">기간</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" />
|
|
<span className="text-gray-400">~</span>
|
|
<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 className="flex items-center gap-1.5 shrink-0">
|
|
<button onClick={() => setMonthRange(0)} className="px-2.5 py-1.5 text-xs bg-rose-50 text-rose-600 rounded-lg hover:bg-rose-100 font-medium">이번달</button>
|
|
<button onClick={() => setMonthRange(-1)} className="px-2.5 py-1.5 text-xs bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 font-medium">지난달</button>
|
|
<button onClick={() => setMonthRange(-2)} className="px-2.5 py-1.5 text-xs bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 font-medium">D-2월</button>
|
|
<button onClick={() => setMonthRange(-3)} className="px-2.5 py-1.5 text-xs bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 font-medium">D-3월</button>
|
|
<button onClick={setYearRange} className="px-2.5 py-1.5 text-xs bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 font-medium">올해전체</button>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<label className="text-sm text-gray-600 whitespace-nowrap">계정</label>
|
|
<select value={account} onChange={(e) => setAccount(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
|
<option value="all">전체 (204+205)</option>
|
|
<option value="204">204 미지급금</option>
|
|
<option value="205">205 미지급비용</option>
|
|
</select>
|
|
</div>
|
|
{activeTab === 'integrated' && (
|
|
<div className="relative" style={{flex: '1 1 200px', maxWidth: '300px'}}>
|
|
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
|
<input type="text" placeholder="거래처 검색..." value={vendorSearch} onChange={(e) => setVendorSearch(e.target.value)} className="w-full pl-9 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-rose-500" />
|
|
</div>
|
|
)}
|
|
<button onClick={() => { setYearRange(); setAccount('all'); setVendorSearch(''); }}
|
|
className="flex items-center gap-2 px-3 py-2 text-gray-600 hover:bg-gray-100 rounded-lg shrink-0">
|
|
<RefreshCw className="w-4 h-4" /><span className="text-sm">초기화</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 탭 컨텐츠 */}
|
|
{activeTab === 'integrated' && <IntegratedTab startDate={startDate} endDate={endDate} account={account} vendorSearch={vendorSearch} />}
|
|
{activeTab === 'hometax' && <HometaxTab startDate={startDate} endDate={endDate} account={account} />}
|
|
{activeTab === 'journal' && <JournalTab startDate={startDate} endDate={endDate} account={account} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const rootElement = document.getElementById('payables-root');
|
|
if (rootElement) { ReactDOM.createRoot(rootElement).render(<PayablesManagement />); }
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|