testsprite 전략 수립
This commit is contained in:
@@ -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 = ?");
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user