diff --git a/app/Http/Controllers/Finance/JournalEntryController.php b/app/Http/Controllers/Finance/JournalEntryController.php index 0f92d811..48238735 100644 --- a/app/Http/Controllers/Finance/JournalEntryController.php +++ b/app/Http/Controllers/Finance/JournalEntryController.php @@ -55,6 +55,7 @@ public function index(Request $request): JsonResponse 'total_debit' => $entry->total_debit, 'total_credit' => $entry->total_credit, 'status' => $entry->status, + 'source_type' => $entry->source_type, 'created_by_name' => $entry->created_by_name, 'lines' => $entry->lines->map(function ($line) { return [ diff --git a/resources/views/finance/journal-entries.blade.php b/resources/views/finance/journal-entries.blade.php index c952ffda..729d8780 100644 --- a/resources/views/finance/journal-entries.blade.php +++ b/resources/views/finance/journal-entries.blade.php @@ -54,6 +54,8 @@ const ArrowUpCircle = createIcon('arrow-up-circle'); const RefreshCw = createIcon('refresh-cw'); const Settings = createIcon('settings'); +const Calendar = createIcon('calendar'); +const ClipboardList = createIcon('clipboard-list'); const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); @@ -812,6 +814,570 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f ); }; +// ============================================================ +// 탭2: ManualJournalTab (수동전표) +// ============================================================ +const ManualJournalTab = ({ accountCodes, tradingPartners, onPartnerAdded }) => { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState({ totalCount: 0, totalDebit: 0, totalCredit: 0, draftCount: 0, confirmedCount: 0 }); + const [dateRange, setDateRange] = useState({ start: getKoreanDate(-30), end: getKoreanDate() }); + const [statusFilter, setStatusFilter] = useState('all'); + const [searchText, setSearchText] = useState(''); + + // 모달 + const [showModal, setShowModal] = useState(false); + const [editEntryId, setEditEntryId] = 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' }); + const endDate = offset === 0 + ? getKoreanDate() + : new Date(target.getFullYear(), target.getMonth() + 1, 0).toLocaleDateString('en-CA', { timeZone: 'Asia/Seoul' }); + setDateRange({ start, end: endDate }); + }; + + const fetchEntries = async () => { + setLoading(true); + try { + const params = new URLSearchParams({ start_date: dateRange.start, end_date: dateRange.end }); + if (statusFilter !== 'all') params.set('status', statusFilter); + if (searchText.trim()) params.set('search', searchText.trim()); + const res = await fetch(`/finance/journal-entries/list?${params}`); + const data = await res.json(); + if (data.success) { + setEntries(data.data || []); + setStats(data.stats || {}); + } else { + notify(data.message || '조회 실패', 'error'); + } + } catch (err) { + notify('전표 목록 조회 실패', 'error'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchEntries(); }, []); + + const handleRowClick = (entry) => { + setEditEntryId(entry.id); + setShowModal(true); + }; + + const handleAddNew = () => { + setEditEntryId(null); + setShowModal(true); + }; + + const handleSaved = () => { + setShowModal(false); + setEditEntryId(null); + fetchEntries(); + }; + + const handleDeleted = () => { + setShowModal(false); + setEditEntryId(null); + fetchEntries(); + }; + + return ( +
+ {/* 필터 바 */} +
+
+ + setDateRange(prev => ({ ...prev, start: e.target.value }))} + className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-emerald-500 outline-none" /> + ~ + setDateRange(prev => ({ ...prev, end: e.target.value }))} + className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-emerald-500 outline-none" /> +
+ + + + +
+
+
+ + + setSearchText(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && fetchEntries()} + placeholder="전표번호 또는 적요 검색..." + className="flex-1 min-w-[200px] px-3 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" /> + +
+
+ + {/* 통계 카드 */} +
+
+
+
+

전체

{stats.totalCount}건

+
+
+
+
+
+

차변합계

{formatCurrency(stats.totalDebit)}

+
+
+
+
+
+

대변합계

{formatCurrency(stats.totalCredit)}

+
+
+
+
+
+

임시저장

{stats.draftCount}건

+
+
+
+
+
+

확정

{stats.confirmedCount}건

+
+
+
+ + {/* 전표 추가 버튼 */} +
+ +
+ + {/* 전표 목록 테이블 */} +
+ {loading ? ( +
+
+ 전표 목록 조회 중... +
+ ) : entries.length === 0 ? ( +
+ +

조회된 전표가 없습니다.

+
+ ) : ( +
+ + + + + + + + + + + + + + + {entries.map((entry) => ( + handleRowClick(entry)} + className="border-b border-stone-100 hover:bg-stone-50 transition-colors cursor-pointer"> + + + + + + + + + + ))} + +
전표번호일자적요차변합계대변합계출처상태작성자
{entry.entry_no}{entry.entry_date}{entry.description || '-'}{formatCurrency(entry.total_debit)}{formatCurrency(entry.total_credit)} + + {entry.source_type === 'bank_transaction' ? '은행' : '수동'} + + + + {entry.status === 'confirmed' ? '확정' : '임시'} + + {entry.created_by_name || '-'}
+
+ )} +
+ + {/* 수동 전표 모달 */} + {showModal && ( + { setShowModal(false); setEditEntryId(null); }} + onSaved={handleSaved} + onDeleted={handleDeleted} + onPartnerAdded={onPartnerAdded} + /> + )} +
+ ); +}; + +// ============================================================ +// 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', 'X-CSRF-TOKEN': CSRF_TOKEN }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + + if (data.success) { + notify(data.message || (isEditMode ? '전표가 수정되었습니다.' : '전표가 저장되었습니다.'), 'success'); + onSaved(); + } else { + notify(data.message || '저장 실패', 'error'); + } + } catch (err) { + notify('저장 중 오류가 발생했습니다.', '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 ( +
+
+ {/* 헤더 */} +
+

+ {isEditMode ? `전표 수정 (${existingEntryNo})` : '수동 전표 생성'} +

+ +
+ +
+ {loadingEntry ? ( +
+
+ 전표 데이터 로딩중... +
+ ) : ( + <> + {/* 전표 정보 */} +
+

전표 정보

+
+
+ + 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" /> +
+
+ +
+ {isEditMode ? existingEntryNo : (previewEntryNo || '자동 생성')} +
+
+
+ + 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" /> +
+
+
+ + {/* 분개 내역 테이블 */} +
+

분개 내역

+
+ + + + + + + + + + + + + + {lines.map((line, index) => ( + + + + + + + + + + ))} + + + + + + + + + +
구분계정과목거래처차변대변적요
+ + + { + const updated = [...lines]; + updated[index] = { ...updated[index], account_code: code, account_name: name }; + setLines(updated); + }} /> + + { + const updated = [...lines]; + updated[index] = { ...updated[index], trading_partner_id: id, trading_partner_name: name }; + setLines(updated); + }} + onAddPartner={() => { setAddPartnerLineIndex(index); setShowAddPartnerModal(true); }} /> + + 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 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'}`} /> + + 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 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'}`} /> + + updateLine(index, 'description', e.target.value)} + 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" /> + + +
+ + + 차변 +

{formatCurrency(totalDebit)}

+
+ 대변 +

{formatCurrency(totalCredit)}

+
+ {difference !== 0 ? ( +
+ 차이: {formatCurrency(Math.abs(difference))} +
+ ) : totalDebit > 0 ? ( +
+ 대차 균형 +
+ ) : null} +
+
+
+ + )} +
+ + {/* 하단 버튼 */} +
+
+ {isEditMode && ( + + )} +
+
+ + +
+
+
+ + {/* 거래처 추가 모달 */} + { 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); + }} + /> +
+ ); +}; + // ============================================================ // JournalEntryModal (은행거래 분개 모달) // ============================================================ @@ -1217,6 +1783,7 @@ function App() { const [accountCodes, setAccountCodes] = useState([]); const [tradingPartners, setTradingPartners] = useState([]); const [showSettingsModal, setShowSettingsModal] = useState(false); + const [activeTab, setActiveTab] = useState('bank'); const fetchMasterData = async () => { try { @@ -1245,7 +1812,7 @@ function App() {

일반전표입력

-

계좌입출금내역을 기반으로 분개 전표를 생성합니다

+

은행거래 기반 분개 또는 수동 전표를 생성합니다

- + {/* 탭 네비게이션 */} +
+ + +
+ + {activeTab === 'bank' ? ( + + ) : ( + + )}