Files
sam-kd/salesmanagement/index.php

2814 lines
178 KiB
PHP
Raw Normal View History

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>영업 관리 시스템 - CodeBridgeExy</title>
<!-- Fonts: Pretendard -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Pretendard', 'sans-serif'],
},
colors: {
background: 'rgb(250, 250, 250)',
primary: {
DEFAULT: '#2563eb', // blue-600
foreground: '#ffffff',
},
},
borderRadius: {
'card': '12px',
}
}
}
}
</script>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Icons: Lucide React (via CDN is tricky, using simple SVG icons or a library wrapper if needed. For now, using text/simple SVGs) -->
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body class="bg-background text-slate-800 antialiased">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// --- Components ---
// 1. Header Component
const Header = ({ companyInfo, onOpenHelp, selectedRole, onRoleChange }) => {
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const profileMenuRef = React.useRef(null);
// 외부 클릭 시 메뉴 닫기
useEffect(() => {
const handleClickOutside = (event) => {
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target)) {
setIsProfileMenuOpen(false);
}
};
if (isProfileMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
// 아이콘 업데이트
setTimeout(() => {
lucide.createIcons();
}, 50);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isProfileMenuOpen]);
if (!companyInfo) return <div className="h-16 bg-white shadow-sm animate-pulse"></div>;
const roles = ['운영자', '영업관리'];
return (
<header className="bg-white border-b border-gray-100 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-slate-900">영업관리</h1>
</div>
<div className="flex items-center gap-4">
<a href="../index.php" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
<i data-lucide="home" className="w-4 h-4"></i>
홈으로
</a>
<button onClick={onOpenHelp} className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
<i data-lucide="help-circle" className="w-4 h-4"></i>
도움말
</button>
<div className="relative" ref={profileMenuRef}>
<button
onClick={() => setIsProfileMenuOpen(!isProfileMenuOpen)}
className="px-3 py-1.5 rounded-md bg-slate-100 hover:bg-slate-200 transition-colors flex items-center gap-2 cursor-pointer text-sm font-medium text-slate-700"
>
<span>{selectedRole}</span>
<i data-lucide="chevron-down" className="w-4 h-4"></i>
</button>
{isProfileMenuOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-slate-200 py-2 z-50">
<div className="px-4 py-2 border-b border-slate-100">
<div className="text-xs text-slate-500 mb-1">역할 선택</div>
<div className="text-sm font-medium text-slate-900">{selectedRole}</div>
</div>
{roles.map((role) => (
<button
key={role}
onClick={() => {
onRoleChange(role);
setIsProfileMenuOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-slate-50 transition-colors flex items-center gap-2 ${
selectedRole === role ? 'bg-blue-50 text-blue-700 font-medium' : 'text-slate-700'
}`}
>
{selectedRole === role && (
<i data-lucide="check" className="w-4 h-4"></i>
)}
{role}
</button>
))}
</div>
)}
</div>
</div>
</div>
</header>
);
};
// Operator View Component
const OperatorView = () => {
const [selectedManager, setSelectedManager] = useState(null);
const [managers, setManagers] = useState([]);
// 영업담당 데이터 생성
useEffect(() => {
const generateManagerData = () => {
const managerNames = ['김철수', '이영희', '박민수', '정수진', '최동욱'];
const getRandomSales = () => Math.floor(Math.random() * 50000000) + 30000000;
const getRandomContracts = () => Math.floor(Math.random() * 15) + 5;
// 랜덤 날짜 생성 (최근 12개월 내)
const getRandomDate = () => {
const now = new Date();
const monthsAgo = Math.floor(Math.random() * 12);
const date = new Date(now.getFullYear(), now.getMonth() - monthsAgo, Math.floor(Math.random() * 28) + 1);
return date.toISOString().split('T')[0];
};
// 계약 생성
const generateContracts = (count) => {
return Array.from({ length: count }, () => ({
id: `contract-${Date.now()}-${Math.random()}`,
customer: `고객사 ${String.fromCharCode(65 + Math.floor(Math.random() * 26))}`,
contractDate: getRandomDate(),
amount: getRandomSales()
}));
};
return managerNames.map((name, idx) => {
// 각 역할별 계약 생성
const directContracts = generateContracts(Math.floor(Math.random() * 5) + 2); // 직접 판매
const managerContracts = generateContracts(Math.floor(Math.random() * 8) + 3); // 1차 하위
const educatorContracts = generateContracts(Math.floor(Math.random() * 5) + 1); // 2차 하위
// 역할 구분 추가
directContracts.forEach(c => c.role = 'direct'); // 직접 판매 20%
managerContracts.forEach(c => c.role = 'manager'); // 관리자 5%
educatorContracts.forEach(c => c.role = 'educator'); // 교육자 3%
const allContracts = [...directContracts, ...managerContracts, ...educatorContracts];
// 역할별 총 매출
const directSales = directContracts.reduce((sum, c) => sum + c.amount, 0);
const managerSales = managerContracts.reduce((sum, c) => sum + c.amount, 0);
const educatorSales = educatorContracts.reduce((sum, c) => sum + c.amount, 0);
const totalSales = directSales + managerSales + educatorSales;
// 역할별 수당
const directCommission = directSales * 0.20;
const managerCommission = managerSales * 0.05;
const educatorCommission = educatorSales * 0.03;
const totalCommission = directCommission + managerCommission + educatorCommission;
// 당월 필터링
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
const currentMonthContracts = allContracts.filter(c => {
const d = new Date(c.contractDate);
return d.getMonth() === currentMonth && d.getFullYear() === currentYear;
});
const monthlyDirectSales = currentMonthContracts.filter(c => c.role === 'direct').reduce((sum, c) => sum + c.amount, 0);
const monthlyManagerSales = currentMonthContracts.filter(c => c.role === 'manager').reduce((sum, c) => sum + c.amount, 0);
const monthlyEducatorSales = currentMonthContracts.filter(c => c.role === 'educator').reduce((sum, c) => sum + c.amount, 0);
const monthlySales = monthlyDirectSales + monthlyManagerSales + monthlyEducatorSales;
const monthlyCommission = (monthlyDirectSales * 0.20) + (monthlyManagerSales * 0.05) + (monthlyEducatorSales * 0.03);
// 지난달 필터링
const lastMonth = currentMonth === 0 ? 11 : currentMonth - 1;
const lastMonthYear = currentMonth === 0 ? currentYear - 1 : currentYear;
const lastMonthContracts = allContracts.filter(c => {
const d = new Date(c.contractDate);
return d.getMonth() === lastMonth && d.getFullYear() === lastMonthYear;
});
const lastMonthDirectSales = lastMonthContracts.filter(c => c.role === 'direct').reduce((sum, c) => sum + c.amount, 0);
const lastMonthManagerSales = lastMonthContracts.filter(c => c.role === 'manager').reduce((sum, c) => sum + c.amount, 0);
const lastMonthEducatorSales = lastMonthContracts.filter(c => c.role === 'educator').reduce((sum, c) => sum + c.amount, 0);
const lastMonthSales = lastMonthDirectSales + lastMonthManagerSales + lastMonthEducatorSales;
const lastMonthCommission = (lastMonthDirectSales * 0.20) + (lastMonthManagerSales * 0.05) + (lastMonthEducatorSales * 0.03);
return {
id: idx + 1,
name: name,
contractCount: allContracts.length,
monthlyContractCount: currentMonthContracts.length,
lastMonthContractCount: lastMonthContracts.length,
totalSales,
directSales,
managerSales,
educatorSales,
totalCommission,
directCommission,
managerCommission,
educatorCommission,
monthlySales,
monthlyCommission,
lastMonthSales,
lastMonthCommission,
contracts: allContracts
};
});
};
setManagers(generateManagerData());
}, []);
// 통계 계산
const totalStats = managers.reduce((acc, manager) => ({
contractCount: acc.contractCount + manager.contractCount,
monthlyContractCount: acc.monthlyContractCount + manager.monthlyContractCount,
lastMonthContractCount: acc.lastMonthContractCount + manager.lastMonthContractCount,
totalSales: acc.totalSales + manager.totalSales,
totalCommission: acc.totalCommission + manager.totalCommission,
monthlySales: acc.monthlySales + manager.monthlySales,
monthlyCommission: acc.monthlyCommission + manager.monthlyCommission,
lastMonthSales: acc.lastMonthSales + manager.lastMonthSales,
lastMonthCommission: acc.lastMonthCommission + manager.lastMonthCommission
}), {
contractCount: 0,
monthlyContractCount: 0,
lastMonthContractCount: 0,
totalSales: 0,
totalCommission: 0,
monthlySales: 0,
monthlyCommission: 0,
lastMonthSales: 0,
lastMonthCommission: 0
});
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
useEffect(() => {
setTimeout(() => {
lucide.createIcons();
}, 100);
}, [selectedManager, managers]);
return (
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
<h2 className="text-2xl font-bold text-slate-900 mb-6">영업담당 관리</h2>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-6 mb-8">
<div
className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedManager(null)}
>
<div className="flex items-start justify-between mb-4">
<h3 className="text-sm font-medium text-slate-500"> 건수</h3>
<div className="p-2 bg-blue-50 rounded-lg text-blue-600">
<i data-lucide="file-check" className="w-5 h-5"></i>
</div>
</div>
<div className="text-xl font-bold text-slate-900 mb-1 break-words">{totalStats.contractCount}</div>
<div className="text-xs text-slate-400">전체 계약 건수</div>
</div>
<div
className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedManager(null)}
>
<div className="flex items-start justify-between mb-4">
<h3 className="text-sm font-medium text-slate-500">이번달 건수</h3>
<div className="p-2 bg-indigo-50 rounded-lg text-indigo-600">
<i data-lucide="calendar" className="w-5 h-5"></i>
</div>
</div>
<div className="text-xl font-bold text-slate-900 mb-1 break-words">{totalStats.monthlyContractCount}</div>
<div className="text-xs text-slate-400">이번달 신규 계약</div>
</div>
<div
className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedManager(null)}
>
<div className="flex items-start justify-between mb-4">
<h3 className="text-sm font-medium text-slate-500"> 가입비</h3>
<div className="p-2 bg-green-50 rounded-lg text-green-600">
<i data-lucide="trending-up" className="w-5 h-5"></i>
</div>
</div>
<div className="text-lg font-bold text-slate-900 mb-1 break-words leading-tight">{formatCurrency(totalStats.totalSales)}</div>
<div className="text-xs text-slate-400">전체 누적 가입비</div>
</div>
<div
className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedManager(null)}
>
<div className="flex items-start justify-between mb-4">
<h3 className="text-sm font-medium text-slate-500"> 수당 지급</h3>
<div className="p-2 bg-purple-50 rounded-lg text-purple-600">
<i data-lucide="wallet" className="w-5 h-5"></i>
</div>
</div>
<div className="text-lg font-bold text-slate-900 mb-1 break-words leading-tight">{formatCurrency(totalStats.totalCommission)}</div>
<div className="text-xs text-slate-400">전체 누적 수당</div>
</div>
<div
className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedManager(null)}
>
<div className="flex items-start justify-between mb-4">
<h3 className="text-sm font-medium text-slate-500">이번달 수당</h3>
<div className="p-2 bg-teal-50 rounded-lg text-teal-600">
<i data-lucide="credit-card" className="w-5 h-5"></i>
</div>
</div>
<div className="text-lg font-bold text-slate-900 mb-1 break-words leading-tight">{formatCurrency(totalStats.monthlyCommission)}</div>
<div className="text-xs text-slate-400">이번달 지급 예정</div>
</div>
<div
className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedManager(null)}
>
<div className="flex items-start justify-between mb-4">
<h3 className="text-sm font-medium text-slate-500">지난달 수당</h3>
<div className="p-2 bg-orange-50 rounded-lg text-orange-600">
<i data-lucide="calendar-check" className="w-5 h-5"></i>
</div>
</div>
<div className="text-lg font-bold text-slate-900 mb-1 break-words leading-tight">{formatCurrency(totalStats.lastMonthCommission)}</div>
<div className="text-xs text-slate-400">지난달 지급 완료</div>
</div>
</div>
{/* 영업담당 리스트 또는 세부 리스트 */}
{selectedManager ? (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-slate-900">{selectedManager.name} 세부 수당 리스트</h3>
<button
onClick={() => setSelectedManager(null)}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 font-medium transition-colors flex items-center gap-2"
>
<i data-lucide="arrow-left" className="w-4 h-4"></i>
목록으로
</button>
</div>
<div className="space-y-6">
{/* 역할별 수당 요약 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg p-4 border border-green-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-green-900">직접 판매</span>
<span className="text-xs font-bold text-green-700 bg-green-100 px-2 py-1 rounded">20%</span>
</div>
<div className="text-lg font-bold text-green-900">{formatCurrency(selectedManager.directCommission)}</div>
<div className="text-xs text-green-600 mt-1">{formatCurrency(selectedManager.directSales)} × 20%</div>
</div>
<div className="bg-gradient-to-br from-purple-50 to-violet-50 rounded-lg p-4 border border-purple-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-purple-900">관리자 수당</span>
<span className="text-xs font-bold text-purple-700 bg-purple-100 px-2 py-1 rounded">5%</span>
</div>
<div className="text-lg font-bold text-purple-900">{formatCurrency(selectedManager.managerCommission)}</div>
<div className="text-xs text-purple-600 mt-1">{formatCurrency(selectedManager.managerSales)} × 5%</div>
</div>
<div className="bg-gradient-to-br from-orange-50 to-amber-50 rounded-lg p-4 border border-orange-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-orange-900">교육자 수당</span>
<span className="text-xs font-bold text-orange-700 bg-orange-100 px-2 py-1 rounded">3%</span>
</div>
<div className="text-lg font-bold text-orange-900">{formatCurrency(selectedManager.educatorCommission)}</div>
<div className="text-xs text-orange-600 mt-1">{formatCurrency(selectedManager.educatorSales)} × 3%</div>
</div>
</div>
{/* 계약 목록 */}
<div className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-slate-600">
<thead className="bg-slate-50 text-xs uppercase font-medium text-slate-500">
<tr>
<th className="px-6 py-4">번호</th>
<th className="px-6 py-4">고객사</th>
<th className="px-6 py-4">계약일</th>
<th className="px-6 py-4">역할</th>
<th className="px-6 py-4 text-right">가입비</th>
<th className="px-6 py-4 text-right">수당</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{selectedManager.contracts && selectedManager.contracts.map((contract, idx) => {
const getRoleName = (role) => {
if (role === 'direct') return '직접 판매';
if (role === 'manager') return '관리자';
if (role === 'educator') return '교육자';
return '-';
};
const getRoleColor = (role) => {
if (role === 'direct') return 'bg-green-100 text-green-800';
if (role === 'manager') return 'bg-purple-100 text-purple-800';
if (role === 'educator') return 'bg-orange-100 text-orange-800';
return 'bg-slate-100 text-slate-800';
};
const getCommissionRate = (role) => {
if (role === 'direct') return 0.20;
if (role === 'manager') return 0.05;
if (role === 'educator') return 0.03;
return 0;
};
const commission = contract.amount * getCommissionRate(contract.role);
return (
<tr key={contract.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4 text-slate-900">{idx + 1}</td>
<td className="px-6 py-4 font-medium text-slate-900">{contract.customer}</td>
<td className="px-6 py-4 text-slate-700">{contract.contractDate}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleColor(contract.role)}`}>
{getRoleName(contract.role)} ({getCommissionRate(contract.role) * 100}%)
</span>
</td>
<td className="px-6 py-4 text-right font-medium text-slate-900">{formatCurrency(contract.amount)}</td>
<td className="px-6 py-4 text-right font-bold text-blue-600">{formatCurrency(commission)}</td>
</tr>
);
})}
</tbody>
<tfoot className="bg-slate-50 border-t-2 border-slate-300">
<tr>
<td colSpan="4" className="px-6 py-4 text-sm font-bold text-slate-900">합계</td>
<td className="px-6 py-4 text-right text-sm font-bold text-slate-900">{formatCurrency(selectedManager.totalSales)}</td>
<td className="px-6 py-4 text-right text-sm font-bold text-blue-900">{formatCurrency(selectedManager.totalCommission)}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
) : (
<div>
<h3 className="text-xl font-bold text-slate-900 mb-4">영업담당 목록</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{managers.map((manager) => (
<div
key={manager.id}
onClick={() => setSelectedManager(manager)}
className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow cursor-pointer"
>
<div className="flex items-center justify-between mb-4">
<h4 className="text-lg font-bold text-slate-900">{manager.name}</h4>
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-700">
<i data-lucide="user" className="w-5 h-5"></i>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-500"> 건수</span>
<span className="font-medium text-slate-900">{manager.contractCount}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">이번달 건수</span>
<span className="font-medium text-indigo-700">{manager.monthlyContractCount}</span>
</div>
<div className="flex justify-between text-sm pb-2 border-b border-slate-200">
<span className="text-slate-500"> 가입비</span>
<span className="font-medium text-slate-900">{formatCurrency(manager.totalSales)}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-green-600"> 직접 판매 (20%)</span>
<span className="font-medium text-green-700">{formatCurrency(manager.directCommission)}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-purple-600"> 관리자 (5%)</span>
<span className="font-medium text-purple-700">{formatCurrency(manager.managerCommission)}</span>
</div>
<div className="flex justify-between text-xs pb-2 border-b border-slate-200">
<span className="text-orange-600"> 교육자 (3%)</span>
<span className="font-medium text-orange-700">{formatCurrency(manager.educatorCommission)}</span>
</div>
<div className="flex justify-between text-sm font-bold">
<span className="text-slate-900"> 수당</span>
<span className="text-blue-700">{formatCurrency(manager.totalCommission)}</span>
</div>
<div className="flex justify-between text-sm border-t border-slate-200 pt-2">
<span className="text-slate-500">이번달 수당</span>
<span className="font-medium text-green-700">{formatCurrency(manager.monthlyCommission)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">지난달 수당</span>
<span className="font-medium text-orange-700">{formatCurrency(manager.lastMonthCommission)}</span>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-100">
<div className="text-xs text-slate-400 flex items-center gap-1">
<i data-lucide="chevron-right" className="w-3 h-3"></i>
클릭하여 세부 내역 보기
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 아이템 설정 카드 (운영자 전용) */}
<div className="mt-12 pt-8 border-t border-slate-200">
<h3 className="text-xl font-bold text-slate-900 mb-6 flex items-center gap-2">
<i data-lucide="settings" className="w-5 h-5 text-blue-600"></i>
아이템 가격 설정
</h3>
<ItemPricingManager />
</div>
</main>
);
};
// 아이템 가격 관리 컴포넌트 (운영자 전용)
const ItemPricingManager = () => {
const [pricingItems, setPricingItems] = useState([]);
const [loading, setLoading] = useState(true);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState(null);
useEffect(() => {
fetchPricingItems();
}, []);
const fetchPricingItems = async () => {
try {
setLoading(true);
const response = await fetch('api/package_pricing.php?action=list');
const result = await response.json();
if (result.success) {
setPricingItems(result.data);
}
} catch (error) {
console.error('가격 정보 로드 실패:', error);
} finally {
setLoading(false);
}
};
const handleEditItem = (item) => {
setEditingItem({ ...item });
setEditModalOpen(true);
};
const handleSaveItem = async () => {
if (!editingItem) return;
try {
const response = await fetch('api/package_pricing.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_type: editingItem.item_type,
item_id: editingItem.item_id,
join_fee: editingItem.join_fee,
subscription_fee: editingItem.subscription_fee,
total_amount: editingItem.total_amount,
allow_flexible_pricing: editingItem.allow_flexible_pricing ? 1 : 0
})
});
const result = await response.json();
if (result.success) {
await fetchPricingItems();
// 모달 닫기
setEditModalOpen(false);
setTimeout(() => {
setEditingItem(null);
}, 100);
} else {
alert('저장에 실패했습니다: ' + (result.error || '알 수 없는 오류'));
}
} catch (error) {
console.error('저장 실패:', error);
alert('저장 중 오류가 발생했습니다.');
}
};
const handleCloseModal = () => {
// 모달 닫기 - 상태 변경만
setEditModalOpen(false);
// editingItem은 약간의 딜레이 후 정리
setTimeout(() => {
setEditingItem(null);
}, 100);
};
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val || 0);
useEffect(() => {
const timer = setTimeout(() => {
lucide.createIcons();
}, 100);
return () => clearTimeout(timer);
}, [pricingItems]);
useEffect(() => {
if (editModalOpen) {
const timer = setTimeout(() => {
lucide.createIcons();
}, 200);
return () => clearTimeout(timer);
}
}, [editModalOpen]);
if (loading) {
return (
<div className="text-center py-8 text-slate-500">
<i data-lucide="loader" className="w-6 h-6 animate-spin mx-auto mb-2"></i>
로딩 ...
</div>
);
}
// 모델과 패키지 분리
const models = pricingItems.filter(item => item.item_type === 'model');
const packages = pricingItems.filter(item => item.item_type === 'package');
return (
<div className="space-y-6">
{/* 모델 카드 그리드 */}
{models.length > 0 && (
<div>
<h4 className="text-lg font-semibold text-slate-700 mb-4">선택모델</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{models.map(item => (
<div key={`${item.item_type}_${item.item_id}`} className="bg-white rounded-lg p-5 shadow-sm border border-slate-200 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h5 className="font-semibold text-slate-900">{item.item_name}</h5>
{item.sub_name && (
<p className="text-xs text-slate-500 mt-1">{item.sub_name}</p>
)}
</div>
<button
onClick={() => handleEditItem(item)}
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="설정"
>
<i data-lucide="edit" className="w-4 h-4"></i>
</button>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-600">총액:</span>
<span className="font-semibold text-blue-600">
{item.total_amount ? formatCurrency(item.total_amount) : '미설정'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">가입비:</span>
<span className="text-slate-900">{formatCurrency(item.join_fee)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600"> 구독료:</span>
<span className="text-slate-900">{formatCurrency(item.subscription_fee)}</span>
</div>
<div className="pt-2 border-t border-slate-100">
<div className="flex items-center justify-between">
<span className="text-slate-600">재량권 허용:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
item.allow_flexible_pricing
? 'bg-green-100 text-green-800'
: 'bg-slate-100 text-slate-600'
}`}>
{item.allow_flexible_pricing ? '허용' : '불가'}
</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 패키지 카드 그리드 */}
{packages.length > 0 && (
<div>
<h4 className="text-lg font-semibold text-slate-700 mb-4">패키지</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{packages.map(item => (
<div key={`${item.item_type}_${item.item_id}`} className="bg-white rounded-lg p-5 shadow-sm border border-slate-200 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h5 className="font-semibold text-slate-900">{item.item_name}</h5>
{item.sub_name && (
<p className="text-xs text-slate-500 mt-1">{item.sub_name}</p>
)}
</div>
<button
onClick={() => handleEditItem(item)}
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="설정"
>
<i data-lucide="edit" className="w-4 h-4"></i>
</button>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-600">총액:</span>
<span className="font-semibold text-blue-600">
{item.total_amount ? formatCurrency(item.total_amount) : '미설정'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">가입비:</span>
<span className="text-slate-900">{formatCurrency(item.join_fee)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600"> 구독료:</span>
<span className="text-slate-900">{formatCurrency(item.subscription_fee)}</span>
</div>
<div className="pt-2 border-t border-slate-100">
<div className="flex items-center justify-between">
<span className="text-slate-600">재량권 허용:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
item.allow_flexible_pricing
? 'bg-green-100 text-green-800'
: 'bg-slate-100 text-slate-600'
}`}>
{item.allow_flexible_pricing ? '허용' : '불가'}
</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 편집 모달 */}
{editModalOpen && editingItem && (
<div key={`edit-${editingItem.item_type}-${editingItem.item_id}`} className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={handleCloseModal}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50">
<h3 className="text-xl font-bold text-slate-900 flex items-center gap-2">
<i data-lucide="settings" className="w-5 h-5 text-blue-600"></i>
아이템 가격 설정
</h3>
<button onClick={handleCloseModal} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">항목명</label>
<div className="text-base font-semibold text-slate-900">{editingItem.item_name}</div>
{editingItem.sub_name && (
<div className="text-sm text-slate-500 mt-1">{editingItem.sub_name}</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
총액 () <span className="text-xs text-slate-500">- 영업담당이 금액을 기준으로 가입비/구독료를 조정할 있습니다</span>
</label>
<input
type="text"
value={editingItem.total_amount ? editingItem.total_amount.toLocaleString('ko-KR') : ''}
onChange={(e) => {
const value = e.target.value.replace(/,/g, '');
const numValue = value === '' ? null : parseFloat(value);
if (!isNaN(numValue) || value === '') {
setEditingItem(prev => ({
...prev,
total_amount: numValue
}));
}
}}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="총액을 입력하세요"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">현재 가입비 ()</label>
<input
type="text"
value={(editingItem.join_fee || 0).toLocaleString('ko-KR')}
onChange={(e) => {
const value = e.target.value.replace(/,/g, '');
const numValue = value === '' ? 0 : parseFloat(value);
if (!isNaN(numValue) || value === '') {
setEditingItem(prev => ({
...prev,
join_fee: numValue
}));
}
}}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="가입비를 입력하세요"
/>
<p className="text-xs text-slate-500 mt-1">운영자가 설정한 기본 가입비 (영업담당이 재량권으로 조정 가능)</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">현재 구독료 ()</label>
<input
type="text"
value={(editingItem.subscription_fee || 0).toLocaleString('ko-KR')}
onChange={(e) => {
const value = e.target.value.replace(/,/g, '');
const numValue = value === '' ? 0 : parseFloat(value);
if (!isNaN(numValue) || value === '') {
setEditingItem(prev => ({
...prev,
subscription_fee: numValue
}));
}
}}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="월 구독료를 입력하세요"
/>
<p className="text-xs text-slate-500 mt-1">운영자가 설정한 기본 구독료 (영업담당이 재량권으로 조정 가능)</p>
</div>
<div className="pt-4 border-t border-slate-200">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={editingItem.allow_flexible_pricing || false}
onChange={(e) => setEditingItem(prev => ({
...prev,
allow_flexible_pricing: e.target.checked
}))}
className="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-slate-700">영업담당 재량권 허용</span>
<p className="text-xs text-slate-500 mt-1">
체크 영업담당이 총액 범위 내에서 가입비와 구독료를 자유롭게 조정할 있습니다.
</p>
</div>
</label>
</div>
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
<button
onClick={handleCloseModal}
className="px-4 py-2 text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 font-medium transition-colors"
>
취소
</button>
<button
onClick={handleSaveItem}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors shadow-sm"
>
저장
</button>
</div>
</div>
</div>
)}
</div>
);
};
// ... (StatCard component remains same) ...
const StatCard = ({ title, value, subtext, icon }) => (
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-4">
<h3 className="text-sm font-medium text-slate-500">{title}</h3>
<div className="p-2 bg-blue-50 rounded-lg text-blue-600">
{icon}
</div>
</div>
<div className="text-2xl font-bold text-slate-900 mb-1">{value}</div>
{subtext && <div className="text-xs text-slate-400">{subtext}</div>}
</div>
);
// 3. Main App Component
const App = () => {
const [loading, setLoading] = useState(true);
const [data, setData] = useState(null);
const [selectedRecord, setSelectedRecord] = useState(null);
const [isHelpOpen, setIsHelpOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState('영업관리'); // 기본값: 영업관리
const [organizationData, setOrganizationData] = useState(null); // 조직 데이터
useEffect(() => {
// Fetch Mock Data with role parameter
fetch(`api/company_info.php?role=${encodeURIComponent(selectedRole)}`)
.then(res => res.json())
.then(jsonData => {
setData(jsonData);
setLoading(false);
})
.catch(err => {
console.error("Failed to fetch data:", err);
setLoading(false);
});
// 조직 데이터 생성 (영업관리일 때만)
if (selectedRole === '영업관리') {
generateOrganizationData();
}
}, [selectedRole]);
// 조직 데이터 생성 함수
const generateOrganizationData = () => {
const names = ['김철수', '이영희', '박민수', '정수진', '최동욱', '강미영', '윤재현', '임하늘', '송지영', '한도윤'];
const getRandomName = () => names[Math.floor(Math.random() * names.length)] + Math.floor(Math.random() * 100);
const getRandomSales = () => Math.floor(Math.random() * 50000000) + 10000000;
const getRandomContracts = () => Math.floor(Math.random() * 20) + 1;
// 랜덤 날짜 생성 (최근 12개월 내)
const getRandomDate = () => {
const now = new Date();
const monthsAgo = Math.floor(Math.random() * 12);
const date = new Date(now.getFullYear(), now.getMonth() - monthsAgo, Math.floor(Math.random() * 28) + 1);
return date.toISOString().split('T')[0];
};
// 여러 계약 건 생성
const generateContracts = (count) => {
return Array.from({ length: count }, () => ({
id: `contract-${Date.now()}-${Math.random()}`,
contractDate: getRandomDate(),
amount: getRandomSales()
}));
};
// Depth 2 (2차 하위 영업관리) - 직접판매 항목 없음
const generateLevel2 = (count) => {
return Array.from({ length: count }, (_, i) => {
const contractsData = generateContracts(getRandomContracts());
const totalSales = contractsData.reduce((sum, c) => sum + c.amount, 0);
const contractCount = contractsData.length;
// 내 조직(Depth 0) 입장에서 Depth 2는 교육자 수당 3%
const commission = totalSales * 0.03;
return {
id: `l2-${Date.now()}-${i}`,
name: getRandomName(),
depth: 2,
role: '영업관리',
totalSales,
contractCount,
commission,
contracts: contractsData,
children: []
};
});
};
// Depth 1 (직속 하위 영업관리) - 직접판매 항목 없음
const generateLevel1 = (count) => {
return Array.from({ length: count }, (_, i) => {
const hasLevel2 = Math.random() > 0.4; // 60% 확률로 2차 하위를 가짐
const level2Children = hasLevel2 ? generateLevel2(Math.floor(Math.random() * 3) + 1) : [];
const ownContractsData = generateContracts(getRandomContracts());
const ownSales = ownContractsData.reduce((sum, c) => sum + c.amount, 0);
const ownContracts = ownContractsData.length;
const level2Sales = level2Children.reduce((sum, c) => sum + c.totalSales, 0);
const totalSales = ownSales + level2Sales;
const contractCount = ownContracts + level2Children.reduce((sum, c) => sum + c.contractCount, 0);
// 내 조직(Depth 0) 입장에서 Depth 1은 관리자 수당 5%
const commission = totalSales * 0.05;
return {
id: `l1-${Date.now()}-${i}`,
name: getRandomName(),
depth: 1,
role: '영업관리',
totalSales,
contractCount,
commission,
contracts: ownContractsData,
children: level2Children
};
});
};
// Root (내 조직)
const level1Children = generateLevel1(Math.floor(Math.random() * 3) + 2);
const ownContractsData = generateContracts(getRandomContracts());
const ownSales = ownContractsData.reduce((sum, c) => sum + c.amount, 0);
const ownContracts = ownContractsData.length;
// 내 직접 판매 항목 (오직 내 조직만 가짐)
const myDirectSales = {
id: 'root-direct',
name: '내 직접 판매',
depth: 0,
role: '직접 판매',
isDirect: true,
totalSales: ownSales,
contractCount: ownContracts,
commission: ownSales * 0.20,
contracts: ownContractsData,
children: []
};
const allChildren = [myDirectSales, ...level1Children];
// 1차 하위의 직접 판매 (관리자 수당 5%)
const level1DirectSales = level1Children.reduce((sum, c) => sum + c.totalSales, 0);
// 2차 하위의 판매 (교육자 수당 3%)
const level2Sales = level1Children.reduce((sum, c) =>
c.children.reduce((s, gc) => s + gc.totalSales, 0), 0);
const totalSales = ownSales + level1DirectSales;
const contractCount = ownContracts + level1Children.reduce((sum, c) => sum + c.contractCount, 0);
// 내 조직의 총 수당: 직접 판매 20% + 1차 하위 5% + 2차 하위 3%
const commission = (ownSales * 0.20) + (level1DirectSales * 0.05) + (level2Sales * 0.03);
const orgData = {
id: 'root',
name: '내 조직',
depth: 0,
role: '영업관리자',
totalSales,
contractCount,
commission,
contracts: ownContractsData,
children: allChildren
};
setOrganizationData(orgData);
};
// 역할 변경 시 아이콘 업데이트 (조건부 return 전에 호출해야 함)
useEffect(() => {
if (!loading && data) {
setTimeout(() => {
lucide.createIcons();
}, 100);
}
}, [selectedRole, loading, data]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (!data) return <div>데이터를 불러올 없습니다.</div>;
// 역할에 따른 화면 렌더링
const renderContentByRole = () => {
switch (selectedRole) {
case '운영자':
return <OperatorView />;
case '영업관리':
// 조직 데이터 기반 통계 계산
const calculateOrgStats = (orgData) => {
if (!orgData) return {
totalRevenue: 0,
totalCommission: 0,
totalCount: 0,
sellerCommission: 0,
managerCommission: 0,
educatorCommission: 0
};
// 직접 판매 (20%)
const myDirectSales = orgData.children.find(c => c.isDirect);
const sellerCommission = myDirectSales ? myDirectSales.totalSales * 0.20 : 0;
// 1차 하위 (5%)
const level1Children = orgData.children.filter(c => !c.isDirect && c.depth === 1);
const level1Sales = level1Children.reduce((sum, c) => sum + c.totalSales, 0);
const managerCommission = level1Sales * 0.05;
// 2차 하위 (3%)
const level2Sales = level1Children.reduce((sum, c) =>
c.children.reduce((s, gc) => s + gc.totalSales, 0), 0);
const educatorCommission = level2Sales * 0.03;
return {
totalRevenue: orgData.totalSales,
totalCommission: orgData.commission,
totalCount: orgData.contractCount,
sellerCommission,
managerCommission,
educatorCommission
};
};
const stats = calculateOrgStats(organizationData);
const totalRevenue = stats.totalRevenue;
const totalCommission = stats.totalCommission;
const totalCount = stats.totalCount;
const totalSellerCommission = stats.sellerCommission;
const totalManagerCommission = stats.managerCommission;
const totalEducatorCommission = stats.educatorCommission;
const commissionRate = totalRevenue > 0 ? ((totalCommission / totalRevenue) * 100).toFixed(1) : 0;
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
return (
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{/* 수당 지급 일정 안내 */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<i data-lucide="info" className="w-5 h-5 text-blue-600 mt-0.5"></i>
<div>
<h3 className="text-sm font-bold text-blue-900 mb-2">수당 지급 일정 안내</h3>
<p className="text-sm text-blue-800"> 가입비 수당은 가입비 완료 지급됩니다.</p>
</div>
</div>
</div>
{/* 전체 누적 통계 및 조직 트리 */}
<SalesManagementDashboard organizationData={organizationData} onRefresh={generateOrganizationData} />
{/* Simulator Section */}
<SimulatorSection salesConfig={data.sales_config} selectedRole={selectedRole} />
</main>
);
default:
return (
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
<div className="text-center py-20">
<h2 className="text-2xl font-bold text-slate-900 mb-4"> 없는 역할</h2>
</div>
</main>
);
}
};
return (
<div className="min-h-screen pb-20">
<Header
companyInfo={data.company_info}
onOpenHelp={() => setIsHelpOpen(true)}
selectedRole={selectedRole}
onRoleChange={setSelectedRole}
/>
{renderContentByRole()}
{/* Detail Modal */}
{selectedRecord && (
<CommissionDetailModal
record={selectedRecord}
programs={data.sales_config.programs}
onClose={() => setSelectedRecord(null)}
/>
)}
{/* Help Modal */}
{isHelpOpen && (
<HelpModal onClose={() => setIsHelpOpen(false)} />
)}
</div>
);
};
// ... (SimulatorSection, SalesList, CommissionDetailModal remain same) ...
// 6. Sales Management Dashboard Component
const SalesManagementDashboard = ({ organizationData, onRefresh }) => {
const [periodType, setPeriodType] = useState('current_month'); // current_month, custom
const [startYear, setStartYear] = useState(new Date().getFullYear());
const [startMonth, setStartMonth] = useState(new Date().getMonth() + 1);
const [endYear, setEndYear] = useState(new Date().getFullYear());
const [endMonth, setEndMonth] = useState(new Date().getMonth() + 1);
const [periodOrgData, setPeriodOrgData] = useState(null);
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
// 전체 누적 통계
const calculateTotalStats = (orgData) => {
if (!orgData) return { totalRevenue: 0, totalCommission: 0, totalCount: 0, commissionRate: 0 };
return {
totalRevenue: orgData.totalSales,
totalCommission: orgData.commission,
totalCount: orgData.contractCount,
commissionRate: orgData.totalSales > 0 ? ((orgData.commission / orgData.totalSales) * 100).toFixed(1) : 0
};
};
// 기간별 통계
const calculatePeriodStats = (orgData) => {
if (!orgData) return {
sellerCommission: 0,
managerCommission: 0,
educatorCommission: 0,
totalCommission: 0,
totalRevenue: 0,
commissionRate: 0
};
const myDirectSales = orgData.children.find(c => c.isDirect);
const sellerCommission = myDirectSales ? myDirectSales.totalSales * 0.20 : 0;
const level1Children = orgData.children.filter(c => !c.isDirect && c.depth === 1);
const level1Sales = level1Children.reduce((sum, c) => sum + c.totalSales, 0);
const managerCommission = level1Sales * 0.05;
const level2Sales = level1Children.reduce((sum, c) =>
c.children.reduce((s, gc) => s + gc.totalSales, 0), 0);
const educatorCommission = level2Sales * 0.03;
const totalCommission = sellerCommission + managerCommission + educatorCommission;
return {
sellerCommission,
managerCommission,
educatorCommission,
totalCommission,
totalRevenue: orgData.totalSales,
commissionRate: orgData.totalSales > 0 ? ((totalCommission / orgData.totalSales) * 100).toFixed(1) : 0
};
};
// 기간별 데이터 필터링
useEffect(() => {
if (!organizationData) return;
let startDate, endDate;
if (periodType === 'current_month') {
// 당월
const now = new Date();
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0);
} else {
// 커스텀 기간
startDate = new Date(startYear, startMonth - 1, 1);
endDate = new Date(endYear, endMonth, 0);
}
// 날짜 범위로 계약 필터링
const filterNodeByDate = (node) => {
const filteredContracts = (node.contracts || []).filter(contract => {
const contractDate = new Date(contract.contractDate);
return contractDate >= startDate && contractDate <= endDate;
});
const filteredChildren = node.children.map(child => filterNodeByDate(child)).filter(c => c !== null);
// 자신의 계약과 하위 계약 합산
const ownSales = filteredContracts.reduce((sum, c) => sum + c.amount, 0);
const childrenSales = filteredChildren.reduce((sum, c) => sum + c.totalSales, 0);
const totalSales = ownSales + childrenSales;
const contractCount = filteredContracts.length + filteredChildren.reduce((sum, c) => sum + c.contractCount, 0);
// 데이터가 없으면 null 반환
if (totalSales === 0 && filteredChildren.length === 0) {
return null;
}
// 수당 재계산
let commission = 0;
if (node.isDirect) {
// 직접 판매
if (node.depth === 0) commission = ownSales * 0.20;
else if (node.depth === 1) commission = ownSales * 0.05;
else if (node.depth === 2) commission = ownSales * 0.03;
} else {
// 영업관리
if (node.depth === 0) {
// 내 조직: 직접 20% + 1차 하위 5% + 2차 하위 3%
const myDirect = filteredChildren.find(c => c.isDirect);
const level1 = filteredChildren.filter(c => !c.isDirect && c.depth === 1);
const level1Sales = level1.reduce((sum, c) => sum + c.totalSales, 0);
const level2Sales = level1.reduce((sum, c) =>
c.children.reduce((s, gc) => s + gc.totalSales, 0), 0);
commission = (myDirect ? myDirect.totalSales * 0.20 : 0) + (level1Sales * 0.05) + (level2Sales * 0.03);
} else if (node.depth === 1) {
commission = totalSales * 0.05;
} else if (node.depth === 2) {
commission = totalSales * 0.03;
}
}
return {
...node,
totalSales,
contractCount,
commission,
contracts: filteredContracts,
children: filteredChildren
};
};
const filtered = filterNodeByDate(organizationData);
setPeriodOrgData(filtered);
}, [periodType, startYear, startMonth, endYear, endMonth, organizationData]);
const totalStats = calculateTotalStats(organizationData);
const periodStats = calculatePeriodStats(periodOrgData);
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
const months = Array.from({ length: 12 }, (_, i) => i + 1);
useEffect(() => {
setTimeout(() => {
lucide.createIcons();
}, 100);
}, [periodType]);
return (
<>
{/* 전체 누적 통계 */}
<section>
<h2 className="text-xl font-bold text-slate-900 mb-6">전체 누적 실적</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<StatCard
title="총 가입비"
value={formatCurrency(totalStats.totalRevenue)}
subtext="전체 누적 가입비"
icon={<i data-lucide="trending-up" className="w-5 h-5"></i>}
/>
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-card p-6 shadow-sm border border-blue-200 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-4">
<h3 className="text-sm font-medium text-blue-700"> 수당</h3>
<div className="p-2 bg-blue-100 rounded-lg text-blue-600">
<i data-lucide="wallet" className="w-5 h-5"></i>
</div>
</div>
<div className="text-2xl font-bold text-blue-900 mb-1">{formatCurrency(totalStats.totalCommission)}</div>
<div className="text-xs text-blue-600 font-medium"> 가입비의 {totalStats.commissionRate}%</div>
</div>
<StatCard
title="전체 건수"
value={`${totalStats.totalCount}건`}
subtext="전체 계약 건수"
icon={<i data-lucide="file-check" className="w-5 h-5"></i>}
/>
</div>
</section>
{/* 기간 선택 UI */}
<section className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
<h2 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
<i data-lucide="calendar-range" className="w-5 h-5 text-blue-600"></i>
기간별 조회
</h2>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => setPeriodType('current_month')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
periodType === 'current_month'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
당월
</button>
<button
onClick={() => setPeriodType('custom')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
periodType === 'custom'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
기간 설정
</button>
</div>
{periodType === 'custom' && (
<div className="flex items-center gap-3">
<select
value={startYear}
onChange={(e) => setStartYear(Number(e.target.value))}
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<select
value={startMonth}
onChange={(e) => setStartMonth(Number(e.target.value))}
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{months.map(m => <option key={m} value={m}>{m}</option>)}
</select>
<span className="text-slate-500">~</span>
<select
value={endYear}
onChange={(e) => setEndYear(Number(e.target.value))}
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<select
value={endMonth}
onChange={(e) => setEndMonth(Number(e.target.value))}
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{months.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
)}
<div className="text-sm text-slate-500">
{periodType === 'current_month'
? `${new Date().getFullYear()}년 ${new Date().getMonth() + 1}월`
: `${startYear}년 ${startMonth}월 ~ ${endYear}년 ${endMonth}월`}
</div>
</div>
</section>
{/* 기간별 역할별 수당 상세 */}
<section className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
<h3 className="text-lg font-bold text-slate-900 mb-4 flex items-center gap-2">
<i data-lucide="layers" className="w-5 h-5 text-blue-600"></i>
역할별 수당 상세
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border border-green-200">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<i data-lucide="user" className="w-4 h-4 text-green-600"></i>
</div>
<span className="text-sm font-medium text-green-900">판매자 수당</span>
</div>
<span className="text-xs font-bold text-green-700 bg-green-100 px-2 py-1 rounded">20%</span>
</div>
<div className="text-2xl font-bold text-green-900">{formatCurrency(periodStats.sellerCommission)}</div>
<div className="text-xs text-green-600 mt-1">직접 판매 수당</div>
</div>
<div className="p-4 bg-gradient-to-br from-purple-50 to-violet-50 rounded-lg border border-purple-200">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-purple-100 flex items-center justify-center">
<i data-lucide="users" className="w-4 h-4 text-purple-600"></i>
</div>
<span className="text-sm font-medium text-purple-900">관리자 수당</span>
</div>
<span className="text-xs font-bold text-purple-700 bg-purple-100 px-2 py-1 rounded">5%</span>
</div>
<div className="text-2xl font-bold text-purple-900">{formatCurrency(periodStats.managerCommission)}</div>
<div className="text-xs text-purple-600 mt-1">1 하위 관리 수당</div>
</div>
<div className="p-4 bg-gradient-to-br from-orange-50 to-amber-50 rounded-lg border border-orange-200">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-orange-100 flex items-center justify-center">
<i data-lucide="graduation-cap" className="w-4 h-4 text-orange-600"></i>
</div>
<span className="text-sm font-medium text-orange-900">교육자 수당</span>
</div>
<span className="text-xs font-bold text-orange-700 bg-orange-100 px-2 py-1 rounded">3%</span>
</div>
<div className="text-2xl font-bold text-orange-900">{formatCurrency(periodStats.educatorCommission)}</div>
<div className="text-xs text-orange-600 mt-1">2 하위 교육 수당</div>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<i data-lucide="info" className="w-4 h-4 text-blue-600"></i>
<span className="text-sm text-blue-800">
<strong>판매자</strong> 직접 판매, <strong>관리자</strong> 하위 1단계, <strong>교육자</strong> 하위 2단계 실적에서 수당 지급
</span>
</div>
<div className="text-right">
<div className="text-sm font-bold text-blue-900">{formatCurrency(periodStats.totalCommission)}</div>
<div className="text-xs text-blue-600"> 가입비의 {periodStats.commissionRate}%</div>
</div>
</div>
</div>
</section>
{/* 기간별 조직 트리 */}
<OrganizationTree organizationData={periodOrgData} onRefresh={onRefresh} showPeriodData={true} />
</>
);
};
// 7. Hierarchical Organization Tree Component
const OrganizationTree = ({ organizationData, onRefresh, showPeriodData }) => {
const [expandedNodes, setExpandedNodes] = useState(new Set(['root']));
const [selectedManager, setSelectedManager] = useState(null);
const toggleNode = (nodeId) => {
const newExpanded = new Set(expandedNodes);
if (newExpanded.has(nodeId)) {
newExpanded.delete(nodeId);
} else {
newExpanded.add(nodeId);
}
setExpandedNodes(newExpanded);
};
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
const getDepthColor = (depth, isDirect) => {
if (isDirect) return 'bg-yellow-50 border-yellow-200'; // 직접 판매는 노란색
switch(depth) {
case 0: return 'bg-blue-50 border-blue-200';
case 1: return 'bg-green-50 border-green-200';
case 2: return 'bg-purple-50 border-purple-200';
default: return 'bg-slate-50 border-slate-200';
}
};
const getDepthIcon = (depth, isDirect) => {
if (isDirect) return 'shopping-cart'; // 직접 판매는 쇼핑카트 아이콘
switch(depth) {
case 0: return 'crown';
case 1: return 'user-check';
case 2: return 'users';
default: return 'user';
}
};
const renderNode = (node, depth = 0) => {
const isExpanded = expandedNodes.has(node.id);
const hasChildren = node.children && node.children.length > 0;
const paddingLeft = depth * 24;
const isDirect = node.isDirect || false;
return (
<div key={node.id}>
<div
className={`p-4 border ${getDepthColor(node.depth, isDirect)} rounded-lg hover:shadow-md transition-all cursor-pointer mb-2`}
style={{ marginLeft: `${paddingLeft}px` }}
onClick={() => {
if (hasChildren && !isDirect) toggleNode(node.id);
if (!isDirect) setSelectedManager(node);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
{hasChildren && !isDirect && (
<button
className="p-1 hover:bg-white/50 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
toggleNode(node.id);
}}
>
<i data-lucide={isExpanded ? 'chevron-down' : 'chevron-right'} className="w-4 h-4"></i>
</button>
)}
{(!hasChildren || isDirect) && <div className="w-6"></div>}
<div className={`w-10 h-10 rounded-full ${isDirect ? 'bg-yellow-100' : 'bg-white/80'} flex items-center justify-center`}>
<i data-lucide={getDepthIcon(node.depth, isDirect)} className="w-5 h-5 text-slate-600"></i>
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className={`font-bold ${isDirect ? 'text-orange-900' : 'text-slate-900'}`}>{node.name}</h4>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDirect ? 'bg-orange-100 text-orange-800' : 'bg-white/60'}`}>
{node.role}
</span>
{hasChildren && !isDirect && (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
하위 {node.children.filter(c => !c.isDirect).length}
</span>
)}
</div>
<div className="flex gap-4 mt-1 text-xs text-slate-600">
<span>매출: <strong className="text-slate-900">{formatCurrency(node.totalSales)}</strong></span>
<span>계약: <strong className="text-slate-900">{node.contractCount}</strong></span>
{node.depth === 0 && !isDirect ? (
<span> 수당: <strong className="text-blue-600">{formatCurrency(node.commission)}</strong></span>
) : (
<span title={`내가 받는 ${isDirect ? (node.depth === 0 ? '직접판매(20%)' : node.depth === 1 ? '관리자(5%)' : '교육자(3%)') : (node.depth === 1 ? '관리자(5%)' : '교육자(3%)')} 수당`}>
수당: <strong className="text-blue-600">{formatCurrency(node.commission)}</strong>
</span>
)}
</div>
</div>
</div>
{!isDirect && (
<button
onClick={(e) => {
e.stopPropagation();
setSelectedManager(node);
}}
className="p-2 hover:bg-white/50 rounded-lg transition-colors"
>
<i data-lucide="more-vertical" className="w-4 h-4 text-slate-400"></i>
</button>
)}
</div>
</div>
{isExpanded && hasChildren && (
<div className="space-y-2">
{node.children.map(child => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
useEffect(() => {
setTimeout(() => {
lucide.createIcons();
}, 100);
}, [expandedNodes, organizationData]);
if (!organizationData) {
return (
<section className="bg-white rounded-card shadow-sm border border-slate-100 p-8">
<div className="text-center text-slate-500">
<i data-lucide="loader" className="w-6 h-6 animate-spin mx-auto mb-2"></i>
조직 구조 로딩 ...
</div>
</section>
);
}
return (
<>
<section className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden">
<div className="p-6 border-b border-slate-100 bg-gradient-to-r from-blue-50 to-indigo-50">
<div className="flex justify-between items-start mb-3">
<div>
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
<i data-lucide="network" className="w-5 h-5 text-blue-600"></i>
{showPeriodData ? '기간별 조직 구조 및 실적' : '조직 구조 및 실적'}
</h2>
<p className="text-xs text-slate-500 mt-1">계층별 가입비 수당 현황 ( 관점)</p>
</div>
{!showPeriodData && (
<button
onClick={() => {
if (onRefresh) onRefresh();
}}
className="px-3 py-2 bg-white rounded-lg hover:bg-blue-50 transition-colors text-sm font-medium text-slate-700 flex items-center gap-2 shadow-sm"
>
<i data-lucide="refresh-cw" className="w-4 h-4"></i>
새로고침
</button>
)}
</div>
<div className="flex gap-2 text-xs">
<div className="px-3 py-1.5 bg-blue-100 text-blue-800 rounded-full font-medium">
직접 판매: 수당 20%
</div>
<div className="px-3 py-1.5 bg-green-100 text-green-800 rounded-full font-medium">
직속 하위: 수당 5%
</div>
<div className="px-3 py-1.5 bg-purple-100 text-purple-800 rounded-full font-medium">
2 하위: 수당 3%
</div>
</div>
</div>
<div className="p-6">
{renderNode(organizationData)}
</div>
</section>
{/* Detail Modal */}
{selectedManager && (
<ManagerDetailModal
manager={selectedManager}
onClose={() => setSelectedManager(null)}
/>
)}
</>
);
};
// Manager Detail Modal
const ManagerDetailModal = ({ manager, onClose }) => {
if (!manager) return null;
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
const commissionRate = manager.totalSales > 0 ? ((manager.commission / manager.totalSales) * 100).toFixed(1) : 0;
useEffect(() => {
setTimeout(() => {
lucide.createIcons();
}, 100);
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-gradient-to-r from-blue-50 to-indigo-50 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-600">
<i data-lucide="user" className="w-6 h-6"></i>
</div>
<div>
<h3 className="text-xl font-bold text-slate-900">{manager.name}</h3>
<p className="text-sm text-slate-500">{manager.role}</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-white/50 rounded-full transition-colors">
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div className="p-6 space-y-6 overflow-y-auto flex-1">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-blue-50 rounded-xl border border-blue-100">
<div className="text-xs text-blue-600 font-medium mb-1 flex items-center gap-1">
<i data-lucide="trending-up" className="w-3 h-3"></i>
매출
</div>
<div className="text-xl font-bold text-blue-900">{formatCurrency(manager.totalSales)}</div>
</div>
<div className="p-4 bg-green-50 rounded-xl border border-green-100">
<div className="text-xs text-green-600 font-medium mb-1 flex items-center gap-1">
<i data-lucide="file-check" className="w-3 h-3"></i>
계약 건수
</div>
<div className="text-xl font-bold text-green-900">{manager.contractCount}</div>
</div>
</div>
<div className="p-4 bg-gradient-to-br from-purple-50 to-indigo-50 rounded-xl border border-purple-200">
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-purple-600 font-medium mb-1 flex items-center gap-1">
<i data-lucide="wallet" className="w-3 h-3"></i>
예상 수당
</div>
<div className="text-2xl font-bold text-purple-900">{formatCurrency(manager.commission)}</div>
</div>
<div className="text-right">
<div className="text-xs text-purple-600 mb-1">수당률</div>
<div className="text-lg font-bold text-purple-700">{commissionRate}%</div>
</div>
</div>
</div>
{/* 계약 목록 */}
{manager.contracts && manager.contracts.length > 0 && (
<div>
<h4 className="text-sm font-bold text-slate-900 mb-3 flex items-center gap-2">
<i data-lucide="calendar-check" className="w-4 h-4 text-blue-600"></i>
계약 내역 ({manager.contracts.length})
</h4>
<div className="border border-slate-200 rounded-lg overflow-hidden max-h-80 overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500">번호</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500">계약일</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500">가입비</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500"> 수당</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{manager.contracts.map((contract, idx) => {
const commissionForThis = manager.isDirect
? (manager.depth === 0 ? contract.amount * 0.20 : manager.depth === 1 ? contract.amount * 0.05 : contract.amount * 0.03)
: (manager.depth === 1 ? contract.amount * 0.05 : contract.amount * 0.03);
return (
<tr key={contract.id} className="hover:bg-slate-50">
<td className="px-4 py-3 text-slate-900">{idx + 1}</td>
<td className="px-4 py-3 text-slate-900">{contract.contractDate}</td>
<td className="px-4 py-3 text-right font-medium text-slate-900">{formatCurrency(contract.amount)}</td>
<td className="px-4 py-3 text-right font-bold text-blue-600">{formatCurrency(commissionForThis)}</td>
</tr>
);
})}
</tbody>
<tfoot className="bg-slate-50 border-t border-slate-200">
<tr>
<td colSpan="2" className="px-4 py-3 text-sm font-bold text-slate-900">합계</td>
<td className="px-4 py-3 text-right text-sm font-bold text-slate-900">{formatCurrency(manager.totalSales)}</td>
<td className="px-4 py-3 text-right text-sm font-bold text-blue-900">{formatCurrency(manager.commission)}</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{manager.children && manager.children.length > 0 && (
<div>
<h4 className="text-sm font-bold text-slate-900 mb-3 flex items-center gap-2">
<i data-lucide="users" className="w-4 h-4 text-blue-600"></i>
하위 조직 ({manager.children.length})
</h4>
<div className="space-y-2 max-h-60 overflow-y-auto">
{manager.children.map(child => (
<div key={child.id} className="p-3 bg-slate-50 rounded-lg border border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-white flex items-center justify-center">
<i data-lucide="user" className="w-4 h-4 text-slate-600"></i>
</div>
<div>
<div className="font-medium text-slate-900 text-sm">{child.name}</div>
<div className="text-xs text-slate-500">{child.role}</div>
</div>
</div>
<div className="text-right">
<div className="text-sm font-bold text-slate-900">{formatCurrency(child.totalSales)}</div>
<div className="text-xs text-slate-500">{child.contractCount}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end flex-shrink-0">
<button onClick={onClose} className="px-4 py-2 bg-white border border-slate-200 rounded-lg text-slate-700 hover:bg-slate-50 font-medium transition-colors">
닫기
</button>
</div>
</div>
</div>
);
};
// 8. Sub-Manager Detail Modal
// 8. Help Modal
const HelpModal = ({ onClose }) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50">
<h3 className="text-xl font-bold text-slate-900 flex items-center gap-2">
<i data-lucide="book-open" className="w-5 h-5 text-blue-600"></i>
수당 체계 설명서
</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto text-slate-600 text-sm leading-relaxed">
<section>
<h4 className="text-base font-bold text-slate-900 mb-2">1. 수당 체계 개요</h4>
<p className="mb-3">영업 수당은 <strong>가입비</strong> 대해서만 지급됩니다.</p>
<p>수당은 판매자와 상위 관리자, 교육자에게 차등 지급됩니다.</p>
</section>
<section>
<h4 className="text-base font-bold text-slate-900 mb-2">2. 수당 구성</h4>
<p className="mb-3">영업 수당은 가지 역할에 따라 구분되어 지급됩니다:</p>
<ul className="list-disc pl-5 space-y-2">
<li><strong>판매자 (Seller)</strong>: 직접 영업을 성사시킨 담당자 - <span className="text-blue-600 font-semibold">가입비의 20%</span></li>
<li><strong>관리자 (Manager)</strong>: 판매자를 데려온 상위 담당자 - <span className="text-green-600 font-semibold">가입비의 5%</span></li>
<li><strong>교육자 (Educator)</strong>: 관리자를 데려온 상위 담당자 - <span className="text-purple-600 font-semibold">가입비의 3%</span></li>
</ul>
</section>
<section>
<h4 className="text-base font-bold text-slate-900 mb-2">3. 수당 지급 요율</h4>
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<table className="w-full text-left">
<thead>
<tr className="border-b border-slate-200">
<th className="pb-2">구분</th>
<th className="pb-2">가입비 요율</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<tr>
<td className="py-2 font-medium">판매자</td>
<td className="py-2 text-blue-600 font-bold">20%</td>
</tr>
<tr>
<td className="py-2 font-medium">관리자</td>
<td className="py-2 text-green-600 font-bold">5%</td>
</tr>
<tr>
<td className="py-2 font-medium">교육자</td>
<td className="py-2 text-purple-600 font-bold">3%</td>
</tr>
</tbody>
</table>
</div>
</section>
<section>
<h4 className="text-base font-bold text-slate-900 mb-2">4. 계층 구조 이해</h4>
<div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
<p className="text-sm text-blue-900 mb-3"><strong>예시:</strong></p>
<div className="space-y-2 text-sm text-blue-900">
<p> A가 B를 영입하고, B가 C를 영입했습니다.</p>
<p> C가 판매를 성사시키면:</p>
<ul className="list-disc pl-5 mt-2 space-y-1">
<li><strong>C (판매자)</strong>: 가입비의 20% 수당</li>
<li><strong>B (관리자)</strong>: 가입비의 5% 수당</li>
<li><strong>A (교육자)</strong>: 가입비의 3% 수당</li>
</ul>
</div>
</div>
</section>
<section>
<h4 className="text-base font-bold text-slate-900 mb-2">5. 지급일 정책</h4>
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<ul className="list-disc pl-5 space-y-1 text-sm text-slate-700">
<li><strong>계약일:</strong> 가입비 완료일을 기준으로 합니다.</li>
<li><strong>수당 지급일:</strong> 가입비 완료 지급됩니다.</li>
</ul>
</div>
</section>
<section>
<h4 className="text-base font-bold text-slate-900 mb-2">6. 회사 마진</h4>
<p>가입비에서 수당(최대 28%) 제외한 금액이 회사 마진으로 귀속됩니다.</p>
</section>
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end">
<button onClick={onClose} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors shadow-sm">
확인
</button>
</div>
</div>
</div>
);
};
// 4. Simulator Component
const SimulatorSection = ({ salesConfig, selectedRole }) => {
const [duration, setDuration] = useState(salesConfig.default_contract_period || 84);
const [simulatorGuideOpen, setSimulatorGuideOpen] = useState(false);
const [pricingData, setPricingData] = useState({});
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState(null);
const isOperator = selectedRole === '운영자';
// 1차 선택: 선택모델, 공사관리, 공정/정부지원사업
const [selectedSelectModels, setSelectedSelectModels] = useState(false);
const [selectedConstruction, setSelectedConstruction] = useState(false);
const [selectedProcessGov, setSelectedProcessGov] = useState(false);
// 2차 선택: 선택모델의 세부 모델들
const [selectedModels, setSelectedModels] = useState([]);
// DB에서 가격 정보 가져오기
useEffect(() => {
const fetchPricing = async () => {
try {
const response = await fetch('api/package_pricing.php?action=list');
const result = await response.json();
if (result.success) {
const pricingMap = {};
result.data.forEach(item => {
pricingMap[`${item.item_type}_${item.item_id}`] = item;
});
setPricingData(pricingMap);
}
} catch (error) {
console.error('가격 정보 로드 실패:', error);
}
};
fetchPricing();
}, []);
const packageTypes = salesConfig.package_types || [];
const selectModelsPackage = packageTypes.find(p => p.id === 'select_models');
const constructionPackage = packageTypes.find(p => p.id === 'construction_management');
const processGovPackage = packageTypes.find(p => p.id === 'process_government');
// DB 가격 정보와 기본 설정 병합
const getItemPrice = (itemType, itemId, defaultJoinFee, defaultSubFee) => {
const key = `${itemType}_${itemId}`;
if (pricingData[key]) {
return {
join_fee: pricingData[key].join_fee,
subscription_fee: pricingData[key].subscription_fee
};
}
return {
join_fee: defaultJoinFee,
subscription_fee: defaultSubFee
};
};
// 선택된 항목들의 총 가입비 계산 (구독료 제거)
let totalJoinFee = 0;
let totalSellerCommission = 0;
let totalManagerCommission = 0;
let totalEducatorCommission = 0;
// 선택모델의 세부 모델들 합산
if (selectedSelectModels && selectModelsPackage) {
selectedModels.forEach(modelId => {
const model = selectModelsPackage.models.find(m => m.id === modelId);
if (model) {
const price = getItemPrice('model', modelId, model.join_fee, model.subscription_fee);
totalJoinFee += price.join_fee || 0;
// 가입비에 대한 수당만 계산: 판매자 20%, 관리자 5%, 교육자 3%
const rates = model.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0.03 } };
totalSellerCommission += (price.join_fee * (rates.seller.join || 0.20));
totalManagerCommission += (price.join_fee * (rates.manager.join || 0.05));
totalEducatorCommission += (price.join_fee * (rates.educator?.join || 0.03));
}
});
}
// 공사관리 패키지
if (selectedConstruction && constructionPackage) {
const price = getItemPrice('package', 'construction_management', constructionPackage.join_fee, constructionPackage.subscription_fee);
totalJoinFee += price.join_fee || 0;
const rates = constructionPackage.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0.03 } };
totalSellerCommission += (price.join_fee * (rates.seller.join || 0.20));
totalManagerCommission += (price.join_fee * (rates.manager.join || 0.05));
totalEducatorCommission += (price.join_fee * (rates.educator?.join || 0.03));
}
// 공정/정부지원사업 패키지
if (selectedProcessGov && processGovPackage) {
const price = getItemPrice('package', 'process_government', processGovPackage.join_fee, processGovPackage.subscription_fee);
totalJoinFee += price.join_fee || 0;
const rates = processGovPackage.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0.03 } };
totalSellerCommission += (price.join_fee * (rates.seller.join || 0.20));
totalManagerCommission += (price.join_fee * (rates.manager.join || 0.05));
totalEducatorCommission += (price.join_fee * (rates.educator?.join || 0.03));
}
const totalCommission = totalSellerCommission + totalManagerCommission + totalEducatorCommission;
const totalRevenue = totalJoinFee; // 구독료 제거
const commissionRate = totalRevenue > 0 ? ((totalCommission / totalRevenue) * 100).toFixed(1) : 0;
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
const handleModelToggle = (modelId) => {
setSelectedModels(prev =>
prev.includes(modelId)
? prev.filter(id => id !== modelId)
: [...prev, modelId]
);
};
const handleEditPrice = (itemType, itemId, itemName, subName, defaultJoinFee, defaultSubFee) => {
const key = `${itemType}_${itemId}`;
const currentPrice = pricingData[key] || { join_fee: defaultJoinFee, subscription_fee: defaultSubFee };
setEditingItem({
item_type: itemType,
item_id: itemId,
item_name: itemName,
sub_name: subName,
join_fee: currentPrice.join_fee || defaultJoinFee,
subscription_fee: currentPrice.subscription_fee || defaultSubFee
});
setEditModalOpen(true);
};
const handleSavePrice = async () => {
if (!editingItem) return;
try {
const response = await fetch('api/package_pricing.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_type: editingItem.item_type,
item_id: editingItem.item_id,
join_fee: editingItem.join_fee,
subscription_fee: editingItem.subscription_fee
})
});
const result = await response.json();
if (result.success) {
// 가격 정보 다시 로드
const fetchResponse = await fetch('api/package_pricing.php?action=list');
const fetchResult = await fetchResponse.json();
if (fetchResult.success) {
const pricingMap = {};
fetchResult.data.forEach(item => {
pricingMap[`${item.item_type}_${item.item_id}`] = item;
});
setPricingData(pricingMap);
}
setEditModalOpen(false);
setEditingItem(null);
} else {
alert('가격 저장에 실패했습니다: ' + (result.error || '알 수 없는 오류'));
}
} catch (error) {
console.error('가격 저장 실패:', error);
alert('가격 저장 중 오류가 발생했습니다.');
}
};
// 아이콘 업데이트
useEffect(() => {
setTimeout(() => {
lucide.createIcons();
}, 100);
}, [selectedSelectModels, selectedModels]);
useEffect(() => {
if (editModalOpen) {
setTimeout(() => {
lucide.createIcons();
}, 150);
}
}, [editModalOpen]);
return (
<section className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden flex flex-col lg:flex-row">
{/* Input Form */}
<div className="p-8 lg:w-2/5 border-b lg:border-b-0 lg:border-r border-slate-100">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-slate-900 flex items-center gap-2">
<i data-lucide="calculator" className="w-5 h-5 text-blue-600"></i>
수당 시뮬레이터
</h2>
<button
onClick={() => setSimulatorGuideOpen(true)}
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="사용 가이드 보기"
>
<i data-lucide="help-circle" className="w-5 h-5"></i>
</button>
</div>
<div className="space-y-6">
{/* 1차 선택 */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">패키지 선택</label>
<div className="space-y-3">
{/* 선택모델 */}
<div className="flex items-start gap-3 p-4 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors">
<input
type="checkbox"
id="select_models"
checked={selectedSelectModels}
onChange={(e) => {
setSelectedSelectModels(e.target.checked);
if (!e.target.checked) {
setSelectedModels([]);
}
}}
className="mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<label htmlFor="select_models" className="flex-1 cursor-pointer">
<div className="font-medium text-slate-900">선택모델</div>
<div className="text-xs text-slate-500 mt-1">여러 모델을 선택할 있습니다</div>
</label>
</div>
{/* 공사관리 */}
{constructionPackage && (() => {
const price = getItemPrice('package', 'construction_management', constructionPackage.join_fee, constructionPackage.subscription_fee);
return (
<div className="flex items-start gap-3 p-4 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors">
<input
type="checkbox"
id="construction_management"
checked={selectedConstruction}
onChange={(e) => setSelectedConstruction(e.target.checked)}
className="mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<label htmlFor="construction_management" className="flex-1 cursor-pointer">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-slate-900">공사관리</div>
<div className="text-xs text-slate-500 mt-1">패키지</div>
<div className="text-xs text-blue-600 mt-1">
가입비: {formatCurrency(price.join_fee)} / 구독료: {formatCurrency(price.subscription_fee)}
</div>
</div>
{isOperator && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditPrice('package', 'construction_management', '공사관리', '패키지', constructionPackage.join_fee, constructionPackage.subscription_fee);
}}
className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="가격 설정"
>
<i data-lucide="settings" className="w-4 h-4"></i>
</button>
)}
</div>
</label>
</div>
);
})()}
{/* 공정/정부지원사업 */}
{processGovPackage && (() => {
const price = getItemPrice('package', 'process_government', processGovPackage.join_fee, processGovPackage.subscription_fee);
return (
<div className="flex items-start gap-3 p-4 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors">
<input
type="checkbox"
id="process_government"
checked={selectedProcessGov}
onChange={(e) => setSelectedProcessGov(e.target.checked)}
className="mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<label htmlFor="process_government" className="flex-1 cursor-pointer">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-slate-900">공정/정부지원사업</div>
<div className="text-xs text-slate-500 mt-1">패키지</div>
<div className="text-xs text-blue-600 mt-1">
가입비: {formatCurrency(price.join_fee)} / 구독료: {formatCurrency(price.subscription_fee)}
</div>
</div>
{isOperator && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditPrice('package', 'process_government', '공정/정부지원사업', '패키지', processGovPackage.join_fee, processGovPackage.subscription_fee);
}}
className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="가격 설정"
>
<i data-lucide="settings" className="w-4 h-4"></i>
</button>
)}
</div>
</label>
</div>
);
})()}
</div>
</div>
{/* 2차 선택: 선택모델의 세부 모델들 */}
{selectedSelectModels && selectModelsPackage && (
<div className="border-t border-slate-200 pt-6">
<label className="block text-sm font-medium text-slate-700 mb-3">선택모델 세부 항목</label>
<div className="space-y-2 max-h-60 overflow-y-auto">
{selectModelsPackage.models.map(model => {
const price = getItemPrice('model', model.id, model.join_fee, model.subscription_fee);
return (
<div key={model.id} className="flex items-start gap-3 p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors">
<input
type="checkbox"
id={model.id}
checked={selectedModels.includes(model.id)}
onChange={() => handleModelToggle(model.id)}
className="mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<label htmlFor={model.id} className="flex-1 cursor-pointer">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-slate-900 text-sm">{model.name}</div>
{model.sub_name && (
<div className="text-xs text-slate-500 mt-0.5">{model.sub_name}</div>
)}
<div className="text-xs text-blue-600 mt-1">
가입비: {formatCurrency(price.join_fee)} / 구독료: {formatCurrency(price.subscription_fee)}
</div>
</div>
{isOperator && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditPrice('model', model.id, model.name, model.sub_name, model.join_fee, model.subscription_fee);
}}
className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors ml-2"
title="가격 설정"
>
<i data-lucide="settings" className="w-4 h-4"></i>
</button>
)}
</div>
</label>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{/* Result Card */}
<div className="p-8 lg:w-3/5 bg-slate-50 flex flex-col justify-center">
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-50 rounded-full -mr-16 -mt-16 opacity-50"></div>
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-6">예상 수당 명세서</h3>
<div className="space-y-4 mb-6">
<div className="flex justify-between items-center p-3 bg-slate-50 rounded-lg">
<span className="text-slate-600"> 가입비</span>
<span className="font-bold text-slate-900">{formatCurrency(totalRevenue)}</span>
</div>
<div className="flex justify-between items-center p-3 bg-slate-50 rounded-lg">
<span className="text-slate-600">판매자 수당 (20%)</span>
<span className="font-bold text-slate-900">{formatCurrency(totalSellerCommission)}</span>
</div>
<div className="flex justify-between items-center p-3 bg-slate-50 rounded-lg">
<span className="text-slate-600">관리자 수당 (5%)</span>
<span className="font-bold text-slate-900">{formatCurrency(totalManagerCommission)}</span>
</div>
<div className="flex justify-between items-center p-3 bg-slate-50 rounded-lg">
<span className="text-slate-600">교육자 수당 (3%)</span>
<span className="font-bold text-slate-900">{formatCurrency(totalEducatorCommission)}</span>
</div>
</div>
<div className="pt-6 border-t border-dashed border-slate-200">
<div className="flex justify-between items-end">
<span className="text-lg font-bold text-slate-800"> 예상 수당</span>
<span className="text-3xl font-extrabold text-blue-600">{formatCurrency(totalCommission)}</span>
</div>
<div className="flex justify-between items-center mt-2">
<p className="text-xs text-slate-400">* 세전 금액 기준입니다.</p>
<p className="text-xs font-medium text-blue-600"> 매출의 {commissionRate}%</p>
</div>
</div>
</div>
</div>
{/* Simulator Guide Modal */}
{simulatorGuideOpen && (
<SimulatorGuideModal onClose={() => setSimulatorGuideOpen(false)} salesConfig={salesConfig} />
)}
{/* Price Edit Modal */}
{editModalOpen && editingItem && (
<PriceEditModal
key={`edit-${editingItem.item_type}-${editingItem.item_id}`}
item={editingItem}
onClose={() => {
setEditModalOpen(false);
setEditingItem(null);
}}
onSave={handleSavePrice}
setEditingItem={setEditingItem}
/>
)}
</section>
);
};
// Price Edit Modal Component
const PriceEditModal = ({ item, onClose, onSave, setEditingItem }) => {
const handleChange = (field, value) => {
const numValue = value.replace(/,/g, '');
const parsedValue = parseFloat(numValue) || 0;
setEditingItem(prev => ({
...prev,
[field]: parsedValue
}));
};
useEffect(() => {
setTimeout(() => {
lucide.createIcons();
}, 100);
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50">
<h3 className="text-xl font-bold text-slate-900 flex items-center gap-2">
<i data-lucide="dollar-sign" className="w-5 h-5 text-blue-600"></i>
가격 설정
</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">항목명</label>
<div className="text-base font-semibold text-slate-900">{item.item_name}</div>
{item.sub_name && (
<div className="text-sm text-slate-500 mt-1">{item.sub_name}</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">가입비 ()</label>
<input
type="text"
value={(item.join_fee || 0).toLocaleString('ko-KR')}
onChange={(e) => handleChange('join_fee', e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2"> 구독료 ()</label>
<input
type="text"
value={(item.subscription_fee || 0).toLocaleString('ko-KR')}
onChange={(e) => handleChange('subscription_fee', e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 font-medium transition-colors"
>
취소
</button>
<button
onClick={onSave}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors shadow-sm"
>
저장
</button>
</div>
</div>
</div>
);
};
// Simulator Guide Modal Component
const SimulatorGuideModal = ({ onClose, salesConfig }) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 flex-shrink-0">
<h3 className="text-xl font-bold text-slate-900 flex items-center gap-2">
<i data-lucide="book-open" className="w-5 h-5 text-blue-600"></i>
수당 시뮬레이터 사용 가이드
</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div className="p-6 space-y-6 overflow-y-auto flex-1 text-slate-600 text-sm leading-relaxed">
<section>
<h4 className="text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
<i data-lucide="info" className="w-4 h-4 text-blue-600"></i>
수당 시뮬레이터란?
</h4>
<p className="mb-3">수당 시뮬레이터는 고객과 계약을 체결하기 전에 예상 수당을 미리 계산해볼 있는 도구입니다.</p>
<div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
<p className="text-sm text-blue-800 font-medium mb-2">💡 활용 시나리오</p>
<ul className="list-disc pl-5 space-y-1 text-sm text-blue-800">
<li>고객과 계약 조건을 논의할 예상 수당을 즉시 확인</li>
<li>다양한 패키지에 따른 수당 비교</li>
<li>영업 목표 설정 수당 예측</li>
<li>고객에게 맞춤형 제안서 작성 수당 정보 확인</li>
</ul>
</div>
</section>
<section>
<h4 className="text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
<i data-lucide="calculator" className="w-4 h-4 text-blue-600"></i>
수당 계산 방식
</h4>
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<p className="text-sm text-slate-700 mb-3">수당은 <strong>가입비</strong> 대해서만 계산됩니다.</p>
<div className="space-y-3">
<div className="border-l-4 border-blue-500 pl-3">
<h5 className="font-semibold text-slate-900 mb-1">판매자 수당 (20%)</h5>
<p className="text-xs text-slate-600">가입비 × 20%</p>
</div>
<div className="border-l-4 border-green-500 pl-3">
<h5 className="font-semibold text-slate-900 mb-1">관리자 수당 (5%)</h5>
<p className="text-xs text-slate-600">가입비 × 5%</p>
</div>
<div className="border-l-4 border-purple-500 pl-3">
<h5 className="font-semibold text-slate-900 mb-1">교육자 수당 (3%)</h5>
<p className="text-xs text-slate-600">가입비 × 3%</p>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-100">
<p className="text-xs text-blue-800 font-medium mb-1">📌 계산 예시</p>
<p className="text-xs text-blue-800 mb-2">가입비 100만원인 경우:</p>
<ul className="list-disc pl-5 space-y-1 text-xs text-blue-800">
<li>판매자 수당: 100만원 × 20% = 20만원</li>
<li>관리자 수당: 100만원 × 5% = 5만원</li>
<li>교육자 수당: 100만원 × 3% = 3만원</li>
<li> 수당: 28만원 (가입비의 28%)</li>
</ul>
</div>
</div>
</section>
<section>
<h4 className="text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
<i data-lucide="users" className="w-4 h-4 text-blue-600"></i>
계층 구조
</h4>
<div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
<p className="text-sm text-blue-900 mb-2"><strong>수당 지급 구조:</strong></p>
<ul className="list-disc pl-5 space-y-1 text-sm text-blue-900">
<li><strong>판매자:</strong> 직접 영업을 성사시킨 담당자</li>
<li><strong>관리자:</strong> 판매자를 데려온 상위 담당자</li>
<li><strong>교육자:</strong> 관리자를 데려온 상위 담당자</li>
</ul>
</div>
</section>
<section>
<h4 className="text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
<i data-lucide="lightbulb" className="w-4 h-4 text-blue-600"></i>
활용
</h4>
<div className="bg-amber-50 rounded-lg p-4 border border-amber-100">
<ul className="list-disc pl-5 space-y-2 text-sm text-amber-900">
<li><strong>실시간 계산:</strong> 패키지를 선택하면 즉시 예상 수당이 계산됩니다.</li>
<li><strong>패키지 비교:</strong> 다양한 패키지를 선택하여 수당 차이를 비교해보세요.</li>
<li><strong>영업 전략:</strong> 높은 수당이 나오는 패키지를 파악하여 영업 전략을 수립하세요.</li>
</ul>
</div>
</section>
<section>
<h4 className="text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
<i data-lucide="alert-circle" className="w-4 h-4 text-blue-600"></i>
주의사항
</h4>
<div className="bg-red-50 rounded-lg p-4 border border-red-100">
<ul className="list-disc pl-5 space-y-1 text-sm text-red-800">
<li>계산된 수당은 <strong>예상치</strong>이며, 실제 지급 수당은 계약 조건에 따라 달라질 있습니다.</li>
<li>표시된 금액은 <strong>세전 금액</strong>이며, 실제 지급 시에는 소득세가 공제됩니다.</li>
<li>수당은 가입비 완료 지급됩니다.</li>
</ul>
</div>
</section>
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end flex-shrink-0">
<button onClick={onClose} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors shadow-sm">
확인
</button>
</div>
</div>
</div>
);
};
// 5. Sales List Component
const SalesList = ({ salesRecords, programs, onSelectRecord }) => {
const getProgramName = (id) => programs.find(p => p.id === id)?.name || id;
return (
<section className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden">
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
<h2 className="text-lg font-bold text-slate-900">영업 실적 현황</h2>
<button className="text-sm text-blue-600 font-medium hover:underline">전체 보기</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-slate-600">
<thead className="bg-slate-50 text-xs uppercase font-medium text-slate-500">
<tr>
<th className="px-6 py-4">고객사</th>
<th className="px-6 py-4">프로그램</th>
<th className="px-6 py-4">계약일</th>
<th className="px-6 py-4">상태</th>
<th className="px-6 py-4 text-right">상세보기</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{salesRecords.map(record => (
<tr key={record.id} className="hover:bg-slate-50 transition-colors cursor-pointer" onClick={() => onSelectRecord(record)}>
<td className="px-6 py-4 font-medium text-slate-900">{record.customer_name}</td>
<td className="px-6 py-4">{getProgramName(record.program_id)}</td>
<td className="px-6 py-4">{record.contract_date}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
record.status === 'Active' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{record.status}
</span>
</td>
<td className="px-6 py-4 text-right">
<button className="text-slate-400 hover:text-blue-600">
<i data-lucide="chevron-right" className="w-4 h-4"></i>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
};
// 6. Commission Detail Modal
const CommissionDetailModal = ({ record, programs, onClose }) => {
if (!record) return null;
const program = programs.find(p => p.id === record.program_id);
const rates = program?.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0.03 } };
// Calculations (구독료 제거, 가입비에 대한 수당만 계산)
const joinFee = record.join_fee;
const calc = (base, rate) => base * rate;
// 판매자: 가입비의 20%
const sellerJoin = calc(joinFee, rates.seller.join || 0.20);
const sellerTotal = sellerJoin;
// 관리자: 가입비의 5%
const managerJoin = calc(joinFee, rates.manager.join || 0.05);
const managerTotal = managerJoin;
// 교육자: 가입비의 3%
const educatorJoin = calc(joinFee, rates.educator?.join || 0.03);
const educatorTotal = educatorJoin;
const totalCommission = sellerTotal + managerTotal + educatorTotal;
const companyMargin = joinFee - totalCommission; // 회사 마진 = 가입비 - 총 수당
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[90vh]" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 shrink-0">
<div>
<h3 className="text-xl font-bold text-slate-900">{record.customer_name} 수당 상세</h3>
<p className="text-sm text-slate-500">{program?.name} | {duration}개월 계약</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div className="p-6 space-y-8 overflow-y-auto">
{/* 1. Key Dates Section */}
<section>
<h4 className="text-sm font-bold text-slate-900 mb-3 flex items-center gap-2">
<i data-lucide="calendar" className="w-4 h-4 text-blue-600"></i>
주요 일정 정보
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="p-3 bg-slate-50 rounded-lg border border-slate-100">
<div className="text-xs text-slate-500 mb-1">계약일</div>
<div className="font-medium text-slate-900 text-sm">{record.dates?.contract || '-'}</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg border border-slate-100">
<div className="text-xs text-slate-500 mb-1">가입비 지급일</div>
<div className="font-medium text-slate-900 text-sm">{record.dates?.join_fee || '-'}</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg border border-slate-100">
<div className="text-xs text-slate-500 mb-1">서비스 시작일</div>
<div className="font-medium text-slate-900 text-sm">{record.dates?.service_start || '-'}</div>
</div>
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
<div className="text-xs text-blue-600 mb-1">최근 수정일</div>
<div className="font-bold text-blue-900 text-sm">{record.dates?.product_modified || '-'}</div>
</div>
</div>
</section>
{/* 2. Commission Summary */}
<section>
<h4 className="text-sm font-bold text-slate-900 mb-3 flex items-center gap-2">
<i data-lucide="calculator" className="w-4 h-4 text-blue-600"></i>
수당 상세 내역
</h4>
<div className="border border-slate-200 rounded-xl overflow-hidden">
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 text-xs uppercase font-medium text-slate-500">
<tr>
<th className="px-4 py-3 border-b border-slate-200">구분</th>
<th className="px-4 py-3 border-b border-slate-200">가입비 ({formatCurrency(joinFee)})</th>
<th className="px-4 py-3 border-b border-slate-200 text-right">수당</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<tr>
<td className="px-4 py-3 font-medium text-slate-900">판매자</td>
<td className="px-4 py-3 text-slate-600">
<span className="text-xs text-slate-400">20%</span>
</td>
<td className="px-4 py-3 text-right font-bold text-slate-900">{formatCurrency(sellerTotal)}</td>
</tr>
<tr>
<td className="px-4 py-3 font-medium text-slate-900">관리자</td>
<td className="px-4 py-3 text-slate-600">
<span className="text-xs text-slate-400">5%</span>
</td>
<td className="px-4 py-3 text-right font-bold text-slate-900">{formatCurrency(managerTotal)}</td>
</tr>
<tr>
<td className="px-4 py-3 font-medium text-slate-900">교육자</td>
<td className="px-4 py-3 text-slate-600">
<span className="text-xs text-slate-400">3%</span>
</td>
<td className="px-4 py-3 text-right font-bold text-slate-900">{formatCurrency(educatorTotal)}</td>
</tr>
</tbody>
<tfoot className="bg-slate-50 border-t border-slate-200">
<tr>
<td className="px-4 py-3 font-bold text-slate-900">회사 마진</td>
<td className="px-4 py-3 text-slate-500">가입비 - 수당</td>
<td className="px-4 py-3 text-right font-bold text-slate-900">{formatCurrency(companyMargin)}</td>
</tr>
</tfoot>
</table>
</div>
</section>
{/* 3. Product History Section */}
{record.history && (
<section>
<h4 className="text-sm font-bold text-slate-900 mb-3 flex items-center gap-2">
<i data-lucide="history" className="w-4 h-4 text-blue-600"></i>
상품 변경 이력
</h4>
<div className="relative pl-4 border-l-2 border-slate-200 space-y-6">
{record.history.map((item, idx) => (
<div key={idx} className="relative">
<div className={`absolute -left-[21px] top-1 w-3 h-3 rounded-full border-2 border-white ${
item.type === 'New Contract' ? 'bg-green-500' : 'bg-blue-500'
}`}></div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1">
<span className="text-xs font-bold text-slate-500">{item.date}</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full w-fit ${
item.type === 'New Contract' ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'
}`}>{item.type}</span>
</div>
<div className="mt-1 p-3 bg-slate-50 rounded-lg border border-slate-100">
<p className="text-sm font-medium text-slate-900">{item.description}</p>
<p className="text-xs text-slate-500 mt-1">Program ID: {item.program_id}</p>
</div>
</div>
))}
</div>
</section>
)}
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end shrink-0">
<button onClick={onClose} className="px-4 py-2 bg-white border border-slate-200 rounded-lg text-slate-700 hover:bg-slate-50 font-medium transition-colors">
닫기
</button>
</div>
</div>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
// Initialize Lucide Icons after render and on updates
const initIcons = () => {
setTimeout(() => {
lucide.createIcons();
}, 100);
};
initIcons();
// 주기적으로 아이콘 업데이트 (동적 콘텐츠를 위해)
setInterval(() => {
lucide.createIcons();
}, 500);
</script>
</body>
</html>