- SVG 아이콘 대신 favicon-32x32.png 이미지 사용 - 모든 페이지 로고 통일 (favicon + SAM + 서브타이틀) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
5952 lines
403 KiB
PHP
5952 lines
403 KiB
PHP
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>영업 관리 시스템</title>
|
||
|
||
<!-- Favicon -->
|
||
<link rel="apple-touch-icon" sizes="180x180" href="../img/apple-touch-icon.png">
|
||
<link rel="icon" type="image/png" sizes="32x32" href="../img/favicon-32x32.png">
|
||
<link rel="icon" type="image/png" sizes="16x16" href="../img/favicon-16x16.png">
|
||
<link rel="shortcut icon" href="../img/favicon.png">
|
||
|
||
<!-- Console Warning Filter -->
|
||
<script>
|
||
(function() {
|
||
const originalLog = console.log;
|
||
const originalWarn = console.warn;
|
||
const originalInfo = console.info;
|
||
const suppressPatterns = [
|
||
'cdn.tailwindcss.com',
|
||
'in-browser Babel transformer',
|
||
'React DevTools',
|
||
'development version of React'
|
||
];
|
||
|
||
const filter = (orig) => function() {
|
||
const args = Array.from(arguments);
|
||
const firstArg = args[0];
|
||
if (typeof firstArg === 'string' && suppressPatterns.some(p => firstArg.includes(p))) {
|
||
return;
|
||
}
|
||
orig.apply(console, args);
|
||
};
|
||
|
||
console.log = filter(originalLog);
|
||
console.warn = filter(originalWarn);
|
||
console.info = filter(originalInfo);
|
||
})();
|
||
</script>
|
||
|
||
<!-- Fonts: Pretendard -->
|
||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
|
||
|
||
<?php require_once __DIR__ . '/../lib/meta_common.php'; ?>
|
||
|
||
<script>
|
||
tailwind.config = {
|
||
theme: {
|
||
extend: {
|
||
fontFamily: {
|
||
sans: ['Pretendard', 'Inter', 'Noto Sans KR', 'sans-serif'],
|
||
},
|
||
colors: {
|
||
brand: {
|
||
50: '#f0f9ff',
|
||
100: '#e0f2fe',
|
||
200: '#bae6fd',
|
||
500: '#0ea5e9',
|
||
600: '#0284c7',
|
||
700: '#0369a1',
|
||
900: '#0c4a6e',
|
||
},
|
||
background: 'rgb(250, 250, 250)',
|
||
primary: {
|
||
DEFAULT: '#2563eb', // blue-600
|
||
foreground: '#ffffff',
|
||
},
|
||
},
|
||
borderRadius: {
|
||
'card': '12px',
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
</head>
|
||
<body class="bg-background text-slate-800 antialiased">
|
||
<div id="root"></div>
|
||
|
||
<script type="text/babel">
|
||
const { useState, useEffect, useRef } = React;
|
||
|
||
// Utility Functions
|
||
const formatBusinessNo = (val) => {
|
||
if (!val) return '';
|
||
const clean = val.replace(/[^0-9]/g, '');
|
||
if (clean.length <= 3) return clean;
|
||
if (clean.length <= 5) return `${clean.slice(0, 3)}-${clean.slice(3)}`;
|
||
return `${clean.slice(0, 3)}-${clean.slice(3, 5)}-${clean.slice(5, 10)}`;
|
||
};
|
||
|
||
const formatPhone = (val) => {
|
||
if (!val) return '';
|
||
const clean = val.replace(/[^0-9]/g, '');
|
||
if (clean.length <= 3) return clean;
|
||
if (clean.length <= 7) return `${clean.slice(0, 3)}-${clean.slice(3)}`;
|
||
if (clean.length <= 11) {
|
||
if (clean.length === 11) return `${clean.slice(0, 3)}-${clean.slice(3, 7)}-${clean.slice(7)}`;
|
||
return `${clean.slice(0, 3)}-${clean.slice(3, 6)}-${clean.slice(6)}`;
|
||
}
|
||
return `${clean.slice(0, 3)}-${clean.slice(3, 7)}-${clean.slice(7, 11)}`;
|
||
};
|
||
|
||
// 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]);
|
||
// 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>;
|
||
};
|
||
|
||
// --- Components ---
|
||
|
||
const StatCard = ({ title, value, subtext, icon, onClick, className = "" }) => (
|
||
<div
|
||
className={`bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow ${onClick ? 'cursor-pointer' : ''} ${className}`}
|
||
onClick={onClick}
|
||
>
|
||
<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>
|
||
);
|
||
|
||
// 1. Header Component
|
||
const Header = ({ companyInfo, onOpenHelp, selectedRole, onRoleChange, currentUser, onLogout }) => {
|
||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||
const profileMenuRef = React.useRef(null);
|
||
|
||
// 외부 클릭 시 메뉴 닫기
|
||
useEffect(() => {
|
||
const handleClickOutside = (event) => {
|
||
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target)) {
|
||
setIsProfileMenuOpen(false);
|
||
}
|
||
};
|
||
|
||
if (isProfileMenuOpen) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside);
|
||
};
|
||
}, [isProfileMenuOpen]);
|
||
|
||
if (!companyInfo) return <div className="h-16 bg-white shadow-sm animate-pulse"></div>;
|
||
|
||
const roles = ['운영자', '영업관리', '매니저'];
|
||
|
||
return (
|
||
<header className="bg-white border-b border-gray-100 sticky top-0 z-50">
|
||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||
<div className="flex items-center gap-6">
|
||
<a href="../index.php" className="flex items-center gap-3 cursor-pointer">
|
||
<img src="../img/favicon-32x32.png" alt="SAM" className="w-10 h-10 rounded-xl" />
|
||
<div className="flex flex-col">
|
||
<span className="text-lg font-bold text-slate-800 leading-tight">SAM</span>
|
||
<span className="text-xs text-slate-500 leading-tight">영업관리</span>
|
||
</div>
|
||
</a>
|
||
|
||
{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>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4">
|
||
<a href="../index.php" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1 font-medium transition-colors">
|
||
<LucideIcon name="home" className="w-4 h-4" />
|
||
<span className="hidden sm:inline">홈으로</span>
|
||
</a>
|
||
<button onClick={onOpenHelp} className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1 font-medium transition-colors">
|
||
<LucideIcon name="help-circle" className="w-4 h-4" />
|
||
<span className="hidden sm:inline">도움말</span>
|
||
</button>
|
||
|
||
{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>
|
||
)}
|
||
|
||
<div className="relative" ref={profileMenuRef}>
|
||
<button
|
||
onClick={() => setIsProfileMenuOpen(!isProfileMenuOpen)}
|
||
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"
|
||
>
|
||
<LucideIcon name="user-cog" className="w-4 h-4 text-slate-500" />
|
||
<span>{currentUser ? `${currentUser.name} (${currentUser.member_id})` : selectedRole}</span>
|
||
<LucideIcon name="chevron-down" className="w-4 h-4" />
|
||
</button>
|
||
|
||
{isProfileMenuOpen && (
|
||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-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>
|
||
</div>
|
||
{roles.map((role) => (
|
||
<button
|
||
key={role}
|
||
onClick={() => {
|
||
onRoleChange(role);
|
||
setIsProfileMenuOpen(false);
|
||
}}
|
||
className={`w-full text-left px-4 py-2 text-sm hover:bg-slate-50 transition-colors flex items-center gap-2 ${
|
||
selectedRole === role ? 'bg-blue-50 text-blue-700 font-bold' : 'text-slate-700 font-medium'
|
||
}`}
|
||
>
|
||
{selectedRole === role ? (
|
||
<LucideIcon name="check-circle-2" className="w-4 h-4" />
|
||
) : (
|
||
<div className="w-4" />
|
||
)}
|
||
{role}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
);
|
||
};
|
||
|
||
// Operator View Component
|
||
const OperatorView = ({ currentUser }) => {
|
||
const [selectedManager, setSelectedManager] = useState(null);
|
||
const [managers, setManagers] = useState([]);
|
||
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);
|
||
|
||
const [formData, setFormData] = useState({
|
||
member_id: '',
|
||
password: '',
|
||
name: '',
|
||
phone: '',
|
||
email: '',
|
||
role: 'manager',
|
||
parent_id: '',
|
||
remarks: ''
|
||
});
|
||
|
||
const fillRandomOperatorData = () => {
|
||
const sampleNames = ['성시경', '아이유', '조세호', '양세형', '박나래', '장도연', '유재석', '하하', '지석진', '김광석', '이문세', '이선희', '조용필'];
|
||
const sampleIds = ['agent_k', 'team_alpha', 'pro_sales', 'top_tier', 'gold_star', 'sky_line', 'ace_manager', 'super_star'];
|
||
|
||
const randomItem = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||
const randomNum = (len) => Array.from({length: len}, () => Math.floor(Math.random() * 10)).join('');
|
||
|
||
const name = randomItem(sampleNames);
|
||
const idSuffix = randomNum(3);
|
||
|
||
setFormData({
|
||
...formData,
|
||
member_id: randomItem(sampleIds) + idSuffix,
|
||
password: 'password123',
|
||
name: name,
|
||
phone: formatPhone('010' + randomNum(8)),
|
||
email: `sales_${randomNum(4)}@example.com`,
|
||
remarks: '운영자 생성 샘플 데이터'
|
||
});
|
||
setIsIdChecked(true);
|
||
setIdCheckMessage('신규 등록 시 아이디 중복 확인 권장');
|
||
};
|
||
|
||
useEffect(() => {
|
||
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 })
|
||
});
|
||
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';
|
||
|
||
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 [operatorStats, setOperatorStats] = useState({
|
||
totalSales: 0,
|
||
totalCommission: 0,
|
||
totalCount: 0,
|
||
pendingJoin: 0,
|
||
pendingPayment: 0
|
||
});
|
||
|
||
useEffect(() => {
|
||
fetchOperatorDashboard();
|
||
}, []);
|
||
|
||
const fetchOperatorDashboard = async () => {
|
||
try {
|
||
// Fetch global stats including pending counts from backend
|
||
const res = await fetch(`api/get_performance.php`);
|
||
const result = await res.json();
|
||
|
||
if (result.success && result.total_stats) {
|
||
setOperatorStats({
|
||
totalSales: result.total_stats.totalSales || 0,
|
||
totalCommission: result.total_stats.totalCommission || 0,
|
||
totalCount: result.total_stats.totalCount || 0,
|
||
pendingJoin: result.total_stats.pendingJoin || 0,
|
||
pendingPayment: result.total_stats.pendingPayment || 0
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error('Operator dashboard fetch failed:', err);
|
||
}
|
||
};
|
||
|
||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
|
||
|
||
return (
|
||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-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"
|
||
>
|
||
<LucideIcon name="user-plus" className="w-5 h-5" />
|
||
신규 담당자 등록
|
||
</button>
|
||
</div>
|
||
{/* Dashboard Metrics */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
|
||
<StatCard
|
||
title="전체 누적 매출"
|
||
value={formatCurrency(operatorStats.totalSales)}
|
||
subtext="플랫폼 전체 계약 규모"
|
||
icon={<LucideIcon name="trending-up" className="w-5 h-5" />}
|
||
/>
|
||
<StatCard
|
||
title="지급 완료 수당"
|
||
value={formatCurrency(operatorStats.totalCommission)}
|
||
subtext="최종 지급 승인된 총액"
|
||
icon={<LucideIcon name="wallet" className="w-5 h-5 text-emerald-500" />}
|
||
/>
|
||
<StatCard
|
||
title="전체 계약 건수"
|
||
value={`${operatorStats.totalCount}건`}
|
||
subtext="활성 테넌트 계약 총계"
|
||
icon={<LucideIcon name="file-check" className="w-5 h-5" />}
|
||
/>
|
||
|
||
{/* 가입 승인 대기 */}
|
||
<div className={`bg-white rounded-card p-6 shadow-sm border ${operatorStats.pendingJoin > 0 ? 'border-amber-200 bg-amber-50/10' : 'border-slate-100'}`}>
|
||
<div className="flex items-start justify-between mb-4">
|
||
<h3 className="text-sm font-medium text-slate-500">가입 승인 대기</h3>
|
||
<div className={`p-2 rounded-lg ${operatorStats.pendingJoin > 0 ? 'bg-amber-100 text-amber-600' : 'bg-slate-100 text-slate-400'}`}>
|
||
<LucideIcon name="user-plus" className="w-5 h-5" />
|
||
</div>
|
||
</div>
|
||
<div className={`text-2xl font-black mb-1 ${operatorStats.pendingJoin > 0 ? 'text-amber-600' : 'text-slate-900'}`}>
|
||
{operatorStats.pendingJoin}건
|
||
</div>
|
||
<div className="text-xs text-slate-400">신규 가입 검토 대기</div>
|
||
</div>
|
||
|
||
{/* 지급 승인 대기 */}
|
||
<div className={`bg-white rounded-card p-6 shadow-sm border ${operatorStats.pendingPayment > 0 ? 'border-rose-200 bg-rose-50/10' : 'border-slate-100'}`}>
|
||
<div className="flex items-start justify-between mb-4">
|
||
<h3 className="text-sm font-medium text-slate-500">지급 승인 대기</h3>
|
||
<div className={`p-2 rounded-lg ${operatorStats.pendingPayment > 0 ? 'bg-rose-100 text-rose-600' : 'bg-slate-100 text-slate-400'}`}>
|
||
<LucideIcon name="credit-card" className="w-5 h-5" />
|
||
</div>
|
||
</div>
|
||
<div className={`text-2xl font-black mb-1 ${operatorStats.pendingPayment > 0 ? 'text-rose-600' : 'text-slate-900'}`}>
|
||
{operatorStats.pendingPayment}건
|
||
</div>
|
||
<div className="text-xs text-slate-400">수당 지급 검토 대기</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 테넌트 승인 관리 섹션 (운영자 전용) */}
|
||
<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-emerald-600 rounded-xl text-white shadow-lg shadow-emerald-100">
|
||
<LucideIcon name="check-square" className="w-6 h-6" />
|
||
</div>
|
||
테넌트 계약 및 수당 승인 관리
|
||
</h3>
|
||
<TenantConfirmationManager />
|
||
</div>
|
||
|
||
{/* 영업담당자 통합 관리 (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`} />
|
||
</button>
|
||
</div>
|
||
</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">{formatPhone(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>
|
||
|
||
{/* 아이템 설정 카드 (운영자 전용) */}
|
||
<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>
|
||
베이직 요금 및 수당 설정
|
||
</h3>
|
||
<ItemPricingManager />
|
||
</div>
|
||
|
||
{/* 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 ? '정보 수정' : '신규 회원 등록'}
|
||
{!editingMember && (
|
||
<button
|
||
type="button"
|
||
onClick={fillRandomOperatorData}
|
||
className="p-2 bg-amber-50 text-amber-600 rounded-xl hover:bg-amber-100 transition-all border border-amber-200 group relative ml-1 shadow-sm"
|
||
title="샘플 데이터 자동 입력"
|
||
>
|
||
<LucideIcon name="zap" className="w-4 h-4" />
|
||
<span className="absolute -top-12 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-3 py-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50 shadow-2xl">랜덤 데이터 채우기</span>
|
||
</button>
|
||
)}
|
||
</h3>
|
||
<button type="button" onClick={() => setIsModalOpen(false)} className="btn-close-modal group">
|
||
<span>✕</span>
|
||
</button>
|
||
</div>
|
||
|
||
<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>
|
||
</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>
|
||
<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}
|
||
autoComplete="new-password"
|
||
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: formatPhone(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>
|
||
</div>
|
||
</div>
|
||
|
||
<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="btn-close-modal group">
|
||
<span>✕</span>
|
||
</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">
|
||
<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-center">작업</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{members.filter(m => m.parent_id == detailModalUser.id).length === 0 ? (
|
||
<tr>
|
||
<td colSpan="5" className="px-6 py-12 text-center text-slate-400">
|
||
하위 멤버가 없습니다.
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
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={() => {
|
||
console.log('[OperatorView-DetailModal] 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>
|
||
<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"
|
||
>
|
||
닫기
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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>
|
||
)}
|
||
</main>
|
||
);
|
||
};
|
||
|
||
// 테넌트 계약 승인 관리 컴포넌트 (운영자 전용)
|
||
const TenantConfirmationManager = () => {
|
||
const [tenants, setTenants] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [expandedTenantId, setExpandedTenantId] = useState(null);
|
||
const [tenantProducts, setTenantProducts] = useState({});
|
||
|
||
useEffect(() => {
|
||
fetchTenants();
|
||
}, []);
|
||
|
||
const fetchTenants = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const res = await fetch('api/sales_tenants.php?action=list_tenants');
|
||
const data = await res.json();
|
||
if (data.success) setTenants(data.data);
|
||
} catch (err) {
|
||
console.error('Fetch tenants error:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 테넌트 목록이 로드되면 모든 테넌트의 상품을 미리 로드하여 미승인 건수를 계산
|
||
useEffect(() => {
|
||
if (tenants.length > 0) {
|
||
tenants.forEach(t => {
|
||
if (!tenantProducts[t.id]) {
|
||
fetchProducts(t.id);
|
||
}
|
||
});
|
||
}
|
||
}, [tenants]);
|
||
|
||
const fetchProducts = async (tenantId) => {
|
||
try {
|
||
const res = await fetch(`api/sales_tenants.php?action=tenant_products&tenant_id=${tenantId}`);
|
||
const result = await res.json();
|
||
if (result.success) {
|
||
setTenantProducts(prev => ({ ...prev, [tenantId]: result.data }));
|
||
}
|
||
} catch (err) {
|
||
console.error('Fetch products error:', err);
|
||
}
|
||
};
|
||
|
||
const handleToggleTenant = (tenantId) => {
|
||
if (expandedTenantId === tenantId) {
|
||
setExpandedTenantId(null);
|
||
} else {
|
||
setExpandedTenantId(tenantId);
|
||
if (!tenantProducts[tenantId]) {
|
||
fetchProducts(tenantId);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleConfirmProduct = async (productId, field, currentValue, tenantId) => {
|
||
try {
|
||
const res = await fetch('api/sales_tenants.php?action=confirm_product', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: productId, field: field, value: !currentValue })
|
||
});
|
||
const result = await res.json();
|
||
if (result.success) {
|
||
// 제품 목록 업데이트
|
||
fetchProducts(tenantId);
|
||
} else {
|
||
alert(result.error);
|
||
}
|
||
} catch (err) {
|
||
alert('승인 처리 중 오류가 발생했습니다.');
|
||
}
|
||
};
|
||
|
||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val || 0);
|
||
|
||
return (
|
||
<div 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="clipboard-check" className="w-5 h-5 text-emerald-600" />
|
||
테넌트 계약 승인 정산 관리
|
||
</h3>
|
||
<button onClick={fetchTenants} className="p-2 text-slate-500 hover:bg-slate-100 rounded-lg transition-colors">
|
||
<LucideIcon name="refresh-cw" className={`${loading ? 'animate-spin' : ''} w-5 h-5`} />
|
||
</button>
|
||
</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 w-12"></th>
|
||
<th className="px-6 py-4">테넌트명</th>
|
||
<th className="px-6 py-4">담당 영업자</th>
|
||
<th className="px-6 py-4 text-center">미처리 건수</th>
|
||
<th className="px-6 py-4 text-center">등록일</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{loading && tenants.length === 0 ? (
|
||
<tr><td colSpan="5" className="px-6 py-10 text-center text-slate-400">로딩 중...</td></tr>
|
||
) : tenants.length === 0 ? (
|
||
<tr><td colSpan="5" className="px-6 py-10 text-center text-slate-400">등록된 테넌트가 없습니다.</td></tr>
|
||
) : tenants.map(t => (
|
||
<React.Fragment key={t.id}>
|
||
<tr className={`hover:bg-slate-50 transition-colors ${expandedTenantId === t.id ? 'bg-emerald-50/30' : ''}`}>
|
||
<td className="px-6 py-4">
|
||
<button onClick={() => handleToggleTenant(t.id)} className="text-slate-400 hover:text-emerald-600">
|
||
<LucideIcon name={expandedTenantId === t.id ? "chevron-down" : "chevron-right"} className="w-4 h-4" />
|
||
</button>
|
||
</td>
|
||
<td className="px-6 py-4 font-bold text-slate-900">{t.tenant_name}</td>
|
||
<td className="px-6 py-4">
|
||
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded text-xs font-bold">
|
||
{t.manager_name}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-center">
|
||
<span className={`font-mono font-bold ${
|
||
(() => {
|
||
const products = tenantProducts[t.id];
|
||
if (!products) return 'text-slate-300';
|
||
const unconfirmed = products.filter(p => p.join_approved == 0 || p.payment_approved == 0).length;
|
||
return unconfirmed > 0 ? 'text-red-500 animate-pulse' : 'text-slate-400';
|
||
})()
|
||
}`}>
|
||
{(() => {
|
||
const products = tenantProducts[t.id];
|
||
if (!products) return '...';
|
||
return products.filter(p => p.join_approved == 0 || p.payment_approved == 0).length;
|
||
})()}건
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-center text-slate-400 text-xs">{t.created_at?.split(' ')[0]}</td>
|
||
</tr>
|
||
{expandedTenantId === t.id && (
|
||
<tr>
|
||
<td colSpan="5" className="px-6 py-4 bg-slate-50/50 border-b border-emerald-100">
|
||
<div className="pl-12 space-y-4 animate-in slide-in-from-top-1 duration-200">
|
||
<h4 className="text-sm font-black text-slate-700 flex items-center gap-2">
|
||
<div className="w-1.5 h-1.5 bg-emerald-500 rounded-full"></div>
|
||
계약 상품 승인 현황
|
||
</h4>
|
||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm">
|
||
<table className="w-full text-xs">
|
||
<thead className="bg-slate-100 text-slate-600 font-bold border-b border-slate-200">
|
||
<tr>
|
||
<th className="px-4 py-2">상품명</th>
|
||
<th className="px-4 py-2 text-right">가입비</th>
|
||
<th className="px-4 py-2 text-right text-slate-400">월 구독료</th>
|
||
<th className="px-4 py-2 text-center">가입 승인</th>
|
||
<th className="px-4 py-2 text-center">지급 승인</th>
|
||
<th className="px-4 py-2 text-right">정산 지급액</th>
|
||
<th className="px-4 py-2 text-center">계약일</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{!tenantProducts[t.id] ? (
|
||
<tr><td colSpan="7" className="px-4 py-8 text-center text-slate-400">로딩 중...</td></tr>
|
||
) : tenantProducts[t.id].length === 0 ? (
|
||
<tr><td colSpan="7" className="px-4 py-8 text-center text-slate-400">등록된 계약이 없습니다.</td></tr>
|
||
) : tenantProducts[t.id].map(p => (
|
||
<tr key={p.id} className="hover:bg-slate-50 transition-colors">
|
||
<td className="px-4 py-3 font-medium text-slate-800">{p.product_name}</td>
|
||
<td className="px-4 py-3 text-right text-slate-600 font-mono">{formatCurrency(p.contract_amount)}</td>
|
||
<td className="px-4 py-3 text-right text-slate-400 font-mono italic">{formatCurrency(p.subscription_fee || 0)}</td>
|
||
<td className="px-4 py-3 text-center">
|
||
<button
|
||
onClick={() => handleConfirmProduct(p.id, 'join_approved', p.join_approved == 1, t.id)}
|
||
className={`px-3 py-1 rounded-lg font-bold text-[10px] transition-all flex items-center gap-1 mx-auto border ${
|
||
p.join_approved == 1
|
||
? 'bg-blue-600 text-white border-blue-600 shadow-sm'
|
||
: 'bg-white text-slate-400 border-slate-200 hover:border-blue-500 hover:text-blue-600'
|
||
}`}
|
||
>
|
||
<LucideIcon name={p.join_approved == 1 ? "check-circle" : "circle"} className="w-3 h-3" />
|
||
{p.join_approved == 1 ? '가입승인됨' : '가입승인'}
|
||
</button>
|
||
</td>
|
||
<td className="px-4 py-3 text-center">
|
||
<button
|
||
onClick={() => handleConfirmProduct(p.id, 'payment_approved', p.payment_approved == 1, t.id)}
|
||
disabled={p.join_approved != 1}
|
||
className={`px-3 py-1 rounded-lg font-bold text-[10px] transition-all flex items-center gap-1 mx-auto border ${
|
||
p.payment_approved == 1
|
||
? 'bg-emerald-600 text-white border-emerald-600 shadow-sm'
|
||
: p.join_approved == 1
|
||
? 'bg-white text-slate-400 border-slate-200 hover:border-emerald-500 hover:text-emerald-600'
|
||
: 'bg-slate-50 text-slate-300 border-slate-100 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
<LucideIcon name={p.payment_approved == 1 ? "check-circle" : "circle"} className="w-3 h-3" />
|
||
{p.payment_approved == 1 ? '지급승인됨' : '지급승인'}
|
||
</button>
|
||
</td>
|
||
<td className="px-4 py-3 text-right">
|
||
{p.payment_approved == 1 ? (
|
||
<div className="flex flex-col items-end">
|
||
<span className="font-bold text-slate-900 font-mono">{formatCurrency(p.payout_amount)}</span>
|
||
<span className="text-[10px] text-emerald-600 font-black">{p.payout_rate}% 적용</span>
|
||
</div>
|
||
) : (
|
||
<span className="text-slate-300">-</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3 text-center text-slate-400">{p.contract_date}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</React.Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
// 아이템 가격 관리 컴포넌트 (운영자 전용)
|
||
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 handleAddItem = (type) => {
|
||
setEditingItem({
|
||
isNew: true,
|
||
item_type: type,
|
||
item_id: '',
|
||
item_name: '',
|
||
sub_name: '',
|
||
join_fee: 0,
|
||
subscription_fee: 0,
|
||
total_amount: 0,
|
||
allow_flexible_pricing: true
|
||
});
|
||
setEditModalOpen(true);
|
||
};
|
||
|
||
const handleEditItem = (item) => {
|
||
setEditingItem({ ...item });
|
||
setEditModalOpen(true);
|
||
};
|
||
|
||
const handleSaveItem = async () => {
|
||
if (!editingItem) return;
|
||
|
||
try {
|
||
const isNew = editingItem.isNew;
|
||
const method = isNew ? 'POST' : 'PUT';
|
||
const itemId = isNew ? (editingItem.item_id || `${editingItem.item_type}_${Date.now()}`) : editingItem.item_id;
|
||
|
||
// 유효성 검사 (신규일 때 이름 필수)
|
||
if (isNew && !editingItem.item_name) {
|
||
alert('항목명을 입력해주세요.');
|
||
return;
|
||
}
|
||
|
||
const response = await fetch('api/package_pricing.php', {
|
||
method: method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
item_type: editingItem.item_type,
|
||
item_id: itemId,
|
||
item_name: editingItem.item_name,
|
||
sub_name: editingItem.sub_name,
|
||
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 handleDeleteItem = async (item) => {
|
||
if (!confirm('정말 이 항목을 삭제하시겠습니까?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`api/package_pricing.php?action=delete&item_type=${item.item_type}&item_id=${item.item_id}`, {
|
||
method: 'DELETE'
|
||
});
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
await fetchPricingItems();
|
||
setEditModalOpen(false);
|
||
setTimeout(() => setEditingItem(null), 100);
|
||
} else {
|
||
alert(result.error || '삭제 실패');
|
||
}
|
||
} catch (err) {
|
||
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">
|
||
{/* 모델 카드 그리드 */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h4 className="text-lg font-semibold text-slate-700">선택모델</h4>
|
||
<button
|
||
onClick={() => handleAddItem('model')}
|
||
className="px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-sm font-bold hover:bg-blue-100 transition-colors flex items-center gap-1"
|
||
>
|
||
<LucideIcon name="plus" className="w-4 h-4" />
|
||
상품 추가
|
||
</button>
|
||
</div>
|
||
|
||
{models.length > 0 ? (
|
||
<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 className="text-center py-8 text-slate-400 bg-slate-50 rounded-xl border border-dashed border-slate-300">
|
||
등록된 상품이 없습니다.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 패키지 카드 그리드 */}
|
||
<div className="mt-8">
|
||
<div className="flex items-center justify-between mb-4 p-4 bg-slate-50 rounded-xl border border-slate-200">
|
||
<h4 className="text-lg font-bold text-slate-800">패키지 품목 관리</h4>
|
||
<button
|
||
onClick={() => handleAddItem('package')}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold hover:bg-blue-700 transition-colors shadow-sm flex items-center gap-2"
|
||
>
|
||
<LucideIcon name="plus" className="w-4 h-4" />
|
||
패키지 추가
|
||
</button>
|
||
</div>
|
||
|
||
{packages.length > 0 ? (
|
||
<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 className="text-center py-8 text-slate-400 bg-slate-50 rounded-xl border border-dashed border-slate-300">
|
||
등록된 패키지가 없습니다.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 편집 모달 */}
|
||
{editModalOpen && editingItem && (
|
||
<div key={`edit-${editingItem.item_type || 'new'}-${editingItem.item_id || 'new'}`} className="fixed inset-0 z-50 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">
|
||
<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" />
|
||
{editingItem.isNew ? '새 상품 추가' : '아이템 가격 설정'}
|
||
</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">
|
||
{editingItem.isNew ? (
|
||
<>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">항목명</label>
|
||
<input
|
||
type="text"
|
||
value={editingItem.item_name || ''}
|
||
onChange={e => setEditingItem(prev => ({ ...prev, item_name: 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"
|
||
placeholder="예: QR코드"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">카테고리 (부제목)</label>
|
||
<input
|
||
type="text"
|
||
value={editingItem.sub_name || ''}
|
||
onChange={e => setEditingItem(prev => ({ ...prev, sub_name: 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"
|
||
placeholder="예: 설비관리"
|
||
/>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<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-between gap-3">
|
||
{/* 삭제 버튼 (수정 모드일 때만) */}
|
||
{!editingItem.isNew ? (
|
||
<button
|
||
onClick={() => handleDeleteItem(editingItem)}
|
||
className="px-4 py-2 text-red-600 bg-red-50 border border-red-200 rounded-lg hover:bg-red-100 font-medium transition-colors"
|
||
>
|
||
삭제
|
||
</button>
|
||
) : (
|
||
<div></div> // Spacer
|
||
)}
|
||
<div className="flex 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>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
|
||
|
||
// --- NEW: Login View Component ---
|
||
const LoginView = ({ onLoginSuccess }) => {
|
||
const [memberId, setMemberId] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const executeLogin = async (id, pw) => {
|
||
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: id, password: pw })
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
if (data.message && !data.user) {
|
||
alert(data.message); // Admin/Manager creation notice
|
||
} else {
|
||
onLoginSuccess(data.user);
|
||
}
|
||
} else {
|
||
setError(data.error || '아이디 또는 비밀번호가 일치하지 않습니다.');
|
||
}
|
||
} catch (err) {
|
||
setError('서버 통신 오류가 발생했습니다.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleLogin = (e) => {
|
||
e.preventDefault();
|
||
executeLogin(memberId, password);
|
||
};
|
||
|
||
const handleAutoLogin = (id, pw) => {
|
||
setMemberId(id);
|
||
setPassword(pw);
|
||
executeLogin(id, pw);
|
||
};
|
||
|
||
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-600 rounded-full flex items-center justify-center mx-auto mb-4 text-white shadow-lg shadow-blue-100">
|
||
<LucideIcon name="lock" className="w-8 h-8" />
|
||
</div>
|
||
<h2 className="text-3xl font-black text-slate-900 tracking-tight">SAM 로그인</h2>
|
||
<p className="text-slate-500 mt-2 font-medium">서비스 이용을 위해 로그인해주세요.</p>
|
||
</div>
|
||
|
||
<form onSubmit={handleLogin} className="space-y-6">
|
||
<div>
|
||
<label className="block text-xs font-black text-slate-500 uppercase tracking-wider mb-1.5 ml-1">아이디</label>
|
||
<input
|
||
type="text"
|
||
id="username"
|
||
name="username"
|
||
required
|
||
autoComplete="username"
|
||
value={memberId}
|
||
onChange={(e) => setMemberId(e.target.value)}
|
||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all font-medium"
|
||
placeholder="아이디를 입력하세요"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-black text-slate-500 uppercase tracking-wider mb-1.5 ml-1">비밀번호</label>
|
||
<input
|
||
type="password"
|
||
id="password"
|
||
name="password"
|
||
required
|
||
autoComplete="current-password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all font-medium"
|
||
placeholder="비밀번호를 입력하세요"
|
||
/>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="p-4 bg-red-50 border border-red-100 rounded-xl text-sm text-red-600 font-bold flex items-center gap-2 animate-shake">
|
||
<LucideIcon name="alert-circle" className="w-4 h-4" />
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-xl shadow-blue-200 transition-all transform active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2 text-lg"
|
||
>
|
||
{loading ? (
|
||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||
) : (
|
||
<>
|
||
<LucideIcon name="log-in" className="w-5 h-5" />
|
||
로그인
|
||
</>
|
||
)}
|
||
</button>
|
||
</form>
|
||
|
||
<div className="mt-10 pt-8 border-t border-slate-100">
|
||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest text-center mb-4">테스트용 계정 정보</h4>
|
||
<div className="grid grid-cols-1 gap-2">
|
||
<div
|
||
onClick={() => handleAutoLogin('admin', 'admin')}
|
||
className="flex items-center justify-between p-3 bg-slate-50 rounded-xl border border-slate-100 cursor-pointer hover:bg-blue-50 hover:border-blue-200 transition-all group shadow-sm"
|
||
>
|
||
<span className="text-xs font-bold text-slate-500 group-hover:text-blue-600">운영자</span>
|
||
<span className="text-xs font-mono text-slate-600 group-hover:text-blue-500">admin / admin</span>
|
||
</div>
|
||
<div
|
||
onClick={() => handleAutoLogin('sales', 'sales')}
|
||
className="flex items-center justify-between p-3 bg-slate-50 rounded-xl border border-slate-100 cursor-pointer hover:bg-blue-50 hover:border-blue-200 transition-all group shadow-sm"
|
||
>
|
||
<span className="text-xs font-bold text-slate-500 group-hover:text-blue-600">영업관리</span>
|
||
<span className="text-xs font-mono text-slate-600 group-hover:text-blue-500">sales / sales</span>
|
||
</div>
|
||
<div
|
||
onClick={() => handleAutoLogin('manager', 'manager')}
|
||
className="flex items-center justify-between p-3 bg-slate-50 rounded-xl border border-slate-100 cursor-pointer hover:bg-blue-50 hover:border-blue-200 transition-all group shadow-sm"
|
||
>
|
||
<span className="text-xs font-bold text-slate-500 group-hover:text-blue-600">매니저</span>
|
||
<span className="text-xs font-mono text-slate-600 group-hover:text-blue-500">manager / manager</span>
|
||
</div>
|
||
</div>
|
||
</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: ''
|
||
});
|
||
|
||
const fillRandomManagerData = () => {
|
||
const sampleNames = ['한효주', '공유', '이동욱', '김고은', '신세경', '박서준', '김수현', '수지', '남주혁'];
|
||
const sampleIds = ['sales_pro', 'mkt_king', 'deal_maker', 'win_win', 'growth_lab', 'biz_hero'];
|
||
|
||
const randomItem = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||
const randomNum = (len) => Array.from({length: len}, () => Math.floor(Math.random() * 10)).join('');
|
||
|
||
const name = randomItem(sampleNames);
|
||
const idSuffix = randomNum(3);
|
||
|
||
setFormData({
|
||
member_id: randomItem(sampleIds) + idSuffix,
|
||
password: 'password123',
|
||
name: name,
|
||
phone: formatPhone('010' + randomNum(8)),
|
||
email: `manager_${randomNum(4)}@example.com`,
|
||
remarks: '영업관리 앱 생성 샘플 데이터'
|
||
});
|
||
setIsIdChecked(true);
|
||
setIdCheckMessage('신규 등록 시 아이디 중복 확인 권장');
|
||
};
|
||
|
||
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">{formatPhone(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 ? '담당자 정보 수정' : '신규 담당자 등록'}
|
||
{!editingMember && (
|
||
<button
|
||
type="button"
|
||
onClick={fillRandomManagerData}
|
||
className="ml-2 p-1.5 bg-amber-50 text-amber-600 rounded-lg hover:bg-amber-100 transition-all border border-amber-200 group relative"
|
||
title="샘플 데이터 자동 입력"
|
||
>
|
||
<LucideIcon name="zap" className="w-3.5 h-3.5" />
|
||
<span className="absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50 shadow-xl">랜덤 데이터 채우기</span>
|
||
</button>
|
||
)}
|
||
</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}
|
||
autoComplete="new-password"
|
||
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: formatPhone(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>
|
||
)}
|
||
|
||
{/* 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>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- Data: Sales Scenario Steps (영업관리용) ---
|
||
const SALES_SCENARIO_STEPS = [
|
||
{ id: 1, title: "사전 준비", subtitle: "Preparation", icon: "search", color: "bg-blue-100 text-blue-600", description: "고객사를 만나기 전, 철저한 분석을 통해 성공 확률을 높이는 단계입니다.", checkpoints: [
|
||
{ title: "고객사 심층 분석", detail: "홈페이지, 뉴스 등을 통해 이슈와 비전을 파악하세요.", pro_tip: "직원들의 불만 사항을 미리 파악하면 미팅 시 강력한 무기가 됩니다." },
|
||
{ title: "재무 건전성 확인", detail: "매출액, 영업이익 추이를 확인하고 IT 투자 여력을 가늠해보세요.", pro_tip: "성장 추세라면 '확장성'과 '관리 효율'을 강조하세요." },
|
||
{ title: "경쟁사 및 시장 동향", detail: "핵심 기능에 집중하여 도입 속도가 빠르다는 점을 정리하세요.", pro_tip: "경쟁사를 비방하기보다 차별화된 가치를 제시하세요." },
|
||
{ title: "가설 수립 (Hypothesis)", detail: "구체적인 페인포인트 가설을 세우고 질문을 준비하세요.", pro_tip: "'만약 ~하다면' 화법으로 고객의 'Yes'를 유도하세요." }
|
||
]},
|
||
{ id: 2, title: "접근 및 탐색", subtitle: "Approach", icon: "phone-call", color: "bg-indigo-100 text-indigo-600", description: "담당자와의 첫 접점을 만들고, 미팅 기회를 확보하는 단계입니다.", checkpoints: [
|
||
{ title: "Key-man 식별 및 컨택", detail: "실무 책임자(팀장급)와 의사결정권자(임원급) 라인을 파악하세요.", pro_tip: "전달드릴 자료가 있다고 하여 Gatekeeper를 통과하세요." },
|
||
{ title: "맞춤형 콜드메일/콜", detail: "사전 조사 내용을 바탕으로 해결 방안을 제안하세요.", pro_tip: "제목에 고객사 이름을 넣어 클릭률을 높이세요." },
|
||
{ title: "미팅 일정 확정", detail: "인사이트 공유를 목적으로 미팅을 제안하세요.", pro_tip: "두 가지 시간대를 제시하여 양자택일을 유도하세요." }
|
||
]},
|
||
{ id: 3, title: "현장 진단", subtitle: "Diagnosis", icon: "stethoscope", color: "bg-purple-100 text-purple-600", description: "고객의 업무 현장을 직접 확인하고 진짜 문제를 찾아내는 단계입니다.", checkpoints: [
|
||
{ title: "AS-IS 프로세스 맵핑", detail: "고객과 함께 업무 흐름도를 그리며 병목을 찾으세요.", pro_tip: "고객 스스로 문제를 깨닫게 하는 것이 가장 효과적입니다." },
|
||
{ title: "비효율/리스크 식별", detail: "데이터 누락, 중복 입력 등 리스크를 수치화하세요.", pro_tip: "불편함을 시간과 비용으로 환산하여 설명하세요." },
|
||
{ title: "To-Be 이미지 스케치", detail: "도입 후 업무가 어떻게 간소화될지 시각화하세요.", pro_tip: "비포/애프터의 극명한 차이를 보여주세요." }
|
||
]},
|
||
{ id: 4, title: "솔루션 제안", subtitle: "Proposal", icon: "presentation", color: "bg-pink-100 text-pink-600", description: "SAM을 통해 고객의 문제를 어떻게 해결할 수 있는지 증명하는 단계입니다.", checkpoints: [
|
||
{ title: "맞춤형 데모 시연", detail: "핵심 기능을 위주로 고객사 데이터를 넣어 시연하세요.", pro_tip: "고객사 로고를 넣어 '이미 우리 것'이라는 느낌을 주세요." },
|
||
{ title: "ROI 분석 보고서", detail: "비용 대비 절감 가능한 수치를 산출하여 증명하세요.", pro_tip: "보수적인 ROI가 훨씬 더 높은 신뢰를 줍니다." },
|
||
{ title: "단계별 도입 로드맵", detail: "부담을 줄이기 위해 단계적 확산 방안을 제시하세요.", pro_tip: "1단계는 핵심 문제 해결에만 집중하세요." }
|
||
]},
|
||
{ id: 5, title: "협상 및 조율", subtitle: "Negotiation", icon: "scale", color: "bg-orange-100 text-orange-600", description: "도입을 가로막는 장애물을 제거하고 조건을 합의하는 단계입니다.", checkpoints: [
|
||
{ title: "가격/조건 협상", detail: "할인 대신 범위나 기간 조정 등으로 합의하세요.", pro_tip: "Give & Take 원칙을 지키며 기대를 관리하세요." },
|
||
{ title: "의사결정권자 설득", detail: "CEO/CFO의 관심사에 맞는 보고용 장표를 제공하세요.", pro_tip: "실무자가 내부 보고 사업을 잘하게 돕는 것이 핵심입니다." }
|
||
]},
|
||
{ id: 6, title: "계약 체결", subtitle: "Closing", icon: "pen-tool", color: "bg-green-100 text-green-600", description: "공식적인 파트너십을 맺고 법적 효력을 발생시키는 단계입니다.", checkpoints: [
|
||
{ title: "계약서 날인 및 교부", detail: "전자계약 등을 통해 체결 시간을 단축하세요.", pro_tip: "원본은 항상 안전하게 보관하고 백업하세요." },
|
||
{ title: "세금계산서 발행", detail: "정확한 수금 일정을 확인하고 발행하세요.", pro_tip: "가입비 입금이 완료되어야 다음 단계가 시작됩니다." },
|
||
{ title: "계약 완료 (확정)", detail: "축하 인사를 전하고 후속 지원 일정을 잡으세요.", pro_tip: "계약은 진정한 서비스의 시작임을 강조하세요." }
|
||
]}
|
||
];
|
||
|
||
// --- Data: Manager Scenario Steps (매니저용/프로젝트관리) ---
|
||
// --- Data: Manager Scenario Steps (매니저용/프로젝트관리) ---
|
||
const MANAGER_SCENARIO_STEPS = [
|
||
{ id: 1, title: "영업 이관", subtitle: "Handover", icon: "file-input", color: "bg-blue-100 text-blue-600", description: "영업팀으로부터 고객 정보를 전달받고, 프로젝트의 배경과 핵심 요구사항을 파악하는 단계입니다.", checkpoints: [
|
||
{ title: "영업 히스토리 리뷰", detail: "영업 담당자가 작성한 미팅록, 고객의 페인포인트, 예산 범위, 예상 일정 등을 꼼꼼히 확인하세요.", pro_tip: "영업 담당자에게 '고객이 가장 꽂힌 포인트'가 무엇인지 꼭 물어보세요. 그게 프로젝트의 CSF입니다." },
|
||
{ title: "고객사 기본 정보 파악", detail: "고객사의 업종, 규모, 주요 경쟁사 등을 파악하여 커뮤니케이션 톤앤매너를 준비하세요.", pro_tip: "IT 지식이 부족한 고객이라면 전문 용어 사용을 자제하고 쉬운 비유를 준비해야 합니다." },
|
||
{ title: "RFP/요구사항 문서 분석", detail: "고객이 전달한 요구사항 문서(RFP 등)가 있다면 기술적으로 실현 가능한지 1차 검토하세요.", pro_tip: "모호한 문장을 찾아내어 구체적인 수치나 기능으로 정의할 준비를 하세요." },
|
||
{ title: "내부 킥오프 (영업-매니저)", detail: "영업팀과 함께 프로젝트의 리스크 요인(까다로운 담당자 등)을 사전에 공유받으세요.", pro_tip: "영업 단계에서 '무리하게 약속한 기능'이 있는지 반드시 체크해야 합니다." }
|
||
], tips: "잘못된 시작은 엉뚱한 결말을 낳습니다. 영업팀의 약속을 검증하세요." },
|
||
{ id: 2, title: "요구사항 파악", subtitle: "Requirements", icon: "search", color: "bg-indigo-100 text-indigo-600", description: "고객과 직접 만나 구체적인 니즈를 청취하고, 숨겨진 요구사항까지 발굴하는 단계입니다.", checkpoints: [
|
||
{ title: "고객 인터뷰 및 실사", detail: "현업 담당자를 만나 실제 업무 프로세스를 확인하고 시스템이 필요한 진짜 이유를 찾으세요.", pro_tip: "'왜 이 기능이 필요하세요?'라고 3번 물어보세요(5 Whys). 목적을 찾아야 합니다." },
|
||
{ title: "요구사항 구체화 (Scope)", detail: "고객의 요구사항을 기능 단위로 쪼개고 우선순위(Must/Should/Could)를 매기세요.", pro_tip: "'오픈 시점에 반드시 필요한 기능'과 '추후 고도화할 기능'을 명확히 구분해 주세요." },
|
||
{ title: "제약 사항 확인", detail: "예산, 일정, 레거시 시스템 연동, 보안 규정 등 프로젝트의 제약 조건을 명확히 하세요.", pro_tip: "특히 '데이터 이관' 이슈를 조심하세요. 엑셀 데이터가 엉망인 경우가 태반입니다." },
|
||
{ title: "유사 레퍼런스 제시", detail: "비슷한 고민을 했던 다른 고객사의 해결 사례를 보여주며 제안하는 방향의 신뢰를 얻으세요.", pro_tip: "'A사도 이렇게 푸셨습니다'라는 한마디가 백 마디 설명보다 강력합니다." }
|
||
], tips: "고객은 자기가 뭘 원하는지 모를 때가 많습니다. 질문으로 답을 찾아주세요." },
|
||
{ id: 3, title: "개발자 협의", subtitle: "Dev Consult", icon: "code-2", color: "bg-purple-100 text-purple-600", description: "파악된 요구사항을 개발팀에 전달하고 기술적 실현 가능성과 공수를 산정합니다.", checkpoints: [
|
||
{ title: "요구사항 기술 검토", detail: "개발 리드와 함께 고객의 요구사항이 기술적으로 구현 가능한지 검토하세요.", pro_tip: "개발자가 '안 돼요'라고 하면 '왜 안 되는지', '대안은 무엇인지'를 반드시 물어보세요." },
|
||
{ title: "공수 산정 (Estimation)", detail: "기능별 개발 예상 시간(M/M)을 산출하고 필요한 리소스를 파악하세요.", pro_tip: "개발 공수는 항상 버퍼(Buffer)를 20% 정도 두세요. 버그나 스펙 변경은 반드시 일어납니다." },
|
||
{ title: "아키텍처/스택 선정", detail: "프로젝트에 적합한 기술 스택과 시스템 아키텍처를 확정하세요.", pro_tip: "최신 기술보다 유지보수 용이성과 개발팀의 숙련도를 최우선으로 고려하세요." },
|
||
{ title: "리스크 식별 및 대안 수립", detail: "기술적 난이도가 높은 기능 등 리스크를 식별하고 대안(Plan B)을 마련하세요.", pro_tip: "리스크는 감추지 말고 공유해야 합니다. 미리 말하면 관리입니다." }
|
||
], tips: "개발자는 '기능'을 만들지만, 매니저는 '가치'를 만듭니다. 통역사가 되어주세요." },
|
||
{ id: 4, title: "제안 및 견적", subtitle: "Proposal", icon: "file-text", color: "bg-pink-100 text-pink-600", description: "개발팀 검토 내용을 바탕으로 수행 계획서(SOW)와 견적서를 작성하여 제안합니다.", checkpoints: [
|
||
{ title: "WBS 및 일정 계획 수립", detail: "분석/설계/개발/테스트/오픈 등 단계별 상세 일정을 수립하세요.", pro_tip: "고객의 검수(UAT) 기간을 충분히 잡으세요. 고객은 생각보다 바빠서 피드백이 늦어집니다." },
|
||
{ title: "견적서(Quotation) 작성", detail: "개발 공수, 솔루션 비용, 인프라 비용 등을 포함한 상세 견적서를 작성하세요.", pro_tip: "'기능별 상세 견적'을 제공하면 신뢰도가 높아지고 네고 방어에도 유리합니다." },
|
||
{ title: "제안서(SOW) 작성", detail: "범위(Scope), 수행 방법론, 산출물 목록 등을 명시한 제안서를 작성하세요.", pro_tip: "'제외 범위(Out of Scope)'를 명확히 적으세요. 나중에 딴소리 듣지 않으려면요." },
|
||
{ title: "제안 발표 (PT)", detail: "고객에게 제안 내용을 설명하고 우리가 가장 적임자임을 설득하세요.", pro_tip: "발표 자료는 '고객의 언어'로 작성하세요. 기술 용어 남발은 금물입니다." }
|
||
], tips: "견적서는 숫자가 아니라 '신뢰'를 담아야 합니다." },
|
||
{ id: 5, title: "조율 및 협상", subtitle: "Negotiation", icon: "scale", color: "bg-orange-100 text-orange-600", description: "제안 내용을 바탕으로 고객과 범위, 일정, 비용을 최종 조율하는 단계입니다.", checkpoints: [
|
||
{ title: "범위 및 일정 조정", detail: "예산이나 일정에 맞춰 기능을 가감하거나 단계별 오픈 전략을 협의하세요.", pro_tip: "무리한 일정 단축은 단호하게 거절하되, '선오픈'과 같은 대안을 제시하세요." },
|
||
{ title: "추가 요구사항 대응", detail: "제안 과정에서 나온 추가 요구사항에 대해 비용 청구 여부를 결정하세요.", pro_tip: "서비스로 해주더라도 '원래 얼마짜리인데 이번만 하는 것'이라고 인지시키세요." },
|
||
{ title: "R&R 명확화", detail: "우리 회사와 고객사가 각각 해야 할 역할을 명문화하세요.", pro_tip: "프로젝트 지연의 절반은 고객의 자료 전달 지연입니다. 숙제를 명확히 알려주세요." },
|
||
{ title: "최종 합의 도출", detail: "모든 쟁점 사항을 정리하고 최종 합의된 내용을 문서로 남기세요.", pro_tip: "구두 합의는 힘이 없습니다. 반드시 이메일이나 회의록으로 남기세요." }
|
||
], tips: "협상은 이기는 게 아니라, 같이 갈 수 있는 길을 찾는 것입니다." },
|
||
{ id: 6, title: "착수 및 계약", subtitle: "Kickoff", icon: "flag", color: "bg-green-100 text-green-600", description: "계약을 체결하고 프로젝트를 공식적으로 시작하는 단계입니다.", checkpoints: [
|
||
{ title: "계약서 검토 및 날인", detail: "과업지시서, 기술협약서 등 계약 부속 서류를 꼼꼼히 챙기세요.", pro_tip: "계약서에 '검수 조건'을 명확히 넣으세요. 실현 가능한 조건이어야 합니다." },
|
||
{ title: "프로젝트 팀 구성", detail: "수행 인력을 확정하고 내부 킥오프를 진행하세요.", pro_tip: "팀원들에게 프로젝트 배경뿐만 아니라 '고객의 성향'도 공유해 주세요." },
|
||
{ title: "착수 보고회 (Kick-off)", detail: "전원이 모여 프로젝트의 목표, 일정, 커뮤니케이션 룰을 공유하세요.", pro_tip: "첫인상이 전문적이어야 프로젝트가 순탄합니다. 깔끔하게 준비하세요." },
|
||
{ title: "협업 도구 세팅", detail: "Jira, Slack 등 협업 도구를 세팅하고 고객을 초대하세요.", pro_tip: "소통 채널 단일화가 성공의 열쇠입니다. 간단 가이드를 제공하세요." }
|
||
], tips: "시작이 좋아야 끝도 좋습니다. 룰을 명확히 세우세요." }
|
||
];
|
||
// --- Refined Voice Recorder Component ---
|
||
const VoiceRecorder = ({ tenantId, scenarioType, stepId }) => {
|
||
const [isRecording, setIsRecording] = useState(false);
|
||
const [audioBlob, setAudioBlob] = useState(null);
|
||
const [timer, setTimer] = useState(0);
|
||
const [transcript, setTranscript] = useState('');
|
||
const [finalTranscript, setFinalTranscript] = useState('');
|
||
const [status, setStatus] = useState('대기중');
|
||
const [savedRecordings, setSavedRecordings] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [selectedRecording, setSelectedRecording] = useState(null);
|
||
|
||
const mediaRecorderRef = useRef(null);
|
||
const audioChunksRef = useRef([]);
|
||
const timerIntervalRef = useRef(null);
|
||
const recognitionRef = useRef(null);
|
||
const streamRef = useRef(null);
|
||
const canvasRef = useRef(null);
|
||
const animationIdRef = useRef(null);
|
||
const audioContextRef = useRef(null);
|
||
const analyserRef = useRef(null);
|
||
const finalTranscriptRef = useRef('');
|
||
|
||
useEffect(() => {
|
||
loadRecordings();
|
||
return () => {
|
||
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
|
||
if (animationIdRef.current) cancelAnimationFrame(animationIdRef.current);
|
||
if (streamRef.current) streamRef.current.getTracks().forEach(track => track.stop());
|
||
if (audioContextRef.current) audioContextRef.current.close();
|
||
};
|
||
}, [tenantId, stepId]);
|
||
|
||
const loadRecordings = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch(`api/sales_tenants.php?action=get_consultations&tenant_id=${tenantId}&scenario_type=${scenarioType}`);
|
||
const result = await res.json();
|
||
if (result.success) {
|
||
setSavedRecordings(result.data.filter(log => log.consultation_type === 'audio' && log.step_id == stepId));
|
||
}
|
||
} catch (err) { console.error(err); }
|
||
setLoading(false);
|
||
};
|
||
|
||
const formatTime = (seconds) => {
|
||
const mins = Math.floor(seconds / 60);
|
||
const secs = seconds % 60;
|
||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||
};
|
||
|
||
const drawWaveform = () => {
|
||
if (!analyserRef.current || !canvasRef.current) return;
|
||
const canvas = canvasRef.current;
|
||
const ctx = canvas.getContext('2d');
|
||
const analyser = analyserRef.current;
|
||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||
analyser.getByteTimeDomainData(dataArray);
|
||
ctx.fillStyle = '#f8fafc';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeStyle = '#6366f1';
|
||
ctx.beginPath();
|
||
const sliceWidth = canvas.width / dataArray.length;
|
||
let x = 0;
|
||
for (let i = 0; i < dataArray.length; i++) {
|
||
const v = dataArray[i] / 128.0;
|
||
const y = v * canvas.height / 2;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
x += sliceWidth;
|
||
}
|
||
ctx.lineTo(canvas.width, canvas.height / 2);
|
||
ctx.stroke();
|
||
animationIdRef.current = requestAnimationFrame(drawWaveform);
|
||
};
|
||
|
||
const startRecording = async () => {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
streamRef.current = stream;
|
||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
audioContextRef.current = audioContext;
|
||
const analyser = audioContext.createAnalyser();
|
||
analyserRef.current = analyser;
|
||
const source = audioContext.createMediaStreamSource(stream);
|
||
source.connect(analyser);
|
||
analyser.fftSize = 2048;
|
||
|
||
if (canvasRef.current) {
|
||
canvasRef.current.width = canvasRef.current.offsetWidth;
|
||
canvasRef.current.height = 100;
|
||
drawWaveform();
|
||
}
|
||
|
||
const mediaRecorder = new MediaRecorder(stream);
|
||
mediaRecorderRef.current = mediaRecorder;
|
||
audioChunksRef.current = [];
|
||
mediaRecorder.ondataavailable = (event) => {
|
||
if (event.data.size > 0) audioChunksRef.current.push(event.data);
|
||
};
|
||
mediaRecorder.onstop = () => {
|
||
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||
setAudioBlob(blob);
|
||
};
|
||
mediaRecorder.start();
|
||
|
||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||
if (SpeechRecognition) {
|
||
const recognition = new SpeechRecognition();
|
||
recognition.lang = 'ko-KR';
|
||
recognition.continuous = true;
|
||
recognition.interimResults = true;
|
||
finalTranscriptRef.current = '';
|
||
setFinalTranscript('');
|
||
recognition.onresult = (event) => {
|
||
let interim = '';
|
||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||
const transcriptText = event.results[i][0].transcript;
|
||
if (event.results[i].isFinal) {
|
||
finalTranscriptRef.current += transcriptText + ' ';
|
||
setFinalTranscript(finalTranscriptRef.current);
|
||
} else {
|
||
interim += transcriptText;
|
||
}
|
||
}
|
||
const displayText = finalTranscriptRef.current + (interim ? `<span class="text-slate-400">${interim}</span>` : '');
|
||
setTranscript(displayText || '음성을 인식하고 있습니다...');
|
||
};
|
||
recognition.onend = () => {
|
||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||
try { recognition.start(); } catch (e) {}
|
||
}
|
||
};
|
||
recognition.start();
|
||
recognitionRef.current = recognition;
|
||
}
|
||
|
||
setIsRecording(true);
|
||
setStatus('녹음 중...');
|
||
setTimer(0);
|
||
timerIntervalRef.current = setInterval(() => setTimer(prev => prev + 1), 1000);
|
||
} catch (error) {
|
||
console.error('Recording failed:', error);
|
||
alert('마이크 권한을 허용해주세요.');
|
||
}
|
||
};
|
||
|
||
const stopRecording = () => {
|
||
if (mediaRecorderRef.current && isRecording) mediaRecorderRef.current.stop();
|
||
if (recognitionRef.current) recognitionRef.current.stop();
|
||
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
|
||
if (animationIdRef.current) cancelAnimationFrame(animationIdRef.current);
|
||
if (streamRef.current) {
|
||
streamRef.current.getTracks().forEach(track => track.stop());
|
||
streamRef.current = null;
|
||
}
|
||
setIsRecording(false);
|
||
setStatus('녹음 완료');
|
||
setTranscript(finalTranscriptRef.current.trim());
|
||
};
|
||
|
||
const saveRecording = async () => {
|
||
if (!audioBlob) return;
|
||
const formData = new FormData();
|
||
formData.append('audio_file', audioBlob, `rec_${Date.now()}.webm`);
|
||
formData.append('log_text', finalTranscriptRef.current.replace(/<[^>]*>/g, '').trim() || '음성 녹음 기록');
|
||
formData.append('tenant_id', tenantId);
|
||
formData.append('scenario_type', scenarioType);
|
||
formData.append('step_id', stepId);
|
||
formData.append('consultation_type', 'audio');
|
||
|
||
try {
|
||
const response = await fetch('api/sales_tenants.php?action=save_consultation', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
setAudioBlob(null);
|
||
setTranscript('');
|
||
finalTranscriptRef.current = '';
|
||
setTimer(0);
|
||
setStatus('대기중');
|
||
loadRecordings();
|
||
} else {
|
||
alert('저장 실패: ' + result.error);
|
||
}
|
||
} catch (error) {
|
||
console.error('Save error:', error);
|
||
alert('저장 중 오류가 발생했습니다.');
|
||
}
|
||
};
|
||
|
||
const deleteLog = async (id) => {
|
||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||
try {
|
||
const res = await fetch('api/sales_tenants.php?action=delete_consultation', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id })
|
||
});
|
||
if ((await res.json()).success) loadRecordings();
|
||
} catch (err) { console.error(err); }
|
||
};
|
||
|
||
return (
|
||
<div className="bg-white rounded-[2rem] border-2 border-slate-100 p-8 space-y-6 shadow-sm flex flex-col h-full">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="text-xl font-black text-slate-900 flex items-center gap-2">
|
||
<LucideIcon name="mic" className="w-6 h-6 text-indigo-600" />
|
||
고객사 상담 녹음
|
||
</h4>
|
||
</div>
|
||
|
||
<div className="bg-slate-50/50 rounded-3xl p-8 flex flex-col items-center gap-4 relative overflow-hidden border border-slate-100">
|
||
<button
|
||
onClick={isRecording ? stopRecording : startRecording}
|
||
className={`w-24 h-24 rounded-full flex items-center justify-center text-white transition-all shadow-xl ${isRecording ? 'bg-red-500 animate-pulse' : 'bg-blue-600 hover:scale-105 shadow-blue-100'}`}
|
||
>
|
||
<LucideIcon name={isRecording ? "square" : "mic"} className="w-10 h-10" />
|
||
</button>
|
||
<div className="text-center">
|
||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{status}</div>
|
||
<div className={`text-4xl font-black font-mono ${isRecording ? 'text-red-500' : 'text-slate-900'}`}>{formatTime(timer)}</div>
|
||
</div>
|
||
|
||
<canvas ref={canvasRef} className="w-full h-20 bg-white rounded-2xl shadow-inner" style={{ display: isRecording ? 'block' : 'none' }}></canvas>
|
||
|
||
{transcript && (
|
||
<div className="w-full p-4 bg-white rounded-2xl border border-slate-100 text-sm font-bold text-slate-600 max-h-32 overflow-y-auto italic" dangerouslySetInnerHTML={{ __html: transcript }}></div>
|
||
)}
|
||
|
||
{audioBlob && (
|
||
<div className="w-full flex gap-2 animate-in slide-in-from-bottom-2">
|
||
<button onClick={saveRecording} className="flex-1 py-4 bg-slate-900 text-white rounded-2xl font-black text-sm hover:bg-black transition-all shadow-xl">
|
||
저장하기
|
||
</button>
|
||
<button onClick={() => { setAudioBlob(null); setTranscript(''); setTimer(0); setStatus('대기중'); }} className="px-8 py-4 bg-slate-100 text-slate-600 rounded-2xl font-black text-sm hover:bg-slate-200 transition-all">
|
||
취소
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Recorded List Integrated */}
|
||
<div className="flex-1 space-y-4">
|
||
<div className="flex items-center justify-between pt-4">
|
||
<h5 className="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||
<LucideIcon name="list" className="w-4 h-4" />
|
||
저장된 녹음 목록
|
||
</h5>
|
||
<button onClick={loadRecordings} className="text-[10px] font-black text-indigo-600 hover:text-indigo-800 flex items-center gap-1">
|
||
<LucideIcon name="refresh-cw" className={`w-3 h-3 ${loading ? 'animate-spin' : ''}`} />
|
||
새로고침
|
||
</button>
|
||
</div>
|
||
|
||
<div className="overflow-hidden rounded-2xl border border-slate-100">
|
||
<table className="w-full text-xs text-left">
|
||
<thead className="bg-slate-50 border-b border-slate-100">
|
||
<tr>
|
||
<th className="px-4 py-3 font-black text-slate-400 uppercase tracking-tighter w-12">번호</th>
|
||
<th className="px-4 py-3 font-black text-slate-400 uppercase tracking-tighter w-32">작성일</th>
|
||
<th className="px-4 py-3 font-black text-slate-400 uppercase tracking-tighter">텍스트 미리보기</th>
|
||
<th className="px-4 py-3 font-black text-slate-400 uppercase tracking-tighter text-right w-24">동작</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-50">
|
||
{savedRecordings.length === 0 ? (
|
||
<tr>
|
||
<td colSpan="4" className="px-4 py-12 text-center text-slate-300 font-bold italic">저장된 녹음 내용이 없습니다.</td>
|
||
</tr>
|
||
) : (
|
||
savedRecordings.map((rec, i) => (
|
||
<tr key={rec.id} className="hover:bg-slate-50 transition-colors group">
|
||
<td className="px-4 py-3 text-slate-400 font-mono">{savedRecordings.length - i}</td>
|
||
<td className="px-4 py-3 text-slate-600 font-bold">{rec.created_at.split(' ')[0]}</td>
|
||
<td className="px-4 py-3 text-slate-500 font-medium truncate max-w-[120px]">{rec.log_text}</td>
|
||
<td className="px-4 py-3 text-right">
|
||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button onClick={() => setSelectedRecording(rec)} className="p-1.5 text-slate-400 hover:text-indigo-600" title="상세보기"><LucideIcon name="eye" className="w-3.5 h-3.5" /></button>
|
||
<a href={rec.audio_file_path} download className="p-1.5 text-slate-400 hover:text-blue-600" title="다운로드"><LucideIcon name="download" className="w-3.5 h-3.5" /></a>
|
||
<button onClick={() => deleteLog(rec.id)} className="p-1.5 text-slate-400 hover:text-red-500" title="삭제"><LucideIcon name="trash-2" className="w-3.5 h-3.5" /></button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Details Modal */}
|
||
{selectedRecording && (
|
||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-6 bg-slate-900/40 backdrop-blur-sm" onClick={() => setSelectedRecording(null)}>
|
||
<div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-xl overflow-hidden animate-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
|
||
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||
<h3 className="text-xl font-black text-slate-900 flex items-center gap-3">
|
||
<div className="p-2 bg-indigo-100 text-indigo-600 rounded-xl"><LucideIcon name="mic" className="w-5 h-5" /></div>
|
||
상담 녹음 상세보기
|
||
</h3>
|
||
<button onClick={() => setSelectedRecording(null)} className="p-2 hover:bg-slate-200 rounded-full transition-colors"><LucideIcon name="x" className="w-5 h-5 text-slate-400" /></button>
|
||
</div>
|
||
<div className="p-8 space-y-8">
|
||
<div className="bg-slate-50 rounded-2xl p-6 border border-slate-100">
|
||
<audio src={selectedRecording.audio_file_path} controls className="w-full h-10" />
|
||
</div>
|
||
<div className="space-y-3">
|
||
<h6 className="text-[10px] font-black text-slate-400 uppercase tracking-widest">전체 텍스트 기록</h6>
|
||
<div className="p-6 bg-slate-50 rounded-[2rem] border border-slate-100 text-sm font-bold text-slate-700 whitespace-pre-wrap leading-relaxed max-h-60 overflow-y-auto">
|
||
{selectedRecording.log_text}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="p-6 bg-slate-50/50 border-t border-slate-100 flex justify-end">
|
||
<button onClick={() => setSelectedRecording(null)} className="px-8 py-3 bg-slate-900 text-white rounded-2xl font-black text-sm hover:bg-black transition-all">확인</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- Refined File Uploader Component ---
|
||
const FileUploader = ({ tenantId, scenarioType, stepId }) => {
|
||
const [uploading, setUploading] = useState(false);
|
||
const [savedFiles, setSavedFiles] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const fileInputRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
loadFiles();
|
||
}, [tenantId, stepId]);
|
||
|
||
const loadFiles = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch(`api/sales_tenants.php?action=get_consultations&tenant_id=${tenantId}&scenario_type=${scenarioType}`);
|
||
const result = await res.json();
|
||
if (result.success) {
|
||
setSavedFiles(result.data.filter(log => log.consultation_type === 'file' && log.step_id == stepId));
|
||
}
|
||
} catch (err) { console.error(err); }
|
||
setLoading(false);
|
||
};
|
||
|
||
const handleFileSelect = async (e) => {
|
||
const selectedFiles = Array.from(e.target.files);
|
||
if (selectedFiles.length === 0) return;
|
||
|
||
setUploading(true);
|
||
|
||
try {
|
||
const CHUNK_SIZE = 512 * 1024; // 512KB chunks (서버의 극도로 낮은 용량 제한 대응)
|
||
|
||
for (const file of selectedFiles) {
|
||
const uploadId = Date.now().toString(36) + Math.random().toString(36).substring(2);
|
||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||
|
||
for (let i = 0; i < totalChunks; i++) {
|
||
const chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
||
const formData = new FormData();
|
||
formData.append('file', chunk);
|
||
formData.append('uploadId', uploadId);
|
||
formData.append('chunkIndex', i);
|
||
formData.append('totalChunks', totalChunks);
|
||
formData.append('fileName', file.name);
|
||
formData.append('tenant_id', tenantId);
|
||
formData.append('scenario_type', scenarioType);
|
||
formData.append('step_id', stepId);
|
||
formData.append('consultation_type', 'file');
|
||
|
||
const response = await fetch('api/sales_tenants.php?action=upload_chunk', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (response.status === 413) {
|
||
throw new Error(`파일 '${file.name}'이 서버의 단일 요청 제한을 초과했습니다. 관리자에게 Nginx 수정을 요청하거나 더 작은 청크를 사용해야 합니다.`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
if (!result.success) {
|
||
throw new Error(result.error || '청크 업로드 실패');
|
||
}
|
||
}
|
||
}
|
||
|
||
loadFiles();
|
||
alert('모든 파일이 성공적으로 업로드되었습니다.');
|
||
} catch (error) {
|
||
console.error('Upload error:', error);
|
||
alert('업로드 중 오류가 발생했습니다: ' + error.message);
|
||
} finally {
|
||
setUploading(false);
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
const deleteLog = async (id) => {
|
||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||
try {
|
||
const res = await fetch('api/sales_tenants.php?action=delete_consultation', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id })
|
||
});
|
||
if ((await res.json()).success) loadFiles();
|
||
} catch (err) { console.error(err); }
|
||
};
|
||
|
||
return (
|
||
<div className="bg-white rounded-[2rem] border-2 border-slate-100 p-8 space-y-6 shadow-sm flex flex-col h-full">
|
||
<h4 className="text-xl font-black text-slate-900 flex items-center gap-2">
|
||
<LucideIcon name="paperclip" className="w-6 h-6 text-blue-600" />
|
||
첨부파일 추가
|
||
</h4>
|
||
|
||
<div
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="w-full h-48 border-2 border-dashed border-slate-200 rounded-[2rem] flex flex-col items-center justify-center gap-4 cursor-pointer hover:bg-slate-50/50 hover:border-blue-400 transition-all group"
|
||
>
|
||
<div className="p-5 bg-blue-50 text-blue-600 rounded-3xl group-hover:scale-110 shadow-lg shadow-blue-50 transition-all">
|
||
{uploading ? <LucideIcon name="loader" className="w-8 h-8 animate-spin" /> : <LucideIcon name="upload-cloud" className="w-8 h-8" />}
|
||
</div>
|
||
<div className="text-center">
|
||
<div className="text-sm font-black text-slate-700">
|
||
{uploading ? '파일을 분석하여 업로드 중...' : '클릭하여 파일 선택 (여러 개 가능)'}
|
||
</div>
|
||
<div className="text-[11px] font-bold text-slate-400 mt-1">회의록, 제안요청서 등 관련 서류</div>
|
||
</div>
|
||
</div>
|
||
<input type="file" ref={fileInputRef} className="hidden" multiple onChange={handleFileSelect} />
|
||
|
||
<div className="flex-1 space-y-4">
|
||
<div className="flex items-center justify-between pt-4">
|
||
<h5 className="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||
<LucideIcon name="paperclip" className="w-4 h-4" />
|
||
업로드된 파일 목록
|
||
</h5>
|
||
<button onClick={loadFiles} className="text-[10px] font-black text-blue-600 hover:text-blue-800 flex items-center gap-1">
|
||
<LucideIcon name="refresh-cw" className={`w-3 h-3 ${loading ? 'animate-spin' : ''}`} />
|
||
새로고침
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
||
{savedFiles.length === 0 ? (
|
||
<div className="py-12 flex flex-col items-center gap-3 opacity-20">
|
||
<LucideIcon name="inbox" className="w-12 h-12" />
|
||
<span className="text-sm font-bold">업로드된 파일이 없습니다</span>
|
||
</div>
|
||
) : (
|
||
savedFiles.map(log => (
|
||
<div key={log.id} className="p-5 bg-slate-50/50 rounded-3xl border border-slate-100 group relative">
|
||
<div className="flex justify-between items-start mb-3">
|
||
<div className="flex items-center gap-2 text-[10px] font-black text-slate-400">
|
||
<LucideIcon name="calendar" className="w-3 h-3" />
|
||
{log.created_at}
|
||
</div>
|
||
<button onClick={() => deleteLog(log.id)} className="p-1 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"><LucideIcon name="trash-2" className="w-3.5 h-3.5" /></button>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{(() => {
|
||
try {
|
||
const paths = JSON.parse(log.attachment_paths);
|
||
return paths.map((p, i) => (
|
||
<a key={i} href={p.path} target="_blank" className="flex items-center justify-between p-3 bg-white rounded-2xl border border-slate-100 hover:border-blue-400 transition-all">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<LucideIcon name="file" className="w-4 h-4 text-slate-400" />
|
||
<span className="text-xs font-bold text-slate-700 truncate max-w-[150px]">{p.name}</span>
|
||
</div>
|
||
<LucideIcon name="download" className="w-3.5 h-3.5 text-blue-500" />
|
||
</a>
|
||
));
|
||
} catch(e) { return <span className="text-xs font-bold text-slate-400">파일 정보 파싱 중 오류</span> }
|
||
})()}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ManagerScenarioView = ({ tenant, onClose, scenarioType = 'manager', onTriggerContract }) => {
|
||
const steps = scenarioType === 'sales' ? SALES_SCENARIO_STEPS : MANAGER_SCENARIO_STEPS;
|
||
const [activeStep, setActiveStep] = useState(steps[0]);
|
||
const [checklist, setChecklist] = useState({});
|
||
const [logs, setLogs] = useState([]);
|
||
const [newLog, setNewLog] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const newSteps = scenarioType === 'sales' ? SALES_SCENARIO_STEPS : MANAGER_SCENARIO_STEPS;
|
||
setActiveStep(newSteps[0]);
|
||
fetchScenarioData();
|
||
fetchConsultations();
|
||
}, [tenant.id, scenarioType]);
|
||
|
||
const fetchScenarioData = async () => {
|
||
try {
|
||
const res = await fetch(`api/sales_tenants.php?action=get_scenario&tenant_id=${tenant.id}&scenario_type=${scenarioType}`);
|
||
const result = await res.json();
|
||
if (result.success) {
|
||
const map = {};
|
||
result.data.forEach(item => {
|
||
const key = `${item.scenario_type}_${item.step_id}_${item.checkpoint_index}`;
|
||
map[key] = (item.is_checked == 1);
|
||
});
|
||
setChecklist(map);
|
||
}
|
||
} catch (err) { /* console.error('Fetch scenario error:', err); */ }
|
||
};
|
||
|
||
const fetchConsultations = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch(`api/sales_tenants.php?action=get_consultations&tenant_id=${tenant.id}&scenario_type=${scenarioType}`);
|
||
const result = await res.json();
|
||
if (result.success) setLogs(result.data);
|
||
} catch (err) { console.error(err); }
|
||
setLoading(false);
|
||
};
|
||
|
||
const toggleCheck = async (stepId, index) => {
|
||
const key = `${scenarioType}_${stepId}_${index}`;
|
||
const newState = !checklist[key];
|
||
|
||
// Optimistic UI Update
|
||
setChecklist(prev => ({ ...prev, [key]: newState }));
|
||
|
||
try {
|
||
const res = await fetch('api/sales_tenants.php?action=update_checklist', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
tenant_id: tenant.id,
|
||
scenario_type: scenarioType,
|
||
step_id: stepId,
|
||
checkpoint_index: index,
|
||
is_checked: newState
|
||
})
|
||
});
|
||
const result = await res.json();
|
||
if (!result.success) {
|
||
alert('저장 실패: ' + (result.error || '알 수 없는 오류'));
|
||
setChecklist(prev => ({ ...prev, [key]: !newState })); // Rollback
|
||
}
|
||
} catch (err) {
|
||
// console.error('Checkbox toggle error:', err);
|
||
alert('서버와 통신하는 중 오류가 발생했습니다.');
|
||
setChecklist(prev => ({ ...prev, [key]: !newState })); // Rollback
|
||
}
|
||
};
|
||
|
||
const saveLog = async () => {
|
||
if (!newLog.trim()) return;
|
||
try {
|
||
const res = await fetch('api/sales_tenants.php?action=save_consultation', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
tenant_id: tenant.id,
|
||
scenario_type: scenarioType,
|
||
step_id: activeStep.id,
|
||
log_text: newLog,
|
||
consultation_type: 'text'
|
||
})
|
||
});
|
||
if ((await res.json()).success) {
|
||
setNewLog('');
|
||
fetchConsultations();
|
||
}
|
||
} catch (err) { console.error(err); }
|
||
};
|
||
|
||
const deleteLog = async (id) => {
|
||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||
try {
|
||
const res = await fetch('api/sales_tenants.php?action=delete_consultation', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id })
|
||
});
|
||
if ((await res.json()).success) {
|
||
fetchConsultations();
|
||
}
|
||
} catch (err) { console.error(err); }
|
||
};
|
||
|
||
const getStepProgress = (stepId) => {
|
||
const step = steps.find(s => s.id === stepId);
|
||
if (!step) return 0;
|
||
const total = step.checkpoints.length;
|
||
let checked = 0;
|
||
for (let i = 0; i < total; i++) {
|
||
if (checklist[`${scenarioType}_${stepId}_${i}`]) checked++;
|
||
}
|
||
return Math.round((checked / total) * 100);
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-[120] bg-slate-900/60 backdrop-blur-sm flex justify-center p-4 lg:p-8">
|
||
<div className="bg-white rounded-[2rem] shadow-2xl w-full max-w-7xl overflow-hidden flex flex-col animate-in zoom-in duration-200">
|
||
{/* Header */}
|
||
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50">
|
||
<div className="flex items-center gap-4">
|
||
<div className="p-3 bg-blue-600 rounded-2xl text-white shadow-lg shadow-blue-100">
|
||
<LucideIcon name="clipboard-check" className="w-6 h-6" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-2xl font-black text-slate-900 leading-tight">
|
||
{scenarioType === 'sales' ? '영업 전략 시나리오 매니지먼트' : '상담 프로세스 매니지먼트'}
|
||
</h3>
|
||
<p className="text-sm font-bold text-slate-500 flex items-center gap-2">
|
||
<LucideIcon name="building" className="w-3.5 h-3.5" />
|
||
{tenant.tenant_name} ({tenant.representative})
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button onClick={onClose} className="p-3 hover:bg-slate-200 rounded-full transition-colors">
|
||
<LucideIcon name="x" className="w-6 h-6 text-slate-500" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 flex overflow-hidden">
|
||
{/* Primary Sidebar: Steps */}
|
||
<div className="w-72 border-r border-slate-100 bg-slate-50/50 p-6 space-y-3 overflow-y-auto">
|
||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-2 mb-4 block">
|
||
{scenarioType === 'sales' ? 'Sales Strategy' : 'Business Steps'}
|
||
</label>
|
||
{steps.map(step => {
|
||
const progress = getStepProgress(step.id);
|
||
const isActive = activeStep.id === step.id;
|
||
return (
|
||
<button
|
||
key={step.id}
|
||
onClick={() => setActiveStep(step)}
|
||
className={`w-full group text-left p-4 rounded-2xl transition-all duration-300 relative overflow-hidden ${isActive ? 'bg-white shadow-md border-slate-200 ring-1 ring-slate-200' : 'hover:bg-slate-100'}`}
|
||
>
|
||
<div className="flex items-center gap-3 relative z-10">
|
||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center transition-transform group-hover:scale-110 ${isActive ? step.color : 'bg-slate-200 text-slate-400'}`}>
|
||
<LucideIcon name={step.icon} className="w-5 h-5" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center justify-between">
|
||
<div className={`text-sm font-black ${isActive ? 'text-slate-900' : 'text-slate-500'}`}>{step.title}</div>
|
||
<span className={`text-[10px] font-bold ${isActive ? 'text-blue-600' : 'text-slate-400'}`}>{progress}%</span>
|
||
</div>
|
||
<div className="w-full bg-slate-200 h-1 rounded-full mt-2">
|
||
<div className={`h-full rounded-full transition-all ${step.color.replace('text-', 'bg-').replace('100', '500')}`} style={{ width: `${progress}%` }}></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Main Content Area */}
|
||
<div className="flex-1 overflow-y-auto p-10 bg-white">
|
||
<div className="max-w-4xl mx-auto space-y-12">
|
||
{/* Step Info */}
|
||
<div className="animate-in slide-in-from-top-4 duration-500">
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${activeStep.color}`}>{activeStep.subtitle}</span>
|
||
<span className="text-xs font-bold text-slate-400">Step 0{activeStep.id}</span>
|
||
</div>
|
||
<h2 className="text-4xl font-black text-slate-900 mb-4">{activeStep.title}</h2>
|
||
<div className="flex items-center justify-between gap-4">
|
||
<p className="text-lg text-slate-600 font-medium leading-relaxed">{activeStep.description}</p>
|
||
{scenarioType === 'sales' && activeStep.id === 6 && (
|
||
<button
|
||
onClick={() => onTriggerContract(tenant)}
|
||
className="shrink-0 px-6 py-3 bg-blue-600 text-white rounded-2xl font-black hover:bg-blue-700 transition-all shadow-xl shadow-blue-100 flex items-center gap-2 animate-bounce"
|
||
>
|
||
<LucideIcon name="shopping-cart" className="w-5 h-5" />
|
||
계약 상품 등록하기
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tips Area */}
|
||
{activeStep.tips && (
|
||
<div className="p-6 bg-blue-50 border border-blue-100 rounded-3xl animate-in slide-in-from-bottom-4 duration-500">
|
||
<div className="flex items-start gap-4">
|
||
<div className="p-3 bg-blue-100 rounded-2xl text-blue-600">
|
||
<LucideIcon name="lightbulb" className="w-6 h-6" />
|
||
</div>
|
||
<div>
|
||
<h5 className="font-black text-blue-900 mb-1">Manager Secret Tips</h5>
|
||
<p className="text-blue-700 font-medium">{activeStep.tips}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Checklist */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
{activeStep.checkpoints.map((cp, idx) => (
|
||
<div key={idx}
|
||
onClick={() => toggleCheck(activeStep.id, idx)}
|
||
className={`p-6 rounded-3xl border-2 transition-all cursor-pointer group ${checklist[`${scenarioType}_${activeStep.id}_${idx}`] ? 'bg-emerald-50 border-emerald-100' : 'bg-white border-slate-100 hover:border-blue-200'}`}
|
||
>
|
||
<div className="flex items-start gap-4">
|
||
<div className={`w-6 h-6 rounded-lg border-2 flex items-center justify-center shrink-0 transition-colors ${checklist[`${scenarioType}_${activeStep.id}_${idx}`] ? 'bg-emerald-500 border-emerald-500 text-white' : 'border-slate-300 group-hover:border-blue-500'}`}>
|
||
{checklist[`${scenarioType}_${activeStep.id}_${idx}`] && <LucideIcon name="check" className="w-4 h-4" />}
|
||
</div>
|
||
<div>
|
||
<h4 className={`font-black mb-2 transition-colors ${checklist[`${scenarioType}_${activeStep.id}_${idx}`] ? 'text-emerald-900' : 'text-slate-900 group-hover:text-blue-600'}`}>{cp.title}</h4>
|
||
<p className="text-sm text-slate-500 leading-relaxed italic">{cp.detail}</p>
|
||
{checklist[`${scenarioType}_${activeStep.id}_${idx}`] && cp.pro_tip && (
|
||
<div className="mt-4 p-4 bg-white/60 rounded-xl text-xs text-emerald-700 font-bold border border-emerald-100">
|
||
💡 Tip: {cp.pro_tip}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Step Features: Voice & Files - Only for Requirements step (id 2) */}
|
||
{activeStep.id === 2 && (
|
||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8 animate-in slide-in-from-bottom-6 duration-700 pt-8 border-t border-slate-100">
|
||
<VoiceRecorder
|
||
tenantId={tenant.id}
|
||
scenarioType={scenarioType}
|
||
stepId={activeStep.id}
|
||
/>
|
||
<FileUploader
|
||
tenantId={tenant.id}
|
||
scenarioType={scenarioType}
|
||
stepId={activeStep.id}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Log Area */}
|
||
<div className="pt-12 border-t border-dashed border-slate-200 space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-xl font-black text-slate-900 flex items-center gap-2">
|
||
<LucideIcon name="message-square" className="w-5 h-5 text-blue-600" />
|
||
실행 기록 및 상담 내용
|
||
</h3>
|
||
</div>
|
||
|
||
<div className="bg-slate-50 p-6 rounded-3xl border border-slate-100">
|
||
<textarea
|
||
value={newLog}
|
||
onChange={(e) => setNewLog(e.target.value)}
|
||
className="w-full bg-white border border-slate-200 rounded-2xl p-4 text-sm font-medium focus:ring-4 focus:ring-blue-100 outline-none transition-all h-32 resize-none"
|
||
placeholder="오늘의 업무 내용이나 상담 특이사항을 기록하세요..."
|
||
></textarea>
|
||
<div className="mt-4 flex justify-end">
|
||
<button
|
||
onClick={saveLog}
|
||
className="px-8 py-3 bg-slate-900 text-white rounded-2xl font-black hover:bg-black transition-all shadow-lg flex items-center gap-2"
|
||
>
|
||
<LucideIcon name="save" className="w-4 h-4" />
|
||
기록 저장
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Log Timeline - Only Text Logs */}
|
||
<div className="space-y-4">
|
||
{logs.filter(log => log.consultation_type === 'text').length === 0 ? (
|
||
<div className="text-center py-20 text-slate-400 font-bold bg-slate-50/50 rounded-3xl border border-dashed border-slate-200">
|
||
아직 기록된 내용이 없습니다.
|
||
</div>
|
||
) : logs.filter(log => log.consultation_type === 'text').map(log => (
|
||
<div key={log.id} className="p-6 bg-white border border-slate-100 rounded-3xl shadow-sm hover:shadow-md transition-all group relative">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded-xl bg-slate-50 text-slate-600">
|
||
<LucideIcon name="message-square" className="w-4 h-4" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-[10px] font-black uppercase tracking-tighter">Step {log.step_id}</span>
|
||
<span className="text-[10px] font-bold text-slate-400 tracking-tighter">{log.created_at}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => deleteLog(log.id)}
|
||
className="opacity-0 group-hover:opacity-100 p-2 text-slate-300 hover:text-red-500 transition-all"
|
||
>
|
||
<LucideIcon name="trash-2" className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<p className="text-sm font-bold text-slate-800 leading-relaxed whitespace-pre-wrap">{log.log_text}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- NEW: Profit Management View (Tenants & Commissions) ---
|
||
const ProfitManagementView = ({ currentUser, salesConfig, currentRole }) => {
|
||
const [tenants, setTenants] = useState([]);
|
||
const [stats, setStats] = useState({ tenant_count: 0, total_revenue: 0, total_commission: 0, confirmed_commission: 0 });
|
||
const [tenantProducts, setTenantProducts] = useState({});
|
||
const [loading, setLoading] = useState(true);
|
||
const [isTenantModalOpen, setIsTenantModalOpen] = useState(false);
|
||
const [isProductModalOpen, setIsProductModalOpen] = useState(false);
|
||
const [selectedTenant, setSelectedTenant] = useState(null);
|
||
const [activeSalesScenarioTenant, setActiveSalesScenarioTenant] = useState(null);
|
||
const [activeManagerScenarioTenant, setActiveManagerScenarioTenant] = useState(null);
|
||
const [expandedTenantId, setExpandedTenantId] = useState(null);
|
||
const [pricingData, setPricingData] = useState({});
|
||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||
const [selectedSubModels, setSelectedSubModels] = useState([]);
|
||
const [editProductId, setEditProductId] = useState(null);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [potentialManagerList, setPotentialManagerList] = useState([]);
|
||
const [activeManagerPopover, setActiveManagerPopover] = useState(null);
|
||
const [editingTenantId, setEditingTenantId] = useState(null);
|
||
const popoverRef = useRef(null);
|
||
|
||
// Filtered manager list based on role
|
||
const filteredManagers = (currentRole === '운영자')
|
||
? potentialManagerList
|
||
: potentialManagerList.filter(m => m.id == currentUser.id || m.parent_id == currentUser.id);
|
||
|
||
const [tenantFormData, setTenantFormData] = useState({
|
||
tenant_name: '', representative: '', business_no: '', contact_phone: '', email: '', address: '',
|
||
sales_manager_id: currentUser ? currentUser.id : ''
|
||
});
|
||
const [productFormData, setProductFormData] = useState({
|
||
product_name: '', contract_amount: '', subscription_fee: '', commission_rate: '20', contract_date: new Date().toISOString().split('T')[0]
|
||
});
|
||
|
||
const fillRandomTenantData = () => {
|
||
const sampleCompanies = ['(주)가나다소프트', '(주)에이비씨시스템', '(주)코드브리지', '(주)샘테크', '(주)미래이노베이션', '(주)글로벌네트웍스', '(주)디지털솔루션', '(주)한국IT연구소', '(주)스마트플랫폼'];
|
||
const sampleNames = ['김철수', '이영희', '박지민', '최민석', '정수아', '강동원', '한예슬', '홍길동', '장미란'];
|
||
const sampleAddresses = ['서울특별시 강남구 테헤란로 123', '경기도 성남시 분당구 판교역로 456', '서울특별시 서초구 서초대로 789', '부산광역시 해운대구 센텀중앙로 101', '인천광역시 연수구 송도과학로 202', '대구광역시 수성구 달구벌대로 303'];
|
||
|
||
const randomItem = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||
const randomNum = (len) => Array.from({length: len}, () => Math.floor(Math.random() * 10)).join('');
|
||
|
||
const randomData = {
|
||
tenant_name: randomItem(sampleCompanies),
|
||
representative: randomItem(sampleNames),
|
||
business_no: formatBusinessNo(randomNum(10)),
|
||
contact_phone: formatPhone('010' + randomNum(8)),
|
||
email: `test_${randomNum(4)}@example.com`,
|
||
address: randomItem(sampleAddresses)
|
||
};
|
||
setTenantFormData(randomData);
|
||
};
|
||
|
||
const fillRandomProductData = () => {
|
||
const selectModelsPkg = (salesConfig.package_types || []).find(p => p.id === 'select_models');
|
||
if (!selectModelsPkg) return;
|
||
|
||
setSelectedCategory('select_models');
|
||
|
||
const allModels = selectModelsPkg.models || [];
|
||
// Randomly select 2-4 models
|
||
const randomCount = Math.floor(Math.random() * 3) + 2;
|
||
const shuffled = [...allModels].sort(() => 0.5 - Math.random());
|
||
const selectedIds = shuffled.slice(0, Math.min(randomCount, allModels.length)).map(m => m.id);
|
||
|
||
setSelectedSubModels(selectedIds);
|
||
|
||
// Calculate total based on selected models and pricingData
|
||
const total = selectedIds.reduce((sum, id) => {
|
||
const modelObj = allModels.find(m => m.id === id);
|
||
const pKey = `model_${id}`;
|
||
const priceInfo = pricingData[pKey] || { join_fee: modelObj ? modelObj.join_fee : 0 };
|
||
return sum + (Number(priceInfo.join_fee) || 0);
|
||
}, 0);
|
||
|
||
setProductFormData({
|
||
...productFormData,
|
||
product_name: `선택모델(${selectedIds.length}종)`,
|
||
contract_amount: total,
|
||
subscription_fee: 0, // 모델 선택 시 구독료는 일단 0으로 (필요시 수동입력)
|
||
commission_rate: '20',
|
||
contract_date: new Date().toISOString().split('T')[0]
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
fetchPricing();
|
||
fetchManagers();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (event) => {
|
||
if (popoverRef.current && !popoverRef.current.contains(event.target)) {
|
||
setActiveManagerPopover(null);
|
||
}
|
||
};
|
||
if (activeManagerPopover) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
}
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside);
|
||
};
|
||
}, [activeManagerPopover]);
|
||
|
||
const fetchManagers = async () => {
|
||
try {
|
||
const res = await fetch('api/sales_tenants.php?action=list_managers');
|
||
const result = await res.json();
|
||
if (result.success) setPotentialManagerList(result.data);
|
||
} catch (err) {
|
||
console.error('Fetch managers error:', err);
|
||
}
|
||
};
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
const fetchData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [tenantsRes, statsRes] = await Promise.all([
|
||
fetch('api/sales_tenants.php?action=list_tenants'),
|
||
fetch('api/sales_tenants.php?action=my_stats')
|
||
]);
|
||
const tenantsData = await tenantsRes.json();
|
||
const statsData = await statsRes.json();
|
||
|
||
if (tenantsData.success) setTenants(tenantsData.data);
|
||
if (statsData.success) setStats(statsData.data);
|
||
} catch (err) {
|
||
console.error('Fetch error:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchProducts = async (tenantId) => {
|
||
try {
|
||
const res = await fetch(`api/sales_tenants.php?action=tenant_products&tenant_id=${tenantId}`);
|
||
const result = await res.json();
|
||
if (result.success) {
|
||
setTenantProducts(prev => ({ ...prev, [tenantId]: result.data }));
|
||
}
|
||
} catch (err) {
|
||
console.error('Fetch products error:', err);
|
||
}
|
||
};
|
||
|
||
const handleToggleTenant = (tenantId) => {
|
||
if (expandedTenantId === tenantId) {
|
||
setExpandedTenantId(null);
|
||
} else {
|
||
setExpandedTenantId(tenantId);
|
||
if (!tenantProducts[tenantId]) {
|
||
fetchProducts(tenantId);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleUpdateManagerAssignment = async (tenantId, targetManagerId) => {
|
||
if (!currentUser || !currentUser.id) {
|
||
alert('로그인 정보가 유효하지 않습니다.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('api/sales_tenants.php?action=update_tenant_manager', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
tenant_id: tenantId,
|
||
sales_manager_id: targetManagerId
|
||
})
|
||
});
|
||
|
||
const result = await res.json();
|
||
if (result.success) {
|
||
await fetchData();
|
||
setActiveManagerPopover(null);
|
||
if (result.message) alert(result.message);
|
||
} else {
|
||
alert(result.error || '업데이트 실패');
|
||
}
|
||
} catch (err) {
|
||
console.error('Assignment error:', err);
|
||
alert('서버와 통신하는 중 오류가 발생했습니다.');
|
||
}
|
||
};
|
||
|
||
const handleCreateTenant = async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
const action = editingTenantId ? 'update_tenant' : 'create_tenant';
|
||
const payload = editingTenantId ? { ...tenantFormData, id: editingTenantId } : tenantFormData;
|
||
|
||
const res = await fetch(`api/sales_tenants.php?action=${action}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const result = await res.json();
|
||
if (result.success) {
|
||
alert(result.message);
|
||
setIsTenantModalOpen(false);
|
||
setEditingTenantId(null);
|
||
await fetchData();
|
||
|
||
setTenantFormData({ tenant_name: '', representative: '', business_no: '', contact_phone: '', email: '', address: '', sales_manager_id: currentUser.id });
|
||
|
||
} else {
|
||
alert(result.error);
|
||
}
|
||
} catch (err) {
|
||
alert('처리 중 오류가 발생했습니다.');
|
||
}
|
||
};
|
||
|
||
const handleOpenEditTenant = (t) => {
|
||
setEditingTenantId(t.id);
|
||
setTenantFormData({
|
||
tenant_name: t.tenant_name,
|
||
representative: t.representative,
|
||
business_no: t.business_no,
|
||
contact_phone: t.contact_phone,
|
||
email: t.email,
|
||
address: t.address,
|
||
sales_manager_id: t.sales_manager_id
|
||
});
|
||
setIsTenantModalOpen(true);
|
||
};
|
||
|
||
const handleDeleteTenant = async (tenantId) => {
|
||
if (!confirm('정말로 이 테넌트를 삭제하시겠습니까? 관련 계약 및 모든 기록이 삭제됩니다.')) return;
|
||
try {
|
||
const res = await fetch('api/sales_tenants.php?action=delete_tenant', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: tenantId })
|
||
});
|
||
const result = await res.json();
|
||
if (result.success) {
|
||
alert('삭제되었습니다.');
|
||
fetchData();
|
||
} else {
|
||
alert(result.error);
|
||
}
|
||
} catch (err) {
|
||
alert('삭제 중 오류가 발생했습니다.');
|
||
}
|
||
};
|
||
|
||
const handleAddProduct = async (e) => {
|
||
e.preventDefault();
|
||
if (isSaving) return;
|
||
if (!productFormData.contract_amount || !productFormData.product_name) {
|
||
alert('필수 정보를 입력해주세요.');
|
||
return;
|
||
}
|
||
|
||
setIsSaving(true);
|
||
try {
|
||
const action = editProductId ? 'update_product' : 'add_product';
|
||
const payload = editProductId
|
||
? { ...productFormData, id: editProductId, sub_models: selectedSubModels }
|
||
: { ...productFormData, tenant_id: selectedTenant.id, sub_models: selectedSubModels };
|
||
|
||
const res = await fetch(`api/sales_tenants.php?action=${action}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const result = await res.json();
|
||
if (result.success) {
|
||
alert(editProductId ? '계약 정보가 수정되었습니다.' : '계약 정보가 등록되었습니다.');
|
||
setIsProductModalOpen(false);
|
||
setEditProductId(null);
|
||
setProductFormData({ product_name: '', contract_amount: '', subscription_fee: '', commission_rate: '20', contract_date: new Date().toISOString().split('T')[0] });
|
||
setSelectedCategory(null);
|
||
setSelectedSubModels([]);
|
||
fetchData();
|
||
fetchProducts(editProductId ? selectedTenant.id : selectedTenant.id); // selectedTenant is still valid
|
||
} else {
|
||
alert(result.error);
|
||
}
|
||
} catch (err) {
|
||
alert('처리 중 오류가 발생했습니다.');
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteProduct = async (tenantId, productId) => {
|
||
if (!confirm('정말로 이 계약 정보를 삭제하시겠습니까?')) return;
|
||
try {
|
||
const res = await fetch('api/sales_tenants.php?action=delete_product', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: productId })
|
||
});
|
||
const result = await res.json();
|
||
if (result.success) {
|
||
alert('계약 정보가 삭제되었습니다.');
|
||
fetchData();
|
||
fetchProducts(tenantId);
|
||
} else {
|
||
alert(result.error);
|
||
}
|
||
} catch (err) {
|
||
alert('삭제 중 오류가 발생했습니다.');
|
||
}
|
||
};
|
||
|
||
const handleOpenEditProduct = (tenant, product) => {
|
||
setSelectedTenant(tenant);
|
||
setEditProductId(product.id);
|
||
setProductFormData({
|
||
product_name: product.product_name,
|
||
contract_amount: product.contract_amount,
|
||
subscription_fee: product.subscription_fee || 0,
|
||
commission_rate: product.commission_rate,
|
||
contract_date: product.contract_date
|
||
});
|
||
|
||
// Restore sub models if available
|
||
if (product.sub_models) {
|
||
try {
|
||
const subModels = JSON.parse(product.sub_models);
|
||
setSelectedSubModels(subModels || []);
|
||
setSelectedCategory('select_models');
|
||
} catch (e) {
|
||
console.error('Sub-models parse error:', e);
|
||
setSelectedSubModels([]);
|
||
}
|
||
} else {
|
||
// Try to guess from name if it's a fixed package
|
||
const pkg = (salesConfig.package_types || []).find(p => p.name === product.product_name);
|
||
if (pkg) setSelectedCategory(pkg.id);
|
||
else setSelectedCategory(null);
|
||
setSelectedSubModels([]);
|
||
}
|
||
|
||
setIsProductModalOpen(true);
|
||
};
|
||
|
||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val || 0);
|
||
|
||
return (
|
||
<div className="space-y-8">
|
||
{/* 수익 통계 카드 */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||
<StatCard
|
||
title="관리 테넌트"
|
||
value={`${stats.tenant_count || 0}개`}
|
||
subtext="등록된 총 업체 수"
|
||
icon={<LucideIcon name="building-2" className="w-5 h-5" />}
|
||
/>
|
||
{currentRole !== '매니저' && (
|
||
<StatCard
|
||
title="총 가입비 실적"
|
||
value={formatCurrency(stats.total_revenue)}
|
||
subtext="전체 가입비 합계"
|
||
icon={<LucideIcon name="bar-chart-3" className="w-5 h-5" />}
|
||
/>
|
||
)}
|
||
<StatCard
|
||
title={currentRole === '매니저' ? "지급예정 구독료" : "누적 가입비 수당"}
|
||
value={formatCurrency(stats.total_commission)}
|
||
subtext={currentRole === '매니저' ? "전체 계약 건 예상 구독료" : "전체 가입비 수당 합계"}
|
||
icon={<LucideIcon name="coins" className="w-5 h-5" />}
|
||
/>
|
||
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 rounded-card p-6 shadow-sm border border-emerald-200">
|
||
<div className="flex items-start justify-between mb-4">
|
||
<h3 className="text-sm font-medium text-emerald-700">{currentRole === '매니저' ? "지급완료 구독액" : "확정 가입비 수당 (지급대상)"}</h3>
|
||
<div className="p-2 bg-emerald-100 rounded-lg text-emerald-600">
|
||
<LucideIcon name="check-circle" className="w-5 h-5" />
|
||
</div>
|
||
</div>
|
||
<div className="text-2xl font-bold text-emerald-900 mb-1">{formatCurrency(stats.confirmed_commission)}</div>
|
||
<div className="text-xs text-emerald-600 font-medium">운영팀 승인 완료된 금액 (지급: 계약 익월 말일)</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 테넌트 목록 섹션 */}
|
||
<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 bg-slate-50/50">
|
||
<h3 className="text-xl font-bold text-slate-900 flex items-center gap-2">
|
||
<LucideIcon name="layout-list" className="w-5 h-5 text-blue-600" />
|
||
테넌트 및 계약 관리
|
||
</h3>
|
||
{currentRole === '영업관리' && (
|
||
<button
|
||
onClick={() => {
|
||
setEditingTenantId(null);
|
||
setTenantFormData({
|
||
tenant_name: '', representative: '', business_no: '', contact_phone: '', email: '', address: '',
|
||
sales_manager_id: currentUser ? currentUser.id : ''
|
||
});
|
||
setIsTenantModalOpen(true);
|
||
}}
|
||
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-md"
|
||
>
|
||
<LucideIcon name="plus" className="w-4 h-4" />
|
||
신규 테넌트 등록
|
||
</button>
|
||
)}
|
||
</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 w-12"></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="6" className="px-6 py-10 text-center text-slate-400">데이터 로딩 중...</td></tr>
|
||
) : tenants.length === 0 ? (
|
||
<tr><td colSpan="6" className="px-6 py-10 text-center text-slate-400">등록된 테넌트가 없습니다.</td></tr>
|
||
) : tenants.map(t => (
|
||
<React.Fragment key={t.id}>
|
||
<tr className={`hover:bg-blue-50/30 transition-colors ${expandedTenantId === t.id ? 'bg-blue-50/50' : ''}`}>
|
||
<td className="px-6 py-4">
|
||
<button onClick={() => handleToggleTenant(t.id)} className="text-slate-400 hover:text-blue-600">
|
||
<LucideIcon name={expandedTenantId === t.id ? "chevron-down" : "chevron-right"} className="w-4 h-4" />
|
||
</button>
|
||
</td>
|
||
<td className="px-6 py-4 font-bold text-slate-900">
|
||
{t.tenant_name}
|
||
<div className="text-[10px] text-slate-400 font-normal mt-0.5">{t.representative} | {formatPhone(t.contact_phone)}</div>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<div className="flex items-center gap-2">
|
||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-[10px] font-bold">영업: {t.register_name}</span>
|
||
<div className="relative">
|
||
{t.sales_manager_id ? (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); if(currentRole==='영업관리') setActiveManagerPopover(t.id); }}
|
||
className={`px-2 py-0.5 rounded text-[10px] font-bold border transition-all flex items-center gap-1 ${
|
||
t.sales_manager_id == currentUser.id
|
||
? 'bg-blue-100 text-blue-700 border-blue-200'
|
||
: 'bg-blue-50 text-blue-600 border-blue-100'
|
||
} ${currentRole === '영업관리' ? 'cursor-pointer hover:bg-white hover:shadow-sm' : 'cursor-default'}`}
|
||
>
|
||
<LucideIcon name="user-check" className="w-2.5 h-2.5" />
|
||
관리: {t.sales_manager_id == currentUser.id ? '본인' : (t.manager_name || '지정됨')}
|
||
{currentRole === '영업관리' && <LucideIcon name="chevron-down" className="w-2 h-2" />}
|
||
</button>
|
||
) : (
|
||
currentRole === '영업관리' && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setActiveManagerPopover(t.id); }}
|
||
className="px-2 py-0.5 bg-amber-50 text-amber-600 hover:bg-white hover:shadow-sm rounded text-[10px] font-bold border border-amber-200 transition-all flex items-center gap-1"
|
||
>
|
||
<LucideIcon name="user-plus" className="w-2.5 h-2.5" />
|
||
관리: 미지정
|
||
<LucideIcon name="chevron-down" className="w-2 h-2" />
|
||
</button>
|
||
)
|
||
)}
|
||
|
||
{activeManagerPopover === t.id && (
|
||
<div
|
||
ref={popoverRef}
|
||
className="absolute left-0 mt-2 w-48 bg-white rounded-xl shadow-xl border border-slate-100 py-2 z-[110] animate-in fade-in slide-in-from-top-1 duration-200"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="px-4 py-2 border-b border-slate-50">
|
||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">매니저 지정/변경</div>
|
||
</div>
|
||
<div className="max-h-60 overflow-y-auto">
|
||
<button
|
||
onClick={() => handleUpdateManagerAssignment(t.id, currentUser.id)}
|
||
className={`w-full text-left px-4 py-2 text-xs hover:bg-slate-50 flex items-center gap-2 ${t.sales_manager_id == currentUser.id ? 'text-blue-600 font-bold bg-blue-50' : 'text-slate-700 font-medium'}`}
|
||
>
|
||
<LucideIcon name="user-check" className="w-3 h-3" />
|
||
본인이 직접수행
|
||
</button>
|
||
{filteredManagers.filter(m => m.id != currentUser.id).map(m => (
|
||
<button
|
||
key={m.id}
|
||
onClick={() => handleUpdateManagerAssignment(t.id, m.id)}
|
||
className={`w-full text-left px-4 py-2 text-xs hover:bg-slate-50 flex items-center gap-2 ${t.sales_manager_id == m.id ? 'text-blue-600 font-bold bg-blue-50' : 'text-slate-700 font-medium'}`}
|
||
>
|
||
<div className="w-3 h-3 rounded bg-slate-100 flex items-center justify-center text-[8px] font-bold">{m.role === 'sales_admin' ? '관' : '매'}</div>
|
||
{m.name} ({m.member_id})
|
||
</button>
|
||
))}
|
||
</div>
|
||
{t.sales_manager_id && (
|
||
<div className="mt-1 pt-1 border-t border-slate-50">
|
||
<button
|
||
onClick={() => handleUpdateManagerAssignment(t.id, null)}
|
||
className="w-full text-left px-4 py-2 text-xs text-red-500 font-bold hover:bg-red-50 flex items-center gap-2"
|
||
>
|
||
<LucideIcon name="user-minus" className="w-3 h-3" />
|
||
지정 해제
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-slate-400 text-xs">{t.created_at?.split(' ')[0]}</td>
|
||
<td className="px-6 py-4 text-center">
|
||
<div className="flex items-center gap-2 justify-center">
|
||
{(currentRole === '영업관리' || currentRole === '운영자') && (
|
||
<button
|
||
onClick={() => setActiveSalesScenarioTenant(t)}
|
||
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-all border border-blue-700 flex items-center gap-1 shadow-sm"
|
||
>
|
||
<LucideIcon name="trending-up" className="w-3.5 h-3.5" />
|
||
영업 진행
|
||
</button>
|
||
)}
|
||
|
||
{(currentRole === '영업관리' || currentRole === '운영자') && !(t.register_role === 'operator' && currentRole !== '운영자') && (
|
||
<button
|
||
onClick={() => {
|
||
setSelectedTenant(t);
|
||
setIsProductModalOpen(true);
|
||
}}
|
||
className="px-3 py-1.5 bg-indigo-600 text-white rounded-lg font-bold hover:bg-indigo-700 transition-all border border-indigo-700 flex items-center gap-1 shadow-sm"
|
||
>
|
||
<LucideIcon name="settings-2" className="w-3.5 h-3.5" />
|
||
상세계약 설정
|
||
</button>
|
||
)}
|
||
|
||
{(currentRole === '매니저' || currentRole === '운영자' || (currentRole === '영업관리' && t.sales_manager_id == currentUser.id)) && (
|
||
<button
|
||
onClick={() => setActiveManagerScenarioTenant(t)}
|
||
className="px-3 py-1.5 bg-emerald-600 text-white rounded-lg font-bold hover:bg-emerald-700 transition-all border border-emerald-700 flex items-center gap-1 shadow-sm"
|
||
>
|
||
<LucideIcon name="clipboard-check" className="w-3.5 h-3.5" />
|
||
매니저 진행
|
||
</button>
|
||
)}
|
||
|
||
{(currentRole === '영업관리' || currentRole === '운영자') && !(t.register_role === 'operator' && currentRole !== '운영자') && (
|
||
<div className="flex items-center gap-1 ml-2 border-l border-slate-200 pl-2">
|
||
<button
|
||
onClick={() => handleOpenEditTenant(t)}
|
||
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||
title="수정 및 계약 관리"
|
||
>
|
||
<LucideIcon name="edit-2" className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteTenant(t.id)}
|
||
className="p-1.5 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||
title="삭제"
|
||
>
|
||
<LucideIcon name="trash-2" className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
{(currentRole === '영업관리' || currentRole === '운영자') && t.register_role === 'operator' && currentRole !== '운영자' && (
|
||
<div className="flex items-center gap-1 ml-2 border-l border-slate-200 pl-2">
|
||
<div className="p-1.5 text-slate-300 cursor-help" title="운영팀 등록 테넌트는 수정/삭제가 제약됩니다.">
|
||
<LucideIcon name="lock" className="w-4 h-4" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{expandedTenantId === t.id && (
|
||
<tr>
|
||
<td colSpan="6" className="px-6 py-4 bg-slate-50/50 border-b border-blue-100">
|
||
<div className="pl-12 space-y-4 animate-in slide-in-from-top-1 duration-200">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="text-sm font-black text-slate-700 flex items-center gap-2">
|
||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full"></div>
|
||
체결 상품 및 수당 내역
|
||
</h4>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm">
|
||
<table className="w-full text-xs">
|
||
<thead className="bg-slate-100 text-slate-600 font-bold border-b border-slate-200">
|
||
<tr>
|
||
<th className="px-4 py-2">상품명</th>
|
||
<th className="px-4 py-2 text-right">가입비</th>
|
||
<th className="px-4 py-2 text-right text-slate-500">월 구독료</th>
|
||
<th className="px-4 py-2 text-center">수익기준</th>
|
||
<th className="px-4 py-2 text-right text-blue-600">내 수익</th>
|
||
<th className="px-4 py-2 text-center">계약일</th>
|
||
<th className="px-4 py-2 text-center">가입</th>
|
||
<th className="px-4 py-2 text-center">지급</th>
|
||
<th className="px-4 py-2 text-center">관리</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{!tenantProducts[t.id] ? (
|
||
<tr><td colSpan="9" className="px-4 py-8 text-center text-slate-400">로딩 중...</td></tr>
|
||
) : tenantProducts[t.id].length === 0 ? (
|
||
<tr><td colSpan="9" className="px-4 py-8 text-center text-slate-400">등록된 계약 정보가 없습니다.</td></tr>
|
||
) : tenantProducts[t.id].map(p => (
|
||
<tr key={p.id} className="hover:bg-slate-50 transition-colors">
|
||
<td className="px-4 py-3 font-medium text-slate-800">{p.product_name}</td>
|
||
<td className="px-4 py-3 text-right text-slate-600 font-mono">{formatCurrency(p.contract_amount)}</td>
|
||
<td className="px-4 py-3 text-right text-slate-400 font-mono italic">{formatCurrency(p.subscription_fee || 0)}</td>
|
||
<td className="px-4 py-3 text-center text-slate-500">가입비 ({Number(p.commission_rate)}%)</td>
|
||
<td className="px-4 py-3 text-right font-bold text-blue-600 font-mono">{formatCurrency(p.commission_amount)}</td>
|
||
<td className="px-4 py-3 text-center text-slate-400">{p.contract_date}</td>
|
||
<td className="px-4 py-3 text-center">
|
||
{p.join_approved == 1 ? (
|
||
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full font-black text-[9px] uppercase">Join OK</span>
|
||
) : (
|
||
<span className="px-2 py-0.5 bg-slate-100 text-slate-400 rounded-full font-bold text-[9px] uppercase">Wait</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3 text-center">
|
||
{p.payment_approved == 1 ? (
|
||
<span className="px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-full font-black text-[9px] uppercase">Paid</span>
|
||
) : (
|
||
<span className="px-2 py-0.5 bg-slate-100 text-slate-400 rounded-full font-bold text-[9px] uppercase">Wait</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3 text-center">
|
||
{(currentRole === '영업관리' || currentRole === '운영자') && p.join_approved == 0 && (
|
||
<div className="flex items-center justify-center gap-1">
|
||
<button
|
||
onClick={() => handleOpenEditProduct(t, p)}
|
||
className="p-1.5 text-blue-500 hover:bg-blue-50 rounded-lg transition-colors group relative"
|
||
title="계약 수정"
|
||
>
|
||
<LucideIcon name="edit" className="w-4 h-4" />
|
||
<span className="absolute -top-8 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">수정</span>
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteProduct(t.id, p.id)}
|
||
className="p-1.5 text-rose-500 hover:bg-rose-50 rounded-lg transition-colors group relative"
|
||
title="계약 삭제"
|
||
>
|
||
<LucideIcon name="trash-2" className="w-4 h-4" />
|
||
<span className="absolute -top-8 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">삭제</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</React.Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Tenant Modal */}
|
||
{isTenantModalOpen && (
|
||
<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-lg overflow-hidden animate-in zoom-in duration-200">
|
||
<form onSubmit={handleCreateTenant}>
|
||
<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={editingTenantId ? "edit-3" : "building"} className="w-5 h-5 text-blue-600" />
|
||
{editingTenantId ? '테넌트 정보 수정' : '신규 테넌트 등록'}
|
||
<button
|
||
type="button"
|
||
onClick={fillRandomTenantData}
|
||
className="ml-2 p-1.5 bg-amber-50 text-amber-600 rounded-lg hover:bg-amber-100 transition-all border border-amber-200 group relative"
|
||
title="샘플 데이터 자동 입력"
|
||
>
|
||
<LucideIcon name="zap" className="w-3.5 h-3.5" />
|
||
<span className="absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50 shadow-xl">랜덤 데이터 채우기</span>
|
||
</button>
|
||
</h3>
|
||
<button type="button" onClick={() => { setIsTenantModalOpen(false); setEditingTenantId(null); }} 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-xs font-bold text-slate-500 mb-1">업체명 *</label>
|
||
<input type="text" required value={tenantFormData.tenant_name} onChange={e => setTenantFormData({...tenantFormData, tenant_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" placeholder="예: (주)미래소프트" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-500 mb-1">대표자명</label>
|
||
<input type="text" value={tenantFormData.representative} onChange={e => setTenantFormData({...tenantFormData, representative: 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="홍길동" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-500 mb-1">사업자번호</label>
|
||
<input type="text" value={tenantFormData.business_no} onChange={e => setTenantFormData({...tenantFormData, business_no: formatBusinessNo(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="000-00-00000" />
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-500 mb-1">연락처</label>
|
||
<input type="tel" value={tenantFormData.contact_phone} onChange={e => setTenantFormData({...tenantFormData, contact_phone: formatPhone(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={tenantFormData.email} onChange={e => setTenantFormData({...tenantFormData, 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@mail.com" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-500 mb-1">담당 매니저 지정</label>
|
||
<select
|
||
value={tenantFormData.sales_manager_id}
|
||
onChange={e => setTenantFormData({...tenantFormData, sales_manager_id: 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 bg-white"
|
||
>
|
||
{filteredManagers.map(m => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.name} ({m.role}) {m.id === currentUser.id ? '- 본인' : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<p className="mt-1 text-[10px] text-slate-400 font-medium">* 직접 관리하시려면 본인을 선택하고, 별도 매니저에게 맡기려면 매니저를 선택하세요.</p>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-500 mb-1">주소</label>
|
||
<input type="text" value={tenantFormData.address} onChange={e => setTenantFormData({...tenantFormData, address: 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="상세 주소를 입력하세요" />
|
||
</div>
|
||
</div>
|
||
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-between items-center gap-3">
|
||
<div className="flex gap-2">
|
||
{editingTenantId && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const t = tenants.find(tt => tt.id === editingTenantId);
|
||
setSelectedTenant(t);
|
||
setIsTenantModalOpen(false);
|
||
setIsProductModalOpen(true);
|
||
}}
|
||
className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg font-bold hover:bg-indigo-100 transition-all border border-indigo-100 flex items-center gap-1"
|
||
>
|
||
<LucideIcon name="settings-2" className="w-4 h-4" />
|
||
상세계약 설정
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button type="button" onClick={() => { setIsTenantModalOpen(false); setEditingTenantId(null); }} className="px-4 py-2 text-slate-700 bg-white border border-slate-300 rounded-lg font-medium">취소</button>
|
||
<button type="submit" className="px-6 py-2 bg-blue-600 text-white rounded-lg font-bold shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all">
|
||
{editingTenantId ? '수정 완료' : '등록하기'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Product Modal */}
|
||
{isProductModalOpen && selectedTenant && (
|
||
<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 flex flex-col max-h-[90vh] overflow-hidden animate-in zoom-in duration-200">
|
||
<form onSubmit={handleAddProduct} className="flex flex-col overflow-hidden">
|
||
<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="package-plus" className="w-5 h-5 text-indigo-600" />
|
||
계약 정보 추가
|
||
<button
|
||
type="button"
|
||
onClick={fillRandomProductData}
|
||
className="ml-2 p-1.5 bg-amber-50 text-amber-600 rounded-lg hover:bg-amber-100 transition-all border border-amber-200 group relative"
|
||
title="샘플 데이터 자동 입력"
|
||
>
|
||
<LucideIcon name="zap" className="w-3.5 h-3.5" />
|
||
<span className="absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50 shadow-xl">랜덤 데이터 채우기</span>
|
||
</button>
|
||
</h3>
|
||
<button type="button" onClick={() => {
|
||
setIsProductModalOpen(false);
|
||
setEditProductId(null);
|
||
setProductFormData({ product_name: '', contract_amount: '', subscription_fee: '', commission_rate: '20', contract_date: new Date().toISOString().split('T')[0] });
|
||
}} 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 flex-1 overflow-y-auto">
|
||
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100 mb-2">
|
||
<p className="text-xs text-blue-600 font-bold uppercase tracking-wider mb-1">Target Tenant</p>
|
||
<p className="text-sm font-black text-blue-900">{selectedTenant.tenant_name}</p>
|
||
</div>
|
||
|
||
{/* Simulator-like Selector */}
|
||
<div className="space-y-4">
|
||
<label className="block text-xs font-bold text-slate-500 mb-1">상품 및 패키지 선택 *</label>
|
||
<div className="grid grid-cols-1 gap-2">
|
||
{(salesConfig.package_types || []).map(pkg => {
|
||
const isSelected = selectedCategory === pkg.id;
|
||
const key = `package_${pkg.id}`;
|
||
const dbPrice = pricingData[key] || { join_fee: pkg.join_fee };
|
||
|
||
return (
|
||
<div key={pkg.id}
|
||
onClick={() => {
|
||
setSelectedCategory(pkg.id);
|
||
if (pkg.id !== 'select_models') {
|
||
setProductFormData({
|
||
...productFormData,
|
||
product_name: pkg.name,
|
||
contract_amount: dbPrice.join_fee,
|
||
subscription_fee: dbPrice.subscription_fee,
|
||
commission_rate: '20' // 가입비의 20%
|
||
});
|
||
setSelectedSubModels([]);
|
||
} else {
|
||
setProductFormData({
|
||
...productFormData,
|
||
product_name: '선택모델 하이브리드',
|
||
contract_amount: 0,
|
||
subscription_fee: 0,
|
||
commission_rate: '20'
|
||
});
|
||
}
|
||
}}
|
||
className={`p-3 border rounded-xl cursor-pointer transition-all ${isSelected ? 'border-indigo-500 bg-indigo-50 ring-2 ring-indigo-200' : 'border-slate-200 hover:border-slate-300 bg-white'}`}
|
||
>
|
||
<div className="flex justify-between items-center">
|
||
<div className="font-bold text-slate-900 text-sm">{pkg.name}</div>
|
||
{pkg.id !== 'select_models' && (
|
||
<div className="text-xs font-black text-indigo-600">{formatCurrency(dbPrice.join_fee)}</div>
|
||
)}
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-0.5">{pkg.id === 'select_models' ? '세부 모델을 직접 선택합니다' : '고정형 패키지'}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Select Models Detail */}
|
||
{selectedCategory === 'select_models' && (
|
||
<div className="p-3 bg-slate-50 rounded-xl border border-slate-200 space-y-2 max-h-48 overflow-y-auto">
|
||
<label className="block text-[10px] font-black text-slate-400 uppercase mb-2">세부 모델 선택</label>
|
||
{(salesConfig.package_types.find(p => p.id === 'select_models').models || []).map(model => {
|
||
const isChecked = selectedSubModels.includes(model.id);
|
||
const key = `model_${model.id}`;
|
||
const dbPrice = pricingData[key] || { join_fee: model.join_fee };
|
||
|
||
return (
|
||
<div key={model.id} className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={isChecked}
|
||
onChange={(e) => {
|
||
let newSubModels;
|
||
if (e.target.checked) {
|
||
newSubModels = [...selectedSubModels, model.id];
|
||
} else {
|
||
newSubModels = selectedSubModels.filter(id => id !== model.id);
|
||
}
|
||
setSelectedSubModels(newSubModels);
|
||
|
||
// Calculate total amount
|
||
const total = newSubModels.reduce((sum, id) => {
|
||
const m = salesConfig.package_types.find(p => p.id === 'select_models').models.find(mod => mod.id === id);
|
||
const pKey = `model_${id}`;
|
||
const mPrice = pricingData[pKey] || { join_fee: m.join_fee };
|
||
return sum + (mPrice.join_fee || 0);
|
||
}, 0);
|
||
|
||
setProductFormData({
|
||
...productFormData,
|
||
product_name: newSubModels.length > 0 ? `선택모델(${newSubModels.length}종)` : '',
|
||
contract_amount: total,
|
||
subscription_fee: 0
|
||
});
|
||
}}
|
||
className="w-4 h-4 text-indigo-600 rounded"
|
||
/>
|
||
<div className="flex-1 text-xs font-medium text-slate-700">{model.name}</div>
|
||
<div className="text-[10px] text-slate-500">{formatCurrency(dbPrice.join_fee)}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4 border-t border-slate-100 pt-4">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-500 mb-1">가입비 (자동설정)</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={productFormData.contract_amount ? Number(productFormData.contract_amount).toLocaleString() : ''}
|
||
onChange={e => setProductFormData({...productFormData, contract_amount: e.target.value.replace(/[^0-9]/g, '')})}
|
||
className="w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50 font-bold text-blue-600 text-right"
|
||
placeholder="0"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-500 mb-1">월 구독료</label>
|
||
<input
|
||
type="text"
|
||
value={productFormData.subscription_fee ? Number(productFormData.subscription_fee).toLocaleString() : ''}
|
||
onChange={e => setProductFormData({...productFormData, subscription_fee: e.target.value.replace(/[^0-9]/g, '')})}
|
||
className="w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 font-bold text-slate-700 text-right"
|
||
placeholder="0"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-500 mb-1">상품명 (자동설정)</label>
|
||
<input type="text" required value={productFormData.product_name} onChange={e => setProductFormData({...productFormData, product_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 bg-slate-50" placeholder="위에서 상품을 선택하세요" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-500 mb-1">계약일</label>
|
||
<input type="date" value={productFormData.contract_date} onChange={e => setProductFormData({...productFormData, contract_date: 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>
|
||
<div className="p-3 bg-amber-50 rounded-lg border border-amber-100 mb-2">
|
||
<div className="flex justify-between items-center mb-1">
|
||
<span className="text-xs font-bold text-amber-700">수익 기준:</span>
|
||
<span className="text-xs font-bold text-amber-900">가입비의 20%</span>
|
||
</div>
|
||
</div>
|
||
<div className="p-3 bg-indigo-50 rounded-lg border border-indigo-100 flex justify-between items-center">
|
||
<span className="text-xs font-bold text-indigo-700">예상 내 수익:</span>
|
||
<span className="text-lg font-black text-indigo-900">
|
||
{formatCurrency((productFormData.contract_amount || 0) * 0.20)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
|
||
<button type="button" onClick={() => setIsProductModalOpen(false)} className="px-4 py-2 text-slate-700 bg-white border border-slate-300 rounded-lg font-medium">취소</button>
|
||
<button type="submit" className="px-6 py-2 bg-indigo-600 text-white rounded-lg font-bold shadow-lg shadow-indigo-100 hover:bg-indigo-700 transition-all">저장하기</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Scenario Modals */}
|
||
{activeSalesScenarioTenant && (
|
||
<ManagerScenarioView
|
||
key={`sales_${activeSalesScenarioTenant.id}`}
|
||
tenant={activeSalesScenarioTenant}
|
||
scenarioType="sales"
|
||
onClose={() => setActiveSalesScenarioTenant(null)}
|
||
onTriggerContract={(t) => {
|
||
setActiveSalesScenarioTenant(null);
|
||
setSelectedTenant(t);
|
||
setIsProductModalOpen(true);
|
||
}}
|
||
/>
|
||
)}
|
||
{activeManagerScenarioTenant && (
|
||
<ManagerScenarioView
|
||
key={`manager_${activeManagerScenarioTenant.id}`}
|
||
tenant={activeManagerScenarioTenant}
|
||
scenarioType="manager"
|
||
onClose={() => setActiveManagerScenarioTenant(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 3. Main App Component
|
||
const App = () => {
|
||
const [loading, setLoading] = useState(true);
|
||
const [data, setData] = useState(null);
|
||
const [selectedRecord, setSelectedRecord] = useState(null);
|
||
const [isHelpOpen, setIsHelpOpen] = useState(false);
|
||
const [selectedRole, setSelectedRole] = useState('영업관리'); // 기본값: 영업관리
|
||
const [organizationData, setOrganizationData] = useState(null);
|
||
const [systemTotalStats, setSystemTotalStats] = useState(null);
|
||
const [isOrgLoading, setIsOrgLoading] = useState(false);
|
||
|
||
// Session states
|
||
const [currentUser, setCurrentUser] = useState(null);
|
||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||
|
||
useEffect(() => {
|
||
checkSession();
|
||
|
||
// Fetch Mock Data (Remains same for UI parts)
|
||
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]);
|
||
|
||
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);
|
||
|
||
// 역할에 맞춰 화면 자동 전환
|
||
const userRole = sessData.user.role;
|
||
if (userRole === 'operator') setSelectedRole('운영자');
|
||
else if (userRole === 'sales_admin') setSelectedRole('영업관리');
|
||
else if (userRole === 'manager') setSelectedRole('매니저');
|
||
} else {
|
||
setCurrentUser(null);
|
||
setIsLoggedIn(false);
|
||
}
|
||
} catch (err) {
|
||
console.error('Session check failed:', err);
|
||
}
|
||
};
|
||
|
||
const handleLoginSuccess = (user) => {
|
||
setCurrentUser(user);
|
||
setIsLoggedIn(true);
|
||
|
||
// 역할에 맞춰 화면 자동 전환
|
||
if (user.role === 'operator') setSelectedRole('운영자');
|
||
else if (user.role === 'sales_admin') setSelectedRole('영업관리');
|
||
else if (user.role === 'manager') setSelectedRole('매니저');
|
||
};
|
||
|
||
const handleLogout = async () => {
|
||
await fetch('api/sales_members.php?action=logout', { method: 'POST' });
|
||
setIsLoggedIn(false);
|
||
setCurrentUser(null);
|
||
setOrganizationData(null);
|
||
};
|
||
|
||
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);
|
||
setSystemTotalStats(result.total_stats);
|
||
} else {
|
||
console.error('Performance fetch failed:', result.error);
|
||
setOrganizationData(null);
|
||
setSystemTotalStats(null);
|
||
}
|
||
} catch (err) {
|
||
console.error('Fetch error:', err);
|
||
setOrganizationData(null);
|
||
} finally {
|
||
setIsOrgLoading(false);
|
||
}
|
||
};
|
||
|
||
// 역할 변경 시 아이콘 업데이트 (조건부 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 = () => {
|
||
if (!isLoggedIn) {
|
||
return <LoginView onLoginSuccess={handleLoginSuccess} />;
|
||
}
|
||
|
||
switch (selectedRole) {
|
||
case '운영자':
|
||
return <OperatorView currentUser={currentUser} />;
|
||
case '영업관리':
|
||
case '매니저':
|
||
return (
|
||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-12">
|
||
{/* 수당 지급 일정 안내 - 매니제 제외 */}
|
||
{selectedRole !== '매니저' && (
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<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>
|
||
)}
|
||
|
||
{/* Dashbaord & Org Tree */}
|
||
{selectedRole === '영업관리' && (
|
||
<SalesManagementDashboard
|
||
organizationData={organizationData}
|
||
systemTotalStats={systemTotalStats}
|
||
onRefresh={fetchPerformanceData}
|
||
isLoading={isOrgLoading}
|
||
/>
|
||
)}
|
||
|
||
{/* NEW: Profit & Tenant Management Section */}
|
||
<section className="mt-20 pt-12 border-t border-slate-200">
|
||
<div className="flex items-center gap-3 mb-8">
|
||
<div className="p-2 bg-blue-600 rounded-xl text-white shadow-lg shadow-blue-100">
|
||
<LucideIcon name="wallet" className="w-6 h-6" />
|
||
</div>
|
||
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">수익 및 테넌트 관리</h2>
|
||
</div>
|
||
<ProfitManagementView currentUser={currentUser} salesConfig={data.sales_config} currentRole={selectedRole} />
|
||
</section>
|
||
|
||
{selectedRole === '영업관리' && (
|
||
<>
|
||
<ManagerManagementView currentUser={currentUser} />
|
||
</>
|
||
)}
|
||
</main>
|
||
);
|
||
default:
|
||
return (
|
||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||
<div className="text-center py-20">
|
||
<h2 className="text-2xl font-bold text-slate-900 mb-4">알 수 없는 역할</h2>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen pb-20">
|
||
<Header
|
||
companyInfo={data.company_info}
|
||
onOpenHelp={() => setIsHelpOpen(true)}
|
||
selectedRole={selectedRole}
|
||
onRoleChange={async (role) => {
|
||
if (role !== selectedRole) {
|
||
await handleLogout();
|
||
setSelectedRole(role);
|
||
setIsOrgLoading(true);
|
||
}
|
||
}}
|
||
currentUser={currentUser}
|
||
onLogout={handleLogout}
|
||
/>
|
||
|
||
{renderContentByRole()}
|
||
|
||
{/* Detail Modal */}
|
||
{selectedRecord && (
|
||
<CommissionDetailModal
|
||
record={selectedRecord}
|
||
programs={data.sales_config.programs}
|
||
onClose={() => setSelectedRecord(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* Help Modal */}
|
||
{isHelpOpen && (
|
||
<HelpModal onClose={() => setIsHelpOpen(false)} />
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ... (SimulatorSection, SalesList, CommissionDetailModal remain same) ...
|
||
|
||
// 6. Sales Management Dashboard Component
|
||
const SalesManagementDashboard = ({ organizationData, systemTotalStats, onRefresh, isLoading }) => {
|
||
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: Number(orgData.totalSales) || 0,
|
||
totalCommission: Number(orgData.commission) || 0,
|
||
totalCount: Number(orgData.contractCount) || 0,
|
||
commissionRate: Number(orgData.totalSales) > 0 ? ((Number(orgData.commission) / Number(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 ? Number(myDirectSales.commission) || 0 : 0;
|
||
|
||
const level1Children = orgData.children.filter(c => !c.isDirect && c.depth === 1);
|
||
const managerCommission = level1Children.reduce((sum, c) => sum + (Number(c.commission) || 0), 0);
|
||
|
||
const educatorCommission = 0; // 메뉴제작 협업수당: 운영팀 별도 산정
|
||
|
||
const totalCommission = sellerCommission + managerCommission + educatorCommission;
|
||
|
||
return {
|
||
sellerCommission,
|
||
managerCommission,
|
||
educatorCommission,
|
||
totalCommission,
|
||
totalRevenue: Number(orgData.totalSales) || 0,
|
||
commissionRate: Number(orgData.totalSales) > 0 ? ((totalCommission / Number(orgData.totalSales)) * 100).toFixed(1) : 0
|
||
};
|
||
};
|
||
|
||
// 기간별 데이터 필터링
|
||
useEffect(() => {
|
||
if (!organizationData) return;
|
||
|
||
let startDate, endDate;
|
||
|
||
if (periodType === 'current_month') {
|
||
// 당월
|
||
const now = new Date();
|
||
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||
} else {
|
||
// 커스텀 기간
|
||
startDate = new Date(startYear, startMonth - 1, 1);
|
||
endDate = new Date(endYear, endMonth, 0);
|
||
}
|
||
|
||
// 날짜 범위로 계약 필터링
|
||
const filterNodeByDate = (node) => {
|
||
const 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 => {
|
||
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 + (Number(c.amount) || 0), 0);
|
||
// 수당 집계용 매출 (지급 승인된 건만)
|
||
const ownApprovedSales = filteredContracts.filter(c => c.payment_approved == 1).reduce((sum, c) => sum + (Number(c.amount) || 0), 0);
|
||
|
||
const childrenSales = filteredChildren.reduce((sum, c) => sum + (Number(c.totalSales) || 0), 0);
|
||
const childrenApprovedSalesForComm = filteredChildren.reduce((sum, c) => sum + (c.approvedSalesForComm || 0), 0); // Not directly used but helpful context
|
||
|
||
const totalSales = ownSales + childrenSales;
|
||
const contractCount = filteredContracts.length + filteredChildren.reduce((sum, c) => sum + (Number(c.contractCount) || 0), 0);
|
||
|
||
// 데이터가 없으면 null 반환
|
||
if (totalSales === 0 && filteredChildren.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// 수당 재계산
|
||
let commission = 0;
|
||
if (node.isDirect) {
|
||
// 직접 판매
|
||
if (node.depth === 0) commission = ownApprovedSales * 0.20;
|
||
else if (node.depth === 1) commission = ownApprovedSales * 0.05;
|
||
else if (node.depth === 2) commission = 0;
|
||
} else {
|
||
// 영업관리
|
||
if (node.depth === 0) {
|
||
// 내 조직: 직접 20% + 1차 하위 5% (메뉴제작 협업수당 별도)
|
||
const myDirect = filteredChildren.find(c => c.isDirect);
|
||
const level1 = filteredChildren.filter(c => !c.isDirect && c.depth === 1);
|
||
|
||
// 1차 하위 영업자의 지급승인 완료된 실적 합계
|
||
const level1ApprovedSales = level1.reduce((sum, c) => {
|
||
// c.contracts 중 payment_approved == 1 인 것들
|
||
const nodeApproved = (c.contracts || []).filter(p => p.payment_approved == 1).reduce((s, p) => s + (Number(p.amount) || 0), 0);
|
||
return sum + nodeApproved;
|
||
}, 0);
|
||
|
||
commission = (myDirect ? myDirect.commission : 0) + (level1ApprovedSales * 0.05);
|
||
} else if (node.depth === 1) {
|
||
// 하위 영업자 노드 자체의 수당 (지급승인된 건의 5%)
|
||
commission = ownApprovedSales * 0.05;
|
||
} else if (node.depth === 2) {
|
||
commission = 0;
|
||
}
|
||
}
|
||
|
||
return {
|
||
...node,
|
||
totalSales,
|
||
ownApprovedSales, // Added for context
|
||
contractCount,
|
||
commission,
|
||
contracts: filteredContracts,
|
||
children: filteredChildren
|
||
};
|
||
};
|
||
|
||
const filtered = filterNodeByDate(organizationData);
|
||
setPeriodOrgData(filtered);
|
||
}, [periodType, startYear, startMonth, endYear, endMonth, organizationData]);
|
||
|
||
const totalStats = systemTotalStats ? {
|
||
totalRevenue: systemTotalStats.totalSales,
|
||
totalCommission: systemTotalStats.totalCommission,
|
||
totalCount: systemTotalStats.totalCount,
|
||
pendingJoin: systemTotalStats.pendingJoin || 0,
|
||
pendingPayment: systemTotalStats.pendingPayment || 0,
|
||
commissionRate: systemTotalStats.totalSales > 0 ? ((systemTotalStats.totalCommission / systemTotalStats.totalSales) * 100).toFixed(1) : 0
|
||
} : {
|
||
totalRevenue: Number(organizationData?.totalSales) || 0,
|
||
totalCommission: Number(organizationData?.commission) || 0,
|
||
totalCount: Number(organizationData?.contractCount) || 0,
|
||
pendingJoin: Number(organizationData?.pendingJoin) || 0,
|
||
pendingPayment: Number(organizationData?.pendingPayment) || 0,
|
||
commissionRate: Number(organizationData?.totalSales) > 0 ? ((Number(organizationData?.commission) / Number(organizationData?.totalSales)) * 100).toFixed(1) : 0
|
||
};
|
||
|
||
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-5 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 className={`bg-white rounded-card p-6 shadow-sm border ${totalStats.pendingJoin > 0 ? 'border-amber-200 bg-amber-50/10' : 'border-slate-100'}`}>
|
||
<div className="flex items-start justify-between mb-4">
|
||
<h3 className="text-sm font-medium text-slate-500">가입 승인 대기</h3>
|
||
<div className={`p-2 rounded-lg ${totalStats.pendingJoin > 0 ? 'bg-amber-100 text-amber-600' : 'bg-slate-100 text-slate-400'}`}>
|
||
<LucideIcon name="user-plus" className="w-5 h-5" />
|
||
</div>
|
||
</div>
|
||
<div className={`text-2xl font-black mb-1 ${totalStats.pendingJoin > 0 ? 'text-amber-600' : 'text-slate-900'}`}>
|
||
{totalStats.pendingJoin}건
|
||
</div>
|
||
<div className="text-xs text-slate-400">조직 내 가입 승인 대기</div>
|
||
</div>
|
||
|
||
{/* 지급 승인 대기 */}
|
||
<div className={`bg-white rounded-card p-6 shadow-sm border ${totalStats.pendingPayment > 0 ? 'border-rose-200 bg-rose-50/10' : 'border-slate-100'}`}>
|
||
<div className="flex items-start justify-between mb-4">
|
||
<h3 className="text-sm font-medium text-slate-500">지급 승인 대기</h3>
|
||
<div className={`p-2 rounded-lg ${totalStats.pendingPayment > 0 ? 'bg-rose-100 text-rose-600' : 'bg-slate-100 text-slate-400'}`}>
|
||
<LucideIcon name="credit-card" className="w-5 h-5" />
|
||
</div>
|
||
</div>
|
||
<div className={`text-2xl font-black mb-1 ${totalStats.pendingPayment > 0 ? 'text-rose-600' : 'text-slate-900'}`}>
|
||
{totalStats.pendingPayment}건
|
||
</div>
|
||
<div className="text-xs text-slate-400">조직 내 지급 승인 대기</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 기간 선택 UI */}
|
||
<section className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
|
||
<h2 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||
<LucideIcon name="calendar-range" className="w-5 h-5 text-blue-600" />
|
||
기간별 조회
|
||
</h2>
|
||
|
||
<div className="flex flex-wrap items-center gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setPeriodType('current_month')}
|
||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||
periodType === 'current_month'
|
||
? 'bg-blue-600 text-white'
|
||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
당월
|
||
</button>
|
||
<button
|
||
onClick={() => setPeriodType('custom')}
|
||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||
periodType === 'custom'
|
||
? 'bg-blue-600 text-white'
|
||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
기간 설정
|
||
</button>
|
||
</div>
|
||
|
||
{periodType === 'custom' && (
|
||
<div className="flex items-center gap-3">
|
||
<select
|
||
value={startYear}
|
||
onChange={(e) => {
|
||
const newStartYear = Number(e.target.value);
|
||
setStartYear(newStartYear);
|
||
// Logic: If Start Year > End Year, set End Year = Start Year.
|
||
// Also if Start Year == End Year and Start Month > End Month, set End Month = Start Month.
|
||
if (newStartYear > endYear) {
|
||
setEndYear(newStartYear);
|
||
} else if (newStartYear === endYear && startMonth > endMonth) {
|
||
setEndMonth(startMonth);
|
||
}
|
||
}}
|
||
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
>
|
||
{years.map(y => <option key={y} value={y}>{y}년</option>)}
|
||
</select>
|
||
<select
|
||
value={startMonth}
|
||
onChange={(e) => {
|
||
const newStartMonth = Number(e.target.value);
|
||
setStartMonth(newStartMonth);
|
||
// Logic: If Start Year == End Year and Start Month > End Month, set End Month = Start Month.
|
||
if (startYear === endYear && newStartMonth > endMonth) {
|
||
setEndMonth(newStartMonth);
|
||
}
|
||
// If Start Year > End Year, it should have been handled by Year change, but robustly:
|
||
if (startYear > endYear) {
|
||
setEndYear(startYear);
|
||
}
|
||
}}
|
||
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
>
|
||
{months.map(m => <option key={m} value={m}>{m}월</option>)}
|
||
</select>
|
||
<span className="text-slate-500">~</span>
|
||
<select
|
||
value={endYear}
|
||
onChange={(e) => {
|
||
const newEndYear = Number(e.target.value);
|
||
setEndYear(newEndYear);
|
||
// Logic: If End Year < Start Year, set Start Year = End Year.
|
||
// Also if End Year == Start Year and End Month < Start Month, set Start Month = End Month.
|
||
if (newEndYear < startYear) {
|
||
setStartYear(newEndYear);
|
||
} else if (newEndYear === startYear && endMonth < startMonth) {
|
||
setStartMonth(endMonth);
|
||
}
|
||
}}
|
||
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
>
|
||
{years.map(y => <option key={y} value={y}>{y}년</option>)}
|
||
</select>
|
||
<select
|
||
value={endMonth}
|
||
onChange={(e) => {
|
||
const newEndMonth = Number(e.target.value);
|
||
setEndMonth(newEndMonth);
|
||
// Logic: If End Year == Start Year and End Month < Start Month, set Start Month = End Month.
|
||
if (endYear === startYear && newEndMonth < startMonth) {
|
||
setStartMonth(newEndMonth);
|
||
}
|
||
// If End Year < Start Year, it should have been handled by Year change, but robustly:
|
||
if (endYear < startYear) {
|
||
setStartYear(endYear);
|
||
}
|
||
}}
|
||
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
>
|
||
{months.map(m => <option key={m} value={m}>{m}월</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
<div className="text-sm text-slate-500">
|
||
{periodType === 'current_month'
|
||
? `${new Date().getFullYear()}년 ${new Date().getMonth() + 1}월`
|
||
: `${startYear}년 ${startMonth}월 ~ ${endYear}년 ${endMonth}월`}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 기간별 역할별 수당 상세 */}
|
||
<section className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
|
||
<h3 className="text-lg font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||
<LucideIcon name="layers" className="w-5 h-5 text-blue-600" />
|
||
역할별 수당 상세
|
||
</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div className="p-4 bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border border-green-200">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
|
||
<LucideIcon name="user" className="w-4 h-4 text-green-600" />
|
||
</div>
|
||
<span className="text-sm font-medium text-green-900">판매자</span>
|
||
</div>
|
||
<span className="text-xs font-bold text-green-700 bg-green-100 px-2 py-1 rounded">20%</span>
|
||
</div>
|
||
<div className="text-2xl font-bold text-green-900">{formatCurrency(periodStats.sellerCommission)}</div>
|
||
<div className="text-xs text-green-600 mt-1"></div>
|
||
</div>
|
||
|
||
<div className="p-4 bg-gradient-to-br from-purple-50 to-violet-50 rounded-lg border border-purple-200">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-8 h-8 rounded-full bg-purple-100 flex items-center justify-center">
|
||
<LucideIcon name="users" className="w-4 h-4 text-purple-600" />
|
||
</div>
|
||
<span className="text-sm font-medium text-purple-900">관리자</span>
|
||
</div>
|
||
<span className="text-xs font-bold text-purple-700 bg-purple-100 px-2 py-1 rounded">5%</span>
|
||
</div>
|
||
<div className="text-2xl font-bold text-purple-900">{formatCurrency(periodStats.managerCommission)}</div>
|
||
<div className="text-xs text-purple-600 mt-1"></div>
|
||
</div>
|
||
|
||
<div className="p-4 bg-gradient-to-br from-orange-50 to-amber-50 rounded-lg border border-orange-200">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-8 h-8 rounded-full bg-orange-100 flex items-center justify-center">
|
||
<LucideIcon name="users" className="w-4 h-4 text-orange-600" />
|
||
</div>
|
||
<span className="text-sm font-medium text-orange-900">메뉴제작 협업수당</span>
|
||
</div>
|
||
<span className="text-xs font-bold text-orange-700 bg-orange-100 px-2 py-1 rounded">별도</span>
|
||
</div>
|
||
<div className="text-2xl font-bold text-orange-900">운영팀 산정</div>
|
||
<div className="text-xs text-orange-600 mt-1"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||
<div className="flex items-center justify-end">
|
||
<div className="text-right">
|
||
<div className="text-sm font-bold text-blue-900">{formatCurrency(periodStats.totalCommission)}</div>
|
||
<div className="text-xs text-blue-600">총 가입비 대비 수당</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 기간별 조직 트리 */}
|
||
<OrganizationTree organizationData={periodOrgData} onRefresh={onRefresh} showPeriodData={true} isLoading={isLoading} />
|
||
</>
|
||
);
|
||
};
|
||
|
||
// 7. Hierarchical Organization Tree Component
|
||
const OrganizationTree = ({ organizationData, onRefresh, showPeriodData, isLoading }) => {
|
||
const [expandedNodes, setExpandedNodes] = useState(new Set(['root']));
|
||
const [selectedManager, setSelectedManager] = useState(null);
|
||
|
||
const toggleNode = (nodeId) => {
|
||
const newExpanded = new Set(expandedNodes);
|
||
if (newExpanded.has(nodeId)) {
|
||
newExpanded.delete(nodeId);
|
||
} else {
|
||
newExpanded.add(nodeId);
|
||
}
|
||
setExpandedNodes(newExpanded);
|
||
};
|
||
|
||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
|
||
|
||
const getDepthColor = (depth, isDirect) => {
|
||
if (isDirect) return 'bg-yellow-50 border-yellow-200'; // 직접 판매는 노란색
|
||
switch(depth) {
|
||
case 0: return 'bg-blue-50 border-blue-200';
|
||
case 1: return 'bg-green-50 border-green-200';
|
||
case 2: return 'bg-purple-50 border-purple-200';
|
||
default: return 'bg-slate-50 border-slate-200';
|
||
}
|
||
};
|
||
|
||
const getDepthIcon = (depth, isDirect) => {
|
||
if (isDirect) return 'shopping-cart'; // 직접 판매는 쇼핑카트 아이콘
|
||
switch(depth) {
|
||
case 0: return 'crown';
|
||
case 1: return 'user-check';
|
||
case 2: return 'users';
|
||
default: return 'user';
|
||
}
|
||
};
|
||
|
||
const renderNode = (node, depth = 0) => {
|
||
const isExpanded = expandedNodes.has(node.id);
|
||
const hasChildren = node.children && node.children.length > 0;
|
||
const paddingLeft = depth * 24;
|
||
const isDirect = node.isDirect || false;
|
||
|
||
return (
|
||
<div key={node.id}>
|
||
<div
|
||
className={`p-4 border ${getDepthColor(node.depth, isDirect)} rounded-lg hover:shadow-md transition-all cursor-pointer mb-2`}
|
||
style={{ marginLeft: `${paddingLeft}px` }}
|
||
onClick={() => {
|
||
if (hasChildren && !isDirect) toggleNode(node.id);
|
||
if (!isDirect) setSelectedManager(node);
|
||
}}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3 flex-1">
|
||
{hasChildren && !isDirect && (
|
||
<button
|
||
className="p-1 hover:bg-white/50 rounded transition-colors"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
toggleNode(node.id);
|
||
}}
|
||
>
|
||
<LucideIcon name={isExpanded ? 'chevron-down' : 'chevron-right'} className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
{(!hasChildren || isDirect) && <div className="w-6"></div>}
|
||
|
||
<div className={`w-10 h-10 rounded-full ${isDirect ? 'bg-yellow-100' : 'bg-white/80'} flex items-center justify-center`}>
|
||
<LucideIcon name={getDepthIcon(node.depth, isDirect)} className="w-5 h-5 text-slate-600" />
|
||
</div>
|
||
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<h4 className={`font-bold ${isDirect ? 'text-orange-900' : 'text-slate-900'}`}>{node.name}</h4>
|
||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDirect ? 'bg-orange-100 text-orange-800' : 'bg-white/60'}`}>
|
||
{node.role}
|
||
</span>
|
||
{hasChildren && !isDirect && (
|
||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
|
||
하위 {node.children.filter(c => !c.isDirect).length}명
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-4 mt-1 text-xs text-slate-600">
|
||
<span>매출: <strong className="text-slate-900">{formatCurrency(node.totalSales)}</strong></span>
|
||
<span>계약: <strong className="text-slate-900">{node.contractCount}건</strong></span>
|
||
{node.depth === 0 && !isDirect ? (
|
||
<span>내 총 수당: <strong className="text-blue-600">{formatCurrency(node.commission)}</strong></span>
|
||
) : (
|
||
<span title={`내가 받는 ${isDirect ? (node.depth === 0 ? '판매자 (20%)' : node.depth === 1 ? '관리자 (5%)' : '메뉴제작 협업자') : (node.depth === 1 ? '관리자 (5%)' : '메뉴제작 협업자')} 수당`}>
|
||
내 수당: <strong className="text-blue-600">{formatCurrency(node.commission)}</strong>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{!isDirect && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setSelectedManager(node);
|
||
}}
|
||
className="p-2 hover:bg-white/50 rounded-lg transition-colors"
|
||
>
|
||
<LucideIcon name="more-vertical" className="w-4 h-4 text-slate-400" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{isExpanded && hasChildren && (
|
||
<div className="space-y-2">
|
||
{node.children.map(child => renderNode(child, depth + 1))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
|
||
|
||
if (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>
|
||
);
|
||
}
|
||
|
||
if (!organizationData) {
|
||
return (
|
||
<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>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<section className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden">
|
||
<div className="p-6 border-b border-slate-100 bg-gradient-to-r from-blue-50 to-indigo-50">
|
||
<div className="flex justify-between items-start mb-3">
|
||
<div>
|
||
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||
<LucideIcon name="network" className="w-5 h-5 text-blue-600" />
|
||
{showPeriodData ? '기간별 조직 구조 및 실적' : '조직 구조 및 실적'}
|
||
</h2>
|
||
<p className="text-xs text-slate-500 mt-1">계층별 가입비 및 수당 현황 (내 관점)</p>
|
||
</div>
|
||
{!showPeriodData && (
|
||
<button
|
||
onClick={() => {
|
||
if (onRefresh) onRefresh();
|
||
}}
|
||
className="px-3 py-2 bg-white rounded-lg hover:bg-blue-50 transition-colors text-sm font-medium text-slate-700 flex items-center gap-2 shadow-sm"
|
||
>
|
||
<LucideIcon name="refresh-cw" className="w-4 h-4" />
|
||
새로고침
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex gap-2 text-xs">
|
||
<div className="px-3 py-1.5 bg-blue-100 text-blue-800 rounded-full font-medium">
|
||
판매자 (20%)
|
||
</div>
|
||
<div className="px-3 py-1.5 bg-green-100 text-green-800 rounded-full font-medium">
|
||
관리자 (5%)
|
||
</div>
|
||
<div className="px-3 py-1.5 bg-purple-100 text-purple-800 rounded-full font-medium">
|
||
메뉴제작 협업자 (별도)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-6">
|
||
{renderNode(organizationData)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* Detail Modal */}
|
||
{selectedManager && (
|
||
<ManagerDetailModal
|
||
manager={selectedManager}
|
||
onClose={() => setSelectedManager(null)}
|
||
showPeriodData={showPeriodData}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
// Manager Detail Modal
|
||
const ManagerDetailModal = ({ manager, onClose, showPeriodData }) => {
|
||
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;
|
||
|
||
const directSalesTotal = manager.contracts ? manager.contracts.reduce((sum, c) => sum + Number(c.amount), 0) : 0;
|
||
const directCommissionTotal = manager.contracts ? manager.contracts.reduce((sum, c) => sum + Number(c.commission || 0), 0) : 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-xs text-slate-500">{manager.role} | {showPeriodData ? '기간 실적 상세' : '누적 실적 상세'}</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 = contract.commission || 0;
|
||
|
||
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 className="bg-blue-50/30">
|
||
<td colSpan="2" className="px-4 py-3 text-sm font-bold text-slate-800">직접 계약 합계</td>
|
||
<td className="px-4 py-3 text-right text-sm font-bold text-slate-900">{formatCurrency(directSalesTotal)}</td>
|
||
<td className="px-4 py-3 text-right text-sm font-bold text-blue-900">{formatCurrency(directCommissionTotal)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{manager.children && manager.children.length > 0 && (
|
||
<div>
|
||
<h4 className="text-sm font-bold text-slate-900 mb-3 flex items-center gap-2">
|
||
<LucideIcon name="users" className="w-4 h-4 text-blue-600" />
|
||
하위 조직 ({manager.children.length}명)
|
||
</h4>
|
||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||
{manager.children.map(child => (
|
||
<div key={child.id} className="p-3 bg-slate-50 rounded-lg border border-slate-200 flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-8 h-8 rounded-full bg-white flex items-center justify-center">
|
||
<LucideIcon name="user" className="w-4 h-4 text-slate-600" />
|
||
</div>
|
||
<div>
|
||
<div className="font-medium text-slate-900 text-sm">{child.name}</div>
|
||
<div className="text-xs text-slate-500">{child.role}</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-sm font-bold text-slate-900">{formatCurrency(child.totalSales)}</div>
|
||
<div className="text-xs text-slate-500">{child.contractCount}건</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end flex-shrink-0">
|
||
<button onClick={onClose} className="px-4 py-2 bg-white border border-slate-200 rounded-lg text-slate-700 hover:bg-slate-50 font-medium transition-colors">
|
||
닫기
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 8. Sub-Manager Detail Modal
|
||
// 8. Help Modal
|
||
const HelpModal = ({ onClose }) => {
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden" onClick={e => e.stopPropagation()}>
|
||
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50">
|
||
<h3 className="text-xl font-bold text-slate-900 flex items-center gap-2">
|
||
<LucideIcon name="book-open" className="w-5 h-5 text-blue-600" />
|
||
수당 체계 설명서
|
||
</h3>
|
||
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
|
||
<LucideIcon name="x" className="w-5 h-5 text-slate-500" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto text-slate-600 text-sm leading-relaxed">
|
||
<section>
|
||
<h4 className="text-base font-bold text-slate-900 mb-2">1. 수당 체계 개요</h4>
|
||
<p className="mb-3">영업 수당은 <strong>가입비</strong>에 대해서만 지급됩니다.</p>
|
||
<p>수당은 판매자와 그 상위 관리자, 메뉴제작 협업자에게 지급됩니다.</p>
|
||
</section>
|
||
|
||
<section>
|
||
<h4 className="text-base font-bold text-slate-900 mb-2">2. 수당 구성</h4>
|
||
<p className="mb-3">영업 수당은 세 가지 역할에 따라 구분되어 지급됩니다:</p>
|
||
<ul className="list-disc pl-5 space-y-2">
|
||
<li><strong>판매자 (Seller)</strong>: 직접 영업을 성사시킨 담당자 - <span className="text-blue-600 font-semibold">가입비의 20%</span></li>
|
||
<li><strong>관리자 (Manager)</strong>: 판매자를 데려온 상위 담당자 - <span className="text-green-600 font-semibold">가입비의 5%</span></li>
|
||
<li><strong>메뉴제작 협업자 (Collaborator)</strong>: 관리자를 데려온 상위 담당자 - <span className="text-purple-600 font-semibold">운영팀 별도 산정</span></li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section>
|
||
<h4 className="text-base font-bold text-slate-900 mb-2">3. 수당 지급 요율</h4>
|
||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
|
||
<table className="w-full text-left">
|
||
<thead>
|
||
<tr className="border-b border-slate-200">
|
||
<th className="pb-2">구분</th>
|
||
<th className="pb-2">가입비 요율</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
<tr>
|
||
<td className="py-2 font-medium">판매자</td>
|
||
<td className="py-2 text-blue-600 font-bold">20%</td>
|
||
</tr>
|
||
<tr>
|
||
<td className="py-2 font-medium">관리자</td>
|
||
<td className="py-2 text-green-600 font-bold">5%</td>
|
||
</tr>
|
||
<tr>
|
||
<td className="py-2 font-medium">메뉴제작 협업자</td>
|
||
<td className="py-2 text-purple-600 font-bold">별도 산정</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h4 className="text-base font-bold text-slate-900 mb-2">4. 계층 구조 이해</h4>
|
||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
|
||
<p className="text-sm text-blue-900 mb-3"><strong>예시:</strong></p>
|
||
<div className="space-y-2 text-sm text-blue-900">
|
||
<p>• A가 B를 영입하고, B가 C를 영입했습니다.</p>
|
||
<p>• C가 판매를 성사시키면:</p>
|
||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||
<li><strong>C (판매자)</strong>: 가입비의 20% 수당</li>
|
||
<li><strong>B (관리자)</strong>: 가입비의 5% 수당</li>
|
||
<li><strong>A (메뉴제작 협업자)</strong>: 운영팀 별도 산정</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h4 className="text-base font-bold text-slate-900 mb-2">5. 지급일 정책</h4>
|
||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
|
||
<ul className="list-disc pl-5 space-y-1 text-sm text-slate-700">
|
||
<li><strong>계약일:</strong> 가입비 완료일을 기준으로 합니다.</li>
|
||
<li><strong>수당 지급일:</strong> 가입비 완료 후 지급됩니다.</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h4 className="text-base font-bold text-slate-900 mb-2">6. 회사 마진</h4>
|
||
<p>가입비에서 영업 수당(총 25%)을 제외한 금액이 회사 마진으로 귀속됩니다. (메뉴제작 협업수당은 별도)</p>
|
||
</section>
|
||
</div>
|
||
|
||
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end">
|
||
<button onClick={onClose} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors shadow-sm">
|
||
확인
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 4. Simulator Component
|
||
const SimulatorSection = ({ salesConfig, selectedRole }) => {
|
||
const [duration, setDuration] = useState(salesConfig.default_contract_period || 84);
|
||
const [simulatorGuideOpen, setSimulatorGuideOpen] = useState(false);
|
||
const [pricingData, setPricingData] = useState({});
|
||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||
const [editingItem, setEditingItem] = useState(null);
|
||
const isOperator = selectedRole === '운영자';
|
||
|
||
// 1차 선택: 동적 패키지 관리
|
||
const [selectedPackageIds, setSelectedPackageIds] = useState([]);
|
||
const [selectedModels, setSelectedModels] = useState([]);
|
||
const [salesMode, setSalesMode] = useState('direct'); // 'direct' (직접영업) or 'manager' (영업사원 실적관리)
|
||
|
||
// 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 || [];
|
||
|
||
// 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;
|
||
|
||
packageTypes.forEach(pkg => {
|
||
if (pkg.id === 'select_models') {
|
||
if (selectedPackageIds.includes(pkg.id)) {
|
||
(pkg.models || []).forEach(modelIdOrObj => {
|
||
// package_types의 models가 ID 배열일수도, 객체 배열일수도 있음 (company_info.php에선 객체 배열)
|
||
const model = typeof modelIdOrObj === 'string'
|
||
? pkg.models.find(m => m.id === modelIdOrObj)
|
||
: modelIdOrObj;
|
||
|
||
if (model && selectedModels.includes(model.id)) {
|
||
const price = getItemPrice('model', model.id, model.join_fee, model.subscription_fee);
|
||
totalJoinFee += price.join_fee || 0;
|
||
|
||
const rates = model.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0 } };
|
||
totalSellerCommission += (price.join_fee * (rates.seller?.join || 0.20));
|
||
totalManagerCommission += (price.join_fee * (rates.manager?.join || 0.05));
|
||
}
|
||
});
|
||
}
|
||
} else {
|
||
if (selectedPackageIds.includes(pkg.id)) {
|
||
const price = getItemPrice('package', pkg.id, pkg.join_fee, pkg.subscription_fee);
|
||
totalJoinFee += price.join_fee || 0;
|
||
|
||
const rates = pkg.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0 } };
|
||
totalSellerCommission += (price.join_fee * (rates.seller?.join || 0.20));
|
||
totalManagerCommission += (price.join_fee * (rates.manager?.join || 0.05));
|
||
}
|
||
}
|
||
});
|
||
|
||
const displayCommission = salesMode === 'direct' ? totalSellerCommission : totalManagerCommission;
|
||
const totalCommission = displayCommission + 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 handlePackageToggle = (pkgId) => {
|
||
setSelectedPackageIds(prev => {
|
||
const next = prev.includes(pkgId)
|
||
? prev.filter(id => id !== pkgId)
|
||
: [...prev, pkgId];
|
||
if (pkgId === 'select_models' && prev.includes(pkgId)) {
|
||
setSelectedModels([]);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
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>
|
||
{/* Left Column: Selection */}
|
||
<div className="space-y-8">
|
||
{/* 영업 모드 선택 (Radio Buttons) */}
|
||
<div>
|
||
<label className="block text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
|
||
<LucideIcon name="user-check" className="w-4 h-4 text-blue-600" />
|
||
영업 유형 선택
|
||
</label>
|
||
<div className="grid grid-cols-2 gap-3 p-1 bg-slate-100 rounded-xl">
|
||
<button
|
||
onClick={() => setSalesMode('direct')}
|
||
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-bold transition-all ${
|
||
salesMode === 'direct'
|
||
? 'bg-white text-blue-600 shadow-sm border-blue-100'
|
||
: 'text-slate-500 hover:text-slate-700'
|
||
}`}
|
||
>
|
||
<LucideIcon name="user" className="w-4 h-4" />
|
||
직접영업
|
||
</button>
|
||
<button
|
||
onClick={() => setSalesMode('manager')}
|
||
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-bold transition-all ${
|
||
salesMode === 'manager'
|
||
? 'bg-white text-blue-600 shadow-sm border-blue-100'
|
||
: 'text-slate-500 hover:text-slate-700'
|
||
}`}
|
||
>
|
||
<LucideIcon name="users" className="w-4 h-4" />
|
||
영업사원
|
||
</button>
|
||
</div>
|
||
<p className="text-[11px] text-slate-400 mt-2 px-1">
|
||
* 직접영업: 본인이 직접 계약 시 (판매자 수당 발생)<br/>
|
||
* 영업사원: 소속 사원이 계약 시 (관리자 수당 발생)
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-bold text-slate-700 mb-3">패키지 선택</label>
|
||
<div className="space-y-3">
|
||
{packageTypes.map(pkg => {
|
||
if (pkg.id === 'select_models') {
|
||
return (
|
||
<div key={pkg.id} className="flex items-start gap-3 p-4 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors">
|
||
<input
|
||
type="checkbox"
|
||
id={pkg.id}
|
||
checked={selectedPackageIds.includes(pkg.id)}
|
||
onChange={() => handlePackageToggle(pkg.id)}
|
||
className="mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
|
||
/>
|
||
<label htmlFor={pkg.id} className="flex-1 cursor-pointer">
|
||
<div className="font-medium text-slate-900">{pkg.name}</div>
|
||
<div className="text-xs text-slate-500 mt-1">여러 모델을 선택할 수 있습니다</div>
|
||
</label>
|
||
</div>
|
||
);
|
||
} else {
|
||
const price = getItemPrice('package', pkg.id, pkg.join_fee, pkg.subscription_fee);
|
||
return (
|
||
<div key={pkg.id} className="flex items-start gap-3 p-4 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors">
|
||
<input
|
||
type="checkbox"
|
||
id={pkg.id}
|
||
checked={selectedPackageIds.includes(pkg.id)}
|
||
onChange={() => handlePackageToggle(pkg.id)}
|
||
className="mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
|
||
/>
|
||
<label htmlFor={pkg.id} className="flex-1 cursor-pointer">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="font-medium text-slate-900">{pkg.name}</div>
|
||
<div className="text-xs text-slate-500 mt-1">{pkg.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('package', pkg.id, pkg.name, pkg.sub_name, pkg.join_fee, pkg.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차 선택: 선택모델의 세부 모델들 */}
|
||
{selectedPackageIds.includes('select_models') && (() => {
|
||
const selectModelsPkg = packageTypes.find(p => p.id === 'select_models');
|
||
if (!selectModelsPkg || !selectModelsPkg.models) return null;
|
||
return (
|
||
<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">
|
||
{selectModelsPkg.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>
|
||
|
||
{salesMode === 'direct' && (
|
||
<div className="flex justify-between items-center p-3 bg-blue-50/50 rounded-lg border border-blue-100/50">
|
||
<span className="text-slate-600 font-medium text-sm flex items-center gap-2">
|
||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||
판매자 수당 (20%)
|
||
</span>
|
||
<span className="font-bold text-blue-700">{formatCurrency(totalSellerCommission)}</span>
|
||
</div>
|
||
)}
|
||
|
||
{salesMode === 'manager' && (
|
||
<div className="flex justify-between items-center p-3 bg-indigo-50/50 rounded-lg border border-indigo-100/50">
|
||
<span className="text-slate-600 font-medium text-sm flex items-center gap-2">
|
||
<span className="w-2 h-2 bg-indigo-500 rounded-full"></span>
|
||
관리자 수당 (5%)
|
||
</span>
|
||
<span className="font-bold text-indigo-700">{formatCurrency(totalManagerCommission)}</span>
|
||
</div>
|
||
)}
|
||
|
||
<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">운영팀 별도 산정</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pt-6 border-t border-dashed border-slate-200">
|
||
<div className="flex justify-between items-end">
|
||
<span className="text-lg font-bold text-slate-800">총 예상 수당</span>
|
||
<span className="text-3xl font-extrabold text-blue-600">{formatCurrency(totalCommission)}</span>
|
||
</div>
|
||
<div className="flex justify-between items-center mt-2">
|
||
<p className="text-xs text-slate-400">* 세전 금액 기준입니다.</p>
|
||
<p className="text-xs font-medium text-blue-600">총 매출의 {commissionRate}%</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Simulator Guide Modal */}
|
||
{simulatorGuideOpen && (
|
||
<SimulatorGuideModal onClose={() => setSimulatorGuideOpen(false)} salesConfig={salesConfig} />
|
||
)}
|
||
|
||
{/* Price Edit Modal */}
|
||
{editModalOpen && editingItem && (
|
||
<PriceEditModal
|
||
key={`edit-${editingItem.item_type}-${editingItem.item_id}`}
|
||
item={editingItem}
|
||
onClose={() => {
|
||
setEditModalOpen(false);
|
||
setEditingItem(null);
|
||
}}
|
||
onSave={handleSavePrice}
|
||
setEditingItem={setEditingItem}
|
||
/>
|
||
)}
|
||
</section>
|
||
);
|
||
};
|
||
|
||
// Price Edit Modal Component
|
||
const PriceEditModal = ({ item, onClose, onSave, setEditingItem }) => {
|
||
const handleChange = (field, value) => {
|
||
const numValue = value.replace(/,/g, '');
|
||
const parsedValue = parseFloat(numValue) || 0;
|
||
setEditingItem(prev => ({
|
||
...prev,
|
||
[field]: parsedValue
|
||
}));
|
||
};
|
||
|
||
useEffect(() => {
|
||
setTimeout(() => {
|
||
lucide.createIcons();
|
||
}, 100);
|
||
}, []);
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden" onClick={e => e.stopPropagation()}>
|
||
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50">
|
||
<h3 className="text-xl font-bold text-slate-900 flex items-center gap-2">
|
||
<i data-lucide="dollar-sign" className="w-5 h-5 text-blue-600"></i>
|
||
가격 설정
|
||
</h3>
|
||
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
|
||
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">항목명</label>
|
||
<div className="text-base font-semibold text-slate-900">{item.item_name}</div>
|
||
{item.sub_name && (
|
||
<div className="text-sm text-slate-500 mt-1">{item.sub_name}</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-2">가입비 (원)</label>
|
||
<input
|
||
type="text"
|
||
value={(item.join_fee || 0).toLocaleString('ko-KR')}
|
||
onChange={(e) => handleChange('join_fee', e.target.value)}
|
||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-2">월 구독료 (원)</label>
|
||
<input
|
||
type="text"
|
||
value={(item.subscription_fee || 0).toLocaleString('ko-KR')}
|
||
onChange={(e) => handleChange('subscription_fee', e.target.value)}
|
||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 font-medium transition-colors"
|
||
>
|
||
취소
|
||
</button>
|
||
<button
|
||
onClick={onSave}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors shadow-sm"
|
||
>
|
||
저장
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Simulator Guide Modal Component
|
||
const SimulatorGuideModal = ({ onClose, salesConfig }) => {
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
|
||
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 flex-shrink-0">
|
||
<h3 className="text-xl font-bold text-slate-900 flex items-center gap-2">
|
||
<i data-lucide="book-open" className="w-5 h-5 text-blue-600"></i>
|
||
수당 시뮬레이터 사용 가이드
|
||
</h3>
|
||
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
|
||
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 space-y-6 overflow-y-auto flex-1 text-slate-600 text-sm leading-relaxed">
|
||
<section>
|
||
<h4 className="text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
|
||
<i data-lucide="info" className="w-4 h-4 text-blue-600"></i>
|
||
수당 시뮬레이터란?
|
||
</h4>
|
||
<p className="mb-3">수당 시뮬레이터는 고객과 계약을 체결하기 전에 예상 수당을 미리 계산해볼 수 있는 도구입니다.</p>
|
||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
|
||
<p className="text-sm text-blue-800 font-medium mb-2">💡 활용 시나리오</p>
|
||
<ul className="list-disc pl-5 space-y-1 text-sm text-blue-800">
|
||
<li>고객과 계약 조건을 논의할 때 예상 수당을 즉시 확인</li>
|
||
<li>다양한 패키지에 따른 수당 비교</li>
|
||
<li>영업 목표 설정 및 수당 예측</li>
|
||
<li>고객에게 맞춤형 제안서 작성 시 수당 정보 확인</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h4 className="text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
|
||
<i data-lucide="calculator" className="w-4 h-4 text-blue-600"></i>
|
||
수당 계산 방식
|
||
</h4>
|
||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
|
||
<p className="text-sm text-slate-700 mb-3">수당은 <strong>가입비</strong>에 대해서만 계산됩니다.</p>
|
||
|
||
<div className="space-y-3">
|
||
<div className="border-l-4 border-blue-500 pl-3">
|
||
<h5 className="font-semibold text-slate-900 mb-1">판매자 수당 (20%)</h5>
|
||
<p className="text-xs text-slate-600">가입비 × 20%</p>
|
||
</div>
|
||
|
||
<div className="border-l-4 border-green-500 pl-3">
|
||
<h5 className="font-semibold text-slate-900 mb-1">관리자 수당 (5%)</h5>
|
||
<p className="text-xs text-slate-600">가입비 × 5%</p>
|
||
</div>
|
||
|
||
<div className="border-l-4 border-purple-500 pl-3">
|
||
<h5 className="font-semibold text-slate-900 mb-1">메뉴제작 협업수당</h5>
|
||
<p className="text-xs text-slate-600">운영팀 별도 산정</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-100">
|
||
<p className="text-xs text-blue-800 font-medium mb-1">📌 계산 예시</p>
|
||
<p className="text-xs text-blue-800 mb-2">가입비 100만원인 경우:</p>
|
||
<ul className="list-disc pl-5 space-y-1 text-xs text-blue-800">
|
||
<li>판매자 수당: 100만원 × 20% = 20만원</li>
|
||
<li>관리자 수당: 100만원 × 5% = 5만원</li>
|
||
<li>메뉴제작 협업수당: 운영팀 별도 산정</li>
|
||
<li>총 수당: 25만원 + 메뉴제작 협업수당</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h4 className="text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
|
||
<i data-lucide="users" className="w-4 h-4 text-blue-600"></i>
|
||
계층 구조
|
||
</h4>
|
||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
|
||
<p className="text-sm text-blue-900 mb-2"><strong>수당 지급 구조:</strong></p>
|
||
<ul className="list-disc pl-5 space-y-1 text-sm text-blue-900">
|
||
<li><strong>판매자:</strong> 직접 영업을 성사시킨 담당자</li>
|
||
<li><strong>관리자:</strong> 판매자를 데려온 상위 담당자</li>
|
||
<li><strong>메뉴제작 협업자:</strong> 관리자를 데려온 상위 담당자</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h4 className="text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
|
||
<i data-lucide="lightbulb" className="w-4 h-4 text-blue-600"></i>
|
||
활용 팁
|
||
</h4>
|
||
<div className="bg-amber-50 rounded-lg p-4 border border-amber-100">
|
||
<ul className="list-disc pl-5 space-y-2 text-sm text-amber-900">
|
||
<li><strong>실시간 계산:</strong> 패키지를 선택하면 즉시 예상 수당이 계산됩니다.</li>
|
||
<li><strong>패키지 비교:</strong> 다양한 패키지를 선택하여 수당 차이를 비교해보세요.</li>
|
||
<li><strong>영업 전략:</strong> 높은 수당이 나오는 패키지를 파악하여 영업 전략을 수립하세요.</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h4 className="text-base font-bold text-slate-900 mb-3 flex items-center gap-2">
|
||
<i data-lucide="alert-circle" className="w-4 h-4 text-blue-600"></i>
|
||
주의사항
|
||
</h4>
|
||
<div className="bg-red-50 rounded-lg p-4 border border-red-100">
|
||
<ul className="list-disc pl-5 space-y-1 text-sm text-red-800">
|
||
<li>계산된 수당은 <strong>예상치</strong>이며, 실제 지급 수당은 계약 조건에 따라 달라질 수 있습니다.</li>
|
||
<li>표시된 금액은 <strong>세전 금액</strong>이며, 실제 지급 시에는 소득세가 공제됩니다.</li>
|
||
<li>수당은 가입비 완료 후 지급됩니다.</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end flex-shrink-0">
|
||
<button onClick={onClose} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors shadow-sm">
|
||
확인
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 5. Sales List Component
|
||
const SalesList = ({ salesRecords, programs, onSelectRecord }) => {
|
||
const getProgramName = (id) => programs.find(p => p.id === id)?.name || id;
|
||
|
||
return (
|
||
<section className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden">
|
||
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
|
||
<h2 className="text-lg font-bold text-slate-900">영업 실적 현황</h2>
|
||
<button className="text-sm text-blue-600 font-medium hover:underline">전체 보기</button>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left text-sm text-slate-600">
|
||
<thead className="bg-slate-50 text-xs uppercase font-medium text-slate-500">
|
||
<tr>
|
||
<th className="px-6 py-4">고객사</th>
|
||
<th className="px-6 py-4">프로그램</th>
|
||
<th className="px-6 py-4">계약일</th>
|
||
<th className="px-6 py-4">상태</th>
|
||
<th className="px-6 py-4 text-right">상세보기</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{salesRecords.map(record => (
|
||
<tr key={record.id} className="hover:bg-slate-50 transition-colors cursor-pointer" onClick={() => onSelectRecord(record)}>
|
||
<td className="px-6 py-4 font-medium text-slate-900">{record.customer_name}</td>
|
||
<td className="px-6 py-4">{getProgramName(record.program_id)}</td>
|
||
<td className="px-6 py-4">{record.contract_date}</td>
|
||
<td className="px-6 py-4">
|
||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||
record.status === 'Active' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
|
||
}`}>
|
||
{record.status}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-right">
|
||
<button className="text-slate-400 hover:text-blue-600">
|
||
<i data-lucide="chevron-right" className="w-4 h-4"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
// 6. Commission Detail Modal
|
||
const CommissionDetailModal = ({ record, programs, onClose }) => {
|
||
if (!record) return null;
|
||
|
||
const program = programs.find(p => p.id === record.program_id);
|
||
const rates = program?.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0 } };
|
||
|
||
// 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;
|
||
|
||
// 메뉴제작 협업자: 운영팀 별도 산정
|
||
const educatorJoin = 0;
|
||
const educatorTotal = educatorJoin;
|
||
|
||
const totalCommission = sellerTotal + managerTotal + educatorTotal;
|
||
const companyMargin = joinFee - totalCommission; // 회사 마진 = 가입비 - 영업 수당
|
||
|
||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[90vh]" onClick={e => e.stopPropagation()}>
|
||
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50 shrink-0">
|
||
<div>
|
||
<h3 className="text-xl font-bold text-slate-900">{record.customer_name} 수당 상세</h3>
|
||
<p className="text-sm text-slate-500">{program?.name} | {duration}개월 계약</p>
|
||
</div>
|
||
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
|
||
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 space-y-8 overflow-y-auto">
|
||
{/* 1. Key Dates Section */}
|
||
<section>
|
||
<h4 className="text-sm font-bold text-slate-900 mb-3 flex items-center gap-2">
|
||
<i data-lucide="calendar" className="w-4 h-4 text-blue-600"></i>
|
||
주요 일정 정보
|
||
</h4>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
<div className="p-3 bg-slate-50 rounded-lg border border-slate-100">
|
||
<div className="text-xs text-slate-500 mb-1">계약일</div>
|
||
<div className="font-medium text-slate-900 text-sm">{record.dates?.contract || '-'}</div>
|
||
</div>
|
||
<div className="p-3 bg-slate-50 rounded-lg border border-slate-100">
|
||
<div className="text-xs text-slate-500 mb-1">가입비 지급일</div>
|
||
<div className="font-medium text-slate-900 text-sm">{record.dates?.join_fee || '-'}</div>
|
||
</div>
|
||
<div className="p-3 bg-slate-50 rounded-lg border border-slate-100">
|
||
<div className="text-xs text-slate-500 mb-1">서비스 시작일</div>
|
||
<div className="font-medium text-slate-900 text-sm">{record.dates?.service_start || '-'}</div>
|
||
</div>
|
||
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
|
||
<div className="text-xs text-blue-600 mb-1">최근 수정일</div>
|
||
<div className="font-bold text-blue-900 text-sm">{record.dates?.product_modified || '-'}</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 2. Commission Summary */}
|
||
<section>
|
||
<h4 className="text-sm font-bold text-slate-900 mb-3 flex items-center gap-2">
|
||
<i data-lucide="calculator" className="w-4 h-4 text-blue-600"></i>
|
||
수당 상세 내역
|
||
</h4>
|
||
<div className="border border-slate-200 rounded-xl overflow-hidden">
|
||
<table className="w-full text-sm text-left">
|
||
<thead className="bg-slate-50 text-xs uppercase font-medium text-slate-500">
|
||
<tr>
|
||
<th className="px-4 py-3 border-b border-slate-200">구분</th>
|
||
<th className="px-4 py-3 border-b border-slate-200">가입비 ({formatCurrency(joinFee)})</th>
|
||
<th className="px-4 py-3 border-b border-slate-200 text-right">수당</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
<tr>
|
||
<td className="px-4 py-3 font-medium text-slate-900">판매자</td>
|
||
<td className="px-4 py-3 text-slate-600">
|
||
<span className="text-xs text-slate-400">20%</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-right font-bold text-slate-900">{formatCurrency(sellerTotal)}</td>
|
||
</tr>
|
||
<tr>
|
||
<td className="px-4 py-3 font-medium text-slate-900">관리자</td>
|
||
<td className="px-4 py-3 text-slate-600">
|
||
<span className="text-xs text-slate-400">5%</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-right font-bold text-slate-900">{formatCurrency(managerTotal)}</td>
|
||
</tr>
|
||
<tr>
|
||
<td className="px-4 py-3 font-medium text-slate-900">메뉴제작 협업자</td>
|
||
<td className="px-4 py-3 text-slate-600">
|
||
<span className="text-xs text-slate-400">별도</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-right font-bold text-slate-900">운영팀 산정</td>
|
||
</tr>
|
||
</tbody>
|
||
<tfoot className="bg-slate-50 border-t border-slate-200">
|
||
<tr>
|
||
<td className="px-4 py-3 font-bold text-slate-900">회사 마진</td>
|
||
<td className="px-4 py-3 text-slate-500">가입비 - 영업 수당</td>
|
||
<td className="px-4 py-3 text-right font-bold text-slate-900">{formatCurrency(companyMargin)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 3. Product History Section */}
|
||
{record.history && (
|
||
<section>
|
||
<h4 className="text-sm font-bold text-slate-900 mb-3 flex items-center gap-2">
|
||
<i data-lucide="history" className="w-4 h-4 text-blue-600"></i>
|
||
상품 변경 이력
|
||
</h4>
|
||
<div className="relative pl-4 border-l-2 border-slate-200 space-y-6">
|
||
{record.history.map((item, idx) => (
|
||
<div key={idx} className="relative">
|
||
<div className={`absolute -left-[21px] top-1 w-3 h-3 rounded-full border-2 border-white ${
|
||
item.type === 'New Contract' ? 'bg-green-500' : 'bg-blue-500'
|
||
}`}></div>
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1">
|
||
<span className="text-xs font-bold text-slate-500">{item.date}</span>
|
||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full w-fit ${
|
||
item.type === 'New Contract' ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'
|
||
}`}>{item.type}</span>
|
||
</div>
|
||
<div className="mt-1 p-3 bg-slate-50 rounded-lg border border-slate-100">
|
||
<p className="text-sm font-medium text-slate-900">{item.description}</p>
|
||
<p className="text-xs text-slate-500 mt-1">Program ID: {item.program_id}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
|
||
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end shrink-0">
|
||
<button onClick={onClose} className="px-4 py-2 bg-white border border-slate-200 rounded-lg text-slate-700 hover:bg-slate-50 font-medium transition-colors">
|
||
닫기
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
|
||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||
root.render(<App />);
|
||
|
||
// Initialize Lucide Icons after render and on updates
|
||
const initIcons = () => {
|
||
setTimeout(() => {
|
||
lucide.createIcons();
|
||
}, 100);
|
||
};
|
||
|
||
initIcons();
|
||
|
||
// 주기적으로 아이콘 업데이트 (동적 콘텐츠를 위해)
|
||
setInterval(() => {
|
||
lucide.createIcons();
|
||
}, 500);
|
||
</script>
|
||
</body>
|
||
</html>
|