Files
sam-sales/salesmanagement/index.php
2025-12-17 12:59:26 +09:00

2823 lines
180 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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;
// Lucide Icon Wrapper
const LucideIcon = ({ name, size, className, onClick }) => {
const ref = React.useRef(null);
React.useEffect(() => {
if (window.lucide && ref.current) {
const i = document.createElement('i');
i.setAttribute('data-lucide', name);
// Combine className and size helpers if needed, but existing code uses w-4 h-4 etc.
// We just pass className through.
if (className) i.className = className;
ref.current.innerHTML = '';
ref.current.appendChild(i);
window.lucide.createIcons({ root: ref.current });
}
}, [name, className]);
return <span ref={ref} onClick={onClick} style={{ display: 'contents' }}></span>;
};
// --- 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);
}
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">
<LucideIcon name="home" className="w-4 h-4" />
홈으로
</a>
<button onClick={onOpenHelp} className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
<LucideIcon name="help-circle" className="w-4 h-4" />
도움말
</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>
<LucideIcon name="chevron-down" className="w-4 h-4" />
</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 && (
<LucideIcon name="check" className="w-4 h-4" />
)}
{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);
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">
<LucideIcon name="file-check" className="w-5 h-5" />
</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">
<LucideIcon name="calendar" className="w-5 h-5" />
</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">
<LucideIcon name="trending-up" className="w-5 h-5" />
</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">
<LucideIcon name="wallet" className="w-5 h-5" />
</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">
<LucideIcon name="credit-card" className="w-5 h-5" />
</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">
<LucideIcon name="calendar-check" className="w-5 h-5" />
</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"
>
<LucideIcon name="arrow-left" className="w-4 h-4" />
목록으로
</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">
<LucideIcon name="user" className="w-5 h-5" />
</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">
<LucideIcon name="chevron-right" className="w-3 h-3" />
클릭하여 세부 내역 보기
</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">
<LucideIcon name="settings" className="w-5 h-5 text-blue-600" />
아이템 가격 설정
</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);
if (loading) {
return (
<div className="text-center py-8 text-slate-500">
<LucideIcon name="loader" className="w-6 h-6 animate-spin mx-auto mb-2" />
로딩 ...
</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="설정"
>
<LucideIcon name="edit" className="w-4 h-4" />
</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="설정"
>
<LucideIcon name="edit" className="w-4 h-4" />
</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">
<LucideIcon name="settings" className="w-5 h-5 text-blue-600" />
아이템 가격 설정
</h3>
<button onClick={handleCloseModal} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
<LucideIcon name="x" className="w-5 h-5 text-slate-500" />
</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 전에 호출해야 함)
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">
<LucideIcon name="info" className="w-5 h-5 text-blue-600 mt-0.5" />
<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);
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={<LucideIcon name="trending-up" className="w-5 h-5" />}
/>
<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">
<LucideIcon name="wallet" className="w-5 h-5" />
</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={<LucideIcon name="file-check" className="w-5 h-5" />}
/>
</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">
<LucideIcon name="calendar-range" className="w-5 h-5 text-blue-600" />
기간별 조회
</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) => {
const newStartYear = Number(e.target.value);
setStartYear(newStartYear);
// Logic: If Start Year > End Year, set End Year = Start Year.
// Also if Start Year == End Year and Start Month > End Month, set End Month = Start Month.
if (newStartYear > endYear) {
setEndYear(newStartYear);
} else if (newStartYear === endYear && startMonth > endMonth) {
setEndMonth(startMonth);
}
}}
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) => {
const newStartMonth = Number(e.target.value);
setStartMonth(newStartMonth);
// Logic: If Start Year == End Year and Start Month > End Month, set End Month = Start Month.
if (startYear === endYear && newStartMonth > endMonth) {
setEndMonth(newStartMonth);
}
// If Start Year > End Year, it should have been handled by Year change, but robustly:
if (startYear > endYear) {
setEndYear(startYear);
}
}}
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) => {
const newEndYear = Number(e.target.value);
setEndYear(newEndYear);
// Logic: If End Year < Start Year, set Start Year = End Year.
// Also if End Year == Start Year and End Month < Start Month, set Start Month = End Month.
if (newEndYear < startYear) {
setStartYear(newEndYear);
} else if (newEndYear === startYear && endMonth < startMonth) {
setStartMonth(endMonth);
}
}}
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) => {
const newEndMonth = Number(e.target.value);
setEndMonth(newEndMonth);
// Logic: If End Year == Start Year and End Month < Start Month, set Start Month = End Month.
if (endYear === startYear && newEndMonth < startMonth) {
setStartMonth(newEndMonth);
}
// If End Year < Start Year, it should have been handled by Year change, but robustly:
if (endYear < startYear) {
setStartYear(endYear);
}
}}
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">
<LucideIcon name="layers" className="w-5 h-5 text-blue-600" />
역할별 수당 상세
</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">
<LucideIcon name="user" className="w-4 h-4 text-green-600" />
</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">
<LucideIcon name="users" className="w-4 h-4 text-purple-600" />
</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">
<LucideIcon name="graduation-cap" className="w-4 h-4 text-orange-600" />
</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);
}}
>
<LucideIcon name={isExpanded ? 'chevron-down' : 'chevron-right'} className="w-4 h-4" />
</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`}>
<LucideIcon name={getDepthIcon(node.depth, isDirect)} className="w-5 h-5 text-slate-600" />
</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"
>
<LucideIcon name="more-vertical" className="w-4 h-4 text-slate-400" />
</button>
)}
</div>
</div>
{isExpanded && hasChildren && (
<div className="space-y-2">
{node.children.map(child => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
if (!organizationData) {
return (
<section className="bg-white rounded-card shadow-sm border border-slate-100 p-8">
<div className="text-center text-slate-500">
<LucideIcon name="loader" className="w-6 h-6 animate-spin mx-auto mb-2" />
조직 구조 로딩 ...
</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">
<LucideIcon name="network" className="w-5 h-5 text-blue-600" />
{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"
>
<LucideIcon name="refresh-cw" className="w-4 h-4" />
새로고침
</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;
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">
<LucideIcon name="user" className="w-6 h-6" />
</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">
<LucideIcon name="x" className="w-5 h-5 text-slate-500" />
</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">
<LucideIcon name="trending-up" className="w-3 h-3" />
매출
</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">
<LucideIcon name="file-check" className="w-3 h-3" />
계약 건수
</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">
<LucideIcon name="wallet" className="w-3 h-3" />
예상 수당
</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">
<LucideIcon name="calendar-check" className="w-4 h-4 text-blue-600" />
계약 내역 ({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">
<LucideIcon name="users" className="w-4 h-4 text-blue-600" />
하위 조직 ({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">
<LucideIcon name="user" className="w-4 h-4 text-slate-600" />
</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">
<LucideIcon name="book-open" className="w-5 h-5 text-blue-600" />
수당 체계 설명서
</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
<LucideIcon name="x" className="w-5 h-5 text-slate-500" />
</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('가격 저장 중 오류가 발생했습니다.');
}
};
// 아이콘 업데이트
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">
<LucideIcon name="calculator" className="w-5 h-5 text-blue-600" />
수당 시뮬레이터
</h2>
<button
onClick={() => setSimulatorGuideOpen(true)}
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="사용 가이드 보기"
>
<LucideIcon name="help-circle" className="w-5 h-5" />
</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="가격 설정"
>
<LucideIcon name="settings" className="w-4 h-4" />
</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="가격 설정"
>
<LucideIcon name="settings" className="w-4 h-4" />
</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="가격 설정"
>
<LucideIcon name="settings" className="w-4 h-4" />
</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>