Files
sam-manage/resources/views/barobill/ecard/index.blade.php
pro b934bc2e12 fix:엑셀 내보내기를 현재 화면 데이터 기반으로 변경
- GET → POST 방식으로 변경
- 저장된 DB 데이터 대신 현재 화면에 표시된 데이터 내보내기
- 프론트엔드에서 logs, splits 데이터를 JSON으로 전송
- Blob 다운로드 방식으로 파일 저장
- 금액 필드에 콤마 포맷팅 적용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:13:16 +09:00

1345 lines
71 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") }}',
};
const CSRF_TOKEN = '{{ csrf_token() }}';
// 날짜 유틸리티 함수
const getMonthDates = (offset = 0) => {
const now = new Date();
const today = now.toISOString().split('T')[0];
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 = lastDay.toISOString().split('T')[0];
const endDate = offset >= 0 && lastDayStr > today ? today : lastDayStr;
return {
from: firstDay.toISOString().split('T')[0],
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 containerRef = 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(() => {
const handleClickOutside = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setIsOpen(false);
setSearch('');
}
};
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('');
};
const handleClear = (e) => {
e.stopPropagation();
onChange('', '');
setSearch('');
};
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-50 mt-1 w-48 bg-white border border-stone-200 rounded-lg shadow-lg">
{/* 검색 입력 */}
<div className="p-2 border-b border-stone-100">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
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 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 => (
<div
key={code.code}
onClick={() => handleSelect(code)}
className={`px-3 py-1.5 text-xs cursor-pointer hover:bg-purple-50 ${
value === code.code ? 'bg-purple-100 text-purple-700' : 'text-stone-700'
}`}
>
<span className="font-mono 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 => ({
amount: parseFloat(s.split_amount || s.amount || 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개 행 생성
setSplits([{
amount: log.approvalAmount || 0,
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;
const splitTotal = splits.reduce((sum, s) => sum + (parseFloat(s.amount) || 0), 0);
const isValid = Math.abs(originalAmount - splitTotal) < 0.01;
const addSplit = () => {
const remaining = originalAmount - splitTotal;
const defaultDeductionType = log.deductionType || (log.merchantBizNum ? 'deductible' : 'non_deductible');
setSplits([...splits, {
amount: remaining > 0 ? remaining : 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">
<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) => (
<div key={index} className="flex items-start gap-3 p-3 bg-stone-50 rounded-lg">
<div className="flex-1 grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-stone-500 mb-1">금액</label>
<input
type="text"
value={formatAmountInput(split.amount)}
onChange={(e) => updateSplit(index, { amount: parseAmountInput(e.target.value) })}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm text-right focus:ring-2 focus:ring-purple-500 outline-none"
placeholder="0"
/>
</div>
<div>
<label className="block text-xs text-stone-500 mb-1">계정과목</label>
<AccountCodeSelect
value={split.accountCode}
onChange={(code, name) => updateSplit(index, { accountCode: code, accountName: name })}
accountCodes={accountCodes}
/>
</div>
<div>
<label className="block text-xs text-stone-500 mb-1">공제</label>
<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>
);
};
// TransactionTable Component
const TransactionTable = ({
logs,
loading,
dateFrom,
dateTo,
onDateFromChange,
onDateToChange,
onThisMonth,
onLastMonth,
onSearch,
totalCount,
accountCodes,
onAccountCodeChange,
onFieldChange,
onSave,
onExport,
saving,
hasChanges,
splits,
onOpenSplitModal,
onDeleteSplits
}) => {
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
</div>
);
}
return (
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden">
<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={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={ {maxHeight: '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 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">승인번호</th>
<th className="px-4 py-4 bg-stone-50 min-w-[150px]">계정과목</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{logs.length === 0 ? (
<tr>
<td colSpan="10" 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>
</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.cardNumMasked || '-'}
</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">
<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="판매자상호"
/>
{log.merchantBizNum && (
<div className="text-xs text-stone-400 mt-1">{log.merchantBizNum}</div>
)}
</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">
<span className={log.approvalType === '1' ? 'text-purple-600' : 'text-red-600'}>
{log.approvalAmountFormatted}
</span>
{hasSplits && (
<div className="text-xs text-amber-600 mt-1">분개됨 ({logSplits.length})</div>
)}
</td>
<td className="px-4 py-3 text-right text-stone-500">
{log.taxFormatted}
</td>
<td className="px-4 py-3 text-stone-500 font-mono text-xs">
{log.approvalNum || '-'}
</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>
</tr>
{/* 분개 행들 */}
{hasSplits && logSplits.map((split, splitIdx) => (
<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 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(split.split_amount || split.amount || 0)}
</td>
<td></td>
<td></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>
</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 currentMonth = getMonthDates(0);
const [dateFrom, setDateFrom] = useState(currentMonth.from);
const [dateTo, setDateTo] = useState(currentMonth.to);
// 초기 로드
useEffect(() => {
loadCards();
loadAccountCodes();
}, []);
// 날짜 또는 카드 변경 시 거래내역 로드
useEffect(() => {
if (dateFrom && dateTo) {
loadTransactions();
}
}, [dateFrom, dateTo, 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) {
setSplits(data.data || {});
}
} catch (err) {
console.error('분개 데이터 로드 오류:', err);
}
};
// 분개 모달 열기
const handleOpenSplitModal = (log, uniqueKey, existingSplits = []) => {
setSplitModalLog(log);
setSplitModalKey(uniqueKey);
setSplitModalExisting(existingSplits);
setSplitModalOpen(true);
};
// 분개 저장
const handleSaveSplits = async (log, splitData) => {
try {
const 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 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>
@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="조회기간 합계"
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"
/>
<StatCard
title="승인건수"
value={`${(summary.approvalCount || 0).toLocaleString()}건`}
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="M5 13l4 4L19 7"/></svg>}
color="green"
/>
<StatCard
title="취소건수"
value={`${(summary.cancelCount || 0).toLocaleString()}건`}
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="M6 18L18 6M6 6l12 12"/></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}
/>
)}
{/* Split Modal */}
<SplitModal
isOpen={splitModalOpen}
onClose={() => setSplitModalOpen(false)}
log={splitModalLog}
accountCodes={accountCodes}
onSave={handleSaveSplits}
onReset={handleResetSplits}
splits={splitModalExisting}
/>
{/* 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