2025-12-17 12:59:26 +09:00
|
|
|
|
<!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);
|
|
|
|
|
|
if (className) i.className = className;
|
|
|
|
|
|
ref.current.innerHTML = '';
|
|
|
|
|
|
ref.current.appendChild(i);
|
|
|
|
|
|
window.lucide.createIcons({ root: ref.current });
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [name, className]);
|
2025-12-21 19:19:02 +09:00
|
|
|
|
// Ensure icon itself doesn't eat clicks if no specific handler
|
|
|
|
|
|
return <span ref={ref} onClick={onClick} className={`inline-flex items-center justify-center ${className || ''}`} style={{ pointerEvents: onClick ? 'auto' : 'none' }}></span>;
|
2025-12-17 12:59:26 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// --- Components ---
|
|
|
|
|
|
|
|
|
|
|
|
// 1. Header Component
|
2025-12-21 19:19:02 +09:00
|
|
|
|
const Header = ({ companyInfo, onOpenHelp, selectedRole, onRoleChange, currentUser, onLogout }) => {
|
2025-12-17 12:59:26 +09:00
|
|
|
|
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>;
|
|
|
|
|
|
|
2025-12-21 19:19:02 +09:00
|
|
|
|
const roles = ['운영자', '영업관리', '매니저'];
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
|
|
|
|
|
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">
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<div className="flex items-center gap-6">
|
|
|
|
|
|
<h1 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
|
|
|
|
|
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white">
|
|
|
|
|
|
<LucideIcon name="briefcase" className="w-5 h-5" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span>SAM 영업관리</span>
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
|
|
|
|
|
|
{currentUser && (
|
|
|
|
|
|
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-100 rounded-full">
|
|
|
|
|
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
|
|
|
|
|
|
<span className="text-xs font-bold text-slate-700">
|
|
|
|
|
|
{currentUser.name} ({currentUser.member_id})
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</div>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<div className="flex items-center gap-4">
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<a href="../index.php" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1 font-medium transition-colors">
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<LucideIcon name="home" className="w-4 h-4" />
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<span className="hidden sm:inline">홈으로</span>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</a>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<button onClick={onOpenHelp} className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1 font-medium transition-colors">
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<LucideIcon name="help-circle" className="w-4 h-4" />
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<span className="hidden sm:inline">도움말</span>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</button>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
|
|
|
|
|
|
{currentUser && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onLogout}
|
|
|
|
|
|
className="text-sm text-red-500 hover:text-red-700 flex items-center gap-1 font-medium transition-colors ml-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<LucideIcon name="log-out" className="w-4 h-4" />
|
|
|
|
|
|
<span className="hidden sm:inline">로그아웃</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<div className="relative" ref={profileMenuRef}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setIsProfileMenuOpen(!isProfileMenuOpen)}
|
2025-12-21 19:19:02 +09:00
|
|
|
|
className="px-3 py-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 transition-all flex items-center gap-2 cursor-pointer text-sm font-bold text-slate-700 border border-slate-200"
|
2025-12-17 12:59:26 +09:00
|
|
|
|
>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<LucideIcon name="user-cog" className="w-4 h-4 text-slate-500" />
|
|
|
|
|
|
<span>{currentUser ? `${currentUser.name} (${currentUser.member_id})` : selectedRole}</span>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<LucideIcon name="chevron-down" className="w-4 h-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{isProfileMenuOpen && (
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-xl border border-slate-100 py-2 z-50 animate-in fade-in slide-in-from-top-1 duration-200">
|
|
|
|
|
|
<div className="px-4 py-2 border-b border-slate-50">
|
|
|
|
|
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">접속 모드 변경</div>
|
|
|
|
|
|
<div className="text-sm font-bold text-slate-900">{selectedRole}</div>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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 ${
|
2025-12-21 19:19:02 +09:00
|
|
|
|
selectedRole === role ? 'bg-blue-50 text-blue-700 font-bold' : 'text-slate-700 font-medium'
|
2025-12-17 12:59:26 +09:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
{selectedRole === role ? (
|
|
|
|
|
|
<LucideIcon name="check-circle-2" className="w-4 h-4" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="w-4" />
|
2025-12-17 12:59:26 +09:00
|
|
|
|
)}
|
|
|
|
|
|
{role}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Operator View Component
|
2025-12-21 19:19:02 +09:00
|
|
|
|
const OperatorView = ({ currentUser }) => {
|
2025-12-17 12:59:26 +09:00
|
|
|
|
const [selectedManager, setSelectedManager] = useState(null);
|
|
|
|
|
|
const [managers, setManagers] = useState([]);
|
2025-12-21 19:19:02 +09:00
|
|
|
|
const [members, setMembers] = useState([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
|
|
const [editingMember, setEditingMember] = useState(null);
|
|
|
|
|
|
const [detailModalUser, setDetailModalUser] = useState(null);
|
|
|
|
|
|
const [isIdChecked, setIsIdChecked] = useState(false);
|
|
|
|
|
|
const [isIdChecking, setIsIdChecking] = useState(false);
|
|
|
|
|
|
const [idCheckMessage, setIdCheckMessage] = useState('');
|
|
|
|
|
|
const [deleteConfirmMember, setDeleteConfirmMember] = useState(null);
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
2025-12-21 19:19:02 +09:00
|
|
|
|
const [formData, setFormData] = useState({
|
|
|
|
|
|
member_id: '',
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
phone: '',
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
role: 'manager',
|
|
|
|
|
|
parent_id: '',
|
|
|
|
|
|
remarks: ''
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-17 12:59:26 +09:00
|
|
|
|
useEffect(() => {
|
2025-12-21 19:19:02 +09:00
|
|
|
|
fetchMembers();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const fetchMembers = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`api/sales_members.php?action=list`);
|
|
|
|
|
|
const result = await res.json();
|
|
|
|
|
|
if (result.success) setMembers(result.data);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Fetch error:', err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenAdd = () => {
|
|
|
|
|
|
setEditingMember(null);
|
|
|
|
|
|
setFormData({ member_id: '', password: '', name: '', phone: '', email: '', role: 'manager', parent_id: '', remarks: '' });
|
|
|
|
|
|
setIsIdChecked(false);
|
|
|
|
|
|
setIdCheckMessage('');
|
|
|
|
|
|
setIsModalOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCheckId = async () => {
|
|
|
|
|
|
if (!formData.member_id) {
|
|
|
|
|
|
setIdCheckMessage('아이디를 입력해주세요.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setIsIdChecking(true);
|
|
|
|
|
|
setIdCheckMessage('');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`api/sales_members.php?action=check_id`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ member_id: formData.member_id })
|
2025-12-17 12:59:26 +09:00
|
|
|
|
});
|
2025-12-21 19:19:02 +09:00
|
|
|
|
const result = await res.json();
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
if (result.exists) {
|
|
|
|
|
|
setIdCheckMessage('이미 사용 중인 아이디입니다.');
|
|
|
|
|
|
setIsIdChecked(false);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setIdCheckMessage('사용 가능한 아이디입니다.');
|
|
|
|
|
|
setIsIdChecked(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setIdCheckMessage(result.error || '확인 실패');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setIdCheckMessage('오류가 발생했습니다.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsIdChecking(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenEdit = (member) => {
|
|
|
|
|
|
setEditingMember(member);
|
|
|
|
|
|
setFormData({
|
|
|
|
|
|
member_id: member.member_id,
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
name: member.name,
|
|
|
|
|
|
phone: member.phone || '',
|
|
|
|
|
|
email: member.email || '',
|
|
|
|
|
|
role: member.role || 'manager',
|
|
|
|
|
|
parent_id: member.parent_id || '',
|
|
|
|
|
|
remarks: member.remarks || ''
|
|
|
|
|
|
});
|
|
|
|
|
|
setIsModalOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const action = editingMember ? 'update' : 'create';
|
|
|
|
|
|
const method = editingMember ? 'PUT' : 'POST';
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
2025-12-21 19:19:02 +09:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`api/sales_members.php${action === 'create' ? '?action=create' : ''}`, {
|
|
|
|
|
|
method: method,
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
...formData,
|
|
|
|
|
|
id: editingMember?.id
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await res.json();
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
alert(result.message);
|
|
|
|
|
|
setIsModalOpen(false);
|
|
|
|
|
|
fetchMembers();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert(result.error);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
alert('저장 중 오류가 발생했습니다.');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleMemberDelete = (member) => {
|
|
|
|
|
|
console.log('[OperatorView] handleDelete triggered for:', member.name, member.id);
|
|
|
|
|
|
setDeleteConfirmMember(member);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const executeDelete = async () => {
|
|
|
|
|
|
const member = deleteConfirmMember;
|
|
|
|
|
|
if (!member) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`api/sales_members.php?action=delete`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ id: member.id })
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await res.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
alert(result.message || '삭제되었습니다.');
|
|
|
|
|
|
setDeleteConfirmMember(null);
|
|
|
|
|
|
fetchMembers();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert(result.error || '삭제에 실패했습니다.');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Delete error exception:', err);
|
|
|
|
|
|
alert('삭제 중 오류가 발생했습니다: ' + err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 통계 계산 (실제 데이터 기반)
|
|
|
|
|
|
const totalStats = {
|
|
|
|
|
|
contractCount: 0, // 나중에 실적 API와 연동 필요
|
|
|
|
|
|
totalSales: 0,
|
|
|
|
|
|
totalCommission: 0,
|
|
|
|
|
|
monthlyContractCount: 0,
|
|
|
|
|
|
monthlySales: 0,
|
2025-12-17 12:59:26 +09:00
|
|
|
|
monthlyCommission: 0,
|
|
|
|
|
|
lastMonthCommission: 0
|
2025-12-21 19:19:02 +09:00
|
|
|
|
};
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
|
|
|
|
|
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-12">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">영업 전체 관리</h2>
|
|
|
|
|
|
<p className="text-slate-500 mt-2 text-lg">플랫폼의 모든 영업 관리자와 매니저를 총괄 관리합니다.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleOpenAdd}
|
|
|
|
|
|
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-xl font-bold flex items-center gap-2 transition-all shadow-xl shadow-blue-200 hover:-translate-y-0.5 active:translate-y-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
<LucideIcon name="user-plus" className="w-5 h-5" />
|
|
|
|
|
|
신규 담당자 등록
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 통계 카드 */}
|
2025-12-20 21:55:43 +09:00
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<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>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
|
|
|
|
|
|
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow">
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<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>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
|
|
|
|
|
|
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow">
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<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>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
|
|
|
|
|
|
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow">
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<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>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
|
|
|
|
|
|
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow">
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<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>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
|
|
|
|
|
|
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow">
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-12-21 19:19:02 +09:00
|
|
|
|
{/* 영업담당자 통합 관리 (CRUD Table) */}
|
|
|
|
|
|
<section className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden">
|
|
|
|
|
|
<div className="p-6 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
|
|
|
|
|
|
<h3 className="text-xl font-bold text-slate-900 flex items-center gap-2">
|
|
|
|
|
|
<LucideIcon name="users" className="w-5 h-5 text-blue-600" />
|
|
|
|
|
|
전체 영업자 및 매니저 목록
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<button onClick={fetchMembers} className="p-2 text-slate-500 hover:bg-slate-100 rounded-lg transition-colors" title="새로고침">
|
|
|
|
|
|
<LucideIcon name="refresh-cw" className={`${loading ? 'animate-spin' : ''} w-5 h-5`} />
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="overflow-x-auto text-sm">
|
|
|
|
|
|
<table className="w-full text-left">
|
|
|
|
|
|
<thead className="bg-slate-50 border-b border-slate-100 text-slate-500 font-bold uppercase text-xs">
|
|
|
|
|
|
<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">연락처</th>
|
|
|
|
|
|
<th className="px-6 py-4">가입일</th>
|
|
|
|
|
|
<th className="px-6 py-4 text-center">관리</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody className="divide-y divide-slate-100">
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<tr><td colSpan="7" className="px-6 py-12 text-center text-slate-400">데이터 로딩 중...</td></tr>
|
|
|
|
|
|
) : members.length === 0 ? (
|
|
|
|
|
|
<tr><td colSpan="7" className="px-6 py-12 text-center text-slate-400">등록된 영업 인력이 없습니다.</td></tr>
|
|
|
|
|
|
) : members.map(m => (
|
|
|
|
|
|
<tr key={m.id} className="hover:bg-blue-50/30 transition-colors">
|
|
|
|
|
|
<td className="px-6 py-4 font-bold text-slate-900">{m.name}</td>
|
|
|
|
|
|
<td className="px-6 py-4 text-slate-600 font-mono text-xs">{m.member_id}</td>
|
|
|
|
|
|
<td className="px-6 py-4">
|
|
|
|
|
|
<span
|
|
|
|
|
|
onClick={() => m.role === 'sales_admin' && setDetailModalUser(m)}
|
|
|
|
|
|
className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase transition-all ${
|
|
|
|
|
|
m.role === 'sales_admin' ? 'bg-indigo-100 text-indigo-700 cursor-pointer hover:bg-indigo-200 ring-1 ring-indigo-200' :
|
|
|
|
|
|
m.role === 'manager' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-700'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
title={m.role === 'sales_admin' ? "하위 멤버 보기" : ""}
|
|
|
|
|
|
>
|
|
|
|
|
|
{m.role === 'sales_admin' ? '영업관리' : m.role === 'manager' ? '매니저' : m.role}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-6 py-4 text-slate-500">
|
|
|
|
|
|
{m.parent_id ? (() => {
|
|
|
|
|
|
const parent = members.find(p => p.id == m.parent_id);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
|
<LucideIcon name="corner-down-right" className="w-3 h-3 text-slate-300" />
|
|
|
|
|
|
{parent ? `${parent.name} (${parent.member_id})` : m.parent_id}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
})() : '-'}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-6 py-4 text-slate-600">{m.phone || '-'}</td>
|
|
|
|
|
|
<td className="px-6 py-4 text-slate-400 text-xs">{m.created_at?.split(' ')[0]}</td>
|
|
|
|
|
|
<td className="px-6 py-4">
|
|
|
|
|
|
<div className="flex items-center justify-center gap-1">
|
|
|
|
|
|
<button onClick={() => handleOpenEdit(m)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
|
|
|
|
|
<LucideIcon name="edit-2" className="w-4 h-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
console.log('[OperatorView] Delete button clicked for:', m.name);
|
|
|
|
|
|
handleMemberDelete(m);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
|
|
|
|
title="인력 삭제"
|
|
|
|
|
|
>
|
|
|
|
|
|
<LucideIcon name="trash-2" className="w-4 h-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Member CRUD Modal */}
|
|
|
|
|
|
{isModalOpen && (
|
|
|
|
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
|
|
|
|
|
|
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in duration-200 border border-slate-200">
|
|
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
|
|
|
|
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
|
|
|
|
|
<h3 className="text-2xl font-black text-slate-900 flex items-center gap-3">
|
|
|
|
|
|
<div className="p-2 bg-blue-600 rounded-xl text-white">
|
|
|
|
|
|
<LucideIcon name={editingMember ? "user-cog" : "user-plus"} className="w-6 h-6" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{editingMember ? '정보 수정' : '신규 회원 등록'}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<button type="button" onClick={() => setIsModalOpen(false)} className="p-2 hover:bg-slate-200 rounded-full transition-colors text-slate-400">
|
|
|
|
|
|
<LucideIcon name="x" className="w-6 h-6" />
|
|
|
|
|
|
</button>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</div>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
|
|
|
|
|
|
<div className="p-8 space-y-6">
|
|
|
|
|
|
<div className="grid grid-cols-10 gap-6">
|
|
|
|
|
|
<div className="space-y-1.5 col-span-3">
|
|
|
|
|
|
<label className="text-xs font-black text-slate-500 uppercase tracking-wider">성명 *</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text" required
|
|
|
|
|
|
value={formData.name}
|
|
|
|
|
|
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
|
|
|
|
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all font-bold"
|
|
|
|
|
|
placeholder="홍길동"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1.5 col-span-7">
|
|
|
|
|
|
<label className="text-xs font-black text-slate-500 uppercase tracking-wider">아이디 *</label>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text" required
|
|
|
|
|
|
disabled={editingMember}
|
|
|
|
|
|
value={formData.member_id}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
setFormData({...formData, member_id: e.target.value});
|
|
|
|
|
|
setIsIdChecked(false);
|
|
|
|
|
|
setIdCheckMessage('');
|
|
|
|
|
|
}}
|
|
|
|
|
|
className={`w-full px-4 py-3 bg-slate-50 border ${isIdChecked ? 'border-emerald-500 bg-emerald-50' : 'border-slate-200'} rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all font-mono disabled:opacity-50`}
|
|
|
|
|
|
placeholder="sales_01"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{!editingMember && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={handleCheckId}
|
|
|
|
|
|
disabled={isIdChecking || !formData.member_id}
|
|
|
|
|
|
className={`px-4 py-2 rounded-xl font-bold transition-all whitespace-nowrap flex items-center gap-2 ${
|
|
|
|
|
|
isIdChecked
|
|
|
|
|
|
? 'bg-emerald-500 text-white'
|
|
|
|
|
|
: 'bg-slate-200 text-slate-600 hover:bg-slate-300 shadow-sm'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isIdChecking ? (
|
|
|
|
|
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
|
|
|
|
|
) : isIdChecked ? (
|
|
|
|
|
|
<LucideIcon name="check" className="w-4 h-4" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<LucideIcon name="search" className="w-4 h-4" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{isIdChecking ? '...' : isIdChecked ? '완료' : '중복 확인'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{!editingMember && idCheckMessage && (
|
|
|
|
|
|
<p className={`text-[10px] font-bold ml-1 mt-1 flex items-center gap-1 ${isIdChecked ? 'text-emerald-600' : 'text-red-500'}`}>
|
|
|
|
|
|
{idCheckMessage}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</div>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<label className="text-xs font-black text-slate-500 uppercase tracking-wider">역할 설정</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={formData.role}
|
|
|
|
|
|
onChange={(e) => setFormData({...formData, role: e.target.value})}
|
|
|
|
|
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all font-bold"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="sales_admin">영업관리 (Sales Admin)</option>
|
|
|
|
|
|
<option value="manager">매니저 (Manager)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<label className="text-xs font-black text-slate-500 uppercase tracking-wider">상위 보스</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={formData.parent_id}
|
|
|
|
|
|
onChange={(e) => setFormData({...formData, parent_id: e.target.value})}
|
|
|
|
|
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all font-bold"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">없음 (최상위)</option>
|
|
|
|
|
|
{members.filter(m => m.id != editingMember?.id).map(m => (
|
|
|
|
|
|
<option key={m.id} value={m.id}>{m.name} ({m.member_id})</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<label className="text-xs font-black text-slate-500 uppercase tracking-wider">비밀번호 {editingMember && '(변경 시에만 입력)'}</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
required={!editingMember}
|
|
|
|
|
|
value={formData.password}
|
|
|
|
|
|
onChange={(e) => setFormData({...formData, password: e.target.value})}
|
|
|
|
|
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
|
|
|
|
|
|
placeholder={editingMember ? "********" : "비밀번호 입력"}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<label className="text-xs font-black text-slate-500 uppercase tracking-wider">연락처</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="tel"
|
|
|
|
|
|
value={formData.phone}
|
|
|
|
|
|
onChange={(e) => setFormData({...formData, phone: e.target.value})}
|
|
|
|
|
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all font-medium"
|
|
|
|
|
|
placeholder="010-0000-0000"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<label className="text-xs font-black text-slate-500 uppercase tracking-wider">이메일</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="email"
|
|
|
|
|
|
value={formData.email}
|
|
|
|
|
|
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
|
|
|
|
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all font-medium"
|
|
|
|
|
|
placeholder="example@email.com"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<div className="p-8 border-t border-slate-100 bg-slate-50/50 flex justify-end gap-4">
|
|
|
|
|
|
<button type="button" onClick={() => setIsModalOpen(false)} className="px-6 py-3 text-slate-600 bg-white border border-slate-200 rounded-xl font-bold hover:bg-slate-50 transition-all">취소</button>
|
|
|
|
|
|
<button type="submit"
|
|
|
|
|
|
disabled={!editingMember && !isIdChecked}
|
|
|
|
|
|
className={`px-8 py-3 bg-blue-600 text-white rounded-xl font-bold shadow-xl shadow-blue-100 hover:bg-blue-700 transition-all ${(!editingMember && !isIdChecked) ? 'opacity-50 cursor-not-allowed grayscale' : ''}`}>
|
|
|
|
|
|
{editingMember ? '수정 완료' : '등록 하기'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Manager Detail Modal (하위 멤버 목록) */}
|
|
|
|
|
|
{detailModalUser && (
|
|
|
|
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
|
|
|
|
|
|
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-4xl overflow-hidden animate-in fade-in zoom-in duration-200 border border-slate-200 flex flex-col max-h-[80vh]">
|
|
|
|
|
|
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="text-2xl font-black text-slate-900 flex items-center gap-3">
|
|
|
|
|
|
<div className="p-2 bg-indigo-600 rounded-xl text-white">
|
|
|
|
|
|
<LucideIcon name="users" className="w-6 h-6" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{detailModalUser.name} ({detailModalUser.member_id}) 하위 멤버
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p className="text-slate-500 mt-1">이 영업관리자에게 소속된 모든 매니저 목록입니다.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button onClick={() => setDetailModalUser(null)} className="p-2 hover:bg-slate-200 rounded-full transition-colors text-slate-400">
|
|
|
|
|
|
<LucideIcon name="x" className="w-6 h-6" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="overflow-y-auto p-8">
|
|
|
|
|
|
<table className="w-full text-left text-sm">
|
|
|
|
|
|
<thead className="bg-slate-50 border-b border-slate-100 text-slate-500 font-bold uppercase text-xs">
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<tr>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<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-center">작업</th>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody className="divide-y divide-slate-100">
|
2025-12-21 19:19:02 +09:00
|
|
|
|
{members.filter(m => m.parent_id == detailModalUser.id).length === 0 ? (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td colSpan="5" className="px-6 py-12 text-center text-slate-400">
|
|
|
|
|
|
하위 멤버가 없습니다.
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
) : (
|
|
|
|
|
|
members.filter(m => m.parent_id == detailModalUser.id).map(m => (
|
|
|
|
|
|
<tr key={m.id} className="hover:bg-blue-50/30 transition-colors">
|
|
|
|
|
|
<td className="px-6 py-4 font-bold text-slate-900">{m.name}</td>
|
|
|
|
|
|
<td className="px-6 py-4 text-slate-600 font-mono text-xs">{m.member_id}</td>
|
|
|
|
|
|
<td className="px-6 py-4">
|
|
|
|
|
|
<span className="px-2 py-0.5 rounded-full text-[10px] bg-emerald-100 text-emerald-700 font-bold uppercase">
|
|
|
|
|
|
매니저
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-6 py-4 text-slate-600">{m.phone || '-'}</td>
|
|
|
|
|
|
<td className="px-6 py-4 text-center">
|
|
|
|
|
|
<div className="flex items-center justify-center gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setDetailModalUser(null);
|
|
|
|
|
|
handleOpenEdit(m);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
|
|
|
|
|
title="수정"
|
|
|
|
|
|
>
|
|
|
|
|
|
<LucideIcon name="edit-2" className="w-4 h-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
2025-12-21 19:19:14 +09:00
|
|
|
|
console.log('[OperatorView-DetailModal] Delete button clicked for:', m.name);
|
2025-12-21 19:19:02 +09:00
|
|
|
|
handleMemberDelete(m);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
|
|
|
|
title="삭제"
|
|
|
|
|
|
>
|
|
|
|
|
|
<LucideIcon name="trash-2" className="w-4 h-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<div className="p-8 border-t border-slate-100 bg-slate-50/50 flex justify-end">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setDetailModalUser(null)}
|
|
|
|
|
|
className="px-8 py-3 bg-slate-200 text-slate-700 rounded-xl font-bold hover:bg-slate-300 transition-all"
|
2025-12-17 12:59:26 +09:00
|
|
|
|
>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
닫기
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 아이템 설정 카드 (운영자 전용) */}
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<div className="mt-20 pt-12 border-t border-slate-200">
|
|
|
|
|
|
<h3 className="text-2xl font-black text-slate-900 mb-8 flex items-center gap-3">
|
|
|
|
|
|
<div className="p-2 bg-indigo-600 rounded-xl text-white shadow-lg shadow-indigo-100">
|
|
|
|
|
|
<LucideIcon name="settings" className="w-6 h-6" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
베이직 요금 및 수당 설정
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</h3>
|
|
|
|
|
|
<ItemPricingManager />
|
|
|
|
|
|
</div>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
|
|
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
|
|
|
|
{deleteConfirmMember && (
|
|
|
|
|
|
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-in fade-in duration-300">
|
|
|
|
|
|
<div className="bg-white rounded-[2rem] shadow-2xl w-full max-w-md overflow-hidden animate-in zoom-in duration-200 border border-white/20">
|
|
|
|
|
|
<div className="p-8 text-center">
|
|
|
|
|
|
<div className="w-20 h-20 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-6 ring-8 ring-red-50/50">
|
|
|
|
|
|
<LucideIcon name="trash-2" className="w-10 h-10 text-red-500" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h3 className="text-2xl font-black text-slate-900 mb-3">인력 삭제 확인</h3>
|
|
|
|
|
|
<p className="text-slate-600 leading-relaxed font-medium">
|
|
|
|
|
|
정말 <span className="text-red-600 font-bold">'{deleteConfirmMember.name}({deleteConfirmMember.member_id})'</span> 인력을 삭제하시겠습니까?
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
{members.some(m => m.parent_id == deleteConfirmMember.id) && (
|
|
|
|
|
|
<div className="mt-4 p-4 bg-amber-50 border border-amber-100 rounded-2xl flex items-start gap-3 text-left">
|
|
|
|
|
|
<LucideIcon name="alert-triangle" className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-sm font-bold text-amber-900">주의 사항</p>
|
|
|
|
|
|
<p className="text-xs text-amber-800 mt-1 leading-normal">
|
|
|
|
|
|
이 인력은 하위 담당자가 있습니다. 삭제 시 상위 관리자 정보가 유실될 수 있습니다.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="p-6 bg-slate-50 flex gap-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setDeleteConfirmMember(null)}
|
|
|
|
|
|
className="flex-1 py-4 bg-white text-slate-600 rounded-2xl font-bold border border-slate-200 hover:bg-slate-100 transition-all active:scale-[0.98]"
|
|
|
|
|
|
>
|
|
|
|
|
|
취소
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={executeDelete}
|
|
|
|
|
|
className="flex-1 py-4 bg-red-600 text-white rounded-2xl font-bold shadow-lg shadow-red-200 hover:bg-red-700 transition-all active:scale-[0.98]"
|
|
|
|
|
|
>
|
|
|
|
|
|
삭제 실행
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-21 19:19:02 +09:00
|
|
|
|
// 2. StatCard Component (Move up to be reusable)
|
|
|
|
|
|
const StatCard = ({ title, value, subtext, icon, onClick }) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow ${onClick ? 'cursor-pointer' : ''}`}
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-12-21 19:19:02 +09:00
|
|
|
|
// --- NEW: Login View Component ---
|
|
|
|
|
|
const LoginView = ({ onLoginSuccess, selectedRole }) => {
|
|
|
|
|
|
const [memberId, setMemberId] = useState('');
|
|
|
|
|
|
const [password, setPassword] = useState('');
|
|
|
|
|
|
const [error, setError] = useState('');
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (selectedRole === '운영자') { setMemberId('admin'); setPassword('admin'); }
|
|
|
|
|
|
else if (selectedRole === '영업관리') { setMemberId('sales'); setPassword('sales'); }
|
|
|
|
|
|
else if (selectedRole === '매니저') { setMemberId('manager'); setPassword('manager'); }
|
|
|
|
|
|
else { setMemberId(''); setPassword(''); }
|
|
|
|
|
|
}, [selectedRole]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleLogin = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError('');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('api/sales_members.php?action=login', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ member_id: memberId, password })
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (data.success) {
|
|
|
|
|
|
if (data.message && !data.user) {
|
|
|
|
|
|
alert(data.message); // Admin/Manager creation notice (requiring manual re-login)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (data.message) console.log(data.message);
|
|
|
|
|
|
onLoginSuccess(data.user);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setError(data.error || '로그인에 실패했습니다.');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError('서버 통신 오류가 발생했습니다.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center p-4">
|
|
|
|
|
|
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl border border-slate-100 p-8">
|
|
|
|
|
|
<div className="text-center mb-8">
|
|
|
|
|
|
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
|
|
|
|
<LucideIcon name="lock" className="w-8 h-8 text-blue-600" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h2 className="text-2xl font-bold text-slate-900">영업관리 로그인</h2>
|
|
|
|
|
|
<p className="text-slate-500 mt-2 font-medium">[{selectedRole}] 접근 권한이 필요합니다.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleLogin} className="space-y-6">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">아이디</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
required
|
|
|
|
|
|
value={memberId}
|
|
|
|
|
|
onChange={(e) => setMemberId(e.target.value)}
|
|
|
|
|
|
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
|
|
|
|
|
placeholder="아이디를 입력하세요"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">비밀번호</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
required
|
|
|
|
|
|
value={password}
|
|
|
|
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
|
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
|
|
|
|
|
placeholder="비밀번호를 입력하세요"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
|
<div className="p-3 bg-red-50 border border-red-100 rounded-lg text-sm text-red-600 font-medium">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
disabled={loading}
|
|
|
|
|
|
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-bold shadow-lg shadow-blue-200 transition-all transform active:scale-[0.98] disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
{loading ? '로그인 중...' : '로그인'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-8 pt-6 border-t border-slate-100 text-center">
|
|
|
|
|
|
<p className="text-xs text-slate-400 font-medium">
|
|
|
|
|
|
테스트 계정: <br/>
|
|
|
|
|
|
운영자: admin / admin <br/>
|
|
|
|
|
|
영업관리: sales / sales <br/>
|
|
|
|
|
|
매니저: manager / manager
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// --- NEW: Manager Management View (CRUD) ---
|
|
|
|
|
|
const ManagerManagementView = ({ currentUser }) => {
|
|
|
|
|
|
const [members, setMembers] = useState([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
|
|
const [editingMember, setEditingMember] = useState(null);
|
|
|
|
|
|
const [isIdChecked, setIsIdChecked] = useState(false);
|
|
|
|
|
|
const [isIdChecking, setIsIdChecking] = useState(false);
|
|
|
|
|
|
const [idCheckMessage, setIdCheckMessage] = useState('');
|
|
|
|
|
|
const [deleteConfirmMember, setDeleteConfirmMember] = useState(null);
|
|
|
|
|
|
|
|
|
|
|
|
// Form states
|
|
|
|
|
|
const [formData, setFormData] = useState({
|
|
|
|
|
|
member_id: '',
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
phone: '',
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
remarks: ''
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (currentUser) {
|
|
|
|
|
|
fetchMembers();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [currentUser]);
|
|
|
|
|
|
|
|
|
|
|
|
const fetchMembers = async () => {
|
|
|
|
|
|
if (!currentUser) return;
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`api/sales_members.php?action=list&parent_id=${currentUser.id}`);
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (data.success) setMembers(data.data);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Fetch error:', err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenAdd = () => {
|
|
|
|
|
|
setEditingMember(null);
|
|
|
|
|
|
setFormData({ member_id: '', password: '', name: '', phone: '', email: '', remarks: '' });
|
|
|
|
|
|
setIsIdChecked(false);
|
|
|
|
|
|
setIdCheckMessage('');
|
|
|
|
|
|
setIsModalOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCheckId = async () => {
|
|
|
|
|
|
if (!formData.member_id) {
|
|
|
|
|
|
setIdCheckMessage('아이디를 입력해주세요.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setIsIdChecking(true);
|
|
|
|
|
|
setIdCheckMessage('');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`api/sales_members.php?action=check_id`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ member_id: formData.member_id })
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await res.json();
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
if (result.exists) {
|
|
|
|
|
|
setIdCheckMessage('이미 사용 중인 아이디입니다.');
|
|
|
|
|
|
setIsIdChecked(false);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setIdCheckMessage('사용 가능한 아이디입니다.');
|
|
|
|
|
|
setIsIdChecked(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setIdCheckMessage(result.error || '확인 실패');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setIdCheckMessage('오류가 발생했습니다.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsIdChecking(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenEdit = (member) => {
|
|
|
|
|
|
setEditingMember(member);
|
|
|
|
|
|
setFormData({
|
|
|
|
|
|
member_id: member.member_id,
|
|
|
|
|
|
password: '', // 비밀번호는 비워둠 (변경 시만 입력)
|
|
|
|
|
|
name: member.name,
|
|
|
|
|
|
phone: member.phone || '',
|
|
|
|
|
|
email: member.email || '',
|
|
|
|
|
|
remarks: member.remarks || ''
|
|
|
|
|
|
});
|
|
|
|
|
|
setIsIdChecked(true); // 수정 시에는 이미 존재하므로 체크 완료 상태로 설정
|
|
|
|
|
|
setIsModalOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const action = editingMember ? 'update' : 'create';
|
|
|
|
|
|
const method = editingMember ? 'PUT' : 'POST';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`api/sales_members.php${action === 'create' ? '?action=create' : ''}`, {
|
|
|
|
|
|
method: method,
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
...formData,
|
|
|
|
|
|
id: editingMember?.id
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await res.json();
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
alert(result.message);
|
|
|
|
|
|
setIsModalOpen(false);
|
|
|
|
|
|
fetchMembers();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert(result.error);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
alert('저장 중 오류가 발생했습니다.');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDelete = (member) => {
|
|
|
|
|
|
console.log('[ManagerManagementView] handleDelete triggered for:', member.name, member.id);
|
|
|
|
|
|
setDeleteConfirmMember(member);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const executeDelete = async () => {
|
|
|
|
|
|
const member = deleteConfirmMember;
|
|
|
|
|
|
if (!member) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`api/sales_members.php?action=delete`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ id: member.id })
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await res.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
alert(result.message || '삭제되었습니다.');
|
|
|
|
|
|
setDeleteConfirmMember(null);
|
|
|
|
|
|
fetchMembers();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert(result.error || '삭제에 실패했습니다.');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Delete error:', err);
|
|
|
|
|
|
alert('삭제 중 오류가 발생했습니다.');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-2xl font-bold text-slate-900">내 하위 담당자 관리</h2>
|
|
|
|
|
|
<p className="text-slate-500 text-sm mt-1">내가 직접 영입하여 관리하는 팀원들의 정보를 관리합니다.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleOpenAdd}
|
|
|
|
|
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-bold flex items-center gap-2 transition-all shadow-lg shadow-blue-100"
|
|
|
|
|
|
>
|
|
|
|
|
|
<LucideIcon name="plus" className="w-4 h-4" />
|
|
|
|
|
|
담당자 등록
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden text-sm">
|
|
|
|
|
|
<table className="w-full text-left">
|
|
|
|
|
|
<thead className="bg-slate-50 border-b border-slate-100 text-slate-500 font-bold uppercase text-xs">
|
|
|
|
|
|
<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">비고</th>
|
|
|
|
|
|
<th className="px-6 py-4">등록일</th>
|
|
|
|
|
|
<th className="px-6 py-4 text-center">관리</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody className="divide-y divide-slate-100">
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<tr><td colSpan="7" className="px-6 py-10 text-center text-slate-400">데이터 로딩 중...</td></tr>
|
|
|
|
|
|
) : members.length === 0 ? (
|
|
|
|
|
|
<tr><td colSpan="7" className="px-6 py-10 text-center text-slate-400">등록된 담당자가 없습니다.</td></tr>
|
|
|
|
|
|
) : members.map(m => (
|
|
|
|
|
|
<tr key={m.id} className="hover:bg-slate-50 transition-colors">
|
|
|
|
|
|
<td className="px-6 py-4 font-bold text-slate-900">{m.name}</td>
|
|
|
|
|
|
<td className="px-6 py-4 text-slate-600">{m.member_id}</td>
|
|
|
|
|
|
<td className="px-6 py-4 text-slate-600">{m.phone || '-'}</td>
|
|
|
|
|
|
<td className="px-6 py-4 text-slate-600">{m.email || '-'}</td>
|
|
|
|
|
|
<td className="px-6 py-4 text-slate-500 italic max-w-xs truncate">{m.remarks || '-'}</td>
|
|
|
|
|
|
<td className="px-6 py-4 text-slate-400 text-xs">{m.created_at?.split(' ')[0]}</td>
|
|
|
|
|
|
<td className="px-6 py-4">
|
|
|
|
|
|
<div className="flex items-center justify-center gap-2">
|
|
|
|
|
|
<button onClick={() => handleOpenEdit(m)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg" title="수정">
|
|
|
|
|
|
<LucideIcon name="edit-2" className="w-4 h-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
console.log('[ManagerManagementView] Delete button clicked for:', m.name);
|
|
|
|
|
|
handleDelete(m);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="p-2 text-red-600 hover:bg-red-50 rounded-lg"
|
|
|
|
|
|
title="삭제"
|
|
|
|
|
|
>
|
|
|
|
|
|
<LucideIcon name="trash-2" className="w-4 h-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* CRUD Modal */}
|
|
|
|
|
|
{isModalOpen && (
|
|
|
|
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
|
|
|
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
|
|
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
|
|
|
|
<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={editingMember ? "edit" : "user-plus"} className="w-5 h-5 text-blue-600" />
|
|
|
|
|
|
{editingMember ? '담당자 정보 수정' : '신규 담당자 등록'}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<button type="button" onClick={() => setIsModalOpen(false)} 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 className="grid grid-cols-10 gap-4">
|
|
|
|
|
|
<div className="col-span-3">
|
|
|
|
|
|
<label className="block text-xs font-bold text-slate-500 mb-1">성명 *</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text" required
|
|
|
|
|
|
value={formData.name}
|
|
|
|
|
|
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
|
|
|
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="col-span-7">
|
|
|
|
|
|
<label className="block text-xs font-bold text-slate-500 mb-1">아이디 *</label>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text" required
|
|
|
|
|
|
disabled={editingMember}
|
|
|
|
|
|
value={formData.member_id}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
setFormData({...formData, member_id: e.target.value});
|
|
|
|
|
|
setIsIdChecked(false);
|
|
|
|
|
|
setIdCheckMessage('');
|
|
|
|
|
|
}}
|
|
|
|
|
|
className={`w-full px-3 py-2 border ${isIdChecked ? 'border-emerald-500 bg-emerald-50' : 'border-slate-200'} rounded-lg outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-slate-50 font-mono`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{!editingMember && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={handleCheckId}
|
|
|
|
|
|
disabled={isIdChecking || !formData.member_id}
|
|
|
|
|
|
className={`px-3 py-1 rounded-lg font-bold transition-all whitespace-nowrap text-xs flex items-center gap-1 ${
|
|
|
|
|
|
isIdChecked
|
|
|
|
|
|
? 'bg-emerald-500 text-white'
|
|
|
|
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 shadow-sm border border-slate-200'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isIdChecking ? '...' : isIdChecked ? '완료' : '중복 확인'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{!editingMember && idCheckMessage && (
|
|
|
|
|
|
<p className={`text-[10px] font-bold ml-1 mt-1 ${isIdChecked ? 'text-emerald-600' : 'text-red-500'}`}>{idCheckMessage}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs font-bold text-slate-500 mb-1">비밀번호 {editingMember && '(변경 시에만 입력)'}</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
required={!editingMember}
|
|
|
|
|
|
value={formData.password}
|
|
|
|
|
|
onChange={(e) => setFormData({...formData, password: e.target.value})}
|
|
|
|
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
|
|
|
placeholder={editingMember ? "********" : "비밀번호 입력"}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs font-bold text-slate-500 mb-1">연락처</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="tel"
|
|
|
|
|
|
value={formData.phone}
|
|
|
|
|
|
onChange={(e) => setFormData({...formData, phone: e.target.value})}
|
|
|
|
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
|
|
|
placeholder="010-0000-0000"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs font-bold text-slate-500 mb-1">이메일</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="email"
|
|
|
|
|
|
value={formData.email}
|
|
|
|
|
|
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
|
|
|
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
|
|
|
placeholder="example@email.com"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs font-bold text-slate-500 mb-1">비고</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={formData.remarks}
|
|
|
|
|
|
onChange={(e) => setFormData({...formData, remarks: e.target.value})}
|
|
|
|
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 h-20 resize-none"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
|
|
|
|
|
|
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-slate-700 bg-white border border-slate-300 rounded-lg font-medium">취소</button>
|
|
|
|
|
|
<button type="submit"
|
|
|
|
|
|
disabled={!editingMember && !isIdChecked}
|
|
|
|
|
|
className={`px-6 py-2 bg-blue-600 text-white rounded-lg font-bold shadow-lg shadow-blue-100 ${(!editingMember && !isIdChecked) ? 'opacity-50 grayscale cursor-not-allowed' : 'hover:bg-blue-700 transition-all'}`}>저장</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-12-21 19:19:14 +09:00
|
|
|
|
|
|
|
|
|
|
{/* Delete Confirmation Modal for ManagerManagementView */}
|
|
|
|
|
|
{deleteConfirmMember && (
|
|
|
|
|
|
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-in fade-in duration-300">
|
|
|
|
|
|
<div className="bg-white rounded-[2rem] shadow-2xl w-full max-w-md overflow-hidden animate-in zoom-in duration-200 border border-white/20">
|
|
|
|
|
|
<div className="p-8 text-center">
|
|
|
|
|
|
<div className="w-20 h-20 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-6 ring-8 ring-red-50/50">
|
|
|
|
|
|
<LucideIcon name="trash-2" className="w-10 h-10 text-red-500" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h3 className="text-2xl font-black text-slate-900 mb-3">담당자 삭제 확인</h3>
|
|
|
|
|
|
<p className="text-slate-600 leading-relaxed font-medium">
|
|
|
|
|
|
정말 <span className="text-red-600 font-bold">'{deleteConfirmMember.name}({deleteConfirmMember.member_id})'</span> 담당자를 삭제하시겠습니까?
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="p-6 bg-slate-50 flex gap-3 mt-4">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setDeleteConfirmMember(null)}
|
|
|
|
|
|
className="flex-1 py-4 bg-white text-slate-600 rounded-2xl font-bold border border-slate-200"
|
|
|
|
|
|
>
|
|
|
|
|
|
취소
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={executeDelete}
|
|
|
|
|
|
className="flex-1 py-4 bg-red-600 text-white rounded-2xl font-bold shadow-lg shadow-red-200"
|
|
|
|
|
|
>
|
|
|
|
|
|
삭제 실행
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-12-21 19:19:02 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-17 12:59:26 +09:00
|
|
|
|
// 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('영업관리'); // 기본값: 영업관리
|
2025-12-21 19:19:02 +09:00
|
|
|
|
const [organizationData, setOrganizationData] = useState(null);
|
|
|
|
|
|
const [isOrgLoading, setIsOrgLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// Session states
|
|
|
|
|
|
const [currentUser, setCurrentUser] = useState(null);
|
|
|
|
|
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-12-21 19:19:02 +09:00
|
|
|
|
checkSession();
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch Mock Data (Remains same for UI parts)
|
2025-12-17 12:59:26 +09:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [selectedRole]);
|
2025-12-21 19:19:02 +09:00
|
|
|
|
|
|
|
|
|
|
const checkSession = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('api/sales_members.php?action=check_session');
|
|
|
|
|
|
const sessData = await res.json();
|
|
|
|
|
|
if (sessData.success) {
|
|
|
|
|
|
setCurrentUser(sessData.user);
|
|
|
|
|
|
setIsLoggedIn(true);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Auto-login for test roles if no session
|
|
|
|
|
|
if (['영업관리', '매니저'].includes(selectedRole)) {
|
|
|
|
|
|
await attemptAutoLogin(selectedRole);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setCurrentUser(null);
|
|
|
|
|
|
setIsLoggedIn(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Session check failed:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
2025-12-21 19:19:02 +09:00
|
|
|
|
const handleLoginSuccess = (user) => {
|
|
|
|
|
|
setCurrentUser(user);
|
|
|
|
|
|
setIsLoggedIn(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleLogout = async () => {
|
|
|
|
|
|
await fetch('api/sales_members.php?action=logout', { method: 'POST' });
|
|
|
|
|
|
setIsLoggedIn(false);
|
|
|
|
|
|
setCurrentUser(null);
|
|
|
|
|
|
setOrganizationData(null);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const attemptAutoLogin = async (role) => {
|
|
|
|
|
|
let memberId, password;
|
|
|
|
|
|
if (role === '영업관리') { memberId = 'sales'; password = 'sales'; }
|
|
|
|
|
|
else if (role === '매니저') { memberId = 'manager'; password = 'manager'; }
|
|
|
|
|
|
else if (role === '운영자') { memberId = 'admin'; password = 'admin'; }
|
|
|
|
|
|
else return;
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`Attempting auto login for ${role}...`);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('api/sales_members.php?action=login', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ member_id: memberId, password })
|
2025-12-17 12:59:26 +09:00
|
|
|
|
});
|
2025-12-21 19:19:02 +09:00
|
|
|
|
const result = await res.json();
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
setCurrentUser(result.user);
|
|
|
|
|
|
setIsLoggedIn(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Auto login failed:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
// 실적 데이터 가져오기 (로그인 상태일 때만)
|
|
|
|
|
|
if (isLoggedIn) {
|
|
|
|
|
|
fetchPerformanceData();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setOrganizationData(null);
|
|
|
|
|
|
setIsOrgLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isLoggedIn, selectedRole]);
|
|
|
|
|
|
|
|
|
|
|
|
// 실적 데이터 가져오기 함수
|
|
|
|
|
|
const fetchPerformanceData = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setIsOrgLoading(true);
|
|
|
|
|
|
const res = await fetch(`api/get_performance.php`);
|
|
|
|
|
|
const result = await res.json();
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
setOrganizationData(result.org_tree);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Performance fetch failed:', result.error);
|
|
|
|
|
|
setOrganizationData(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Fetch error:', err);
|
|
|
|
|
|
setOrganizationData(null);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsOrgLoading(false);
|
|
|
|
|
|
}
|
2025-12-17 12:59:26 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 역할 변경 시 아이콘 업데이트 (조건부 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 = () => {
|
2025-12-21 19:19:02 +09:00
|
|
|
|
if (!isLoggedIn) {
|
|
|
|
|
|
return <LoginView onLoginSuccess={handleLoginSuccess} selectedRole={selectedRole} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 12:59:26 +09:00
|
|
|
|
switch (selectedRole) {
|
|
|
|
|
|
case '운영자':
|
2025-12-21 19:19:02 +09:00
|
|
|
|
return <OperatorView currentUser={currentUser} />;
|
2025-12-17 12:59:26 +09:00
|
|
|
|
case '영업관리':
|
2025-12-21 19:19:02 +09:00
|
|
|
|
case '매니저':
|
2025-12-17 12:59:26 +09:00
|
|
|
|
return (
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-12">
|
2025-12-17 12:59:26 +09:00
|
|
|
|
{/* 수당 지급 일정 안내 */}
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-12-21 19:19:02 +09:00
|
|
|
|
{/* Dashbaord & Org Tree */}
|
|
|
|
|
|
<SalesManagementDashboard organizationData={organizationData} onRefresh={fetchPerformanceData} isLoading={isOrgLoading} />
|
|
|
|
|
|
|
|
|
|
|
|
{selectedRole === '영업관리' && (
|
|
|
|
|
|
<ManagerManagementView currentUser={currentUser} />
|
|
|
|
|
|
)}
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2025-12-21 19:19:02 +09:00
|
|
|
|
</div>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</main>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="min-h-screen pb-20">
|
|
|
|
|
|
<Header
|
|
|
|
|
|
companyInfo={data.company_info}
|
|
|
|
|
|
onOpenHelp={() => setIsHelpOpen(true)}
|
|
|
|
|
|
selectedRole={selectedRole}
|
2025-12-21 19:19:02 +09:00
|
|
|
|
onRoleChange={async (role) => {
|
|
|
|
|
|
if (role !== selectedRole) {
|
|
|
|
|
|
await handleLogout();
|
|
|
|
|
|
setSelectedRole(role);
|
|
|
|
|
|
setIsOrgLoading(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
currentUser={currentUser}
|
|
|
|
|
|
onLogout={handleLogout}
|
2025-12-17 12:59:26 +09:00
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{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
|
2025-12-21 19:19:02 +09:00
|
|
|
|
const SalesManagementDashboard = ({ organizationData, onRefresh, isLoading }) => {
|
2025-12-17 12:59:26 +09:00
|
|
|
|
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);
|
2025-12-20 21:46:23 +09:00
|
|
|
|
const educatorCommission = 0; // 메뉴제작 협업수당: 운영팀 별도 산정
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
|
|
|
|
|
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) => {
|
2025-12-21 19:19:02 +09:00
|
|
|
|
const nodeRole = node.isDirect ? (node.depth === 0 ? 'direct' : (node.depth === 1 ? 'manager' : 'educator')) : 'manager'; // Default role for children nodes
|
|
|
|
|
|
|
|
|
|
|
|
const filteredContracts = (node.contracts || []).map(contract => ({
|
|
|
|
|
|
...contract,
|
|
|
|
|
|
role: nodeRole
|
|
|
|
|
|
})).filter(contract => {
|
2025-12-17 12:59:26 +09:00
|
|
|
|
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;
|
2025-12-20 21:46:23 +09:00
|
|
|
|
else if (node.depth === 2) commission = 0; // 메뉴제작 협업수당: 운영팀 별도 산정
|
2025-12-17 12:59:26 +09:00
|
|
|
|
} else {
|
|
|
|
|
|
// 영업관리
|
|
|
|
|
|
if (node.depth === 0) {
|
2025-12-20 21:46:23 +09:00
|
|
|
|
// 내 조직: 직접 20% + 1차 하위 5% (메뉴제작 협업수당 별도)
|
2025-12-17 12:59:26 +09:00
|
|
|
|
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);
|
2025-12-20 21:46:23 +09:00
|
|
|
|
commission = (myDirect ? myDirect.totalSales * 0.20 : 0) + (level1Sales * 0.05); // 메뉴제작 협업수당 제외
|
2025-12-17 12:59:26 +09:00
|
|
|
|
} else if (node.depth === 1) {
|
|
|
|
|
|
commission = totalSales * 0.05;
|
|
|
|
|
|
} else if (node.depth === 2) {
|
2025-12-20 21:46:23 +09:00
|
|
|
|
commission = 0; // 메뉴제작 협업수당: 운영팀 별도 산정
|
2025-12-17 12:59:26 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<span className="text-sm font-medium text-green-900">판매자</span>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<div className="text-xs text-green-600 mt-1"></div>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<span className="text-sm font-medium text-purple-900">관리자</span>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<div className="text-xs text-purple-600 mt-1"></div>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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">
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<LucideIcon name="users" className="w-4 h-4 text-orange-600" />
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</div>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<span className="text-sm font-medium text-orange-900">메뉴제작 협업수당</span>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</div>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<span className="text-xs font-bold text-orange-700 bg-orange-100 px-2 py-1 rounded">별도</span>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</div>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<div className="text-2xl font-bold text-orange-900">운영팀 산정</div>
|
|
|
|
|
|
<div className="text-xs text-orange-600 mt-1"></div>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<div className="flex items-center justify-end">
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<div className="text-right">
|
|
|
|
|
|
<div className="text-sm font-bold text-blue-900">{formatCurrency(periodStats.totalCommission)}</div>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<div className="text-xs text-blue-600">총 가입비 대비 수당</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 기간별 조직 트리 */}
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<OrganizationTree organizationData={periodOrgData} onRefresh={onRefresh} showPeriodData={true} isLoading={isLoading} />
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 7. Hierarchical Organization Tree Component
|
2025-12-21 19:19:02 +09:00
|
|
|
|
const OrganizationTree = ({ organizationData, onRefresh, showPeriodData, isLoading }) => {
|
2025-12-17 12:59:26 +09:00
|
|
|
|
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>
|
|
|
|
|
|
) : (
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<span title={`내가 받는 ${isDirect ? (node.depth === 0 ? '판매자 (20%)' : node.depth === 1 ? '관리자 (5%)' : '메뉴제작 협업자') : (node.depth === 1 ? '관리자 (5%)' : '메뉴제작 협업자')} 수당`}>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
내 수당: <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>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-21 19:19:02 +09:00
|
|
|
|
if (isLoading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section className="bg-white rounded-card shadow-sm border border-slate-100 p-8 space-y-4">
|
|
|
|
|
|
{[1, 2, 3].map(i => (
|
|
|
|
|
|
<div key={i} className="p-4 border border-slate-100 rounded-lg animate-pulse bg-slate-50/50">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<div className="w-10 h-10 rounded-full bg-slate-100"></div>
|
|
|
|
|
|
<div className="flex-1 space-y-2">
|
|
|
|
|
|
<div className="h-4 bg-slate-100 rounded w-1/4"></div>
|
|
|
|
|
|
<div className="h-3 bg-slate-100 rounded w-1/2"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 12:59:26 +09:00
|
|
|
|
if (!organizationData) {
|
|
|
|
|
|
return (
|
2025-12-21 19:19:02 +09:00
|
|
|
|
<section className="bg-white rounded-card shadow-sm border border-slate-100 p-12 text-center">
|
|
|
|
|
|
<div className="max-w-md mx-auto">
|
|
|
|
|
|
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-6 text-slate-300 border border-slate-50 shadow-inner">
|
|
|
|
|
|
<LucideIcon name="database-zap" className="w-10 h-10" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h3 className="text-xl font-bold text-slate-900 mb-2">실적 데이터가 존재하지 않습니다</h3>
|
|
|
|
|
|
<p className="text-slate-500 text-sm mb-8 leading-relaxed">
|
|
|
|
|
|
선택한 기간 내에 등록된 계약 정보나 조직 구성 데이터가 없습니다.<br/>
|
|
|
|
|
|
아직 실적이 발생하지 않았거나, 시스템 동기화 중일 수 있습니다.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => onRefresh && onRefresh()}
|
|
|
|
|
|
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl transition-all shadow-lg shadow-blue-100 hover:-translate-y-0.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<LucideIcon name="refresh-cw" className="w-4 h-4" />
|
|
|
|
|
|
실적 데이터 새로고침
|
|
|
|
|
|
</button>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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">
|
2025-12-20 21:46:23 +09:00
|
|
|
|
판매자 (20%)
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="px-3 py-1.5 bg-green-100 text-green-800 rounded-full font-medium">
|
2025-12-20 21:46:23 +09:00
|
|
|
|
관리자 (5%)
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="px-3 py-1.5 bg-purple-100 text-purple-800 rounded-full font-medium">
|
2025-12-20 21:46:23 +09:00
|
|
|
|
메뉴제작 협업자 (별도)
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<p>수당은 판매자와 그 상위 관리자, 메뉴제작 협업자에게 지급됩니다.</p>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<li><strong>메뉴제작 협업자 (Collaborator)</strong>: 관리자를 데려온 상위 담당자 - <span className="text-purple-600 font-semibold">운영팀 별도 산정</span></li>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<td className="py-2 font-medium">메뉴제작 협업자</td>
|
|
|
|
|
|
<td className="py-2 text-purple-600 font-bold">별도 산정</td>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<li><strong>A (메뉴제작 협업자)</strong>: 운영팀 별도 산정</li>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<p>가입비에서 영업 수당(총 25%)을 제외한 금액이 회사 마진으로 귀속됩니다. (메뉴제작 협업수당은 별도)</p>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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;
|
|
|
|
|
|
|
2025-12-20 21:46:23 +09:00
|
|
|
|
// 가입비에 대한 수당만 계산: 판매자 20%, 관리자 5%, 메뉴제작 협업수당 별도
|
|
|
|
|
|
const rates = model.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0 } };
|
2025-12-17 12:59:26 +09:00
|
|
|
|
totalSellerCommission += (price.join_fee * (rates.seller.join || 0.20));
|
|
|
|
|
|
totalManagerCommission += (price.join_fee * (rates.manager.join || 0.05));
|
2025-12-20 21:46:23 +09:00
|
|
|
|
totalEducatorCommission += 0; // 운영팀 별도 산정
|
2025-12-17 12:59:26 +09:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 공사관리 패키지
|
|
|
|
|
|
if (selectedConstruction && constructionPackage) {
|
|
|
|
|
|
const price = getItemPrice('package', 'construction_management', constructionPackage.join_fee, constructionPackage.subscription_fee);
|
|
|
|
|
|
totalJoinFee += price.join_fee || 0;
|
|
|
|
|
|
|
2025-12-20 21:46:23 +09:00
|
|
|
|
const rates = constructionPackage.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0 } };
|
2025-12-17 12:59:26 +09:00
|
|
|
|
totalSellerCommission += (price.join_fee * (rates.seller.join || 0.20));
|
|
|
|
|
|
totalManagerCommission += (price.join_fee * (rates.manager.join || 0.05));
|
2025-12-20 21:46:23 +09:00
|
|
|
|
totalEducatorCommission += 0;
|
2025-12-17 12:59:26 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 공정/정부지원사업 패키지
|
|
|
|
|
|
if (selectedProcessGov && processGovPackage) {
|
|
|
|
|
|
const price = getItemPrice('package', 'process_government', processGovPackage.join_fee, processGovPackage.subscription_fee);
|
|
|
|
|
|
totalJoinFee += price.join_fee || 0;
|
|
|
|
|
|
|
2025-12-20 21:46:23 +09:00
|
|
|
|
const rates = processGovPackage.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0 } };
|
2025-12-17 12:59:26 +09:00
|
|
|
|
totalSellerCommission += (price.join_fee * (rates.seller.join || 0.20));
|
|
|
|
|
|
totalManagerCommission += (price.join_fee * (rates.manager.join || 0.05));
|
2025-12-20 21:46:23 +09:00
|
|
|
|
totalEducatorCommission += 0;
|
2025-12-17 12:59:26 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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">
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<span className="text-slate-600">메뉴제작 협업수당</span>
|
|
|
|
|
|
<span className="font-bold text-slate-900">운영팀 별도 산정</span>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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">
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<h5 className="font-semibold text-slate-900 mb-1">메뉴제작 협업수당</h5>
|
|
|
|
|
|
<p className="text-xs text-slate-600">운영팀 별도 산정</p>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<li>메뉴제작 협업수당: 운영팀 별도 산정</li>
|
|
|
|
|
|
<li>총 수당: 25만원 + 메뉴제작 협업수당</li>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<li><strong>메뉴제작 협업자:</strong> 관리자를 데려온 상위 담당자</li>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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);
|
2025-12-20 21:46:23 +09:00
|
|
|
|
const rates = program?.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0 } };
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
2025-12-20 21:46:23 +09:00
|
|
|
|
// 메뉴제작 협업자: 운영팀 별도 산정
|
|
|
|
|
|
const educatorJoin = 0;
|
2025-12-17 12:59:26 +09:00
|
|
|
|
const educatorTotal = educatorJoin;
|
|
|
|
|
|
|
|
|
|
|
|
const totalCommission = sellerTotal + managerTotal + educatorTotal;
|
2025-12-20 21:46:23 +09:00
|
|
|
|
const companyMargin = joinFee - totalCommission; // 회사 마진 = 가입비 - 영업 수당
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
|
|
|
|
|
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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<td className="px-4 py-3 font-medium text-slate-900">메뉴제작 협업자</td>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<td className="px-4 py-3 text-slate-600">
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<span className="text-xs text-slate-400">별도</span>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</td>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<td className="px-4 py-3 text-right font-bold text-slate-900">운영팀 산정</td>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
</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>
|
2025-12-20 21:46:23 +09:00
|
|
|
|
<td className="px-4 py-3 text-slate-500">가입비 - 영업 수당</td>
|
2025-12-17 12:59:26 +09:00
|
|
|
|
<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>
|