Files
sam-manage/resources/views/barobill/eaccount/index.blade.php
김보곤 35696400a2 fix: [eaccount] 기간 검색 시 stale closure 문제 수정
- loadTransactions/loadSplits에 명시적 날짜 파라미터 추가
- 조회 버튼 클릭 시 TransactionTable prop의 최신 날짜 직접 전달
- 편의 버튼(이번달/지난달/D-N월) 클릭 시 자동 검색 트리거
2026-03-04 12:50:49 +09:00

2440 lines
122 KiB
PHP

@extends('layouts.app')
@section('title', '계좌 입출금내역')
@section('content')
<div id="eaccount-root"></div>
@endsection
@push('scripts')
@include('partials.react-cdn')
<script type="text/babel">
const { useState, useEffect, useRef, useCallback } = React;
// API Routes
const API = {
accounts: '{{ route("barobill.eaccount.accounts") }}',
transactions: '{{ route("barobill.eaccount.transactions") }}',
accountCodes: '{{ route("barobill.eaccount.account-codes") }}',
accountCodesAll: '{{ route("barobill.eaccount.account-codes.all") }}',
accountCodesStore: '{{ route("barobill.eaccount.account-codes.store") }}',
accountCodesUpdate: (id) => `/barobill/eaccount/account-codes/${id}`,
accountCodesDestroy: (id) => `/barobill/eaccount/account-codes/${id}`,
save: '{{ route("barobill.eaccount.save") }}',
export: '{{ route("barobill.eaccount.export") }}',
saveOverride: '{{ route("barobill.eaccount.save-override") }}',
manualStore: '{{ route("barobill.eaccount.manual.store") }}',
manualUpdate: '{{ route("barobill.eaccount.manual.update", ":id") }}',
manualDestroy: '{{ route("barobill.eaccount.manual.destroy", ":id") }}',
splits: '{{ route("barobill.eaccount.splits") }}',
saveSplits: '{{ route("barobill.eaccount.splits.save") }}',
deleteSplits: '{{ route("barobill.eaccount.splits.delete") }}',
clientsSearch: '{{ route("barobill.eaccount.clients.search") }}',
};
const CSRF_TOKEN = '{{ csrf_token() }}';
// 로컬 타임존 기준 날짜 포맷 (YYYY-MM-DD) - 한국 시간 기준
const formatLocalDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// 날짜 유틸리티 함수
const getMonthDates = (offset = 0) => {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + offset;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
return {
from: formatLocalDate(firstDay),
to: formatLocalDate(lastDay)
};
};
// Toast 알림 (전역 showToast 사용)
const notify = (message, type = 'info') => {
if (typeof window.showToast === 'function') {
window.showToast(message, type);
} else {
alert(message);
}
};
// CompactStat Component (큰 크기 통계 표시)
const CompactStat = ({ label, value, color = 'stone' }) => {
const colorClasses = {
blue: 'text-blue-600',
green: 'text-green-600',
emerald: 'text-emerald-600',
red: 'text-red-600',
stone: 'text-stone-700'
};
return (
<div className="flex items-center gap-3 px-6 py-4 bg-white rounded-xl border border-stone-200 shadow-sm">
<span className="text-base text-stone-500 font-medium">{label}</span>
<span className={`text-xl font-bold ${colorClasses[color]}`}>{value}</span>
</div>
);
};
// AccountSelector Component (컴팩트 버전)
const AccountSelector = ({ accounts, selectedAccount, onSelect }) => (
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => onSelect('')}
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
selectedAccount === ''
? 'bg-emerald-600 text-white'
: 'bg-stone-100 text-stone-600 hover:bg-stone-200'
}`}
>
전체
</button>
{accounts.map(acc => (
<button
key={acc.bankAccountNum}
onClick={() => onSelect(acc.bankAccountNum)}
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
selectedAccount === acc.bankAccountNum
? 'bg-emerald-600 text-white'
: 'bg-stone-100 text-stone-600 hover:bg-stone-200'
}`}
>
{acc.bankName} ****{acc.bankAccountNum ? acc.bankAccountNum.slice(-4) : ''}
</button>
))}
</div>
);
// AccountCodeSelect Component (검색 가능한 드롭다운)
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 searchLower = search.toLowerCase();
return code.code.toLowerCase().includes(searchLower) ||
code.name.toLowerCase().includes(searchLower);
});
// 검색어 변경 시 하이라이트 초기화
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) => {
const selected = accountCodes.find(c => c.code === code.code);
onChange(code.code, selected?.name || '');
setIsOpen(false);
setSearch('');
setHighlightIndex(-1);
};
const handleClear = (e) => {
e.stopPropagation();
onChange('', '');
setSearch('');
setHighlightIndex(-1);
};
// 키보드 네비게이션
const handleKeyDown = (e) => {
const maxIndex = Math.min(filteredCodes.length, 50) - 1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const newIndex = highlightIndex < maxIndex ? highlightIndex + 1 : 0;
setHighlightIndex(newIndex);
// 스크롤 따라가기
setTimeout(() => {
if (listRef.current && listRef.current.children[newIndex]) {
listRef.current.children[newIndex].scrollIntoView({ block: 'nearest' });
}
}, 0);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const newIndex = highlightIndex > 0 ? highlightIndex - 1 : maxIndex;
setHighlightIndex(newIndex);
setTimeout(() => {
if (listRef.current && listRef.current.children[newIndex]) {
listRef.current.children[newIndex].scrollIntoView({ block: 'nearest' });
}
}, 0);
} else if (e.key === 'Enter' && filteredCodes.length > 0) {
e.preventDefault();
const selectIndex = highlightIndex >= 0 ? highlightIndex : 0;
handleSelect(filteredCodes[selectIndex]);
} 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 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' : 'text-stone-400'}>
{displayText || '선택'}
</span>
<div className="flex items-center gap-1">
{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-48 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>
);
};
// ClientCodeSelect Component (서버 검색 기반 드롭다운)
const ClientCodeSelect = ({ value, clientName, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [highlightIndex, setHighlightIndex] = useState(-1);
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const containerRef = useRef(null);
const listRef = useRef(null);
const debounceRef = useRef(null);
// 표시 텍스트
const displayText = value ? `${value} ${clientName || ''}` : '';
// 검색어 변경 시 debounce API 호출
useEffect(() => {
if (!isOpen) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!search || search.length < 1) {
setResults([]);
setHighlightIndex(-1);
return;
}
setSearching(true);
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(`${API.clientsSearch}?q=${encodeURIComponent(search)}`);
const data = await res.json();
if (data.success) {
setResults(data.data || []);
}
} catch (err) {
console.error('거래처 검색 오류:', err);
} finally {
setSearching(false);
setHighlightIndex(-1);
}
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search, isOpen]);
// 외부 클릭 시 닫기
useEffect(() => {
const handleClickOutside = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setIsOpen(false);
setSearch('');
setHighlightIndex(-1);
setResults([]);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (item) => {
onChange(item.code, item.name);
setIsOpen(false);
setSearch('');
setHighlightIndex(-1);
setResults([]);
};
const handleClear = (e) => {
e.stopPropagation();
onChange('', '');
setSearch('');
setHighlightIndex(-1);
setResults([]);
};
// 키보드 네비게이션
const handleKeyDown = (e) => {
const maxIndex = results.length - 1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const newIndex = highlightIndex < maxIndex ? highlightIndex + 1 : 0;
setHighlightIndex(newIndex);
setTimeout(() => {
if (listRef.current && listRef.current.children[newIndex]) {
listRef.current.children[newIndex].scrollIntoView({ block: 'nearest' });
}
}, 0);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const newIndex = highlightIndex > 0 ? highlightIndex - 1 : maxIndex;
setHighlightIndex(newIndex);
setTimeout(() => {
if (listRef.current && listRef.current.children[newIndex]) {
listRef.current.children[newIndex].scrollIntoView({ block: 'nearest' });
}
}, 0);
} else if (e.key === 'Enter' && results.length > 0) {
e.preventDefault();
const selectIndex = highlightIndex >= 0 ? highlightIndex : 0;
handleSelect(results[selectIndex]);
} else if (e.key === 'Escape') {
setIsOpen(false);
setSearch('');
setHighlightIndex(-1);
setResults([]);
}
};
return (
<div ref={containerRef} className="relative">
{/* 선택 버튼 */}
<div
onClick={() => setIsOpen(!isOpen)}
className={`w-full px-2 py-1 text-xs border rounded cursor-pointer flex items-center justify-between gap-1 ${
isOpen ? 'border-blue-500 ring-2 ring-blue-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-blue-500 outline-none"
autoFocus
/>
</div>
{/* 옵션 목록 */}
<div ref={listRef} className="max-h-48 overflow-y-auto">
{searching ? (
<div className="px-3 py-2 text-xs text-stone-400 text-center">
검색 ...
</div>
) : results.length === 0 ? (
<div className="px-3 py-2 text-xs text-stone-400 text-center">
{search ? '검색 결과 없음' : '검색어를 입력하세요'}
</div>
) : (
results.map((item, index) => (
<div
key={item.code}
onClick={() => handleSelect(item)}
className={`px-3 py-1.5 text-xs cursor-pointer ${
index === highlightIndex
? 'bg-blue-600 text-white font-semibold'
: value === item.code
? 'bg-blue-100 text-blue-700'
: 'text-stone-700 hover:bg-blue-50'
}`}
>
<span className={`font-mono ${index === highlightIndex ? 'text-white' : 'text-blue-600'}`}>{item.code}</span>
<span className="ml-1">{item.name}</span>
</div>
))
)}
</div>
</div>
)}
</div>
);
};
// AccountCodeSettingsModal Component
const AccountCodeSettingsModal = ({ isOpen, onClose, onUpdate }) => {
const [codes, setCodes] = useState([]);
const [loading, setLoading] = useState(false);
const [newCode, setNewCode] = useState('');
const [newName, setNewName] = useState('');
const [newCategory, setNewCategory] = useState('');
const [filter, setFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const categories = ['자산', '부채', '자본', '수익', '비용'];
// 모달 열릴 때 데이터 로드
useEffect(() => {
if (isOpen) {
loadCodes();
}
}, [isOpen]);
const loadCodes = async () => {
setLoading(true);
try {
const res = await fetch(API.accountCodesAll);
const data = await res.json();
if (data.success) {
setCodes(data.data || []);
}
} catch (err) {
notify('계정과목 로드 실패', 'error');
} finally {
setLoading(false);
}
};
const handleAdd = async () => {
if (!newCode.trim() || !newName.trim()) {
notify('코드와 이름을 입력해주세요.', 'warning');
return;
}
try {
const res = await fetch(API.accountCodesStore, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN
},
body: JSON.stringify({
code: newCode.trim(),
name: newName.trim(),
category: newCategory || null
})
});
const data = await res.json();
if (data.success) {
notify('계정과목이 추가되었습니다.', 'success');
setNewCode('');
setNewName('');
setNewCategory('');
loadCodes();
onUpdate();
} else {
notify(data.error || '추가 실패', 'error');
}
} catch (err) {
notify('추가 실패: ' + err.message, 'error');
}
};
const handleToggleActive = async (item) => {
try {
const res = await fetch(API.accountCodesUpdate(item.id), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN
},
body: JSON.stringify({ is_active: !item.is_active })
});
const data = await res.json();
if (data.success) {
loadCodes();
onUpdate();
}
} catch (err) {
notify('변경 실패', 'error');
}
};
const handleDelete = async (item) => {
if (!confirm(`"${item.code} ${item.name}" 계정과목을 삭제하시겠습니까?`)) return;
try {
const res = await fetch(API.accountCodesDestroy(item.id), {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': CSRF_TOKEN
}
});
const data = await res.json();
if (data.success) {
notify('삭제되었습니다.', 'success');
loadCodes();
onUpdate();
} else {
notify(data.error || '삭제 실패', 'error');
}
} catch (err) {
notify('삭제 실패: ' + err.message, 'error');
}
};
const filteredCodes = codes.filter(c => {
const matchText = filter === '' ||
c.code.includes(filter) ||
c.name.includes(filter);
const matchCategory = categoryFilter === '' || c.category === categoryFilter;
return matchText && matchCategory;
});
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stone-200 bg-stone-50">
<h2 className="text-lg font-bold text-stone-900">계정과목 설정</h2>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600 text-2xl">&times;</button>
</div>
{/* Add Form */}
<div className="px-6 py-4 border-b border-stone-100 bg-emerald-50/50">
<div className="flex gap-2 items-end">
<div className="flex-1">
<label className="block text-xs font-medium text-stone-600 mb-1">코드</label>
<input
type="text"
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
placeholder="예: 101"
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
/>
</div>
<div className="flex-[2]">
<label className="block text-xs font-medium text-stone-600 mb-1">계정과목명</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="예: 현금"
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-stone-600 mb-1">분류</label>
<select
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
>
<option value="">선택</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<button
onClick={handleAdd}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 text-sm font-medium"
>
추가
</button>
</div>
</div>
{/* Filter */}
<div className="px-6 py-3 border-b border-stone-100 flex gap-3">
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="코드 또는 이름 검색..."
className="flex-1 px-3 py-2 border border-stone-200 rounded-lg text-sm"
/>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 border border-stone-200 rounded-lg text-sm"
>
<option value="">전체 분류</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
<span className="text-sm text-stone-500 py-2">
{filteredCodes.length}
</span>
</div>
{/* List */}
<div className="overflow-y-auto" style={ {maxHeight: '400px'} }>
{loading ? (
<div className="p-8 text-center text-stone-400">로딩 ...</div>
) : (
<table className="w-full text-sm">
<thead className="bg-stone-50 sticky top-0">
<tr>
<th className="px-4 py-3 text-left font-medium text-stone-600">코드</th>
<th className="px-4 py-3 text-left font-medium text-stone-600">계정과목명</th>
<th className="px-4 py-3 text-left font-medium text-stone-600">분류</th>
<th className="px-4 py-3 text-center font-medium text-stone-600">상태</th>
<th className="px-4 py-3 text-center font-medium text-stone-600">작업</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{filteredCodes.map(item => (
<tr key={item.id} className={`hover:bg-stone-50 ${!item.is_active ? 'opacity-50' : ''}`}>
<td className="px-4 py-2 font-mono">{item.code}</td>
<td className="px-4 py-2">{item.name}</td>
<td className="px-4 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${
item.category === '자산' ? 'bg-blue-100 text-blue-700' :
item.category === '부채' ? 'bg-red-100 text-red-700' :
item.category === '자본' ? 'bg-purple-100 text-purple-700' :
item.category === '수익' ? 'bg-green-100 text-green-700' :
item.category === '비용' ? 'bg-orange-100 text-orange-700' :
'bg-stone-100 text-stone-600'
}`}>
{item.category || '-'}
</span>
</td>
<td className="px-4 py-2 text-center">
<button
onClick={() => handleToggleActive(item)}
className={`px-2 py-1 rounded text-xs ${
item.is_active
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-stone-100 text-stone-500 hover:bg-stone-200'
}`}
>
{item.is_active ? '사용중' : '미사용'}
</button>
</td>
<td className="px-4 py-2 text-center">
<button
onClick={() => handleDelete(item)}
className="text-red-500 hover:text-red-700 text-xs"
>
삭제
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-stone-200 bg-stone-50 flex justify-end">
<button
onClick={onClose}
className="px-6 py-2 bg-stone-600 text-white rounded-lg hover:bg-stone-700 font-medium"
>
닫기
</button>
</div>
</div>
</div>
);
};
// 적요/내용 수정 모달 컴포넌트
const TransactionEditModal = ({ isOpen, onClose, log, onSave }) => {
const [modifiedSummary, setModifiedSummary] = useState('');
const [modifiedCast, setModifiedCast] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen && log) {
// 현재 표시되는 값으로 초기화 (수정된 값이 있으면 그 값, 없으면 원본)
setModifiedSummary(log.summary || '');
setModifiedCast(log.cast || '');
}
}, [isOpen, log]);
const handleSave = async () => {
if (!log?.uniqueKey) {
notify('고유 키가 없습니다.', 'error');
return;
}
setSaving(true);
try {
const res = await fetch(API.saveOverride, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
},
body: JSON.stringify({
uniqueKey: log.uniqueKey,
modifiedSummary: modifiedSummary !== log.originalSummary ? modifiedSummary : null,
modifiedCast: modifiedCast !== log.originalCast ? modifiedCast : null,
}),
});
const data = await res.json();
if (data.success) {
notify(data.message, 'success');
onSave(modifiedSummary, modifiedCast);
onClose();
} else {
notify(data.error || '저장 실패', 'error');
}
} catch (err) {
notify('저장 오류: ' + err.message, 'error');
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!confirm('원본 값으로 되돌리시겠습니까?')) return;
setSaving(true);
try {
const res = await fetch(API.saveOverride, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
},
body: JSON.stringify({
uniqueKey: log.uniqueKey,
modifiedSummary: null,
modifiedCast: null,
}),
});
const data = await res.json();
if (data.success) {
notify('원본으로 복원되었습니다.', 'success');
onSave(log.originalSummary, log.originalCast);
onClose();
} else {
notify(data.error || '복원 실패', 'error');
}
} catch (err) {
notify('복원 오류: ' + err.message, 'error');
} finally {
setSaving(false);
}
};
if (!isOpen || !log) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stone-200 bg-stone-50">
<h2 className="text-lg font-bold text-stone-900">적요/내용 수정</h2>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600 text-2xl">&times;</button>
</div>
{/* 거래 정보 */}
<div className="px-6 py-4 bg-emerald-50/50 border-b border-stone-100">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-stone-500">거래일시:</span>
<span className="ml-2 font-medium">{log.transDateTime}</span>
</div>
<div>
<span className="text-stone-500">계좌:</span>
<span className="ml-2 font-medium">{log.bankName}</span>
</div>
<div>
<span className="text-stone-500">입금:</span>
<span className="ml-2 font-medium text-blue-600">{log.deposit > 0 ? log.depositFormatted + '원' : '-'}</span>
</div>
<div>
<span className="text-stone-500">출금:</span>
<span className="ml-2 font-medium text-red-600">{log.withdraw > 0 ? log.withdrawFormatted + '원' : '-'}</span>
</div>
</div>
</div>
{/* 수정 폼 */}
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
적요
{log.isOverridden && log.originalSummary !== modifiedSummary && (
<span className="ml-2 text-xs text-amber-600">(수정됨)</span>
)}
</label>
<input
type="text"
value={modifiedSummary}
onChange={(e) => setModifiedSummary(e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
placeholder="적요 입력"
/>
{log.originalSummary && modifiedSummary !== log.originalSummary && (
<p className="mt-1 text-xs text-stone-400">원본: {log.originalSummary}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
내용 (상대계좌예금주명)
{log.isOverridden && log.originalCast !== modifiedCast && (
<span className="ml-2 text-xs text-amber-600">(수정됨)</span>
)}
</label>
<input
type="text"
value={modifiedCast}
onChange={(e) => setModifiedCast(e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
placeholder="내용 입력"
/>
{log.originalCast && modifiedCast !== log.originalCast && (
<p className="mt-1 text-xs text-stone-400">원본: {log.originalCast}</p>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-stone-200 bg-stone-50 flex justify-between">
<div>
{log.isOverridden && (
<button
onClick={handleReset}
disabled={saving}
className="px-4 py-2 text-amber-600 hover:text-amber-700 text-sm font-medium disabled:opacity-50"
>
원본으로 복원
</button>
)}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-stone-200 text-stone-700 rounded-lg hover:bg-stone-300 text-sm font-medium"
>
취소
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 text-sm font-medium disabled:opacity-50"
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
</div>
</div>
);
};
// BankSplitModal Component - 계좌 입출금 분개 모달
const BankSplitModal = ({ isOpen, onClose, log, accountCodes, onSave, onReset, splits: existingSplits }) => {
const [splits, setSplits] = useState([]);
const [saving, setSaving] = useState(false);
const [resetting, setResetting] = useState(false);
useEffect(() => {
if (isOpen && log) {
if (existingSplits && existingSplits.length > 0) {
// 기존 분개 로드
setSplits(existingSplits.map(s => ({
amount: parseFloat(s.split_amount || s.amount || 0),
accountCode: s.account_code || s.accountCode || '',
accountName: s.account_name || s.accountName || '',
description: s.description || '',
memo: s.memo || ''
})));
} else {
// 새 분개: 원본 금액으로 1개 행 생성
const origAmount = log.deposit > 0 ? log.deposit : log.withdraw;
setSplits([{
amount: origAmount,
accountCode: log.accountCode || '',
accountName: log.accountName || '',
description: log.summary || '',
memo: ''
}]);
}
}
}, [isOpen, log, existingSplits]);
if (!isOpen || !log) return null;
const originalAmount = log.deposit > 0 ? log.deposit : log.withdraw;
const isDeposit = log.deposit > 0;
const splitTotal = splits.reduce((sum, s) => sum + (parseFloat(s.amount) || 0), 0);
const isValid = Math.abs(originalAmount - splitTotal) < 0.01;
const addSplit = () => {
const remaining = originalAmount - splitTotal;
setSplits([...splits, {
amount: remaining > 0 ? remaining : 0,
accountCode: '',
accountName: '',
description: '',
memo: ''
}]);
};
const removeSplit = (index) => {
if (splits.length <= 1) return;
setSplits(splits.filter((_, i) => i !== index));
};
const updateSplit = (index, updates) => {
const newSplits = [...splits];
newSplits[index] = { ...newSplits[index], ...updates };
setSplits(newSplits);
};
const handleSave = async () => {
if (!isValid) {
notify('분개 합계금액이 원본 금액과 일치하지 않습니다.', 'error');
return;
}
setSaving(true);
await onSave(log, splits);
setSaving(false);
onClose();
};
const handleReset = async () => {
if (!confirm('분개를 삭제하고 원본 거래로 복구하시겠습니까?')) {
return;
}
setResetting(true);
await onReset(log);
setResetting(false);
onClose();
};
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0);
const formatAmountInput = (value) => {
if (!value && value !== 0) return '';
return new Intl.NumberFormat('ko-KR').format(value);
};
const parseAmountInput = (value) => {
const cleaned = String(value).replace(/[^0-9.-]/g, '');
return parseFloat(cleaned) || 0;
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
<div className="p-6 border-b border-stone-200">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">거래 분개</h3>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="mt-3 p-3 bg-stone-50 rounded-lg text-sm">
<div className="flex justify-between mb-1">
<span className="text-stone-500">적요</span>
<span className="font-medium">{log.summary || '-'}</span>
</div>
<div className="flex justify-between mb-1">
<span className="text-stone-500">거래일시</span>
<span className="font-medium">{log.transDateTime || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-stone-500">{isDeposit ? '입금액' : '출금액'}</span>
<span className={`font-bold ${isDeposit ? 'text-blue-600' : 'text-red-600'}`}>
{formatCurrency(originalAmount)}
</span>
</div>
</div>
</div>
<div className="p-6 overflow-y-auto" style={ {maxHeight: '400px'} }>
<div className="space-y-3">
{splits.map((split, index) => (
<div key={index} className="flex items-start gap-3 p-3 bg-stone-50 rounded-lg">
<div className="flex-1 grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-stone-500 mb-1">금액 <span className="text-red-500">*</span></label>
<input
type="text"
value={formatAmountInput(split.amount)}
onChange={(e) => updateSplit(index, { amount: parseAmountInput(e.target.value) })}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm text-right focus:ring-2 focus:ring-emerald-500 outline-none"
placeholder="0"
/>
</div>
<div>
<label className="block text-xs text-stone-500 mb-1">내역</label>
<input
type="text"
value={split.description || ''}
onChange={(e) => updateSplit(index, { description: e.target.value })}
placeholder="내역"
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
/>
</div>
<div>
<label className="block text-xs text-stone-500 mb-1">메모</label>
<input
type="text"
value={split.memo || ''}
onChange={(e) => updateSplit(index, { memo: e.target.value })}
placeholder="분개 메모 (선택)"
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
/>
</div>
</div>
<button
onClick={() => removeSplit(index)}
disabled={splits.length <= 1}
className="mt-6 p-2 text-red-500 hover:bg-red-50 rounded-lg disabled:opacity-30 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 12H4" />
</svg>
</button>
</div>
))}
</div>
<button
onClick={addSplit}
className="mt-3 w-full py-2 border-2 border-dashed border-stone-300 text-stone-500 rounded-lg hover:border-emerald-400 hover:text-emerald-600 transition-colors flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
분개 항목 추가
</button>
</div>
<div className="p-6 border-t border-stone-200 bg-stone-50">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-stone-500">분개 합계</span>
<span className={`font-bold ${isValid ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(splitTotal)}
{!isValid && (
<span className="ml-2 text-xs font-normal">
(차이: {formatCurrency(originalAmount - splitTotal)})
</span>
)}
</span>
</div>
<div className="flex gap-3">
{existingSplits && existingSplits.length > 0 && (
<button
onClick={handleReset}
disabled={resetting}
className="py-2 px-4 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{resetting ? '복구 중...' : '분개 복구'}
</button>
)}
<button
onClick={onClose}
className="flex-1 py-2 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-100 transition-colors"
>
취소
</button>
<button
onClick={handleSave}
disabled={!isValid || saving}
className="flex-1 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? '저장 중...' : '분개 저장'}
</button>
</div>
</div>
</div>
</div>
);
};
// ManualEntryModal Component (수동입력 모달)
const ManualEntryModal = ({ isOpen, onClose, onSave, editData, accounts, logs }) => {
const [form, setForm] = useState({});
const [saving, setSaving] = useState(false);
const [baseBalance, setBaseBalance] = useState(0);
const [focusedField, setFocusedField] = useState(null);
const isEditMode = !!editData;
// 거래일 기준으로 해당 계좌의 직전 거래 잔액 찾기
const findBaseBalanceByDate = (accountNum, transDate, transTime) => {
if (!accountNum || !logs || logs.length === 0) return 0;
const targetDt = (transDate || '') + (transTime || '000000');
// 같은 계좌의 거래만 필터 (수정 중인 건은 제외)
const accountLogs = logs.filter(l =>
l.bankAccountNum === accountNum &&
!(editData && l.isManual && l.dbId === editData.dbId)
);
if (accountLogs.length === 0) return 0;
// logs는 날짜 내림차순 정렬 → 입력일보다 이전인 첫 번째 거래의 잔액이 기준
for (const log of accountLogs) {
const logDt = (log.transDate || '') + (log.transTime || '');
if (logDt < targetDt) {
return log.balance || 0;
}
}
return 0;
};
// 기준잔액 재계산 (계좌, 날짜, 시간 변경 시)
const recalcBase = (accountNum, transDate, transTime) => {
const base = findBaseBalanceByDate(accountNum, transDate, transTime);
setBaseBalance(base);
return base;
};
useEffect(() => {
if (isOpen) {
if (editData) {
const isRegisteredAccount = accounts.some(a => a.bankAccountNum === editData.bankAccountNum);
// 수정 모드: 직전 거래 잔액을 찾아서 기준잔액으로 설정
const base = findBaseBalanceByDate(editData.bankAccountNum, editData.transDate, editData.transTime);
setBaseBalance(base);
setForm({
bank_account_num: editData.bankAccountNum || '',
bank_code: editData.bankCode || '',
bank_name: editData.bankName || '',
_manualAccount: !isRegisteredAccount,
trans_date: editData.transDate || '',
trans_time: editData.transTime || '',
trans_type: editData.deposit > 0 ? 'deposit' : 'withdraw',
amount: editData.deposit > 0 ? editData.deposit : editData.withdraw,
balance: editData.balance || 0,
summary: editData.summary || '',
cast: editData.cast || '',
memo: editData.memo || '',
trans_office: editData.transOffice || '',
});
} else {
const today = new Date();
const todayStr = today.getFullYear() +
String(today.getMonth() + 1).padStart(2, '0') +
String(today.getDate()).padStart(2, '0');
setBaseBalance(0);
setForm({
bank_account_num: '',
bank_code: '',
bank_name: '',
_manualAccount: false,
trans_date: todayStr,
trans_time: '',
trans_type: 'withdraw',
amount: 0,
balance: 0,
summary: '',
cast: '',
memo: '',
trans_office: '',
});
}
}
}, [isOpen, editData]);
const handleSubmit = async () => {
if (!form.bank_account_num || !form.trans_date || form.amount === '' || form.amount === 0) {
notify('계좌번호, 거래일, 금액을 입력해주세요.', 'error');
return;
}
setSaving(true);
const submitData = {
bank_account_num: form.bank_account_num,
bank_code: form.bank_code,
bank_name: form.bank_name,
trans_date: form.trans_date,
trans_time: form.trans_time || '',
deposit: form.trans_type === 'deposit' ? Number(form.amount) : 0,
withdraw: form.trans_type === 'withdraw' ? Number(form.amount) : 0,
balance: Number(form.balance) || 0,
summary: form.summary,
cast: form.cast,
memo: form.memo,
trans_office: form.trans_office,
};
await onSave(submitData, editData?.dbId);
setSaving(false);
};
// 잔액 자동 계산
const calcBalance = (base, transType, amount) => {
const amt = Number(amount) || 0;
return transType === 'deposit' ? base + amt : base - amt;
};
const handleAccountSelect = (accNum) => {
if (accNum === '__manual__') {
setBaseBalance(0);
setForm(prev => {
const newBal = calcBalance(0, prev.trans_type, prev.amount);
return { ...prev, bank_account_num: '', bank_code: '', bank_name: '', _manualAccount: true, balance: newBal };
});
} else {
const acc = accounts.find(a => a.bankAccountNum === accNum);
if (acc) {
setForm(prev => {
const base = recalcBase(acc.bankAccountNum, prev.trans_date, prev.trans_time);
const newBalance = calcBalance(base, prev.trans_type, prev.amount);
return {
...prev,
bank_account_num: acc.bankAccountNum,
bank_code: acc.bankCode || '',
bank_name: acc.bankName || '',
_manualAccount: false,
balance: newBalance,
};
});
}
}
};
// 거래일 변경 시 기준잔액 재계산
const handleDateChange = (field, value) => {
setForm(prev => {
const newForm = { ...prev, [field]: value };
const base = recalcBase(newForm.bank_account_num, newForm.trans_date, newForm.trans_time);
const newBalance = calcBalance(base, newForm.trans_type, newForm.amount);
return { ...newForm, balance: newBalance };
});
};
const formatAmount = (val) => {
const num = String(val).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const handleAmountChange = (field, value) => {
const num = String(value).replace(/[^\d]/g, '');
const numVal = num ? Number(num) : 0;
if (field === 'amount') {
const newBalance = calcBalance(baseBalance, form.trans_type, numVal);
setForm(prev => ({ ...prev, amount: numVal, balance: newBalance }));
} else {
setForm(prev => ({ ...prev, [field]: numVal }));
}
};
const handleTransTypeChange = (newType) => {
const newBalance = calcBalance(baseBalance, newType, form.amount);
setForm(prev => ({ ...prev, trans_type: newType, balance: newBalance }));
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stone-200 bg-emerald-50">
<h2 className="text-lg font-bold text-stone-900">
{isEditMode ? '수동 거래 수정' : '수동 거래 등록'}
</h2>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600 text-2xl">&times;</button>
</div>
{/* Form */}
<div className="px-6 py-4 space-y-4">
{/* 계좌 선택 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">계좌 <span className="text-red-500">*</span></label>
<select
value={form._manualAccount ? '__manual__' : form.bank_account_num}
onChange={(e) => handleAccountSelect(e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
>
<option value="">계좌 선택</option>
{accounts.map(acc => (
<option key={acc.bankAccountNum} value={acc.bankAccountNum}>
{acc.bankName} {acc.bankAccountNum}
</option>
))}
<option value="__manual__">직접 입력</option>
</select>
{form._manualAccount && (
<div className="mt-2 grid grid-cols-2 gap-2">
<input
type="text"
value={form.bank_name}
onChange={(e) => setForm(prev => ({ ...prev, bank_name: e.target.value }))}
placeholder="은행명"
className="px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
/>
<input
type="text"
value={form.bank_account_num}
onChange={(e) => setForm(prev => ({ ...prev, bank_account_num: e.target.value }))}
placeholder="계좌번호"
className="px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
/>
</div>
)}
</div>
{/* 거래일 / 거래시간 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">거래일 <span className="text-red-500">*</span></label>
<input
type="text"
value={form.trans_date || ''}
onChange={(e) => handleDateChange('trans_date', e.target.value.replace(/[^\d]/g, '').slice(0, 8))}
placeholder="YYYYMMDD"
maxLength={8}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">거래시간</label>
<input
type="text"
value={form.trans_time || ''}
onChange={(e) => handleDateChange('trans_time', e.target.value.replace(/[^\d]/g, '').slice(0, 6))}
placeholder="HHMMSS"
maxLength={6}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
/>
</div>
</div>
{/* 거래유형 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">거래유형 <span className="text-red-500">*</span></label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="trans_type"
value="deposit"
checked={form.trans_type === 'deposit'}
onChange={() => handleTransTypeChange('deposit')}
className="text-blue-600"
/>
<span className="text-sm text-blue-600 font-medium">입금</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="trans_type"
value="withdraw"
checked={form.trans_type === 'withdraw'}
onChange={() => handleTransTypeChange('withdraw')}
className="text-red-600"
/>
<span className="text-sm text-red-600 font-medium">출금</span>
</label>
</div>
</div>
{/* 금액 / 잔액 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">금액 <span className="text-red-500">*</span></label>
<input
type="text"
value={focusedField === 'amount' && form.amount === 0 ? '' : formatAmount(form.amount)}
onChange={(e) => handleAmountChange('amount', e.target.value)}
onFocus={() => setFocusedField('amount')}
onBlur={() => setFocusedField(null)}
placeholder="금액 입력"
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none text-right"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
잔액 <span className="text-xs text-stone-400 font-normal">(자동계산)</span>
</label>
<input
type="text"
value={focusedField === 'balance' && form.balance === 0 ? '' : formatAmount(form.balance)}
onChange={(e) => handleAmountChange('balance', e.target.value)}
onFocus={() => setFocusedField('balance')}
onBlur={() => setFocusedField(null)}
placeholder="잔액"
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none text-right bg-stone-50"
/>
{baseBalance !== 0 && (
<p className="mt-1 text-xs text-stone-400">
기준 잔액: {Number(baseBalance).toLocaleString()}
</p>
)}
</div>
</div>
{/* 적요 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">적요</label>
<input
type="text"
value={form.summary || ''}
onChange={(e) => setForm(prev => ({ ...prev, summary: e.target.value }))}
placeholder="적요 입력"
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
/>
</div>
{/* 상대계좌예금주명 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">상대계좌예금주명</label>
<input
type="text"
value={form.cast || ''}
onChange={(e) => setForm(prev => ({ ...prev, cast: e.target.value }))}
placeholder="예금주명 입력"
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
/>
</div>
{/* 메모 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">메모</label>
<input
type="text"
value={form.memo || ''}
onChange={(e) => setForm(prev => ({ ...prev, memo: e.target.value }))}
placeholder="메모 입력"
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
/>
</div>
{/* 취급점 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">취급점</label>
<input
type="text"
value={form.trans_office || ''}
onChange={(e) => setForm(prev => ({ ...prev, trans_office: e.target.value }))}
placeholder="취급점 입력"
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
/>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-stone-200 bg-stone-50 flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-stone-200 text-stone-700 rounded-lg hover:bg-stone-300 text-sm font-medium"
>
취소
</button>
<button
onClick={handleSubmit}
disabled={saving}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 text-sm font-medium disabled:opacity-50"
>
{saving ? '저장 중...' : (isEditMode ? '수정' : '등록')}
</button>
</div>
</div>
</div>
);
};
// TransactionTable Component
const TransactionTable = ({
logs,
loading,
dateFrom,
dateTo,
onDateFromChange,
onDateToChange,
onThisMonth,
onLastMonth,
onMonthOffset,
onSearch,
totalCount,
onCastChange,
onSave,
onExport,
saving,
hasChanges,
onEditTransaction,
onManualNew,
onManualEdit,
onManualDelete,
onClientCodeChange,
splits,
onOpenSplitModal,
onDeleteSplits
}) => {
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600"></div>
</div>
);
}
return (
<div className="bg-white rounded-xl shadow-sm border border-stone-100 min-h-[calc(100vh-200px)]">
<div className="p-6 border-b border-stone-100">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<h2 className="text-lg font-bold text-stone-900">입출금 내역</h2>
{/* 기간 조회 필터 */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-sm text-stone-500">기간</label>
<input
type="date"
value={dateFrom}
onChange={(e) => onDateFromChange(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"
/>
<span className="text-stone-400">~</span>
<input
type="date"
value={dateTo}
onChange={(e) => onDateToChange(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"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={onThisMonth}
className="px-3 py-1.5 text-sm bg-emerald-50 text-emerald-600 rounded-lg hover:bg-emerald-100 transition-colors font-medium"
>
이번
</button>
<button
onClick={onLastMonth}
className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium"
>
지난달
</button>
<button
onClick={() => onMonthOffset(-2)}
className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium"
>
D-2
</button>
<button
onClick={() => onMonthOffset(-3)}
className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium"
>
D-3
</button>
<button
onClick={() => onMonthOffset(-4)}
className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium"
>
D-4
</button>
<button
onClick={() => onMonthOffset(-5)}
className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium"
>
D-5
</button>
<button
onClick={() => onSearch(dateFrom, dateTo)}
className="px-4 py-1.5 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors font-medium flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
조회
</button>
</div>
<span className="text-sm text-stone-500 ml-2">
조회: <span className="font-semibold text-stone-700">{logs.length}</span>
{totalCount !== logs.length && (
<span className="text-stone-400"> / 전체 {totalCount}</span>
)}
</span>
</div>
</div>
{/* 저장/엑셀 버튼 */}
<div className="flex items-center gap-2 mt-4">
<button
onClick={onSave}
disabled={saving || logs.length === 0}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
hasChanges
? 'bg-emerald-600 text-white hover:bg-emerald-700'
: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{saving ? (
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
)}
{hasChanges ? '변경사항 저장' : '저장'}
</button>
<button
onClick={onExport}
disabled={logs.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
엑셀 다운로드
</button>
<button
onClick={onManualNew}
className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded-lg text-sm font-medium hover:bg-green-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
수동입력
</button>
</div>
</div>
<div className="overflow-x-auto" style={ {minHeight: '500px', overflowY: 'auto'} }>
<table className="w-full text-left text-sm text-stone-600">
<thead className="bg-stone-50 text-xs uppercase font-medium text-stone-500 sticky top-0">
<tr>
<th className="px-2 py-4 bg-stone-50 text-center w-[50px]">번호</th>
<th className="px-4 py-4 bg-stone-50">거래일시</th>
<th className="px-4 py-4 bg-stone-50">계좌정보</th>
<th className="px-4 py-4 bg-stone-50">적요/내용</th>
<th className="px-4 py-4 text-right bg-stone-50 text-blue-600">입금</th>
<th className="px-4 py-4 text-right bg-stone-50 text-red-600">출금</th>
<th className="px-4 py-4 text-right bg-stone-50">잔액</th>
<th className="px-4 py-4 bg-stone-50">취급점</th>
<th className="px-4 py-4 bg-stone-50 min-w-[120px]">상대계좌예금주명</th>
<th className="px-4 py-4 bg-stone-50 min-w-[140px]">거래처코드</th>
<th className="px-4 py-4 bg-stone-50 text-center w-[80px]">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{logs.length === 0 ? (
<tr>
<td colSpan="11" className="px-6 py-8 text-center text-stone-400">
해당 기간에 조회된 입출금 내역이 없습니다.
</td>
</tr>
) : (
logs.map((log, index) => {
const logSplits = splits && splits[log.uniqueKey] ? splits[log.uniqueKey] : [];
const hasSplits = logSplits.length > 0;
return (
<React.Fragment key={index}>
<tr className={`hover:bg-stone-50 transition-colors ${log.isSaved ? 'bg-green-50/30' : ''} ${log.isOverridden ? 'bg-amber-50/50' : ''}`}>
<td className="px-2 py-3 text-center text-xs text-stone-500 font-medium">
{index + 1}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="font-medium text-stone-900">{log.transDateTime || '-'}</div>
{log.isManual && (
<span className="inline-block px-1.5 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded mt-0.5">수동</span>
)}
</td>
<td className="px-4 py-3">
<div className="font-medium text-stone-900">{log.bankName}</div>
<div className="text-xs text-stone-400 font-mono">
{log.bankAccountNum ? '****' + log.bankAccountNum.slice(-4) : '-'}
</div>
</td>
<td
className="px-4 py-3 cursor-pointer hover:bg-emerald-50 group"
onClick={() => onEditTransaction(index)}
>
<div className="flex items-center gap-1">
<div className="font-medium text-stone-900">{log.summary || '-'}</div>
{log.isOverridden && (
<span className="px-1 py-0.5 bg-amber-100 text-amber-700 text-xs rounded">수정</span>
)}
<svg className="w-3 h-3 text-stone-400 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
{log.memo && <div className="text-xs text-stone-400">{log.memo}</div>}
</td>
<td className="px-4 py-3 text-right font-medium text-blue-600">
{log.deposit > 0 ? log.depositFormatted + '원' : '-'}
</td>
<td className="px-4 py-3 text-right font-medium text-red-600">
{log.withdraw > 0 ? log.withdrawFormatted + '원' : '-'}
</td>
<td className="px-4 py-3 text-right text-stone-700">
{log.balanceFormatted}
</td>
<td className="px-4 py-3 text-stone-500 text-sm">
{log.transOffice || '-'}
</td>
<td className="px-4 py-3">
<input
type="text"
className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
value={log.cast || ''}
onChange={(e) => onCastChange(index, e.target.value)}
placeholder="예금주명 입력"
/>
</td>
<td className="px-4 py-3">
<ClientCodeSelect
value={log.clientCode || ''}
clientName={log.clientName || ''}
onChange={(code, name) => onClientCodeChange(index, code, name)}
/>
</td>
<td className="px-3 py-3 text-center">
{log.isManual && (
<div className="flex items-center gap-1 justify-center">
<button
onClick={() => onManualEdit(log)}
className="p-1 text-blue-500 hover:bg-blue-50 rounded transition-colors"
title="수정"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => onManualDelete(log.dbId)}
className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
title="삭제"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
)}
</td>
</tr>
{/* 분개 하위 행 */}
{hasSplits && logSplits.map((split, sIdx) => (
<tr key={`${index}-split-${sIdx}`} className="bg-amber-50/30">
<td className="px-2 py-2 text-center">
<svg className="w-4 h-4 text-amber-400 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</td>
<td className="px-4 py-2 text-xs text-stone-400" colSpan="2">
<span className="text-amber-600 font-medium">분개 #{sIdx + 1}</span>
{split.memo && <span className="ml-2 text-stone-400">- {split.memo}</span>}
</td>
<td className="px-4 py-2 text-xs text-stone-500">{split.description || '-'}</td>
<td className="px-4 py-2 text-right text-sm font-medium text-blue-600">
{log.deposit > 0 ? new Intl.NumberFormat('ko-KR').format(split.split_amount || 0) + '원' : '-'}
</td>
<td className="px-4 py-2 text-right text-sm font-medium text-red-600">
{log.withdraw > 0 ? new Intl.NumberFormat('ko-KR').format(split.split_amount || 0) + '원' : '-'}
</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2"></td>
<td className="px-3 py-2"></td>
</tr>
))}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
</div>
);
};
// Main App Component
const App = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [accounts, setAccounts] = useState([]);
const [selectedAccount, setSelectedAccount] = useState('');
const [logs, setLogs] = useState([]);
const [summary, setSummary] = useState({});
const [pagination, setPagination] = useState({});
const [error, setError] = useState(null);
const [accountCodes, setAccountCodes] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingLogIndex, setEditingLogIndex] = useState(null);
// 수동입력 관련 상태
const [manualModalOpen, setManualModalOpen] = useState(false);
const [manualEditData, setManualEditData] = useState(null);
// 분개 관련 상태
const [splits, setSplits] = useState({});
const [splitModalOpen, setSplitModalOpen] = useState(false);
const [splitModalLog, setSplitModalLog] = useState(null);
const [splitModalKey, setSplitModalKey] = useState('');
const [splitModalExisting, setSplitModalExisting] = useState([]);
// 날짜 필터 상태 (기본: 현재 월)
const currentMonth = getMonthDates(0);
const [dateFrom, setDateFrom] = useState(currentMonth.from);
const [dateTo, setDateTo] = useState(currentMonth.to);
// 초기 로드
useEffect(() => {
loadAccounts();
loadAccountCodes();
loadTransactions();
}, []);
const loadAccounts = async () => {
try {
const response = await fetch(API.accounts);
const data = await response.json();
if (data.success) {
setAccounts(data.accounts || []);
}
} catch (err) {
console.error('계좌 목록 로드 오류:', err);
}
};
const loadAccountCodes = async () => {
try {
const response = await fetch(API.accountCodes);
const data = await response.json();
if (data.success) {
setAccountCodes(data.data || []);
}
} catch (err) {
console.error('계정과목 목록 로드 오류:', err);
}
};
const loadTransactions = async (page = 1, fromOverride = null, toOverride = null) => {
const from = fromOverride || dateFrom;
const to = toOverride || dateTo;
setLoading(true);
setError(null);
setHasChanges(false);
try {
const params = new URLSearchParams({
startDate: from.replace(/-/g, ''),
endDate: to.replace(/-/g, ''),
accountNum: selectedAccount,
page: page,
limit: 50
});
const response = await fetch(`${API.transactions}?${params}`);
if (!response.ok) {
const text = await response.text();
throw new Error(`서버 응답 오류 (${response.status}): ${text.substring(0, 200)}`);
}
const text = await response.text();
if (!text) {
throw new Error('서버가 빈 응답을 반환했습니다. (타임아웃 가능성)');
}
let data;
try { data = JSON.parse(text); } catch (e) {
throw new Error('JSON 파싱 실패: ' + text.substring(0, 200));
}
if (data.success) {
setLogs(data.data?.logs || []);
setPagination(data.data?.pagination || {});
setSummary(data.data?.summary || {});
// 분개 데이터도 함께 로드
loadSplits(from, to);
} else {
setError(data.error || '조회 실패');
setLogs([]);
}
} catch (err) {
setError('서버 통신 오류: ' + err.message);
setLogs([]);
} finally {
setLoading(false);
}
};
// 분개 데이터 로드
const loadSplits = async (fromOverride = null, toOverride = null) => {
try {
const params = new URLSearchParams({
startDate: (fromOverride || dateFrom).replace(/-/g, ''),
endDate: (toOverride || dateTo).replace(/-/g, ''),
});
const response = await fetch(`${API.splits}?${params}`);
const data = await response.json();
if (data.success) {
setSplits(data.data || {});
}
} catch (err) {
console.error('분개 데이터 로드 오류:', err);
}
};
// 분개 모달 열기
const handleOpenSplitModal = (log, uniqueKey, existingSplits) => {
setSplitModalLog(log);
setSplitModalKey(uniqueKey);
setSplitModalExisting(existingSplits || []);
setSplitModalOpen(true);
};
// 분개 저장
const handleSaveSplits = async (log, splitData) => {
try {
const originalAmount = log.deposit > 0 ? log.deposit : log.withdraw;
const response = await fetch(API.saveSplits, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json'
},
body: JSON.stringify({
uniqueKey: log.uniqueKey,
originalData: {
bankAccountNum: log.bankAccountNum,
transDt: log.transDate + (log.transTime || ''),
transDate: log.transDate,
originalDeposit: log.deposit || 0,
originalWithdraw: log.withdraw || 0,
originalAmount: originalAmount,
summary: log.summary || '',
},
splits: splitData
})
});
const data = await response.json();
if (data.success) {
notify(data.message, 'success');
loadSplits();
} else {
notify(data.error || '분개 저장 실패', 'error');
}
} catch (err) {
notify('분개 저장 오류: ' + err.message, 'error');
}
};
// 분개 삭제
const handleDeleteSplits = async (uniqueKey) => {
try {
const response = await fetch(API.deleteSplits, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json'
},
body: JSON.stringify({ uniqueKey })
});
const data = await response.json();
if (data.success) {
notify(data.message, 'success');
loadSplits();
} else {
notify(data.error || '분개 삭제 실패', 'error');
}
} catch (err) {
notify('분개 삭제 오류: ' + err.message, 'error');
}
};
// 분개 복구 (모달 내 복구 버튼)
const handleResetSplits = async (log) => {
await handleDeleteSplits(log.uniqueKey);
};
// 계정과목 변경 핸들러
const handleAccountCodeChange = useCallback((index, code, name) => {
setLogs(prevLogs => {
const newLogs = [...prevLogs];
newLogs[index] = {
...newLogs[index],
accountCode: code,
accountName: name
};
return newLogs;
});
setHasChanges(true);
}, []);
// 상대계좌예금주명 변경 핸들러
const handleCastChange = useCallback((index, value) => {
console.log('[CastChange Debug] index:', index, 'value:', value);
setLogs(prevLogs => {
const newLogs = [...prevLogs];
console.log('[CastChange Debug] 기존 cast:', newLogs[index]?.cast, 'isManual:', newLogs[index]?.isManual, 'dbId:', newLogs[index]?.dbId);
newLogs[index] = {
...newLogs[index],
cast: value
};
return newLogs;
});
setHasChanges(true);
}, []);
// 거래처코드 변경 핸들러
const handleClientCodeChange = useCallback((index, code, name) => {
setLogs(prevLogs => {
const newLogs = [...prevLogs];
newLogs[index] = {
...newLogs[index],
clientCode: code,
clientName: name
};
return newLogs;
});
setHasChanges(true);
}, []);
// 거래 수정 모달 열기
const handleEditTransaction = useCallback((index) => {
setEditingLogIndex(index);
setEditModalOpen(true);
}, []);
// 오버라이드 저장 후 로그 업데이트
const handleSaveOverride = useCallback((newSummary, newCast) => {
if (editingLogIndex !== null) {
setLogs(prevLogs => {
const newLogs = [...prevLogs];
newLogs[editingLogIndex] = {
...newLogs[editingLogIndex],
summary: newSummary,
cast: newCast,
isOverridden: true
};
return newLogs;
});
}
}, [editingLogIndex]);
// 저장 핸들러
const handleSave = async () => {
if (logs.length === 0) return;
// 디버그: 저장할 수동 거래 확인
const manualLogs = logs.filter(l => l.isManual);
console.log('[Save Debug] 전체 로그 수:', logs.length, '수동 거래 수:', manualLogs.length);
manualLogs.forEach(l => {
console.log('[Save Debug] 수동 거래:', {dbId: l.dbId, isManual: l.isManual, cast: l.cast, summary: l.summary});
});
setSaving(true);
try {
console.log('[Save Debug] API.save URL:', API.save);
const response = await fetch(API.save, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json'
},
body: JSON.stringify({ transactions: logs })
});
console.log('[Save Debug] 응답 status:', response.status);
const data = await response.json();
console.log('[Save Debug] 응답 data:', data);
if (data.success) {
notify(data.message, 'success');
setHasChanges(false);
// 저장 후 다시 로드하여 isSaved 상태 갱신
loadTransactions();
} else {
notify(data.error || '저장 실패', 'error');
}
} catch (err) {
console.error('[Save Debug] 오류:', err);
notify('저장 오류: ' + err.message, 'error');
} finally {
setSaving(false);
}
};
// 엑셀 다운로드 핸들러
const handleExport = () => {
const params = new URLSearchParams({
startDate: dateFrom.replace(/-/g, ''),
endDate: dateTo.replace(/-/g, ''),
accountNum: selectedAccount
});
window.location.href = `${API.export}?${params}`;
};
// 수동입력 - 새로 등록 열기
const handleManualNew = () => {
setManualEditData(null);
setManualModalOpen(true);
};
// 수동입력 - 수정 열기
const handleManualEdit = (log) => {
setManualEditData(log);
setManualModalOpen(true);
};
// 수동입력 - 저장 (등록/수정)
const handleManualSave = async (formData, editId) => {
try {
const isEdit = !!editId;
const url = isEdit
? API.manualUpdate.replace(':id', editId)
: API.manualStore;
const method = isEdit ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json'
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (data.success) {
notify(data.message, 'success');
setManualModalOpen(false);
setManualEditData(null);
loadTransactions();
} else {
notify(data.error || '저장 실패', 'error');
}
} catch (err) {
notify('저장 오류: ' + err.message, 'error');
}
};
// 수동입력 - 삭제
const handleManualDelete = async (id) => {
if (!confirm('이 수동 입력 건을 삭제하시겠습니까?')) return;
try {
const url = API.manualDestroy.replace(':id', id);
const response = await fetch(url, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success) {
notify(data.message, 'success');
loadTransactions();
} else {
notify(data.error || '삭제 실패', 'error');
}
} catch (err) {
notify('삭제 오류: ' + err.message, 'error');
}
};
// 이번 달 버튼
const handleThisMonth = () => {
const dates = getMonthDates(0);
setDateFrom(dates.from);
setDateTo(dates.to);
loadTransactions(1, dates.from, dates.to);
};
// 지난달 버튼
const handleLastMonth = () => {
const dates = getMonthDates(-1);
setDateFrom(dates.from);
setDateTo(dates.to);
loadTransactions(1, dates.from, dates.to);
};
// N개월 전 버튼
const handleMonthOffset = (offset) => {
const dates = getMonthDates(offset);
setDateFrom(dates.from);
setDateTo(dates.to);
loadTransactions(1, dates.from, dates.to);
};
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
return (
<div className="space-y-8">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-stone-900">계좌 입출금내역</h1>
<p className="text-stone-500 mt-1">바로빌 API를 통한 계좌 입출금내역 조회</p>
</div>
<div className="flex items-center gap-2">
@if($isTestMode)
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-xs font-medium">테스트 모드</span>
@else
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"/>
</svg>
운영 모드
</span>
@endif
@if($hasSoapClient)
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">SOAP 연결됨</span>
@else
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">SOAP 미연결</span>
@endif
</div>
</div>
{/* 통계 + 계좌 선택 (한 줄) */}
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4">
<div className="flex flex-wrap items-center gap-3">
{/* 통계 배지들 */}
<CompactStat label="입금" value={formatCurrency(summary.totalDeposit)} color="blue" />
<CompactStat label="출금" value={formatCurrency(summary.totalWithdraw)} color="red" />
<CompactStat label="잔액" value={formatCurrency(logs.length > 0 ? logs[0].balance : 0)} color="emerald" />
<CompactStat label="계좌" value={`${accounts.length}개`} color="green" />
<CompactStat label="거래" value={`${(summary.count || 0).toLocaleString()}건`} color="stone" />
{/* 구분선 */}
{accounts.length > 0 && <div className="w-px h-6 bg-stone-200 mx-1"></div>}
{/* 계좌 선택 버튼들 */}
{accounts.length > 0 && (
<>
<span className="text-xs text-stone-500">계좌:</span>
<AccountSelector
accounts={accounts}
selectedAccount={selectedAccount}
onSelect={setSelectedAccount}
/>
</>
)}
</div>
</div>
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 p-4 rounded-xl">
<div className="flex items-start gap-3">
<div className="text-xl">⚠️</div>
<div className="flex-1">
<p className="font-semibold mb-2">{error}</p>
{error.includes('-50214') && (
<div className="mt-3 p-3 bg-white rounded border border-red-200 text-sm">
<p className="font-medium mb-2">해결 방법:</p>
<ol className="list-decimal list-inside space-y-1 text-stone-700">
<li>바로빌 사이트(<a href="https://www.barobill.co.kr" target="_blank" className="text-blue-600 hover:underline">https://www.barobill.co.kr</a>) 로그인</li>
<li>계좌 관리 메뉴에서 해당 계좌 확인</li>
<li>계좌 비밀번호가 변경되었는지 확인</li>
<li>인증서가 만료되지 않았는지 확인</li>
<li>필요시 계좌 재등록 또는 비밀번호 재설정</li>
</ol>
</div>
)}
</div>
</div>
</div>
)}
{/* Transaction Table */}
{!error && (
<TransactionTable
logs={logs}
loading={loading}
dateFrom={dateFrom}
dateTo={dateTo}
onDateFromChange={setDateFrom}
onDateToChange={setDateTo}
onThisMonth={handleThisMonth}
onLastMonth={handleLastMonth}
onMonthOffset={handleMonthOffset}
onSearch={(from, to) => loadTransactions(1, from, to)}
totalCount={summary.count || logs.length}
onCastChange={handleCastChange}
onSave={handleSave}
onExport={handleExport}
saving={saving}
hasChanges={hasChanges}
onEditTransaction={handleEditTransaction}
onManualNew={handleManualNew}
onManualEdit={handleManualEdit}
onManualDelete={handleManualDelete}
onClientCodeChange={handleClientCodeChange}
splits={splits}
onOpenSplitModal={handleOpenSplitModal}
onDeleteSplits={handleDeleteSplits}
/>
)}
{/* Bank Split Modal */}
<BankSplitModal
isOpen={splitModalOpen}
onClose={() => { setSplitModalOpen(false); setSplitModalLog(null); setSplitModalExisting([]); }}
log={splitModalLog}
accountCodes={accountCodes}
onSave={handleSaveSplits}
onReset={handleResetSplits}
splits={splitModalExisting}
/>
{/* Transaction Edit Modal */}
<TransactionEditModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setEditingLogIndex(null);
}}
log={editingLogIndex !== null ? logs[editingLogIndex] : null}
onSave={handleSaveOverride}
/>
{/* Manual Entry Modal */}
<ManualEntryModal
isOpen={manualModalOpen}
onClose={() => { setManualModalOpen(false); setManualEditData(null); }}
onSave={handleManualSave}
editData={manualEditData}
accounts={accounts}
logs={logs}
/>
{/* Pagination */}
{!error && pagination.maxPageNum > 1 && (
<div className="flex justify-center gap-2">
<button
onClick={() => loadTransactions(Math.max(1, pagination.currentPage - 1))}
disabled={pagination.currentPage === 1}
className="px-3 py-1 rounded bg-white border disabled:opacity-50"
>
이전
</button>
<span className="px-3 py-1">
{pagination.currentPage} / {pagination.maxPageNum}
</span>
<button
onClick={() => loadTransactions(Math.min(pagination.maxPageNum, pagination.currentPage + 1))}
disabled={pagination.currentPage === pagination.maxPageNum}
className="px-3 py-1 rounded bg-white border disabled:opacity-50"
>
다음
</button>
</div>
)}
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('eaccount-root'));
root.render(<App />);
</script>
@endpush