testsprite 전략 수립

This commit is contained in:
2025-12-30 21:34:17 +09:00
parent 7853672dfa
commit 014cd151df
9 changed files with 1303 additions and 115 deletions

View File

@@ -15,6 +15,28 @@ if (!isset($_SESSION['sales_user'])) {
$currentUser = $_SESSION['sales_user'];
$pdo = db_connect();
/**
* 테넌트에 대한 접근 권한 확인
*/
function checkTenantPermission($pdo, $tenant_id, $currentUser) {
if (!$tenant_id) return false;
if ($currentUser['role'] === 'operator') return true;
$stmt = $pdo->prepare("SELECT manager_id, sales_manager_id FROM sales_tenants WHERE id = ?");
$stmt->execute([$tenant_id]);
$tenant = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$tenant) return false;
// 매니저는 본인이 '담당 매니저'로 배정된 경우만 가능
if ($currentUser['role'] === 'manager') {
return $tenant['sales_manager_id'] == $currentUser['id'];
}
// 영업관리자는 본인이 등록했거나, 본인이 담당 매니저인 경우 가능
return ($tenant['manager_id'] == $currentUser['id'] || $tenant['sales_manager_id'] == $currentUser['id']);
}
// 테이블 자동 생성 (없을 경우)
$pdo->exec("
CREATE TABLE IF NOT EXISTS `sales_tenants` (
@@ -136,23 +158,36 @@ try {
");
$stmt->execute();
} else {
// 내가 영업했거나, 내가 매니저로 배정된 테넌트
$stmt = $pdo->prepare("
SELECT t.*, m.name as register_name, m2.name as manager_name, m2.role as manager_role
FROM sales_tenants t
JOIN sales_member m ON t.manager_id = m.id
LEFT JOIN sales_member m2 ON t.sales_manager_id = m2.id
WHERE t.manager_id = ? OR t.sales_manager_id = ?
ORDER BY t.created_at DESC
");
$stmt->execute([$currentUser['id'], $currentUser['id']]);
if ($currentUser['role'] === 'manager') {
// 매니저는 본인이 담당 매니저로 배정된 테넌트만 조회
$stmt = $pdo->prepare("
SELECT t.*, m.name as register_name, m2.name as manager_name, m2.role as manager_role
FROM sales_tenants t
JOIN sales_member m ON t.manager_id = m.id
LEFT JOIN sales_member m2 ON t.sales_manager_id = m2.id
WHERE t.sales_manager_id = ?
ORDER BY t.created_at DESC
");
$stmt->execute([$currentUser['id']]);
} else {
// 영업관리자는 본인이 영업했거나, 본인이 매니저로 배정된 테넌트 조회
$stmt = $pdo->prepare("
SELECT t.*, m.name as register_name, m2.name as manager_name, m2.role as manager_role
FROM sales_tenants t
JOIN sales_member m ON t.manager_id = m.id
LEFT JOIN sales_member m2 ON t.sales_manager_id = m2.id
WHERE t.manager_id = ? OR t.sales_manager_id = ?
ORDER BY t.created_at DESC
");
$stmt->execute([$currentUser['id'], $currentUser['id']]);
}
}
$tenants = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'data' => $tenants]);
} elseif ($action === 'tenant_products') {
$tenant_id = $_GET['tenant_id'] ?? null;
if (!$tenant_id) throw new Exception("테넌트 ID가 필요합니다.");
if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다.");
$stmt = $pdo->prepare("SELECT * FROM sales_tenant_products WHERE tenant_id = ? ORDER BY created_at DESC");
$stmt->execute([$tenant_id]);
@@ -161,7 +196,7 @@ try {
} elseif ($action === 'my_stats') {
// 현재 로그인한 사용자의 요약 통계
$stmt = $pdo->prepare("
$sql = "
SELECT
COUNT(DISTINCT t.id) as tenant_count,
SUM(p.contract_amount) as total_revenue,
@@ -169,15 +204,26 @@ try {
SUM(CASE WHEN p.operator_confirmed = 1 THEN p.commission_amount ELSE 0 END) as confirmed_commission
FROM sales_tenants t
LEFT JOIN sales_tenant_products p ON t.id = p.tenant_id
WHERE t.manager_id = ?
");
$stmt->execute([$currentUser['id']]);
";
if ($currentUser['role'] === 'manager') {
$sql .= " WHERE t.sales_manager_id = ?";
} else {
$sql .= " WHERE t.manager_id = ? OR t.sales_manager_id = ?";
}
$stmt = $pdo->prepare($sql);
if ($currentUser['role'] === 'manager') {
$stmt->execute([$currentUser['id']]);
} else {
$stmt->execute([$currentUser['id'], $currentUser['id']]);
}
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'data' => $stats]);
} elseif ($action === 'get_scenario') {
$tenant_id = $_GET['tenant_id'] ?? null;
$scenario_type = $_GET['scenario_type'] ?? 'manager';
if (!$tenant_id) throw new Exception("테넌트 ID가 필요합니다.");
if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다.");
$stmt = $pdo->prepare("SELECT * FROM sales_tenant_scenarios WHERE tenant_id = ? AND scenario_type = ?");
$stmt->execute([$tenant_id, $scenario_type]);
@@ -189,7 +235,7 @@ try {
$tenant_id = $_GET['tenant_id'] ?? null;
$scenario_type = $_GET['scenario_type'] ?? 'manager';
$step_id = $_GET['step_id'] ?? null;
if (!$tenant_id) throw new Exception("테넌트 ID가 필요합니다.");
if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다.");
$sql = "SELECT * FROM sales_tenant_consultations WHERE tenant_id = ? AND scenario_type = ?";
$params = [$tenant_id, $scenario_type];
@@ -206,7 +252,7 @@ try {
echo json_encode(['success' => true, 'data' => $results]);
} elseif ($action === 'list_managers') {
// 매니저로 매칭 가능한 사람 목록 (영업관리 또는 매니저 직급)
$stmt = $pdo->prepare("SELECT id, name, role, member_id FROM sales_member WHERE role IN ('영업관리', '매니저') AND is_active = 1 ORDER BY name ASC");
$stmt = $pdo->prepare("SELECT id, name, role, member_id, parent_id FROM sales_member WHERE role IN ('sales_admin', 'manager') AND is_active = 1 ORDER BY name ASC");
$stmt->execute();
$managers = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'data' => $managers]);
@@ -222,6 +268,9 @@ try {
}
if ($action === 'create_tenant') {
if ($currentUser['role'] === 'manager') {
throw new Exception("매니저는 테넌트를 등록할 권한이 없습니다.");
}
$tenant_name = $data['tenant_name'] ?? '';
$representative = $data['representative'] ?? '';
$business_no = $data['business_no'] ?? '';
@@ -239,6 +288,8 @@ try {
} elseif ($action === 'add_product') {
$tenant_id = $data['tenant_id'] ?? null;
if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다.");
$product_name = $data['product_name'] ?? '';
$contract_amount = $data['contract_amount'] ?? 0;
$commission_rate = $data['commission_rate'] ?? 0;
@@ -267,14 +318,18 @@ try {
echo json_encode(['success' => true, 'message' => $confirmed ? '승인되었습니다.' : '승인이 취소되었습니다.']);
} elseif ($action === 'update_checklist') {
$tenant_id = $data['tenant_id'] ?? null;
$tenant_id = isset($data['tenant_id']) ? intval($data['tenant_id']) : null;
$step_id = isset($data['step_id']) ? intval($data['step_id']) : null;
$checkpoint_index = isset($data['checkpoint_index']) ? intval($data['checkpoint_index']) : null;
$is_checked = (isset($data['is_checked']) && ($data['is_checked'] === true || $data['is_checked'] == 1)) ? 1 : 0;
$scenario_type = $data['scenario_type'] ?? 'manager';
$step_id = $data['step_id'] ?? null;
$checkpoint_index = $data['checkpoint_index'] ?? null;
$is_checked = $data['is_checked'] ? 1 : 0;
if (!$tenant_id || $step_id === null || $checkpoint_index === null) throw new Exception("필수 파라미터가 누락되었습니다.");
if ($tenant_id === null || $step_id === null || $checkpoint_index === null) {
throw new Exception("필수 파라미터가 누락되었습니다. (T: $tenant_id, S: $step_id, C: $checkpoint_index)");
}
if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다.");
$stmt = $pdo->prepare("
INSERT INTO sales_tenant_scenarios (tenant_id, scenario_type, step_id, checkpoint_index, is_checked)
VALUES (?, ?, ?, ?, ?)
@@ -286,6 +341,8 @@ try {
} elseif ($action === 'save_consultation') {
$tenant_id = $data['tenant_id'] ?? null;
if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다.");
$scenario_type = $data['scenario_type'] ?? 'manager';
$step_id = $data['step_id'] ?? null;
$log_text = $data['log_text'] ?? '';
@@ -314,6 +371,8 @@ try {
echo json_encode(['success' => true, 'message' => '기록이 저장되었습니다.']);
} elseif ($action === 'upload_attachments') {
$tenant_id = $data['tenant_id'] ?? null;
if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다.");
$scenario_type = $data['scenario_type'] ?? 'manager';
$step_id = $data['step_id'] ?? null;
@@ -353,22 +412,24 @@ try {
$id = $data['id'] ?? null;
if (!$id) throw new Exception("ID가 누락되었습니다.");
// 파일 삭제를 위해 정보 조회
$stmt = $pdo->prepare("SELECT audio_file_path, attachment_paths FROM sales_tenant_consultations WHERE id = ?");
// 권한 확인을 위해 정보 조회
$stmt = $pdo->prepare("SELECT tenant_id, audio_file_path, attachment_paths FROM sales_tenant_consultations WHERE id = ?");
$stmt->execute([$id]);
$c = $stmt->fetch(PDO::FETCH_ASSOC);
if ($c) {
if ($c['audio_file_path'] && file_exists(__DIR__ . "/../" . $c['audio_file_path'])) {
@unlink(__DIR__ . "/../" . $c['audio_file_path']);
}
if ($c['attachment_paths']) {
$paths = json_decode($c['attachment_paths'], true);
if (is_array($paths)) {
foreach ($paths as $p) {
if (isset($p['path']) && file_exists(__DIR__ . "/../" . $p['path'])) {
@unlink(__DIR__ . "/../" . $p['path']);
}
if (!$c) throw new Exception("해당 기록을 찾을 수 없습니다.");
if (!checkTenantPermission($pdo, $c['tenant_id'], $currentUser)) throw new Exception("권한이 없습니다.");
// 파일 삭제 처리
if ($c['audio_file_path'] && file_exists(__DIR__ . "/../" . $c['audio_file_path'])) {
@unlink(__DIR__ . "/../" . $c['audio_file_path']);
}
if ($c['attachment_paths']) {
$paths = json_decode($c['attachment_paths'], true);
if (is_array($paths)) {
foreach ($paths as $p) {
if (isset($p['path']) && file_exists(__DIR__ . "/../" . $p['path'])) {
@unlink(__DIR__ . "/../" . $p['path']);
}
}
}
@@ -381,12 +442,13 @@ try {
$product_id = $data['id'] ?? null;
if (!$product_id) throw new Exception("ID가 누락되었습니다.");
// 승인되지 않은 것만 삭제 가능하도록 보안 체크 (영업관리자 관점)
$stmt = $pdo->prepare("SELECT operator_confirmed FROM sales_tenant_products WHERE id = ?");
// 정보 및 권한 조회
$stmt = $pdo->prepare("SELECT tenant_id, operator_confirmed FROM sales_tenant_products WHERE id = ?");
$stmt->execute([$product_id]);
$p = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$p) throw new Exception("해당 정보를 찾을 수 없습니다.");
if (!checkTenantPermission($pdo, $p['tenant_id'], $currentUser)) throw new Exception("권한이 없습니다.");
if ($p['operator_confirmed'] == 1) throw new Exception("이미 승인된 계약은 삭제할 수 없습니다.");
$stmt = $pdo->prepare("DELETE FROM sales_tenant_products WHERE id = ?");
@@ -399,6 +461,16 @@ try {
if (!$tenant_id) throw new Exception("테넌트 ID가 누락되었습니다.");
// 권한 확인: 배지 지정은 운영자 또는 해당 테넌트를 등록한 영업관리자만 가능
$stmt = $pdo->prepare("SELECT manager_id FROM sales_tenants WHERE id = ?");
$stmt->execute([$tenant_id]);
$t = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$t) throw new Exception("테넌트를 찾을 수 없습니다.");
if ($currentUser['role'] !== 'operator' && $t['manager_id'] != $currentUser['id']) {
throw new Exception("배정 권한이 없습니다.");
}
// manager_id가 0이거나 empty면 null로 처리 (지정 취소)
$manager_val = (!empty($sales_manager_id)) ? intval($sales_manager_id) : null;
@@ -408,22 +480,19 @@ try {
echo json_encode(['success' => true, 'message' => $manager_val ? '담당 매니저가 지정되었습니다.' : '담당 매니저 지정이 취소되었습니다.']);
} elseif ($action === 'update_product') {
$product_id = $data['id'] ?? null;
$product_name = $data['product_name'] ?? '';
$contract_amount = $data['contract_amount'] ?? 0;
$commission_rate = $data['commission_rate'] ?? 0;
$contract_date = $data['contract_date'] ?? date('Y-m-d');
$sub_models = isset($data['sub_models']) ? json_encode($data['sub_models']) : null;
if (!$product_id) throw new Exception("ID가 누락되었습니다.");
if (!$product_id || !$product_name) throw new Exception("필수 정보가 누락되었습니다.");
// 보안 체크: 승인된 것은 수정 불가
$stmt = $pdo->prepare("SELECT operator_confirmed FROM sales_tenant_products WHERE id = ?");
// 정보 및 권한 조회
$stmt = $pdo->prepare("SELECT tenant_id, operator_confirmed FROM sales_tenant_products WHERE id = ?");
$stmt->execute([$product_id]);
$p = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$p) throw new Exception("해당 정보를 찾을 수 없습니다.");
if (!checkTenantPermission($pdo, $p['tenant_id'], $currentUser)) throw new Exception("권한이 없습니다.");
if ($p['operator_confirmed'] == 1) throw new Exception("이미 승인된 계약은 수정할 수 없습니다.");
$product_name = $data['product_name'] ?? '';
$commission_amount = ($contract_amount * $commission_rate) / 100;
$stmt = $pdo->prepare("UPDATE sales_tenant_products SET product_name = ?, contract_amount = ?, commission_rate = ?, commission_amount = ?, contract_date = ?, sub_models = ? WHERE id = ?");

View File

@@ -2488,6 +2488,8 @@
const [loading, setLoading] = useState(false);
useEffect(() => {
const newSteps = scenarioType === 'sales' ? SALES_SCENARIO_STEPS : MANAGER_SCENARIO_STEPS;
setActiveStep(newSteps[0]);
fetchScenarioData();
fetchConsultations();
}, [tenant.id, scenarioType]);
@@ -2499,11 +2501,12 @@
if (result.success) {
const map = {};
result.data.forEach(item => {
map[`${item.step_id}_${item.checkpoint_index}`] = item.is_checked == 1;
const key = `${item.scenario_type}_${item.step_id}_${item.checkpoint_index}`;
map[key] = (item.is_checked == 1);
});
setChecklist(map);
}
} catch (err) { console.error(err); }
} catch (err) { /* console.error('Fetch scenario error:', err); */ }
};
const fetchConsultations = async () => {
@@ -2517,7 +2520,7 @@
};
const toggleCheck = async (stepId, index) => {
const key = `${stepId}_${index}`;
const key = `${scenarioType}_${stepId}_${index}`;
const newState = !checklist[key];
// Optimistic UI Update
@@ -2541,7 +2544,7 @@
setChecklist(prev => ({ ...prev, [key]: !newState })); // Rollback
}
} catch (err) {
console.error('Checkbox toggle error:', err);
// console.error('Checkbox toggle error:', err);
alert('서버와 통신하는 중 오류가 발생했습니다.');
setChecklist(prev => ({ ...prev, [key]: !newState })); // Rollback
}
@@ -2588,7 +2591,7 @@
const total = step.checkpoints.length;
let checked = 0;
for (let i = 0; i < total; i++) {
if (checklist[`${stepId}_${i}`]) checked++;
if (checklist[`${scenarioType}_${stepId}_${i}`]) checked++;
}
return Math.round((checked / total) * 100);
};
@@ -2637,7 +2640,10 @@
<LucideIcon name={step.icon} className="w-5 h-5" />
</div>
<div className="flex-1">
<div className={`text-sm font-black ${isActive ? 'text-slate-900' : 'text-slate-500'}`}>{step.title}</div>
<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>
@@ -2692,16 +2698,16 @@
{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[`${activeStep.id}_${idx}`] ? 'bg-emerald-50 border-emerald-100' : 'bg-white border-slate-100 hover:border-blue-200'}`}
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[`${activeStep.id}_${idx}`] ? 'bg-emerald-500 border-emerald-500 text-white' : 'border-slate-300 group-hover:border-blue-500'}`}>
{checklist[`${activeStep.id}_${idx}`] && <LucideIcon name="check" className="w-4 h-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[`${activeStep.id}_${idx}`] ? 'text-emerald-900' : 'text-slate-900 group-hover:text-blue-600'}`}>{cp.title}</h4>
<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[`${activeStep.id}_${idx}`] && cp.pro_tip && (
{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>
@@ -2712,21 +2718,19 @@
))}
</div>
{/* Step 2 Special Features: Voice & Files */}
{scenarioType === 'manager' && activeStep.id === 2 && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8 animate-in slide-in-from-bottom-6 duration-700">
<VoiceRecorder
tenantId={tenant.id}
scenarioType={scenarioType}
stepId={activeStep.id}
/>
<FileUploader
tenantId={tenant.id}
scenarioType={scenarioType}
stepId={activeStep.id}
/>
</div>
)}
{/* Step Features: Voice & Files - Available for all steps */}
<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">
@@ -2814,6 +2818,13 @@
const [editProductId, setEditProductId] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [potentialManagerList, setPotentialManagerList] = useState([]);
const [activeManagerPopover, setActiveManagerPopover] = 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: '',
@@ -2879,6 +2890,20 @@
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');
@@ -2947,16 +2972,9 @@
}
};
const handleToggleManagerAssignment = async (tenantId, currentManagerId) => {
const isAssigning = !currentManagerId;
const confirmMsg = isAssigning
? '본인이 이 테넌트의 업무 프로세스(매니저 역할)를 직접 수행하시겠습니까?'
: '이 테넌트의 매니저 지정을 취소하시겠습니까? (다른 매니저가 다시 맡을 수 있게 됩니다)';
if (!confirm(confirmMsg)) return;
const handleUpdateManagerAssignment = async (tenantId, targetManagerId) => {
if (!currentUser || !currentUser.id) {
alert('로그인 정보가 유효하지 않습니다. 다시 로그인해주세요.');
alert('로그인 정보가 유효하지 않습니다.');
return;
}
@@ -2966,22 +2984,21 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenant_id: tenantId,
sales_manager_id: isAssigning ? currentUser.id : null
sales_manager_id: targetManagerId
})
});
if (!res.ok) throw new Error('Network response was not ok');
const result = await res.json();
if (result.success) {
await fetchData(); // Wait for data to refresh
alert(result.message);
await fetchData();
setActiveManagerPopover(null);
if (result.message) alert(result.message);
} else {
alert(result.error || '처리에 실패했습니다.');
alert(result.error || '업데이트 실패');
}
} catch (err) {
console.error('Update manager error:', err);
alert('처리 중 오류가 발생했습니다: ' + err.message);
console.error('Assignment error:', err);
alert('서버와 통신하는 중 오류가 발생했습니다.');
}
};
@@ -3189,34 +3206,75 @@
<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>
{t.sales_manager_id ? (
t.sales_manager_id == currentUser.id ? (
<button
onClick={(e) => { e.stopPropagation(); handleToggleManagerAssignment(t.id, t.sales_manager_id); }}
className="px-2 py-0.5 bg-blue-100 text-blue-700 hover:bg-red-50 hover:text-red-600 hover:border-red-200 rounded text-[10px] font-bold border border-blue-200 transition-all flex items-center gap-1 group"
title="매니저 업무 취소"
<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" />
관리: 본인
<LucideIcon name="x" className="w-2 h-2 opacity-0 group-hover:opacity-100 transition-opacity" />
관리: {t.sales_manager_id == currentUser.id ? '본인' : (t.manager_name || '지정됨')}
{currentRole === '영업관리' && <LucideIcon name="chevron-down" className="w-2 h-2" />}
</button>
) : (
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-[10px] font-bold border border-blue-100 flex items-center gap-1">
<LucideIcon name="user" className="w-2.5 h-2.5" />
관리: {t.manager_name}
</span>
)
) : (
currentRole === '영업관리' && (
<button
onClick={(e) => { e.stopPropagation(); handleToggleManagerAssignment(t.id, null); }}
className="px-2 py-0.5 bg-amber-50 text-amber-600 hover:bg-amber-100 rounded text-[10px] font-bold border border-amber-200 transition-colors flex items-center gap-1"
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()}
>
<LucideIcon name="user-plus" className="w-2.5 h-2.5" />
+ 매니저 직접수행
</button>
)
)}
<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>
@@ -3386,7 +3444,7 @@
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"
>
{potentialManagerList.map(m => (
{filteredManagers.map(m => (
<option key={m.id} value={m.id}>
{m.name} ({m.role}) {m.id === currentUser.id ? '- 본인' : ''}
</option>
@@ -3578,6 +3636,7 @@
{/* Scenario Modals */}
{activeSalesScenarioTenant && (
<ManagerScenarioView
key={`sales_${activeSalesScenarioTenant.id}`}
tenant={activeSalesScenarioTenant}
scenarioType="sales"
onClose={() => setActiveSalesScenarioTenant(null)}
@@ -3590,6 +3649,7 @@
)}
{activeManagerScenarioTenant && (
<ManagerScenarioView
key={`manager_${activeManagerScenarioTenant.id}`}
tenant={activeManagerScenarioTenant}
scenarioType="manager"
onClose={() => setActiveManagerScenarioTenant(null)}