Files
sam-manage/resources/views/finance/journal-entries.blade.php
김보곤 436c97d942 fix:일반전표입력 레이아웃 전체 너비로 확장
- max-w-7xl mx-auto 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:39:41 +09:00

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