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

3088 lines
179 KiB
PHP

@extends('layouts.app')
@section('title', '카드 사용내역')
@section('content')
<div id="ecard-root"></div>
@endsection
@push('scripts')
@include('partials.react-cdn')
<script type="text/babel">
const { useState, useEffect, useRef, useCallback } = React;
// API Routes
const API = {
cards: '{{ route("barobill.ecard.cards") }}',
transactions: '{{ route("barobill.ecard.transactions") }}',
accountCodes: '{{ route("barobill.ecard.account-codes") }}',
save: '{{ route("barobill.ecard.save") }}',
export: '{{ route("barobill.ecard.export") }}',
splits: '{{ route("barobill.ecard.splits") }}',
saveSplits: '{{ route("barobill.ecard.splits.save") }}',
deleteSplits: '{{ route("barobill.ecard.splits.delete") }}',
manualStore: '{{ route("barobill.ecard.manual.store") }}',
manualUpdate: '{{ route("barobill.ecard.manual.update", ":id") }}',
manualDestroy: '{{ route("barobill.ecard.manual.destroy", ":id") }}',
hide: '{{ route("barobill.ecard.hide") }}',
restore: '{{ route("barobill.ecard.restore") }}',
hidden: '{{ route("barobill.ecard.hidden") }}',
journalStore: '{{ route("barobill.ecard.journal.store") }}',
journalShow: '{{ route("barobill.ecard.journal.show") }}',
journalDelete: '/barobill/ecard/journal/',
journalStatuses: '{{ route("barobill.ecard.journal.statuses") }}',
};
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 today = formatLocalDate(now);
const year = now.getFullYear();
const month = now.getMonth() + offset;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// 종료일: 이번달이면 오늘, 지난달이면 그 달의 마지막 날
const lastDayStr = formatLocalDate(lastDay);
const endDate = offset >= 0 && lastDayStr > today ? today : lastDayStr;
return {
from: formatLocalDate(firstDay),
to: endDate
};
};
// 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 = {
purple: 'text-purple-600',
green: 'text-green-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-gray-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>
);
};
// 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-purple-500 ring-2 ring-purple-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-[9999] mt-1 w-48 bg-white border border-stone-200 rounded-lg shadow-xl">
{/* 검색 입력 */}
<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-purple-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-purple-600 text-white font-semibold'
: value === code.code
? 'bg-purple-100 text-purple-700'
: 'text-stone-700 hover:bg-purple-50'
}`}
>
<span className={`font-mono ${index === highlightIndex ? 'text-white' : 'text-purple-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>
);
};
// SplitModal Component - 분리 모달
const SplitModal = ({ 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) {
const defaultDeductionType = log.deductionType || (log.merchantBizNum ? 'deductible' : 'non_deductible');
if (existingSplits && existingSplits.length > 0) {
// 기존 분리 로드
setSplits(existingSplits.map(s => {
const hasSupplyTax = s.split_supply_amount !== null && s.split_supply_amount !== undefined;
return {
supplyAmount: hasSupplyTax ? parseFloat(s.split_supply_amount) : parseFloat(s.split_amount || s.amount || 0),
tax: hasSupplyTax ? parseFloat(s.split_tax || 0) : 0,
accountCode: s.account_code || s.accountCode || '',
accountName: s.account_name || s.accountName || '',
deductionType: s.deduction_type || s.deductionType || defaultDeductionType,
evidenceName: s.evidence_name || s.evidenceName || log.evidenceName || log.merchantName || '',
description: s.description || log.description || log.merchantBizType || log.memo || '',
memo: s.memo || ''
};
}));
} else {
// 새 분리: 원본의 공급가액/세액로 1개 행 생성
const origSupply = log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0));
const origTax = log.effectiveTax ?? (log.tax || 0);
setSplits([{
supplyAmount: origSupply,
tax: origTax,
accountCode: log.accountCode || '',
accountName: log.accountName || '',
deductionType: defaultDeductionType,
evidenceName: log.evidenceName || log.merchantName || '',
description: log.description || log.merchantBizType || log.memo || '',
memo: ''
}]);
}
}
}, [isOpen, log, existingSplits]);
if (!isOpen || !log) return null;
const effectiveSupply = log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0));
const effectiveTaxVal = log.effectiveTax ?? (log.tax || 0);
const originalAmount = effectiveSupply + effectiveTaxVal;
// 합계금액 = sum(공급가액 + 세액)
const splitTotal = splits.reduce((sum, s) => sum + (parseFloat(s.supplyAmount) || 0) + (parseFloat(s.tax) || 0), 0);
const isValid = Math.abs(originalAmount - splitTotal) < 0.01;
const addSplit = () => {
const remaining = originalAmount - splitTotal;
const defaultDeductionType = log.deductionType || (log.merchantBizNum ? 'deductible' : 'non_deductible');
setSplits([...splits, {
supplyAmount: remaining > 0 ? remaining : 0,
tax: 0,
accountCode: '',
accountName: '',
deductionType: defaultDeductionType,
evidenceName: log.evidenceName || log.merchantName || '',
description: log.description || log.merchantBizType || log.memo || '',
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.merchantName}</span>
</div>
<div className="flex justify-between mb-1">
<span className="text-stone-500">사용일시</span>
<span className="font-medium">{log.useDateTime}</span>
</div>
<div className="flex justify-between mb-1">
<span className="text-stone-500">공급가액</span>
<span className="font-medium">{formatCurrency(log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0)))}</span>
</div>
<div className="flex justify-between mb-1">
<span className="text-stone-500">세액</span>
<span className="font-medium">{formatCurrency(log.effectiveTax ?? (log.tax || 0))}</span>
</div>
<div className="flex justify-between">
<span className="text-stone-500">합계금액</span>
<span className="font-bold text-purple-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) => {
const rowTotal = (parseFloat(split.supplyAmount) || 0) + (parseFloat(split.tax) || 0);
return (
<div key={index} className="flex items-start gap-3 p-3 bg-stone-50 rounded-lg">
<div className="flex-1 grid grid-cols-3 gap-3">
<div>
<label className="block text-xs text-stone-500 mb-1">공급가액</label>
<input
type="text"
value={formatAmountInput(split.supplyAmount)}
onChange={(e) => updateSplit(index, { supplyAmount: 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-purple-500 outline-none"
placeholder="0"
/>
</div>
<div>
<label className="block text-xs text-stone-500 mb-1">세액</label>
<input
type="text"
value={formatAmountInput(split.tax)}
onChange={(e) => updateSplit(index, { tax: 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-purple-500 outline-none"
placeholder="0"
/>
</div>
<div>
<label className="block text-xs text-stone-500 mb-1">합계금액</label>
<div className="px-3 py-2 bg-stone-100 rounded-lg text-sm text-right font-medium text-purple-700">
{formatCurrency(rowTotal)}
</div>
</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>
<select
value={split.deductionType || 'deductible'}
onChange={(e) => updateSplit(index, { deductionType: e.target.value })}
className={`w-full px-3 py-2 border border-stone-200 rounded-lg text-sm font-bold focus:ring-2 focus:ring-purple-500 outline-none ${
split.deductionType === 'non_deductible'
? 'bg-red-500 text-white'
: 'bg-green-100 text-green-700'
}`}
>
<option value="deductible">공제</option>
<option value="non_deductible">불공</option>
</select>
</div>
<div>
<label className="block text-xs text-stone-500 mb-1">증빙/판매자상호</label>
<input
type="text"
value={split.evidenceName || ''}
onChange={(e) => updateSplit(index, { evidenceName: e.target.value })}
placeholder="판매자상호"
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
/>
</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-purple-500 outline-none"
/>
</div>
<div className="col-span-2">
<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-purple-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-purple-400 hover:text-purple-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-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? '저장 중...' : '분리 저장'}
</button>
</div>
</div>
</div>
</div>
);
};
// ============================================
// TradingPartnerSelect - 거래처 드롭다운
// ============================================
const TradingPartnerSelect = ({ value, valueName, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [partners, setPartners] = useState([]);
const [loading, setLoading] = useState(false);
const [highlightIndex, setHighlightIndex] = useState(-1);
const [dropdownStyle, setDropdownStyle] = useState({});
const containerRef = useRef(null);
const triggerRef = useRef(null);
const listRef = useRef(null);
const dropdownRef = useRef(null);
const displayText = valueName || '';
// 드롭다운 위치 계산 (fixed 기반, 위/아래 자동 판단)
const calcDropdownPos = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const dropdownH = 260; // 예상 드롭다운 높이
const spaceBelow = window.innerHeight - rect.bottom;
const openUp = spaceBelow < dropdownH && rect.top > dropdownH;
if (openUp) {
setDropdownStyle({ position: 'fixed', bottom: window.innerHeight - rect.top + 4, left: rect.left, width: 288, zIndex: 9999 });
} else {
setDropdownStyle({ position: 'fixed', top: rect.bottom + 4, left: rect.left, width: 288, zIndex: 9999 });
}
};
const toggleOpen = () => {
if (!isOpen) { calcDropdownPos(); }
setIsOpen(!isOpen);
};
// 검색어 변경 시 API 조회
useEffect(() => {
if (!isOpen) return;
setLoading(true);
const url = search
? `/finance/journal-entries/trading-partners?search=${encodeURIComponent(search)}`
: '/finance/journal-entries/trading-partners';
fetch(url)
.then(res => res.json())
.then(data => { if (data.success) setPartners(data.data || []); })
.catch(() => {})
.finally(() => setLoading(false));
}, [isOpen, search]);
useEffect(() => { setHighlightIndex(-1); }, [search]);
// 스크롤/리사이즈 시 위치 재계산
useEffect(() => {
if (!isOpen) return;
const handleScroll = () => calcDropdownPos();
const scrollParent = triggerRef.current?.closest('.overflow-y-auto');
if (scrollParent) scrollParent.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleScroll);
return () => {
if (scrollParent) scrollParent.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
};
}, [isOpen]);
useEffect(() => {
const handleClickOutside = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target) &&
dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (partner) => {
onChange(partner.id, partner.name);
setIsOpen(false); setSearch(''); setHighlightIndex(-1);
};
const handleClear = (e) => { e.stopPropagation(); onChange(null, ''); setSearch(''); };
const handleKeyDown = (e) => {
const maxIdx = partners.length - 1;
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightIndex(prev => prev < maxIdx ? prev + 1 : 0); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightIndex(prev => prev > 0 ? prev - 1 : maxIdx); }
else if (e.key === 'Enter' && partners.length > 0) { e.preventDefault(); handleSelect(partners[highlightIndex >= 0 ? highlightIndex : 0]); }
else if (e.key === 'Escape') { setIsOpen(false); setSearch(''); setHighlightIndex(-1); }
};
return (
<div ref={containerRef} className="relative">
<div ref={triggerRef} onClick={toggleOpen}
className={`w-full px-3 py-1.5 text-sm border rounded-lg cursor-pointer flex items-center justify-between gap-1 ${isOpen ? 'border-purple-500 ring-1 ring-purple-500' : 'border-stone-200'} bg-white`}>
<span className={displayText ? 'text-stone-900 truncate' : 'text-stone-400 text-xs'}>{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 && ReactDOM.createPortal(
<div ref={dropdownRef} style={dropdownStyle} className="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.5 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-1 focus:ring-purple-500 outline-none" autoFocus />
</div>
<div ref={listRef} className="max-h-48 overflow-y-auto">
{loading ? (
<div className="px-3 py-2 text-xs text-stone-400 text-center">검색 ...</div>
) : partners.length === 0 ? (
<div className="px-3 py-2 text-xs text-stone-400 text-center">검색 결과 없음</div>
) : partners.map((p, index) => (
<div key={p.id} onClick={() => handleSelect(p)}
className={`px-3 py-1.5 text-sm cursor-pointer ${index === highlightIndex ? 'bg-purple-600 text-white font-semibold' : value === p.id ? 'bg-purple-100 text-purple-700' : 'text-stone-700 hover:bg-purple-50'}`}>
<span className="font-medium">{p.name}</span>
{p.biz_no && <span className={`ml-1 text-xs ${index === highlightIndex ? 'text-purple-100' : 'text-stone-400'}`}>({p.biz_no})</span>}
</div>
))}
</div>
</div>,
document.body
)}
</div>
);
};
// ============================================
// CardJournalModal - 복식부기 분개 모달
// ============================================
const CardJournalModal = ({ isOpen, onClose, onSave, onDelete, log, accountCodes = [] }) => {
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0);
const formatAmountInput = (val) => { const n = String(val).replace(/[^\d]/g, ''); return n ? Number(n).toLocaleString() : ''; };
const parseAmountInput = (val) => parseInt(String(val).replace(/[^\d]/g, ''), 10) || 0;
if (!log) return null;
const supplyAmount = Math.round(log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0)));
const taxAmount = Math.round(log.effectiveTax ?? (log.tax || 0));
const totalAmount = supplyAmount + taxAmount;
const isDeductible = (log.deductionType || 'non_deductible') === 'deductible';
const uniqueKey = log.uniqueKey || `${log.cardNum}|${log.useDt}|${log.approvalNum}|${Math.floor(log.approvalAmount)}`;
// 기본 분개 라인
const getDefaultLines = () => {
// 단일 split 분개 (분리 항목별 개별 분개)
const singleSplit = log._split;
if (singleSplit) {
const splitSupply = Math.round(parseFloat(singleSplit.split_supply_amount ?? singleSplit.supplyAmount ?? singleSplit.split_amount ?? singleSplit.amount ?? 0));
const splitTax = Math.round(parseFloat(singleSplit.split_tax ?? singleSplit.tax ?? 0));
const splitDeductionType = singleSplit.deduction_type || singleSplit.deductionType || 'non_deductible';
const splitAccountCode = singleSplit.account_code || singleSplit.accountCode || '826';
const splitAccountName = singleSplit.account_name || singleSplit.accountName || '잡비';
const lines = [];
let totalDebitSum = 0;
if (splitDeductionType === 'deductible') {
// 공제: 비용 계정 = 공급가액, 부가세대급금 = 세액
lines.push({
dc_type: 'debit', account_code: splitAccountCode, account_name: splitAccountName,
debit_amount: splitSupply, credit_amount: 0,
trading_partner_id: null, trading_partner_name: '', description: singleSplit.memo || ''
});
totalDebitSum += splitSupply;
if (splitTax > 0) {
lines.push({
dc_type: 'debit', account_code: '135', account_name: '부가세대급금',
debit_amount: splitTax, credit_amount: 0,
trading_partner_id: null, trading_partner_name: '', description: ''
});
totalDebitSum += splitTax;
}
} else {
// 불공제: 비용 계정 = 공급가액 + 세액
const combined = splitSupply + splitTax;
lines.push({
dc_type: 'debit', account_code: splitAccountCode, account_name: splitAccountName,
debit_amount: combined, credit_amount: 0,
trading_partner_id: null, trading_partner_name: '', description: singleSplit.memo || ''
});
totalDebitSum += combined;
}
// 대변: 미지급비용 = 합계
lines.push({
dc_type: 'credit', account_code: '205', account_name: '미지급비용',
debit_amount: 0, credit_amount: totalDebitSum,
trading_partner_id: null, trading_partner_name: '', description: ''
});
return lines;
}
// splits가 없으면 기존 로직 (원본 금액 기반, 분리 없는 거래용)
const expenseCode = log.accountCode || '826';
const expenseName = log.accountName || '잡비';
if (isDeductible) {
return [
{ dc_type: 'debit', account_code: expenseCode, account_name: expenseName, debit_amount: supplyAmount, credit_amount: 0, trading_partner_id: null, trading_partner_name: '', description: '' },
{ dc_type: 'debit', account_code: '135', account_name: '부가세대급금', debit_amount: taxAmount, credit_amount: 0, trading_partner_id: null, trading_partner_name: '', description: '' },
{ dc_type: 'credit', account_code: '205', account_name: '미지급비용', debit_amount: 0, credit_amount: totalAmount, trading_partner_id: null, trading_partner_name: '', description: '' },
];
} else {
return [
{ dc_type: 'debit', account_code: expenseCode, account_name: expenseName, debit_amount: totalAmount, credit_amount: 0, trading_partner_id: null, trading_partner_name: '', description: '' },
{ dc_type: 'credit', account_code: '205', account_name: '미지급비용', debit_amount: 0, credit_amount: totalAmount, trading_partner_id: null, trading_partner_name: '', description: '' },
];
}
};
const [lines, setLines] = useState(getDefaultLines());
const [saving, setSaving] = useState(false);
const [loadingJournal, setLoadingJournal] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [journalId, setJournalId] = useState(null);
const [amountMismatch, setAmountMismatch] = useState(null);
// 카드 기준 금액 계산 (split 또는 원본)
const getExpectedCardAmount = () => {
if (log._split) {
const splitSupply = Math.round(parseFloat(log._split.split_supply_amount ?? log._split.supplyAmount ?? log._split.split_amount ?? log._split.amount ?? 0));
const splitTax = Math.round(parseFloat(log._split.split_tax ?? log._split.tax ?? 0));
const splitDeductionType = log._split.deduction_type || log._split.deductionType || 'non_deductible';
if (splitDeductionType === 'deductible') {
return splitSupply + splitTax;
}
return splitSupply + splitTax;
}
return totalAmount;
};
// 기존 분개 로드
useEffect(() => {
if (!isOpen || !log) return;
setAmountMismatch(null);
// 금액 불일치 감지 → 불일치 시 카드 데이터 기준으로 자동 갱신
const checkAndAutoSync = (journalLines, journalId) => {
const journalTotal = journalLines.reduce((sum, l) => sum + (parseInt(l.debit_amount) || 0), 0);
const expectedTotal = getExpectedCardAmount();
if (Math.abs(journalTotal - expectedTotal) > 0) {
// 카드 금액이 변경됨 → 새 카드 금액 기준으로 라인 자동 갱신
setLines(getDefaultLines());
setAmountMismatch({ journalTotal, expectedTotal, diff: expectedTotal - journalTotal });
setIsEditMode(true);
setJournalId(journalId);
return true;
}
return false;
};
if (log._journalData) {
const mappedLines = log._journalData.lines.map(l => ({
dc_type: l.dc_type,
account_code: l.account_code,
account_name: l.account_name,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
trading_partner_id: l.trading_partner_id || null,
trading_partner_name: l.trading_partner_name || '',
description: l.description || '',
}));
if (!checkAndAutoSync(mappedLines, log._journalData.id)) {
setLines(mappedLines);
setIsEditMode(true);
setJournalId(log._journalData.id);
}
} else if (log._hasJournal) {
setLoadingJournal(true);
fetch(`${API.journalShow}?source_key=${encodeURIComponent(uniqueKey)}`)
.then(res => res.json())
.then(data => {
if (data.success && data.data) {
const mappedLines = data.data.lines.map(l => ({
dc_type: l.dc_type,
account_code: l.account_code,
account_name: l.account_name,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
trading_partner_id: l.trading_partner_id || null,
trading_partner_name: l.trading_partner_name || '',
description: l.description || '',
}));
if (!checkAndAutoSync(mappedLines, data.data.id)) {
setLines(mappedLines);
setIsEditMode(true);
setJournalId(data.data.id);
}
} else {
setLines(getDefaultLines());
setIsEditMode(false);
setJournalId(null);
}
})
.catch(err => console.error('분개 로드 오류:', err))
.finally(() => setLoadingJournal(false));
} else {
setLines(getDefaultLines());
setIsEditMode(false);
setJournalId(null);
}
}, [isOpen, log]);
// 카드 데이터 기준으로 분개 라인 갱신
const handleSyncFromCard = () => {
setLines(getDefaultLines());
setAmountMismatch(null);
};
const updateLine = (idx, field, value) => {
setLines(prev => prev.map((l, i) => i === idx ? { ...l, [field]: value } : l));
};
const addLine = () => {
setLines(prev => [...prev, { dc_type: 'debit', account_code: '', account_name: '', debit_amount: 0, credit_amount: 0, trading_partner_id: null, trading_partner_name: '', description: '' }]);
};
const removeLine = (idx) => {
if (lines.length <= 2) return;
setLines(prev => prev.filter((_, i) => i !== idx));
};
const totalDebit = lines.reduce((sum, l) => sum + (parseInt(l.debit_amount) || 0), 0);
const totalCredit = lines.reduce((sum, l) => sum + (parseInt(l.credit_amount) || 0), 0);
const isBalanced = totalDebit === totalCredit && totalDebit > 0;
const toggleDcType = (idx) => {
setLines(prev => prev.map((l, i) => {
if (i !== idx) return l;
const newType = l.dc_type === 'debit' ? 'credit' : 'debit';
return { ...l, dc_type: newType, debit_amount: l.credit_amount, credit_amount: l.debit_amount };
}));
};
const handleSubmit = async () => {
const emptyLine = lines.find(l => !l.account_code || !l.account_name);
if (emptyLine) {
notify('모든 분개 라인의 계정과목을 선택해주세요.', 'warning');
return;
}
if (!isBalanced) {
notify('차변과 대변의 합계가 일치하지 않습니다.', 'warning');
return;
}
setSaving(true);
// entry_date: useDt에서 YYYY-MM-DD 추출
const useDt = log.useDt || '';
const entryDate = useDt.length >= 8
? `${useDt.substring(0,4)}-${useDt.substring(4,6)}-${useDt.substring(6,8)}`
: new Date().toISOString().substring(0,10);
await onSave({
source_key: uniqueKey,
entry_date: entryDate,
description: `${log.merchantName || ''} 카드결제`,
lines,
});
setSaving(false);
};
const handleDelete = async () => {
if (!journalId) return;
if (!confirm('분개를 삭제하시겠습니까?')) return;
setSaving(true);
await onDelete(journalId);
setSaving(false);
};
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-2xl shadow-2xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-hidden">
<div className="p-6 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">
{isEditMode ? '분개 수정' : '분개 생성'}
</h3>
<button onClick={onClose} className="p-2 hover:bg-stone-100 rounded-lg transition-colors">
<svg className="w-5 h-5 text-stone-500" 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="p-6 overflow-y-auto max-h-[65vh] space-y-5">
{/* 카드 거래 정보 */}
<div className="bg-stone-50 rounded-xl p-4">
<h4 className="text-sm font-semibold text-stone-700 mb-3">카드 거래 정보</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div><span className="text-stone-500">가맹점: </span><span className="font-medium">{log.merchantName || '-'}</span></div>
<div><span className="text-stone-500">사용일시: </span><span className="font-medium">{log.useDateTime || '-'}</span></div>
<div><span className="text-stone-500">공급가액: </span><span className="font-medium">{formatCurrency(supplyAmount)}</span></div>
<div><span className="text-stone-500">세액: </span><span className="font-medium">{formatCurrency(taxAmount)}</span></div>
</div>
{log._split && (
<div className="mt-3">
<div className="flex items-center gap-2 mb-2">
<span className="inline-flex items-center px-2.5 py-1 bg-amber-100 text-amber-700 rounded-lg text-xs font-bold">
분리 항목 분개
</span>
{isEditMode && (
<span className="text-xs text-stone-400">저장된 분개가 있어 참고용으로 표시됩니다</span>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm bg-amber-50 rounded-lg p-3">
<div><span className="text-stone-500">계정: </span><span className="font-medium">{log._split.account_name || log._split.accountName || '미지정'}</span></div>
<div><span className="text-stone-500">공제: </span><span className="font-medium">{(log._split.deduction_type || log._split.deductionType) === 'deductible' ? '공제' : '불공제'}</span></div>
<div><span className="text-stone-500">공급가액: </span><span className="font-medium">{formatCurrency(Math.round(parseFloat(log._split.split_supply_amount ?? log._split.supplyAmount ?? log._split.split_amount ?? log._split.amount ?? 0)))}</span></div>
<div><span className="text-stone-500">세액: </span><span className="font-medium">{formatCurrency(Math.round(parseFloat(log._split.split_tax ?? log._split.tax ?? 0)))}</span></div>
</div>
</div>
)}
</div>
{/* 카드 금액 변경으로 분개 자동 갱신 안내 */}
{amountMismatch && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-amber-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="flex-1">
<p className="text-sm font-bold text-amber-700">카드 금액이 변경되어 분개 라인이 자동 갱신되었습니다</p>
<div className="mt-2 grid grid-cols-3 gap-2 text-sm">
<div><span className="text-amber-600">현재 카드 금액: </span><span className="font-bold text-amber-700">{formatCurrency(amountMismatch.expectedTotal)}</span></div>
<div><span className="text-amber-600">이전 분개 금액: </span><span className="font-bold text-amber-700">{formatCurrency(amountMismatch.journalTotal)}</span></div>
<div><span className="text-amber-600">차이: </span><span className="font-bold text-amber-700">{formatCurrency(Math.abs(amountMismatch.diff))}</span></div>
</div>
</div>
</div>
</div>
)}
{loadingJournal ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-600 border-t-transparent"></div>
<span className="ml-2 text-sm text-stone-500">분개 데이터 로딩중...</span>
</div>
) : (
<div>
<h4 className="text-sm font-semibold text-stone-700 mb-3">분개 내역</h4>
<table className="w-full text-sm border border-stone-200 rounded-lg overflow-hidden">
<thead>
<tr className="bg-stone-100">
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200 w-16">/</th>
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">계정과목</th>
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">거래처</th>
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200 w-32">차변금액</th>
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200 w-32">대변금액</th>
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200 w-10"></th>
</tr>
</thead>
<tbody>
{lines.map((line, idx) => (
<tr key={idx} className="border-b border-stone-100">
<td className="px-3 py-2 text-center">
<button
type="button"
onClick={() => toggleDcType(idx)}
className={`px-2 py-0.5 rounded text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity ${line.dc_type === 'debit' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'}`}
title="클릭하여 차변/대변 전환"
>
{line.dc_type === 'debit' ? '차변' : '대변'}
</button>
</td>
<td className="px-3 py-2">
<AccountCodeSelect
value={line.account_code}
onChange={(code, name) => {
setLines(prev => prev.map((l, i) => i === idx ? { ...l, account_code: code, account_name: name } : l));
}}
accountCodes={accountCodes}
/>
</td>
<td className="px-3 py-2">
<TradingPartnerSelect
value={line.trading_partner_id}
valueName={line.trading_partner_name}
onChange={(id, name) => {
setLines(prev => prev.map((l, i) => i === idx ? { ...l, trading_partner_id: id, trading_partner_name: name } : l));
}}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
value={formatAmountInput(line.debit_amount)}
onChange={(e) => updateLine(idx, 'debit_amount', parseAmountInput(e.target.value))}
className="w-full px-2 py-1 border border-stone-200 rounded text-sm text-right focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="px-3 py-2">
<input
type="text"
value={formatAmountInput(line.credit_amount)}
onChange={(e) => updateLine(idx, 'credit_amount', parseAmountInput(e.target.value))}
className="w-full px-2 py-1 border border-stone-200 rounded text-sm text-right focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="px-3 py-2 text-center">
<button
onClick={() => removeLine(idx)}
disabled={lines.length <= 2}
className="text-red-400 hover:text-red-600 disabled:opacity-30 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="M20 12H4" />
</svg>
</button>
</td>
</tr>
))}
{/* 합계 */}
<tr className={`font-bold ${isBalanced ? 'bg-green-50' : 'bg-red-50'}`}>
<td colSpan="3" className="px-3 py-2 text-center text-sm">합계</td>
<td className="px-3 py-2 text-right text-sm">{formatCurrency(totalDebit)}</td>
<td className="px-3 py-2 text-right text-sm">{formatCurrency(totalCredit)}</td>
<td></td>
</tr>
</tbody>
</table>
{!isBalanced && (
<p className="text-red-500 text-xs mt-2">차변과 대변의 합계가 일치하지 않습니다. (차이: {formatCurrency(Math.abs(totalDebit - totalCredit))})</p>
)}
{/* 행 추가 버튼 */}
<button
onClick={addLine}
className="mt-3 w-full py-2 border-2 border-dashed border-stone-300 text-stone-500 rounded-lg hover:border-purple-400 hover:text-purple-600 transition-colors flex items-center justify-center gap-2 text-sm"
>
<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="p-4 border-t border-stone-100 flex justify-between">
<div>
{isEditMode && journalId && (
<button
onClick={handleDelete}
disabled={saving}
className="px-4 py-2 bg-red-50 text-red-600 rounded-lg text-sm font-medium hover:bg-red-100 transition-colors disabled:opacity-50"
>
분개 삭제
</button>
)}
</div>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 bg-stone-100 text-stone-700 rounded-lg text-sm font-medium hover:bg-stone-200 transition-colors">
취소
</button>
<button
onClick={handleSubmit}
disabled={saving || !isBalanced || loadingJournal}
className="px-6 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{saving && <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>}
{isEditMode ? '분개 수정' : '분개 저장'}
</button>
</div>
</div>
</div>
</div>
);
};
// 카드사 코드 목록
const CARD_COMPANIES = [
{ code: '01', name: '비씨' },
{ code: '02', name: 'KB국민' },
{ code: '03', name: '하나(외환)' },
{ code: '04', name: '삼성' },
{ code: '06', name: '신한' },
{ code: '07', name: '현대' },
{ code: '08', name: '롯데' },
{ code: '11', name: 'NH농협' },
{ code: '12', name: '수협' },
{ code: '13', name: '씨티' },
{ code: '14', name: '우리' },
{ code: '15', name: '광주' },
{ code: '16', name: '전북' },
{ code: '21', name: '하나' },
{ code: '22', name: '제주' },
{ code: '23', name: 'SC제일' },
{ code: '25', name: 'KDB산업' },
{ code: '26', name: 'IBK기업' },
{ code: '27', name: '새마을금고' },
{ code: '28', name: '신협' },
{ code: '29', name: '저축은행' },
{ code: '30', name: '우체국' },
{ code: '31', name: '카카오뱅크' },
{ code: '32', name: 'K뱅크' },
{ code: '33', name: '토스뱅크' },
];
// ManualEntryModal Component - 수동입력 모달
const ManualEntryModal = ({ isOpen, onClose, onSave, editData, accountCodes, cards }) => {
const [form, setForm] = useState({});
const [saving, setSaving] = useState(false);
const isEditMode = !!editData;
useEffect(() => {
if (isOpen) {
if (editData) {
// 수정 모드: 기존 데이터로 채움
const isRegisteredCard = cards.some(c => c.cardNum === editData.cardNum);
setForm({
card_company: editData.cardCompany || '',
card_num: editData.cardNum || '',
_manualCard: !isRegisteredCard,
use_date: editData.useDate || '',
use_time: editData.useTime || '',
approval_num: editData.approvalNum || '',
approval_type: editData.approvalType || '1',
approval_amount: editData.effectiveSupplyAmount || 0,
tax: editData.effectiveTax || 0,
merchant_name: editData.merchantName || '',
merchant_biz_num: editData.merchantBizNum || '',
deduction_type: editData.deductionType || 'deductible',
account_code: editData.accountCode || '',
account_name: editData.accountName || '',
evidence_name: editData.evidenceName || '',
description: editData.description || '',
memo: editData.memo || '',
});
} else {
// 신규 모드: 빈 폼
const today = new Date();
const todayStr = today.getFullYear() +
String(today.getMonth() + 1).padStart(2, '0') +
String(today.getDate()).padStart(2, '0');
setForm({
card_company: '',
card_num: '',
_manualCard: false,
use_date: todayStr,
use_time: '',
approval_num: '',
approval_type: '1',
approval_amount: 0,
tax: 0,
merchant_name: '',
merchant_biz_num: '',
deduction_type: 'deductible',
account_code: '',
account_name: '',
evidence_name: '',
description: '',
memo: '',
});
}
}
}, [isOpen, editData]);
if (!isOpen) return null;
const updateField = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
if (!form.card_company || !form.card_num || !form.use_date || !form.approval_type || form.approval_amount === '' || form.tax === '') {
notify('필수 항목을 모두 입력해주세요.', 'error');
return;
}
setSaving(true);
await onSave(form, editData?.dbId);
setSaving(false);
};
// use_date 표시용 포맷 (YYYYMMDD -> YYYY-MM-DD)
const formatDateForInput = (val) => {
if (!val || val.length !== 8) return val;
return val.substring(0, 4) + '-' + val.substring(4, 6) + '-' + val.substring(6, 8);
};
const parseDateFromInput = (val) => val.replace(/-/g, '');
// use_time 표시용 포맷 (HHMMSS -> HH:MM)
const formatTimeForInput = (val) => {
if (!val) return '';
const clean = val.replace(/:/g, '');
if (clean.length >= 4) return clean.substring(0, 2) + ':' + clean.substring(2, 4);
return val;
};
const parseTimeFromInput = (val) => {
const clean = val.replace(/:/g, '');
return clean.padEnd(6, '0').substring(0, 6);
};
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0);
const parseCurrency = (val) => parseFloat(String(val).replace(/[^0-9.-]/g, '')) || 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">
{isEditMode ? '수동 거래 수정' : '수동 거래 입력'}
</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>
<div className="p-6 overflow-y-auto" style={ {maxHeight: '60vh'} }>
<div className="grid grid-cols-2 gap-4">
{/* 카드 선택 */}
<div className="col-span-2">
<label className="block text-sm font-medium text-stone-700 mb-1">카드 선택 <span className="text-red-500">*</span></label>
<select
value={form.card_num ? `${form.card_company}|${form.card_num}` : ''}
onChange={(e) => {
const val = e.target.value;
if (val === '__manual__') {
updateField('card_company', '');
updateField('card_num', '');
updateField('_manualCard', true);
} else if (val) {
const [company, ...numParts] = val.split('|');
const cardNum = numParts.join('|');
const card = cards.find(c => c.cardNum === cardNum);
updateField('card_company', company || (card?.cardCompany || ''));
updateField('card_num', cardNum);
updateField('_manualCard', false);
} else {
updateField('card_company', '');
updateField('card_num', '');
updateField('_manualCard', false);
}
}}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
>
<option value="">-- 카드를 선택하세요 --</option>
{cards.map(card => (
<option key={card.cardNum} value={`${card.cardCompany}|${card.cardNum}`}>
{card.cardBrand} ****{card.cardNum.slice(-4)} {card.alias ? `(${card.alias})` : ''} [{card.cardNum}]
</option>
))}
<option value="__manual__">직접 입력</option>
</select>
</div>
{/* 직접 입력 모드일 때만 표시 */}
{form._manualCard && (
<>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">카드사 <span className="text-red-500">*</span></label>
<select
value={form.card_company}
onChange={(e) => updateField('card_company', e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
>
<option value="">선택</option>
{CARD_COMPANIES.map(c => (
<option key={c.code} value={c.code}>{c.name}</option>
))}
</select>
</div>
<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.card_num}
onChange={(e) => updateField('card_num', e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
placeholder="카드번호 입력"
/>
</div>
</>
)}
{/* 사용일 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">사용일 <span className="text-red-500">*</span></label>
<input
type="date"
value={formatDateForInput(form.use_date)}
onChange={(e) => updateField('use_date', parseDateFromInput(e.target.value))}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
/>
</div>
{/* 사용시간 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">사용시간</label>
<input
type="time"
value={formatTimeForInput(form.use_time)}
onChange={(e) => updateField('use_time', parseTimeFromInput(e.target.value))}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
/>
</div>
{/* 승인번호 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">승인번호</label>
<input
type="text"
value={form.approval_num}
onChange={(e) => updateField('approval_num', e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
placeholder="승인번호"
/>
</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 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="1"
checked={form.approval_type === '1'}
onChange={(e) => updateField('approval_type', e.target.value)}
className="text-purple-600 focus:ring-purple-500"
/>
<span className="text-sm">승인</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
value="2"
checked={form.approval_type === '2'}
onChange={(e) => updateField('approval_type', e.target.value)}
className="text-red-600 focus:ring-red-500"
/>
<span className="text-sm">취소</span>
</label>
</div>
</div>
{/* 공급가액 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">공급가액 <span className="text-red-500">*</span></label>
<input
type="text"
value={formatCurrency(form.approval_amount)}
onChange={(e) => {
const val = parseCurrency(e.target.value);
updateField('approval_amount', val);
}}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm text-right focus:ring-2 focus:ring-purple-500 outline-none"
placeholder="0"
/>
</div>
{/* 세액 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">세액 <span className="text-red-500">*</span></label>
<input
type="text"
value={formatCurrency(form.tax)}
onChange={(e) => {
const val = parseCurrency(e.target.value);
updateField('tax', val);
}}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm text-right focus:ring-2 focus:ring-purple-500 outline-none"
placeholder="0"
/>
</div>
{/* 가맹점명 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">가맹점명</label>
<input
type="text"
value={form.merchant_name}
onChange={(e) => updateField('merchant_name', e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
placeholder="가맹점명"
/>
</div>
{/* 사업자번호 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">사업자번호</label>
<input
type="text"
value={form.merchant_biz_num}
onChange={(e) => updateField('merchant_biz_num', e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
placeholder="000-00-00000"
/>
</div>
{/* 공제여부 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">공제여부 <span className="text-red-500">*</span></label>
<select
value={form.deduction_type}
onChange={(e) => updateField('deduction_type', e.target.value)}
className={`w-full px-3 py-2 border border-stone-200 rounded-lg text-sm font-bold focus:ring-2 focus:ring-purple-500 outline-none ${
form.deduction_type === 'non_deductible'
? 'bg-red-500 text-white'
: 'bg-green-100 text-green-700'
}`}
>
<option value="deductible">공제</option>
<option value="non_deductible">불공</option>
</select>
</div>
{/* 계정과목 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">계정과목</label>
<AccountCodeSelect
value={form.account_code}
onChange={(code, name) => {
updateField('account_code', code);
updateField('account_name', name);
}}
accountCodes={accountCodes}
/>
</div>
{/* 증빙/판매자상호 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">증빙/판매자상호</label>
<input
type="text"
value={form.evidence_name}
onChange={(e) => updateField('evidence_name', e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
placeholder="증빙/판매자상호"
/>
</div>
{/* 내역 */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">내역</label>
<input
type="text"
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
placeholder="내역"
/>
</div>
{/* 메모 */}
<div className="col-span-2">
<label className="block text-sm font-medium text-stone-700 mb-1">메모</label>
<input
type="text"
value={form.memo}
onChange={(e) => updateField('memo', e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none"
placeholder="메모 (선택)"
/>
</div>
</div>
{/* 합계 금액 표시 */}
<div className="mt-4 p-3 bg-purple-50 rounded-lg">
<div className="flex justify-between text-sm">
<span className="text-stone-600">합계 금액 (공급가액 + 세액)</span>
<span className="font-bold text-purple-700">
{new Intl.NumberFormat('ko-KR').format((parseFloat(form.approval_amount) || 0) + (parseFloat(form.tax) || 0))}
</span>
</div>
</div>
</div>
<div className="p-6 border-t border-stone-200 bg-stone-50 flex gap-3">
<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={handleSubmit}
disabled={saving}
className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? '저장 중...' : (isEditMode ? '수정' : '등록')}
</button>
</div>
</div>
</div>
);
};
// TransactionTable Component
const TransactionTable = ({
logs,
loading,
totalCount,
accountCodes,
onAccountCodeChange,
onFieldChange,
splits,
onOpenSplitModal,
onDeleteSplits,
onManualEdit,
onManualDelete,
onHide,
showHidden,
hiddenLogs,
onRestore,
loadingHidden,
journalMap,
onOpenJournalModal,
}) => {
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-purple-600"></div>
</div>
);
}
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden min-h-[calc(100vh-200px)]">
<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-3 py-4 bg-stone-50 w-10">분리</th>
<th className="px-3 py-4 bg-stone-50 w-10">분개</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 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 bg-stone-50 min-w-[250px]">내역</th>
<th className="px-4 py-4 text-right bg-stone-50">합계금액</th>
<th className="px-4 py-4 text-right bg-stone-50">공급가액</th>
<th className="px-4 py-4 text-right bg-stone-50">세액</th>
<th className="px-4 py-4 bg-stone-50 min-w-[150px]">계정과목</th>
<th className="px-3 py-4 bg-stone-50 w-16">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{logs.length === 0 ? (
<tr>
<td colSpan="14" className="px-6 py-8 text-center text-stone-400">
해당 기간에 조회된 카드 사용내역이 없습니다.
</td>
</tr>
) : (
logs.map((log, index) => {
const uniqueKey = log.uniqueKey || `${log.cardNum}|${log.useDt}|${log.approvalNum}|${Math.floor(log.approvalAmount)}`;
const logSplits = splits[uniqueKey] || [];
const hasSplits = logSplits.length > 0;
return (
<React.Fragment key={index}>
{/* 원본 거래 행 */}
<tr className={`hover:bg-stone-50 transition-colors ${log.isSaved ? 'bg-purple-50/30' : ''} ${hasSplits ? 'bg-amber-50/50' : ''}`}>
{/* 분리 열 */}
<td className="px-3 py-3 text-center">
{(() => {
if (hasSplits) {
return (
<button
onClick={() => onOpenSplitModal(log, uniqueKey, logSplits)}
className="px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded text-[10px] font-bold hover:bg-amber-200 transition-colors"
title={`분리 ${logSplits.length}건 (클릭하여 수정)`}
>
{logSplits.length}
</button>
);
} else {
return (
<button
onClick={() => onOpenSplitModal(log, uniqueKey, [])}
className="p-1.5 text-amber-500 hover:bg-amber-100 rounded-lg 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>
);
}
})()}
</td>
{/* 분개 열 */}
<td className="px-3 py-3 text-center">
{(() => {
if (hasSplits) {
// 분리 항목별 분개 집계
const splitJournalCount = logSplits.filter(s => journalMap[`${uniqueKey}|split:${s.id}`]).length;
if (splitJournalCount === logSplits.length) {
return (
<span className="px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded text-[10px] font-bold">
완료
</span>
);
} else {
return (
<span className={`text-[10px] font-bold ${splitJournalCount > 0 ? 'text-purple-600' : 'text-stone-400'}`}>
{splitJournalCount}/{logSplits.length}
</span>
);
}
}
const jInfo = journalMap[uniqueKey];
if (jInfo) {
return (
<button
onClick={() => onOpenJournalModal(log, uniqueKey, true, logSplits)}
className="px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded text-[10px] font-bold hover:bg-emerald-200 transition-colors"
title={`전표: ${jInfo.entry_no}`}
>
완료
</button>
);
} else {
return (
<button
onClick={() => onOpenJournalModal(log, uniqueKey, false, logSplits)}
className="p-1.5 text-purple-500 hover:bg-purple-100 rounded-lg 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>
);
}
})()}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="font-medium text-stone-900">{log.useDateTime || '-'}</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.cardBrand}</div>
<div className="text-xs text-stone-400 font-mono">
{log.cardNum ? '****-' + log.cardNum.slice(-4) : '-'}
</div>
</td>
<td className="px-4 py-3 text-center">
{hasSplits ? (
<span className="text-stone-400 font-medium">-</span>
) : (
<select
value={log.deductionType || (log.merchantBizNum ? 'deductible' : 'non_deductible')}
onChange={(e) => onFieldChange(index, 'deductionType', e.target.value)}
className={`px-4 py-1.5 pr-8 rounded text-sm font-bold border-0 cursor-pointer w-[100px] ${
(log.deductionType || (log.merchantBizNum ? 'deductible' : 'non_deductible')) === 'deductible'
? 'bg-green-100 text-green-700'
: 'bg-red-500 text-white'
}`}
>
<option value="deductible">공제</option>
<option value="non_deductible">불공</option>
</select>
)}
</td>
<td className="px-4 py-3 text-xs text-stone-600 font-mono whitespace-nowrap">
{log.merchantBizNum || '-'}
</td>
<td className="px-4 py-3 text-sm text-stone-700 whitespace-nowrap">
{(() => {
const name = log.merchantName || '-';
if (name.length <= 6) return name;
return (
<span
className="cursor-pointer hover:text-purple-600"
title={name}
onClick={() => { window._merchantModal = name; window.dispatchEvent(new Event('showMerchantModal')); }}
>
{name.slice(0, 6)}...
</span>
);
})()}
</td>
<td className="px-4 py-3">
<input
type="text"
value={log.evidenceName ?? log.merchantName ?? ''}
onChange={(e) => onFieldChange(index, 'evidenceName', e.target.value)}
className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"
placeholder="판매자상호"
/>
</td>
<td className="px-4 py-3">
<input
type="text"
value={log.description ?? log.merchantBizType ?? log.memo ?? ''}
onChange={(e) => onFieldChange(index, 'description', e.target.value)}
className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"
placeholder="내역"
/>
</td>
<td className="px-4 py-3 text-right font-medium">
{(() => {
const effectiveSupply = log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0));
const effectiveTax = log.effectiveTax ?? (log.tax || 0);
const totalAmount = effectiveSupply + effectiveTax;
return (
<div>
<span className={log.approvalType === '1' ? 'text-purple-600' : 'text-red-600'}>
{new Intl.NumberFormat('ko-KR').format(totalAmount)}
</span>
{log.isAmountModified && totalAmount !== log.approvalAmount && (
<div className="text-xs text-stone-400 line-through">{log.approvalAmountFormatted}</div>
)}
</div>
);
})()}
{!hasSplits && journalMap[uniqueKey] && (
<div className="text-xs text-emerald-600 mt-1">{journalMap[uniqueKey].entry_no}</div>
)}
{hasSplits && (() => {
const splitJournalCount = logSplits.filter(s => journalMap[`${uniqueKey}|split:${s.id}`]).length;
if (splitJournalCount > 0) {
return <div className="text-xs text-emerald-600 mt-1">분개 {splitJournalCount}/{logSplits.length}</div>;
}
return <div className="text-xs text-amber-600 mt-1">분리됨({logSplits.length})</div>;
})()}
</td>
<td className={`px-4 py-3 text-right ${log.isAmountModified && log.modifiedSupplyAmount !== null ? 'bg-orange-50' : ''}`}>
{hasSplits ? (
<span className="text-stone-500">
{new Intl.NumberFormat('ko-KR').format(log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0)))}
</span>
) : (
<div>
<input
type="text"
value={new Intl.NumberFormat('ko-KR').format(log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0)))}
onChange={(e) => {
const val = parseFloat(String(e.target.value).replace(/[^0-9.-]/g, '')) || 0;
const originalSupply = (log.approvalAmount || 0) - (log.tax || 0);
const isModified = Math.abs(val - originalSupply) > 0.01;
onFieldChange(index, 'effectiveSupplyAmount', val);
onFieldChange(index, 'modifiedSupplyAmount', isModified ? val : null);
onFieldChange(index, 'isAmountModified', isModified || log.modifiedTax !== null);
}}
className={`w-full px-2 py-1 text-sm text-right border rounded focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500 ${
log.modifiedSupplyAmount !== null ? 'border-orange-300 bg-orange-50' : 'border-stone-200'
}`}
/>
{log.modifiedSupplyAmount !== null && (
<div className="text-xs text-stone-400 line-through mt-0.5">
{new Intl.NumberFormat('ko-KR').format((log.approvalAmount || 0) - (log.tax || 0))}
</div>
)}
</div>
)}
</td>
<td className={`px-4 py-3 text-right ${log.isAmountModified && log.modifiedTax !== null ? 'bg-orange-50' : ''}`}>
{hasSplits ? (
<span className="text-stone-500">
{new Intl.NumberFormat('ko-KR').format(log.effectiveTax ?? (log.tax || 0))}
</span>
) : (
<div>
<input
type="text"
value={new Intl.NumberFormat('ko-KR').format(log.effectiveTax ?? (log.tax || 0))}
onChange={(e) => {
const val = parseFloat(String(e.target.value).replace(/[^0-9.-]/g, '')) || 0;
const originalTax = log.tax || 0;
const isModified = Math.abs(val - originalTax) > 0.01;
onFieldChange(index, 'effectiveTax', val);
onFieldChange(index, 'modifiedTax', isModified ? val : null);
onFieldChange(index, 'isAmountModified', isModified || log.modifiedSupplyAmount !== null);
}}
className={`w-full px-2 py-1 text-sm text-right border rounded focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500 ${
log.modifiedTax !== null ? 'border-orange-300 bg-orange-50' : 'border-stone-200'
}`}
/>
{log.modifiedTax !== null && (
<div className="text-xs text-stone-400 line-through mt-0.5">
{new Intl.NumberFormat('ko-KR').format(log.tax || 0)}
</div>
)}
</div>
)}
</td>
<td className="px-4 py-3">
{!hasSplits && (
<>
<AccountCodeSelect
value={log.accountCode}
onChange={(code, name) => onAccountCodeChange(index, code, name)}
accountCodes={accountCodes}
/>
{log.accountName && (
<div className="text-xs text-purple-600 mt-1">{log.accountName}</div>
)}
</>
)}
{hasSplits && (
<span className="text-xs text-amber-600">
분리됨 ({logSplits.length})
</span>
)}
</td>
<td className="px-3 py-3 text-center">
<div className="flex items-center gap-1 justify-center">
{log.isManual && (
<>
<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>
</>
)}
<button
onClick={() => onHide(log, uniqueKey)}
className="p-1 text-stone-400 hover: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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</td>
</tr>
{/* 분리 행들 */}
{hasSplits && logSplits.map((split, splitIdx) => {
const splitSupply = split.split_supply_amount !== null && split.split_supply_amount !== undefined
? parseFloat(split.split_supply_amount)
: parseFloat(split.split_amount || split.amount || 0);
const splitTax = split.split_tax !== null && split.split_tax !== undefined
? parseFloat(split.split_tax)
: 0;
const splitTotal = splitSupply + splitTax;
return (
<tr key={`${index}-split-${splitIdx}`} className="bg-amber-50/30 hover:bg-amber-100/30">
<td className="px-3 py-2 text-center">
<div className="w-4 h-4 border-l-2 border-b-2 border-amber-300 ml-2"></div>
</td>
<td className="px-3 py-2 text-center">
{(() => {
const splitSourceKey = `${uniqueKey}|split:${split.id}`;
const sjInfo = journalMap[splitSourceKey];
if (sjInfo) {
return (
<button
onClick={() => onOpenJournalModal(log, splitSourceKey, true, logSplits, split)}
className="px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded text-[10px] font-bold hover:bg-emerald-200 transition-colors"
title={`전표: ${sjInfo.entry_no}`}
>
완료
</button>
);
} else {
return (
<button
onClick={() => onOpenJournalModal(log, splitSourceKey, false, logSplits, split)}
className="px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded text-[10px] font-bold hover:bg-purple-200 transition-colors"
title="분리 항목 분개"
>
분개
</button>
);
}
})()}
</td>
<td colSpan="2" className="px-4 py-2 text-xs text-stone-600">
분리 #{splitIdx + 1} {split.memo && `- ${split.memo}`}
</td>
<td className="px-4 py-2 text-center">
<span className={`px-2 py-1 rounded text-xs font-bold ${
(split.deduction_type || split.deductionType) === 'non_deductible'
? 'bg-red-500 text-white'
: 'bg-green-100 text-green-700'
}`}>
{(split.deduction_type || split.deductionType) === 'non_deductible' ? '불공' : '공제'}
</span>
</td>
<td></td>
<td></td>
<td className="px-4 py-2 text-xs text-stone-600">
{split.evidence_name || split.evidenceName || '-'}
</td>
<td className="px-4 py-2 text-xs text-stone-600">
{split.description || '-'}
</td>
<td className="px-4 py-2 text-right font-medium text-amber-700 text-sm">
{new Intl.NumberFormat('ko-KR').format(splitTotal)}
</td>
<td className="px-4 py-2 text-right text-xs text-amber-600">
{new Intl.NumberFormat('ko-KR').format(splitSupply)}
</td>
<td className="px-4 py-2 text-right text-xs text-amber-600">
{new Intl.NumberFormat('ko-KR').format(splitTax)}
</td>
<td className="px-4 py-2 text-xs">
{split.account_code || split.accountCode ? (
<span className="text-purple-600">
{split.account_code || split.accountCode} {split.account_name || split.accountName}
</span>
) : (
<span className="text-stone-400">미지정</span>
)}
</td>
<td></td>
</tr>
);
})}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
{/* 숨김 데이터 영역 */}
{showHidden && (
<div className="border-t-2 border-red-200 bg-red-50/50">
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
<h3 className="text-sm font-bold text-red-700">숨김 처리된 거래 ({hiddenLogs.length})</h3>
</div>
{loadingHidden ? (
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-red-500"></div>
</div>
) : hiddenLogs.length === 0 ? (
<p className="text-sm text-red-400 py-4 text-center">숨김 처리된 거래가 없습니다.</p>
) : (
<table className="w-full text-left text-sm text-stone-600">
<thead className="bg-red-100/50 text-xs uppercase font-medium text-red-500">
<tr>
<th className="px-4 py-2">사용일</th>
<th className="px-4 py-2">카드번호</th>
<th className="px-4 py-2">승인번호</th>
<th className="px-4 py-2">가맹점명</th>
<th className="px-4 py-2 text-right">금액</th>
<th className="px-4 py-2">숨김일시</th>
<th className="px-4 py-2 text-center">복원</th>
</tr>
</thead>
<tbody className="divide-y divide-red-100">
{hiddenLogs.map((h, i) => {
const dateStr = h.useDate
? `${h.useDate.substring(0,4)}-${h.useDate.substring(4,6)}-${h.useDate.substring(6,8)}`
: '-';
return (
<tr key={h.id || i} className="bg-red-50 hover:bg-red-100/50">
<td className="px-4 py-2 text-sm">{dateStr}</td>
<td className="px-4 py-2 text-sm font-mono">{h.cardNum}</td>
<td className="px-4 py-2 text-sm">{h.approvalNum || '-'}</td>
<td className="px-4 py-2 text-sm">{h.merchantName || '-'}</td>
<td className="px-4 py-2 text-sm text-right font-medium">{h.originalAmountFormatted}</td>
<td className="px-4 py-2 text-xs text-stone-400">{h.hiddenAt}</td>
<td className="px-4 py-2 text-center">
<button
onClick={() => onRestore(h.uniqueKey)}
className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-600 transition-colors font-medium"
>
복원
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
);
};
// Main App Component
const App = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [cards, setCards] = useState([]);
const [selectedCard, setSelectedCard] = 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 [journalMap, setJournalMap] = useState({});
const [journalModalOpen, setJournalModalOpen] = useState(false);
const [journalModalLog, setJournalModalLog] = 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 [manualModalOpen, setManualModalOpen] = useState(false);
const [manualEditData, setManualEditData] = useState(null);
// 숨김 관련 상태
const [showHidden, setShowHidden] = useState(false);
const [hiddenLogs, setHiddenLogs] = useState([]);
const [loadingHidden, setLoadingHidden] = useState(false);
// 가맹점명 모달
const [merchantNameModal, setMerchantNameModal] = useState(null);
useEffect(() => {
const handler = () => setMerchantNameModal(window._merchantModal || '');
window.addEventListener('showMerchantModal', handler);
return () => window.removeEventListener('showMerchantModal', handler);
}, []);
// 날짜 필터 상태 (기본: 현재 월)
const currentMonth = getMonthDates(0);
const [dateFrom, setDateFrom] = useState(currentMonth.from);
const [dateTo, setDateTo] = useState(currentMonth.to);
const isInitialMount = useRef(true);
// 초기 로드
useEffect(() => {
loadCards();
loadAccountCodes();
loadTransactions();
}, []);
// 카드 선택 변경 시 자동 조회
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
loadTransactions();
}, [selectedCard]);
const loadCards = async () => {
try {
const response = await fetch(API.cards);
const data = await response.json();
if (data.success) {
setCards(data.cards || []);
}
} 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, ''),
cardNum: selectedCard,
page: page,
limit: 200
});
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 || {});
} else {
setError(data.error || '조회 실패');
setLogs([]);
}
} catch (err) {
setError('서버 통신 오류: ' + err.message);
setLogs([]);
} finally {
setLoading(false);
}
// 분리 데이터 로드
loadSplits(from, to);
loadJournalStatuses(from, to);
};
// 분리 데이터 로드
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) {
const newSplits = data.data || {};
setSplits(newSplits);
// useEffect에서 splits 변경 감지하여 재계산 처리
}
} catch (err) {
console.error('분리 데이터 로드 오류:', err);
}
};
// 복식부기 분개 상태 로드
const loadJournalStatuses = 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.journalStatuses}?${params}`);
const data = await response.json();
if (data.success) {
setJournalMap(data.data || {});
}
} catch (err) {
console.error('분개 상태 로드 오류:', err);
}
};
// 복식부기 분개 모달 열기
const handleOpenJournalModal = (log, sourceKey, hasJournal, logSplits, singleSplit) => {
const logWithJournalInfo = {
...log,
uniqueKey: sourceKey,
_hasJournal: hasJournal,
_journalData: null,
_splits: logSplits || [],
_split: singleSplit || null,
};
setJournalModalLog(logWithJournalInfo);
setJournalModalOpen(true);
};
// 복식부기 분개 저장
const handleSaveJournal = async (payload) => {
try {
const response = await fetch(API.journalStore, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json'
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
notify(data.message, 'success');
setJournalModalOpen(false);
setJournalModalLog(null);
loadJournalStatuses();
loadSplits(); // splits 상태 갱신
} else {
notify(data.message || '분개 저장 실패', 'error');
}
} catch (err) {
notify('분개 저장 오류: ' + err.message, 'error');
}
};
// 복식부기 분개 삭제
const handleDeleteJournal = async (journalId) => {
try {
const response = await fetch(`${API.journalDelete}${journalId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success) {
notify(data.message, 'success');
setJournalModalOpen(false);
setJournalModalLog(null);
loadJournalStatuses();
} else {
notify(data.message || '분개 삭제 실패', 'error');
}
} catch (err) {
notify('분개 삭제 오류: ' + err.message, 'error');
}
};
// 요약 재계산: 분리가 있는 거래는 원본 대신 분리별 통계로 대체
// 수정된 공급가액/세액이 있으면 해당 값으로 합계 반영
const recalculateSummary = (currentLogs, allSplits) => {
if (!currentLogs || currentLogs.length === 0) return;
let totalAmount = 0;
let deductibleAmount = 0;
let deductibleCount = 0;
let deductibleSupply = 0;
let deductibleTax = 0;
let nonDeductibleAmount = 0;
let nonDeductibleCount = 0;
let totalTax = 0;
currentLogs.forEach(log => {
const uniqueKey = log.uniqueKey || `${log.cardNum}|${log.useDt}|${log.approvalNum}|${Math.floor(log.approvalAmount)}`;
const logSplits = allSplits[uniqueKey] || [];
if (logSplits.length > 0) {
// 분리가 있는 거래: 각 분리별로 계산
logSplits.forEach(split => {
const splitSupply = split.split_supply_amount !== null && split.split_supply_amount !== undefined
? parseFloat(split.split_supply_amount) : parseFloat(split.split_amount || 0);
const splitTax = split.split_tax !== null && split.split_tax !== undefined
? parseFloat(split.split_tax) : 0;
const splitTotal = Math.abs(splitSupply + splitTax);
const splitType = split.deduction_type || split.deductionType || 'non_deductible';
totalAmount += splitTotal;
if (splitType === 'deductible') {
deductibleAmount += splitTotal;
deductibleCount++;
deductibleSupply += Math.abs(splitSupply);
deductibleTax += Math.abs(splitTax);
totalTax += Math.abs(splitTax);
} else {
nonDeductibleAmount += splitTotal;
nonDeductibleCount++;
}
});
} else {
// 분리가 없는 거래: 수정된 금액(effectiveSupplyAmount/effectiveTax)으로 계산
const type = log.deductionType || 'non_deductible';
const effSupply = Math.abs(log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0)));
const effTax = Math.abs(log.effectiveTax ?? (log.tax || 0));
const amount = effSupply + effTax;
totalAmount += amount;
if (type === 'deductible') {
deductibleAmount += amount;
deductibleCount++;
deductibleSupply += effSupply;
deductibleTax += effTax;
totalTax += effTax;
} else {
nonDeductibleAmount += amount;
nonDeductibleCount++;
}
}
});
setSummary(prev => ({
...prev,
totalAmount,
deductibleAmount,
deductibleCount,
deductibleSupply,
deductibleTax,
nonDeductibleAmount,
nonDeductibleCount,
totalTax,
}));
};
// splits 또는 logs 변경 시 요약 재계산
// 페이지네이션 중이면(전체 데이터가 아니면) 백엔드 통계를 유지
useEffect(() => {
if (logs.length > 0) {
const totalCount = summary.count || 0;
if (totalCount <= logs.length) {
// 전체 데이터가 한 페이지에 있으므로 분리 반영 재계산
recalculateSummary(logs, splits);
}
// 페이지네이션 중이면 백엔드 전체 통계 유지
}
}, [splits, logs]);
// 분리 모달 열기
const handleOpenSplitModal = (log, uniqueKey, existingSplits = []) => {
setSplitModalLog(log);
setSplitModalKey(uniqueKey);
setSplitModalExisting(existingSplits);
setSplitModalOpen(true);
};
// 분리 저장
const handleSaveSplits = async (log, splitData) => {
try {
const uniqueKey = splitModalKey;
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: uniqueKey,
originalData: {
cardNum: log.cardNum,
useDt: log.useDt,
useDate: log.useDate,
approvalNum: log.approvalNum,
originalAmount: (log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0))) + (log.effectiveTax ?? (log.tax || 0)),
merchantName: log.merchantName
},
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) => {
if (!confirm('분리를 삭제하시겠습니까? 원본 거래로 복원됩니다.')) return;
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) => {
const uniqueKey = log.uniqueKey || `${log.cardNum}|${log.useDt}|${log.approvalNum}|${Math.floor(log.approvalAmount)}`;
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('분리가 복구되었습니다.', 'success');
loadSplits(); // 분리 데이터 새로고침
} else {
notify(data.error || '분리 복구 실패', 'error');
}
} catch (err) {
notify('분리 복구 오류: ' + err.message, 'error');
}
};
// 수동입력 - 새로 등록 열기
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 handleHide = async (log, uniqueKey) => {
if (!confirm('이 거래를 숨기시겠습니까?\n(삭제데이터 보기에서 복원 가능)')) return;
try {
const response = await fetch(API.hide, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json'
},
body: JSON.stringify({
uniqueKey,
originalData: {
cardNum: log.cardNum,
useDate: log.useDate,
approvalNum: log.approvalNum,
approvalAmount: log.approvalAmount,
merchantName: log.merchantName,
}
})
});
const data = await response.json();
if (data.success) {
notify(data.message, 'success');
loadTransactions();
if (showHidden) loadHidden();
} else {
notify(data.error || '숨김 처리 실패', 'error');
}
} catch (err) {
notify('숨김 처리 오류: ' + err.message, 'error');
}
};
// 거래 복원
const handleRestore = async (uniqueKey) => {
if (!confirm('이 거래를 복원하시겠습니까?')) return;
try {
const response = await fetch(API.restore, {
method: 'POST',
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');
loadTransactions();
loadHidden();
} else {
notify(data.error || '복원 실패', 'error');
}
} catch (err) {
notify('복원 오류: ' + err.message, 'error');
}
};
// 숨김 데이터 로드
const loadHidden = async () => {
setLoadingHidden(true);
try {
const params = new URLSearchParams({
startDate: dateFrom.replace(/-/g, ''),
endDate: dateTo.replace(/-/g, '')
});
const response = await fetch(`${API.hidden}?${params}`);
const data = await response.json();
if (data.success) {
setHiddenLogs(data.data || []);
}
} catch (err) {
console.error('숨김 데이터 로드 오류:', err);
} finally {
setLoadingHidden(false);
}
};
// 삭제데이터 보기 토글
const handleToggleHidden = () => {
const newVal = !showHidden;
setShowHidden(newVal);
if (newVal) {
loadHidden();
}
};
// 계정과목 변경 핸들러
const handleAccountCodeChange = useCallback((index, code, name) => {
setLogs(prevLogs => {
const newLogs = [...prevLogs];
newLogs[index] = {
...newLogs[index],
accountCode: code,
accountName: name
};
return newLogs;
});
setHasChanges(true);
}, []);
// 필드 변경 핸들러 (공제, 증빙/판매자상호, 내역)
const handleFieldChange = useCallback((index, field, value) => {
setLogs(prevLogs => {
const newLogs = [...prevLogs];
newLogs[index] = {
...newLogs[index],
[field]: value
};
return newLogs;
});
setHasChanges(true);
}, []);
// 저장 핸들러
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 = async () => {
if (logs.length === 0) {
notify('내보낼 데이터가 없습니다.', 'error');
return;
}
try {
const response = await fetch(API.export, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'text/csv'
},
body: JSON.stringify({
startDate: dateFrom.replace(/-/g, ''),
endDate: dateTo.replace(/-/g, ''),
logs: logs,
splits: splits
})
});
if (!response.ok) {
const errorData = await response.json();
notify(errorData.error || '다운로드 실패', 'error');
return;
}
// Blob으로 파일 다운로드
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `카드사용내역_${dateFrom.replace(/-/g, '')}_${dateTo.replace(/-/g, '')}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
notify('엑셀 다운로드 완료', 'success');
} 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개월 전 버튼 (offset: -2, -3, -4 등)
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-6">
{/* Page Header - 계좌입출금 스타일 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2.5 bg-purple-100 rounded-xl">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="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>
<h1 className="text-2xl font-bold text-stone-900">카드 사용내역</h1>
<p className="text-stone-500 mt-1">바로빌 API를 통한 카드 사용내역 조회 계정과목 관리</p>
</div>
</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-gray-200 p-4">
<div className="flex flex-wrap items-center gap-3">
<CompactStat label="사용액" value={formatCurrency(summary.totalAmount)} color="purple" />
<CompactStat label="공제" value={formatCurrency(summary.deductibleAmount)} color="green" />
<CompactStat label="불공제" value={formatCurrency(summary.nonDeductibleAmount)} color="red" />
<CompactStat label="카드" value={`${cards.length}개`} color="stone" />
<CompactStat label="거래" value={`${(summary.count || logs.length).toLocaleString()}건`} color="stone" />
{/* 구분선 */}
{cards.length > 0 && <div className="w-px h-6 bg-stone-200 mx-1"></div>}
{/* 카드 선택 드롭다운 */}
{cards.length > 0 && (
<>
<span className="text-xs text-stone-500">카드:</span>
<select
value={selectedCard}
onChange={(e) => setSelectedCard(e.target.value)}
className="px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white text-stone-700 focus:ring-2 focus:ring-purple-500 outline-none"
>
<option value="">전체 카드</option>
{cards.map(card => (
<option key={card.cardNum} value={card.cardNum}>
{card.cardBrand} ****{card.cardNum ? card.cardNum.slice(-4) : ''}
</option>
))}
</select>
</>
)}
</div>
</div>
{/* 필터 영역 - 날짜/조회/액션 버튼 통합 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
{/* 기간 조회 필터 */}
<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) => setDateFrom(e.target.value)}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-purple-500 outline-none"
/>
<span className="text-stone-400">~</span>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-purple-500 outline-none"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleThisMonth}
className="px-3 py-1.5 text-sm bg-purple-50 text-purple-600 rounded-lg hover:bg-purple-100 transition-colors font-medium"
>
이번
</button>
<button
onClick={handleLastMonth}
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={() => handleMonthOffset(-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={() => handleMonthOffset(-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={() => handleMonthOffset(-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={() => handleMonthOffset(-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={() => loadTransactions(1, dateFrom, dateTo)}
className="px-4 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-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>
{(summary.count || logs.length) !== logs.length && (
<span className="text-stone-400"> / 전체 {summary.count}</span>
)}
</span>
</div>
{/* 액션 버튼들 */}
<div className="flex items-center gap-2">
<button
onClick={handleSave}
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-purple-600 text-white hover:bg-purple-700'
: 'bg-purple-100 text-purple-700 hover:bg-purple-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={handleManualNew}
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={handleExport}
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={handleToggleHidden}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
showHidden
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-red-100 text-red-700 hover:bg-red-200'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
{showHidden ? '삭제데이터 닫기' : '삭제데이터'}
{showHidden && hiddenLogs.length > 0 && (
<span className="bg-white text-red-600 text-xs px-1.5 py-0.5 rounded-full font-bold">{hiddenLogs.length}</span>
)}
</button>
</div>
</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('-25') && (
<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>
</ol>
</div>
)}
</div>
</div>
</div>
)}
{/* Transaction Table */}
{!error && (
<TransactionTable
logs={logs}
loading={loading}
totalCount={summary.count || logs.length}
accountCodes={accountCodes}
onAccountCodeChange={handleAccountCodeChange}
onFieldChange={handleFieldChange}
splits={splits}
onOpenSplitModal={handleOpenSplitModal}
onDeleteSplits={handleDeleteSplits}
onManualEdit={handleManualEdit}
onManualDelete={handleManualDelete}
onHide={handleHide}
showHidden={showHidden}
hiddenLogs={hiddenLogs}
onRestore={handleRestore}
loadingHidden={loadingHidden}
journalMap={journalMap}
onOpenJournalModal={handleOpenJournalModal}
/>
)}
{/* Split Modal */}
<SplitModal
isOpen={splitModalOpen}
onClose={() => setSplitModalOpen(false)}
log={splitModalLog}
accountCodes={accountCodes}
onSave={handleSaveSplits}
onReset={handleResetSplits}
splits={splitModalExisting}
/>
{/* Card Journal Modal (복식부기) */}
<CardJournalModal
isOpen={journalModalOpen}
onClose={() => { setJournalModalOpen(false); setJournalModalLog(null); }}
onSave={handleSaveJournal}
onDelete={handleDeleteJournal}
log={journalModalLog}
accountCodes={accountCodes}
/>
{/* Manual Entry Modal */}
<ManualEntryModal
isOpen={manualModalOpen}
onClose={() => { setManualModalOpen(false); setManualEditData(null); }}
onSave={handleManualSave}
editData={manualEditData}
accountCodes={accountCodes}
cards={cards}
/>
{/* Merchant Name Modal */}
{merchantNameModal !== null && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setMerchantNameModal(null)}>
<div className="bg-white rounded-xl shadow-xl p-6 max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-stone-900">가맹점명</h3>
<button onClick={() => setMerchantNameModal(null)} className="text-stone-400 hover:text-stone-600">
<svg className="w-5 h-5" 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>
<p className="text-stone-800 break-all">{merchantNameModal}</p>
</div>
</div>
)}
{/* 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('ecard-root'));
root.render(<App />);
</script>
@endpush