- BankTransactionSplit 모델 생성 - EaccountController에 splits/saveSplits/deleteSplits 메서드 추가 - 라우트 3개 추가 (GET/POST/DELETE splits) - BankSplitModal React 컴포넌트 추가 - TransactionTable에 분개 컬럼/하위행 렌더링 - App 컴포넌트에 분개 상태 및 핸들러 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2337 lines
120 KiB
PHP
2337 lines
120 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '계좌 입출금내역')
|
|
|
|
@section('content')
|
|
<!-- 현재 테넌트 정보 카드 (React 외부) -->
|
|
@if($currentTenant)
|
|
<div class="rounded-xl shadow-lg p-5 mb-6" style="background: linear-gradient(to right, #059669, #0d9488); color: white;">
|
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
|
<div class="flex items-center gap-4">
|
|
<div class="p-3 rounded-xl" style="background: rgba(255,255,255,0.2);">
|
|
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: rgba(255,255,255,0.2);">T-ID: {{ $currentTenant->id }}</span>
|
|
@if($currentTenant->id == 1)
|
|
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: #facc15; color: #713f12;">파트너사</span>
|
|
@endif
|
|
</div>
|
|
<h2 class="text-xl font-bold">{{ $currentTenant->company_name }}</h2>
|
|
</div>
|
|
</div>
|
|
@if($barobillMember)
|
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 text-sm">
|
|
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
|
<p class="text-xs" style="color: rgba(255,255,255,0.6);">사업자번호</p>
|
|
<p class="font-medium">{{ $barobillMember->biz_no }}</p>
|
|
</div>
|
|
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
|
<p class="text-xs" style="color: rgba(255,255,255,0.6);">대표자</p>
|
|
<p class="font-medium">{{ $barobillMember->ceo_name ?? '-' }}</p>
|
|
</div>
|
|
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
|
<p class="text-xs" style="color: rgba(255,255,255,0.6);">담당자</p>
|
|
<p class="font-medium">{{ $barobillMember->manager_name ?? '-' }}</p>
|
|
</div>
|
|
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
|
<p class="text-xs" style="color: rgba(255,255,255,0.6);">바로빌 ID</p>
|
|
<p class="font-medium">{{ $barobillMember->barobill_id }}</p>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<div class="flex items-center gap-2" style="color: #fef08a;">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
<span class="text-sm">바로빌 회원사 미연동</span>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<div id="eaccount-root"></div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
|
|
<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") }}',
|
|
};
|
|
|
|
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>
|
|
);
|
|
};
|
|
|
|
// 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">×</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">×</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>
|
|
<AccountCodeSelect
|
|
value={split.accountCode}
|
|
onChange={(code, name) => updateSplit(index, { accountCode: code, accountName: name })}
|
|
accountCodes={accountCodes}
|
|
/>
|
|
</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, accountCodes, accounts, logs }) => {
|
|
const [form, setForm] = useState({});
|
|
const [saving, setSaving] = useState(false);
|
|
const [baseBalance, setBaseBalance] = useState(0);
|
|
|
|
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 || '',
|
|
account_code: editData.accountCode || '',
|
|
account_name: editData.accountName || '',
|
|
});
|
|
} 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: '',
|
|
account_code: '',
|
|
account_name: '',
|
|
});
|
|
}
|
|
}
|
|
}, [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,
|
|
account_code: form.account_code,
|
|
account_name: form.account_name,
|
|
};
|
|
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">×</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={formatAmount(form.amount)}
|
|
onChange={(e) => handleAmountChange('amount', e.target.value)}
|
|
placeholder="0"
|
|
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={formatAmount(form.balance)}
|
|
onChange={(e) => handleAmountChange('balance', e.target.value)}
|
|
placeholder="0"
|
|
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>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1">계정과목</label>
|
|
<AccountCodeSelect
|
|
value={form.account_code || ''}
|
|
onChange={(code, name) => setForm(prev => ({ ...prev, account_code: code, account_name: name }))}
|
|
accountCodes={accountCodes}
|
|
/>
|
|
</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,
|
|
accountCodes,
|
|
onAccountCodeChange,
|
|
onCastChange,
|
|
onSave,
|
|
onExport,
|
|
onOpenSettings,
|
|
saving,
|
|
hasChanges,
|
|
onEditTransaction,
|
|
onManualNew,
|
|
onManualEdit,
|
|
onManualDelete,
|
|
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}
|
|
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>
|
|
<button
|
|
onClick={onOpenSettings}
|
|
className="flex items-center gap-2 px-4 py-2 bg-stone-100 text-stone-700 rounded-lg text-sm font-medium hover:bg-stone-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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</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-[150px]">계정과목</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">
|
|
{!hasSplits ? (
|
|
<button
|
|
onClick={() => onOpenSplitModal(log, log.uniqueKey)}
|
|
className="p-1 text-purple-500 hover:bg-purple-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="M12 4v16m8-8H4" />
|
|
</svg>
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => { if (confirm('분개를 삭제하시겠습니까?')) onDeleteSplits(log.uniqueKey); }}
|
|
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="M20 12H4" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</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">
|
|
{hasSplits ? (
|
|
<button
|
|
onClick={() => onOpenSplitModal(log, log.uniqueKey, logSplits)}
|
|
className="text-sm text-emerald-600 hover:text-emerald-800 hover:underline font-medium"
|
|
>
|
|
분개 수정 ({logSplits.length}건)
|
|
</button>
|
|
) : (
|
|
<>
|
|
<AccountCodeSelect
|
|
value={log.accountCode}
|
|
onChange={(code, name) => onAccountCodeChange(index, code, name)}
|
|
accountCodes={accountCodes}
|
|
/>
|
|
{log.accountName && (
|
|
<div className="text-xs text-emerald-600 mt-1">{log.accountName}</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</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 text-xs">
|
|
{split.account_name && (
|
|
<span className="text-emerald-600">{split.account_code} {split.account_name}</span>
|
|
)}
|
|
</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) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setHasChanges(false);
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
startDate: dateFrom.replace(/-/g, ''),
|
|
endDate: dateTo.replace(/-/g, ''),
|
|
accountNum: selectedAccount,
|
|
page: page,
|
|
limit: 50
|
|
});
|
|
|
|
const response = await fetch(`${API.transactions}?${params}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setLogs(data.data?.logs || []);
|
|
setPagination(data.data?.pagination || {});
|
|
setSummary(data.data?.summary || {});
|
|
// 분개 데이터도 함께 로드
|
|
loadSplits();
|
|
} else {
|
|
setError(data.error || '조회 실패');
|
|
setLogs([]);
|
|
}
|
|
} catch (err) {
|
|
setError('서버 통신 오류: ' + err.message);
|
|
setLogs([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// 분개 데이터 로드
|
|
const loadSplits = async () => {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
startDate: dateFrom.replace(/-/g, ''),
|
|
endDate: 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) => {
|
|
setLogs(prevLogs => {
|
|
const newLogs = [...prevLogs];
|
|
newLogs[index] = {
|
|
...newLogs[index],
|
|
cast: value
|
|
};
|
|
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;
|
|
|
|
setSaving(true);
|
|
try {
|
|
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 })
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
notify(data.message, 'success');
|
|
setHasChanges(false);
|
|
// 저장 후 다시 로드하여 isSaved 상태 갱신
|
|
loadTransactions();
|
|
} else {
|
|
notify(data.error || '저장 실패', 'error');
|
|
}
|
|
} catch (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);
|
|
};
|
|
|
|
// 지난달 버튼
|
|
const handleLastMonth = () => {
|
|
const dates = getMonthDates(-1);
|
|
setDateFrom(dates.from);
|
|
setDateTo(dates.to);
|
|
};
|
|
|
|
// N개월 전 버튼
|
|
const handleMonthOffset = (offset) => {
|
|
const dates = getMonthDates(offset);
|
|
setDateFrom(dates.from);
|
|
setDateTo(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={() => loadTransactions()}
|
|
totalCount={summary.count || logs.length}
|
|
accountCodes={accountCodes}
|
|
onAccountCodeChange={handleAccountCodeChange}
|
|
onCastChange={handleCastChange}
|
|
onSave={handleSave}
|
|
onExport={handleExport}
|
|
onOpenSettings={() => setShowSettingsModal(true)}
|
|
saving={saving}
|
|
hasChanges={hasChanges}
|
|
onEditTransaction={handleEditTransaction}
|
|
onManualNew={handleManualNew}
|
|
onManualEdit={handleManualEdit}
|
|
onManualDelete={handleManualDelete}
|
|
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}
|
|
accountCodes={accountCodes}
|
|
accounts={accounts}
|
|
logs={logs}
|
|
/>
|
|
|
|
{/* Account Code Settings Modal */}
|
|
<AccountCodeSettingsModal
|
|
isOpen={showSettingsModal}
|
|
onClose={() => setShowSettingsModal(false)}
|
|
onUpdate={loadAccountCodes}
|
|
/>
|
|
|
|
{/* 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
|