2206 lines
124 KiB
PHP
2206 lines
124 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '카드 사용내역')
|
|
|
|
@section('content')
|
|
<!-- 현재 테넌트 정보 카드 (React 외부) -->
|
|
@if($currentTenant)
|
|
<div class="rounded-xl shadow-lg p-5 mb-6" style="background: linear-gradient(to right, #7c3aed, #8b5cf6); color: white;">
|
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
|
<div class="flex items-center gap-4">
|
|
<div class="p-3 rounded-xl" style="background: rgba(255,255,255,0.2);">
|
|
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: rgba(255,255,255,0.2);">T-ID: {{ $currentTenant->id }}</span>
|
|
@if($currentTenant->id == 1)
|
|
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: #facc15; color: #713f12;">파트너사</span>
|
|
@endif
|
|
</div>
|
|
<h2 class="text-xl font-bold">{{ $currentTenant->company_name }}</h2>
|
|
</div>
|
|
</div>
|
|
@if($barobillMember)
|
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 text-sm">
|
|
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
|
<p class="text-xs" style="color: rgba(255,255,255,0.6);">사업자번호</p>
|
|
<p class="font-medium">{{ $barobillMember->biz_no }}</p>
|
|
</div>
|
|
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
|
<p class="text-xs" style="color: rgba(255,255,255,0.6);">대표자</p>
|
|
<p class="font-medium">{{ $barobillMember->ceo_name ?? '-' }}</p>
|
|
</div>
|
|
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
|
<p class="text-xs" style="color: rgba(255,255,255,0.6);">담당자</p>
|
|
<p class="font-medium">{{ $barobillMember->manager_name ?? '-' }}</p>
|
|
</div>
|
|
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
|
<p class="text-xs" style="color: rgba(255,255,255,0.6);">바로빌 ID</p>
|
|
<p class="font-medium">{{ $barobillMember->barobill_id }}</p>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<div class="flex items-center gap-2" style="color: #fef08a;">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
<span class="text-sm">바로빌 회원사 미연동</span>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<div id="ecard-root"></div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useRef, useCallback } = React;
|
|
|
|
// API Routes
|
|
const API = {
|
|
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") }}',
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
// StatCard Component
|
|
const StatCard = ({ title, value, subtext, icon, color = 'purple' }) => {
|
|
const colorClasses = {
|
|
purple: 'bg-purple-50 text-purple-600',
|
|
green: 'bg-green-50 text-green-600',
|
|
red: 'bg-red-50 text-red-600',
|
|
stone: 'bg-stone-50 text-stone-600'
|
|
};
|
|
return (
|
|
<div className="bg-white rounded-xl p-6 shadow-sm border border-stone-100 hover:shadow-md transition-shadow">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<h3 className="text-sm font-medium text-stone-500">{title}</h3>
|
|
<div className={`p-2 rounded-lg ${colorClasses[color] || colorClasses.purple}`}>
|
|
{icon}
|
|
</div>
|
|
</div>
|
|
<div className="text-2xl font-bold text-stone-900 mb-1">{value}</div>
|
|
{subtext && <div className="text-xs text-stone-400">{subtext}</div>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// CardSelector Component
|
|
const CardSelector = ({ cards, selectedCard, onSelect }) => (
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={() => onSelect('')}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
selectedCard === ''
|
|
? 'bg-purple-600 text-white'
|
|
: 'bg-white border border-stone-200 text-stone-700 hover:bg-stone-50'
|
|
}`}
|
|
>
|
|
전체 카드
|
|
</button>
|
|
{cards.map(card => (
|
|
<button
|
|
key={card.cardNum}
|
|
onClick={() => onSelect(card.cardNum)}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
selectedCard === card.cardNum
|
|
? 'bg-purple-600 text-white'
|
|
: 'bg-white border border-stone-200 text-stone-700 hover:bg-stone-50'
|
|
}`}
|
|
>
|
|
{card.cardBrand} {card.cardNum ? '****' + card.cardNum.slice(-4) : ''}
|
|
{card.alias && ` (${card.alias})`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// AccountCodeSelect Component (검색 가능한 드롭다운)
|
|
const AccountCodeSelect = ({ value, onChange, accountCodes }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [highlightIndex, setHighlightIndex] = useState(-1);
|
|
const containerRef = useRef(null);
|
|
const listRef = useRef(null);
|
|
|
|
// 선택된 값의 표시 텍스트
|
|
const selectedItem = accountCodes.find(c => c.code === value);
|
|
const displayText = selectedItem ? `${selectedItem.code} ${selectedItem.name}` : '';
|
|
|
|
// 검색 필터링
|
|
const filteredCodes = accountCodes.filter(code => {
|
|
if (!search) return true;
|
|
const searchLower = search.toLowerCase();
|
|
return code.code.toLowerCase().includes(searchLower) ||
|
|
code.name.toLowerCase().includes(searchLower);
|
|
});
|
|
|
|
// 검색어 변경 시 하이라이트 초기화
|
|
useEffect(() => {
|
|
setHighlightIndex(-1);
|
|
}, [search]);
|
|
|
|
// 외부 클릭 시 닫기
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
setIsOpen(false);
|
|
setSearch('');
|
|
setHighlightIndex(-1);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleSelect = (code) => {
|
|
const selected = accountCodes.find(c => c.code === code.code);
|
|
onChange(code.code, selected?.name || '');
|
|
setIsOpen(false);
|
|
setSearch('');
|
|
setHighlightIndex(-1);
|
|
};
|
|
|
|
const handleClear = (e) => {
|
|
e.stopPropagation();
|
|
onChange('', '');
|
|
setSearch('');
|
|
setHighlightIndex(-1);
|
|
};
|
|
|
|
// 키보드 네비게이션
|
|
const handleKeyDown = (e) => {
|
|
const maxIndex = Math.min(filteredCodes.length, 50) - 1;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
const newIndex = highlightIndex < maxIndex ? highlightIndex + 1 : 0;
|
|
setHighlightIndex(newIndex);
|
|
setTimeout(() => {
|
|
if (listRef.current && listRef.current.children[newIndex]) {
|
|
listRef.current.children[newIndex].scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}, 0);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
const newIndex = highlightIndex > 0 ? highlightIndex - 1 : maxIndex;
|
|
setHighlightIndex(newIndex);
|
|
setTimeout(() => {
|
|
if (listRef.current && listRef.current.children[newIndex]) {
|
|
listRef.current.children[newIndex].scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}, 0);
|
|
} else if (e.key === 'Enter' && filteredCodes.length > 0) {
|
|
e.preventDefault();
|
|
const selectIndex = highlightIndex >= 0 ? highlightIndex : 0;
|
|
handleSelect(filteredCodes[selectIndex]);
|
|
} else if (e.key === 'Escape') {
|
|
setIsOpen(false);
|
|
setSearch('');
|
|
setHighlightIndex(-1);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
{/* 선택 버튼 */}
|
|
<div
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={`w-full px-2 py-1 text-xs border rounded cursor-pointer flex items-center justify-between gap-1 ${
|
|
isOpen ? 'border-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 originalAmount = log.approvalAmount || 0;
|
|
// 합계금액 = 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>
|
|
);
|
|
};
|
|
|
|
// 카드사 코드 목록
|
|
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,
|
|
dateFrom,
|
|
dateTo,
|
|
onDateFromChange,
|
|
onDateToChange,
|
|
onThisMonth,
|
|
onLastMonth,
|
|
onSearch,
|
|
totalCount,
|
|
accountCodes,
|
|
onAccountCodeChange,
|
|
onFieldChange,
|
|
onSave,
|
|
onExport,
|
|
saving,
|
|
hasChanges,
|
|
splits,
|
|
onOpenSplitModal,
|
|
onDeleteSplits,
|
|
onManualNew,
|
|
onManualEdit,
|
|
onManualDelete,
|
|
}) => {
|
|
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-stone-100 overflow-hidden min-h-[calc(100vh-200px)]">
|
|
<div className="p-6 border-b border-stone-100">
|
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
|
<h2 className="text-lg font-bold text-stone-900">카드 사용내역</h2>
|
|
{/* 기간 조회 필터 */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm text-stone-500">기간</label>
|
|
<input
|
|
type="date"
|
|
value={dateFrom}
|
|
onChange={(e) => onDateFromChange(e.target.value)}
|
|
className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-purple-500 outline-none"
|
|
/>
|
|
<span className="text-stone-400">~</span>
|
|
<input
|
|
type="date"
|
|
value={dateTo}
|
|
onChange={(e) => onDateToChange(e.target.value)}
|
|
className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-purple-500 outline-none"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onThisMonth}
|
|
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={onLastMonth}
|
|
className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-stone-200 transition-colors font-medium"
|
|
>
|
|
지난달
|
|
</button>
|
|
<button
|
|
onClick={onSearch}
|
|
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>건
|
|
{totalCount !== logs.length && (
|
|
<span className="text-stone-400"> / 전체 {totalCount}건</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{/* 저장/수동입력/엑셀 버튼 */}
|
|
<div className="flex items-center gap-2 mt-4">
|
|
<button
|
|
onClick={onSave}
|
|
disabled={saving || logs.length === 0}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
hasChanges
|
|
? 'bg-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={onManualNew}
|
|
className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded-lg text-sm font-medium hover:bg-green-200 transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
수동입력
|
|
</button>
|
|
<button
|
|
onClick={onExport}
|
|
disabled={logs.length === 0}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
엑셀 다운로드
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-x-auto" style={ {minHeight: '500px', overflowY: 'auto'} }>
|
|
<table className="w-full text-left text-sm text-stone-600">
|
|
<thead className="bg-stone-50 text-xs uppercase font-medium text-stone-500 sticky top-0">
|
|
<tr>
|
|
<th className="px-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="13" 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">
|
|
{hasSplits ? (
|
|
<button
|
|
onClick={() => onDeleteSplits(uniqueKey)}
|
|
className="p-1.5 text-red-500 hover:bg-red-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="M20 12H4" />
|
|
</svg>
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => onOpenSplitModal(log, uniqueKey)}
|
|
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 && (
|
|
<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 && (
|
|
<button
|
|
onClick={() => onOpenSplitModal(log, uniqueKey, logSplits)}
|
|
className="text-xs text-amber-600 hover:text-amber-700 underline"
|
|
>
|
|
분개 수정
|
|
</button>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-3 text-center">
|
|
{log.isManual && (
|
|
<div className="flex items-center gap-1 justify-center">
|
|
<button
|
|
onClick={() => onManualEdit(log)}
|
|
className="p-1 text-blue-500 hover:bg-blue-50 rounded transition-colors"
|
|
title="수정"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => onManualDelete(log.dbId)}
|
|
className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
|
|
title="삭제"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
{/* 분개 행들 */}
|
|
{hasSplits && logSplits.map((split, 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 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>
|
|
</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 [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 [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) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setHasChanges(false);
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
startDate: dateFrom.replace(/-/g, ''),
|
|
endDate: dateTo.replace(/-/g, ''),
|
|
cardNum: selectedCard,
|
|
page: page,
|
|
limit: 50
|
|
});
|
|
|
|
const response = await fetch(`${API.transactions}?${params}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setLogs(data.data?.logs || []);
|
|
setPagination(data.data?.pagination || {});
|
|
setSummary(data.data?.summary || {});
|
|
} else {
|
|
setError(data.error || '조회 실패');
|
|
setLogs([]);
|
|
}
|
|
} catch (err) {
|
|
setError('서버 통신 오류: ' + err.message);
|
|
setLogs([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
|
|
// 분개 데이터 로드
|
|
loadSplits();
|
|
};
|
|
|
|
// 분개 데이터 로드
|
|
const loadSplits = async () => {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
startDate: dateFrom.replace(/-/g, ''),
|
|
endDate: dateTo.replace(/-/g, '')
|
|
});
|
|
const response = await fetch(`${API.splits}?${params}`);
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
const newSplits = data.data || {};
|
|
setSplits(newSplits);
|
|
// 분개 데이터 기반으로 요약 재계산
|
|
recalculateSummary(logs, newSplits);
|
|
}
|
|
} catch (err) {
|
|
console.error('분개 데이터 로드 오류:', err);
|
|
}
|
|
};
|
|
|
|
// 요약 재계산: 분개가 있는 거래는 원본 대신 분개별 통계로 대체
|
|
const recalculateSummary = (currentLogs, allSplits) => {
|
|
if (!currentLogs || currentLogs.length === 0) return;
|
|
|
|
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';
|
|
|
|
if (splitType === 'deductible') {
|
|
deductibleAmount += splitTotal;
|
|
deductibleCount++;
|
|
deductibleSupply += Math.abs(splitSupply);
|
|
deductibleTax += Math.abs(splitTax);
|
|
totalTax += Math.abs(splitTax);
|
|
} else {
|
|
nonDeductibleAmount += splitTotal;
|
|
nonDeductibleCount++;
|
|
}
|
|
});
|
|
} else {
|
|
// 분개가 없는 거래: 기존 방식
|
|
const type = log.deductionType || 'non_deductible';
|
|
const amount = Math.abs(log.approvalAmount || 0);
|
|
const effSupply = Math.abs(log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0)));
|
|
const effTax = Math.abs(log.effectiveTax ?? (log.tax || 0));
|
|
|
|
if (type === 'deductible') {
|
|
deductibleAmount += amount;
|
|
deductibleCount++;
|
|
deductibleSupply += effSupply;
|
|
deductibleTax += effTax;
|
|
totalTax += effTax;
|
|
} else {
|
|
nonDeductibleAmount += amount;
|
|
nonDeductibleCount++;
|
|
}
|
|
}
|
|
});
|
|
|
|
setSummary(prev => ({
|
|
...prev,
|
|
deductibleAmount,
|
|
deductibleCount,
|
|
deductibleSupply,
|
|
deductibleTax,
|
|
nonDeductibleAmount,
|
|
nonDeductibleCount,
|
|
totalTax,
|
|
}));
|
|
};
|
|
|
|
// splits 또는 logs 변경 시 요약 재계산
|
|
useEffect(() => {
|
|
if (logs.length > 0) {
|
|
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.approvalAmount,
|
|
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 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);
|
|
};
|
|
|
|
// 지난달 버튼
|
|
const handleLastMonth = () => {
|
|
const dates = getMonthDates(-1);
|
|
setDateFrom(dates.from);
|
|
setDateTo(dates.to);
|
|
};
|
|
|
|
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Page Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-stone-900">카드 사용내역</h1>
|
|
<p className="text-stone-500 mt-1">바로빌 API를 통한 카드 사용내역 조회 및 계정과목 관리</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
@if($isTestMode)
|
|
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-xs font-medium">테스트 모드</span>
|
|
@else
|
|
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium flex items-center gap-1">
|
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"/>
|
|
</svg>
|
|
운영 모드
|
|
</span>
|
|
@endif
|
|
@if($hasSoapClient)
|
|
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">SOAP 연결됨</span>
|
|
@else
|
|
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">SOAP 미연결</span>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dashboard */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<StatCard
|
|
title="총 사용금액"
|
|
value={formatCurrency(summary.totalAmount)}
|
|
subtext={`승인 ${(summary.approvalCount || 0).toLocaleString()}건 / 취소 ${(summary.cancelCount || 0).toLocaleString()}건`}
|
|
icon={<svg className="w-5 h-5" 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>}
|
|
color="purple"
|
|
/>
|
|
<div className="bg-white rounded-xl p-6 shadow-sm border border-stone-100 hover:shadow-md transition-shadow">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<h3 className="text-sm font-medium text-stone-500">공제</h3>
|
|
<div className="p-2 rounded-lg bg-green-50 text-green-600">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"/></svg>
|
|
</div>
|
|
</div>
|
|
<div className="text-2xl font-bold text-stone-900 mb-2">{formatCurrency(summary.deductibleAmount)}</div>
|
|
<div className="space-y-1 pt-2 border-t border-stone-100">
|
|
<div className="flex justify-between text-xs">
|
|
<span className="text-stone-400">공급가액</span>
|
|
<span className="font-medium text-stone-600">{formatCurrency(summary.deductibleSupply)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-xs">
|
|
<span className="text-stone-400">세액</span>
|
|
<span className="font-medium text-stone-600">{formatCurrency(summary.deductibleTax)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-stone-400 mt-2">{(summary.deductibleCount || 0).toLocaleString()}건</div>
|
|
</div>
|
|
<StatCard
|
|
title="불공제"
|
|
value={formatCurrency(summary.nonDeductibleAmount)}
|
|
subtext={`${(summary.nonDeductibleCount || 0).toLocaleString()}건`}
|
|
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>}
|
|
color="red"
|
|
/>
|
|
<StatCard
|
|
title="등록된 카드"
|
|
value={`${cards.length}개`}
|
|
subtext="사용 가능한 카드"
|
|
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>}
|
|
color="stone"
|
|
/>
|
|
</div>
|
|
|
|
{/* Card Filter */}
|
|
{cards.length > 0 && (
|
|
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-6">
|
|
<h2 className="text-sm font-medium text-stone-700 mb-3">카드 선택</h2>
|
|
<CardSelector
|
|
cards={cards}
|
|
selectedCard={selectedCard}
|
|
onSelect={setSelectedCard}
|
|
/>
|
|
</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}
|
|
dateFrom={dateFrom}
|
|
dateTo={dateTo}
|
|
onDateFromChange={setDateFrom}
|
|
onDateToChange={setDateTo}
|
|
onThisMonth={handleThisMonth}
|
|
onLastMonth={handleLastMonth}
|
|
onSearch={() => loadTransactions()}
|
|
totalCount={summary.count || logs.length}
|
|
accountCodes={accountCodes}
|
|
onAccountCodeChange={handleAccountCodeChange}
|
|
onFieldChange={handleFieldChange}
|
|
onSave={handleSave}
|
|
onExport={handleExport}
|
|
saving={saving}
|
|
hasChanges={hasChanges}
|
|
splits={splits}
|
|
onOpenSplitModal={handleOpenSplitModal}
|
|
onDeleteSplits={handleDeleteSplits}
|
|
onManualNew={handleManualNew}
|
|
onManualEdit={handleManualEdit}
|
|
onManualDelete={handleManualDelete}
|
|
/>
|
|
)}
|
|
|
|
{/* Split Modal */}
|
|
<SplitModal
|
|
isOpen={splitModalOpen}
|
|
onClose={() => setSplitModalOpen(false)}
|
|
log={splitModalLog}
|
|
accountCodes={accountCodes}
|
|
onSave={handleSaveSplits}
|
|
onReset={handleResetSplits}
|
|
splits={splitModalExisting}
|
|
/>
|
|
|
|
{/* 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
|