958 lines
52 KiB
PHP
958 lines
52 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')
|
|
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
|
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
|
|
<script src="https://unpkg.com/lucide@latest?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 Calendar = createIcon('calendar');
|
|
const X = createIcon('x');
|
|
const Edit = createIcon('edit');
|
|
const CheckCircle = createIcon('check-circle');
|
|
const AlertTriangle = createIcon('alert-triangle');
|
|
const ChevronDown = createIcon('chevron-down');
|
|
|
|
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;
|
|
};
|
|
|
|
// ============================================================
|
|
// AccountCodeSelect (emerald 테마, 검색 가능한 드롭다운)
|
|
// ============================================================
|
|
const AccountCodeSelect = ({ value, onChange, accountCodes }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [highlightIndex, setHighlightIndex] = useState(-1);
|
|
const containerRef = useRef(null);
|
|
const listRef = useRef(null);
|
|
|
|
const selectedItem = accountCodes.find(c => c.code === value);
|
|
const displayText = selectedItem ? `${selectedItem.code} ${selectedItem.name}` : '';
|
|
|
|
const filteredCodes = accountCodes.filter(code => {
|
|
if (!search) return true;
|
|
const s = search.toLowerCase();
|
|
return code.code.toLowerCase().includes(s) || code.name.toLowerCase().includes(s);
|
|
});
|
|
|
|
useEffect(() => { setHighlightIndex(-1); }, [search]);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleSelect = (code) => {
|
|
onChange(code.code, code.name);
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
};
|
|
const handleClear = (e) => { e.stopPropagation(); onChange('', ''); setSearch(''); };
|
|
|
|
const handleKeyDown = (e) => {
|
|
const maxIndex = Math.min(filteredCodes.length, 50) - 1;
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
const ni = highlightIndex < maxIndex ? highlightIndex + 1 : 0;
|
|
setHighlightIndex(ni);
|
|
setTimeout(() => { listRef.current?.children[ni]?.scrollIntoView({ block: 'nearest' }); }, 0);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
const ni = highlightIndex > 0 ? highlightIndex - 1 : maxIndex;
|
|
setHighlightIndex(ni);
|
|
setTimeout(() => { listRef.current?.children[ni]?.scrollIntoView({ block: 'nearest' }); }, 0);
|
|
} else if (e.key === 'Enter' && filteredCodes.length > 0) {
|
|
e.preventDefault();
|
|
handleSelect(filteredCodes[highlightIndex >= 0 ? highlightIndex : 0]);
|
|
} else if (e.key === 'Escape') {
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<div onClick={() => setIsOpen(!isOpen)}
|
|
className={`w-full px-2 py-1.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 && (
|
|
<div className="absolute z-50 mt-1 w-56 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>
|
|
)}
|
|
</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 className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">연락처</label>
|
|
<input type="text" value={form.contact} onChange={(e) => setForm({ ...form, contact: e.target.value })}
|
|
placeholder="전화번호"
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">담당자</label>
|
|
<input type="text" value={form.manager} onChange={(e) => setForm({ ...form, manager: e.target.value })}
|
|
placeholder="담당자명"
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">메모</label>
|
|
<input type="text" value={form.memo} onChange={(e) => setForm({ ...form, memo: e.target.value })}
|
|
placeholder="메모"
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-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 }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [highlightIndex, setHighlightIndex] = useState(-1);
|
|
const containerRef = useRef(null);
|
|
const listRef = useRef(null);
|
|
|
|
const displayText = valueName || '';
|
|
|
|
const filteredPartners = tradingPartners.filter(p => {
|
|
if (!search) return true;
|
|
const s = search.toLowerCase();
|
|
return p.name.toLowerCase().includes(s) || (p.biz_no && p.biz_no.includes(search));
|
|
});
|
|
|
|
useEffect(() => { setHighlightIndex(-1); }, [search]);
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleSelect = (partner) => {
|
|
onChange(partner.id, partner.name);
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
};
|
|
const handleClear = (e) => { e.stopPropagation(); onChange(null, ''); setSearch(''); };
|
|
|
|
const handleKeyDown = (e) => {
|
|
const maxIndex = Math.min(filteredPartners.length, 50) - 1;
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
const ni = highlightIndex < maxIndex ? highlightIndex + 1 : 0;
|
|
setHighlightIndex(ni);
|
|
setTimeout(() => { listRef.current?.children[ni]?.scrollIntoView({ block: 'nearest' }); }, 0);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
const ni = highlightIndex > 0 ? highlightIndex - 1 : maxIndex;
|
|
setHighlightIndex(ni);
|
|
setTimeout(() => { listRef.current?.children[ni]?.scrollIntoView({ block: 'nearest' }); }, 0);
|
|
} else if (e.key === 'Enter' && filteredPartners.length > 0) {
|
|
e.preventDefault();
|
|
handleSelect(filteredPartners[highlightIndex >= 0 ? highlightIndex : 0]);
|
|
} else if (e.key === 'Escape') {
|
|
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<div onClick={() => setIsOpen(!isOpen)}
|
|
className={`w-full px-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 && (
|
|
<div className="absolute z-50 mt-1 w-56 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>
|
|
))}
|
|
{filteredPartners.length > 50 && <div className="px-3 py-1 text-xs text-stone-400 text-center border-t">+{filteredPartners.length - 50}개 더 있음</div>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// JournalEntryList
|
|
// ============================================================
|
|
const JournalEntryList = ({ entries, stats, loading, onNew, onSelect, dateRange, setDateRange, searchTerm, setSearchTerm, filterStatus, setFilterStatus, onRefresh }) => {
|
|
return (
|
|
<div>
|
|
{/* 통계 카드 */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
<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"><FileText 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-stone-800">{stats.totalCount || 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-blue-50 rounded-lg"><Edit 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-stone-800">{stats.draftCount || 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-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-stone-800">{stats.confirmedCount || 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"><Save 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-stone-800">{formatCurrency(stats.totalDebit)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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">
|
|
<div className="flex items-center gap-2">
|
|
<input type="date" value={dateRange.start} onChange={(e) => setDateRange(prev => ({ ...prev, start: 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" />
|
|
<span className="text-stone-400">~</span>
|
|
<input type="date" value={dateRange.end} onChange={(e) => setDateRange(prev => ({ ...prev, end: 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" />
|
|
</div>
|
|
<select value={filterStatus} onChange={(e) => setFilterStatus(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="draft">임시저장</option>
|
|
<option value="confirmed">확정</option>
|
|
</select>
|
|
<div className="relative flex-1 min-w-[200px]">
|
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" />
|
|
<input type="text" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)}
|
|
placeholder="전표번호, 적요 검색..."
|
|
className="w-full pl-9 pr-3 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" />
|
|
</div>
|
|
<button onClick={onRefresh} className="px-4 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors">조회</button>
|
|
<button onClick={onNew} className="px-4 py-1.5 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-1">
|
|
<Plus className="w-4 h-4" /> 새 전표
|
|
</button>
|
|
</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>
|
|
) : entries.length === 0 ? (
|
|
<div className="p-12 text-center text-stone-400">
|
|
<FileText className="w-12 h-12 mx-auto mb-3 text-stone-300" />
|
|
<p>등록된 전표가 없습니다.</p>
|
|
</div>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-stone-50 border-b border-stone-200">
|
|
<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-right font-medium text-stone-600">차변합계</th>
|
|
<th className="px-4 py-3 text-right font-medium text-stone-600">대변합계</th>
|
|
<th className="px-4 py-3 text-center font-medium text-stone-600">상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{entries.map(entry => (
|
|
<tr key={entry.id} onClick={() => onSelect(entry)} className="border-b border-stone-100 hover:bg-emerald-50 cursor-pointer transition-colors">
|
|
<td className="px-4 py-3 font-mono text-emerald-600 font-medium">{entry.entry_no}</td>
|
|
<td className="px-4 py-3 text-stone-600">{entry.entry_date}</td>
|
|
<td className="px-4 py-3 text-stone-800 max-w-[300px] truncate">{entry.description || '-'}</td>
|
|
<td className="px-4 py-3 text-right font-medium text-blue-600">{formatCurrency(entry.total_debit)}</td>
|
|
<td className="px-4 py-3 text-right font-medium text-red-600">{formatCurrency(entry.total_credit)}</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${entry.status === 'confirmed' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>
|
|
{entry.status === 'confirmed' ? '확정' : '임시저장'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// JournalEntryForm (이카운트 스타일 분개 입력)
|
|
// ============================================================
|
|
const JournalEntryForm = ({ entry, accountCodes, tradingPartners, onSave, onDelete, onBack, saving, onPartnerAdded }) => {
|
|
const isEdit = !!entry;
|
|
const [showAddPartnerModal, setShowAddPartnerModal] = useState(false);
|
|
const [addPartnerLineIndex, setAddPartnerLineIndex] = useState(null);
|
|
|
|
const emptyLine = () => ({
|
|
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 [formData, setFormData] = useState({
|
|
entry_date: entry?.entry_date || new Date().toISOString().split('T')[0],
|
|
description: entry?.description || '',
|
|
attachment_note: entry?.attachment_note || '',
|
|
});
|
|
|
|
const [lines, setLines] = useState(
|
|
entry?.lines?.length > 0
|
|
? entry.lines.map(l => ({ ...l, key: l.id || Date.now() + Math.random() }))
|
|
: [{ ...emptyLine(), dc_type: 'debit' }, { ...emptyLine(), dc_type: 'credit' }]
|
|
);
|
|
|
|
const [entryNo, setEntryNo] = useState(entry?.entry_no || '');
|
|
|
|
// 새 전표일 때 전표번호 미리보기
|
|
useEffect(() => {
|
|
if (!isEdit) {
|
|
fetch(`/finance/journal-entries/next-entry-no?date=${formData.entry_date}`)
|
|
.then(r => r.json())
|
|
.then(d => { if (d.success) setEntryNo(d.entry_no); });
|
|
}
|
|
}, [formData.entry_date, isEdit]);
|
|
|
|
// 합계 계산
|
|
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 difference = totalDebit - totalCredit;
|
|
const isBalanced = totalDebit === totalCredit && totalDebit > 0;
|
|
|
|
const addLine = () => setLines([...lines, emptyLine()]);
|
|
|
|
const removeLine = (index) => {
|
|
if (lines.length <= 2) return;
|
|
setLines(lines.filter((_, i) => i !== index));
|
|
};
|
|
|
|
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 handleSubmit = () => {
|
|
if (!isBalanced) return;
|
|
|
|
const payload = {
|
|
entry_date: formData.entry_date,
|
|
description: formData.description,
|
|
attachment_note: formData.attachment_note,
|
|
lines: lines.map((l, i) => ({
|
|
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,
|
|
})),
|
|
};
|
|
onSave(payload);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* 헤더 */}
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-5 mb-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-bold text-stone-800">{isEdit ? '전표 수정' : '새 전표 입력'}</h2>
|
|
<button onClick={onBack} className="text-sm text-stone-500 hover:text-stone-700 flex items-center gap-1">
|
|
<ArrowLeft className="w-4 h-4" /> 목록으로
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">전표번호</label>
|
|
<input type="text" value={entryNo} readOnly
|
|
className="w-full px-3 py-2 text-sm bg-stone-50 border border-stone-200 rounded-lg text-stone-500 font-mono" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">전표일자 <span className="text-red-500">*</span></label>
|
|
<input type="date" value={formData.entry_date}
|
|
onChange={(e) => setFormData({ ...formData, entry_date: 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 className="md:col-span-2">
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">적요</label>
|
|
<input type="text" value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: 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 className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden mb-4">
|
|
<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-center font-medium text-stone-600 w-[80px]">구분</th>
|
|
<th className="px-3 py-2.5 text-left font-medium text-stone-600 w-[200px]">계정과목</th>
|
|
<th className="px-3 py-2.5 text-left font-medium text-stone-600 w-[180px]">거래처</th>
|
|
<th className="px-3 py-2.5 text-right font-medium text-stone-600 w-[140px]">차변</th>
|
|
<th className="px-3 py-2.5 text-right font-medium text-stone-600 w-[140px]">대변</th>
|
|
<th className="px-3 py-2.5 text-left font-medium text-stone-600">적요</th>
|
|
<th className="px-3 py-2.5 text-center font-medium text-stone-600 w-[50px]"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{lines.map((line, index) => (
|
|
<tr key={line.key} className="border-b border-stone-100 hover:bg-stone-50">
|
|
{/* 구분 */}
|
|
<td className="px-3 py-2">
|
|
<select value={line.dc_type} onChange={(e) => updateLine(index, 'dc_type', e.target.value)}
|
|
className={`w-full px-2 py-1.5 text-xs border rounded-lg outline-none font-medium ${line.dc_type === 'debit' ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-red-50 border-red-200 text-red-700'}`}>
|
|
<option value="debit">차변</option>
|
|
<option value="credit">대변</option>
|
|
</select>
|
|
</td>
|
|
{/* 계정과목 */}
|
|
<td className="px-3 py-2">
|
|
<AccountCodeSelect
|
|
value={line.account_code}
|
|
accountCodes={accountCodes}
|
|
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}
|
|
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"
|
|
value={line.dc_type === 'debit' ? formatInputCurrency(line.debit_amount) : ''}
|
|
onChange={(e) => updateLine(index, 'debit_amount', parseInputCurrency(e.target.value))}
|
|
disabled={line.dc_type !== 'debit'}
|
|
placeholder={line.dc_type === 'debit' ? '금액 입력' : ''}
|
|
className={`w-full px-2 py-1.5 text-xs text-right border rounded-lg 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"
|
|
value={line.dc_type === 'credit' ? formatInputCurrency(line.credit_amount) : ''}
|
|
onChange={(e) => updateLine(index, 'credit_amount', parseInputCurrency(e.target.value))}
|
|
disabled={line.dc_type !== 'credit'}
|
|
placeholder={line.dc_type === 'credit' ? '금액 입력' : ''}
|
|
className={`w-full px-2 py-1.5 text-xs text-right border rounded-lg 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" value={line.description || ''}
|
|
onChange={(e) => updateLine(index, 'description', e.target.value)}
|
|
placeholder="적요"
|
|
className="w-full px-2 py-1.5 text-xs border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none"
|
|
/>
|
|
</td>
|
|
{/* 삭제 */}
|
|
<td className="px-3 py-2 text-center">
|
|
<button 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-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
{/* 합계 행 */}
|
|
<tfoot>
|
|
<tr className="bg-stone-50 border-t-2 border-stone-300">
|
|
<td colSpan={3} className="px-3 py-3">
|
|
<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-3 text-right">
|
|
<span className="text-xs text-stone-500">차변 합계</span>
|
|
<p className="font-bold text-blue-700">{formatCurrency(totalDebit)}</p>
|
|
</td>
|
|
<td className="px-3 py-3 text-right">
|
|
<span className="text-xs text-stone-500">대변 합계</span>
|
|
<p className="font-bold text-red-700">{formatCurrency(totalCredit)}</p>
|
|
</td>
|
|
<td colSpan={2} className="px-3 py-3">
|
|
{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))}
|
|
({difference > 0 ? '차변 초과' : '대변 초과'})
|
|
</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 className="bg-white rounded-xl shadow-sm border border-stone-100 p-5 mb-4">
|
|
<label className="block text-xs font-medium text-stone-500 mb-1">첨부 메모</label>
|
|
<textarea value={formData.attachment_note}
|
|
onChange={(e) => setFormData({ ...formData, attachment_note: e.target.value })}
|
|
placeholder="추가 메모사항을 입력하세요"
|
|
rows={2}
|
|
className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* 하단 버튼 */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
{isEdit && (
|
|
<button onClick={onDelete}
|
|
className="px-4 py-2 text-sm text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition-colors flex items-center gap-1">
|
|
<Trash2 className="w-4 h-4" /> 삭제
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={onBack} className="px-4 py-2 text-sm text-stone-600 bg-stone-100 rounded-lg hover:bg-stone-200 transition-colors">
|
|
취소
|
|
</button>
|
|
<button onClick={handleSubmit}
|
|
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 ? '저장 중...' : isEdit ? '수정' : '저장'}
|
|
</button>
|
|
</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>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// App (최상위)
|
|
// ============================================================
|
|
function App() {
|
|
const [viewMode, setViewMode] = useState('list');
|
|
const [entries, setEntries] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [stats, setStats] = useState({});
|
|
const [selectedEntry, setSelectedEntry] = useState(null);
|
|
const [accountCodes, setAccountCodes] = useState([]);
|
|
const [tradingPartners, setTradingPartners] = useState([]);
|
|
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState('all');
|
|
const [dateRange, setDateRange] = useState({
|
|
start: new Date(new Date().setDate(new Date().getDate() - 30)).toISOString().split('T')[0],
|
|
end: new Date().toISOString().split('T')[0],
|
|
});
|
|
|
|
const fetchEntries = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({
|
|
start_date: dateRange.start,
|
|
end_date: dateRange.end,
|
|
status: filterStatus,
|
|
search: searchTerm,
|
|
});
|
|
const res = await fetch(`/finance/journal-entries/list?${params}`);
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
setEntries(data.data);
|
|
setStats(data.stats);
|
|
}
|
|
} catch (err) {
|
|
console.error('전표 목록 조회 실패:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
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(() => { fetchEntries(); fetchMasterData(); }, []);
|
|
|
|
const handleNew = () => {
|
|
setSelectedEntry(null);
|
|
setViewMode('form');
|
|
};
|
|
|
|
const handleSelect = (entry) => {
|
|
setSelectedEntry(entry);
|
|
setViewMode('form');
|
|
};
|
|
|
|
const handleSave = async (payload) => {
|
|
setSaving(true);
|
|
try {
|
|
const isEdit = !!selectedEntry;
|
|
const url = isEdit ? `/finance/journal-entries/${selectedEntry.id}` : '/finance/journal-entries/store';
|
|
const method = isEdit ? 'PUT' : 'POST';
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
alert(data.message);
|
|
setViewMode('list');
|
|
fetchEntries();
|
|
} else {
|
|
alert(data.message || '저장에 실패했습니다.');
|
|
}
|
|
} catch (err) {
|
|
console.error('저장 실패:', err);
|
|
alert('저장 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!selectedEntry || !confirm('정말 삭제하시겠습니까?')) return;
|
|
try {
|
|
const res = await fetch(`/finance/journal-entries/${selectedEntry.id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'X-CSRF-TOKEN': CSRF_TOKEN },
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
alert(data.message);
|
|
setViewMode('list');
|
|
fetchEntries();
|
|
}
|
|
} catch (err) {
|
|
alert('삭제 중 오류가 발생했습니다.');
|
|
}
|
|
};
|
|
|
|
const handleBack = () => {
|
|
setViewMode('list');
|
|
setSelectedEntry(null);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* 페이지 헤더 */}
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold text-stone-800">일반전표입력</h1>
|
|
<p className="text-sm text-stone-500 mt-1">차변/대변 분개 전표를 등록하고 관리합니다</p>
|
|
</div>
|
|
|
|
{viewMode === 'list' ? (
|
|
<JournalEntryList
|
|
entries={entries}
|
|
stats={stats}
|
|
loading={loading}
|
|
onNew={handleNew}
|
|
onSelect={handleSelect}
|
|
dateRange={dateRange}
|
|
setDateRange={setDateRange}
|
|
searchTerm={searchTerm}
|
|
setSearchTerm={setSearchTerm}
|
|
filterStatus={filterStatus}
|
|
setFilterStatus={setFilterStatus}
|
|
onRefresh={fetchEntries}
|
|
/>
|
|
) : (
|
|
<JournalEntryForm
|
|
entry={selectedEntry}
|
|
accountCodes={accountCodes}
|
|
tradingPartners={tradingPartners}
|
|
onSave={handleSave}
|
|
onDelete={handleDelete}
|
|
onBack={handleBack}
|
|
saving={saving}
|
|
onPartnerAdded={(newPartner) => setTradingPartners(prev => [...prev, newPartner])}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('journal-entries-root'));
|
|
root.render(<App />);
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|