feat:재무관리 React 페이지 추가 (VAT, 미수금, 미지급금 등 20개)

This commit is contained in:
pro
2026-01-21 19:10:44 +09:00
parent acad251eec
commit c7fc872de7
20 changed files with 7245 additions and 0 deletions

View File

@@ -0,0 +1,242 @@
@extends('layouts.app')
@section('title', '계좌거래내역')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="account-transactions-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const ArrowUpDown = createIcon('arrow-up-down');
const ArrowUpCircle = createIcon('arrow-up-circle');
const ArrowDownCircle = createIcon('arrow-down-circle');
const Search = createIcon('search');
const Download = createIcon('download');
const RefreshCw = createIcon('refresh-cw');
const Building2 = createIcon('building-2');
const Filter = createIcon('filter');
function AccountTransactions() {
const [transactions, setTransactions] = useState([
{ id: 1, date: '2026-01-21', accountName: '기업은행 운영계좌', accountNo: '123-456789-01', type: 'deposit', category: '매출입금', description: '(주)한국테크 서비스대금', amount: 15000000, balance: 125000000, memo: '' },
{ id: 2, date: '2026-01-21', accountName: '신한은행 급여계좌', accountNo: '110-123-456789', type: 'withdrawal', category: '급여지급', description: '1월 급여 이체', amount: 45000000, balance: 55000000, memo: '정기급여' },
{ id: 3, date: '2026-01-20', accountName: '기업은행 운영계좌', accountNo: '123-456789-01', type: 'withdrawal', category: '경비지출', description: '사무실 임대료', amount: 5000000, balance: 110000000, memo: '1월분' },
{ id: 4, date: '2026-01-20', accountName: '국민은행 예비계좌', accountNo: '999-12-345678', type: 'deposit', category: '이자수입', description: '정기예금 이자', amount: 250000, balance: 80250000, memo: '' },
{ id: 5, date: '2026-01-19', accountName: '기업은행 운영계좌', accountNo: '123-456789-01', type: 'deposit', category: '매출입금', description: '글로벌솔루션 계약금', amount: 8500000, balance: 115000000, memo: '' },
{ id: 6, date: '2026-01-19', accountName: '신한은행 급여계좌', accountNo: '110-123-456789', type: 'withdrawal', category: '세금납부', description: '원천세 납부', amount: 3200000, balance: 100000000, memo: '12월분' },
{ id: 7, date: '2026-01-18', accountName: '기업은행 운영계좌', accountNo: '123-456789-01', type: 'withdrawal', category: '거래처지급', description: 'IT솔루션즈 외주비', amount: 12000000, balance: 106500000, memo: '' },
{ id: 8, date: '2026-01-18', accountName: '국민은행 예비계좌', accountNo: '999-12-345678', type: 'transfer', category: '계좌이체', description: '운영자금 이체', amount: 20000000, balance: 80000000, memo: '기업은행으로' },
{ id: 9, date: '2026-01-17', accountName: '기업은행 운영계좌', accountNo: '123-456789-01', type: 'deposit', category: '매출입금', description: '스마트시스템 유지보수', amount: 3500000, balance: 118500000, memo: '' },
{ id: 10, date: '2026-01-17', accountName: '신한은행 급여계좌', accountNo: '110-123-456789', type: 'deposit', category: '계좌이체', description: '운영계좌에서 이체', amount: 50000000, balance: 103200000, memo: '' },
]);
const accounts = ['전체', '기업은행 운영계좌', '신한은행 급여계좌', '국민은행 예비계좌'];
const categories = ['전체', '매출입금', '급여지급', '경비지출', '세금납부', '거래처지급', '이자수입', '계좌이체'];
const [searchTerm, setSearchTerm] = useState('');
const [filterAccount, setFilterAccount] = useState('전체');
const [filterType, setFilterType] = useState('all');
const [filterCategory, setFilterCategory] = useState('전체');
const [dateRange, setDateRange] = useState({ start: '', end: '' });
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const filteredTransactions = transactions.filter(item => {
const matchesSearch = item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.accountName.toLowerCase().includes(searchTerm.toLowerCase());
const matchesAccount = filterAccount === '전체' || item.accountName === filterAccount;
const matchesType = filterType === 'all' || item.type === filterType;
const matchesCategory = filterCategory === '전체' || item.category === filterCategory;
const matchesDateStart = !dateRange.start || item.date >= dateRange.start;
const matchesDateEnd = !dateRange.end || item.date <= dateRange.end;
return matchesSearch && matchesAccount && matchesType && matchesCategory && matchesDateStart && matchesDateEnd;
});
const totalDeposit = filteredTransactions.filter(t => t.type === 'deposit').reduce((sum, t) => sum + t.amount, 0);
const totalWithdrawal = filteredTransactions.filter(t => t.type === 'withdrawal').reduce((sum, t) => sum + t.amount, 0);
const totalTransfer = filteredTransactions.filter(t => t.type === 'transfer').reduce((sum, t) => sum + t.amount, 0);
const handleDownload = () => {
const rows = [['계좌거래내역'], [], ['날짜', '계좌', '구분', '분류', '내용', '금액', '잔액', '메모'],
...filteredTransactions.map(item => [item.date, item.accountName, getTypeLabel(item.type), item.category, item.description, item.amount, item.balance, item.memo])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `계좌거래내역_${new Date().toISOString().split('T')[0]}.csv`; link.click();
};
const getTypeLabel = (type) => {
const labels = { 'deposit': '입금', 'withdrawal': '출금', 'transfer': '이체' };
return labels[type] || type;
};
const getTypeStyle = (type) => {
const styles = {
'deposit': 'text-blue-600',
'withdrawal': 'text-red-600',
'transfer': 'text-gray-600'
};
return styles[type] || 'text-gray-600';
};
const getTypeIcon = (type) => {
if (type === 'deposit') return <ArrowDownCircle className="w-4 h-4 text-blue-500" />;
if (type === 'withdrawal') return <ArrowUpCircle className="w-4 h-4 text-red-500" />;
return <ArrowUpDown className="w-4 h-4 text-gray-500" />;
};
const resetFilters = () => {
setSearchTerm('');
setFilterAccount('전체');
setFilterType('all');
setFilterCategory('전체');
setDateRange({ start: '', end: '' });
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-blue-100 rounded-xl"><ArrowUpDown className="w-6 h-6 text-blue-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">계좌거래내역</h1><p className="text-sm text-gray-500">Account Transactions</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
</div>
</div>
</header>
{/* 요약 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">조회 건수</span><Filter className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{filteredTransactions.length}</p>
</div>
<div className="bg-white rounded-xl border border-blue-200 p-6 bg-blue-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-blue-700">입금 합계</span><ArrowDownCircle className="w-5 h-5 text-blue-500" /></div>
<p className="text-2xl font-bold text-blue-600">{formatCurrency(totalDeposit)}</p>
</div>
<div className="bg-white rounded-xl border border-red-200 p-6 bg-red-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-red-700">출금 합계</span><ArrowUpCircle className="w-5 h-5 text-red-500" /></div>
<p className="text-2xl font-bold text-red-600">{formatCurrency(totalWithdrawal)}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">이체 합계</span><ArrowUpDown className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalTransfer)}</p>
</div>
</div>
{/* 필터 영역 */}
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="내용, 계좌명 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" />
</div>
<select value={filterAccount} onChange={(e) => setFilterAccount(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg">
{accounts.map(acc => <option key={acc} value={acc}>{acc}</option>)}
</select>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg">
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select>
<div className="flex gap-1">
{['all', 'deposit', 'withdrawal', 'transfer'].map(type => (
<button key={type} onClick={() => setFilterType(type)} className={`flex-1 px-2 py-2 rounded-lg text-xs font-medium ${filterType === type ? (type === 'deposit' ? 'bg-blue-600 text-white' : type === 'withdrawal' ? 'bg-red-600 text-white' : type === 'transfer' ? 'bg-gray-600 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{type === 'all' ? '전체' : getTypeLabel(type)}
</button>
))}
</div>
<button onClick={resetFilters} className="flex items-center justify-center gap-2 px-3 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
<RefreshCw className="w-4 h-4" /><span className="text-sm">초기화</span>
</button>
</div>
<div className="flex gap-4 mt-4">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">기간:</span>
<input type="date" value={dateRange.start} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))} className="px-3 py-2 border border-gray-300 rounded-lg text-sm" />
<span className="text-gray-400">~</span>
<input type="date" value={dateRange.end} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))} className="px-3 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
</div>
</div>
{/* 거래내역 테이블 */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">날짜</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">계좌</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">구분</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">분류</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">내용</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">금액</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">잔액</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredTransactions.length === 0 ? (
<tr><td colSpan="7" className="px-6 py-12 text-center text-gray-400">거래내역이 없습니다.</td></tr>
) : filteredTransactions.map(item => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm text-gray-600">{item.date}</td>
<td className="px-6 py-4">
<p className="text-sm font-medium text-gray-900">{item.accountName}</p>
<p className="text-xs text-gray-400">{item.accountNo}</p>
</td>
<td className="px-6 py-4 text-center">
<span className="inline-flex items-center gap-1">
{getTypeIcon(item.type)}
<span className={`text-sm font-medium ${getTypeStyle(item.type)}`}>{getTypeLabel(item.type)}</span>
</span>
</td>
<td className="px-6 py-4"><span className="px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700">{item.category}</span></td>
<td className="px-6 py-4">
<p className="text-sm text-gray-900">{item.description}</p>
{item.memo && <p className="text-xs text-gray-400">{item.memo}</p>}
</td>
<td className={`px-6 py-4 text-sm font-bold text-right ${getTypeStyle(item.type)}`}>
{item.type === 'deposit' ? '+' : '-'}{formatCurrency(item.amount)}
</td>
<td className="px-6 py-4 text-sm text-right text-gray-900">{formatCurrency(item.balance)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
const rootElement = document.getElementById('account-transactions-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<AccountTransactions />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,851 @@
@extends('layouts.app')
@section('title', '법인카드 거래내역')
@push('styles')
<style>
@media print {
.no-print { display: none !important; }
}
</style>
@endpush
@section('content')
<div id="card-transactions-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
// Lucide 아이콘 래핑
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const CreditCard = createIcon('credit-card');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Filter = createIcon('filter');
const Download = createIcon('download');
const Calendar = createIcon('calendar');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const Receipt = createIcon('receipt');
const TrendingUp = createIcon('trending-up');
const TrendingDown = createIcon('trending-down');
const ShoppingBag = createIcon('shopping-bag');
const Coffee = createIcon('coffee');
const Car = createIcon('car');
const Utensils = createIcon('utensils');
const Building = createIcon('building');
const MoreHorizontal = createIcon('more-horizontal');
const ChevronLeft = createIcon('chevron-left');
const ChevronRight = createIcon('chevron-right');
function CardTransactionsManagement() {
// 카드 목록 (실제로는 API에서 가져옴)
const [cards] = useState([
{ id: 1, cardName: '업무용 법인카드', cardNumber: '9410-****-****-9012', holder: '김대표' },
{ id: 2, cardName: '마케팅 법인카드', cardNumber: '5412-****-****-1098', holder: '박마케팅' },
{ id: 3, cardName: '개발팀 체크카드', cardNumber: '4532-****-****-3333', holder: '이개발' },
]);
// 거래내역 데이터
const [transactions, setTransactions] = useState([
{
id: 1,
cardId: 1,
date: '2026-01-21',
time: '12:30',
merchant: '스타벅스 강남역점',
category: '식비',
amount: 15000,
approvalNo: '12345678',
status: 'approved',
memo: '팀 미팅'
},
{
id: 2,
cardId: 1,
date: '2026-01-21',
time: '14:20',
merchant: 'AWS Korea',
category: '운영비',
amount: 2500000,
approvalNo: '23456789',
status: 'approved',
memo: '1월 클라우드 비용'
},
{
id: 3,
cardId: 2,
date: '2026-01-21',
time: '10:15',
merchant: '네이버 광고',
category: '마케팅비',
amount: 500000,
approvalNo: '34567890',
status: 'approved',
memo: '검색광고 충전'
},
{
id: 4,
cardId: 1,
date: '2026-01-20',
time: '18:45',
merchant: '교보문고 광화문점',
category: '도서/교육',
amount: 89000,
approvalNo: '45678901',
status: 'approved',
memo: '개발 서적 구매'
},
{
id: 5,
cardId: 3,
date: '2026-01-20',
time: '16:30',
merchant: '쿠팡',
category: '사무용품',
amount: 156000,
approvalNo: '56789012',
status: 'approved',
memo: '모니터 거치대, 키보드'
},
{
id: 6,
cardId: 1,
date: '2026-01-19',
time: '19:20',
merchant: '한우명가',
category: '접대비',
amount: 450000,
approvalNo: '67890123',
status: 'approved',
memo: '고객사 미팅 식사'
},
{
id: 7,
cardId: 2,
date: '2026-01-19',
time: '11:00',
merchant: 'Google Ads',
category: '마케팅비',
amount: 800000,
approvalNo: '78901234',
status: 'approved',
memo: '디스플레이 광고'
},
{
id: 8,
cardId: 1,
date: '2026-01-18',
time: '09:30',
merchant: 'KTX 예매',
category: '교통비',
amount: 112000,
approvalNo: '89012345',
status: 'approved',
memo: '부산 출장'
},
{
id: 9,
cardId: 3,
date: '2026-01-18',
time: '15:00',
merchant: '다나와',
category: '장비구매',
amount: 1890000,
approvalNo: '90123456',
status: 'approved',
memo: '개발용 노트북 SSD 업그레이드'
},
{
id: 10,
cardId: 1,
date: '2026-01-17',
time: '13:00',
merchant: '취소-스타벅스',
category: '식비',
amount: -8500,
approvalNo: '01234567',
status: 'cancelled',
memo: '주문 취소'
}
]);
// 필터 상태
const [searchTerm, setSearchTerm] = useState('');
const [filterCard, setFilterCard] = useState('all');
const [filterCategory, setFilterCategory] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [dateRange, setDateRange] = useState({
start: new Date(new Date().setDate(new Date().getDate() - 30)).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0]
});
// 모달 상태
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingTransaction, setEditingTransaction] = useState(null);
// 폼 초기값
const initialFormState = {
cardId: 1,
date: new Date().toISOString().split('T')[0],
time: '',
merchant: '',
category: '식비',
amount: '',
approvalNo: '',
status: 'approved',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
// 카테고리 목록
const categories = ['식비', '교통비', '운영비', '마케팅비', '사무용품', '도서/교육', '접대비', '장비구매', '기타'];
// 카테고리 아이콘
const getCategoryIcon = (category) => {
switch (category) {
case '식비': return <Utensils className="w-4 h-4" />;
case '교통비': return <Car className="w-4 h-4" />;
case '마케팅비': return <TrendingUp className="w-4 h-4" />;
case '접대비': return <Coffee className="w-4 h-4" />;
case '사무용품':
case '장비구매': return <ShoppingBag className="w-4 h-4" />;
case '운영비': return <Building className="w-4 h-4" />;
default: return <Receipt className="w-4 h-4" />;
}
};
// 카테고리 색상
const getCategoryColor = (category) => {
const colors = {
'식비': 'bg-orange-100 text-orange-700',
'교통비': 'bg-blue-100 text-blue-700',
'운영비': 'bg-purple-100 text-purple-700',
'마케팅비': 'bg-pink-100 text-pink-700',
'사무용품': 'bg-gray-100 text-gray-700',
'도서/교육': 'bg-emerald-100 text-emerald-700',
'접대비': 'bg-amber-100 text-amber-700',
'장비구매': 'bg-indigo-100 text-indigo-700',
'기타': 'bg-slate-100 text-slate-700'
};
return colors[category] || 'bg-gray-100 text-gray-700';
};
// 금액 포맷
const formatCurrency = (num) => {
if (!num) return '0';
return num.toLocaleString();
};
// 입력용 금액 포맷 (3자리 콤마)
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d-]/g, '');
if (!num) return '';
const isNegative = num.startsWith('-');
const absNum = num.replace('-', '');
if (!absNum) return isNegative ? '-' : '';
return (isNegative ? '-' : '') + Number(absNum).toLocaleString();
};
// 입력값에서 숫자만 추출
const parseInputCurrency = (value) => {
const num = String(value).replace(/[^\d-]/g, '');
return num;
};
// 필터링된 거래내역
const filteredTransactions = transactions.filter(tx => {
const matchesSearch = tx.merchant.toLowerCase().includes(searchTerm.toLowerCase()) ||
tx.memo.toLowerCase().includes(searchTerm.toLowerCase()) ||
tx.approvalNo.includes(searchTerm);
const matchesCard = filterCard === 'all' || tx.cardId === parseInt(filterCard);
const matchesCategory = filterCategory === 'all' || tx.category === filterCategory;
const matchesStatus = filterStatus === 'all' || tx.status === filterStatus;
const matchesDate = tx.date >= dateRange.start && tx.date <= dateRange.end;
return matchesSearch && matchesCard && matchesCategory && matchesStatus && matchesDate;
}).sort((a, b) => {
if (a.date !== b.date) return b.date.localeCompare(a.date);
return b.time.localeCompare(a.time);
});
// 통계 계산
const totalAmount = filteredTransactions.reduce((sum, tx) => sum + tx.amount, 0);
const approvedAmount = filteredTransactions.filter(tx => tx.status === 'approved').reduce((sum, tx) => sum + tx.amount, 0);
const cancelledAmount = filteredTransactions.filter(tx => tx.status === 'cancelled').reduce((sum, tx) => sum + Math.abs(tx.amount), 0);
// 카테고리별 합계
const categoryTotals = filteredTransactions
.filter(tx => tx.status === 'approved' && tx.amount > 0)
.reduce((acc, tx) => {
acc[tx.category] = (acc[tx.category] || 0) + tx.amount;
return acc;
}, {});
// 거래 추가 모달 열기
const handleAddTransaction = () => {
setModalMode('add');
setFormData(initialFormState);
setShowModal(true);
};
// 거래 수정 모달 열기
const handleEditTransaction = (transaction) => {
setModalMode('edit');
setEditingTransaction(transaction);
setFormData({
cardId: transaction.cardId,
date: transaction.date,
time: transaction.time,
merchant: transaction.merchant,
category: transaction.category,
amount: transaction.amount,
approvalNo: transaction.approvalNo,
status: transaction.status,
memo: transaction.memo
});
setShowModal(true);
};
// 저장
const handleSave = () => {
if (!formData.merchant || !formData.amount) {
alert('가맹점명과 금액을 입력해주세요.');
return;
}
if (modalMode === 'add') {
const newTransaction = {
id: Date.now(),
...formData,
cardId: parseInt(formData.cardId),
amount: parseInt(formData.amount) || 0,
time: formData.time || new Date().toTimeString().slice(0, 5)
};
setTransactions(prev => [newTransaction, ...prev]);
} else {
setTransactions(prev => prev.map(tx =>
tx.id === editingTransaction.id
? { ...tx, ...formData, cardId: parseInt(formData.cardId), amount: parseInt(formData.amount) || 0 }
: tx
));
}
setShowModal(false);
setEditingTransaction(null);
};
// 삭제
const handleDelete = (id) => {
if (confirm('정말 삭제하시겠습니까?')) {
setTransactions(prev => prev.filter(tx => tx.id !== id));
if (showModal) {
setShowModal(false);
setEditingTransaction(null);
}
}
};
// Excel 다운로드
const handleDownload = () => {
const rows = [
['법인카드 거래내역', `${dateRange.start} ~ ${dateRange.end}`],
[],
['날짜', '시간', '카드', '가맹점', '카테고리', '금액', '승인번호', '상태', '메모'],
...filteredTransactions.map(tx => [
tx.date,
tx.time,
cards.find(c => c.id === tx.cardId)?.cardName || '',
tx.merchant,
tx.category,
tx.amount,
tx.approvalNo,
tx.status === 'approved' ? '승인' : '취소',
tx.memo
]),
[],
['총 사용금액', '', '', '', '', totalAmount]
];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `법인카드거래내역_${dateRange.start}_${dateRange.end}.csv`;
link.click();
};
// 날짜 그룹화
const groupedTransactions = filteredTransactions.reduce((groups, tx) => {
const date = tx.date;
if (!groups[date]) groups[date] = [];
groups[date].push(tx);
return groups;
}, {});
// 날짜 포맷
const formatDateDisplay = (dateStr) => {
const date = new Date(dateStr);
const days = ['일', '월', '화', '수', '목', '금', '토'];
return `${date.getMonth() + 1}월 ${date.getDate()}일 (${days[date.getDay()]})`;
};
return (
<div className="bg-gray-50 min-h-screen">
{/* 헤더 */}
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-indigo-100 rounded-xl">
<Receipt className="w-6 h-6 text-indigo-600" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">법인카드 거래내역</h1>
<p className="text-sm text-gray-500">Corporate Card Transactions</p>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleDownload}
className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
<span className="text-sm">Excel</span>
</button>
<button
onClick={handleAddTransaction}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
<span className="text-sm font-medium">거래 등록</span>
</button>
</div>
</div>
</div>
</header>
{/* 요약 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500">조회 건수</span>
<Receipt className="w-5 h-5 text-gray-400" />
</div>
<p className="text-2xl font-bold text-gray-900">{filteredTransactions.length}</p>
<p className="text-xs text-gray-400 mt-1">승인 {filteredTransactions.filter(t => t.status === 'approved').length}</p>
</div>
<div className="bg-white rounded-xl border border-indigo-200 p-6 bg-indigo-50/30">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-indigo-700"> 사용금액</span>
<CreditCard className="w-5 h-5 text-indigo-500" />
</div>
<p className="text-2xl font-bold text-indigo-600">{formatCurrency(totalAmount)}</p>
<p className="text-xs text-indigo-500 mt-1">취소 제외</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-emerald-700">승인 금액</span>
<TrendingUp className="w-5 h-5 text-emerald-500" />
</div>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(approvedAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-rose-200 p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-rose-700">취소 금액</span>
<TrendingDown className="w-5 h-5 text-rose-500" />
</div>
<p className="text-2xl font-bold text-rose-600">{formatCurrency(cancelledAmount)}</p>
</div>
</div>
{/* 카테고리별 사용현황 */}
{Object.keys(categoryTotals).length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-3">카테고리별 사용현황</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(categoryTotals)
.sort((a, b) => b[1] - a[1])
.map(([cat, amount]) => (
<div key={cat} className={`flex items-center gap-2 px-3 py-2 rounded-lg ${getCategoryColor(cat)}`}>
{getCategoryIcon(cat)}
<span className="text-sm font-medium">{cat}</span>
<span className="text-sm">{formatCurrency(amount)}</span>
</div>
))
}
</div>
</div>
)}
{/* 필터 영역 */}
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
{/* 검색 */}
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="가맹점, 메모, 승인번호 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
{/* 카드 필터 */}
<div>
<select
value={filterCard}
onChange={(e) => setFilterCard(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
>
<option value="all">전체 카드</option>
{cards.map(card => (
<option key={card.id} value={card.id}>{card.cardName}</option>
))}
</select>
</div>
{/* 카테고리 필터 */}
<div>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
>
<option value="all">전체 카테고리</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
{/* 기간 필터 */}
<div className="flex items-center gap-2">
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))}
className="flex-1 px-2 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 text-sm"
/>
<span className="text-gray-400">~</span>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))}
className="flex-1 px-2 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 text-sm"
/>
</div>
{/* 상태 필터 버튼 */}
<div className="flex gap-1">
<button
onClick={() => setFilterStatus('all')}
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
filterStatus === 'all' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
전체
</button>
<button
onClick={() => setFilterStatus('approved')}
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
filterStatus === 'approved' ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
승인
</button>
<button
onClick={() => setFilterStatus('cancelled')}
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
filterStatus === 'cancelled' ? 'bg-rose-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
취소
</button>
</div>
</div>
</div>
{/* 거래 내역 목록 */}
<div className="space-y-4">
{Object.entries(groupedTransactions).length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<Receipt className="w-12 h-12 mx-auto mb-4 text-gray-300" />
<p className="text-gray-400">거래 내역이 없습니다.</p>
</div>
) : (
Object.entries(groupedTransactions).map(([date, txList]) => (
<div key={date} className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<span className="font-medium text-gray-700">{formatDateDisplay(date)}</span>
</div>
<span className="text-sm text-gray-500">
{txList.length} / {formatCurrency(txList.reduce((s, t) => s + t.amount, 0))}
</span>
</div>
<div className="divide-y divide-gray-100">
{txList.map(tx => (
<div
key={tx.id}
onClick={() => handleEditTransaction(tx)}
className="px-6 py-4 hover:bg-gray-50 cursor-pointer transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`p-2 rounded-lg ${getCategoryColor(tx.category)}`}>
{getCategoryIcon(tx.category)}
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{tx.merchant}</span>
<span className={`px-2 py-0.5 rounded text-xs ${
tx.status === 'approved'
? 'bg-emerald-100 text-emerald-700'
: 'bg-rose-100 text-rose-700'
}`}>
{tx.status === 'approved' ? '승인' : '취소'}
</span>
</div>
<div className="flex items-center gap-3 text-sm text-gray-500">
<span>{tx.time}</span>
<span>{cards.find(c => c.id === tx.cardId)?.cardName}</span>
<span className={`px-1.5 py-0.5 rounded text-xs ${getCategoryColor(tx.category)}`}>
{tx.category}
</span>
{tx.memo && <span className="text-gray-400">| {tx.memo}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className={`text-lg font-bold ${tx.amount < 0 ? 'text-rose-600' : 'text-gray-900'}`}>
{tx.amount < 0 ? '' : ''}{formatCurrency(tx.amount)}
</p>
<p className="text-xs text-gray-400">승인번호: {tx.approvalNo}</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); handleEditTransaction(tx); }}
className="p-2 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded-lg transition-colors"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(tx.id); }}
className="p-2 text-gray-400 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
))}
</div>
</div>
))
)}
</div>
{/* 등록/수정 모달 */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">
{modalMode === 'add' ? '거래 등록' : '거래 수정'}
</h3>
<button
onClick={() => { setShowModal(false); setEditingTransaction(null); }}
className="p-1 hover:bg-gray-100 rounded-lg"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">카드 *</label>
<select
value={formData.cardId}
onChange={(e) => setFormData(prev => ({ ...prev, cardId: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
>
{cards.map(card => (
<option key={card.id} value={card.id}>{card.cardName}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">날짜 *</label>
<input
type="date"
value={formData.date}
onChange={(e) => setFormData(prev => ({ ...prev, date: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">시간</label>
<input
type="time"
value={formData.time}
onChange={(e) => setFormData(prev => ({ ...prev, time: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">가맹점명 *</label>
<input
type="text"
value={formData.merchant}
onChange={(e) => setFormData(prev => ({ ...prev, merchant: e.target.value }))}
placeholder="가맹점명을 입력하세요"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">금액 *</label>
<input
type="text"
value={formatInputCurrency(formData.amount)}
onChange={(e) => setFormData(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))}
placeholder="0"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 text-right"
/>
<p className="text-xs text-gray-400 mt-1">취소 음수(-) 입력</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">승인번호</label>
<input
type="text"
value={formData.approvalNo}
onChange={(e) => setFormData(prev => ({ ...prev, approvalNo: e.target.value }))}
placeholder="승인번호"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">상태</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="approved"
checked={formData.status === 'approved'}
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))}
className="w-4 h-4 text-indigo-600"
/>
<span className="text-sm text-gray-700">승인</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="cancelled"
checked={formData.status === 'cancelled'}
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))}
className="w-4 h-4 text-indigo-600"
/>
<span className="text-sm text-gray-700">취소</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">메모</label>
<input
type="text"
value={formData.memo}
onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))}
placeholder="메모를 입력하세요"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && (
<button
onClick={() => handleDelete(editingTransaction.id)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"
>
<span>🗑️</span> 삭제
</button>
)}
<button
onClick={() => { setShowModal(false); setEditingTransaction(null); }}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 flex items-center justify-center gap-2"
>
<span></span> 취소
</button>
<button
onClick={handleSave}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg flex items-center justify-center gap-2"
>
<span></span> {modalMode === 'add' ? '등록' : '저장'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// React 앱 마운트
const rootElement = document.getElementById('card-transactions-root');
if (rootElement) {
ReactDOM.createRoot(rootElement).render(<CardTransactionsManagement />);
}
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,262 @@
@extends('layouts.app')
@section('title', '상담수수료')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="consulting-fee-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const MessageCircle = createIcon('message-circle');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const DollarSign = createIcon('dollar-sign');
const Users = createIcon('users');
function ConsultingFeeManagement() {
const [fees, setFees] = useState([
{ id: 1, date: '2026-01-21', consultant: '김상담', customer: '(주)제조산업', service: '기술 컨설팅', hours: 8, hourlyRate: 200000, amount: 1600000, status: 'pending', memo: 'MES 도입 상담' },
{ id: 2, date: '2026-01-18', consultant: '박컨설', customer: '(주)스마트팩토리', service: '프로세스 컨설팅', hours: 16, hourlyRate: 250000, amount: 4000000, status: 'paid', memo: '공정 개선' },
{ id: 3, date: '2026-01-15', consultant: '김상담', customer: '(주)디지털제조', service: '기술 컨설팅', hours: 4, hourlyRate: 200000, amount: 800000, status: 'paid', memo: 'ERP 연동 상담' },
{ id: 4, date: '2026-01-10', consultant: '이자문', customer: '(주)AI산업', service: '전략 컨설팅', hours: 24, hourlyRate: 300000, amount: 7200000, status: 'pending', memo: 'DX 전략 수립' },
{ id: 5, date: '2025-12-20', consultant: '박컨설', customer: '(주)테크솔루션', service: '프로세스 컨설팅', hours: 8, hourlyRate: 250000, amount: 2000000, status: 'paid', memo: '' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterConsultant, setFilterConsultant] = useState('all');
const [dateRange, setDateRange] = useState({
start: new Date(new Date().setMonth(new Date().getMonth() - 3)).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0]
});
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const consultants = ['김상담', '박컨설', '이자문', '최전문'];
const services = ['기술 컨설팅', '프로세스 컨설팅', '전략 컨설팅', '교육/훈련', '기타'];
const initialFormState = {
date: new Date().toISOString().split('T')[0],
consultant: '김상담',
customer: '',
service: '기술 컨설팅',
hours: '',
hourlyRate: 200000,
amount: '',
status: 'pending',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const filteredFees = fees.filter(item => {
const matchesSearch = item.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.consultant.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesConsultant = filterConsultant === 'all' || item.consultant === filterConsultant;
const matchesDate = item.date >= dateRange.start && item.date <= dateRange.end;
return matchesSearch && matchesStatus && matchesConsultant && matchesDate;
});
const totalAmount = filteredFees.reduce((sum, item) => sum + item.amount, 0);
const paidAmount = filteredFees.filter(i => i.status === 'paid').reduce((sum, item) => sum + item.amount, 0);
const pendingAmount = filteredFees.filter(i => i.status === 'pending').reduce((sum, item) => sum + item.amount, 0);
const totalHours = filteredFees.reduce((sum, item) => sum + item.hours, 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.customer || !formData.hours) { alert('필수 항목을 입력해주세요.'); return; }
const hours = parseInt(formData.hours) || 0;
const hourlyRate = parseInt(formData.hourlyRate) || 0;
const amount = parseInt(formData.amount) || hours * hourlyRate;
if (modalMode === 'add') {
setFees(prev => [{ id: Date.now(), ...formData, hours, hourlyRate, amount }, ...prev]);
} else {
setFees(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, hours, hourlyRate, amount } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setFees(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['상담수수료', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '컨설턴트', '고객사', '서비스', '시간', '시급', '금액', '상태'],
...filteredFees.map(item => [item.date, item.consultant, item.customer, item.service, item.hours, item.hourlyRate, item.amount, item.status === 'paid' ? '지급완료' : '지급예정'])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `상담수수료_${dateRange.start}_${dateRange.end}.csv`; link.click();
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-cyan-100 rounded-xl"><MessageCircle className="w-6 h-6 text-cyan-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">상담수수료</h1><p className="text-sm text-gray-500">Consulting Fee</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">수수료 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 시간</span><Users className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{totalHours}시간</p>
</div>
<div className="bg-white rounded-xl border border-cyan-200 p-6 bg-cyan-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-cyan-700"> 수수료</span><DollarSign className="w-5 h-5 text-cyan-500" /></div>
<p className="text-2xl font-bold text-cyan-600">{formatCurrency(totalAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">지급완료</span></div>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(paidAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">지급예정</span></div>
<p className="text-2xl font-bold text-amber-600">{formatCurrency(pendingAmount)}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="고객사, 컨설턴트 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-cyan-500" />
</div>
<select value={filterConsultant} onChange={(e) => setFilterConsultant(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 컨설턴트</option>{consultants.map(c => <option key={c} value={c}>{c}</option>)}</select>
<div className="flex items-center gap-2">
<input type="date" value={dateRange.start} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
<span>~</span>
<input type="date" value={dateRange.end} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
<div className="flex gap-1">
{['all', 'paid', 'pending'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium ${filterStatus === status ? (status === 'paid' ? 'bg-green-600 text-white' : status === 'pending' ? 'bg-yellow-500 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{status === 'all' ? '전체' : status === 'paid' ? '완료' : '예정'}
</button>
))}
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">날짜</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">컨설턴트</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">고객사</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">서비스</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">시간</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">금액</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredFees.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredFees.map(item => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
<td className="px-6 py-4 text-sm text-gray-600">{item.date}</td>
<td className="px-6 py-4"><span className="px-2 py-1 bg-cyan-100 text-cyan-700 rounded text-xs font-medium">{item.consultant}</span></td>
<td className="px-6 py-4 text-sm font-medium text-gray-900">{item.customer}</td>
<td className="px-6 py-4 text-sm text-gray-600">{item.service}</td>
<td className="px-6 py-4 text-sm text-center text-gray-600">{item.hours}h</td>
<td className="px-6 py-4 text-sm font-bold text-right text-cyan-600">{formatCurrency(item.amount)}</td>
<td className="px-6 py-4 text-center"><span className={`px-2 py-1 rounded-full text-xs font-medium ${item.status === 'paid' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>{item.status === 'paid' ? '지급완료' : '지급예정'}</span></td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '수수료 등록' : '수수료 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">날짜 *</label><input type="date" value={formData.date} onChange={(e) => setFormData(prev => ({ ...prev, date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">컨설턴트</label><select value={formData.consultant} onChange={(e) => setFormData(prev => ({ ...prev, consultant: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{consultants.map(c => <option key={c} value={c}>{c}</option>)}</select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">고객사 *</label><input type="text" value={formData.customer} onChange={(e) => setFormData(prev => ({ ...prev, customer: e.target.value }))} placeholder="고객사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">서비스</label><select value={formData.service} onChange={(e) => setFormData(prev => ({ ...prev, service: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{services.map(s => <option key={s} value={s}>{s}</option>)}</select></div>
<div className="grid grid-cols-3 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">시간 *</label><input type="number" value={formData.hours} onChange={(e) => setFormData(prev => ({ ...prev, hours: e.target.value }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">시급</label><input type="text" value={formatInputCurrency(formData.hourlyRate)} onChange={(e) => setFormData(prev => ({ ...prev, hourlyRate: parseInputCurrency(e.target.value) }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">금액</label><input type="text" value={formatInputCurrency(formData.amount)} onChange={(e) => setFormData(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))} placeholder="자동계산" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="pending">지급예정</option><option value="paid">지급완료</option></select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('consulting-fee-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<ConsultingFeeManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,600 @@
@extends('layouts.app')
@section('title', '법인카드 등록/조회')
@push('styles')
<style>
@media print {
.no-print { display: none !important; }
}
</style>
@endpush
@section('content')
<div id="corporate-cards-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
// Lucide 아이콘 래핑
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const CreditCard = createIcon('credit-card');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const X = createIcon('x');
const Building = createIcon('building');
const Calendar = createIcon('calendar');
const DollarSign = createIcon('dollar-sign');
const CheckCircle = createIcon('check-circle');
const XCircle = createIcon('x-circle');
function CorporateCardsManagement() {
// 카드 목록 데이터
const [cards, setCards] = useState([
{
id: 1,
cardName: '업무용 법인카드',
cardCompany: '삼성카드',
cardNumber: '9410-1234-5678-9012',
cardType: 'credit',
paymentDay: 15,
creditLimit: 10000000,
currentUsage: 3500000,
holder: '김대표',
status: 'active',
memo: '일반 업무용'
},
{
id: 2,
cardName: '마케팅 법인카드',
cardCompany: '현대카드',
cardNumber: '5412-9876-5432-1098',
cardType: 'credit',
paymentDay: 25,
creditLimit: 5000000,
currentUsage: 2100000,
holder: '박마케팅',
status: 'active',
memo: '마케팅/광고비 전용'
},
{
id: 3,
cardName: '개발팀 체크카드',
cardCompany: '국민카드',
cardNumber: '4532-1111-2222-3333',
cardType: 'debit',
paymentDay: 0,
creditLimit: 0,
currentUsage: 850000,
holder: '이개발',
status: 'active',
memo: '개발 장비/소프트웨어 구매'
},
{
id: 4,
cardName: '예비 법인카드',
cardCompany: '신한카드',
cardNumber: '9876-5555-4444-3333',
cardType: 'credit',
paymentDay: 10,
creditLimit: 3000000,
currentUsage: 0,
holder: '최관리',
status: 'inactive',
memo: '비상용'
}
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add'); // 'add' or 'edit'
const [editingCard, setEditingCard] = useState(null);
// 새 카드 폼 초기값
const initialFormState = {
cardName: '',
cardCompany: '삼성카드',
cardNumber: '',
cardType: 'credit',
paymentDay: 15,
creditLimit: '',
holder: '',
status: 'active',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
// 카드사 목록
const cardCompanies = ['삼성카드', '현대카드', '국민카드', '신한카드', '롯데카드', 'BC카드', '하나카드', '우리카드', 'NH농협카드'];
// 금액 포맷
const formatCurrency = (num) => {
if (!num) return '0';
return num.toLocaleString();
};
// 입력용 금액 포맷 (3자리 콤마)
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
if (!num) return '';
return Number(num).toLocaleString();
};
// 입력값에서 숫자만 추출
const parseInputCurrency = (value) => {
return String(value).replace(/[^\d]/g, '');
};
// 카드번호 포맷
const formatCardNumber = (num) => {
if (!num) return '';
return num.replace(/(.{4})/g, '$1-').slice(0, -1);
};
// 필터링된 카드 목록
const filteredCards = cards.filter(card => {
const matchesSearch = card.cardName.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.cardCompany.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.holder.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === 'all' || card.status === filterStatus;
return matchesSearch && matchesStatus;
});
// 카드 추가 모달 열기
const handleAddCard = () => {
setModalMode('add');
setFormData(initialFormState);
setShowModal(true);
};
// 카드 수정 모달 열기
const handleEditCard = (card) => {
setModalMode('edit');
setEditingCard(card);
setFormData({
cardName: card.cardName,
cardCompany: card.cardCompany,
cardNumber: card.cardNumber,
cardType: card.cardType,
paymentDay: card.paymentDay,
creditLimit: card.creditLimit,
holder: card.holder,
status: card.status,
memo: card.memo
});
setShowModal(true);
};
// 카드 저장
const handleSaveCard = () => {
if (!formData.cardName || !formData.cardNumber || !formData.holder) {
alert('필수 항목을 입력해주세요.');
return;
}
if (modalMode === 'add') {
const newCard = {
id: Date.now(),
...formData,
creditLimit: parseInt(formData.creditLimit) || 0,
currentUsage: 0
};
setCards(prev => [...prev, newCard]);
} else {
setCards(prev => prev.map(card =>
card.id === editingCard.id
? { ...card, ...formData, creditLimit: parseInt(formData.creditLimit) || 0 }
: card
));
}
setShowModal(false);
setEditingCard(null);
};
// 카드 삭제
const handleDeleteCard = (id) => {
if (confirm('정말 삭제하시겠습니까?')) {
setCards(prev => prev.filter(card => card.id !== id));
if (showModal) {
setShowModal(false);
setEditingCard(null);
}
}
};
// 사용률 계산
const getUsagePercent = (usage, limit) => {
if (!limit) return 0;
return Math.round((usage / limit) * 100);
};
// 사용률 색상
const getUsageColor = (percent) => {
if (percent >= 80) return 'bg-red-500';
if (percent >= 50) return 'bg-yellow-500';
return 'bg-emerald-500';
};
// 총 한도 및 사용액
const totalLimit = cards.filter(c => c.status === 'active' && c.cardType === 'credit').reduce((sum, c) => sum + c.creditLimit, 0);
const totalUsage = cards.filter(c => c.status === 'active').reduce((sum, c) => sum + c.currentUsage, 0);
return (
<div className="bg-gray-50 min-h-screen">
{/* 헤더 */}
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-violet-100 rounded-xl">
<CreditCard className="w-6 h-6 text-violet-600" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">법인카드 등록/조회</h1>
<p className="text-sm text-gray-500">Corporate Card Management</p>
</div>
</div>
<button
onClick={handleAddCard}
className="flex items-center gap-2 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
<span className="text-sm font-medium">카드 등록</span>
</button>
</div>
</div>
</header>
{/* 요약 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500">등록 카드</span>
<CreditCard className="w-5 h-5 text-gray-400" />
</div>
<p className="text-2xl font-bold text-gray-900">{cards.length}</p>
<p className="text-xs text-gray-400 mt-1">활성 {cards.filter(c => c.status === 'active').length}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500"> 한도</span>
<DollarSign className="w-5 h-5 text-gray-400" />
</div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalLimit)}</p>
<p className="text-xs text-gray-400 mt-1">신용카드 기준</p>
</div>
<div className="bg-white rounded-xl border border-violet-200 p-6 bg-violet-50/30">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-violet-700">이번 사용</span>
<CreditCard className="w-5 h-5 text-violet-500" />
</div>
<p className="text-2xl font-bold text-violet-600">{formatCurrency(totalUsage)}</p>
<p className="text-xs text-violet-500 mt-1">{totalLimit > 0 ? getUsagePercent(totalUsage, totalLimit) : 0}% 사용</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500">잔여 한도</span>
<DollarSign className="w-5 h-5 text-emerald-500" />
</div>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(totalLimit - totalUsage)}</p>
<p className="text-xs text-gray-400 mt-1">사용 가능</p>
</div>
</div>
{/* 검색 및 필터 */}
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="카드명, 카드사, 사용자 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => setFilterStatus('all')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filterStatus === 'all' ? 'bg-violet-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
전체
</button>
<button
onClick={() => setFilterStatus('active')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filterStatus === 'active' ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
활성
</button>
<button
onClick={() => setFilterStatus('inactive')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filterStatus === 'inactive' ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
비활성
</button>
</div>
</div>
</div>
{/* 카드 목록 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredCards.map(card => (
<div
key={card.id}
onClick={() => handleEditCard(card)}
className={`bg-white rounded-xl border-2 p-6 cursor-pointer transition-all hover:shadow-lg ${
card.status === 'active' ? 'border-gray-200 hover:border-violet-300' : 'border-gray-200 opacity-60'
}`}
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${card.status === 'active' ? 'bg-violet-100' : 'bg-gray-100'}`}>
<CreditCard className={`w-5 h-5 ${card.status === 'active' ? 'text-violet-600' : 'text-gray-400'}`} />
</div>
<div>
<h3 className="font-bold text-gray-900">{card.cardName}</h3>
<p className="text-sm text-gray-500">{card.cardCompany}</p>
</div>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
card.status === 'active' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500'
}`}>
{card.status === 'active' ? '활성' : '비활성'}
</span>
</div>
<div className="mb-4">
<p className="text-lg font-mono text-gray-700 tracking-wider">{card.cardNumber}</p>
</div>
<div className="grid grid-cols-2 gap-4 mb-4 text-sm">
<div>
<p className="text-gray-500">사용자</p>
<p className="font-medium text-gray-900">{card.holder}</p>
</div>
<div>
<p className="text-gray-500">카드종류</p>
<p className="font-medium text-gray-900">{card.cardType === 'credit' ? '신용카드' : '체크카드'}</p>
</div>
{card.cardType === 'credit' && (
<>
<div>
<p className="text-gray-500">결제일</p>
<p className="font-medium text-gray-900">매월 {card.paymentDay}</p>
</div>
<div>
<p className="text-gray-500">한도</p>
<p className="font-medium text-gray-900">{formatCurrency(card.creditLimit)}</p>
</div>
</>
)}
</div>
{card.cardType === 'credit' && card.creditLimit > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500">사용현황</span>
<span className="text-sm font-medium text-gray-900">
{formatCurrency(card.currentUsage)} / {formatCurrency(card.creditLimit)}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getUsageColor(getUsagePercent(card.currentUsage, card.creditLimit))}`}
style={{ width: `${Math.min(getUsagePercent(card.currentUsage, card.creditLimit), 100)}%` }}
></div>
</div>
<p className="text-xs text-gray-400 mt-1 text-right">{getUsagePercent(card.currentUsage, card.creditLimit)}% 사용</p>
</div>
)}
{card.memo && (
<p className="text-xs text-gray-400 mt-3 pt-3 border-t border-gray-100">{card.memo}</p>
)}
</div>
))}
{filteredCards.length === 0 && (
<div className="col-span-full text-center py-12 text-gray-400">
<CreditCard className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>등록된 카드가 없습니다.</p>
</div>
)}
</div>
{/* 등록/수정 모달 */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">
{modalMode === 'add' ? '법인카드 등록' : '법인카드 수정'}
</h3>
<button
onClick={() => { setShowModal(false); setEditingCard(null); }}
className="p-1 hover:bg-gray-100 rounded-lg"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">카드명 *</label>
<input
type="text"
value={formData.cardName}
onChange={(e) => setFormData(prev => ({ ...prev, cardName: e.target.value }))}
placeholder="예: 업무용 법인카드"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">카드사</label>
<select
value={formData.cardCompany}
onChange={(e) => setFormData(prev => ({ ...prev, cardCompany: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500"
>
{cardCompanies.map(company => (
<option key={company} value={company}>{company}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">카드종류</label>
<select
value={formData.cardType}
onChange={(e) => setFormData(prev => ({ ...prev, cardType: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500"
>
<option value="credit">신용카드</option>
<option value="debit">체크카드</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">카드번호 *</label>
<input
type="text"
value={formData.cardNumber}
onChange={(e) => setFormData(prev => ({ ...prev, cardNumber: e.target.value }))}
placeholder="1234-5678-9012-3456"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500 font-mono"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">사용자 *</label>
<input
type="text"
value={formData.holder}
onChange={(e) => setFormData(prev => ({ ...prev, holder: e.target.value }))}
placeholder="카드 사용자명"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select
value={formData.status}
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500"
>
<option value="active">활성</option>
<option value="inactive">비활성</option>
</select>
</div>
</div>
{formData.cardType === 'credit' && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">결제일</label>
<select
value={formData.paymentDay}
onChange={(e) => setFormData(prev => ({ ...prev, paymentDay: parseInt(e.target.value) }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500"
>
{[1, 5, 10, 14, 15, 20, 25, 27].map(day => (
<option key={day} value={day}>매월 {day}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">사용한도</label>
<input
type="text"
value={formatInputCurrency(formData.creditLimit)}
onChange={(e) => setFormData(prev => ({ ...prev, creditLimit: parseInputCurrency(e.target.value) }))}
placeholder="0"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500 text-right"
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">메모</label>
<textarea
value={formData.memo}
onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))}
placeholder="카드 용도나 특이사항..."
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500 resize-none"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && (
<button
onClick={() => handleDeleteCard(editingCard.id)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"
>
<span>🗑️</span> 삭제
</button>
)}
<button
onClick={() => { setShowModal(false); setEditingCard(null); }}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 flex items-center justify-center gap-2"
>
<span></span> 취소
</button>
<button
onClick={handleSaveCard}
className="flex-1 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg flex items-center justify-center gap-2"
>
<span></span> {modalMode === 'add' ? '등록' : '저장'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// React 앱 마운트
const rootElement = document.getElementById('corporate-cards-root');
if (rootElement) {
ReactDOM.createRoot(rootElement).render(<CorporateCardsManagement />);
}
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,262 @@
@extends('layouts.app')
@section('title', '법인차량 등록')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="corporate-vehicles-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const Car = createIcon('car');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const Calendar = createIcon('calendar');
const Fuel = createIcon('fuel');
const Gauge = createIcon('gauge');
function CorporateVehiclesManagement() {
const [vehicles, setVehicles] = useState([
{ id: 1, plateNumber: '12가 3456', model: '제네시스 G80', type: '승용차', year: 2024, purchaseDate: '2024-03-15', purchasePrice: 75000000, driver: '김대표', status: 'active', mileage: 15000, insuranceExpiry: '2025-03-14', inspectionExpiry: '2026-03-14', memo: '대표이사 차량' },
{ id: 2, plateNumber: '34나 5678', model: '현대 스타렉스', type: '승합차', year: 2023, purchaseDate: '2023-06-20', purchasePrice: 45000000, driver: '박기사', status: 'active', mileage: 48000, insuranceExpiry: '2024-06-19', inspectionExpiry: '2025-06-19', memo: '직원 출퇴근용' },
{ id: 3, plateNumber: '56다 7890', model: '기아 레이', type: '승용차', year: 2022, purchaseDate: '2022-01-10', purchasePrice: 15000000, driver: '이영업', status: 'active', mileage: 62000, insuranceExpiry: '2025-01-09', inspectionExpiry: '2026-01-09', memo: '영업용' },
{ id: 4, plateNumber: '78라 1234', model: '포터2', type: '화물차', year: 2021, purchaseDate: '2021-08-05', purchasePrice: 25000000, driver: '최배송', status: 'maintenance', mileage: 95000, insuranceExpiry: '2024-08-04', inspectionExpiry: '2025-08-04', memo: '배송용' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterType, setFilterType] = useState('all');
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const types = ['승용차', '승합차', '화물차', 'SUV'];
const initialFormState = {
plateNumber: '',
model: '',
type: '승용차',
year: new Date().getFullYear(),
purchaseDate: '',
purchasePrice: '',
driver: '',
status: 'active',
mileage: '',
insuranceExpiry: '',
inspectionExpiry: '',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const filteredVehicles = vehicles.filter(item => {
const matchesSearch = item.model.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.plateNumber.includes(searchTerm) ||
item.driver.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesType = filterType === 'all' || item.type === filterType;
return matchesSearch && matchesStatus && matchesType;
});
const totalVehicles = vehicles.length;
const activeVehicles = vehicles.filter(v => v.status === 'active').length;
const totalValue = vehicles.reduce((sum, v) => sum + v.purchasePrice, 0);
const totalMileage = vehicles.reduce((sum, v) => sum + v.mileage, 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.plateNumber || !formData.model) { alert('필수 항목을 입력해주세요.'); return; }
const purchasePrice = parseInt(formData.purchasePrice) || 0;
const mileage = parseInt(formData.mileage) || 0;
if (modalMode === 'add') {
setVehicles(prev => [{ id: Date.now(), ...formData, purchasePrice, mileage }, ...prev]);
} else {
setVehicles(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, purchasePrice, mileage } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setVehicles(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['법인차량 등록'], [], ['차량번호', '모델', '종류', '연식', '취득일', '취득가', '운전자', '상태', '주행거리'],
...filteredVehicles.map(item => [item.plateNumber, item.model, item.type, item.year, item.purchaseDate, item.purchasePrice, item.driver, item.status, item.mileage])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = '법인차량목록.csv'; link.click();
};
const getStatusColor = (status) => {
const colors = { active: 'bg-emerald-100 text-emerald-700', maintenance: 'bg-amber-100 text-amber-700', disposed: 'bg-gray-100 text-gray-700' };
return colors[status] || 'bg-gray-100 text-gray-700';
};
const getStatusLabel = (status) => {
const labels = { active: '운행중', maintenance: '정비중', disposed: '처분' };
return labels[status] || status;
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-slate-100 rounded-xl"><Car className="w-6 h-6 text-slate-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">법인차량 등록</h1><p className="text-sm text-gray-500">Corporate Vehicles</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">차량 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 차량</span><Car className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{totalVehicles}</p>
<p className="text-xs text-gray-400 mt-1">운행중 {activeVehicles}</p>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6 bg-slate-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-slate-700"> 취득가</span></div>
<p className="text-2xl font-bold text-slate-600">{formatCurrency(totalValue)}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 주행거리</span><Gauge className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalMileage)}km</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">평균 주행</span></div>
<p className="text-2xl font-bold text-gray-900">{totalVehicles > 0 ? formatCurrency(Math.round(totalMileage / totalVehicles)) : 0}km</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="차량번호, 모델, 운전자 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-slate-500" />
</div>
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 종류</option>{types.map(t => <option key={t} value={t}>{t}</option>)}</select>
<div className="flex gap-1">
{['all', 'active', 'maintenance'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium ${filterStatus === status ? (status === 'active' ? 'bg-green-600 text-white' : status === 'maintenance' ? 'bg-yellow-500 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{status === 'all' ? '전체' : getStatusLabel(status)}
</button>
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredVehicles.map(item => (
<div key={item.id} onClick={() => handleEdit(item)} className="bg-white rounded-xl border border-gray-200 p-6 cursor-pointer hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-100 rounded-lg"><Car className="w-5 h-5 text-slate-600" /></div>
<div>
<h3 className="font-bold text-gray-900">{item.model}</h3>
<p className="text-sm text-gray-500">{item.plateNumber}</p>
</div>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(item.status)}`}>{getStatusLabel(item.status)}</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div><p className="text-gray-500">종류</p><p className="font-medium">{item.type}</p></div>
<div><p className="text-gray-500">연식</p><p className="font-medium">{item.year}</p></div>
<div><p className="text-gray-500">운전자</p><p className="font-medium">{item.driver}</p></div>
<div><p className="text-gray-500">주행거리</p><p className="font-medium">{formatCurrency(item.mileage)}km</p></div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex justify-between items-center">
<span className="text-sm text-gray-500">취득가</span>
<span className="font-bold text-slate-600">{formatCurrency(item.purchasePrice)}</span>
</div>
</div>
))}
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '차량 등록' : '차량 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">차량번호 *</label><input type="text" value={formData.plateNumber} onChange={(e) => setFormData(prev => ({ ...prev, plateNumber: e.target.value }))} placeholder="12가 3456" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">종류</label><select value={formData.type} onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{types.map(t => <option key={t} value={t}>{t}</option>)}</select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">모델 *</label><input type="text" value={formData.model} onChange={(e) => setFormData(prev => ({ ...prev, model: e.target.value }))} placeholder="차량 모델명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">연식</label><input type="number" value={formData.year} onChange={(e) => setFormData(prev => ({ ...prev, year: parseInt(e.target.value) }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">취득일</label><input type="date" value={formData.purchaseDate} onChange={(e) => setFormData(prev => ({ ...prev, purchaseDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">취득가</label><input type="text" value={formatInputCurrency(formData.purchasePrice)} onChange={(e) => setFormData(prev => ({ ...prev, purchasePrice: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">주행거리(km)</label><input type="text" value={formatInputCurrency(formData.mileage)} onChange={(e) => setFormData(prev => ({ ...prev, mileage: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">운전자</label><input type="text" value={formData.driver} onChange={(e) => setFormData(prev => ({ ...prev, driver: e.target.value }))} placeholder="운전자명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="active">운행중</option><option value="maintenance">정비중</option><option value="disposed">처분</option></select></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">보험 만료일</label><input type="date" value={formData.insuranceExpiry} onChange={(e) => setFormData(prev => ({ ...prev, insuranceExpiry: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">검사 만료일</label><input type="date" value={formData.inspectionExpiry} onChange={(e) => setFormData(prev => ({ ...prev, inspectionExpiry: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('corporate-vehicles-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<CorporateVehiclesManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,257 @@
@extends('layouts.app')
@section('title', '고객사별 정산')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="customer-settlement-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const Building2 = createIcon('building-2');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const DollarSign = createIcon('dollar-sign');
const Calendar = createIcon('calendar');
const CheckCircle = createIcon('check-circle');
const Clock = createIcon('clock');
function CustomerSettlementManagement() {
const [settlements, setSettlements] = useState([
{ id: 1, period: '2026-01', customer: '(주)제조산업', totalSales: 160000000, commission: 4800000, expense: 500000, netAmount: 154700000, status: 'pending', settledDate: '', memo: '' },
{ id: 2, period: '2026-01', customer: '(주)테크솔루션', totalSales: 6000000, commission: 300000, expense: 0, netAmount: 5700000, status: 'settled', settledDate: '2026-01-20', memo: '' },
{ id: 3, period: '2026-01', customer: '(주)디지털제조', totalSales: 80000000, commission: 2400000, expense: 200000, netAmount: 77400000, status: 'settled', settledDate: '2026-01-15', memo: '' },
{ id: 4, period: '2025-12', customer: '(주)AI산업', totalSales: 36000000, commission: 720000, expense: 100000, netAmount: 35180000, status: 'settled', settledDate: '2025-12-31', memo: '연간 계약' },
{ id: 5, period: '2025-12', customer: '(주)스마트팩토리', totalSales: 15000000, commission: 750000, expense: 0, netAmount: 14250000, status: 'settled', settledDate: '2025-12-28', memo: '' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterPeriod, setFilterPeriod] = useState('all');
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const periods = [...new Set(settlements.map(s => s.period))].sort().reverse();
const initialFormState = {
period: new Date().toISOString().slice(0, 7),
customer: '',
totalSales: '',
commission: '',
expense: '',
netAmount: '',
status: 'pending',
settledDate: '',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const filteredSettlements = settlements.filter(item => {
const matchesSearch = item.customer.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesPeriod = filterPeriod === 'all' || item.period === filterPeriod;
return matchesSearch && matchesStatus && matchesPeriod;
});
const totalSales = filteredSettlements.reduce((sum, item) => sum + item.totalSales, 0);
const totalCommission = filteredSettlements.reduce((sum, item) => sum + item.commission, 0);
const totalNet = filteredSettlements.reduce((sum, item) => sum + item.netAmount, 0);
const settledAmount = filteredSettlements.filter(i => i.status === 'settled').reduce((sum, item) => sum + item.netAmount, 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.customer || !formData.totalSales) { alert('필수 항목을 입력해주세요.'); return; }
const totalSales = parseInt(formData.totalSales) || 0;
const commission = parseInt(formData.commission) || 0;
const expense = parseInt(formData.expense) || 0;
const netAmount = totalSales - commission - expense;
if (modalMode === 'add') {
setSettlements(prev => [{ id: Date.now(), ...formData, totalSales, commission, expense, netAmount }, ...prev]);
} else {
setSettlements(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, totalSales, commission, expense, netAmount } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setSettlements(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['고객사별 정산'], [], ['정산월', '고객사', '매출액', '수수료', '비용', '정산금액', '상태', '정산일'],
...filteredSettlements.map(item => [item.period, item.customer, item.totalSales, item.commission, item.expense, item.netAmount, item.status === 'settled' ? '정산완료' : '정산대기', item.settledDate])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `고객사별정산_${filterPeriod || 'all'}.csv`; link.click();
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-indigo-100 rounded-xl"><Building2 className="w-6 h-6 text-indigo-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">고객사별 정산</h1><p className="text-sm text-gray-500">Customer Settlement</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">정산 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 매출</span><DollarSign className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalSales)}</p>
</div>
<div className="bg-white rounded-xl border border-indigo-200 p-6 bg-indigo-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-indigo-700">정산 금액</span><Building2 className="w-5 h-5 text-indigo-500" /></div>
<p className="text-2xl font-bold text-indigo-600">{formatCurrency(totalNet)}</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">정산완료</span><CheckCircle className="w-5 h-5 text-emerald-500" /></div>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(settledAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-rose-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-rose-700">수수료 합계</span></div>
<p className="text-2xl font-bold text-rose-600">{formatCurrency(totalCommission)}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="고객사 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500" />
</div>
<select value={filterPeriod} onChange={(e) => setFilterPeriod(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 기간</option>{periods.map(p => <option key={p} value={p}>{p}</option>)}</select>
<div className="flex gap-1">
{['all', 'settled', 'pending'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium ${filterStatus === status ? (status === 'settled' ? 'bg-green-600 text-white' : status === 'pending' ? 'bg-yellow-500 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{status === 'all' ? '전체' : status === 'settled' ? '완료' : '대기'}
</button>
))}
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">정산월</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">고객사</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">매출액</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">수수료</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">비용</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">정산금액</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredSettlements.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredSettlements.map(item => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
<td className="px-6 py-4 text-sm text-gray-600">{item.period}</td>
<td className="px-6 py-4 text-sm font-medium text-gray-900">{item.customer}</td>
<td className="px-6 py-4 text-sm text-right text-gray-900">{formatCurrency(item.totalSales)}</td>
<td className="px-6 py-4 text-sm text-right text-rose-600">{formatCurrency(item.commission)}</td>
<td className="px-6 py-4 text-sm text-right text-gray-600">{formatCurrency(item.expense)}</td>
<td className="px-6 py-4 text-sm font-bold text-right text-indigo-600">{formatCurrency(item.netAmount)}</td>
<td className="px-6 py-4 text-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${item.status === 'settled' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>
{item.status === 'settled' ? '정산완료' : '정산대기'}
</span>
{item.settledDate && <p className="text-xs text-gray-400 mt-1">{item.settledDate}</p>}
</td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '정산 등록' : '정산 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">정산월 *</label><input type="month" value={formData.period} onChange={(e) => setFormData(prev => ({ ...prev, period: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="pending">정산대기</option><option value="settled">정산완료</option></select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">고객사 *</label><input type="text" value={formData.customer} onChange={(e) => setFormData(prev => ({ ...prev, customer: e.target.value }))} placeholder="고객사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-3 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">매출액 *</label><input type="text" value={formatInputCurrency(formData.totalSales)} onChange={(e) => setFormData(prev => ({ ...prev, totalSales: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">수수료</label><input type="text" value={formatInputCurrency(formData.commission)} onChange={(e) => setFormData(prev => ({ ...prev, commission: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">비용</label><input type="text" value={formatInputCurrency(formData.expense)} onChange={(e) => setFormData(prev => ({ ...prev, expense: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">정산일</label><input type="date" value={formData.settledDate} onChange={(e) => setFormData(prev => ({ ...prev, settledDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('customer-settlement-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<CustomerSettlementManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,247 @@
@extends('layouts.app')
@section('title', '고객사 관리')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="customers-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const Users = createIcon('users');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const Building = createIcon('building');
const Phone = createIcon('phone');
const Mail = createIcon('mail');
const MapPin = createIcon('map-pin');
function CustomersManagement() {
const [customers, setCustomers] = useState([
{ id: 1, name: '(주)제조산업', bizNo: '123-45-67890', ceo: '김제조', industry: 'IT/소프트웨어', grade: 'VIP', contact: '02-1234-5678', email: 'contact@manufacturing.co.kr', address: '서울시 강남구 테헤란로 123', manager: '박담당', managerPhone: '010-1234-5678', status: 'active', memo: '주요 고객사' },
{ id: 2, name: '(주)테크솔루션', bizNo: '234-56-78901', ceo: '이테크', industry: 'IT/소프트웨어', grade: 'Gold', contact: '02-2345-6789', email: 'info@techsolution.co.kr', address: '서울시 서초구 반포대로 45', manager: '김매니저', managerPhone: '010-2345-6789', status: 'active', memo: '' },
{ id: 3, name: '(주)스마트팩토리', bizNo: '345-67-89012', ceo: '박스마트', industry: '제조업', grade: 'Gold', contact: '031-456-7890', email: 'smart@factory.co.kr', address: '경기도 수원시 영통구 삼성로 100', manager: '이담당', managerPhone: '010-3456-7890', status: 'active', memo: '' },
{ id: 4, name: '(주)디지털제조', bizNo: '456-78-90123', ceo: '최디지털', industry: '제조업', grade: 'Silver', contact: '032-567-8901', email: 'digital@mfg.co.kr', address: '인천시 연수구 송도과학로 50', manager: '최실장', managerPhone: '010-4567-8901', status: 'active', memo: '' },
{ id: 5, name: '(주)AI산업', bizNo: '567-89-01234', ceo: '정에이아이', industry: 'IT/소프트웨어', grade: 'Silver', contact: '02-678-9012', email: 'hello@ai-industry.co.kr', address: '서울시 마포구 월드컵로 200', manager: '정대리', managerPhone: '010-5678-9012', status: 'inactive', memo: '프로젝트 종료' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterGrade, setFilterGrade] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const grades = ['VIP', 'Gold', 'Silver', 'Bronze'];
const industries = ['IT/소프트웨어', '제조업', '서비스업', '유통업', '금융업', '기타'];
const initialFormState = {
name: '',
bizNo: '',
ceo: '',
industry: 'IT/소프트웨어',
grade: 'Silver',
contact: '',
email: '',
address: '',
manager: '',
managerPhone: '',
status: 'active',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const filteredCustomers = customers.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.ceo.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.manager.toLowerCase().includes(searchTerm.toLowerCase());
const matchesGrade = filterGrade === 'all' || item.grade === filterGrade;
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
return matchesSearch && matchesGrade && matchesStatus;
});
const totalCustomers = customers.length;
const activeCustomers = customers.filter(c => c.status === 'active').length;
const vipCustomers = customers.filter(c => c.grade === 'VIP').length;
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.name) { alert('회사명을 입력해주세요.'); return; }
if (modalMode === 'add') {
setCustomers(prev => [{ id: Date.now(), ...formData }, ...prev]);
} else {
setCustomers(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setCustomers(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['고객사 관리'], [], ['회사명', '사업자번호', '대표자', '업종', '등급', '연락처', '이메일', '담당자', '상태'],
...filteredCustomers.map(item => [item.name, item.bizNo, item.ceo, item.industry, item.grade, item.contact, item.email, item.manager, item.status === 'active' ? '활성' : '비활성'])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = '고객사목록.csv'; link.click();
};
const getGradeColor = (grade) => {
const colors = { VIP: 'bg-purple-100 text-purple-700', Gold: 'bg-amber-100 text-amber-700', Silver: 'bg-gray-100 text-gray-700', Bronze: 'bg-orange-100 text-orange-700' };
return colors[grade] || 'bg-gray-100 text-gray-700';
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-blue-100 rounded-xl"><Users className="w-6 h-6 text-blue-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">고객사 관리</h1><p className="text-sm text-gray-500">Customer Management</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">고객사 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 고객사</span><Building className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{totalCustomers}</p>
</div>
<div className="bg-white rounded-xl border border-blue-200 p-6 bg-blue-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-blue-700">활성 고객</span></div>
<p className="text-2xl font-bold text-blue-600">{activeCustomers}</p>
</div>
<div className="bg-white rounded-xl border border-purple-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-purple-700">VIP 고객</span></div>
<p className="text-2xl font-bold text-purple-600">{vipCustomers}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">비활성</span></div>
<p className="text-2xl font-bold text-gray-900">{totalCustomers - activeCustomers}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="회사명, 대표자, 담당자 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" />
</div>
<select value={filterGrade} onChange={(e) => setFilterGrade(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 등급</option>{grades.map(g => <option key={g} value={g}>{g}</option>)}</select>
<div className="flex gap-1">
{['all', 'active', 'inactive'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium ${filterStatus === status ? (status === 'active' ? 'bg-blue-600 text-white' : status === 'inactive' ? 'bg-gray-600 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-600'}`}>
{status === 'all' ? '전체' : status === 'active' ? '활성' : '비활성'}
</button>
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredCustomers.map(item => (
<div key={item.id} onClick={() => handleEdit(item)} className="bg-white rounded-xl border border-gray-200 p-6 cursor-pointer hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-bold text-gray-900">{item.name}</h3>
<p className="text-sm text-gray-500">{item.industry}</p>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${getGradeColor(item.grade)}`}>{item.grade}</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-gray-600"><Building className="w-4 h-4" /><span>대표: {item.ceo}</span></div>
<div className="flex items-center gap-2 text-gray-600"><Phone className="w-4 h-4" /><span>{item.contact}</span></div>
<div className="flex items-center gap-2 text-gray-600"><Mail className="w-4 h-4" /><span className="truncate">{item.email}</span></div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex justify-between items-center">
<span className="text-sm text-gray-500">담당: {item.manager}</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${item.status === 'active' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500'}`}>
{item.status === 'active' ? '활성' : '비활성'}
</span>
</div>
</div>
))}
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '고객사 등록' : '고객사 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">회사명 *</label><input type="text" value={formData.name} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="(주)회사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">사업자번호</label><input type="text" value={formData.bizNo} onChange={(e) => setFormData(prev => ({ ...prev, bizNo: e.target.value }))} placeholder="123-45-67890" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">대표자</label><input type="text" value={formData.ceo} onChange={(e) => setFormData(prev => ({ ...prev, ceo: e.target.value }))} placeholder="대표자명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">업종</label><select value={formData.industry} onChange={(e) => setFormData(prev => ({ ...prev, industry: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{industries.map(i => <option key={i} value={i}>{i}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">등급</label><select value={formData.grade} onChange={(e) => setFormData(prev => ({ ...prev, grade: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{grades.map(g => <option key={g} value={g}>{g}</option>)}</select></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">연락처</label><input type="text" value={formData.contact} onChange={(e) => setFormData(prev => ({ ...prev, contact: e.target.value }))} placeholder="02-1234-5678" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">이메일</label><input type="email" value={formData.email} onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))} placeholder="email@company.co.kr" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">주소</label><input type="text" value={formData.address} onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))} placeholder="주소" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">담당자</label><input type="text" value={formData.manager} onChange={(e) => setFormData(prev => ({ ...prev, manager: e.target.value }))} placeholder="담당자명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">담당자 연락처</label><input type="text" value={formData.managerPhone} onChange={(e) => setFormData(prev => ({ ...prev, managerPhone: e.target.value }))} placeholder="010-1234-5678" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="active">활성</option><option value="inactive">비활성</option></select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('customers-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<CustomersManagement />); }
</script>
@endverbatim
@endpush

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,263 @@
@extends('layouts.app')
@section('title', '지출관리')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="expense-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const TrendingDown = createIcon('trending-down');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const DollarSign = createIcon('dollar-sign');
const CreditCard = createIcon('credit-card');
const FileText = createIcon('file-text');
function ExpenseManagement() {
const [expenses, setExpenses] = useState([
{ id: 1, date: '2026-01-21', vendor: 'AWS Korea', description: '클라우드 서버 비용', category: '운영비', amount: 2500000, status: 'paid', paymentMethod: '법인카드', invoiceNo: 'EXP-2026-001', memo: '1월분' },
{ id: 2, date: '2026-01-20', vendor: '(주)우리사무실', description: '사무실 임대료', category: '임대료', amount: 3500000, status: 'paid', paymentMethod: '계좌이체', invoiceNo: 'EXP-2026-002', memo: '1월분' },
{ id: 3, date: '2026-01-19', vendor: '김개발', description: '외주 개발비', category: '외주비', amount: 15000000, status: 'pending', paymentMethod: '계좌이체', invoiceNo: 'EXP-2026-003', memo: '' },
{ id: 4, date: '2026-01-18', vendor: '다나와', description: '개발 장비 구매', category: '장비비', amount: 1890000, status: 'paid', paymentMethod: '법인카드', invoiceNo: 'EXP-2026-004', memo: 'SSD 업그레이드' },
{ id: 5, date: '2026-01-15', vendor: '정규직 5명', description: '급여 지급', category: '인건비', amount: 25000000, status: 'paid', paymentMethod: '계좌이체', invoiceNo: 'EXP-2026-005', memo: '1월 급여' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterCategory, setFilterCategory] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [dateRange, setDateRange] = useState({
start: new Date(new Date().setDate(new Date().getDate() - 30)).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0]
});
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const categories = ['인건비', '운영비', '임대료', '외주비', '장비비', '마케팅비', '복리후생', '소모품비', '기타지출'];
const paymentMethods = ['계좌이체', '법인카드', '현금'];
const initialFormState = {
date: new Date().toISOString().split('T')[0],
vendor: '',
description: '',
category: '운영비',
amount: '',
status: 'pending',
paymentMethod: '계좌이체',
invoiceNo: '',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const filteredExpenses = expenses.filter(item => {
const matchesSearch = item.vendor.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = filterCategory === 'all' || item.category === filterCategory;
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesDate = item.date >= dateRange.start && item.date <= dateRange.end;
return matchesSearch && matchesCategory && matchesStatus && matchesDate;
});
const totalAmount = filteredExpenses.reduce((sum, item) => sum + item.amount, 0);
const paidAmount = filteredExpenses.filter(i => i.status === 'paid').reduce((sum, item) => sum + item.amount, 0);
const pendingAmount = filteredExpenses.filter(i => i.status === 'pending').reduce((sum, item) => sum + item.amount, 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.vendor || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; }
if (modalMode === 'add') {
setExpenses(prev => [{ id: Date.now(), ...formData, amount: parseInt(formData.amount) || 0 }, ...prev]);
} else {
setExpenses(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, amount: parseInt(formData.amount) || 0 } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setExpenses(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['지출관리', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '지급처', '내용', '카테고리', '금액', '상태', '결제방법', '메모'],
...filteredExpenses.map(item => [item.date, item.vendor, item.description, item.category, item.amount, item.status === 'paid' ? '지급완료' : '지급예정', item.paymentMethod, item.memo])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `지출관리_${dateRange.start}_${dateRange.end}.csv`; link.click();
};
const getCategoryColor = (cat) => {
const colors = { '인건비': 'bg-blue-100 text-blue-700', '운영비': 'bg-purple-100 text-purple-700', '임대료': 'bg-amber-100 text-amber-700', '외주비': 'bg-emerald-100 text-emerald-700', '장비비': 'bg-indigo-100 text-indigo-700', '마케팅비': 'bg-pink-100 text-pink-700', '복리후생': 'bg-cyan-100 text-cyan-700', '소모품비': 'bg-orange-100 text-orange-700', '기타지출': 'bg-gray-100 text-gray-700' };
return colors[cat] || 'bg-gray-100 text-gray-700';
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-rose-100 rounded-xl"><TrendingDown className="w-6 h-6 text-rose-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">지출관리</h1><p className="text-sm text-gray-500">Expense Management</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">지출 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 지출</span><DollarSign className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalAmount)}</p>
<p className="text-xs text-gray-400 mt-1">{filteredExpenses.length}</p>
</div>
<div className="bg-white rounded-xl border border-rose-200 p-6 bg-rose-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-rose-700">지급완료</span><CreditCard className="w-5 h-5 text-rose-500" /></div>
<p className="text-2xl font-bold text-rose-600">{formatCurrency(paidAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">지급예정</span><FileText className="w-5 h-5 text-amber-500" /></div>
<p className="text-2xl font-bold text-amber-600">{formatCurrency(pendingAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">지급율</span></div>
<p className="text-2xl font-bold text-gray-900">{totalAmount > 0 ? Math.round((paidAmount / totalAmount) * 100) : 0}%</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="지급처, 내용 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-rose-500" />
</div>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 카테고리</option>{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}</select>
<div className="flex items-center gap-2">
<input type="date" value={dateRange.start} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
<span>~</span>
<input type="date" value={dateRange.end} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
<div className="flex gap-1">
{['all', 'paid', 'pending'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium ${filterStatus === status ? (status === 'paid' ? 'bg-green-600 text-white' : status === 'pending' ? 'bg-yellow-500 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{status === 'all' ? '전체' : status === 'paid' ? '완료' : '예정'}
</button>
))}
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">날짜</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">지급처</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">내용</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">카테고리</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">금액</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredExpenses.length === 0 ? (
<tr><td colSpan="7" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredExpenses.map(item => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
<td className="px-6 py-4 text-sm text-gray-600">{item.date}</td>
<td className="px-6 py-4"><p className="text-sm font-medium text-gray-900">{item.vendor}</p><p className="text-xs text-gray-400">{item.paymentMethod}</p></td>
<td className="px-6 py-4 text-sm text-gray-600">{item.description}</td>
<td className="px-6 py-4"><span className={`px-2 py-1 rounded text-xs font-medium ${getCategoryColor(item.category)}`}>{item.category}</span></td>
<td className="px-6 py-4 text-sm font-bold text-right text-rose-600">{formatCurrency(item.amount)}</td>
<td className="px-6 py-4 text-center"><span className={`px-2 py-1 rounded-full text-xs font-medium ${item.status === 'paid' ? 'bg-rose-100 text-rose-700' : 'bg-amber-100 text-amber-700'}`}>{item.status === 'paid' ? '지급완료' : '지급예정'}</span></td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '지출 등록' : '지출 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">날짜 *</label><input type="date" value={formData.date} onChange={(e) => setFormData(prev => ({ ...prev, date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">카테고리</label><select value={formData.category} onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}</select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">지급처 *</label><input type="text" value={formData.vendor} onChange={(e) => setFormData(prev => ({ ...prev, vendor: e.target.value }))} placeholder="지급처명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">내용</label><input type="text" value={formData.description} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} placeholder="지출 내용" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">금액 *</label><input type="text" value={formatInputCurrency(formData.amount)} onChange={(e) => setFormData(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">결제방법</label><select value={formData.paymentMethod} onChange={(e) => setFormData(prev => ({ ...prev, paymentMethod: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{paymentMethods.map(m => <option key={m} value={m}>{m}</option>)}</select></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="pending">지급예정</option><option value="paid">지급완료</option></select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">증빙번호</label><input type="text" value={formData.invoiceNo} onChange={(e) => setFormData(prev => ({ ...prev, invoiceNo: e.target.value }))} placeholder="EXP-2026-001" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('expense-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<ExpenseManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,259 @@
@extends('layouts.app')
@section('title', '수입관리')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="income-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const TrendingUp = createIcon('trending-up');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const Calendar = createIcon('calendar');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const DollarSign = createIcon('dollar-sign');
const Building = createIcon('building');
const FileText = createIcon('file-text');
function IncomeManagement() {
const [incomes, setIncomes] = useState([
{ id: 1, date: '2026-01-21', customer: '(주)제조산업', description: '개발비 1차', category: '개발비', amount: 80000000, status: 'completed', invoiceNo: 'INV-2026-001', memo: '계약금 50%' },
{ id: 2, date: '2026-01-20', customer: '(주)테크솔루션', description: '월 구독료', category: '구독료', amount: 500000, status: 'completed', invoiceNo: 'INV-2026-002', memo: '1월분' },
{ id: 3, date: '2026-01-19', customer: '(주)스마트팩토리', description: '컨설팅 비용', category: '용역비', amount: 15000000, status: 'pending', invoiceNo: 'INV-2026-003', memo: '' },
{ id: 4, date: '2026-01-18', customer: '(주)디지털제조', description: '잔금', category: '개발비', amount: 65000000, status: 'completed', invoiceNo: 'INV-2026-004', memo: '잔금 50%' },
{ id: 5, date: '2026-01-15', customer: '(주)AI산업', description: '유지보수', category: '유지보수', amount: 3000000, status: 'completed', invoiceNo: 'INV-2026-005', memo: '연간 계약' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterCategory, setFilterCategory] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [dateRange, setDateRange] = useState({
start: new Date(new Date().setDate(new Date().getDate() - 30)).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0]
});
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const categories = ['개발비', '구독료', '용역비', '유지보수', '라이선스', '기타수입'];
const initialFormState = {
date: new Date().toISOString().split('T')[0],
customer: '',
description: '',
category: '개발비',
amount: '',
status: 'pending',
invoiceNo: '',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const filteredIncomes = incomes.filter(item => {
const matchesSearch = item.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = filterCategory === 'all' || item.category === filterCategory;
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesDate = item.date >= dateRange.start && item.date <= dateRange.end;
return matchesSearch && matchesCategory && matchesStatus && matchesDate;
});
const totalAmount = filteredIncomes.reduce((sum, item) => sum + item.amount, 0);
const completedAmount = filteredIncomes.filter(i => i.status === 'completed').reduce((sum, item) => sum + item.amount, 0);
const pendingAmount = filteredIncomes.filter(i => i.status === 'pending').reduce((sum, item) => sum + item.amount, 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.customer || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; }
if (modalMode === 'add') {
setIncomes(prev => [{ id: Date.now(), ...formData, amount: parseInt(formData.amount) || 0 }, ...prev]);
} else {
setIncomes(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, amount: parseInt(formData.amount) || 0 } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setIncomes(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['수입관리', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '거래처', '내용', '카테고리', '금액', '상태', '세금계산서번호', '메모'],
...filteredIncomes.map(item => [item.date, item.customer, item.description, item.category, item.amount, item.status === 'completed' ? '입금완료' : '입금대기', item.invoiceNo, item.memo])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `수입관리_${dateRange.start}_${dateRange.end}.csv`; link.click();
};
const getCategoryColor = (cat) => {
const colors = { '개발비': 'bg-blue-100 text-blue-700', '구독료': 'bg-purple-100 text-purple-700', '용역비': 'bg-emerald-100 text-emerald-700', '유지보수': 'bg-amber-100 text-amber-700', '라이선스': 'bg-pink-100 text-pink-700', '기타수입': 'bg-gray-100 text-gray-700' };
return colors[cat] || 'bg-gray-100 text-gray-700';
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-emerald-100 rounded-xl"><TrendingUp className="w-6 h-6 text-emerald-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">수입관리</h1><p className="text-sm text-gray-500">Income Management</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">수입 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 수입</span><DollarSign className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalAmount)}</p>
<p className="text-xs text-gray-400 mt-1">{filteredIncomes.length}</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6 bg-emerald-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">입금완료</span><TrendingUp className="w-5 h-5 text-emerald-500" /></div>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(completedAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">입금대기</span><FileText className="w-5 h-5 text-amber-500" /></div>
<p className="text-2xl font-bold text-amber-600">{formatCurrency(pendingAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">입금율</span></div>
<p className="text-2xl font-bold text-gray-900">{totalAmount > 0 ? Math.round((completedAmount / totalAmount) * 100) : 0}%</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="거래처, 내용 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500" />
</div>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 카테고리</option>{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}</select>
<div className="flex items-center gap-2">
<input type="date" value={dateRange.start} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
<span>~</span>
<input type="date" value={dateRange.end} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
<div className="flex gap-1">
{['all', 'completed', 'pending'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium ${filterStatus === status ? (status === 'completed' ? 'bg-green-600 text-white' : status === 'pending' ? 'bg-yellow-500 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{status === 'all' ? '전체' : status === 'completed' ? '완료' : '대기'}
</button>
))}
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">날짜</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">거래처</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">내용</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">카테고리</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">금액</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredIncomes.length === 0 ? (
<tr><td colSpan="7" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredIncomes.map(item => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
<td className="px-6 py-4 text-sm text-gray-600">{item.date}</td>
<td className="px-6 py-4"><p className="text-sm font-medium text-gray-900">{item.customer}</p>{item.invoiceNo && <p className="text-xs text-gray-400">{item.invoiceNo}</p>}</td>
<td className="px-6 py-4 text-sm text-gray-600">{item.description}</td>
<td className="px-6 py-4"><span className={`px-2 py-1 rounded text-xs font-medium ${getCategoryColor(item.category)}`}>{item.category}</span></td>
<td className="px-6 py-4 text-sm font-bold text-right text-emerald-600">{formatCurrency(item.amount)}</td>
<td className="px-6 py-4 text-center"><span className={`px-2 py-1 rounded-full text-xs font-medium ${item.status === 'completed' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>{item.status === 'completed' ? '입금완료' : '입금대기'}</span></td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '수입 등록' : '수입 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">날짜 *</label><input type="date" value={formData.date} onChange={(e) => setFormData(prev => ({ ...prev, date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">카테고리</label><select value={formData.category} onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}</select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">거래처 *</label><input type="text" value={formData.customer} onChange={(e) => setFormData(prev => ({ ...prev, customer: e.target.value }))} placeholder="거래처명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">내용</label><input type="text" value={formData.description} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} placeholder="수입 내용" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">금액 *</label><input type="text" value={formatInputCurrency(formData.amount)} onChange={(e) => setFormData(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="pending">입금대기</option><option value="completed">입금완료</option></select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">세금계산서 번호</label><input type="text" value={formData.invoiceNo} onChange={(e) => setFormData(prev => ({ ...prev, invoiceNo: e.target.value }))} placeholder="INV-2026-001" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('income-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<IncomeManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,242 @@
@extends('layouts.app')
@section('title', '일반 거래처')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="partners-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const Building2 = createIcon('building-2');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const Phone = createIcon('phone');
const Mail = createIcon('mail');
const Truck = createIcon('truck');
const Hammer = createIcon('hammer');
function PartnersManagement() {
const [partners, setPartners] = useState([
{ id: 1, name: 'AWS Korea', type: 'vendor', category: '클라우드', bizNo: '111-22-33333', contact: '1544-1234', email: 'support@aws.amazon.com', manager: '김AWS', managerPhone: '', status: 'active', memo: '클라우드 인프라' },
{ id: 2, name: '(주)외주개발', type: 'vendor', category: '외주개발', bizNo: '222-33-44444', contact: '02-5555-6666', email: 'contact@outsource.co.kr', manager: '박개발', managerPhone: '010-5555-6666', status: 'active', memo: '프론트엔드 전문' },
{ id: 3, name: '한국타이어', type: 'vendor', category: '차량관리', bizNo: '333-44-55555', contact: '1588-0000', email: 'service@hankook.com', manager: '', managerPhone: '', status: 'active', memo: '' },
{ id: 4, name: '삼성화재', type: 'vendor', category: '보험', bizNo: '444-55-66666', contact: '1588-5114', email: 'insurance@samsung.com', manager: '이보험', managerPhone: '010-7777-8888', status: 'active', memo: '법인차량 보험' },
{ id: 5, name: '김개발 프리랜서', type: 'freelancer', category: '외주개발', bizNo: '', contact: '', email: 'kim.dev@gmail.com', manager: '김개발', managerPhone: '010-1111-2222', status: 'active', memo: '백엔드 개발자' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all');
const [filterCategory, setFilterCategory] = useState('all');
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const types = [{ value: 'vendor', label: '공급업체' }, { value: 'freelancer', label: '프리랜서' }];
const categories = ['클라우드', '외주개발', '차량관리', '보험', '사무용품', '마케팅', '법률/회계', '기타'];
const initialFormState = {
name: '',
type: 'vendor',
category: '기타',
bizNo: '',
contact: '',
email: '',
manager: '',
managerPhone: '',
status: 'active',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const filteredPartners = partners.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.manager.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = filterType === 'all' || item.type === filterType;
const matchesCategory = filterCategory === 'all' || item.category === filterCategory;
return matchesSearch && matchesType && matchesCategory;
});
const totalPartners = partners.length;
const vendorCount = partners.filter(p => p.type === 'vendor').length;
const freelancerCount = partners.filter(p => p.type === 'freelancer').length;
const activeCount = partners.filter(p => p.status === 'active').length;
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.name) { alert('거래처명을 입력해주세요.'); return; }
if (modalMode === 'add') {
setPartners(prev => [{ id: Date.now(), ...formData }, ...prev]);
} else {
setPartners(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setPartners(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['일반 거래처'], [], ['거래처명', '유형', '분류', '사업자번호', '연락처', '이메일', '담당자', '상태'],
...filteredPartners.map(item => [item.name, item.type === 'vendor' ? '공급업체' : '프리랜서', item.category, item.bizNo, item.contact, item.email, item.manager, item.status === 'active' ? '활성' : '비활성'])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = '거래처목록.csv'; link.click();
};
const getTypeIcon = (type) => type === 'vendor' ? <Truck className="w-4 h-4" /> : <Hammer className="w-4 h-4" />;
const getTypeColor = (type) => type === 'vendor' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700';
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-emerald-100 rounded-xl"><Building2 className="w-6 h-6 text-emerald-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">일반 거래처</h1><p className="text-sm text-gray-500">Partners / Vendors</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">거래처 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 거래처</span><Building2 className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{totalPartners}</p>
</div>
<div className="bg-white rounded-xl border border-blue-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-blue-700">공급업체</span><Truck className="w-5 h-5 text-blue-500" /></div>
<p className="text-2xl font-bold text-blue-600">{vendorCount}</p>
</div>
<div className="bg-white rounded-xl border border-purple-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-purple-700">프리랜서</span><Hammer className="w-5 h-5 text-purple-500" /></div>
<p className="text-2xl font-bold text-purple-600">{freelancerCount}</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">활성</span></div>
<p className="text-2xl font-bold text-emerald-600">{activeCount}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="거래처명, 담당자 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500" />
</div>
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 유형</option>{types.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}</select>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 분류</option>{categories.map(c => <option key={c} value={c}>{c}</option>)}</select>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">거래처명</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">유형</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">분류</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">연락처</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">담당자</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredPartners.length === 0 ? (
<tr><td colSpan="7" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredPartners.map(item => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
<td className="px-6 py-4"><p className="text-sm font-medium text-gray-900">{item.name}</p>{item.bizNo && <p className="text-xs text-gray-400">{item.bizNo}</p>}</td>
<td className="px-6 py-4"><span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium ${getTypeColor(item.type)}`}>{getTypeIcon(item.type)}{item.type === 'vendor' ? '공급업체' : '프리랜서'}</span></td>
<td className="px-6 py-4"><span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">{item.category}</span></td>
<td className="px-6 py-4"><p className="text-sm text-gray-600">{item.contact || item.email}</p></td>
<td className="px-6 py-4"><p className="text-sm text-gray-900">{item.manager}</p>{item.managerPhone && <p className="text-xs text-gray-400">{item.managerPhone}</p>}</td>
<td className="px-6 py-4 text-center"><span className={`px-2 py-1 rounded-full text-xs font-medium ${item.status === 'active' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500'}`}>{item.status === 'active' ? '활성' : '비활성'}</span></td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '거래처 등록' : '거래처 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">거래처명 *</label><input type="text" value={formData.name} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="거래처명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">유형</label><select value={formData.type} onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{types.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">분류</label><select value={formData.category} onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{categories.map(c => <option key={c} value={c}>{c}</option>)}</select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">사업자번호</label><input type="text" value={formData.bizNo} onChange={(e) => setFormData(prev => ({ ...prev, bizNo: e.target.value }))} placeholder="123-45-67890" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">연락처</label><input type="text" value={formData.contact} onChange={(e) => setFormData(prev => ({ ...prev, contact: e.target.value }))} placeholder="02-1234-5678" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">이메일</label><input type="email" value={formData.email} onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))} placeholder="email@company.com" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">담당자</label><input type="text" value={formData.manager} onChange={(e) => setFormData(prev => ({ ...prev, manager: e.target.value }))} placeholder="담당자명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">담당자 연락처</label><input type="text" value={formData.managerPhone} onChange={(e) => setFormData(prev => ({ ...prev, managerPhone: e.target.value }))} placeholder="010-1234-5678" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="active">활성</option><option value="inactive">비활성</option></select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('partners-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<PartnersManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,337 @@
@extends('layouts.app')
@section('title', '미지급금 관리')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="payables-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const CreditCard = createIcon('credit-card');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const Banknote = createIcon('banknote');
const AlertTriangle = createIcon('alert-triangle');
const CheckCircle = createIcon('check-circle');
const Clock = createIcon('clock');
const RefreshCw = createIcon('refresh-cw');
function PayablesManagement() {
const [payables, setPayables] = useState([
{ id: 1, vendorName: '(주)오피스월드', invoiceNo: 'PO-2026-0123', issueDate: '2026-01-10', dueDate: '2026-01-25', amount: 3500000, paidAmount: 0, status: 'unpaid', category: '사무용품', description: '1월 사무용품 구매' },
{ id: 2, vendorName: 'IT솔루션즈', invoiceNo: 'PO-2026-0118', issueDate: '2026-01-05', dueDate: '2026-01-20', amount: 12000000, paidAmount: 6000000, status: 'partial', category: '소프트웨어', description: 'ERP 라이선스' },
{ id: 3, vendorName: '클라우드서비스', invoiceNo: 'PO-2025-0956', issueDate: '2025-12-20', dueDate: '2026-01-05', amount: 8500000, paidAmount: 8500000, status: 'paid', category: '서비스', description: '12월 클라우드 서비스' },
{ id: 4, vendorName: '인테리어프로', invoiceNo: 'PO-2025-0912', issueDate: '2025-12-01', dueDate: '2025-12-20', amount: 25000000, paidAmount: 0, status: 'overdue', category: '시설', description: '사무실 리모델링' },
{ id: 5, vendorName: '보안시스템', invoiceNo: 'PO-2026-0128', issueDate: '2026-01-15', dueDate: '2026-01-30', amount: 4800000, paidAmount: 0, status: 'unpaid', category: '장비', description: '보안 장비 구매' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterCategory, setFilterCategory] = useState('all');
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const [showPayModal, setShowPayModal] = useState(false);
const [payingItem, setPayingItem] = useState(null);
const [payAmount, setPayAmount] = useState('');
const [payDate, setPayDate] = useState('');
const categories = ['사무용품', '소프트웨어', '서비스', '시설', '장비', '외주', '기타'];
const initialFormState = {
vendorName: '',
invoiceNo: '',
issueDate: new Date().toISOString().split('T')[0],
dueDate: '',
amount: '',
paidAmount: 0,
status: 'unpaid',
category: '사무용품',
description: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const calculateOverdueDays = (dueDate) => {
if (!dueDate) return 0;
const today = new Date();
const due = new Date(dueDate);
const diff = Math.floor((today - due) / (1000 * 60 * 60 * 24));
return diff > 0 ? diff : 0;
};
const filteredPayables = payables.filter(item => {
const matchesSearch = item.vendorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.invoiceNo.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesCategory = filterCategory === 'all' || item.category === filterCategory;
return matchesSearch && matchesStatus && matchesCategory;
});
const totalAmount = payables.reduce((sum, item) => sum + item.amount, 0);
const totalPaid = payables.reduce((sum, item) => sum + item.paidAmount, 0);
const totalUnpaid = totalAmount - totalPaid;
const overdueAmount = payables.filter(i => i.status === 'overdue').reduce((sum, item) => sum + (item.amount - item.paidAmount), 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.vendorName || !formData.invoiceNo || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; }
if (modalMode === 'add') {
setPayables(prev => [{ id: Date.now(), ...formData, amount: parseInt(formData.amount) || 0, paidAmount: 0 }, ...prev]);
} else {
setPayables(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, amount: parseInt(formData.amount) || 0 } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setPayables(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handlePay = (item) => {
setPayingItem(item);
setPayAmount('');
setPayDate(new Date().toISOString().split('T')[0]);
setShowPayModal(true);
};
const processPayment = () => {
const amount = parseInt(parseInputCurrency(payAmount)) || 0;
if (amount <= 0) { alert('지급액을 입력해주세요.'); return; }
const remaining = payingItem.amount - payingItem.paidAmount;
if (amount > remaining) { alert('지급액이 잔액을 초과합니다.'); return; }
setPayables(prev => prev.map(item => {
if (item.id === payingItem.id) {
const newPaid = item.paidAmount + amount;
const newStatus = newPaid >= item.amount ? 'paid' : 'partial';
return { ...item, paidAmount: newPaid, status: newStatus };
}
return item;
}));
setShowPayModal(false);
setPayingItem(null);
};
const handleDownload = () => {
const rows = [['미지급금 관리'], [], ['거래처', '청구서번호', '발행일', '만기일', '분류', '청구금액', '지급액', '잔액', '상태'],
...filteredPayables.map(item => [item.vendorName, item.invoiceNo, item.issueDate, item.dueDate, item.category, item.amount, item.paidAmount, item.amount - item.paidAmount, getStatusLabel(item.status)])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `미지급금관리_${new Date().toISOString().split('T')[0]}.csv`; link.click();
};
const getStatusLabel = (status) => {
const labels = { 'unpaid': '미지급', 'partial': '부분지급', 'paid': '지급완료', 'overdue': '연체' };
return labels[status] || status;
};
const getStatusStyle = (status) => {
const styles = {
'unpaid': 'bg-amber-100 text-amber-700',
'partial': 'bg-blue-100 text-blue-700',
'paid': 'bg-emerald-100 text-emerald-700',
'overdue': 'bg-red-100 text-red-700'
};
return styles[status] || 'bg-gray-100 text-gray-700';
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-rose-100 rounded-xl"><CreditCard className="w-6 h-6 text-rose-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">미지급금 관리</h1><p className="text-sm text-gray-500">Accounts Payable</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">미지급금 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 채무액</span><CreditCard className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalAmount)}</p>
<p className="text-xs text-gray-400 mt-1">{payables.length}</p>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-6 bg-amber-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">미지급잔액</span><Clock className="w-5 h-5 text-amber-500" /></div>
<p className="text-2xl font-bold text-amber-600">{formatCurrency(totalUnpaid)}</p>
</div>
<div className="bg-white rounded-xl border border-red-200 p-6 bg-red-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-red-700">연체금액</span><AlertTriangle className="w-5 h-5 text-red-500" /></div>
<p className="text-2xl font-bold text-red-600">{formatCurrency(overdueAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6 bg-emerald-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">지급완료</span><CheckCircle className="w-5 h-5 text-emerald-500" /></div>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(totalPaid)}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="거래처, 청구서번호 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-rose-500" />
</div>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg">
<option value="all">전체 분류</option>
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select>
<div className="flex gap-1">
{['all', 'unpaid', 'partial', 'overdue', 'paid'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-2 py-2 rounded-lg text-xs font-medium ${filterStatus === status ? (status === 'paid' ? 'bg-green-600 text-white' : status === 'overdue' ? 'bg-red-600 text-white' : status === 'partial' ? 'bg-blue-600 text-white' : status === 'unpaid' ? 'bg-yellow-500 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{status === 'all' ? '전체' : getStatusLabel(status)}
</button>
))}
</div>
<button onClick={() => { setSearchTerm(''); setFilterStatus('all'); setFilterCategory('all'); }} className="flex items-center justify-center gap-2 px-3 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
<RefreshCw className="w-4 h-4" /><span className="text-sm">초기화</span>
</button>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">거래처</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">청구서번호</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">만기일</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">분류</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">청구금액</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">잔액</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">연체일</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredPayables.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredPayables.map(item => (
<tr key={item.id} onClick={() => handleEdit(item)} className={`hover:bg-gray-50 cursor-pointer ${item.status === 'overdue' ? 'bg-red-50/50' : ''}`}>
<td className="px-6 py-4"><p className="text-sm font-medium text-gray-900">{item.vendorName}</p><p className="text-xs text-gray-400">{item.description}</p></td>
<td className="px-6 py-4 text-sm text-gray-600">{item.invoiceNo}</td>
<td className="px-6 py-4 text-sm text-gray-600">{item.dueDate}</td>
<td className="px-6 py-4"><span className="px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700">{item.category}</span></td>
<td className="px-6 py-4 text-sm font-medium text-right text-gray-900">{formatCurrency(item.amount)}</td>
<td className="px-6 py-4 text-sm font-bold text-right text-rose-600">{formatCurrency(item.amount - item.paidAmount)}</td>
<td className="px-6 py-4 text-center"><span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusStyle(item.status)}`}>{getStatusLabel(item.status)}</span></td>
<td className="px-6 py-4 text-center text-sm">{item.status === 'overdue' ? <span className="text-red-600 font-medium">{calculateOverdueDays(item.dueDate)}</span> : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '미지급금 등록' : '미지급금 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">거래처 *</label><input type="text" value={formData.vendorName} onChange={(e) => setFormData(prev => ({ ...prev, vendorName: e.target.value }))} placeholder="거래처명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">청구서번호 *</label><input type="text" value={formData.invoiceNo} onChange={(e) => setFormData(prev => ({ ...prev, invoiceNo: e.target.value }))} placeholder="PO-2026-001" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">발행일</label><input type="date" value={formData.issueDate} onChange={(e) => setFormData(prev => ({ ...prev, issueDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">만기일</label><input type="date" value={formData.dueDate} onChange={(e) => setFormData(prev => ({ ...prev, dueDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">분류</label><select value={formData.category} onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">청구금액 *</label><input type="text" value={formatInputCurrency(formData.amount)} onChange={(e) => setFormData(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">적요</label><input type="text" value={formData.description} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} placeholder="적요 입력" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
{modalMode === 'edit' && (
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="unpaid">미지급</option><option value="partial">부분지급</option><option value="paid">지급완료</option><option value="overdue">연체</option></select></div>
)}
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg">삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
{showPayModal && payingItem && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">지급 처리</h3>
<button onClick={() => setShowPayModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<p className="font-medium text-gray-900">{payingItem.vendorName}</p>
<p className="text-sm text-gray-500">{payingItem.invoiceNo}</p>
<div className="mt-3 space-y-1 text-sm">
<div className="flex justify-between"><span className="text-gray-500">청구금액</span><span>{formatCurrency(payingItem.amount)}</span></div>
<div className="flex justify-between"><span className="text-gray-500">기지급액</span><span>{formatCurrency(payingItem.paidAmount)}</span></div>
<div className="flex justify-between font-bold"><span className="text-rose-600">잔액</span><span className="text-rose-600">{formatCurrency(payingItem.amount - payingItem.paidAmount)}</span></div>
</div>
</div>
<div className="space-y-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">지급일</label><input type="date" value={payDate} onChange={(e) => setPayDate(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">지급액 *</label><input type="text" value={formatInputCurrency(payAmount)} onChange={(e) => setPayAmount(parseInputCurrency(e.target.value))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={() => setShowPayModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={processPayment} className="flex-1 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg">지급 처리</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('payables-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<PayablesManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,268 @@
@extends('layouts.app')
@section('title', '매입관리')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="purchase-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const ShoppingCart = createIcon('shopping-cart');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const DollarSign = createIcon('dollar-sign');
const TrendingDown = createIcon('trending-down');
const FileText = createIcon('file-text');
const Building = createIcon('building');
function PurchaseManagement() {
const [purchases, setPurchases] = useState([
{ id: 1, date: '2026-01-20', vendor: 'AWS Korea', item: '클라우드 서비스', category: '운영비', amount: 2500000, vat: 250000, status: 'received', invoiceNo: 'PUR-2026-001', memo: '월정액' },
{ id: 2, date: '2026-01-18', vendor: '(주)오피스', item: '사무용품', category: '소모품', amount: 500000, vat: 50000, status: 'received', invoiceNo: 'PUR-2026-002', memo: '' },
{ id: 3, date: '2026-01-15', vendor: '김개발', item: '외주 개발', category: '외주비', amount: 15000000, vat: 1500000, status: 'pending', invoiceNo: 'PUR-2026-003', memo: '프론트엔드' },
{ id: 4, date: '2026-01-10', vendor: '다나와', item: '노트북', category: '장비', amount: 1890000, vat: 189000, status: 'received', invoiceNo: 'PUR-2026-004', memo: '개발용' },
{ id: 5, date: '2026-01-05', vendor: 'Google', item: 'Workspace 구독', category: '운영비', amount: 300000, vat: 30000, status: 'received', invoiceNo: 'PUR-2026-005', memo: '연간' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterCategory, setFilterCategory] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [dateRange, setDateRange] = useState({
start: new Date(new Date().setMonth(new Date().getMonth() - 3)).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0]
});
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const categories = ['운영비', '외주비', '장비', '소모품', '마케팅', '교육비', '기타'];
const initialFormState = {
date: new Date().toISOString().split('T')[0],
vendor: '',
item: '',
category: '운영비',
amount: '',
vat: '',
status: 'pending',
invoiceNo: '',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const filteredPurchases = purchases.filter(item => {
const matchesSearch = item.vendor.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.item.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = filterCategory === 'all' || item.category === filterCategory;
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesDate = item.date >= dateRange.start && item.date <= dateRange.end;
return matchesSearch && matchesCategory && matchesStatus && matchesDate;
});
const totalAmount = filteredPurchases.reduce((sum, item) => sum + item.amount, 0);
const totalVat = filteredPurchases.reduce((sum, item) => sum + item.vat, 0);
const receivedAmount = filteredPurchases.filter(i => i.status === 'received').reduce((sum, item) => sum + item.amount, 0);
const pendingAmount = filteredPurchases.filter(i => i.status === 'pending').reduce((sum, item) => sum + item.amount, 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.vendor || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; }
const amount = parseInt(formData.amount) || 0;
const vat = parseInt(formData.vat) || Math.round(amount * 0.1);
if (modalMode === 'add') {
setPurchases(prev => [{ id: Date.now(), ...formData, amount, vat }, ...prev]);
} else {
setPurchases(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, amount, vat } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setPurchases(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['매입관리', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '공급자', '품목', '카테고리', '공급가액', 'VAT', '합계', '상태'],
...filteredPurchases.map(item => [item.date, item.vendor, item.item, item.category, item.amount, item.vat, item.amount + item.vat, item.status === 'received' ? '수령완료' : '대기중'])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `매입관리_${dateRange.start}_${dateRange.end}.csv`; link.click();
};
const getCategoryColor = (cat) => {
const colors = { '운영비': 'bg-purple-100 text-purple-700', '외주비': 'bg-emerald-100 text-emerald-700', '장비': 'bg-indigo-100 text-indigo-700', '소모품': 'bg-orange-100 text-orange-700', '마케팅': 'bg-pink-100 text-pink-700', '교육비': 'bg-cyan-100 text-cyan-700', '기타': 'bg-gray-100 text-gray-700' };
return colors[cat] || 'bg-gray-100 text-gray-700';
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-orange-100 rounded-xl"><ShoppingCart className="w-6 h-6 text-orange-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">매입관리</h1><p className="text-sm text-gray-500">Purchase Management</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">매입 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 매입</span><DollarSign className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalAmount)}</p>
<p className="text-xs text-gray-400 mt-1">VAT 별도</p>
</div>
<div className="bg-white rounded-xl border border-orange-200 p-6 bg-orange-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-orange-700">수령완료</span><FileText className="w-5 h-5 text-orange-500" /></div>
<p className="text-2xl font-bold text-orange-600">{formatCurrency(receivedAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">대기중</span><TrendingDown className="w-5 h-5 text-amber-500" /></div>
<p className="text-2xl font-bold text-amber-600">{formatCurrency(pendingAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">VAT 합계</span></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalVat)}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="공급자, 품목 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500" />
</div>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 카테고리</option>{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}</select>
<div className="flex items-center gap-2">
<input type="date" value={dateRange.start} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
<span>~</span>
<input type="date" value={dateRange.end} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
<div className="flex gap-1">
{['all', 'received', 'pending'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium ${filterStatus === status ? (status === 'received' ? 'bg-green-600 text-white' : status === 'pending' ? 'bg-yellow-500 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{status === 'all' ? '전체' : status === 'received' ? '완료' : '대기'}
</button>
))}
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">날짜</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">공급자</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">품목</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">카테고리</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">공급가액</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">합계</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredPurchases.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredPurchases.map(item => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
<td className="px-6 py-4 text-sm text-gray-600">{item.date}</td>
<td className="px-6 py-4"><p className="text-sm font-medium text-gray-900">{item.vendor}</p></td>
<td className="px-6 py-4"><p className="text-sm text-gray-600">{item.item}</p>{item.memo && <p className="text-xs text-gray-400">{item.memo}</p>}</td>
<td className="px-6 py-4"><span className={`px-2 py-1 rounded text-xs font-medium ${getCategoryColor(item.category)}`}>{item.category}</span></td>
<td className="px-6 py-4 text-sm font-medium text-right text-gray-900">{formatCurrency(item.amount)}</td>
<td className="px-6 py-4 text-sm font-bold text-right text-orange-600">{formatCurrency(item.amount + item.vat)}</td>
<td className="px-6 py-4 text-center"><span className={`px-2 py-1 rounded-full text-xs font-medium ${item.status === 'received' ? 'bg-orange-100 text-orange-700' : 'bg-amber-100 text-amber-700'}`}>{item.status === 'received' ? '수령완료' : '대기중'}</span></td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '매입 등록' : '매입 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">날짜 *</label><input type="date" value={formData.date} onChange={(e) => setFormData(prev => ({ ...prev, date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">카테고리</label><select value={formData.category} onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}</select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">공급자 *</label><input type="text" value={formData.vendor} onChange={(e) => setFormData(prev => ({ ...prev, vendor: e.target.value }))} placeholder="공급자명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">품목</label><input type="text" value={formData.item} onChange={(e) => setFormData(prev => ({ ...prev, item: e.target.value }))} placeholder="품목명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">공급가액 *</label><input type="text" value={formatInputCurrency(formData.amount)} onChange={(e) => setFormData(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">VAT</label><input type="text" value={formatInputCurrency(formData.vat)} onChange={(e) => setFormData(prev => ({ ...prev, vat: parseInputCurrency(e.target.value) }))} placeholder="자동계산" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="pending">대기중</option><option value="received">수령완료</option></select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">세금계산서 번호</label><input type="text" value={formData.invoiceNo} onChange={(e) => setFormData(prev => ({ ...prev, invoiceNo: e.target.value }))} placeholder="PUR-2026-001" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('purchase-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<PurchaseManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,337 @@
@extends('layouts.app')
@section('title', '미수금 관리')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="receivables-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const Receipt = createIcon('receipt');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const Banknote = createIcon('banknote');
const AlertTriangle = createIcon('alert-triangle');
const CheckCircle = createIcon('check-circle');
const Clock = createIcon('clock');
const RefreshCw = createIcon('refresh-cw');
function ReceivablesManagement() {
const [receivables, setReceivables] = useState([
{ id: 1, customerName: '(주)한국테크', invoiceNo: 'INV-2026-0051', issueDate: '2026-01-05', dueDate: '2026-01-20', amount: 15000000, collectedAmount: 0, status: 'outstanding', category: '서비스', description: '1월 서비스 이용료' },
{ id: 2, customerName: '글로벌솔루션', invoiceNo: 'INV-2026-0048', issueDate: '2026-01-02', dueDate: '2026-01-17', amount: 8500000, collectedAmount: 5000000, status: 'partial', category: '상품', description: '소프트웨어 라이선스' },
{ id: 3, customerName: '스마트시스템', invoiceNo: 'INV-2025-0892', issueDate: '2025-12-15', dueDate: '2025-12-30', amount: 12000000, collectedAmount: 12000000, status: 'collected', category: '서비스', description: '12월 유지보수' },
{ id: 4, customerName: '테크파워', invoiceNo: 'INV-2025-0875', issueDate: '2025-12-01', dueDate: '2025-12-15', amount: 6500000, collectedAmount: 0, status: 'overdue', category: '서비스', description: '컨설팅 비용' },
{ id: 5, customerName: '디지털웍스', invoiceNo: 'INV-2026-0055', issueDate: '2026-01-10', dueDate: '2026-01-25', amount: 9800000, collectedAmount: 0, status: 'outstanding', category: '상품', description: '장비 판매대금' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterCategory, setFilterCategory] = useState('all');
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const [showCollectModal, setShowCollectModal] = useState(false);
const [collectingItem, setCollectingItem] = useState(null);
const [collectAmount, setCollectAmount] = useState('');
const [collectDate, setCollectDate] = useState('');
const categories = ['서비스', '상품', '컨설팅', '기타'];
const initialFormState = {
customerName: '',
invoiceNo: '',
issueDate: new Date().toISOString().split('T')[0],
dueDate: '',
amount: '',
collectedAmount: 0,
status: 'outstanding',
category: '서비스',
description: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const calculateOverdueDays = (dueDate) => {
if (!dueDate) return 0;
const today = new Date();
const due = new Date(dueDate);
const diff = Math.floor((today - due) / (1000 * 60 * 60 * 24));
return diff > 0 ? diff : 0;
};
const filteredReceivables = receivables.filter(item => {
const matchesSearch = item.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.invoiceNo.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesCategory = filterCategory === 'all' || item.category === filterCategory;
return matchesSearch && matchesStatus && matchesCategory;
});
const totalAmount = receivables.reduce((sum, item) => sum + item.amount, 0);
const totalCollected = receivables.reduce((sum, item) => sum + item.collectedAmount, 0);
const totalOutstanding = totalAmount - totalCollected;
const overdueAmount = receivables.filter(i => i.status === 'overdue').reduce((sum, item) => sum + (item.amount - item.collectedAmount), 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.customerName || !formData.invoiceNo || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; }
if (modalMode === 'add') {
setReceivables(prev => [{ id: Date.now(), ...formData, amount: parseInt(formData.amount) || 0, collectedAmount: 0 }, ...prev]);
} else {
setReceivables(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, amount: parseInt(formData.amount) || 0 } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setReceivables(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleCollect = (item) => {
setCollectingItem(item);
setCollectAmount('');
setCollectDate(new Date().toISOString().split('T')[0]);
setShowCollectModal(true);
};
const processCollection = () => {
const amount = parseInt(parseInputCurrency(collectAmount)) || 0;
if (amount <= 0) { alert('수금액을 입력해주세요.'); return; }
const remaining = collectingItem.amount - collectingItem.collectedAmount;
if (amount > remaining) { alert('수금액이 잔액을 초과합니다.'); return; }
setReceivables(prev => prev.map(item => {
if (item.id === collectingItem.id) {
const newCollected = item.collectedAmount + amount;
const newStatus = newCollected >= item.amount ? 'collected' : 'partial';
return { ...item, collectedAmount: newCollected, status: newStatus };
}
return item;
}));
setShowCollectModal(false);
setCollectingItem(null);
};
const handleDownload = () => {
const rows = [['미수금 관리'], [], ['고객사', '청구서번호', '발행일', '만기일', '분류', '청구금액', '수금액', '잔액', '상태'],
...filteredReceivables.map(item => [item.customerName, item.invoiceNo, item.issueDate, item.dueDate, item.category, item.amount, item.collectedAmount, item.amount - item.collectedAmount, getStatusLabel(item.status)])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `미수금관리_${new Date().toISOString().split('T')[0]}.csv`; link.click();
};
const getStatusLabel = (status) => {
const labels = { 'outstanding': '미수', 'partial': '부분수금', 'collected': '수금완료', 'overdue': '연체' };
return labels[status] || status;
};
const getStatusStyle = (status) => {
const styles = {
'outstanding': 'bg-amber-100 text-amber-700',
'partial': 'bg-blue-100 text-blue-700',
'collected': 'bg-emerald-100 text-emerald-700',
'overdue': 'bg-red-100 text-red-700'
};
return styles[status] || 'bg-gray-100 text-gray-700';
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-blue-100 rounded-xl"><Receipt className="w-6 h-6 text-blue-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">미수금 관리</h1><p className="text-sm text-gray-500">Accounts Receivable</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">미수금 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 채권액</span><Receipt className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalAmount)}</p>
<p className="text-xs text-gray-400 mt-1">{receivables.length}</p>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-6 bg-amber-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">미수잔액</span><Clock className="w-5 h-5 text-amber-500" /></div>
<p className="text-2xl font-bold text-amber-600">{formatCurrency(totalOutstanding)}</p>
</div>
<div className="bg-white rounded-xl border border-red-200 p-6 bg-red-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-red-700">연체금액</span><AlertTriangle className="w-5 h-5 text-red-500" /></div>
<p className="text-2xl font-bold text-red-600">{formatCurrency(overdueAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6 bg-emerald-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">수금완료</span><CheckCircle className="w-5 h-5 text-emerald-500" /></div>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(totalCollected)}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="고객사, 청구서번호 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" />
</div>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg">
<option value="all">전체 분류</option>
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select>
<div className="flex gap-1">
{['all', 'outstanding', 'partial', 'overdue', 'collected'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-2 py-2 rounded-lg text-xs font-medium ${filterStatus === status ? (status === 'collected' ? 'bg-green-600 text-white' : status === 'overdue' ? 'bg-red-600 text-white' : status === 'partial' ? 'bg-blue-600 text-white' : status === 'outstanding' ? 'bg-yellow-500 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{status === 'all' ? '전체' : getStatusLabel(status)}
</button>
))}
</div>
<button onClick={() => { setSearchTerm(''); setFilterStatus('all'); setFilterCategory('all'); }} className="flex items-center justify-center gap-2 px-3 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
<RefreshCw className="w-4 h-4" /><span className="text-sm">초기화</span>
</button>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">고객사</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">청구서번호</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">만기일</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">분류</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">청구금액</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">잔액</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">연체일</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredReceivables.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredReceivables.map(item => (
<tr key={item.id} onClick={() => handleEdit(item)} className={`hover:bg-gray-50 cursor-pointer ${item.status === 'overdue' ? 'bg-red-50/50' : ''}`}>
<td className="px-6 py-4"><p className="text-sm font-medium text-gray-900">{item.customerName}</p><p className="text-xs text-gray-400">{item.description}</p></td>
<td className="px-6 py-4 text-sm text-gray-600">{item.invoiceNo}</td>
<td className="px-6 py-4 text-sm text-gray-600">{item.dueDate}</td>
<td className="px-6 py-4"><span className="px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700">{item.category}</span></td>
<td className="px-6 py-4 text-sm font-medium text-right text-gray-900">{formatCurrency(item.amount)}</td>
<td className="px-6 py-4 text-sm font-bold text-right text-blue-600">{formatCurrency(item.amount - item.collectedAmount)}</td>
<td className="px-6 py-4 text-center"><span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusStyle(item.status)}`}>{getStatusLabel(item.status)}</span></td>
<td className="px-6 py-4 text-center text-sm">{item.status === 'overdue' ? <span className="text-red-600 font-medium">{calculateOverdueDays(item.dueDate)}</span> : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '미수금 등록' : '미수금 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">고객사 *</label><input type="text" value={formData.customerName} onChange={(e) => setFormData(prev => ({ ...prev, customerName: e.target.value }))} placeholder="고객사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">청구서번호 *</label><input type="text" value={formData.invoiceNo} onChange={(e) => setFormData(prev => ({ ...prev, invoiceNo: e.target.value }))} placeholder="INV-2026-001" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">발행일</label><input type="date" value={formData.issueDate} onChange={(e) => setFormData(prev => ({ ...prev, issueDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">만기일</label><input type="date" value={formData.dueDate} onChange={(e) => setFormData(prev => ({ ...prev, dueDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">분류</label><select value={formData.category} onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">청구금액 *</label><input type="text" value={formatInputCurrency(formData.amount)} onChange={(e) => setFormData(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">적요</label><input type="text" value={formData.description} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} placeholder="적요 입력" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
{modalMode === 'edit' && (
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="outstanding">미수</option><option value="partial">부분수금</option><option value="collected">수금완료</option><option value="overdue">연체</option></select></div>
)}
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg">삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
{showCollectModal && collectingItem && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">수금 처리</h3>
<button onClick={() => setShowCollectModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<p className="font-medium text-gray-900">{collectingItem.customerName}</p>
<p className="text-sm text-gray-500">{collectingItem.invoiceNo}</p>
<div className="mt-3 space-y-1 text-sm">
<div className="flex justify-between"><span className="text-gray-500">청구금액</span><span>{formatCurrency(collectingItem.amount)}</span></div>
<div className="flex justify-between"><span className="text-gray-500">기수금액</span><span>{formatCurrency(collectingItem.collectedAmount)}</span></div>
<div className="flex justify-between font-bold"><span className="text-blue-600">잔액</span><span className="text-blue-600">{formatCurrency(collectingItem.amount - collectingItem.collectedAmount)}</span></div>
</div>
</div>
<div className="space-y-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">수금일</label><input type="date" value={collectDate} onChange={(e) => setCollectDate(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">수금액 *</label><input type="text" value={formatInputCurrency(collectAmount)} onChange={(e) => setCollectAmount(parseInputCurrency(e.target.value))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={() => setShowCollectModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={processCollection} className="flex-1 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg">수금 처리</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('receivables-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<ReceivablesManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,347 @@
@extends('layouts.app')
@section('title', '환불/해지 관리')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="refunds-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const RotateCcw = createIcon('rotate-ccw');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const CheckCircle = createIcon('check-circle');
const Clock = createIcon('clock');
const XCircle = createIcon('x-circle');
const RefreshCw = createIcon('refresh-cw');
const PlayCircle = createIcon('play-circle');
function RefundsManagement() {
const [refunds, setRefunds] = useState([
{ id: 1, type: 'refund', customerName: '김철수', requestDate: '2026-01-18', productName: '프리미엄 구독', originalAmount: 99000, refundAmount: 49500, reason: '서비스 불만족', status: 'approved', processDate: '2026-01-19', note: '월정액 50% 환불' },
{ id: 2, type: 'cancel', customerName: '이영희', requestDate: '2026-01-17', productName: '엔터프라이즈 플랜', originalAmount: 500000, refundAmount: 300000, reason: '사업 종료', status: 'completed', processDate: '2026-01-18', note: '잔여 기간 환불' },
{ id: 3, type: 'refund', customerName: '박민수', requestDate: '2026-01-15', productName: '베이직 플랜', originalAmount: 29000, refundAmount: 29000, reason: '결제 오류', status: 'completed', processDate: '2026-01-16', note: '전액 환불' },
{ id: 4, type: 'cancel', customerName: '정수연', requestDate: '2026-01-20', productName: '프로 플랜', originalAmount: 199000, refundAmount: 0, reason: '경쟁사 이전', status: 'pending', processDate: '', note: '' },
{ id: 5, type: 'refund', customerName: '최지훈', requestDate: '2026-01-12', productName: '추가 스토리지', originalAmount: 50000, refundAmount: 0, reason: '중복 결제', status: 'rejected', processDate: '2026-01-14', note: '이미 사용한 서비스' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterType, setFilterType] = useState('all');
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const [showProcessModal, setShowProcessModal] = useState(false);
const [processingItem, setProcessingItem] = useState(null);
const [processAction, setProcessAction] = useState('approved');
const [processRefundAmount, setProcessRefundAmount] = useState('');
const [processNote, setProcessNote] = useState('');
const types = ['refund', 'cancel'];
const reasons = ['서비스 불만족', '결제 오류', '사업 종료', '경쟁사 이전', '중복 결제', '기타'];
const initialFormState = {
type: 'refund',
customerName: '',
requestDate: new Date().toISOString().split('T')[0],
productName: '',
originalAmount: '',
refundAmount: 0,
reason: '서비스 불만족',
status: 'pending',
processDate: '',
note: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const filteredRefunds = refunds.filter(item => {
const matchesSearch = item.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.productName.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesType = filterType === 'all' || item.type === filterType;
return matchesSearch && matchesStatus && matchesType;
});
const pendingCount = refunds.filter(i => i.status === 'pending').length;
const completedCount = refunds.filter(i => i.status === 'completed').length;
const rejectedCount = refunds.filter(i => i.status === 'rejected').length;
const totalRefunded = refunds.filter(i => i.status === 'completed' || i.status === 'approved').reduce((sum, item) => sum + item.refundAmount, 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.customerName || !formData.productName || !formData.originalAmount) { alert('필수 항목을 입력해주세요.'); return; }
if (modalMode === 'add') {
setRefunds(prev => [{ id: Date.now(), ...formData, originalAmount: parseInt(formData.originalAmount) || 0, refundAmount: 0 }, ...prev]);
} else {
setRefunds(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, originalAmount: parseInt(formData.originalAmount) || 0, refundAmount: parseInt(formData.refundAmount) || 0 } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setRefunds(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleProcess = (item) => {
setProcessingItem(item);
setProcessAction('approved');
setProcessRefundAmount(item.originalAmount.toString());
setProcessNote('');
setShowProcessModal(true);
};
const executeProcess = () => {
const refundAmt = parseInt(parseInputCurrency(processRefundAmount)) || 0;
setRefunds(prev => prev.map(item => {
if (item.id === processingItem.id) {
if (processAction === 'rejected') {
return { ...item, status: 'rejected', refundAmount: 0, processDate: new Date().toISOString().split('T')[0], note: processNote };
} else {
return { ...item, status: processAction, refundAmount: refundAmt, processDate: new Date().toISOString().split('T')[0], note: processNote };
}
}
return item;
}));
setShowProcessModal(false);
setProcessingItem(null);
};
const handleDownload = () => {
const rows = [['환불/해지 관리'], [], ['유형', '고객명', '요청일', '상품/서비스', '결제금액', '환불금액', '사유', '상태', '처리일'],
...filteredRefunds.map(item => [getTypeLabel(item.type), item.customerName, item.requestDate, item.productName, item.originalAmount, item.refundAmount, item.reason, getStatusLabel(item.status), item.processDate])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `환불해지관리_${new Date().toISOString().split('T')[0]}.csv`; link.click();
};
const getTypeLabel = (type) => {
const labels = { 'refund': '환불', 'cancel': '해지' };
return labels[type] || type;
};
const getStatusLabel = (status) => {
const labels = { 'pending': '대기', 'approved': '승인', 'completed': '완료', 'rejected': '거절' };
return labels[status] || status;
};
const getStatusStyle = (status) => {
const styles = {
'pending': 'bg-amber-100 text-amber-700',
'approved': 'bg-blue-100 text-blue-700',
'completed': 'bg-emerald-100 text-emerald-700',
'rejected': 'bg-red-100 text-red-700'
};
return styles[status] || 'bg-gray-100 text-gray-700';
};
const getTypeStyle = (type) => {
const styles = {
'refund': 'bg-pink-100 text-pink-700',
'cancel': 'bg-indigo-100 text-indigo-700'
};
return styles[type] || 'bg-gray-100 text-gray-700';
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-pink-100 rounded-xl"><RotateCcw className="w-6 h-6 text-pink-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">환불/해지 관리</h1><p className="text-sm text-gray-500">Refunds & Cancellations</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">요청 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-amber-200 p-6 bg-amber-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">처리 대기</span><Clock className="w-5 h-5 text-amber-500" /></div>
<p className="text-2xl font-bold text-amber-600">{pendingCount}</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6 bg-emerald-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">처리 완료</span><CheckCircle className="w-5 h-5 text-emerald-500" /></div>
<p className="text-2xl font-bold text-emerald-600">{completedCount}</p>
</div>
<div className="bg-white rounded-xl border border-red-200 p-6 bg-red-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-red-700">거절</span><XCircle className="w-5 h-5 text-red-500" /></div>
<p className="text-2xl font-bold text-red-600">{rejectedCount}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">환불 총액</span><RotateCcw className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalRefunded)}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="고객명, 상품명 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-pink-500" />
</div>
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg">
<option value="all">전체 유형</option>
<option value="refund">환불</option>
<option value="cancel">해지</option>
</select>
<div className="flex gap-1">
{['all', 'pending', 'approved', 'completed', 'rejected'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-2 py-2 rounded-lg text-xs font-medium ${filterStatus === status ? (status === 'completed' ? 'bg-green-600 text-white' : status === 'rejected' ? 'bg-red-600 text-white' : status === 'approved' ? 'bg-blue-600 text-white' : status === 'pending' ? 'bg-yellow-500 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{status === 'all' ? '전체' : getStatusLabel(status)}
</button>
))}
</div>
<button onClick={() => { setSearchTerm(''); setFilterStatus('all'); setFilterType('all'); }} className="flex items-center justify-center gap-2 px-3 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
<RefreshCw className="w-4 h-4" /><span className="text-sm">초기화</span>
</button>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">유형</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">고객명</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">상품/서비스</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">요청일</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">사유</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">결제금액</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">환불금액</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredRefunds.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredRefunds.map(item => (
<tr key={item.id} onClick={() => handleEdit(item)} className="hover:bg-gray-50 cursor-pointer">
<td className="px-6 py-4"><span className={`px-2 py-1 rounded text-xs font-medium ${getTypeStyle(item.type)}`}>{getTypeLabel(item.type)}</span></td>
<td className="px-6 py-4 text-sm font-medium text-gray-900">{item.customerName}</td>
<td className="px-6 py-4 text-sm text-gray-600">{item.productName}</td>
<td className="px-6 py-4 text-sm text-gray-600">{item.requestDate}</td>
<td className="px-6 py-4 text-sm text-gray-600">{item.reason}</td>
<td className="px-6 py-4 text-sm font-medium text-right text-gray-900">{formatCurrency(item.originalAmount)}</td>
<td className="px-6 py-4 text-sm font-bold text-right text-pink-600">{item.refundAmount ? formatCurrency(item.refundAmount) + '원' : '-'}</td>
<td className="px-6 py-4 text-center"><span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusStyle(item.status)}`}>{getStatusLabel(item.status)}</span></td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '환불/해지 요청 등록' : '환불/해지 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">유형 *</label><select value={formData.type} onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="refund">환불</option><option value="cancel">해지</option></select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">고객명 *</label><input type="text" value={formData.customerName} onChange={(e) => setFormData(prev => ({ ...prev, customerName: e.target.value }))} placeholder="고객명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">상품/서비스 *</label><input type="text" value={formData.productName} onChange={(e) => setFormData(prev => ({ ...prev, productName: e.target.value }))} placeholder="상품/서비스명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">요청일</label><input type="date" value={formData.requestDate} onChange={(e) => setFormData(prev => ({ ...prev, requestDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">결제금액 *</label><input type="text" value={formatInputCurrency(formData.originalAmount)} onChange={(e) => setFormData(prev => ({ ...prev, originalAmount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">사유</label><select value={formData.reason} onChange={(e) => setFormData(prev => ({ ...prev, reason: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{reasons.map(r => <option key={r} value={r}>{r}</option>)}</select></div>
</div>
{modalMode === 'edit' && (
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">환불금액</label><input type="text" value={formatInputCurrency(formData.refundAmount)} onChange={(e) => setFormData(prev => ({ ...prev, refundAmount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="pending">대기</option><option value="approved">승인</option><option value="completed">완료</option><option value="rejected">거절</option></select></div>
</div>
)}
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg">삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
{showProcessModal && processingItem && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">환불/해지 처리</h3>
<button onClick={() => setShowProcessModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<p className="font-medium text-gray-900">{processingItem.customerName} - {processingItem.productName}</p>
<p className="text-sm text-gray-500">{getTypeLabel(processingItem.type)} 요청 ({processingItem.requestDate})</p>
<div className="mt-3 text-sm">
<span>결제금액: {formatCurrency(processingItem.originalAmount)}</span>
</div>
</div>
<div className="space-y-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">처리 결정</label><select value={processAction} onChange={(e) => setProcessAction(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="approved">승인</option><option value="completed">처리완료</option><option value="rejected">거절</option></select></div>
{processAction !== 'rejected' && (
<div><label className="block text-sm font-medium text-gray-700 mb-1">환불금액</label><input type="text" value={formatInputCurrency(processRefundAmount)} onChange={(e) => setProcessRefundAmount(parseInputCurrency(e.target.value))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
)}
<div><label className="block text-sm font-medium text-gray-700 mb-1">처리 메모</label><textarea value={processNote} onChange={(e) => setProcessNote(e.target.value)} rows="2" className="w-full px-3 py-2 border border-gray-300 rounded-lg"></textarea></div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={() => setShowProcessModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={executeProcess} className={`flex-1 px-4 py-2 text-white rounded-lg ${processAction === 'rejected' ? 'bg-red-600 hover:bg-red-700' : 'bg-emerald-600 hover:bg-emerald-700'}`}>{processAction === 'rejected' ? '거절' : '처리'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('refunds-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<RefundsManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,264 @@
@extends('layouts.app')
@section('title', '영업수수료')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="sales-commission-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const DollarSign = createIcon('dollar-sign');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const Users = createIcon('users');
const Percent = createIcon('percent');
const TrendingUp = createIcon('trending-up');
function SalesCommissionManagement() {
const [commissions, setCommissions] = useState([
{ id: 1, date: '2026-01-21', salesperson: '박영업', customer: '(주)제조산업', project: 'MES 시스템', salesAmount: 160000000, rate: 3, commission: 4800000, status: 'pending', memo: '계약 성사' },
{ id: 2, date: '2026-01-15', salesperson: '김세일', customer: '(주)테크솔루션', project: 'SaaS 구독', salesAmount: 6000000, rate: 5, commission: 300000, status: 'paid', memo: '신규 고객' },
{ id: 3, date: '2026-01-10', salesperson: '박영업', customer: '(주)디지털제조', project: 'ERP 연동', salesAmount: 80000000, rate: 3, commission: 2400000, status: 'paid', memo: '' },
{ id: 4, date: '2026-01-05', salesperson: '이영업', customer: '(주)AI산업', project: '유지보수', salesAmount: 36000000, rate: 2, commission: 720000, status: 'pending', memo: '연장 계약' },
{ id: 5, date: '2025-12-20', salesperson: '김세일', customer: '(주)스마트팩토리', project: '컨설팅', salesAmount: 15000000, rate: 5, commission: 750000, status: 'paid', memo: '' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterPerson, setFilterPerson] = useState('all');
const [dateRange, setDateRange] = useState({
start: new Date(new Date().setMonth(new Date().getMonth() - 3)).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0]
});
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const salespersons = ['박영업', '김세일', '이영업', '최판매'];
const initialFormState = {
date: new Date().toISOString().split('T')[0],
salesperson: '박영업',
customer: '',
project: '',
salesAmount: '',
rate: 3,
commission: '',
status: 'pending',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const filteredCommissions = commissions.filter(item => {
const matchesSearch = item.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.project.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.salesperson.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesPerson = filterPerson === 'all' || item.salesperson === filterPerson;
const matchesDate = item.date >= dateRange.start && item.date <= dateRange.end;
return matchesSearch && matchesStatus && matchesPerson && matchesDate;
});
const totalCommission = filteredCommissions.reduce((sum, item) => sum + item.commission, 0);
const paidCommission = filteredCommissions.filter(i => i.status === 'paid').reduce((sum, item) => sum + item.commission, 0);
const pendingCommission = filteredCommissions.filter(i => i.status === 'pending').reduce((sum, item) => sum + item.commission, 0);
const totalSales = filteredCommissions.reduce((sum, item) => sum + item.salesAmount, 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.customer || !formData.salesAmount) { alert('필수 항목을 입력해주세요.'); return; }
const salesAmount = parseInt(formData.salesAmount) || 0;
const commission = parseInt(formData.commission) || Math.round(salesAmount * (formData.rate / 100));
if (modalMode === 'add') {
setCommissions(prev => [{ id: Date.now(), ...formData, salesAmount, commission }, ...prev]);
} else {
setCommissions(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, salesAmount, commission } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setCommissions(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['영업수수료', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '영업담당', '고객사', '프로젝트', '매출액', '수수료율', '수수료', '상태'],
...filteredCommissions.map(item => [item.date, item.salesperson, item.customer, item.project, item.salesAmount, `${item.rate}%`, item.commission, item.status === 'paid' ? '지급완료' : '지급예정'])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `영업수수료_${dateRange.start}_${dateRange.end}.csv`; link.click();
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-violet-100 rounded-xl"><DollarSign className="w-6 h-6 text-violet-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">영업수수료</h1><p className="text-sm text-gray-500">Sales Commission</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">수수료 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 매출</span><TrendingUp className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalSales)}</p>
</div>
<div className="bg-white rounded-xl border border-violet-200 p-6 bg-violet-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-violet-700"> 수수료</span><DollarSign className="w-5 h-5 text-violet-500" /></div>
<p className="text-2xl font-bold text-violet-600">{formatCurrency(totalCommission)}</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">지급완료</span></div>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(paidCommission)}</p>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">지급예정</span></div>
<p className="text-2xl font-bold text-amber-600">{formatCurrency(pendingCommission)}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="고객사, 프로젝트, 담당자 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500" />
</div>
<select value={filterPerson} onChange={(e) => setFilterPerson(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 담당자</option>{salespersons.map(p => <option key={p} value={p}>{p}</option>)}</select>
<div className="flex items-center gap-2">
<input type="date" value={dateRange.start} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
<span>~</span>
<input type="date" value={dateRange.end} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
<div className="flex gap-1">
{['all', 'paid', 'pending'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium ${filterStatus === status ? (status === 'paid' ? 'bg-green-600 text-white' : status === 'pending' ? 'bg-yellow-500 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{status === 'all' ? '전체' : status === 'paid' ? '완료' : '예정'}
</button>
))}
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">날짜</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">영업담당</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">고객사</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">프로젝트</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">매출액</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">수수료율</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">수수료</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredCommissions.length === 0 ? (
<tr><td colSpan="9" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredCommissions.map(item => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
<td className="px-6 py-4 text-sm text-gray-600">{item.date}</td>
<td className="px-6 py-4"><span className="px-2 py-1 bg-violet-100 text-violet-700 rounded text-xs font-medium">{item.salesperson}</span></td>
<td className="px-6 py-4 text-sm font-medium text-gray-900">{item.customer}</td>
<td className="px-6 py-4 text-sm text-gray-600">{item.project}</td>
<td className="px-6 py-4 text-sm text-right text-gray-900">{formatCurrency(item.salesAmount)}</td>
<td className="px-6 py-4 text-sm text-center text-gray-600">{item.rate}%</td>
<td className="px-6 py-4 text-sm font-bold text-right text-violet-600">{formatCurrency(item.commission)}</td>
<td className="px-6 py-4 text-center"><span className={`px-2 py-1 rounded-full text-xs font-medium ${item.status === 'paid' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>{item.status === 'paid' ? '지급완료' : '지급예정'}</span></td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '수수료 등록' : '수수료 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">날짜 *</label><input type="date" value={formData.date} onChange={(e) => setFormData(prev => ({ ...prev, date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">영업담당</label><select value={formData.salesperson} onChange={(e) => setFormData(prev => ({ ...prev, salesperson: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{salespersons.map(p => <option key={p} value={p}>{p}</option>)}</select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">고객사 *</label><input type="text" value={formData.customer} onChange={(e) => setFormData(prev => ({ ...prev, customer: e.target.value }))} placeholder="고객사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">프로젝트</label><input type="text" value={formData.project} onChange={(e) => setFormData(prev => ({ ...prev, project: e.target.value }))} placeholder="프로젝트명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-3 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">매출액 *</label><input type="text" value={formatInputCurrency(formData.salesAmount)} onChange={(e) => setFormData(prev => ({ ...prev, salesAmount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">수수료율(%)</label><input type="number" value={formData.rate} onChange={(e) => setFormData(prev => ({ ...prev, rate: parseFloat(e.target.value) || 0 }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">수수료</label><input type="text" value={formatInputCurrency(formData.commission)} onChange={(e) => setFormData(prev => ({ ...prev, commission: parseInputCurrency(e.target.value) }))} placeholder="자동계산" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="pending">지급예정</option><option value="paid">지급완료</option></select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('sales-commission-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<SalesCommissionManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,266 @@
@extends('layouts.app')
@section('title', '매출관리')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="sales-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const BarChart3 = createIcon('bar-chart-3');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const DollarSign = createIcon('dollar-sign');
const TrendingUp = createIcon('trending-up');
const FileText = createIcon('file-text');
const Building = createIcon('building');
function SalesManagement() {
const [sales, setSales] = useState([
{ id: 1, date: '2026-01-21', customer: '(주)제조산업', project: 'MES 시스템 개발', type: '프로젝트', amount: 160000000, vat: 16000000, status: 'contracted', invoiceNo: 'SAL-2026-001', memo: '계약완료, 1차 50% 입금' },
{ id: 2, date: '2026-01-15', customer: '(주)테크솔루션', project: 'SaaS 구독', type: '구독', amount: 6000000, vat: 600000, status: 'invoiced', invoiceNo: 'SAL-2026-002', memo: '연간 구독 (월 50만원)' },
{ id: 3, date: '2026-01-10', customer: '(주)스마트팩토리', project: '공정관리 시스템', type: '프로젝트', amount: 100000000, vat: 10000000, status: 'negotiating', invoiceNo: '', memo: '협상 중' },
{ id: 4, date: '2026-01-05', customer: '(주)디지털제조', project: 'ERP 연동', type: '프로젝트', amount: 80000000, vat: 8000000, status: 'completed', invoiceNo: 'SAL-2026-003', memo: '완료' },
{ id: 5, date: '2025-12-20', customer: '(주)AI산업', project: '유지보수 계약', type: '유지보수', amount: 36000000, vat: 3600000, status: 'contracted', invoiceNo: 'SAL-2026-004', memo: '연간 계약' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [dateRange, setDateRange] = useState({
start: new Date(new Date().setMonth(new Date().getMonth() - 3)).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0]
});
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const types = ['프로젝트', '구독', '유지보수', '라이선스', '컨설팅'];
const statuses = [
{ value: 'negotiating', label: '협상중', color: 'bg-blue-100 text-blue-700' },
{ value: 'contracted', label: '계약완료', color: 'bg-emerald-100 text-emerald-700' },
{ value: 'invoiced', label: '청구완료', color: 'bg-purple-100 text-purple-700' },
{ value: 'completed', label: '매출확정', color: 'bg-gray-100 text-gray-700' }
];
const initialFormState = {
date: new Date().toISOString().split('T')[0],
customer: '',
project: '',
type: '프로젝트',
amount: '',
vat: '',
status: 'negotiating',
invoiceNo: '',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const filteredSales = sales.filter(item => {
const matchesSearch = item.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.project.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = filterType === 'all' || item.type === filterType;
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesDate = item.date >= dateRange.start && item.date <= dateRange.end;
return matchesSearch && matchesType && matchesStatus && matchesDate;
});
const totalAmount = filteredSales.reduce((sum, item) => sum + item.amount, 0);
const totalVat = filteredSales.reduce((sum, item) => sum + item.vat, 0);
const confirmedAmount = filteredSales.filter(i => i.status === 'completed').reduce((sum, item) => sum + item.amount, 0);
const contractedAmount = filteredSales.filter(i => i.status === 'contracted' || i.status === 'invoiced').reduce((sum, item) => sum + item.amount, 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.customer || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; }
const amount = parseInt(formData.amount) || 0;
const vat = parseInt(formData.vat) || Math.round(amount * 0.1);
if (modalMode === 'add') {
setSales(prev => [{ id: Date.now(), ...formData, amount, vat }, ...prev]);
} else {
setSales(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, amount, vat } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setSales(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['매출관리', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '거래처', '프로젝트', '유형', '공급가액', 'VAT', '합계', '상태'],
...filteredSales.map(item => [item.date, item.customer, item.project, item.type, item.amount, item.vat, item.amount + item.vat, statuses.find(s => s.value === item.status)?.label])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `매출관리_${dateRange.start}_${dateRange.end}.csv`; link.click();
};
const getStatusColor = (status) => statuses.find(s => s.value === status)?.color || 'bg-gray-100 text-gray-700';
const getStatusLabel = (status) => statuses.find(s => s.value === status)?.label || status;
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-blue-100 rounded-xl"><BarChart3 className="w-6 h-6 text-blue-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">매출관리</h1><p className="text-sm text-gray-500">Sales Management</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">매출 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 매출</span><DollarSign className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalAmount)}</p>
<p className="text-xs text-gray-400 mt-1">VAT 별도</p>
</div>
<div className="bg-white rounded-xl border border-blue-200 p-6 bg-blue-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-blue-700">계약 금액</span><FileText className="w-5 h-5 text-blue-500" /></div>
<p className="text-2xl font-bold text-blue-600">{formatCurrency(contractedAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">매출확정</span><TrendingUp className="w-5 h-5 text-emerald-500" /></div>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(confirmedAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">VAT 합계</span></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalVat)}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="거래처, 프로젝트 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" />
</div>
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 유형</option>{types.map(t => <option key={t} value={t}>{t}</option>)}</select>
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 상태</option>{statuses.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}</select>
<div className="flex items-center gap-2">
<input type="date" value={dateRange.start} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
<span>~</span>
<input type="date" value={dateRange.end} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">날짜</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">거래처</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">프로젝트</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">유형</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">공급가액</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">합계(VAT포함)</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredSales.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredSales.map(item => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
<td className="px-6 py-4 text-sm text-gray-600">{item.date}</td>
<td className="px-6 py-4"><p className="text-sm font-medium text-gray-900">{item.customer}</p></td>
<td className="px-6 py-4"><p className="text-sm text-gray-600">{item.project}</p>{item.memo && <p className="text-xs text-gray-400">{item.memo}</p>}</td>
<td className="px-6 py-4"><span className="px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700">{item.type}</span></td>
<td className="px-6 py-4 text-sm font-medium text-right text-gray-900">{formatCurrency(item.amount)}</td>
<td className="px-6 py-4 text-sm font-bold text-right text-blue-600">{formatCurrency(item.amount + item.vat)}</td>
<td className="px-6 py-4 text-center"><span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(item.status)}`}>{getStatusLabel(item.status)}</span></td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '매출 등록' : '매출 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">날짜 *</label><input type="date" value={formData.date} onChange={(e) => setFormData(prev => ({ ...prev, date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">유형</label><select value={formData.type} onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{types.map(t => <option key={t} value={t}>{t}</option>)}</select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">거래처 *</label><input type="text" value={formData.customer} onChange={(e) => setFormData(prev => ({ ...prev, customer: e.target.value }))} placeholder="거래처명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">프로젝트/상품명</label><input type="text" value={formData.project} onChange={(e) => setFormData(prev => ({ ...prev, project: e.target.value }))} placeholder="프로젝트명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">공급가액 *</label><input type="text" value={formatInputCurrency(formData.amount)} onChange={(e) => setFormData(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">VAT</label><input type="text" value={formatInputCurrency(formData.vat)} onChange={(e) => setFormData(prev => ({ ...prev, vat: parseInputCurrency(e.target.value) }))} placeholder="자동계산" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{statuses.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">세금계산서 번호</label><input type="text" value={formData.invoiceNo} onChange={(e) => setFormData(prev => ({ ...prev, invoiceNo: e.target.value }))} placeholder="SAL-2026-001" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('sales-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<SalesManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,268 @@
@extends('layouts.app')
@section('title', '구독관리')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="subscription-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const RefreshCw = createIcon('refresh-cw');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const DollarSign = createIcon('dollar-sign');
const Calendar = createIcon('calendar');
const CheckCircle = createIcon('check-circle');
const AlertCircle = createIcon('alert-circle');
const Users = createIcon('users');
function SubscriptionManagement() {
const [subscriptions, setSubscriptions] = useState([
{ id: 1, customer: '(주)테크솔루션', plan: 'Enterprise', monthlyFee: 500000, billingCycle: 'monthly', startDate: '2025-06-01', nextBilling: '2026-02-01', status: 'active', users: 50, memo: '' },
{ id: 2, customer: '(주)디지털제조', plan: 'Business', monthlyFee: 300000, billingCycle: 'yearly', startDate: '2025-01-01', nextBilling: '2026-01-01', status: 'active', users: 25, memo: '연간 계약 (10% 할인)' },
{ id: 3, customer: '(주)AI산업', plan: 'Enterprise', monthlyFee: 500000, billingCycle: 'monthly', startDate: '2025-09-01', nextBilling: '2026-02-01', status: 'active', users: 40, memo: '' },
{ id: 4, customer: '(주)스마트공장', plan: 'Starter', monthlyFee: 100000, billingCycle: 'monthly', startDate: '2025-11-01', nextBilling: '2026-02-01', status: 'trial', users: 5, memo: '무료 체험 중' },
{ id: 5, customer: '(주)제조테크', plan: 'Business', monthlyFee: 300000, billingCycle: 'monthly', startDate: '2025-03-01', nextBilling: '2026-02-01', status: 'cancelled', users: 15, memo: '2026-01-31 해지 예정' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterPlan, setFilterPlan] = useState('all');
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const plans = ['Starter', 'Business', 'Enterprise'];
const billingCycles = [{ value: 'monthly', label: '월간' }, { value: 'yearly', label: '연간' }];
const initialFormState = {
customer: '',
plan: 'Business',
monthlyFee: '',
billingCycle: 'monthly',
startDate: new Date().toISOString().split('T')[0],
nextBilling: '',
status: 'active',
users: '',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const filteredSubscriptions = subscriptions.filter(item => {
const matchesSearch = item.customer.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesPlan = filterPlan === 'all' || item.plan === filterPlan;
return matchesSearch && matchesStatus && matchesPlan;
});
const activeSubscriptions = subscriptions.filter(s => s.status === 'active');
const monthlyRecurring = activeSubscriptions.reduce((sum, s) => sum + s.monthlyFee, 0);
const yearlyRecurring = monthlyRecurring * 12;
const totalUsers = activeSubscriptions.reduce((sum, s) => sum + s.users, 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.customer || !formData.monthlyFee) { alert('필수 항목을 입력해주세요.'); return; }
const monthlyFee = parseInt(formData.monthlyFee) || 0;
const users = parseInt(formData.users) || 0;
if (modalMode === 'add') {
setSubscriptions(prev => [{ id: Date.now(), ...formData, monthlyFee, users }, ...prev]);
} else {
setSubscriptions(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, monthlyFee, users } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setSubscriptions(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['구독관리'], [], ['고객사', '플랜', '월 요금', '결제주기', '시작일', '다음결제', '상태', '사용자수'],
...filteredSubscriptions.map(item => [item.customer, item.plan, item.monthlyFee, item.billingCycle === 'monthly' ? '월간' : '연간', item.startDate, item.nextBilling, item.status, item.users])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = '구독관리.csv'; link.click();
};
const getStatusColor = (status) => {
const colors = { active: 'bg-emerald-100 text-emerald-700', trial: 'bg-blue-100 text-blue-700', cancelled: 'bg-rose-100 text-rose-700', paused: 'bg-amber-100 text-amber-700' };
return colors[status] || 'bg-gray-100 text-gray-700';
};
const getStatusLabel = (status) => {
const labels = { active: '활성', trial: '체험', cancelled: '해지', paused: '일시정지' };
return labels[status] || status;
};
const getPlanColor = (plan) => {
const colors = { Starter: 'bg-gray-100 text-gray-700', Business: 'bg-blue-100 text-blue-700', Enterprise: 'bg-purple-100 text-purple-700' };
return colors[plan] || 'bg-gray-100 text-gray-700';
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-teal-100 rounded-xl"><RefreshCw className="w-6 h-6 text-teal-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">구독관리</h1><p className="text-sm text-gray-500">Subscription Management</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">구독 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">활성 구독</span><CheckCircle className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{activeSubscriptions.length}</p>
</div>
<div className="bg-white rounded-xl border border-teal-200 p-6 bg-teal-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-teal-700"> 반복 수익(MRR)</span><DollarSign className="w-5 h-5 text-teal-500" /></div>
<p className="text-2xl font-bold text-teal-600">{formatCurrency(monthlyRecurring)}</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700"> 반복 수익(ARR)</span></div>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(yearlyRecurring)}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 사용자</span><Users className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{totalUsers}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="고객사 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500" />
</div>
<select value={filterPlan} onChange={(e) => setFilterPlan(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 플랜</option>{plans.map(p => <option key={p} value={p}>{p}</option>)}</select>
<div className="flex gap-1">
{['all', 'active', 'trial', 'cancelled'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-2 py-2 rounded-lg text-xs font-medium ${filterStatus === status ? (status === 'active' ? 'bg-green-600 text-white' : status === 'trial' ? 'bg-blue-600 text-white' : status === 'cancelled' ? 'bg-red-600 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{status === 'all' ? '전체' : getStatusLabel(status)}
</button>
))}
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">고객사</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">플랜</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600"> 요금</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">결제주기</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">다음 결제</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">사용자</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredSubscriptions.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredSubscriptions.map(item => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
<td className="px-6 py-4"><p className="text-sm font-medium text-gray-900">{item.customer}</p>{item.memo && <p className="text-xs text-gray-400">{item.memo}</p>}</td>
<td className="px-6 py-4"><span className={`px-2 py-1 rounded text-xs font-medium ${getPlanColor(item.plan)}`}>{item.plan}</span></td>
<td className="px-6 py-4 text-sm font-bold text-right text-teal-600">{formatCurrency(item.monthlyFee)}</td>
<td className="px-6 py-4 text-sm text-center text-gray-600">{item.billingCycle === 'monthly' ? '월간' : '연간'}</td>
<td className="px-6 py-4 text-sm text-center text-gray-600">{item.nextBilling}</td>
<td className="px-6 py-4 text-sm text-center text-gray-600">{item.users}</td>
<td className="px-6 py-4 text-center"><span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(item.status)}`}>{getStatusLabel(item.status)}</span></td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '구독 등록' : '구독 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">고객사 *</label><input type="text" value={formData.customer} onChange={(e) => setFormData(prev => ({ ...prev, customer: e.target.value }))} placeholder="고객사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">플랜</label><select value={formData.plan} onChange={(e) => setFormData(prev => ({ ...prev, plan: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{plans.map(p => <option key={p} value={p}>{p}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1"> 요금 *</label><input type="text" value={formatInputCurrency(formData.monthlyFee)} onChange={(e) => setFormData(prev => ({ ...prev, monthlyFee: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">결제주기</label><select value={formData.billingCycle} onChange={(e) => setFormData(prev => ({ ...prev, billingCycle: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{billingCycles.map(b => <option key={b.value} value={b.value}>{b.label}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">사용자 </label><input type="number" value={formData.users} onChange={(e) => setFormData(prev => ({ ...prev, users: e.target.value }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">시작일</label><input type="date" value={formData.startDate} onChange={(e) => setFormData(prev => ({ ...prev, startDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">다음 결제일</label><input type="date" value={formData.nextBilling} onChange={(e) => setFormData(prev => ({ ...prev, nextBilling: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="active">활성</option><option value="trial">체험</option><option value="paused">일시정지</option><option value="cancelled">해지</option></select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('subscription-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<SubscriptionManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,362 @@
@extends('layouts.app')
@section('title', '부가세 관리')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="vat-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const Calculator = createIcon('calculator');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const FileText = createIcon('file-text');
const TrendingUp = createIcon('trending-up');
const TrendingDown = createIcon('trending-down');
const RefreshCw = createIcon('refresh-cw');
function VatManagement() {
const [vatRecords, setVatRecords] = useState([
{ id: 1, period: '2025-2H', type: 'sales', partnerName: '(주)한국테크', invoiceNo: 'TAX-2025-1234', invoiceDate: '2025-12-15', supplyAmount: 10000000, vatAmount: 1000000, totalAmount: 11000000, status: 'filed' },
{ id: 2, period: '2025-2H', type: 'sales', partnerName: '글로벌솔루션', invoiceNo: 'TAX-2025-1235', invoiceDate: '2025-12-20', supplyAmount: 8500000, vatAmount: 850000, totalAmount: 9350000, status: 'filed' },
{ id: 3, period: '2025-2H', type: 'purchase', partnerName: 'IT솔루션즈', invoiceNo: 'TAX-2025-5001', invoiceDate: '2025-12-10', supplyAmount: 5000000, vatAmount: 500000, totalAmount: 5500000, status: 'filed' },
{ id: 4, period: '2026-1H', type: 'sales', partnerName: '스마트시스템', invoiceNo: 'TAX-2026-0001', invoiceDate: '2026-01-05', supplyAmount: 15000000, vatAmount: 1500000, totalAmount: 16500000, status: 'pending' },
{ id: 5, period: '2026-1H', type: 'purchase', partnerName: '클라우드서비스', invoiceNo: 'TAX-2026-0501', invoiceDate: '2026-01-10', supplyAmount: 3000000, vatAmount: 300000, totalAmount: 3300000, status: 'pending' },
{ id: 6, period: '2026-1H', type: 'sales', partnerName: '디지털웍스', invoiceNo: 'TAX-2026-0002', invoiceDate: '2026-01-15', supplyAmount: 7800000, vatAmount: 780000, totalAmount: 8580000, status: 'pending' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterPeriod, setFilterPeriod] = useState('2026-1H');
const [filterType, setFilterType] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const periods = ['2026-1H', '2025-2H', '2025-1H', '2024-2H'];
const initialFormState = {
period: '2026-1H',
type: 'sales',
partnerName: '',
invoiceNo: '',
invoiceDate: new Date().toISOString().split('T')[0],
supplyAmount: '',
vatAmount: '',
totalAmount: '',
status: 'pending'
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
// 공급가액 변경시 부가세 자동 계산
const handleSupplyAmountChange = (value) => {
const supply = parseInt(parseInputCurrency(value)) || 0;
const vat = Math.floor(supply * 0.1);
const total = supply + vat;
setFormData(prev => ({
...prev,
supplyAmount: value,
vatAmount: vat.toString(),
totalAmount: total.toString()
}));
};
const filteredRecords = vatRecords.filter(item => {
const matchesSearch = item.partnerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.invoiceNo.toLowerCase().includes(searchTerm.toLowerCase());
const matchesPeriod = item.period === filterPeriod;
const matchesType = filterType === 'all' || item.type === filterType;
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
return matchesSearch && matchesPeriod && matchesType && matchesStatus;
});
// 기간별 요약 계산
const periodRecords = vatRecords.filter(r => r.period === filterPeriod);
const salesVat = periodRecords.filter(r => r.type === 'sales').reduce((sum, r) => sum + r.vatAmount, 0);
const purchaseVat = periodRecords.filter(r => r.type === 'purchase').reduce((sum, r) => sum + r.vatAmount, 0);
const salesSupply = periodRecords.filter(r => r.type === 'sales').reduce((sum, r) => sum + r.supplyAmount, 0);
const purchaseSupply = periodRecords.filter(r => r.type === 'purchase').reduce((sum, r) => sum + r.supplyAmount, 0);
const netVat = salesVat - purchaseVat;
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item, supplyAmount: item.supplyAmount.toString(), vatAmount: item.vatAmount.toString(), totalAmount: item.totalAmount.toString() }); setShowModal(true); };
const handleSave = () => {
if (!formData.partnerName || !formData.invoiceNo || !formData.supplyAmount) { alert('필수 항목을 입력해주세요.'); return; }
const newItem = {
...formData,
supplyAmount: parseInt(parseInputCurrency(formData.supplyAmount)) || 0,
vatAmount: parseInt(parseInputCurrency(formData.vatAmount)) || 0,
totalAmount: parseInt(parseInputCurrency(formData.totalAmount)) || 0
};
if (modalMode === 'add') {
setVatRecords(prev => [{ id: Date.now(), ...newItem }, ...prev]);
} else {
setVatRecords(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...newItem } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setVatRecords(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['부가세 관리', getPeriodLabel(filterPeriod)], [], ['구분', '거래처', '세금계산서번호', '발행일', '공급가액', '부가세', '합계', '상태'],
...filteredRecords.map(item => [getTypeLabel(item.type), item.partnerName, item.invoiceNo, item.invoiceDate, item.supplyAmount, item.vatAmount, item.totalAmount, getStatusLabel(item.status)])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `부가세관리_${filterPeriod}.csv`; link.click();
};
const getTypeLabel = (type) => {
const labels = { 'sales': '매출', 'purchase': '매입' };
return labels[type] || type;
};
const getStatusLabel = (status) => {
const labels = { 'pending': '미신고', 'filed': '신고완료', 'paid': '납부완료' };
return labels[status] || status;
};
const getStatusStyle = (status) => {
const styles = {
'pending': 'bg-amber-100 text-amber-700',
'filed': 'bg-blue-100 text-blue-700',
'paid': 'bg-emerald-100 text-emerald-700'
};
return styles[status] || 'bg-gray-100 text-gray-700';
};
const getTypeStyle = (type) => {
const styles = {
'sales': 'bg-emerald-100 text-emerald-700',
'purchase': 'bg-pink-100 text-pink-700'
};
return styles[type] || 'bg-gray-100 text-gray-700';
};
const getPeriodLabel = (period) => {
const [year, half] = period.split('-');
return `${year}년 ${half === '1H' ? '1기' : '2기'}`;
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-indigo-100 rounded-xl"><Calculator className="w-6 h-6 text-indigo-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">부가세 관리</h1><p className="text-sm text-gray-500">VAT Management</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">세금계산서 등록</span></button>
</div>
</div>
</header>
{/* 기간 선택 및 요약 */}
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6">
<div className="flex flex-wrap items-center gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">신고기간</label>
<select value={filterPeriod} onChange={(e) => setFilterPeriod(e.target.value)} className="px-4 py-2 pr-10 border border-gray-300 rounded-lg text-lg font-medium min-w-[200px]">
{periods.map(p => <option key={p} value={p}>{getPeriodLabel(p)}</option>)}
</select>
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-emerald-50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-1"><TrendingUp className="w-4 h-4 text-emerald-600" /><span className="text-sm text-emerald-700">매출세액</span></div>
<p className="text-xl font-bold text-emerald-600">{formatCurrency(salesVat)}</p>
<p className="text-xs text-gray-500">공급가액: {formatCurrency(salesSupply)}</p>
</div>
<div className="bg-pink-50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-1"><TrendingDown className="w-4 h-4 text-pink-600" /><span className="text-sm text-pink-700">매입세액</span></div>
<p className="text-xl font-bold text-pink-600">{formatCurrency(purchaseVat)}</p>
<p className="text-xs text-gray-500">공급가액: {formatCurrency(purchaseSupply)}</p>
</div>
<div className={`rounded-lg p-4 ${netVat >= 0 ? 'bg-amber-50' : 'bg-blue-50'}`}>
<div className="flex items-center gap-2 mb-1"><Calculator className={`w-4 h-4 ${netVat >= 0 ? 'text-amber-600' : 'text-blue-600'}`} /><span className={`text-sm ${netVat >= 0 ? 'text-amber-700' : 'text-blue-700'}`}>{netVat >= 0 ? '납부세액' : '환급세액'}</span></div>
<p className={`text-xl font-bold ${netVat >= 0 ? 'text-amber-600' : 'text-blue-600'}`}>{formatCurrency(Math.abs(netVat))}</p>
</div>
<div className="flex items-center">
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg">
<FileText className="w-4 h-4" /><span className="text-sm">신고서 출력</span>
</button>
</div>
</div>
</div>
</div>
{/* 요약 테이블 */}
<div className="bg-white rounded-xl border border-gray-200 mb-6 overflow-hidden">
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
<h3 className="font-medium text-gray-900">{getPeriodLabel(filterPeriod)} 부가세 요약</h3>
</div>
<table className="w-full">
<thead className="bg-gray-100">
<tr>
<th className="px-6 py-2 text-left text-xs font-semibold text-gray-600">구분</th>
<th className="px-6 py-2 text-right text-xs font-semibold text-gray-600">공급가액</th>
<th className="px-6 py-2 text-right text-xs font-semibold text-gray-600">세액</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-gray-100">
<td className="px-6 py-3 text-sm">매출</td>
<td className="px-6 py-3 text-sm text-right">{formatCurrency(salesSupply)}</td>
<td className="px-6 py-3 text-sm text-right text-emerald-600 font-medium">{formatCurrency(salesVat)}</td>
</tr>
<tr className="border-b border-gray-100">
<td className="px-6 py-3 text-sm">매입</td>
<td className="px-6 py-3 text-sm text-right">{formatCurrency(purchaseSupply)}</td>
<td className="px-6 py-3 text-sm text-right text-pink-600 font-medium">({formatCurrency(purchaseVat)})</td>
</tr>
<tr className="bg-gray-50 font-bold">
<td className="px-6 py-3 text-sm">{netVat >= 0 ? '납부세액' : '환급세액'}</td>
<td className="px-6 py-3 text-sm text-right">-</td>
<td className={`px-6 py-3 text-sm text-right ${netVat >= 0 ? 'text-amber-600' : 'text-blue-600'}`}>{formatCurrency(Math.abs(netVat))}</td>
</tr>
</tbody>
</table>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="거래처, 세금계산서번호 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500" />
</div>
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg">
<option value="all">전체 유형</option>
<option value="sales">매출</option>
<option value="purchase">매입</option>
</select>
<div className="flex gap-1">
{['all', 'pending', 'filed', 'paid'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-2 py-2 rounded-lg text-xs font-medium ${filterStatus === status ? (status === 'paid' ? 'bg-green-600 text-white' : status === 'filed' ? 'bg-blue-600 text-white' : status === 'pending' ? 'bg-yellow-500 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
{status === 'all' ? '전체' : getStatusLabel(status)}
</button>
))}
</div>
<button onClick={() => { setSearchTerm(''); setFilterType('all'); setFilterStatus('all'); }} className="flex items-center justify-center gap-2 px-3 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
<RefreshCw className="w-4 h-4" /><span className="text-sm">초기화</span>
</button>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">구분</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">거래처</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">세금계산서번호</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">발행일</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">공급가액</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">부가세</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">합계</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredRecords.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredRecords.map(item => (
<tr key={item.id} onClick={() => handleEdit(item)} className="hover:bg-gray-50 cursor-pointer">
<td className="px-6 py-4"><span className={`px-2 py-1 rounded text-xs font-medium ${getTypeStyle(item.type)}`}>{getTypeLabel(item.type)}</span></td>
<td className="px-6 py-4 text-sm font-medium text-gray-900">{item.partnerName}</td>
<td className="px-6 py-4 text-sm text-gray-600">{item.invoiceNo}</td>
<td className="px-6 py-4 text-sm text-gray-600">{item.invoiceDate}</td>
<td className="px-6 py-4 text-sm font-medium text-right text-gray-900">{formatCurrency(item.supplyAmount)}</td>
<td className="px-6 py-4 text-sm text-right text-gray-600">{formatCurrency(item.vatAmount)}</td>
<td className="px-6 py-4 text-sm font-bold text-right text-indigo-600">{formatCurrency(item.totalAmount)}</td>
<td className="px-6 py-4 text-center"><span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusStyle(item.status)}`}>{getStatusLabel(item.status)}</span></td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '세금계산서 등록' : '세금계산서 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">신고기간 *</label><select value={formData.period} onChange={(e) => setFormData(prev => ({ ...prev, period: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{periods.map(p => <option key={p} value={p}>{getPeriodLabel(p)}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">구분 *</label><select value={formData.type} onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="sales">매출</option><option value="purchase">매입</option></select></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">거래처 *</label><input type="text" value={formData.partnerName} onChange={(e) => setFormData(prev => ({ ...prev, partnerName: e.target.value }))} placeholder="거래처명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">세금계산서번호 *</label><input type="text" value={formData.invoiceNo} onChange={(e) => setFormData(prev => ({ ...prev, invoiceNo: e.target.value }))} placeholder="TAX-2026-0001" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">발행일</label><input type="date" value={formData.invoiceDate} onChange={(e) => setFormData(prev => ({ ...prev, invoiceDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">공급가액 *</label><input type="text" value={formatInputCurrency(formData.supplyAmount)} onChange={(e) => handleSupplyAmountChange(e.target.value)} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">부가세 (자동계산)</label><input type="text" value={formatInputCurrency(formData.vatAmount)} readOnly className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right bg-gray-50" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">합계금액</label><input type="text" value={formatInputCurrency(formData.totalAmount)} readOnly className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right bg-gray-50" /></div>
</div>
{modalMode === 'edit' && (
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="pending">미신고</option><option value="filed">신고완료</option><option value="paid">납부완료</option></select></div>
)}
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg">삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('vat-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<VatManagement />); }
</script>
@endverbatim
@endpush

View File

@@ -0,0 +1,259 @@
@extends('layouts.app')
@section('title', '차량 유지비')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div id="vehicle-maintenance-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const Wrench = createIcon('wrench');
const Plus = createIcon('plus');
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const DollarSign = createIcon('dollar-sign');
const Fuel = createIcon('fuel');
const Car = createIcon('car');
function VehicleMaintenanceManagement() {
const [maintenances, setMaintenances] = useState([
{ id: 1, date: '2026-01-20', vehicle: '12가 3456 (제네시스 G80)', category: '주유', description: '휘발유', amount: 120000, mileage: 15000, vendor: 'GS칼텍스', memo: '' },
{ id: 2, date: '2026-01-18', vehicle: '34나 5678 (현대 스타렉스)', category: '주유', description: '경유', amount: 95000, mileage: 48000, vendor: 'SK에너지', memo: '' },
{ id: 3, date: '2026-01-15', vehicle: '78라 1234 (포터2)', category: '정비', description: '엔진오일 교환', amount: 150000, mileage: 95000, vendor: '현대오토', memo: '정기 점검' },
{ id: 4, date: '2026-01-10', vehicle: '56다 7890 (기아 레이)', category: '보험', description: '자동차보험 연납', amount: 850000, mileage: 62000, vendor: '삼성화재', memo: '2025.01~2026.01' },
{ id: 5, date: '2026-01-05', vehicle: '12가 3456 (제네시스 G80)', category: '세차', description: '세차 및 실내크리닝', amount: 50000, mileage: 14500, vendor: '카와시', memo: '' },
{ id: 6, date: '2025-12-20', vehicle: '34나 5678 (현대 스타렉스)', category: '정비', description: '타이어 교체', amount: 480000, mileage: 45000, vendor: '한국타이어', memo: '4개 교체' },
]);
const [searchTerm, setSearchTerm] = useState('');
const [filterCategory, setFilterCategory] = useState('all');
const [filterVehicle, setFilterVehicle] = useState('all');
const [dateRange, setDateRange] = useState({
start: new Date(new Date().setMonth(new Date().getMonth() - 3)).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0]
});
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const categories = ['주유', '정비', '보험', '세차', '주차', '통행료', '검사', '기타'];
const vehicles = ['12가 3456 (제네시스 G80)', '34나 5678 (현대 스타렉스)', '56다 7890 (기아 레이)', '78라 1234 (포터2)'];
const initialFormState = {
date: new Date().toISOString().split('T')[0],
vehicle: vehicles[0],
category: '주유',
description: '',
amount: '',
mileage: '',
vendor: '',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString() : '';
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const filteredMaintenances = maintenances.filter(item => {
const matchesSearch = item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.vendor.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = filterCategory === 'all' || item.category === filterCategory;
const matchesVehicle = filterVehicle === 'all' || item.vehicle === filterVehicle;
const matchesDate = item.date >= dateRange.start && item.date <= dateRange.end;
return matchesSearch && matchesCategory && matchesVehicle && matchesDate;
});
const totalAmount = filteredMaintenances.reduce((sum, item) => sum + item.amount, 0);
const fuelAmount = filteredMaintenances.filter(m => m.category === '주유').reduce((sum, item) => sum + item.amount, 0);
const maintenanceAmount = filteredMaintenances.filter(m => m.category === '정비').reduce((sum, item) => sum + item.amount, 0);
const otherAmount = totalAmount - fuelAmount - maintenanceAmount;
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.description || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; }
const amount = parseInt(formData.amount) || 0;
const mileage = parseInt(formData.mileage) || 0;
if (modalMode === 'add') {
setMaintenances(prev => [{ id: Date.now(), ...formData, amount, mileage }, ...prev]);
} else {
setMaintenances(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, amount, mileage } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setMaintenances(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['차량 유지비', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '차량', '구분', '내용', '금액', '주행거리', '업체'],
...filteredMaintenances.map(item => [item.date, item.vehicle, item.category, item.description, item.amount, item.mileage, item.vendor])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `차량유지비_${dateRange.start}_${dateRange.end}.csv`; link.click();
};
const getCategoryColor = (cat) => {
const colors = { '주유': 'bg-amber-100 text-amber-700', '정비': 'bg-blue-100 text-blue-700', '보험': 'bg-purple-100 text-purple-700', '세차': 'bg-cyan-100 text-cyan-700', '주차': 'bg-gray-100 text-gray-700', '통행료': 'bg-emerald-100 text-emerald-700', '검사': 'bg-indigo-100 text-indigo-700', '기타': 'bg-slate-100 text-slate-700' };
return colors[cat] || 'bg-gray-100 text-gray-700';
};
return (
<div className="bg-gray-50 min-h-screen">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-amber-100 rounded-xl"><Wrench className="w-6 h-6 text-amber-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">차량 유지비</h1><p className="text-sm text-gray-500">Vehicle Maintenance</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">비용 등록</span></button>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 유지비</span><DollarSign className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalAmount)}</p>
<p className="text-xs text-gray-400 mt-1">{filteredMaintenances.length}</p>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-6 bg-amber-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">주유비</span><Fuel className="w-5 h-5 text-amber-500" /></div>
<p className="text-2xl font-bold text-amber-600">{formatCurrency(fuelAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-blue-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-blue-700">정비비</span><Wrench className="w-5 h-5 text-blue-500" /></div>
<p className="text-2xl font-bold text-blue-600">{formatCurrency(maintenanceAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">기타</span></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(otherAmount)}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="내용, 업체 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500" />
</div>
<select value={filterVehicle} onChange={(e) => setFilterVehicle(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 차량</option>{vehicles.map(v => <option key={v} value={v}>{v}</option>)}</select>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 구분</option>{categories.map(c => <option key={c} value={c}>{c}</option>)}</select>
<div className="flex items-center gap-2 md:col-span-2">
<input type="date" value={dateRange.start} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
<span>~</span>
<input type="date" value={dateRange.end} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">날짜</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">차량</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">구분</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">내용</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">금액</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">주행거리</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredMaintenances.length === 0 ? (
<tr><td colSpan="7" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredMaintenances.map(item => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
<td className="px-6 py-4 text-sm text-gray-600">{item.date}</td>
<td className="px-6 py-4"><p className="text-sm font-medium text-gray-900">{item.vehicle.split(' (')[0]}</p><p className="text-xs text-gray-400">{item.vehicle.split(' (')[1]?.replace(')', '')}</p></td>
<td className="px-6 py-4"><span className={`px-2 py-1 rounded text-xs font-medium ${getCategoryColor(item.category)}`}>{item.category}</span></td>
<td className="px-6 py-4"><p className="text-sm text-gray-900">{item.description}</p>{item.vendor && <p className="text-xs text-gray-400">{item.vendor}</p>}</td>
<td className="px-6 py-4 text-sm font-bold text-right text-amber-600">{formatCurrency(item.amount)}</td>
<td className="px-6 py-4 text-sm text-right text-gray-600">{formatCurrency(item.mileage)}km</td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '비용 등록' : '비용 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">날짜 *</label><input type="date" value={formData.date} onChange={(e) => setFormData(prev => ({ ...prev, date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">구분</label><select value={formData.category} onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{categories.map(c => <option key={c} value={c}>{c}</option>)}</select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">차량</label><select value={formData.vehicle} onChange={(e) => setFormData(prev => ({ ...prev, vehicle: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{vehicles.map(v => <option key={v} value={v}>{v}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">내용 *</label><input type="text" value={formData.description} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} placeholder="내용" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">금액 *</label><input type="text" value={formatInputCurrency(formData.amount)} onChange={(e) => setFormData(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">주행거리(km)</label><input type="text" value={formatInputCurrency(formData.mileage)} onChange={(e) => setFormData(prev => ({ ...prev, mileage: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">업체</label><input type="text" value={formData.vendor} onChange={(e) => setFormData(prev => ({ ...prev, vendor: e.target.value }))} placeholder="업체명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}
const rootElement = document.getElementById('vehicle-maintenance-root');
if (rootElement) { ReactDOM.createRoot(rootElement).render(<VehicleMaintenanceManagement />); }
</script>
@endverbatim
@endpush