- 24개 Blade 파일의 수동 SVG 생성 코드를 lucide.createElement(_def)로 통일 - 불필요한 quote-stripping regex(/^"|"$/g) 제거 - Lucide 공식 API 사용으로 SVG viewBox/path 속성 에러 해결
2167 lines
126 KiB
PHP
2167 lines
126 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="journal-entries-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) => ({ 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 Plus = createIcon('plus');
|
|
const Search = createIcon('search');
|
|
const FileText = createIcon('file-text');
|
|
const Trash2 = createIcon('trash-2');
|
|
const Save = createIcon('save');
|
|
const ArrowLeft = createIcon('arrow-left');
|
|
const X = createIcon('x');
|
|
const Edit = createIcon('edit');
|
|
const Edit3 = createIcon('edit-3');
|
|
const CheckCircle = createIcon('check-circle');
|
|
const AlertTriangle = createIcon('alert-triangle');
|
|
const Landmark = createIcon('landmark');
|
|
const ArrowDownCircle = createIcon('arrow-down-circle');
|
|
const ArrowUpCircle = createIcon('arrow-up-circle');
|
|
const RefreshCw = createIcon('refresh-cw');
|
|
const Settings = createIcon('settings');
|
|
const Calendar = createIcon('calendar');
|
|
|
|
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
|
|
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) => {
|
|
const num = String(value).replace(/[^\d]/g, '');
|
|
return num ? parseInt(num, 10) : 0;
|
|
};
|
|
|
|
const notify = (message, type = 'info') => {
|
|
if (typeof window.showToast === 'function') {
|
|
window.showToast(message, type);
|
|
} else {
|
|
alert(message);
|
|
}
|
|
};
|
|
|
|
// 한국 시간대 날짜
|
|
const getKoreanDate = (offset = 0) => {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + offset);
|
|
return d.toLocaleDateString('en-CA', { timeZone: 'Asia/Seoul' });
|
|
};
|
|
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr || dateStr.length < 8) return dateStr;
|
|
const d = dateStr.replace(/[^0-9]/g, '');
|
|
return `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}`;
|
|
};
|
|
|
|
const formatTime = (timeStr) => {
|
|
if (!timeStr || timeStr.length < 4) return timeStr;
|
|
const t = timeStr.replace(/[^0-9]/g, '');
|
|
return `${t.slice(0,2)}:${t.slice(2,4)}`;
|
|
};
|
|
|
|
// ============================================================
|
|
// AccountCodeSelect (emerald 테마)
|
|
// ============================================================
|
|
const AccountCodeSelect = ({ value, onChange, accountCodes, onTab, triggerId }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [highlightIndex, setHighlightIndex] = useState(-1);
|
|
const [dropdownStyle, setDropdownStyle] = useState({});
|
|
const containerRef = useRef(null);
|
|
const triggerRef = useRef(null);
|
|
const dropdownRef = 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) &&
|
|
(!dropdownRef.current || !dropdownRef.current.contains(e.target))) {
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const calcDropdownPos = () => {
|
|
if (!triggerRef.current) return;
|
|
const rect = triggerRef.current.getBoundingClientRect();
|
|
const spaceBelow = window.innerHeight - rect.bottom;
|
|
const openUp = spaceBelow < 260;
|
|
setDropdownStyle({
|
|
position: 'fixed',
|
|
left: rect.left,
|
|
width: Math.max(rect.width, 224),
|
|
zIndex: 9999,
|
|
...(openUp ? { bottom: window.innerHeight - rect.top + 4 } : { top: rect.bottom + 4 }),
|
|
});
|
|
};
|
|
|
|
const handleSelect = (code) => {
|
|
onChange(code.code, code.name);
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
setTimeout(() => triggerRef.current?.focus(), 0);
|
|
};
|
|
const handleClear = (e) => { e.stopPropagation(); onChange('', ''); setSearch(''); };
|
|
|
|
const handleKeyDown = (e) => {
|
|
const maxIndex = Math.min(filteredCodes.length, 50) - 1;
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
if (filteredCodes.length > 0 && highlightIndex >= 0) {
|
|
onChange(filteredCodes[highlightIndex].code, filteredCodes[highlightIndex].name);
|
|
}
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
setTimeout(() => onTab && onTab(), 0);
|
|
} else 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' && 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 ref={triggerRef} id={triggerId} tabIndex={0}
|
|
onClick={() => { if (!isOpen) calcDropdownPos(); setIsOpen(!isOpen); }}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
if (isOpen) { setIsOpen(false); setSearch(''); setHighlightIndex(-1); }
|
|
setTimeout(() => onTab && onTab(), 0);
|
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
if (!isOpen) calcDropdownPos();
|
|
setIsOpen(!isOpen);
|
|
}
|
|
}}
|
|
className={`w-full px-2 py-1.5 text-xs border rounded cursor-pointer flex items-center justify-between gap-1 ${isOpen ? 'border-emerald-500 ring-2 ring-emerald-500' : 'border-stone-200'} 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 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 && ReactDOM.createPortal(
|
|
<div ref={dropdownRef} style={dropdownStyle} className="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 py-1 text-xs border border-stone-200 rounded focus:ring-1 focus:ring-emerald-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-emerald-600 text-white font-semibold' : value === code.code ? 'bg-emerald-100 text-emerald-700' : 'text-stone-700 hover:bg-emerald-50'}`}>
|
|
<span className={`font-mono ${index === highlightIndex ? 'text-white' : 'text-emerald-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>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// AccountCodeSettingsModal (계정과목 설정)
|
|
// ============================================================
|
|
const AccountCodeSettingsModal = ({ isOpen, onClose, onUpdate }) => {
|
|
const [codes, setCodes] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [newCode, setNewCode] = useState('');
|
|
const [newName, setNewName] = useState('');
|
|
const [newCategory, setNewCategory] = useState('');
|
|
const [filter, setFilter] = useState('');
|
|
const [categoryFilter, setCategoryFilter] = useState('');
|
|
|
|
const categories = ['자산', '부채', '자본', '수익', '비용'];
|
|
|
|
useEffect(() => { if (isOpen) loadCodes(); }, [isOpen]);
|
|
|
|
const loadCodes = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch('/finance/journal-entries/account-codes/all');
|
|
const data = await res.json();
|
|
if (data.success) setCodes(data.data || []);
|
|
} catch (err) {
|
|
notify('계정과목 로드 실패', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAdd = async () => {
|
|
if (!newCode.trim() || !newName.trim()) { notify('코드와 이름을 입력해주세요.', 'warning'); return; }
|
|
try {
|
|
const res = await fetch('/finance/journal-entries/account-codes', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
|
body: JSON.stringify({ code: newCode.trim(), name: newName.trim(), category: newCategory || null }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
notify('계정과목이 추가되었습니다.', 'success');
|
|
setNewCode(''); setNewName(''); setNewCategory('');
|
|
loadCodes(); onUpdate();
|
|
} else {
|
|
notify(data.error || '추가 실패', 'error');
|
|
}
|
|
} catch (err) { notify('추가 실패: ' + err.message, 'error'); }
|
|
};
|
|
|
|
const handleToggleActive = async (item) => {
|
|
try {
|
|
const res = await fetch(`/finance/journal-entries/account-codes/${item.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
|
body: JSON.stringify({ is_active: !item.is_active }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) { loadCodes(); onUpdate(); }
|
|
} catch (err) { notify('변경 실패', 'error'); }
|
|
};
|
|
|
|
const handleDelete = async (item) => {
|
|
if (!confirm(`"${item.code} ${item.name}" 계정과목을 삭제하시겠습니까?`)) return;
|
|
try {
|
|
const res = await fetch(`/finance/journal-entries/account-codes/${item.id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'X-CSRF-TOKEN': CSRF_TOKEN },
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) { notify('삭제되었습니다.', 'success'); loadCodes(); onUpdate(); }
|
|
else { notify(data.error || '삭제 실패', 'error'); }
|
|
} catch (err) { notify('삭제 실패: ' + err.message, 'error'); }
|
|
};
|
|
|
|
const filteredCodes = codes.filter(c => {
|
|
const matchText = filter === '' || c.code.includes(filter) || c.name.includes(filter);
|
|
const matchCategory = categoryFilter === '' || c.category === categoryFilter;
|
|
return matchText && matchCategory;
|
|
});
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden">
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stone-200 bg-stone-50">
|
|
<h2 className="text-lg font-bold text-stone-900">계정과목 설정</h2>
|
|
<button onClick={onClose} className="text-stone-400 hover:text-stone-600 text-2xl">×</button>
|
|
</div>
|
|
<div className="px-6 py-4 border-b border-stone-100 bg-emerald-50/50">
|
|
<div className="flex gap-2 items-end">
|
|
<div className="flex-1">
|
|
<label className="block text-xs font-medium text-stone-600 mb-1">코드</label>
|
|
<input type="text" value={newCode} onChange={(e) => setNewCode(e.target.value)} placeholder="예: 101"
|
|
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
</div>
|
|
<div className="flex-[2]">
|
|
<label className="block text-xs font-medium text-stone-600 mb-1">계정과목명</label>
|
|
<input type="text" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="예: 현금"
|
|
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<label className="block text-xs font-medium text-stone-600 mb-1">분류</label>
|
|
<select value={newCategory} onChange={(e) => setNewCategory(e.target.value)}
|
|
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
|
|
<option value="">선택</option>
|
|
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
|
</select>
|
|
</div>
|
|
<button onClick={handleAdd} className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 text-sm font-medium">추가</button>
|
|
</div>
|
|
</div>
|
|
<div className="px-6 py-3 border-b border-stone-100 flex gap-3">
|
|
<input type="text" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="코드 또는 이름 검색..."
|
|
className="flex-1 px-3 py-2 border border-stone-200 rounded-lg text-sm" />
|
|
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}
|
|
className="px-3 py-2 border border-stone-200 rounded-lg text-sm">
|
|
<option value="">전체 분류</option>
|
|
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
|
</select>
|
|
<span className="text-sm text-stone-500 py-2">{filteredCodes.length}개</span>
|
|
</div>
|
|
<div className="overflow-y-auto" style={ {maxHeight: '400px'} }>
|
|
{loading ? (
|
|
<div className="p-8 text-center text-stone-400">로딩 중...</div>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-stone-50 sticky top-0">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left font-medium text-stone-600">코드</th>
|
|
<th className="px-4 py-3 text-left font-medium text-stone-600">계정과목명</th>
|
|
<th className="px-4 py-3 text-left font-medium text-stone-600">분류</th>
|
|
<th className="px-4 py-3 text-center font-medium text-stone-600">상태</th>
|
|
<th className="px-4 py-3 text-center font-medium text-stone-600">작업</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-stone-100">
|
|
{filteredCodes.map(item => (
|
|
<tr key={item.id} className={`hover:bg-stone-50 ${!item.is_active ? 'opacity-50' : ''}`}>
|
|
<td className="px-4 py-2 font-mono">{item.code}</td>
|
|
<td className="px-4 py-2">{item.name}</td>
|
|
<td className="px-4 py-2">
|
|
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
|
item.category === '자산' ? 'bg-blue-100 text-blue-700' :
|
|
item.category === '부채' ? 'bg-red-100 text-red-700' :
|
|
item.category === '자본' ? 'bg-purple-100 text-purple-700' :
|
|
item.category === '수익' ? 'bg-green-100 text-green-700' :
|
|
item.category === '비용' ? 'bg-orange-100 text-orange-700' :
|
|
'bg-stone-100 text-stone-600'
|
|
}`}>{item.category || '-'}</span>
|
|
</td>
|
|
<td className="px-4 py-2 text-center">
|
|
<button onClick={() => handleToggleActive(item)}
|
|
className={`px-2 py-1 rounded text-xs ${item.is_active ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-stone-100 text-stone-500 hover:bg-stone-200'}`}>
|
|
{item.is_active ? '사용중' : '미사용'}
|
|
</button>
|
|
</td>
|
|
<td className="px-4 py-2 text-center">
|
|
<button onClick={() => handleDelete(item)} className="text-red-500 hover:text-red-700 text-xs">삭제</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
<div className="px-6 py-4 border-t border-stone-200 bg-stone-50 flex justify-end">
|
|
<button onClick={onClose} className="px-6 py-2 bg-stone-600 text-white rounded-lg hover:bg-stone-700 font-medium">닫기</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// AddTradingPartnerModal
|
|
// ============================================================
|
|
const AddTradingPartnerModal = ({ 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()) { alert('거래처명을 입력하세요.'); 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();
|
|
} else {
|
|
alert(data.message || '저장에 실패했습니다.');
|
|
}
|
|
} catch (err) {
|
|
alert('저장 중 오류가 발생했습니다.');
|
|
} 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"><X className="w-5 h-5" /></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-emerald-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-emerald-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-emerald-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-emerald-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-emerald-600 rounded-lg hover:bg-emerald-700 transition-colors disabled:opacity-50">
|
|
{saving ? '저장 중...' : '저장'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// TradingPartnerSelect
|
|
// ============================================================
|
|
const TradingPartnerSelect = ({ value, valueName, onChange, tradingPartners, onAddPartner, onTab, triggerId }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [highlightIndex, setHighlightIndex] = useState(-1);
|
|
const [dropdownStyle, setDropdownStyle] = useState({});
|
|
const containerRef = useRef(null);
|
|
const triggerRef = useRef(null);
|
|
const dropdownRef = 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) &&
|
|
(!dropdownRef.current || !dropdownRef.current.contains(e.target))) {
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const calcDropdownPos = () => {
|
|
if (!triggerRef.current) return;
|
|
const rect = triggerRef.current.getBoundingClientRect();
|
|
const spaceBelow = window.innerHeight - rect.bottom;
|
|
const openUp = spaceBelow < 280;
|
|
setDropdownStyle({
|
|
position: 'fixed',
|
|
left: rect.left,
|
|
width: Math.max(rect.width, 224),
|
|
zIndex: 9999,
|
|
...(openUp ? { bottom: window.innerHeight - rect.top + 4 } : { top: rect.bottom + 4 }),
|
|
});
|
|
};
|
|
|
|
const handleSelect = (partner) => {
|
|
onChange(partner.id, partner.name);
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
setTimeout(() => triggerRef.current?.focus(), 0);
|
|
};
|
|
const handleClear = (e) => { e.stopPropagation(); onChange(null, ''); setSearch(''); };
|
|
|
|
const handleKeyDown = (e) => {
|
|
const maxIndex = Math.min(filteredPartners.length, 50) - 1;
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
if (filteredPartners.length > 0 && highlightIndex >= 0) {
|
|
onChange(filteredPartners[highlightIndex].id, filteredPartners[highlightIndex].name);
|
|
}
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
setTimeout(() => onTab && onTab(), 0);
|
|
} else 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 ref={triggerRef} id={triggerId} tabIndex={0}
|
|
onClick={() => { if (!isOpen) calcDropdownPos(); setIsOpen(!isOpen); }}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
if (isOpen) { setIsOpen(false); setSearch(''); setHighlightIndex(-1); }
|
|
setTimeout(() => onTab && onTab(), 0);
|
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
if (!isOpen) calcDropdownPos();
|
|
setIsOpen(!isOpen);
|
|
}
|
|
}}
|
|
className={`w-full px-2 py-1.5 text-xs border rounded cursor-pointer flex items-center justify-between gap-1 ${isOpen ? 'border-emerald-500 ring-2 ring-emerald-500' : 'border-stone-200'} 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 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 && ReactDOM.createPortal(
|
|
<div ref={dropdownRef} style={dropdownStyle} className="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 py-1 text-xs border border-stone-200 rounded focus:ring-1 focus:ring-emerald-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-emerald-600 hover:bg-emerald-50 font-medium flex items-center gap-1 justify-center transition-colors">
|
|
<Plus className="w-3.5 h-3.5" /> 거래처 추가
|
|
</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-xs cursor-pointer ${index === highlightIndex ? 'bg-emerald-600 text-white font-semibold' : value === p.id ? 'bg-emerald-100 text-emerald-700' : 'text-stone-700 hover:bg-emerald-50'}`}>
|
|
<span className="font-medium">{p.name}</span>
|
|
{p.biz_no && <span className={`ml-1 ${index === highlightIndex ? 'text-emerald-100' : 'text-stone-400'}`}>({p.biz_no})</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// 탭1: BankTransactionTab (은행거래 분개)
|
|
// ============================================================
|
|
const BankTransactionTab = ({ accountCodes, tradingPartners, onPartnerAdded }) => {
|
|
const [transactions, setTransactions] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [summary, setSummary] = useState({});
|
|
const [journalStats, setJournalStats] = useState({});
|
|
const [accounts, setAccounts] = useState([]);
|
|
const [dateRange, setDateRange] = useState({
|
|
start: getKoreanDate(-30).replace(/-/g, ''),
|
|
end: getKoreanDate().replace(/-/g, ''),
|
|
});
|
|
const [selectedAccount, setSelectedAccount] = useState('');
|
|
const [filterJournal, setFilterJournal] = useState('all');
|
|
|
|
// 기간 빠른 선택 헬퍼
|
|
const setMonthRange = (offset) => {
|
|
const now = new Date();
|
|
const target = new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
|
const start = target.toLocaleDateString('en-CA', { timeZone: 'Asia/Seoul' }).replace(/-/g, '');
|
|
const endDate = offset === 0
|
|
? getKoreanDate().replace(/-/g, '')
|
|
: new Date(target.getFullYear(), target.getMonth() + 1, 0).toLocaleDateString('en-CA', { timeZone: 'Asia/Seoul' }).replace(/-/g, '');
|
|
setDateRange({ start, end: endDate });
|
|
};
|
|
|
|
// 모달 상태
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [modalTransaction, setModalTransaction] = useState(null);
|
|
|
|
const fetchTransactions = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({
|
|
startDate: dateRange.start,
|
|
endDate: dateRange.end,
|
|
});
|
|
if (selectedAccount) params.set('accountNum', selectedAccount);
|
|
|
|
const res = await fetch(`/finance/journal-entries/bank-transactions?${params}`);
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
setTransactions(data.data?.logs || []);
|
|
setSummary(data.data?.summary || {});
|
|
setJournalStats(data.data?.journalStats || {});
|
|
if (data.data?.accounts) setAccounts(data.data.accounts);
|
|
} else {
|
|
notify(data.error || data.message || '조회 실패', 'error');
|
|
setTransactions([]);
|
|
}
|
|
} catch (err) {
|
|
console.error('은행거래 조회 실패:', err);
|
|
notify('은행거래 조회 중 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { fetchTransactions(); }, []);
|
|
|
|
// 분개 필터 적용
|
|
const filteredTransactions = transactions.filter(t => {
|
|
if (filterJournal === 'journaled') return t.hasJournal;
|
|
if (filterJournal === 'unjournaled') return !t.hasJournal;
|
|
return true;
|
|
});
|
|
|
|
// 분개 버튼 클릭
|
|
const handleJournal = (tx) => {
|
|
setModalTransaction(tx);
|
|
setShowModal(true);
|
|
};
|
|
|
|
// 분개 저장 완료 후
|
|
const handleJournalSaved = () => {
|
|
setShowModal(false);
|
|
setModalTransaction(null);
|
|
fetchTransactions();
|
|
};
|
|
|
|
// 분개 삭제 완료 후
|
|
const handleJournalDeleted = () => {
|
|
setShowModal(false);
|
|
setModalTransaction(null);
|
|
fetchTransactions();
|
|
};
|
|
|
|
const totalCount = filteredTransactions.length;
|
|
const depositSum = filteredTransactions.reduce((s, t) => s + (parseFloat(t.deposit) || 0), 0);
|
|
const withdrawSum = filteredTransactions.reduce((s, t) => s + (parseFloat(t.withdraw) || 0), 0);
|
|
|
|
return (
|
|
<div>
|
|
{/* 필터 바 */}
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4 mb-4">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<label className="text-sm text-stone-500 font-medium">기간</label>
|
|
<input type="date" value={formatDate(dateRange.start)} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value.replace(/-/g, '') }))}
|
|
className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
<span className="text-stone-400">~</span>
|
|
<input type="date" value={formatDate(dateRange.end)} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value.replace(/-/g, '') }))}
|
|
className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={() => setMonthRange(0)} className="px-3 py-1.5 text-sm bg-emerald-50 text-emerald-600 rounded-lg hover:bg-emerald-100 transition-colors font-medium">이번 달</button>
|
|
<button onClick={() => setMonthRange(-1)} className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium">지난달</button>
|
|
<button onClick={() => setMonthRange(-2)} className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium">D-2월</button>
|
|
<button onClick={() => setMonthRange(-3)} className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium">D-3월</button>
|
|
<button onClick={() => setMonthRange(-4)} className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium">D-4월</button>
|
|
<button onClick={() => setMonthRange(-5)} className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium">D-5월</button>
|
|
<button onClick={fetchTransactions} 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 flex items-center gap-2 disabled:opacity-50">
|
|
<Search className="w-4 h-4" /> 조회
|
|
</button>
|
|
</div>
|
|
<span className="text-sm text-stone-500">
|
|
조회: <span className="font-semibold text-stone-700">{totalCount}</span>건
|
|
</span>
|
|
</div>
|
|
{/* 2행: 계좌 + 분개상태 필터 */}
|
|
<div className="flex flex-wrap items-center gap-3 mt-3">
|
|
<label className="text-sm text-stone-500 font-medium">계좌</label>
|
|
<select value={selectedAccount} onChange={(e) => setSelectedAccount(e.target.value)}
|
|
className="px-3 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none">
|
|
<option value="">전체 계좌</option>
|
|
{accounts.map(a => (
|
|
<option key={a.bank_account_num} value={a.bank_account_num}>
|
|
{a.bank_name} {a.bank_account_num}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<label className="text-sm text-stone-500 font-medium ml-2">분개</label>
|
|
<select value={filterJournal} onChange={(e) => setFilterJournal(e.target.value)}
|
|
className="px-3 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none">
|
|
<option value="all">전체</option>
|
|
<option value="unjournaled">미분개</option>
|
|
<option value="journaled">분개완료</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 통계 카드 */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3 mb-4">
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-stone-50 rounded-lg"><FileText className="w-5 h-5 text-stone-600" /></div>
|
|
<div>
|
|
<p className="text-xs text-stone-500">전체</p>
|
|
<p className="text-lg font-bold text-stone-800">{totalCount}건</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-blue-50 rounded-lg"><ArrowDownCircle className="w-5 h-5 text-blue-600" /></div>
|
|
<div>
|
|
<p className="text-xs text-stone-500">입금</p>
|
|
<p className="text-lg font-bold text-blue-700">{formatCurrency(depositSum)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-red-50 rounded-lg"><ArrowUpCircle className="w-5 h-5 text-red-600" /></div>
|
|
<div>
|
|
<p className="text-xs text-stone-500">출금</p>
|
|
<p className="text-lg font-bold text-red-700">{formatCurrency(withdrawSum)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-emerald-50 rounded-lg"><CheckCircle className="w-5 h-5 text-emerald-600" /></div>
|
|
<div>
|
|
<p className="text-xs text-stone-500">분개완료</p>
|
|
<p className="text-lg font-bold text-emerald-700">{journalStats.journaledCount || 0}건</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-amber-50 rounded-lg"><AlertTriangle className="w-5 h-5 text-amber-600" /></div>
|
|
<div>
|
|
<p className="text-xs text-stone-500">미분개</p>
|
|
<p className="text-lg font-bold text-amber-700">{journalStats.unjournaledCount || 0}건</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 거래 목록 테이블 */}
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden">
|
|
{loading ? (
|
|
<div className="p-12 text-center text-stone-400">
|
|
<div className="animate-spin w-8 h-8 border-4 border-emerald-200 border-t-emerald-600 rounded-full mx-auto mb-3"></div>
|
|
은행거래 조회 중...
|
|
</div>
|
|
) : filteredTransactions.length === 0 ? (
|
|
<div className="p-12 text-center text-stone-400">
|
|
<Landmark className="w-12 h-12 mx-auto mb-3 text-stone-300" />
|
|
<p>조회된 거래가 없습니다.</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-stone-50 border-b border-stone-200">
|
|
<th className="px-3 py-2.5 text-left font-medium text-stone-600 w-[90px]">날짜</th>
|
|
<th className="px-3 py-2.5 text-left font-medium text-stone-600 w-[50px]">시간</th>
|
|
{!selectedAccount && <th className="px-3 py-2.5 text-left font-medium text-stone-600 w-[120px]">계좌</th>}
|
|
<th className="px-3 py-2.5 text-left font-medium text-stone-600">적요</th>
|
|
<th className="px-3 py-2.5 text-right font-medium text-stone-600 w-[120px]">입금</th>
|
|
<th className="px-3 py-2.5 text-right font-medium text-stone-600 w-[120px]">출금</th>
|
|
<th className="px-3 py-2.5 text-right font-medium text-stone-600 w-[120px]">잔액</th>
|
|
<th className="px-3 py-2.5 text-center font-medium text-stone-600 w-[90px]">분개</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredTransactions.map((tx, idx) => (
|
|
<tr key={tx.uniqueKey || idx} className="border-b border-stone-100 hover:bg-stone-50 transition-colors">
|
|
<td className="px-3 py-2 text-stone-600 font-mono text-xs">{formatDate(tx.transDate)}</td>
|
|
<td className="px-3 py-2 text-stone-400 text-xs">{formatTime(tx.transTime)}</td>
|
|
{!selectedAccount && <td className="px-3 py-2 text-stone-500 text-xs truncate max-w-[120px]">{tx.bankName}</td>}
|
|
<td className="px-3 py-2 text-stone-800 max-w-[250px] truncate" title={tx.summary}>{tx.summary || '-'}</td>
|
|
<td className="px-3 py-2 text-right font-medium text-blue-600">
|
|
{parseFloat(tx.deposit) > 0 ? formatCurrency(tx.deposit) : ''}
|
|
</td>
|
|
<td className="px-3 py-2 text-right font-medium text-red-600">
|
|
{parseFloat(tx.withdraw) > 0 ? formatCurrency(tx.withdraw) : ''}
|
|
</td>
|
|
<td className="px-3 py-2 text-right text-stone-600 text-xs">{formatCurrency(tx.balance)}</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{tx.hasJournal ? (
|
|
<button onClick={() => handleJournal(tx)}
|
|
className="px-2.5 py-1 text-xs font-medium bg-emerald-100 text-emerald-700 rounded-full hover:bg-emerald-200 transition-colors">
|
|
완료
|
|
</button>
|
|
) : (
|
|
<button onClick={() => handleJournal(tx)}
|
|
className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-full hover:bg-amber-200 transition-colors">
|
|
분개
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 분개 모달 */}
|
|
{showModal && modalTransaction && (
|
|
<JournalEntryModal
|
|
transaction={modalTransaction}
|
|
accountCodes={accountCodes}
|
|
tradingPartners={tradingPartners}
|
|
onClose={() => { setShowModal(false); setModalTransaction(null); }}
|
|
onSaved={handleJournalSaved}
|
|
onDeleted={handleJournalDeleted}
|
|
onPartnerAdded={onPartnerAdded}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// JournalEntryList (은행거래 + 수동전표 통합 목록)
|
|
// ============================================================
|
|
const JournalEntryList = ({ accountCodes, tradingPartners, onPartnerAdded, refreshKey, onEdit }) => {
|
|
const [transactions, setTransactions] = useState([]);
|
|
const [journalEntries, setJournalEntries] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [accounts, setAccounts] = useState([]);
|
|
const [journalStats, setJournalStats] = useState({});
|
|
const [dateRange, setDateRange] = useState({
|
|
start: getKoreanDate(-30).replace(/-/g, ''),
|
|
end: getKoreanDate().replace(/-/g, ''),
|
|
});
|
|
const [selectedAccount, setSelectedAccount] = useState('');
|
|
const [viewFilter, setViewFilter] = useState('all'); // all, bank, manual, unjournaled
|
|
|
|
// 분개 모달 상태 (은행거래 → 전표 생성)
|
|
const [showJournalModal, setShowJournalModal] = useState(false);
|
|
const [modalTransaction, setModalTransaction] = useState(null);
|
|
|
|
const setMonthRange = (offset) => {
|
|
const now = new Date();
|
|
const target = new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
|
const start = target.toLocaleDateString('en-CA', { timeZone: 'Asia/Seoul' }).replace(/-/g, '');
|
|
const endDate = offset === 0
|
|
? getKoreanDate().replace(/-/g, '')
|
|
: new Date(target.getFullYear(), target.getMonth() + 1, 0).toLocaleDateString('en-CA', { timeZone: 'Asia/Seoul' }).replace(/-/g, '');
|
|
setDateRange({ start, end: endDate });
|
|
};
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const bankParams = new URLSearchParams({ startDate: dateRange.start, endDate: dateRange.end });
|
|
if (selectedAccount) bankParams.set('accountNum', selectedAccount);
|
|
const journalParams = new URLSearchParams({
|
|
start_date: formatDate(dateRange.start),
|
|
end_date: formatDate(dateRange.end),
|
|
});
|
|
|
|
const [bankRes, journalRes] = await Promise.all([
|
|
fetch(`/finance/journal-entries/bank-transactions?${bankParams}`),
|
|
fetch(`/finance/journal-entries/list?${journalParams}`),
|
|
]);
|
|
const bankData = await bankRes.json();
|
|
const journalData = await journalRes.json();
|
|
|
|
if (bankData.success) {
|
|
setTransactions(bankData.data?.logs || []);
|
|
setJournalStats(bankData.data?.journalStats || {});
|
|
if (bankData.data?.accounts) setAccounts(bankData.data.accounts);
|
|
}
|
|
if (journalData.success) {
|
|
setJournalEntries(journalData.data || []);
|
|
}
|
|
} catch (err) {
|
|
console.error('데이터 조회 실패:', err);
|
|
notify('데이터 조회 중 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { fetchData(); }, [refreshKey]);
|
|
|
|
// 전표 맵 생성 (journalId → entry)
|
|
const journalMap = {};
|
|
journalEntries.forEach(je => { journalMap[je.id] = je; });
|
|
|
|
// 통합 행 생성
|
|
const bankLinkedJournalIds = new Set();
|
|
const rows = [];
|
|
|
|
// 1) 은행거래 행
|
|
transactions.forEach(tx => {
|
|
const je = tx.journalId ? journalMap[tx.journalId] : null;
|
|
if (tx.journalId) bankLinkedJournalIds.add(tx.journalId);
|
|
rows.push({
|
|
type: 'bank',
|
|
key: `bank-${tx.uniqueKey}`,
|
|
date: tx.transDate,
|
|
time: tx.transTime,
|
|
description: tx.summary || '-',
|
|
deposit: parseFloat(tx.deposit) || 0,
|
|
withdraw: parseFloat(tx.withdraw) || 0,
|
|
balance: tx.balance,
|
|
hasJournal: tx.hasJournal,
|
|
journalId: tx.journalId,
|
|
entryNo: tx.journalEntryNo || (je && je.entry_no) || null,
|
|
lines: je ? je.lines : [],
|
|
totalDebit: je ? je.total_debit : 0,
|
|
totalCredit: je ? je.total_credit : 0,
|
|
bankTx: tx,
|
|
sortKey: tx.transDate + (tx.transTime || '000000'),
|
|
});
|
|
});
|
|
|
|
// 2) 수동 전표 행 (은행거래에 연결되지 않은 것만)
|
|
journalEntries.forEach(je => {
|
|
if (bankLinkedJournalIds.has(je.id)) return;
|
|
if (je.source_type === 'bank_transaction') return;
|
|
rows.push({
|
|
type: 'manual',
|
|
key: `manual-${je.id}`,
|
|
date: je.entry_date.replace(/-/g, ''),
|
|
time: '',
|
|
description: je.description || '-',
|
|
deposit: 0,
|
|
withdraw: 0,
|
|
balance: null,
|
|
hasJournal: true,
|
|
journalId: je.id,
|
|
entryNo: je.entry_no,
|
|
lines: je.lines || [],
|
|
totalDebit: je.total_debit,
|
|
totalCredit: je.total_credit,
|
|
bankTx: null,
|
|
sortKey: je.entry_date.replace(/-/g, '') + '999999',
|
|
});
|
|
});
|
|
|
|
// 최신순 정렬
|
|
rows.sort((a, b) => b.sortKey.localeCompare(a.sortKey));
|
|
|
|
// 필터 적용
|
|
const filtered = rows.filter(r => {
|
|
if (viewFilter === 'bank') return r.type === 'bank';
|
|
if (viewFilter === 'manual') return r.type === 'manual';
|
|
if (viewFilter === 'unjournaled') return r.type === 'bank' && !r.hasJournal;
|
|
return true;
|
|
});
|
|
|
|
// 통계
|
|
const bankRows = rows.filter(r => r.type === 'bank');
|
|
const manualRows = rows.filter(r => r.type === 'manual');
|
|
const depositSum = bankRows.reduce((s, r) => s + r.deposit, 0);
|
|
const withdrawSum = bankRows.reduce((s, r) => s + r.withdraw, 0);
|
|
const totalDebit = filtered.reduce((s, r) => s + (r.totalDebit || 0), 0);
|
|
const totalCredit = filtered.reduce((s, r) => s + (r.totalCredit || 0), 0);
|
|
|
|
const handleJournal = (tx) => {
|
|
setModalTransaction(tx);
|
|
setShowJournalModal(true);
|
|
};
|
|
|
|
const handleJournalSaved = () => {
|
|
setShowJournalModal(false);
|
|
setModalTransaction(null);
|
|
fetchData();
|
|
};
|
|
|
|
const handleJournalDeleted = () => {
|
|
setShowJournalModal(false);
|
|
setModalTransaction(null);
|
|
fetchData();
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* 필터 바 */}
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4 mb-4">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<label className="text-sm text-stone-500 font-medium">기간</label>
|
|
<input type="date" value={formatDate(dateRange.start)} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value.replace(/-/g, '') }))}
|
|
className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
<span className="text-stone-400">~</span>
|
|
<input type="date" value={formatDate(dateRange.end)} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value.replace(/-/g, '') }))}
|
|
className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
<div className="flex items-center gap-1.5">
|
|
<button onClick={() => setMonthRange(0)} className="px-2.5 py-1.5 text-xs bg-emerald-50 text-emerald-600 rounded-lg hover:bg-emerald-100 transition-colors font-medium">이번달</button>
|
|
<button onClick={() => setMonthRange(-1)} className="px-2.5 py-1.5 text-xs bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium">지난달</button>
|
|
<button onClick={() => setMonthRange(-2)} className="px-2.5 py-1.5 text-xs bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium">D-2월</button>
|
|
<button onClick={() => setMonthRange(-3)} className="px-2.5 py-1.5 text-xs bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium">D-3월</button>
|
|
<button onClick={() => setMonthRange(-4)} className="px-2.5 py-1.5 text-xs bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium">D-4월</button>
|
|
<button onClick={() => setMonthRange(-5)} className="px-2.5 py-1.5 text-xs bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium">D-5월</button>
|
|
</div>
|
|
<button onClick={fetchData} 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 flex items-center gap-2 disabled:opacity-50">
|
|
<Search className="w-4 h-4" /> 조회
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3 mt-3">
|
|
<label className="text-sm text-stone-500 font-medium">계좌</label>
|
|
<select value={selectedAccount} onChange={(e) => setSelectedAccount(e.target.value)}
|
|
className="px-3 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none">
|
|
<option value="">전체 계좌</option>
|
|
{accounts.map(a => (
|
|
<option key={a.bank_account_num} value={a.bank_account_num}>
|
|
{a.bank_name} {a.bank_account_num}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<label className="text-sm text-stone-500 font-medium ml-2">구분</label>
|
|
<div className="flex items-center gap-1">
|
|
{[
|
|
['all', '전체', rows.length],
|
|
['bank', '은행거래', bankRows.length],
|
|
['manual', '수동전표', manualRows.length],
|
|
['unjournaled', '미분개', bankRows.filter(r => !r.hasJournal).length],
|
|
].map(([val, label, count]) => (
|
|
<button key={val} onClick={() => setViewFilter(val)}
|
|
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${viewFilter === val
|
|
? 'bg-emerald-600 text-white'
|
|
: 'bg-stone-100 text-stone-500 hover:bg-stone-200'}`}>
|
|
{label} <span className="font-bold">{count}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 통계 카드 */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3 mb-4">
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="p-1.5 bg-stone-50 rounded-lg"><FileText className="w-4 h-4 text-stone-600" /></div>
|
|
<div>
|
|
<p className="text-[10px] text-stone-400 leading-tight">전체</p>
|
|
<p className="text-base font-bold text-stone-800">{rows.length}건</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="p-1.5 bg-blue-50 rounded-lg"><ArrowDownCircle className="w-4 h-4 text-blue-600" /></div>
|
|
<div>
|
|
<p className="text-[10px] text-stone-400 leading-tight">입금</p>
|
|
<p className="text-base font-bold text-blue-700">{formatCurrency(depositSum)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="p-1.5 bg-red-50 rounded-lg"><ArrowUpCircle className="w-4 h-4 text-red-600" /></div>
|
|
<div>
|
|
<p className="text-[10px] text-stone-400 leading-tight">출금</p>
|
|
<p className="text-base font-bold text-red-700">{formatCurrency(withdrawSum)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="p-1.5 bg-emerald-50 rounded-lg"><CheckCircle className="w-4 h-4 text-emerald-600" /></div>
|
|
<div>
|
|
<p className="text-[10px] text-stone-400 leading-tight">분개완료</p>
|
|
<p className="text-base font-bold text-emerald-700">{journalStats.journaledCount || 0}건</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="p-1.5 bg-amber-50 rounded-lg"><AlertTriangle className="w-4 h-4 text-amber-600" /></div>
|
|
<div>
|
|
<p className="text-[10px] text-stone-400 leading-tight">미분개</p>
|
|
<p className="text-base font-bold text-amber-700">{journalStats.unjournaledCount || 0}건</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 통합 테이블 */}
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden">
|
|
{loading ? (
|
|
<div className="p-12 text-center text-stone-400">
|
|
<div className="animate-spin w-8 h-8 border-4 border-emerald-200 border-t-emerald-600 rounded-full mx-auto mb-3"></div>
|
|
조회 중...
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="p-12 text-center text-stone-400">
|
|
<Landmark className="w-12 h-12 mx-auto mb-3 text-stone-300" />
|
|
<p>조회된 데이터가 없습니다.</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-stone-50 border-b border-stone-200">
|
|
<th className="px-3 py-3 text-left text-sm font-semibold text-stone-600 w-[90px]">날짜</th>
|
|
<th className="px-3 py-3 text-left text-sm font-semibold text-stone-600">적요</th>
|
|
<th className="px-3 py-3 text-right text-sm font-semibold text-stone-600 w-[110px]">입금</th>
|
|
<th className="px-3 py-3 text-right text-sm font-semibold text-stone-600 w-[110px]">출금</th>
|
|
<th className="px-3 py-3 text-right text-sm font-semibold text-stone-600 w-[110px]">잔액</th>
|
|
<th className="px-3 py-3 text-left text-sm font-semibold text-stone-600">분개 내역</th>
|
|
<th className="px-3 py-3 text-right text-sm font-semibold text-stone-600 w-[100px]">차변</th>
|
|
<th className="px-3 py-3 text-right text-sm font-semibold text-stone-600 w-[100px]">대변</th>
|
|
<th className="px-3 py-3 text-center text-sm font-semibold text-stone-600 w-[60px]">분개</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map(row => (
|
|
<tr key={row.key} className={`border-b border-stone-100 hover:bg-stone-50/50 transition-colors group ${row.type === 'manual' ? 'bg-purple-50/30' : ''}`}>
|
|
<td className="px-3 py-2.5 text-stone-600 text-sm whitespace-nowrap">
|
|
<div>{formatDate(row.date)}</div>
|
|
{row.time && <div className="text-stone-400 text-xs">{formatTime(row.time)}</div>}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-stone-800 text-sm max-w-[220px]">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${row.type === 'manual' ? 'bg-purple-500' : 'bg-blue-500'}`}></span>
|
|
<span className="truncate" title={row.description}>{row.description}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right font-medium text-blue-600 text-sm">
|
|
{row.deposit > 0 ? formatCurrency(row.deposit) : ''}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right font-medium text-red-600 text-sm">
|
|
{row.withdraw > 0 ? formatCurrency(row.withdraw) : ''}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right text-stone-500 text-sm">
|
|
{row.balance !== null ? formatCurrency(row.balance) : ''}
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
{row.hasJournal && row.lines.length > 0 ? (
|
|
<div className="space-y-0.5">
|
|
{row.lines.map((l, i) => (
|
|
<div key={l.id || i} className="flex items-center gap-1.5 text-[13px]">
|
|
<span className={`px-1 py-0.5 rounded text-[11px] font-bold leading-none ${l.dc_type === 'debit' ? 'bg-blue-50 text-blue-600' : 'bg-red-50 text-red-600'}`}>
|
|
{l.dc_type === 'debit' ? '차' : '대'}
|
|
</span>
|
|
<span className="text-stone-500 truncate max-w-[100px]">{l.account_name}</span>
|
|
<span className="font-medium text-stone-700">{formatCurrency(l.debit_amount || l.credit_amount)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : row.hasJournal ? (
|
|
<span className="text-emerald-600 text-sm">분개완료</span>
|
|
) : null}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right font-medium text-blue-700 text-sm">
|
|
{row.totalDebit > 0 ? formatCurrency(row.totalDebit) : ''}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right font-medium text-red-700 text-sm">
|
|
{row.totalCredit > 0 ? formatCurrency(row.totalCredit) : ''}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-center">
|
|
{row.type === 'bank' && !row.hasJournal ? (
|
|
<button onClick={() => handleJournal(row.bankTx)}
|
|
className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-full hover:bg-amber-200 transition-colors">
|
|
분개
|
|
</button>
|
|
) : row.hasJournal ? (
|
|
<button onClick={() => row.type === 'bank' ? handleJournal(row.bankTx) : onEdit(row.journalId)}
|
|
className="p-1 text-stone-300 group-hover:text-emerald-500 hover:bg-emerald-50 rounded transition-colors" title="수정">
|
|
<Edit3 className="w-4 h-4" />
|
|
</button>
|
|
) : null}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="bg-stone-50 border-t-2 border-stone-300">
|
|
<td colSpan={2} className="px-3 py-2.5 text-sm text-stone-500 font-medium">합계 ({filtered.length}건)</td>
|
|
<td className="px-3 py-2.5 text-right font-bold text-blue-700 text-sm">{formatCurrency(filtered.reduce((s, r) => s + r.deposit, 0))}</td>
|
|
<td className="px-3 py-2.5 text-right font-bold text-red-700 text-sm">{formatCurrency(filtered.reduce((s, r) => s + r.withdraw, 0))}</td>
|
|
<td></td>
|
|
<td></td>
|
|
<td className="px-3 py-2.5 text-right font-bold text-blue-700 text-sm">{formatCurrency(totalDebit)}</td>
|
|
<td className="px-3 py-2.5 text-right font-bold text-red-700 text-sm">{formatCurrency(totalCredit)}</td>
|
|
<td></td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 분개 모달 (은행거래 → 전표 생성/수정) */}
|
|
{showJournalModal && modalTransaction && (
|
|
<JournalEntryModal
|
|
transaction={modalTransaction}
|
|
accountCodes={accountCodes}
|
|
tradingPartners={tradingPartners}
|
|
onClose={() => { setShowJournalModal(false); setModalTransaction(null); }}
|
|
onSaved={handleJournalSaved}
|
|
onDeleted={handleJournalDeleted}
|
|
onPartnerAdded={onPartnerAdded}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// ManualJournalModal (수동 전표 생성/수정 모달)
|
|
// ============================================================
|
|
const ManualJournalModal = ({ entryId, accountCodes, tradingPartners, onClose, onSaved, onDeleted, onPartnerAdded }) => {
|
|
const isEditMode = !!entryId;
|
|
const [saving, setSaving] = useState(false);
|
|
const [loadingEntry, setLoadingEntry] = useState(false);
|
|
const [entryDate, setEntryDate] = useState(getKoreanDate());
|
|
const [description, setDescription] = useState('');
|
|
const [previewEntryNo, setPreviewEntryNo] = useState('');
|
|
const [existingEntryNo, setExistingEntryNo] = useState('');
|
|
const [showAddPartnerModal, setShowAddPartnerModal] = useState(false);
|
|
const [addPartnerLineIndex, setAddPartnerLineIndex] = useState(null);
|
|
|
|
const getDefaultLines = () => [
|
|
{ key: Date.now(), dc_type: 'debit', account_code: '', account_name: '', trading_partner_id: null, trading_partner_name: '', debit_amount: 0, credit_amount: 0, description: '' },
|
|
{ key: Date.now() + 1, dc_type: 'credit', account_code: '', account_name: '', trading_partner_id: null, trading_partner_name: '', debit_amount: 0, credit_amount: 0, description: '' },
|
|
];
|
|
|
|
const [lines, setLines] = useState(getDefaultLines());
|
|
|
|
// 전표번호 미리보기
|
|
const fetchPreviewEntryNo = async (date) => {
|
|
try {
|
|
const res = await fetch(`/finance/journal-entries/next-entry-no?date=${date}`);
|
|
const data = await res.json();
|
|
if (data.success) setPreviewEntryNo(data.entry_no);
|
|
} catch (err) { /* ignore */ }
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!isEditMode) fetchPreviewEntryNo(entryDate);
|
|
}, [entryDate]);
|
|
|
|
// 수정 모드: 기존 전표 로드
|
|
useEffect(() => {
|
|
if (isEditMode) {
|
|
setLoadingEntry(true);
|
|
fetch(`/finance/journal-entries/${entryId}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success && data.data) {
|
|
const d = data.data;
|
|
setEntryDate(d.entry_date);
|
|
setDescription(d.description || '');
|
|
setExistingEntryNo(d.entry_no);
|
|
if (d.lines && d.lines.length > 0) {
|
|
setLines(d.lines.map((l, i) => ({ ...l, key: l.id || Date.now() + i })));
|
|
}
|
|
}
|
|
})
|
|
.catch(err => notify('전표 로드 실패', 'error'))
|
|
.finally(() => setLoadingEntry(false));
|
|
}
|
|
}, [entryId]);
|
|
|
|
const totalDebit = lines.reduce((sum, l) => sum + (parseInt(l.debit_amount) || 0), 0);
|
|
const totalCredit = lines.reduce((sum, l) => sum + (parseInt(l.credit_amount) || 0), 0);
|
|
const isBalanced = totalDebit === totalCredit && totalDebit > 0;
|
|
const difference = totalDebit - totalCredit;
|
|
|
|
const updateLine = (index, field, value) => {
|
|
const updated = [...lines];
|
|
updated[index] = { ...updated[index], [field]: value };
|
|
if (field === 'dc_type') {
|
|
if (value === 'debit') { updated[index].credit_amount = 0; }
|
|
else { updated[index].debit_amount = 0; }
|
|
}
|
|
setLines(updated);
|
|
};
|
|
|
|
const toggleDcType = (index) => {
|
|
setLines(prev => prev.map((l, i) => {
|
|
if (i !== index) 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 addLine = () => setLines([...lines, {
|
|
key: Date.now() + Math.random(), dc_type: 'debit', account_code: '', account_name: '',
|
|
trading_partner_id: null, trading_partner_name: '', debit_amount: 0, credit_amount: 0, description: '',
|
|
}]);
|
|
|
|
const removeLine = (index) => {
|
|
if (lines.length <= 2) return;
|
|
setLines(lines.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!entryDate) { notify('전표일자를 입력해주세요.', 'warning'); return; }
|
|
const emptyLine = lines.find(l => !l.account_code);
|
|
if (emptyLine) { notify('모든 분개 라인의 계정과목을 선택해주세요.', 'warning'); return; }
|
|
if (!isBalanced) { notify('차변과 대변의 합계가 일치하지 않습니다.', 'warning'); return; }
|
|
|
|
setSaving(true);
|
|
try {
|
|
const payload = {
|
|
entry_date: entryDate,
|
|
description: description,
|
|
lines: lines.map(l => ({
|
|
dc_type: l.dc_type, account_code: l.account_code, account_name: l.account_name,
|
|
trading_partner_id: l.trading_partner_id, trading_partner_name: l.trading_partner_name,
|
|
debit_amount: parseInt(l.debit_amount) || 0, credit_amount: parseInt(l.credit_amount) || 0,
|
|
description: l.description,
|
|
})),
|
|
};
|
|
|
|
const url = isEditMode ? `/finance/journal-entries/${entryId}` : '/finance/journal-entries/store';
|
|
const method = isEditMode ? 'PUT' : 'POST';
|
|
|
|
const res = await fetch(url, {
|
|
method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
try {
|
|
const errData = JSON.parse(text);
|
|
const msg = errData.message || (errData.errors ? Object.values(errData.errors).flat().join(', ') : `저장 실패 (${res.status})`);
|
|
notify(msg, 'error');
|
|
} catch { notify(`저장 실패: 서버 오류 (${res.status})`, 'error'); }
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
notify(data.message || (isEditMode ? '전표가 수정되었습니다.' : '전표가 저장되었습니다.'), 'success');
|
|
onSaved();
|
|
} else {
|
|
notify(data.message || '저장 실패', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('전표 저장 오류:', err);
|
|
notify('저장 중 오류가 발생했습니다: ' + err.message, 'error');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!entryId || !confirm('이 전표를 삭제하시겠습니까?')) return;
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch(`/finance/journal-entries/${entryId}`, {
|
|
method: 'DELETE', headers: { 'X-CSRF-TOKEN': CSRF_TOKEN },
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) { notify('전표가 삭제되었습니다.', 'success'); onDeleted(); }
|
|
else { notify(data.message || '삭제 실패', 'error'); }
|
|
} catch (err) {
|
|
notify('삭제 중 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
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-6xl mx-4 min-h-[80vh] max-h-[95vh] flex flex-col overflow-hidden">
|
|
{/* 헤더 */}
|
|
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between flex-shrink-0">
|
|
<h3 className="text-lg font-bold text-stone-900">
|
|
{isEditMode ? `전표 수정 (${existingEntryNo})` : '수동 전표 생성'}
|
|
</h3>
|
|
<button onClick={onClose} className="p-2 hover:bg-stone-100 rounded-lg transition-colors">
|
|
<X className="w-5 h-5 text-stone-500" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-6 py-5 overflow-y-auto flex-1 space-y-5">
|
|
{loadingEntry ? (
|
|
<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 className="bg-stone-50 rounded-xl p-4 border border-stone-100">
|
|
<h4 className="text-sm font-semibold text-stone-700 mb-3">전표 정보</h4>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">전표일자 <span className="text-red-500">*</span></label>
|
|
<input type="date" value={entryDate} onChange={(e) => setEntryDate(e.target.value)}
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">전표번호</label>
|
|
<div className="w-full px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-500 font-mono">
|
|
{isEditMode ? existingEntryNo : (previewEntryNo || '자동 생성')}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">적요</label>
|
|
<input type="text" value={description} onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="전표 적요를 입력하세요"
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 분개 내역 테이블 */}
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-stone-700 mb-3">분개 내역</h4>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-stone-50 border-b border-stone-200">
|
|
<th className="px-3 py-2 text-center font-medium text-stone-600 w-[70px]">구분</th>
|
|
<th className="px-3 py-2 text-left font-medium text-stone-600 w-[180px]">계정과목</th>
|
|
<th className="px-3 py-2 text-left font-medium text-stone-600 w-[160px]">거래처</th>
|
|
<th className="px-3 py-2 text-right font-medium text-stone-600 w-[120px]">차변</th>
|
|
<th className="px-3 py-2 text-right font-medium text-stone-600 w-[120px]">대변</th>
|
|
<th className="px-3 py-2 text-left font-medium text-stone-600">적요</th>
|
|
<th className="px-3 py-2 text-center font-medium text-stone-600 w-[40px]"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{lines.map((line, index) => (
|
|
<tr key={line.key || index} className="border-b border-stone-100">
|
|
<td className="px-3 py-2 text-center">
|
|
<button type="button" onClick={() => toggleDcType(index)}
|
|
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">
|
|
<AccountCodeSelect value={line.account_code} accountCodes={accountCodes}
|
|
triggerId={`m-account-${index}`}
|
|
onTab={() => document.getElementById(`m-partner-${index}`)?.focus()}
|
|
onChange={(code, name) => {
|
|
const updated = [...lines];
|
|
updated[index] = { ...updated[index], account_code: code, account_name: name };
|
|
setLines(updated);
|
|
}} />
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<TradingPartnerSelect value={line.trading_partner_id} valueName={line.trading_partner_name}
|
|
tradingPartners={tradingPartners}
|
|
triggerId={`m-partner-${index}`}
|
|
onTab={() => document.getElementById(`m-amount-${line.dc_type}-${index}`)?.focus()}
|
|
onChange={(id, name) => {
|
|
const updated = [...lines];
|
|
updated[index] = { ...updated[index], trading_partner_id: id, trading_partner_name: name };
|
|
setLines(updated);
|
|
}}
|
|
onAddPartner={() => { setAddPartnerLineIndex(index); setShowAddPartnerModal(true); }} />
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<input type="text" id={`m-amount-debit-${index}`}
|
|
value={line.dc_type === 'debit' && line.debit_amount ? formatInputCurrency(line.debit_amount) : ''}
|
|
onChange={(e) => updateLine(index, 'debit_amount', parseInputCurrency(e.target.value))}
|
|
onKeyDown={(e) => { if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); document.getElementById(`m-desc-${index}`)?.focus(); } }}
|
|
disabled={line.dc_type !== 'debit'}
|
|
placeholder={line.dc_type === 'debit' ? '금액' : ''}
|
|
className={`w-full px-2 py-1.5 text-xs text-right border rounded outline-none font-medium ${line.dc_type === 'debit' ? 'border-stone-200 focus:ring-2 focus:ring-blue-500 text-blue-700' : 'bg-stone-100 border-stone-100 text-stone-300'}`} />
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<input type="text" id={`m-amount-credit-${index}`}
|
|
value={line.dc_type === 'credit' && line.credit_amount ? formatInputCurrency(line.credit_amount) : ''}
|
|
onChange={(e) => updateLine(index, 'credit_amount', parseInputCurrency(e.target.value))}
|
|
onKeyDown={(e) => { if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); document.getElementById(`m-desc-${index}`)?.focus(); } }}
|
|
disabled={line.dc_type !== 'credit'}
|
|
placeholder={line.dc_type === 'credit' ? '금액' : ''}
|
|
className={`w-full px-2 py-1.5 text-xs text-right border rounded outline-none font-medium ${line.dc_type === 'credit' ? 'border-stone-200 focus:ring-2 focus:ring-red-500 text-red-700' : 'bg-stone-100 border-stone-100 text-stone-300'}`} />
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<input type="text" id={`m-desc-${index}`} value={line.description || ''}
|
|
onChange={(e) => updateLine(index, 'description', e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); const next = document.getElementById(`m-account-${index + 1}`); if (next) next.focus(); } }}
|
|
placeholder="적요"
|
|
className="w-full px-2 py-1.5 text-xs border border-stone-200 rounded focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
<button tabIndex={-1} onClick={() => removeLine(index)} disabled={lines.length <= 2}
|
|
className={`p-1 rounded ${lines.length <= 2 ? 'text-stone-200 cursor-not-allowed' : 'text-stone-400 hover:text-red-500 hover:bg-red-50'}`}>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="bg-stone-50 border-t-2 border-stone-300">
|
|
<td colSpan={3} className="px-3 py-2">
|
|
<button onClick={addLine} className="text-xs text-emerald-600 hover:text-emerald-700 font-medium flex items-center gap-1">
|
|
<Plus className="w-3.5 h-3.5" /> 행 추가
|
|
</button>
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<span className="text-xs text-stone-500">차변</span>
|
|
<p className="font-bold text-blue-700 text-sm">{formatCurrency(totalDebit)}</p>
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<span className="text-xs text-stone-500">대변</span>
|
|
<p className="font-bold text-red-700 text-sm">{formatCurrency(totalCredit)}</p>
|
|
</td>
|
|
<td colSpan={2} className="px-3 py-2">
|
|
{difference !== 0 ? (
|
|
<div className="flex items-center gap-1 text-xs text-red-600 font-medium">
|
|
<AlertTriangle className="w-3.5 h-3.5" /> 차이: {formatCurrency(Math.abs(difference))}
|
|
</div>
|
|
) : totalDebit > 0 ? (
|
|
<div className="flex items-center gap-1 text-xs text-emerald-600 font-medium">
|
|
<CheckCircle className="w-3.5 h-3.5" /> 대차 균형
|
|
</div>
|
|
) : null}
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* 하단 버튼 */}
|
|
<div className="px-6 py-4 border-t border-stone-100 flex justify-between flex-shrink-0">
|
|
<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">
|
|
<Trash2 className="w-4 h-4 inline mr-1" /> 삭제
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<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={!isBalanced || saving}
|
|
className={`px-6 py-2 text-sm font-medium rounded-lg flex items-center gap-1 transition-colors ${isBalanced && !saving ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-stone-200 text-stone-400 cursor-not-allowed'}`}>
|
|
<Save className="w-4 h-4" /> {saving ? '저장 중...' : isEditMode ? '수정' : '저장'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 거래처 추가 모달 */}
|
|
<AddTradingPartnerModal
|
|
isOpen={showAddPartnerModal}
|
|
onClose={() => { setShowAddPartnerModal(false); setAddPartnerLineIndex(null); }}
|
|
onSaved={(newPartner) => {
|
|
if (onPartnerAdded) onPartnerAdded(newPartner);
|
|
if (addPartnerLineIndex !== null) {
|
|
const updated = [...lines];
|
|
updated[addPartnerLineIndex] = {
|
|
...updated[addPartnerLineIndex],
|
|
trading_partner_id: newPartner.id,
|
|
trading_partner_name: newPartner.name,
|
|
};
|
|
setLines(updated);
|
|
}
|
|
setAddPartnerLineIndex(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// JournalEntryModal (은행거래 분개 모달)
|
|
// ============================================================
|
|
const JournalEntryModal = ({ transaction, accountCodes, tradingPartners, onClose, onSaved, onDeleted, onPartnerAdded }) => {
|
|
const tx = transaction;
|
|
const isDeposit = parseFloat(tx.deposit) > 0;
|
|
const amount = isDeposit ? parseInt(tx.deposit) : parseInt(tx.withdraw);
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
const [loadingJournal, setLoadingJournal] = useState(false);
|
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
const [existingJournalId, setExistingJournalId] = useState(null);
|
|
const [description, setDescription] = useState(tx.summary || '');
|
|
const [showAddPartnerModal, setShowAddPartnerModal] = useState(false);
|
|
const [addPartnerLineIndex, setAddPartnerLineIndex] = useState(null);
|
|
|
|
// 기본 분개 라인: 입금=차변 보통예금/대변 미정, 출금=차변 미정/대변 보통예금
|
|
const getDefaultLines = () => {
|
|
if (isDeposit) {
|
|
return [
|
|
{ key: 1, dc_type: 'debit', account_code: '103', account_name: '보통예금', trading_partner_id: null, trading_partner_name: '', debit_amount: amount, credit_amount: 0, description: '' },
|
|
{ key: 2, dc_type: 'credit', account_code: '', account_name: '', trading_partner_id: null, trading_partner_name: '', debit_amount: 0, credit_amount: amount, description: '' },
|
|
];
|
|
} else {
|
|
return [
|
|
{ key: 1, dc_type: 'debit', account_code: '', account_name: '', trading_partner_id: null, trading_partner_name: '', debit_amount: amount, credit_amount: 0, description: '' },
|
|
{ key: 2, dc_type: 'credit', account_code: '103', account_name: '보통예금', trading_partner_id: null, trading_partner_name: '', debit_amount: 0, credit_amount: amount, description: '' },
|
|
];
|
|
}
|
|
};
|
|
|
|
const [lines, setLines] = useState(getDefaultLines());
|
|
|
|
// 기존 분개 로드
|
|
useEffect(() => {
|
|
if (tx.hasJournal) {
|
|
setLoadingJournal(true);
|
|
fetch(`/finance/journal-entries/bank-journals?source_key=${encodeURIComponent(tx.uniqueKey)}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success && data.data) {
|
|
setIsEditMode(true);
|
|
setExistingJournalId(data.data.id);
|
|
setDescription(data.data.description || '');
|
|
if (data.data.lines && data.data.lines.length > 0) {
|
|
setLines(data.data.lines.map((l, i) => ({
|
|
...l,
|
|
key: l.id || Date.now() + i,
|
|
})));
|
|
}
|
|
}
|
|
})
|
|
.catch(err => console.error('기존 분개 로드 오류:', err))
|
|
.finally(() => setLoadingJournal(false));
|
|
}
|
|
}, []);
|
|
|
|
const totalDebit = lines.reduce((sum, l) => sum + (parseInt(l.debit_amount) || 0), 0);
|
|
const totalCredit = lines.reduce((sum, l) => sum + (parseInt(l.credit_amount) || 0), 0);
|
|
const isBalanced = totalDebit === totalCredit && totalDebit > 0;
|
|
const difference = totalDebit - totalCredit;
|
|
|
|
const updateLine = (index, field, value) => {
|
|
const updated = [...lines];
|
|
updated[index] = { ...updated[index], [field]: value };
|
|
if (field === 'dc_type') {
|
|
if (value === 'debit') {
|
|
updated[index].credit_amount = 0;
|
|
} else {
|
|
updated[index].debit_amount = 0;
|
|
}
|
|
}
|
|
setLines(updated);
|
|
};
|
|
|
|
const toggleDcType = (index) => {
|
|
setLines(prev => prev.map((l, i) => {
|
|
if (i !== index) 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 addLine = () => setLines([...lines, {
|
|
key: Date.now() + Math.random(),
|
|
dc_type: 'debit',
|
|
account_code: '',
|
|
account_name: '',
|
|
trading_partner_id: null,
|
|
trading_partner_name: '',
|
|
debit_amount: 0,
|
|
credit_amount: 0,
|
|
description: '',
|
|
}]);
|
|
|
|
const removeLine = (index) => {
|
|
if (lines.length <= 2) return;
|
|
setLines(lines.filter((_, i) => i !== index));
|
|
};
|
|
|
|
// 거래일자를 entry_date 형식으로 변환
|
|
const entryDate = tx.transDate ? `${tx.transDate.slice(0,4)}-${tx.transDate.slice(4,6)}-${tx.transDate.slice(6,8)}` : getKoreanDate();
|
|
|
|
const handleSave = async () => {
|
|
const emptyLine = lines.find(l => !l.account_code);
|
|
if (emptyLine) { notify('모든 분개 라인의 계정과목을 선택해주세요.', 'warning'); return; }
|
|
if (!isBalanced) { notify('차변과 대변의 합계가 일치하지 않습니다.', 'warning'); return; }
|
|
|
|
setSaving(true);
|
|
try {
|
|
// 기존 분개가 있으면 삭제 후 새로 생성
|
|
if (isEditMode && existingJournalId) {
|
|
await fetch(`/finance/journal-entries/bank-journal/${existingJournalId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'X-CSRF-TOKEN': CSRF_TOKEN },
|
|
});
|
|
}
|
|
|
|
const payload = {
|
|
source_key: tx.uniqueKey,
|
|
entry_date: entryDate,
|
|
description: description,
|
|
lines: lines.map(l => ({
|
|
dc_type: l.dc_type,
|
|
account_code: l.account_code,
|
|
account_name: l.account_name,
|
|
trading_partner_id: l.trading_partner_id,
|
|
trading_partner_name: l.trading_partner_name,
|
|
debit_amount: parseInt(l.debit_amount) || 0,
|
|
credit_amount: parseInt(l.credit_amount) || 0,
|
|
description: l.description,
|
|
})),
|
|
};
|
|
|
|
const res = await fetch('/finance/journal-entries/store-from-bank', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
try {
|
|
const errData = JSON.parse(text);
|
|
const msg = errData.message || (errData.errors ? Object.values(errData.errors).flat().join(', ') : `저장 실패 (${res.status})`);
|
|
notify(msg, 'error');
|
|
} catch { notify(`저장 실패: 서버 오류 (${res.status})`, 'error'); }
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
notify(data.message || '분개가 저장되었습니다.', 'success');
|
|
onSaved();
|
|
} else {
|
|
notify(data.message || '분개 저장 실패', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('분개 저장 오류:', err);
|
|
notify('분개 저장 중 오류가 발생했습니다: ' + err.message, 'error');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!existingJournalId || !confirm('분개를 삭제하시겠습니까?')) return;
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch(`/finance/journal-entries/bank-journal/${existingJournalId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'X-CSRF-TOKEN': CSRF_TOKEN },
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
notify('분개가 삭제되었습니다.', 'success');
|
|
onDeleted();
|
|
}
|
|
} catch (err) {
|
|
notify('삭제 중 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
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-6xl mx-4 min-h-[80vh] max-h-[95vh] flex flex-col overflow-hidden">
|
|
{/* 헤더 */}
|
|
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between flex-shrink-0">
|
|
<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">
|
|
<X className="w-5 h-5 text-stone-500" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-6 py-5 overflow-y-auto flex-1 space-y-5">
|
|
{/* 거래 정보 카드 */}
|
|
<div className={`rounded-xl p-4 ${isDeposit ? 'bg-blue-50 border border-blue-100' : 'bg-red-50 border border-red-100'}`}>
|
|
<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">{formatDate(tx.transDate)}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-stone-500">구분: </span>
|
|
<span className={`font-medium ${isDeposit ? 'text-blue-700' : 'text-red-700'}`}>
|
|
{isDeposit ? '입금' : '출금'}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-stone-500">금액: </span>
|
|
<span className="font-bold">{formatCurrency(amount)}원</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-stone-500">적요: </span>
|
|
<span className="font-medium">{tx.summary || '-'}</span>
|
|
</div>
|
|
</div>
|
|
{tx.bankName && (
|
|
<div className="mt-2 text-xs text-stone-500">
|
|
계좌: {tx.bankName} {tx.bankAccountNum}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 전표 적요 */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">전표 적요</label>
|
|
<input type="text" value={description} onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="전표 적요를 입력하세요"
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
</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>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-stone-50 border-b border-stone-200">
|
|
<th className="px-3 py-2 text-center font-medium text-stone-600 w-[70px]">구분</th>
|
|
<th className="px-3 py-2 text-left font-medium text-stone-600 w-[180px]">계정과목</th>
|
|
<th className="px-3 py-2 text-left font-medium text-stone-600 w-[160px]">거래처</th>
|
|
<th className="px-3 py-2 text-right font-medium text-stone-600 w-[120px]">차변</th>
|
|
<th className="px-3 py-2 text-right font-medium text-stone-600 w-[120px]">대변</th>
|
|
<th className="px-3 py-2 text-left font-medium text-stone-600">적요</th>
|
|
<th className="px-3 py-2 text-center font-medium text-stone-600 w-[40px]"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{lines.map((line, index) => (
|
|
<tr key={line.key || index} className="border-b border-stone-100">
|
|
<td className="px-3 py-2 text-center">
|
|
<button type="button" onClick={() => toggleDcType(index)}
|
|
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">
|
|
<AccountCodeSelect
|
|
value={line.account_code}
|
|
accountCodes={accountCodes}
|
|
triggerId={`b-account-${index}`}
|
|
onTab={() => document.getElementById(`b-partner-${index}`)?.focus()}
|
|
onChange={(code, name) => {
|
|
const updated = [...lines];
|
|
updated[index] = { ...updated[index], account_code: code, account_name: name };
|
|
setLines(updated);
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<TradingPartnerSelect
|
|
value={line.trading_partner_id}
|
|
valueName={line.trading_partner_name}
|
|
tradingPartners={tradingPartners}
|
|
triggerId={`b-partner-${index}`}
|
|
onTab={() => document.getElementById(`b-amount-${line.dc_type}-${index}`)?.focus()}
|
|
onChange={(id, name) => {
|
|
const updated = [...lines];
|
|
updated[index] = { ...updated[index], trading_partner_id: id, trading_partner_name: name };
|
|
setLines(updated);
|
|
}}
|
|
onAddPartner={() => { setAddPartnerLineIndex(index); setShowAddPartnerModal(true); }}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<input type="text" id={`b-amount-debit-${index}`}
|
|
value={line.dc_type === 'debit' && line.debit_amount ? formatInputCurrency(line.debit_amount) : ''}
|
|
onChange={(e) => updateLine(index, 'debit_amount', parseInputCurrency(e.target.value))}
|
|
onKeyDown={(e) => { if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); document.getElementById(`b-desc-${index}`)?.focus(); } }}
|
|
disabled={line.dc_type !== 'debit'}
|
|
placeholder={line.dc_type === 'debit' ? '금액' : ''}
|
|
className={`w-full px-2 py-1.5 text-xs text-right border rounded outline-none font-medium ${line.dc_type === 'debit' ? 'border-stone-200 focus:ring-2 focus:ring-blue-500 text-blue-700' : 'bg-stone-100 border-stone-100 text-stone-300'}`}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<input type="text" id={`b-amount-credit-${index}`}
|
|
value={line.dc_type === 'credit' && line.credit_amount ? formatInputCurrency(line.credit_amount) : ''}
|
|
onChange={(e) => updateLine(index, 'credit_amount', parseInputCurrency(e.target.value))}
|
|
onKeyDown={(e) => { if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); document.getElementById(`b-desc-${index}`)?.focus(); } }}
|
|
disabled={line.dc_type !== 'credit'}
|
|
placeholder={line.dc_type === 'credit' ? '금액' : ''}
|
|
className={`w-full px-2 py-1.5 text-xs text-right border rounded outline-none font-medium ${line.dc_type === 'credit' ? 'border-stone-200 focus:ring-2 focus:ring-red-500 text-red-700' : 'bg-stone-100 border-stone-100 text-stone-300'}`}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<input type="text" id={`b-desc-${index}`} value={line.description || ''}
|
|
onChange={(e) => updateLine(index, 'description', e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); const next = document.getElementById(`b-account-${index + 1}`); if (next) next.focus(); } }}
|
|
placeholder="적요"
|
|
className="w-full px-2 py-1.5 text-xs border border-stone-200 rounded focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
<button tabIndex={-1} onClick={() => removeLine(index)}
|
|
disabled={lines.length <= 2}
|
|
className={`p-1 rounded ${lines.length <= 2 ? 'text-stone-200 cursor-not-allowed' : 'text-stone-400 hover:text-red-500 hover:bg-red-50'}`}>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="bg-stone-50 border-t-2 border-stone-300">
|
|
<td colSpan={3} className="px-3 py-2">
|
|
<button onClick={addLine} className="text-xs text-emerald-600 hover:text-emerald-700 font-medium flex items-center gap-1">
|
|
<Plus className="w-3.5 h-3.5" /> 행 추가
|
|
</button>
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<span className="text-xs text-stone-500">차변</span>
|
|
<p className="font-bold text-blue-700 text-sm">{formatCurrency(totalDebit)}</p>
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<span className="text-xs text-stone-500">대변</span>
|
|
<p className="font-bold text-red-700 text-sm">{formatCurrency(totalCredit)}</p>
|
|
</td>
|
|
<td colSpan={2} className="px-3 py-2">
|
|
{difference !== 0 ? (
|
|
<div className="flex items-center gap-1 text-xs text-red-600 font-medium">
|
|
<AlertTriangle className="w-3.5 h-3.5" />
|
|
차이: {formatCurrency(Math.abs(difference))}
|
|
</div>
|
|
) : totalDebit > 0 ? (
|
|
<div className="flex items-center gap-1 text-xs text-emerald-600 font-medium">
|
|
<CheckCircle className="w-3.5 h-3.5" /> 대차 균형
|
|
</div>
|
|
) : null}
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 하단 버튼 */}
|
|
<div className="px-6 py-4 border-t border-stone-100 flex justify-between flex-shrink-0">
|
|
<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">
|
|
<Trash2 className="w-4 h-4 inline mr-1" /> 분개 삭제
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<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={!isBalanced || saving}
|
|
className={`px-6 py-2 text-sm font-medium rounded-lg flex items-center gap-1 transition-colors ${isBalanced && !saving ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-stone-200 text-stone-400 cursor-not-allowed'}`}>
|
|
<Save className="w-4 h-4" /> {saving ? '저장 중...' : isEditMode ? '수정' : '저장'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 거래처 추가 모달 */}
|
|
<AddTradingPartnerModal
|
|
isOpen={showAddPartnerModal}
|
|
onClose={() => { setShowAddPartnerModal(false); setAddPartnerLineIndex(null); }}
|
|
onSaved={(newPartner) => {
|
|
if (onPartnerAdded) onPartnerAdded(newPartner);
|
|
if (addPartnerLineIndex !== null) {
|
|
const updated = [...lines];
|
|
updated[addPartnerLineIndex] = {
|
|
...updated[addPartnerLineIndex],
|
|
trading_partner_id: newPartner.id,
|
|
trading_partner_name: newPartner.name,
|
|
};
|
|
setLines(updated);
|
|
}
|
|
setAddPartnerLineIndex(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function App() {
|
|
const [accountCodes, setAccountCodes] = useState([]);
|
|
const [tradingPartners, setTradingPartners] = useState([]);
|
|
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
|
const [showManualModal, setShowManualModal] = useState(false);
|
|
const [editEntryId, setEditEntryId] = useState(null);
|
|
const [journalListRefreshKey, setJournalListRefreshKey] = useState(0);
|
|
|
|
const fetchMasterData = async () => {
|
|
try {
|
|
const [acRes, tpRes] = await Promise.all([
|
|
fetch('/finance/journal-entries/account-codes'),
|
|
fetch('/finance/journal-entries/trading-partners'),
|
|
]);
|
|
const acData = await acRes.json();
|
|
const tpData = await tpRes.json();
|
|
if (acData.success) setAccountCodes(acData.data);
|
|
if (tpData.success) setTradingPartners(tpData.data);
|
|
} catch (err) {
|
|
console.error('마스터 데이터 로딩 실패:', err);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { fetchMasterData(); }, []);
|
|
|
|
const handlePartnerAdded = (newPartner) => {
|
|
setTradingPartners(prev => [...prev, newPartner]);
|
|
};
|
|
|
|
const refreshJournalList = () => {
|
|
setJournalListRefreshKey(prev => prev + 1);
|
|
};
|
|
|
|
const handleManualSaved = () => {
|
|
setShowManualModal(false);
|
|
setEditEntryId(null);
|
|
refreshJournalList();
|
|
};
|
|
|
|
const handleManualDeleted = () => {
|
|
setShowManualModal(false);
|
|
setEditEntryId(null);
|
|
refreshJournalList();
|
|
};
|
|
|
|
const handleEditEntry = (entryId) => {
|
|
setEditEntryId(entryId);
|
|
setShowManualModal(true);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* 페이지 헤더 */}
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-stone-800">일반전표입력</h1>
|
|
<p className="text-sm text-stone-500 mt-1">계좌입출금내역을 기반으로 분개 전표를 생성합니다</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => { setEditEntryId(null); setShowManualModal(true); }}
|
|
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
전표 추가
|
|
</button>
|
|
<button
|
|
onClick={() => setShowSettingsModal(true)}
|
|
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-stone-700 bg-white border border-stone-300 rounded-lg hover:bg-stone-50 transition-colors"
|
|
>
|
|
<Settings className="w-4 h-4" />
|
|
계정과목 설정
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<JournalEntryList
|
|
accountCodes={accountCodes}
|
|
tradingPartners={tradingPartners}
|
|
onPartnerAdded={handlePartnerAdded}
|
|
refreshKey={journalListRefreshKey}
|
|
onEdit={handleEditEntry}
|
|
/>
|
|
|
|
<AccountCodeSettingsModal
|
|
isOpen={showSettingsModal}
|
|
onClose={() => setShowSettingsModal(false)}
|
|
onUpdate={fetchMasterData}
|
|
/>
|
|
|
|
{showManualModal && (
|
|
<ManualJournalModal
|
|
entryId={editEntryId}
|
|
accountCodes={accountCodes}
|
|
tradingPartners={tradingPartners}
|
|
onClose={() => { setShowManualModal(false); setEditEntryId(null); }}
|
|
onSaved={handleManualSaved}
|
|
onDeleted={handleManualDeleted}
|
|
onPartnerAdded={handlePartnerAdded}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('journal-entries-root'));
|
|
root.render(<App />);
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|