Files
sam-sales/salesmanagement/index.php

5730 lines
387 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>영업 관리 시스템</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" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com?v=3.4.1"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Pretendard', 'sans-serif'],
},
colors: {
background: 'rgb(250, 250, 250)',
primary: {
DEFAULT: '#2563eb', // blue-600
foreground: '#ffffff',
},
},
borderRadius: {
'card': '12px',
}
}
}
}
</script>
<!-- React & ReactDOM (Production Versions) -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js?v=18.2.0"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js?v=18.2.0"></script>
<!-- Babel for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Icons: Lucide React (via CDN is tricky, using simple SVG icons or a library wrapper if needed. For now, using text/simple SVGs) -->
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body class="bg-background text-slate-800 antialiased">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// 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">
<h1 className="text-lg font-bold text-slate-900 flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white">
<LucideIcon name="briefcase" className="w-5 h-5" />
</div>
<span>SAM 영업관리</span>
</h1>
{currentUser && (
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-100 rounded-full">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
<span className="text-xs font-bold text-slate-700">
{currentUser.name} ({currentUser.member_id})
</span>
</div>
)}
</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,
monthlySales: 0,
monthlyCommission: 0,
monthlyCount: 0,
pendingApprovals: 0
});
useEffect(() => {
fetchOperatorDashboard();
}, []);
const fetchOperatorDashboard = async () => {
try {
// Fetch global stats
const res = await fetch(`api/get_performance.php`);
const result = await res.json();
// Fetch tenant product statistics for pending count
const tenantRes = await fetch('api/sales_tenants.php?action=list_tenants');
const tenantData = await tenantRes.json();
let pendingCount = 0;
if (tenantData.success) {
for (const tenant of tenantData.data) {
const prodRes = await fetch(`api/sales_tenants.php?action=tenant_products&tenant_id=${tenant.id}`);
const prodData = await prodRes.json();
if (prodData.success) {
pendingCount += prodData.data.filter(p => !p.operator_confirmed || p.operator_confirmed == 0).length;
}
}
}
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,
monthlySales: result.period_stats?.total_period_commission || 0, // Just a placeholder for monthly
monthlyCommission: result.period_stats?.total_period_commission || 0,
monthlyCount: 0,
pendingApprovals: pendingCount
});
}
} 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-4 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" />}
/>
<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.pendingApprovals > 0 ? 'border-red-200 bg-red-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.pendingApprovals > 0 ? 'bg-red-100 text-red-600' : 'bg-slate-100 text-slate-400'}`}>
<LucideIcon name="alert-circle" className="w-5 h-5" />
</div>
</div>
<div className={`text-2xl font-black mb-1 ${operatorStats.pendingApprovals > 0 ? 'text-red-600' : 'text-slate-900'}`}>
{operatorStats.pendingApprovals}
</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="p-2 hover:bg-slate-200 rounded-full transition-colors text-slate-400">
<LucideIcon name="x" className="w-6 h-6" />
</button>
</div>
<div className="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="p-2 hover:bg-slate-200 rounded-full transition-colors text-slate-400">
<LucideIcon name="x" className="w-6 h-6" />
</button>
</div>
<div className="overflow-y-auto p-8">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 border-b border-slate-100 text-slate-500 font-bold uppercase text-xs">
<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, currentStatus, 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, confirmed: !currentStatus })
});
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.operator_confirmed == 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.operator_confirmed == 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">지급 수수료</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="5" className="px-4 py-8 text-center text-slate-400">로딩 ...</td></tr>
) : tenantProducts[t.id].length === 0 ? (
<tr><td colSpan="5" 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 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">
<button
onClick={() => handleConfirmProduct(p.id, p.operator_confirmed == 1, t.id)}
className={`px-4 py-1.5 rounded-xl font-bold transition-all flex items-center gap-1 border ${
p.operator_confirmed == 1
? 'bg-emerald-600 text-white border-emerald-600 hover:bg-emerald-700 shadow-md shadow-emerald-100'
: 'bg-white text-slate-400 border-slate-200 hover:border-emerald-500 hover:text-emerald-600 hover:bg-emerald-50'
}`}
>
<LucideIcon name={p.operator_confirmed == 1 ? "check-circle" : "circle"} className="w-4 h-4" />
{p.operator_confirmed == 1 ? '지급승인됨' : '지급승인'}
</button>
</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 handleEditItem = (item) => {
setEditingItem({ ...item });
setEditModalOpen(true);
};
const handleSaveItem = async () => {
if (!editingItem) return;
try {
const response = await fetch('api/package_pricing.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_type: editingItem.item_type,
item_id: editingItem.item_id,
join_fee: editingItem.join_fee,
subscription_fee: editingItem.subscription_fee,
total_amount: editingItem.total_amount,
allow_flexible_pricing: editingItem.allow_flexible_pricing ? 1 : 0
})
});
const result = await response.json();
if (result.success) {
await fetchPricingItems();
// 모달 닫기
setEditModalOpen(false);
setTimeout(() => {
setEditingItem(null);
}, 100);
} else {
alert('저장에 실패했습니다: ' + (result.error || '알 수 없는 오류'));
}
} catch (error) {
console.error('저장 실패:', error);
alert('저장 중 오류가 발생했습니다.');
}
};
const handleCloseModal = () => {
// 모달 닫기 - 상태 변경만
setEditModalOpen(false);
// editingItem은 약간의 딜레이 후 정리
setTimeout(() => {
setEditingItem(null);
}, 100);
};
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val || 0);
if (loading) {
return (
<div className="text-center py-8 text-slate-500">
<LucideIcon name="loader" className="w-6 h-6 animate-spin mx-auto mb-2" />
로딩 ...
</div>
);
}
// 모델과 패키지 분리
const models = pricingItems.filter(item => item.item_type === 'model');
const packages = pricingItems.filter(item => item.item_type === 'package');
return (
<div className="space-y-6">
{/* 모델 카드 그리드 */}
{models.length > 0 && (
<div>
<h4 className="text-lg font-semibold text-slate-700 mb-4">선택모델</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{models.map(item => (
<div key={`${item.item_type}_${item.item_id}`} className="bg-white rounded-lg p-5 shadow-sm border border-slate-200 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h5 className="font-semibold text-slate-900">{item.item_name}</h5>
{item.sub_name && (
<p className="text-xs text-slate-500 mt-1">{item.sub_name}</p>
)}
</div>
<button
onClick={() => handleEditItem(item)}
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="설정"
>
<LucideIcon name="edit" className="w-4 h-4" />
</button>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-600">총액:</span>
<span className="font-semibold text-blue-600">
{item.total_amount ? formatCurrency(item.total_amount) : '미설정'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">가입비:</span>
<span className="text-slate-900">{formatCurrency(item.join_fee)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600"> 구독료:</span>
<span className="text-slate-900">{formatCurrency(item.subscription_fee)}</span>
</div>
<div className="pt-2 border-t border-slate-100">
<div className="flex items-center justify-between">
<span className="text-slate-600">재량권 허용:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
item.allow_flexible_pricing
? 'bg-green-100 text-green-800'
: 'bg-slate-100 text-slate-600'
}`}>
{item.allow_flexible_pricing ? '허용' : '불가'}
</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 패키지 카드 그리드 */}
{packages.length > 0 && (
<div>
<h4 className="text-lg font-semibold text-slate-700 mb-4">패키지</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{packages.map(item => (
<div key={`${item.item_type}_${item.item_id}`} className="bg-white rounded-lg p-5 shadow-sm border border-slate-200 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h5 className="font-semibold text-slate-900">{item.item_name}</h5>
{item.sub_name && (
<p className="text-xs text-slate-500 mt-1">{item.sub_name}</p>
)}
</div>
<button
onClick={() => handleEditItem(item)}
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="설정"
>
<LucideIcon name="edit" className="w-4 h-4" />
</button>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-600">총액:</span>
<span className="font-semibold text-blue-600">
{item.total_amount ? formatCurrency(item.total_amount) : '미설정'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">가입비:</span>
<span className="text-slate-900">{formatCurrency(item.join_fee)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600"> 구독료:</span>
<span className="text-slate-900">{formatCurrency(item.subscription_fee)}</span>
</div>
<div className="pt-2 border-t border-slate-100">
<div className="flex items-center justify-between">
<span className="text-slate-600">재량권 허용:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
item.allow_flexible_pricing
? 'bg-green-100 text-green-800'
: 'bg-slate-100 text-slate-600'
}`}>
{item.allow_flexible_pricing ? '허용' : '불가'}
</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 편집 모달 */}
{editModalOpen && editingItem && (
<div key={`edit-${editingItem.item_type}-${editingItem.item_id}`} className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={handleCloseModal}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50">
<h3 className="text-xl font-bold text-slate-900 flex items-center gap-2">
<LucideIcon name="settings" className="w-5 h-5 text-blue-600" />
아이템 가격 설정
</h3>
<button onClick={handleCloseModal} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
<LucideIcon name="x" className="w-5 h-5 text-slate-500" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">항목명</label>
<div className="text-base font-semibold text-slate-900">{editingItem.item_name}</div>
{editingItem.sub_name && (
<div className="text-sm text-slate-500 mt-1">{editingItem.sub_name}</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
총액 () <span className="text-xs text-slate-500">- 영업담당이 금액을 기준으로 가입비/구독료를 조정할 있습니다</span>
</label>
<input
type="text"
value={editingItem.total_amount ? editingItem.total_amount.toLocaleString('ko-KR') : ''}
onChange={(e) => {
const value = e.target.value.replace(/,/g, '');
const numValue = value === '' ? null : parseFloat(value);
if (!isNaN(numValue) || value === '') {
setEditingItem(prev => ({
...prev,
total_amount: numValue
}));
}
}}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="총액을 입력하세요"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">현재 가입비 ()</label>
<input
type="text"
value={(editingItem.join_fee || 0).toLocaleString('ko-KR')}
onChange={(e) => {
const value = e.target.value.replace(/,/g, '');
const numValue = value === '' ? 0 : parseFloat(value);
if (!isNaN(numValue) || value === '') {
setEditingItem(prev => ({
...prev,
join_fee: numValue
}));
}
}}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="가입비를 입력하세요"
/>
<p className="text-xs text-slate-500 mt-1">운영자가 설정한 기본 가입비 (영업담당이 재량권으로 조정 가능)</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">현재 구독료 ()</label>
<input
type="text"
value={(editingItem.subscription_fee || 0).toLocaleString('ko-KR')}
onChange={(e) => {
const value = e.target.value.replace(/,/g, '');
const numValue = value === '' ? 0 : parseFloat(value);
if (!isNaN(numValue) || value === '') {
setEditingItem(prev => ({
...prev,
subscription_fee: numValue
}));
}
}}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="월 구독료를 입력하세요"
/>
<p className="text-xs text-slate-500 mt-1">운영자가 설정한 기본 구독료 (영업담당이 재량권으로 조정 가능)</p>
</div>
<div className="pt-4 border-t border-slate-200">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={editingItem.allow_flexible_pricing || false}
onChange={(e) => setEditingItem(prev => ({
...prev,
allow_flexible_pricing: e.target.checked
}))}
className="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-slate-700">영업담당 재량권 허용</span>
<p className="text-xs text-slate-500 mt-1">
체크 영업담당이 총액 범위 내에서 가입비와 구독료를 자유롭게 조정할 있습니다.
</p>
</div>
</label>
</div>
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
<button
onClick={handleCloseModal}
className="px-4 py-2 text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 font-medium transition-colors"
>
취소
</button>
<button
onClick={handleSaveItem}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors shadow-sm"
>
저장
</button>
</div>
</div>
</div>
)}
</div>
);
};
// --- 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">영업관리 통합 로그인</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: '', commission_rate: '100', 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,
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: '', 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,
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-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>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{!tenantProducts[t.id] ? (
<tr><td colSpan="6" className="px-4 py-8 text-center text-slate-400">로딩 ...</td></tr>
) : tenantProducts[t.id].length === 0 ? (
<tr><td colSpan="6" 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-center text-slate-500">1개월분</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.operator_confirmed == 1 ? (
<span className="px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-full font-bold text-[10px] uppercase">Confirmed</span>
) : (
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 rounded-full font-bold text-[10px] uppercase">Pending</span>
)}
</td>
<td className="px-4 py-3 text-center">
{(currentRole === '영업관리' || currentRole === '운영자') && p.operator_confirmed == 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: '', 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,
commission_rate: '100' // 1개월치 전액
});
setSelectedSubModels([]);
} else {
setProductFormData({
...productFormData,
product_name: '선택모델 하이브리드',
contract_amount: 0,
commission_rate: '100'
});
}
}}
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
});
}}
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.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="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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 mb-1">수익 기준</label>
<div className="w-full px-3 py-2 bg-slate-100 border border-slate-200 rounded-lg text-slate-500 text-sm font-medium">1개월 구독료 (100%)</div>
</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-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))}
</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">
{/* 수당 지급 일정 안내 */}
<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} />
<SimulatorSection salesConfig={data.sales_config} selectedRole={selectedRole} />
</>
)}
</main>
);
default:
return (
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
<div className="text-center py-20">
<h2 className="text-2xl font-bold text-slate-900 mb-4"> 없는 역할</h2>
</div>
</main>
);
}
};
return (
<div className="min-h-screen pb-20">
<Header
companyInfo={data.company_info}
onOpenHelp={() => setIsHelpOpen(true)}
selectedRole={selectedRole}
onRoleChange={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.totalSales) * 0.20 : 0;
const level1Children = orgData.children.filter(c => !c.isDirect && c.depth === 1);
const level1Sales = level1Children.reduce((sum, c) => sum + (Number(c.totalSales) || 0), 0);
const managerCommission = level1Sales * 0.05;
const level2Sales = level1Children.reduce((sum, c) =>
c.children.reduce((s, gc) => s + (Number(gc.totalSales) || 0), 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 childrenSales = filteredChildren.reduce((sum, c) => sum + (Number(c.totalSales) || 0), 0);
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 = ownSales * 0.20;
else if (node.depth === 1) commission = ownSales * 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);
const level1Sales = level1.reduce((sum, c) => sum + (Number(c.totalSales) || 0), 0);
const level2Sales = level1.reduce((sum, c) =>
c.children.reduce((s, gc) => s + (Number(gc.totalSales) || 0), 0), 0);
commission = (myDirect ? Number(myDirect.totalSales) * 0.20 : 0) + (level1Sales * 0.05); // 메뉴제작 협업수당 제외
} else if (node.depth === 1) {
commission = totalSales * 0.05;
} else if (node.depth === 2) {
commission = 0; // 메뉴제작 협업수당: 운영팀 별도 산정
}
}
return {
...node,
totalSales,
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,
commissionRate: systemTotalStats.totalSales > 0 ? ((systemTotalStats.totalCommission / systemTotalStats.totalSales) * 100).toFixed(1) : 0
} : calculateTotalStats(organizationData);
const periodStats = calculatePeriodStats(periodOrgData);
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
const months = Array.from({ length: 12 }, (_, i) => i + 1);
return (
<>
{/* 전체 누적 통계 */}
<section>
<h2 className="text-xl font-bold text-slate-900 mb-6">전체 누적 실적</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<StatCard
title="총 가입비"
value={formatCurrency(totalStats.totalRevenue)}
subtext="전체 누적 가입비"
icon={<LucideIcon name="trending-up" className="w-5 h-5" />}
/>
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-card p-6 shadow-sm border border-blue-200 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-4">
<h3 className="text-sm font-medium text-blue-700"> 수당</h3>
<div className="p-2 bg-blue-100 rounded-lg text-blue-600">
<LucideIcon name="wallet" className="w-5 h-5" />
</div>
</div>
<div className="text-2xl font-bold text-blue-900 mb-1">{formatCurrency(totalStats.totalCommission)}</div>
<div className="text-xs text-blue-600 font-medium"> 가입비의 {totalStats.commissionRate}%</div>
</div>
<StatCard
title="전체 건수"
value={`${totalStats.totalCount}건`}
subtext="전체 계약 건수"
icon={<LucideIcon name="file-check" className="w-5 h-5" />}
/>
</div>
</section>
{/* 기간 선택 UI */}
<section className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
<h2 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
<LucideIcon name="calendar-range" className="w-5 h-5 text-blue-600" />
기간별 조회
</h2>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => setPeriodType('current_month')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
periodType === 'current_month'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
당월
</button>
<button
onClick={() => setPeriodType('custom')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
periodType === 'custom'
? 'bg-blue-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
기간 설정
</button>
</div>
{periodType === 'custom' && (
<div className="flex items-center gap-3">
<select
value={startYear}
onChange={(e) => {
const newStartYear = Number(e.target.value);
setStartYear(newStartYear);
// Logic: If Start Year > End Year, set End Year = Start Year.
// Also if Start Year == End Year and Start Month > End Month, set End Month = Start Month.
if (newStartYear > endYear) {
setEndYear(newStartYear);
} else if (newStartYear === endYear && startMonth > endMonth) {
setEndMonth(startMonth);
}
}}
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<select
value={startMonth}
onChange={(e) => {
const newStartMonth = Number(e.target.value);
setStartMonth(newStartMonth);
// Logic: If Start Year == End Year and Start Month > End Month, set End Month = Start Month.
if (startYear === endYear && newStartMonth > endMonth) {
setEndMonth(newStartMonth);
}
// If Start Year > End Year, it should have been handled by Year change, but robustly:
if (startYear > endYear) {
setEndYear(startYear);
}
}}
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{months.map(m => <option key={m} value={m}>{m}</option>)}
</select>
<span className="text-slate-500">~</span>
<select
value={endYear}
onChange={(e) => {
const newEndYear = Number(e.target.value);
setEndYear(newEndYear);
// Logic: If End Year < Start Year, set Start Year = End Year.
// Also if End Year == Start Year and End Month < Start Month, set Start Month = End Month.
if (newEndYear < startYear) {
setStartYear(newEndYear);
} else if (newEndYear === startYear && endMonth < startMonth) {
setStartMonth(endMonth);
}
}}
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<select
value={endMonth}
onChange={(e) => {
const newEndMonth = Number(e.target.value);
setEndMonth(newEndMonth);
// Logic: If End Year == Start Year and End Month < Start Month, set Start Month = End Month.
if (endYear === startYear && newEndMonth < startMonth) {
setStartMonth(newEndMonth);
}
// If End Year < Start Year, it should have been handled by Year change, but robustly:
if (endYear < startYear) {
setStartYear(endYear);
}
}}
className="px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{months.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
)}
<div className="text-sm text-slate-500">
{periodType === 'current_month'
? `${new Date().getFullYear()}년 ${new Date().getMonth() + 1}월`
: `${startYear}년 ${startMonth}월 ~ ${endYear}년 ${endMonth}월`}
</div>
</div>
</section>
{/* 기간별 역할별 수당 상세 */}
<section className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
<h3 className="text-lg font-bold text-slate-900 mb-4 flex items-center gap-2">
<LucideIcon name="layers" className="w-5 h-5 text-blue-600" />
역할별 수당 상세
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border border-green-200">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<LucideIcon name="user" className="w-4 h-4 text-green-600" />
</div>
<span className="text-sm font-medium text-green-900">판매자</span>
</div>
<span className="text-xs font-bold text-green-700 bg-green-100 px-2 py-1 rounded">20%</span>
</div>
<div className="text-2xl font-bold text-green-900">{formatCurrency(periodStats.sellerCommission)}</div>
<div className="text-xs text-green-600 mt-1"></div>
</div>
<div className="p-4 bg-gradient-to-br from-purple-50 to-violet-50 rounded-lg border border-purple-200">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-purple-100 flex items-center justify-center">
<LucideIcon name="users" className="w-4 h-4 text-purple-600" />
</div>
<span className="text-sm font-medium text-purple-900">관리자</span>
</div>
<span className="text-xs font-bold text-purple-700 bg-purple-100 px-2 py-1 rounded">5%</span>
</div>
<div className="text-2xl font-bold text-purple-900">{formatCurrency(periodStats.managerCommission)}</div>
<div className="text-xs text-purple-600 mt-1"></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 [selectedSelectModels, setSelectedSelectModels] = useState(false);
const [selectedConstruction, setSelectedConstruction] = useState(false);
const [selectedProcessGov, setSelectedProcessGov] = useState(false);
// 2차 선택: 선택모델의 세부 모델들
const [selectedModels, setSelectedModels] = useState([]);
// DB에서 가격 정보 가져오기
useEffect(() => {
const fetchPricing = async () => {
try {
const response = await fetch('api/package_pricing.php?action=list');
const result = await response.json();
if (result.success) {
const pricingMap = {};
result.data.forEach(item => {
pricingMap[`${item.item_type}_${item.item_id}`] = item;
});
setPricingData(pricingMap);
}
} catch (error) {
console.error('가격 정보 로드 실패:', error);
}
};
fetchPricing();
}, []);
const packageTypes = salesConfig.package_types || [];
const selectModelsPackage = packageTypes.find(p => p.id === 'select_models');
const constructionPackage = packageTypes.find(p => p.id === 'construction_management');
const processGovPackage = packageTypes.find(p => p.id === 'process_government');
// DB 가격 정보와 기본 설정 병합
const getItemPrice = (itemType, itemId, defaultJoinFee, defaultSubFee) => {
const key = `${itemType}_${itemId}`;
if (pricingData[key]) {
return {
join_fee: pricingData[key].join_fee,
subscription_fee: pricingData[key].subscription_fee
};
}
return {
join_fee: defaultJoinFee,
subscription_fee: defaultSubFee
};
};
// 선택된 항목들의 총 가입비 계산 (구독료 제거)
let totalJoinFee = 0;
let totalSellerCommission = 0;
let totalManagerCommission = 0;
let totalEducatorCommission = 0;
// 선택모델의 세부 모델들 합산
if (selectedSelectModels && selectModelsPackage) {
selectedModels.forEach(modelId => {
const model = selectModelsPackage.models.find(m => m.id === modelId);
if (model) {
const price = getItemPrice('model', modelId, model.join_fee, model.subscription_fee);
totalJoinFee += price.join_fee || 0;
// 가입비에 대한 수당만 계산: 판매자 20%, 관리자 5%, 메뉴제작 협업수당 별도
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));
totalEducatorCommission += 0; // 운영팀 별도 산정
}
});
}
// 공사관리 패키지
if (selectedConstruction && constructionPackage) {
const price = getItemPrice('package', 'construction_management', constructionPackage.join_fee, constructionPackage.subscription_fee);
totalJoinFee += price.join_fee || 0;
const rates = constructionPackage.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0 } };
totalSellerCommission += (price.join_fee * (rates.seller.join || 0.20));
totalManagerCommission += (price.join_fee * (rates.manager.join || 0.05));
totalEducatorCommission += 0;
}
// 공정/정부지원사업 패키지
if (selectedProcessGov && processGovPackage) {
const price = getItemPrice('package', 'process_government', processGovPackage.join_fee, processGovPackage.subscription_fee);
totalJoinFee += price.join_fee || 0;
const rates = processGovPackage.commission_rates || { seller: { join: 0.20 }, manager: { join: 0.05 }, educator: { join: 0 } };
totalSellerCommission += (price.join_fee * (rates.seller.join || 0.20));
totalManagerCommission += (price.join_fee * (rates.manager.join || 0.05));
totalEducatorCommission += 0;
}
const totalCommission = totalSellerCommission + totalManagerCommission + totalEducatorCommission;
const totalRevenue = totalJoinFee; // 구독료 제거
const commissionRate = totalRevenue > 0 ? ((totalCommission / totalRevenue) * 100).toFixed(1) : 0;
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val);
const handleModelToggle = (modelId) => {
setSelectedModels(prev =>
prev.includes(modelId)
? prev.filter(id => id !== modelId)
: [...prev, modelId]
);
};
const handleEditPrice = (itemType, itemId, itemName, subName, defaultJoinFee, defaultSubFee) => {
const key = `${itemType}_${itemId}`;
const currentPrice = pricingData[key] || { join_fee: defaultJoinFee, subscription_fee: defaultSubFee };
setEditingItem({
item_type: itemType,
item_id: itemId,
item_name: itemName,
sub_name: subName,
join_fee: currentPrice.join_fee || defaultJoinFee,
subscription_fee: currentPrice.subscription_fee || defaultSubFee
});
setEditModalOpen(true);
};
const handleSavePrice = async () => {
if (!editingItem) return;
try {
const response = await fetch('api/package_pricing.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_type: editingItem.item_type,
item_id: editingItem.item_id,
join_fee: editingItem.join_fee,
subscription_fee: editingItem.subscription_fee
})
});
const result = await response.json();
if (result.success) {
// 가격 정보 다시 로드
const fetchResponse = await fetch('api/package_pricing.php?action=list');
const fetchResult = await fetchResponse.json();
if (fetchResult.success) {
const pricingMap = {};
fetchResult.data.forEach(item => {
pricingMap[`${item.item_type}_${item.item_id}`] = item;
});
setPricingData(pricingMap);
}
setEditModalOpen(false);
setEditingItem(null);
} else {
alert('가격 저장에 실패했습니다: ' + (result.error || '알 수 없는 오류'));
}
} catch (error) {
console.error('가격 저장 실패:', error);
alert('가격 저장 중 오류가 발생했습니다.');
}
};
// 아이콘 업데이트
return (
<section className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden flex flex-col lg:flex-row">
{/* Input Form */}
<div className="p-8 lg:w-2/5 border-b lg:border-b-0 lg:border-r border-slate-100">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-slate-900 flex items-center gap-2">
<LucideIcon name="calculator" className="w-5 h-5 text-blue-600" />
수당 시뮬레이터
</h2>
<button
onClick={() => setSimulatorGuideOpen(true)}
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="사용 가이드 보기"
>
<LucideIcon name="help-circle" className="w-5 h-5" />
</button>
</div>
<div className="space-y-6">
{/* 1차 선택 */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">패키지 선택</label>
<div className="space-y-3">
{/* 선택모델 */}
<div className="flex items-start gap-3 p-4 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors">
<input
type="checkbox"
id="select_models"
checked={selectedSelectModels}
onChange={(e) => {
setSelectedSelectModels(e.target.checked);
if (!e.target.checked) {
setSelectedModels([]);
}
}}
className="mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<label htmlFor="select_models" className="flex-1 cursor-pointer">
<div className="font-medium text-slate-900">선택모델</div>
<div className="text-xs text-slate-500 mt-1">여러 모델을 선택할 있습니다</div>
</label>
</div>
{/* 공사관리 */}
{constructionPackage && (() => {
const price = getItemPrice('package', 'construction_management', constructionPackage.join_fee, constructionPackage.subscription_fee);
return (
<div className="flex items-start gap-3 p-4 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors">
<input
type="checkbox"
id="construction_management"
checked={selectedConstruction}
onChange={(e) => setSelectedConstruction(e.target.checked)}
className="mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<label htmlFor="construction_management" className="flex-1 cursor-pointer">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-slate-900">공사관리</div>
<div className="text-xs text-slate-500 mt-1">패키지</div>
<div className="text-xs text-blue-600 mt-1">
가입비: {formatCurrency(price.join_fee)} / 구독료: {formatCurrency(price.subscription_fee)}
</div>
</div>
{isOperator && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditPrice('package', 'construction_management', '공사관리', '패키지', constructionPackage.join_fee, constructionPackage.subscription_fee);
}}
className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="가격 설정"
>
<LucideIcon name="settings" className="w-4 h-4" />
</button>
)}
</div>
</label>
</div>
);
})()}
{/* 공정/정부지원사업 */}
{processGovPackage && (() => {
const price = getItemPrice('package', 'process_government', processGovPackage.join_fee, processGovPackage.subscription_fee);
return (
<div className="flex items-start gap-3 p-4 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors">
<input
type="checkbox"
id="process_government"
checked={selectedProcessGov}
onChange={(e) => setSelectedProcessGov(e.target.checked)}
className="mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<label htmlFor="process_government" className="flex-1 cursor-pointer">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-slate-900">공정/정부지원사업</div>
<div className="text-xs text-slate-500 mt-1">패키지</div>
<div className="text-xs text-blue-600 mt-1">
가입비: {formatCurrency(price.join_fee)} / 구독료: {formatCurrency(price.subscription_fee)}
</div>
</div>
{isOperator && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditPrice('package', 'process_government', '공정/정부지원사업', '패키지', processGovPackage.join_fee, processGovPackage.subscription_fee);
}}
className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="가격 설정"
>
<LucideIcon name="settings" className="w-4 h-4" />
</button>
)}
</div>
</label>
</div>
);
})()}
</div>
</div>
{/* 2차 선택: 선택모델의 세부 모델들 */}
{selectedSelectModels && selectModelsPackage && (
<div className="border-t border-slate-200 pt-6">
<label className="block text-sm font-medium text-slate-700 mb-3">선택모델 세부 항목</label>
<div className="space-y-2 max-h-60 overflow-y-auto">
{selectModelsPackage.models.map(model => {
const price = getItemPrice('model', model.id, model.join_fee, model.subscription_fee);
return (
<div key={model.id} className="flex items-start gap-3 p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors">
<input
type="checkbox"
id={model.id}
checked={selectedModels.includes(model.id)}
onChange={() => handleModelToggle(model.id)}
className="mt-1 w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<label htmlFor={model.id} className="flex-1 cursor-pointer">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-slate-900 text-sm">{model.name}</div>
{model.sub_name && (
<div className="text-xs text-slate-500 mt-0.5">{model.sub_name}</div>
)}
<div className="text-xs text-blue-600 mt-1">
가입비: {formatCurrency(price.join_fee)} / 구독료: {formatCurrency(price.subscription_fee)}
</div>
</div>
{isOperator && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditPrice('model', model.id, model.name, model.sub_name, model.join_fee, model.subscription_fee);
}}
className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors ml-2"
title="가격 설정"
>
<LucideIcon name="settings" className="w-4 h-4" />
</button>
)}
</div>
</label>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{/* Result Card */}
<div className="p-8 lg:w-3/5 bg-slate-50 flex flex-col justify-center">
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-50 rounded-full -mr-16 -mt-16 opacity-50"></div>
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-6">예상 수당 명세서</h3>
<div className="space-y-4 mb-6">
<div className="flex justify-between items-center p-3 bg-slate-50 rounded-lg">
<span className="text-slate-600"> 가입비</span>
<span className="font-bold text-slate-900">{formatCurrency(totalRevenue)}</span>
</div>
<div className="flex justify-between items-center p-3 bg-slate-50 rounded-lg">
<span className="text-slate-600">판매자 수당 (20%)</span>
<span className="font-bold text-slate-900">{formatCurrency(totalSellerCommission)}</span>
</div>
<div className="flex justify-between items-center p-3 bg-slate-50 rounded-lg">
<span className="text-slate-600">관리자 수당 (5%)</span>
<span className="font-bold text-slate-900">{formatCurrency(totalManagerCommission)}</span>
</div>
<div className="flex justify-between items-center p-3 bg-slate-50 rounded-lg">
<span className="text-slate-600">메뉴제작 협업수당</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>