testsprite 전략 수립
This commit is contained in:
14
debug_db.php
Normal file
14
debug_db.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
require_once("lib/mydb.php");
|
||||||
|
$pdo = db_connect();
|
||||||
|
echo "--- sales_tenant_scenarios structure ---\n";
|
||||||
|
$stmt = $pdo->query("DESC sales_tenant_scenarios");
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
print_r($row);
|
||||||
|
}
|
||||||
|
echo "--- sales_tenant_scenarios data ---\n";
|
||||||
|
$stmt = $pdo->query("SELECT * FROM sales_tenant_scenarios LIMIT 10");
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
print_r($row);
|
||||||
|
}
|
||||||
|
?>
|
||||||
301
helper/index.html
Normal file
301
helper/index.html
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SAM Project - AI 교차 검증 전략 브리핑</title>
|
||||||
|
<meta name="description" content="AI 모델 간의 협업을 통해 코드 품질을 극대화하고 개발 비용을 최적화하는 교차 검증 전략">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=Outfit:wght@400;600;800&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="stars-container"></div>
|
||||||
|
|
||||||
|
<nav class="top-nav">
|
||||||
|
<div class="container nav-container">
|
||||||
|
<div class="logo">SAM Project</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<button class="nav-btn active" data-tab="briefing">전략 브리핑</button>
|
||||||
|
<button class="nav-btn" data-tab="learning">퀴즈 & 학습</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
<!-- Briefing Tab -->
|
||||||
|
<div id="tab-briefing" class="tab-content active">
|
||||||
|
<header class="hero">
|
||||||
|
<div class="container">
|
||||||
|
<div class="badge animate-fade-in">Strategic Briefing</div>
|
||||||
|
<h1 class="animate-slide-up">AI 협업 기반<br><span class="gradient-text">코드 교차 검증 전략</span></h1>
|
||||||
|
<p class="subtitle animate-fade-in-delay">Maximize Quality | Optimize Cost | 5-Step Workflow</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="summary" class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="glass-card summary-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="section-tag">Executive Summary</span>
|
||||||
|
<h2>개요 및 핵심 가치</h2>
|
||||||
|
</div>
|
||||||
|
<p class="summary-text text-highlight">
|
||||||
|
본 문서는 AI 모델 간의 협업을 통해 코드 품질을 극대화하고 개발 비용을 최적화하는 <strong>'교차 검증 전략'</strong>을 종합적으로 분석합니다.
|
||||||
|
</p>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="icon">🚀</div>
|
||||||
|
<h3>5단계 사이클</h3>
|
||||||
|
<p>계획 → 검증 → 구현 → 검증 → 테스트로 이어지는 완벽한 품질 관리 루프</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="icon">🤝</div>
|
||||||
|
<h3>역할 최적화</h3>
|
||||||
|
<p>Antigravity(실행)와 Claude Code(감리)의 조화로운 역할 분담</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="icon">💰</div>
|
||||||
|
<h3>비용 절감</h3>
|
||||||
|
<p>검증 단계에만 고성능 모델을 투입하여 전체 토큰 사용량 약 66% 절감</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="strategy-overview" class="section alt-bg">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-tag">I. Strategy Overview</span>
|
||||||
|
<h2 class="section-title">AI 교차 검증의 핵심 개념</h2>
|
||||||
|
<p>고성능 모델의 초기 테스트 통과율(약 42%) 한계를 극복하기 위한 방법론</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container glass-card">
|
||||||
|
<table class="strategy-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>단계</th>
|
||||||
|
<th>주요 도구</th>
|
||||||
|
<th>핵심 역할</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1. 계획 (Plan)</td>
|
||||||
|
<td>Antigravity</td>
|
||||||
|
<td>문제 해결을 위한 초기 계획 수립 및 문서화</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2. 검증 (Verify)</td>
|
||||||
|
<td>Claude Code</td>
|
||||||
|
<td>계획의 논리적 오류 및 프로젝트 원칙 검토</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>3. 실행 (Execute)</td>
|
||||||
|
<td>Antigravity</td>
|
||||||
|
<td>검증된 계획에 따른 고속 코드 구현</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>4. 검증 (Verify)</td>
|
||||||
|
<td>Claude Code</td>
|
||||||
|
<td>완성된 코드의 최종 검토 및 완성도 극대화</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>5. 테스트 (Test)</td>
|
||||||
|
<td>TestSprite MCP</td>
|
||||||
|
<td>자동화 테스트 및 직관적인 스크린샷 피드백</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metaphor-box glass-card animate-slide-up">
|
||||||
|
<div class="metaphor-icon">🏠</div>
|
||||||
|
<h3>전략적 비유</h3>
|
||||||
|
<p>"<strong>Antigravity</strong>라는 숙련된 시공사가 집을 짓기 전과 후에, <strong>Claude Code</strong>라는 꼼꼼한 설계사가
|
||||||
|
도면과 완공 상태를 철저히 감리하는 것과 같습니다."</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="workflow-detail" class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-tag">II. Workflow Detail</span>
|
||||||
|
<h2 class="section-title">5단계 교차 검증 상세 프로세스</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="workflow-steps">
|
||||||
|
<div class="workflow-card glass-card">
|
||||||
|
<div class="step-badge">1단계</div>
|
||||||
|
<h3>계획 수립 및 문서화</h3>
|
||||||
|
<ul class="step-list">
|
||||||
|
<li>문제 분석 및 구조 설계 (Antigravity)</li>
|
||||||
|
<li>work-plan 폴더 내 마크다운(.md) 저장</li>
|
||||||
|
<li>검토를 위한 물리적 근거 마련</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-card glass-card">
|
||||||
|
<div class="step-badge">2단계</div>
|
||||||
|
<h3>1차 계획 검증</h3>
|
||||||
|
<ul class="step-list">
|
||||||
|
<li>코드 작성 원칙 기반 논리 검토 (Claude Code)</li>
|
||||||
|
<li>문서 하단에 보완 사항 추가 (덮어쓰기 금지)</li>
|
||||||
|
<li>변경 이력의 명확한 추적 가능성 확보</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-card glass-card">
|
||||||
|
<div class="step-badge">3단계</div>
|
||||||
|
<h3>코드 구현</h3>
|
||||||
|
<ul class="step-list">
|
||||||
|
<li>보완된 최종 지시에 따른 구현 (Antigravity)</li>
|
||||||
|
<li>잠재적 오류 제거로 인한 에러율 급감</li>
|
||||||
|
<li>정제된 워크플로우를 통한 생산성 향상</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-card glass-card">
|
||||||
|
<div class="step-badge">4단계</div>
|
||||||
|
<h3>2차 최종 결과 검토</h3>
|
||||||
|
<ul class="step-list">
|
||||||
|
<li>계획 반영 여부 최종 점검 (Claude Code)</li>
|
||||||
|
<li>전체 코드 완성도 및 코드 품질 확인</li>
|
||||||
|
<li>최종 릴리스 준위의 결과물 확보</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-card glass-card">
|
||||||
|
<div class="step-badge">5단계</div>
|
||||||
|
<h3>자동 테스트 및 피드백</h3>
|
||||||
|
<ul class="step-list">
|
||||||
|
<li>TestSprite MCP 기반 자동화 테스트</li>
|
||||||
|
<li>스크린샷 리포트를 통한 직관적 오류 파악</li>
|
||||||
|
<li>100%에 수렴하는 품질 완성 사이클</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tools-setup" class="section alt-bg">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-tag">III. Tool Roles & Setup</span>
|
||||||
|
<h2 class="section-title">핵심 도구별 역할 및 설정</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-grid">
|
||||||
|
<div class="tool-card glass-card">
|
||||||
|
<div class="tool-header">
|
||||||
|
<div class="tool-logo">A</div>
|
||||||
|
<h3>Antigravity</h3>
|
||||||
|
</div>
|
||||||
|
<p class="tool-role">실무 실행자 (Doer)</p>
|
||||||
|
<p>토큰 소모가 많은 초기 계획과 코드 구현 전담. VS Code 내 'Claude Code for VS Code'와 병행 설치 권장.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tool-card glass-card">
|
||||||
|
<div class="tool-header">
|
||||||
|
<div class="tool-logo">C</div>
|
||||||
|
<h3>Claude Code</h3>
|
||||||
|
</div>
|
||||||
|
<p class="tool-role">설계 및 감리자 (Reviewer)</p>
|
||||||
|
<p>고비용 모델의 효율적 사용을 위해 검증에 집중. 메모리/에이전트/훅 설정에 프로젝트 규칙 정의 필수.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tool-card glass-card full-width">
|
||||||
|
<div class="tool-header">
|
||||||
|
<div class="tool-logo">T</div>
|
||||||
|
<h3>TestSprite MCP</h3>
|
||||||
|
</div>
|
||||||
|
<div class="tool-detail-grid">
|
||||||
|
<div class="tool-desc">
|
||||||
|
<p class="tool-role">최종 품질 검사관 (Inspector)</p>
|
||||||
|
<p>자동 테스트 및 스크린샷 리포트를 통해 인간이 놓치는 논리적 결함 발견.</p>
|
||||||
|
</div>
|
||||||
|
<div class="setup-steps">
|
||||||
|
<h4>설정 절차</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>API 키:</strong> testsprite.com 발급 (유일 1회 노출)</li>
|
||||||
|
<li><strong>Windows:</strong> 전용 명령어를 통한 MCP 연결</li>
|
||||||
|
<li><strong>명세서:</strong> Claude Code를 통한 Product Spec 생성 및 제공</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="outcomes" class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-tag">IV. Expected Effects</span>
|
||||||
|
<h2 class="section-title">기대 효과 및 실질적 이점</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="outcome-grid">
|
||||||
|
<div class="outcome-card glass-card">
|
||||||
|
<div class="outcome-icon">✨</div>
|
||||||
|
<h3>코드 품질 향상</h3>
|
||||||
|
<p>상호 보완적 모델 협업과 전문 테스트 도구를 통한 안정적인 결과물 생산</p>
|
||||||
|
</div>
|
||||||
|
<div class="outcome-card glass-card highlight">
|
||||||
|
<div class="outcome-icon">📉</div>
|
||||||
|
<h3>비용 및 토큰 절감</h3>
|
||||||
|
<p>고비용 모델을 검증에만 집중 배치하여 <strong>토큰 사용량 약 2/3(약 66%) 절감</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="testimonial glass-card">
|
||||||
|
<blockquote>
|
||||||
|
"Claude Max 200달러 요금제를 쓰다가 이 전략을 적용하고 나서 100달러 요금제로 낮췄습니다. 이제는 토큰 걱정 없이 편하게 작업하고 있습니다."
|
||||||
|
</blockquote>
|
||||||
|
<cite>— 실제 사례 사용자</cite>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Learning Tab (Quiz & Glossary) -->
|
||||||
|
<div id="tab-learning" class="tab-content">
|
||||||
|
<section id="quiz" class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-tag">Assessment</span>
|
||||||
|
<h2 class="section-title">단답형 퀴즈</h2>
|
||||||
|
<p>전략의 핵심 개념을 점검해 보세요. 카드를 클릭하면 정답이 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="quiz-grid" id="quiz-container">
|
||||||
|
<!-- Quiz items will be injected by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="glossary" class="section alt-bg">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-tag">Dictionary</span>
|
||||||
|
<h2 class="section-title">핵심 용어 정리</h2>
|
||||||
|
</div>
|
||||||
|
<div class="glossary-grid" id="glossary-container">
|
||||||
|
<!-- Glossary items will be injected by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<p>© 2025 AI Engineering Helper. SAM Strategy Briefing.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
146
helper/script.js
Normal file
146
helper/script.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
const quizData = [
|
||||||
|
{
|
||||||
|
q: "AI 협업 기반 코드 교차 검증 전략의 5단계 사이클은 무엇이며, 각 단계는 무엇으로 구성되어 있습니까?",
|
||||||
|
a: "계획, 검증, 실행, 검증, 테스트로 구성됩니다. Antigravity가 계획을 수립하고, Claude Code가 검증하며, Antigravity가 코드를 구현하고, Claude Code가 최종 검토한 후, TestSprite MCP가 자동 테스트를 진행합니다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "첫 번째 단계에서 Antigravity가 수립한 계획을 별도의 문서 파일로 저장하는 이유는 무엇입니까?",
|
||||||
|
a: "후속 단계에서 Claude Code가 검토할 수 있는 물리적인 근거를 만들기 위함입니다. 이는 AI 모델 간의 명확한 정보 전달과 이력 관리를 가능하게 합니다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Claude Code는 1차 계획 검증 단계에서 어떤 기준을 바탕으로 계획을 검토하며, 그 역할은 무엇입니까?",
|
||||||
|
a: "미리 설정된 코드 작성 원칙과 기준(메모리, 에이전트, 훅 등)을 바탕으로 검토합니다. 설계상의 오류를 사전에 파악하고 보안사항을 제시하는 '설계 감리' 역할을 수행합니다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "이 교차 검증 전략이 Claude Code의 토큰 사용량을 획기적으로 절감할 수 있는 핵심적인 이유 두 가지를 설명하시오.",
|
||||||
|
a: "첫째, 비용이 높은 Claude Code를 실제 구현이 아닌 '검증'에만 집중 배치하기 때문입니다. 둘째, 구현(Antigravity) 및 자동 테스트(TestSprite) 단계에서는 Claude Code의 토큰이 소모되지 않습니다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "전체 사이클의 마지막 단계에서 TestSprite MCP가 수행하는 주요 역할은 무엇입니까?",
|
||||||
|
a: "완성된 코드에 대해 자동화된 테스트를 실행합니다. 문제가 발견되면 상세 리포트를 생성하여 Antigravity에게 전달함으로써 피드백 루프를 시작합니다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "TestSprite MCP의 API 키를 발급받을 때 반드시 지켜야 할 중요한 보안 수칙은 무엇입니까?",
|
||||||
|
a: "API 키는 단 한 번만 화면에 노출되므로, 즉시 복사하여 별도의 안전한 장소에 반드시 보관해야 합니다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "TestSprite MCP가 테스트 계획을 수립하기 위해 반드시 제공받아야 하는 문서는 무엇이며, 이 문서는 어떻게 생성할 수 있습니까?",
|
||||||
|
a: "테스트할 기능의 동작을 설명하는 'Product specification Doc'(기능 명세서)가 필요합니다. 이는 Claude Code에게 요청하여 간단히 생성할 수 있습니다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "TestSprite MCP가 제공하는 테스트 리포트의 가장 강력하고 직관적인 특징은 무엇입니까?",
|
||||||
|
a: "각 단계별 실행 화면을 스크린샷으로 제공하여, 어느 화면에서 어떤 문제가 발생했는지 마치 사람이 직접 테스트한 것처럼 파악할 수 있다는 점입니다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Claude와 같은 고성능 AI 모델을 사용함에도 불구하고 교차 검증 전략이 필요한 근본적인 이유는 무엇입니까?",
|
||||||
|
a: "최신 연구 기준 고성능 AI 모델의 초기 코드 테스트 통과율이 약 42%에 불과하기 때문입니다. 이러한 한계를 극복하고 완성도를 극대화하기 위해 상호 보완이 필요합니다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "이 교차 검증 전략의 전체 과정을 한 문장의 비유로 요약하여 설명하시오.",
|
||||||
|
a: "Antigravity라는 숙련된 시공사가 집을 짓기 전과 후에, Claude Code라는 꼼꼼한 설계사가 도면과 완공 상태를 철철히 감리하는 것과 같습니다."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const glossaryData = [
|
||||||
|
{ term: "AI 협업 기반의 코드 교차 검증 전략", def: "Antigravity와 Claude Code를 활용하여 계획-검증-실행-검증-테스트의 5단계 사이클을 통해 코드 품질을 극대화하고 비용을 절감하는 방법론." },
|
||||||
|
{ term: "Antigravity", def: "구글의 AI 모델로, 초기 계획 수립과 실제 코드 구현(실행)을 담당하는 '숙련된 시공사' 역할." },
|
||||||
|
{ term: "Claude Code", def: "앤스로픽의 AI 모델로, 계획과 결과물을 검증(Review)하고 감리하는 '꼼꼼한 설계사' 역할." },
|
||||||
|
{ term: "TestSprite MCP", def: "자동화 테스트 실행 도구. 실행 화면 스크린샷을 포함한 상세 리포트를 제공하여 최종 테스트 단계를 수행함." },
|
||||||
|
{ term: "교차 검증 (Cross-Validation)", def: "두 개 이상의 AI 모델이 상호 검토하여 논리적 오류를 줄이고 정교한 결과를 얻는 과정." },
|
||||||
|
{ term: "토큰 (Token)", def: "AI 모델의 언어 처리 단위. 이 전략을 통해 고비용 모델의 토큰 사용량을 2/3 수준으로 절감할 수 있음." },
|
||||||
|
{ term: "피드백 루프 (Feedback Loop)", def: "테스트 문제 발생 시 리포트를 Antigravity에게 전달하여 수정-재검증 사이클을 반복하는 것." },
|
||||||
|
{ term: "코드 작성 원칙과 기준", def: "Claude Code에 설정된 프로젝트 고유 규칙(메모리, 에이전트, 훅 등)으로 검증의 기준이 됨." },
|
||||||
|
{ term: "Product specification Doc", def: "TestSprite MCP가 테스트 계획 수립을 위해 필요로 하는 기능 명세서." }
|
||||||
|
];
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Tab Switching Logic
|
||||||
|
const navBtns = document.querySelectorAll('.nav-btn');
|
||||||
|
const tabContents = document.querySelectorAll('.tab-content');
|
||||||
|
|
||||||
|
navBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const tabId = btn.getAttribute('data-tab');
|
||||||
|
|
||||||
|
// Update Buttons
|
||||||
|
navBtns.forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
// Update Content
|
||||||
|
tabContents.forEach(content => {
|
||||||
|
content.classList.remove('active');
|
||||||
|
if (content.id === `tab-${tabId}`) {
|
||||||
|
content.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll to top when switching
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject Quiz
|
||||||
|
const quizContainer = document.getElementById('quiz-container');
|
||||||
|
quizData.forEach((item, index) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'quiz-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="quiz-card-inner">
|
||||||
|
<div class="quiz-card-front">
|
||||||
|
<span class="quiz-number">Q${(index + 1).toString().padStart(2, '0')}</span>
|
||||||
|
<p class="question-text">${item.q}</p>
|
||||||
|
<p style="font-size: 0.8rem; opacity: 0.5; margin-top: 1rem;">Click to see answer</p>
|
||||||
|
</div>
|
||||||
|
<div class="quiz-card-back">
|
||||||
|
<p class="answer-text">${item.a}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
card.classList.toggle('flipped');
|
||||||
|
});
|
||||||
|
quizContainer.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject Glossary
|
||||||
|
const glossaryContainer = document.getElementById('glossary-container');
|
||||||
|
glossaryData.forEach(item => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'glossary-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<h3>${item.term}</h3>
|
||||||
|
<p>${item.def}</p>
|
||||||
|
`;
|
||||||
|
glossaryContainer.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Background Stars
|
||||||
|
const starsContainer = document.querySelector('.stars-container');
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const star = document.createElement('div');
|
||||||
|
star.className = 'star';
|
||||||
|
const size = Math.random() * 2;
|
||||||
|
star.style.width = `${size}px`;
|
||||||
|
star.style.height = `${size}px`;
|
||||||
|
star.style.left = `${Math.random() * 100}%`;
|
||||||
|
star.style.top = `${Math.random() * 100}%`;
|
||||||
|
star.style.opacity = Math.random();
|
||||||
|
star.style.position = 'absolute';
|
||||||
|
star.style.backgroundColor = 'white';
|
||||||
|
star.style.borderRadius = '50%';
|
||||||
|
if (Math.random() > 0.8) {
|
||||||
|
star.style.animation = `twinkle ${Math.random() * 3 + 2}s infinite alternate`;
|
||||||
|
}
|
||||||
|
starsContainer.appendChild(star);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Twinkle Animation
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes twinkle {
|
||||||
|
from { opacity: 0.2; transform: scale(1); }
|
||||||
|
to { opacity: 1; transform: scale(1.2); }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
537
helper/style.css
Normal file
537
helper/style.css
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
:root {
|
||||||
|
--bg-color: #0B0E14;
|
||||||
|
--card-bg: rgba(255, 255, 255, 0.03);
|
||||||
|
--card-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--primary: #7B61FF;
|
||||||
|
--primary-glow: rgba(123, 97, 255, 0.4);
|
||||||
|
--secondary: #00D1FF;
|
||||||
|
--text-main: #E2E8F0;
|
||||||
|
--text-muted: #94A3B8;
|
||||||
|
--white: #FFFFFF;
|
||||||
|
--gradient: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||||
|
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
.gradient-text {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background Animation */
|
||||||
|
.stars-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
background: radial-gradient(circle at 50% 50%, #1a1a2e 0%, #0B0E14 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.top-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(11, 14, 20, 0.8);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: var(--gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
color: var(--white);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--white);
|
||||||
|
box-shadow: 0 4px 15px var(--primary-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab Content Logic */
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
animation: fadeIn 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Section */
|
||||||
|
.hero {
|
||||||
|
height: 60vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1.2rem;
|
||||||
|
background: rgba(123, 97, 255, 0.1);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: 99px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(2.5rem, 8vw, 4.5rem);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: var(--gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass Card */
|
||||||
|
.glass-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card:hover {
|
||||||
|
border-color: rgba(123, 97, 255, 0.3);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.section {
|
||||||
|
padding: 6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alt-bg {
|
||||||
|
background: rgba(123, 97, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-tag {
|
||||||
|
color: var(--secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary Grid */
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item .icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strategy Table */
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategy-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategy-table th,
|
||||||
|
.strategy-table td {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategy-table th {
|
||||||
|
color: var(--secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategy-table td:first-child {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metaphor Box */
|
||||||
|
.metaphor-box {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaphor-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workflow Steps */
|
||||||
|
.workflow-steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-list {
|
||||||
|
list-style: none;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-list li {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-list li::before {
|
||||||
|
content: "•";
|
||||||
|
color: var(--secondary);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool Grid */
|
||||||
|
.tool-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--gradient);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-role {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-steps h4 {
|
||||||
|
color: var(--white);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-steps li {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outcomes */
|
||||||
|
.outcome-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outcome-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outcome-card.highlight {
|
||||||
|
background: rgba(123, 97, 255, 0.1);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outcome-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testimonial {
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testimonial blockquote {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testimonial cite {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quiz & Glossary Styles (Inherited and Refined) */
|
||||||
|
.quiz-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-card {
|
||||||
|
perspective: 1000px;
|
||||||
|
height: 240px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-card-inner {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-card.flipped .quiz-card-inner {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-card-front,
|
||||||
|
.quiz-card-back {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-card-front {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-card-back {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-number {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glossary Section */
|
||||||
|
.glossary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-card {
|
||||||
|
padding: 2rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-card h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
padding: 4rem 0;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid var(--card-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(30px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up { animation: slideUp 0.8s ease forwards; }
|
||||||
|
.animate-fade-in { animation: fadeIn 1s ease forwards; }
|
||||||
|
.animate-fade-in-delay { opacity: 0; animation: fadeIn 1s ease 0.3s forwards; }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.tool-detail-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero h1 { font-size: 2.5rem; }
|
||||||
|
.hero { height: auto; padding: 6rem 0; }
|
||||||
|
.section { padding: 4rem 0; }
|
||||||
|
.outcome-grid { grid-template-columns: 1fr; }
|
||||||
|
.nav-container { flex-direction: column; gap: 1rem; }
|
||||||
|
}
|
||||||
61
mysql_sync.sh
Normal file
61
mysql_sync.sh
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ===== 설정값 =====
|
||||||
|
DOCKER_CONTAINER="sam-mysql-1"
|
||||||
|
DOCKER_DB="chandj"
|
||||||
|
DOCKER_USER="root"
|
||||||
|
DOCKER_PASS="root"
|
||||||
|
|
||||||
|
REMOTE_USER="pro"
|
||||||
|
REMOTE_HOST="114.203.209.83"
|
||||||
|
REMOTE_DB="chandj"
|
||||||
|
|
||||||
|
CHARSET="utf8mb4"
|
||||||
|
|
||||||
|
# ===== 인자 체크 =====
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "사용법: $0 table1 또는 $0 table1,table2,table3"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 콤마 기준으로 테이블 분리
|
||||||
|
IFS=',' read -ra TABLES <<< "$1"
|
||||||
|
|
||||||
|
for TABLE in "${TABLES[@]}"; do
|
||||||
|
SQL_FILE="${TABLE}.sql"
|
||||||
|
|
||||||
|
echo "▶ 테이블 덤프: $TABLE"
|
||||||
|
|
||||||
|
docker exec -i "$DOCKER_CONTAINER" mysqldump \
|
||||||
|
-u"$DOCKER_USER" -p"$DOCKER_PASS" \
|
||||||
|
--default-character-set="$CHARSET" \
|
||||||
|
--add-drop-table \
|
||||||
|
"$DOCKER_DB" "$TABLE" > "$SQL_FILE"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ 덤프 실패: $TABLE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "▶ 서버로 복사: $SQL_FILE"
|
||||||
|
scp "$SQL_FILE" "$REMOTE_USER@$REMOTE_HOST:~/"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ SCP 실패: $SQL_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "▶ 서버 DB 적용: $TABLE"
|
||||||
|
ssh "$REMOTE_USER@$REMOTE_HOST" \
|
||||||
|
"mysql -u $REMOTE_USER -p $REMOTE_DB < ~/$SQL_FILE"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ DB 적용 실패: $TABLE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ 완료: $TABLE"
|
||||||
|
echo "--------------------------------------"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "🎉 모든 테이블 처리 완료"
|
||||||
@@ -15,6 +15,28 @@ if (!isset($_SESSION['sales_user'])) {
|
|||||||
$currentUser = $_SESSION['sales_user'];
|
$currentUser = $_SESSION['sales_user'];
|
||||||
$pdo = db_connect();
|
$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("
|
$pdo->exec("
|
||||||
CREATE TABLE IF NOT EXISTS `sales_tenants` (
|
CREATE TABLE IF NOT EXISTS `sales_tenants` (
|
||||||
@@ -136,23 +158,36 @@ try {
|
|||||||
");
|
");
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
} else {
|
} else {
|
||||||
// 내가 영업했거나, 내가 매니저로 배정된 테넌트
|
if ($currentUser['role'] === 'manager') {
|
||||||
$stmt = $pdo->prepare("
|
// 매니저는 본인이 담당 매니저로 배정된 테넌트만 조회
|
||||||
SELECT t.*, m.name as register_name, m2.name as manager_name, m2.role as manager_role
|
$stmt = $pdo->prepare("
|
||||||
FROM sales_tenants t
|
SELECT t.*, m.name as register_name, m2.name as manager_name, m2.role as manager_role
|
||||||
JOIN sales_member m ON t.manager_id = m.id
|
FROM sales_tenants t
|
||||||
LEFT JOIN sales_member m2 ON t.sales_manager_id = m2.id
|
JOIN sales_member m ON t.manager_id = m.id
|
||||||
WHERE t.manager_id = ? OR t.sales_manager_id = ?
|
LEFT JOIN sales_member m2 ON t.sales_manager_id = m2.id
|
||||||
ORDER BY t.created_at DESC
|
WHERE t.sales_manager_id = ?
|
||||||
");
|
ORDER BY t.created_at DESC
|
||||||
$stmt->execute([$currentUser['id'], $currentUser['id']]);
|
");
|
||||||
|
$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);
|
$tenants = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
echo json_encode(['success' => true, 'data' => $tenants]);
|
echo json_encode(['success' => true, 'data' => $tenants]);
|
||||||
|
|
||||||
} elseif ($action === 'tenant_products') {
|
} elseif ($action === 'tenant_products') {
|
||||||
$tenant_id = $_GET['tenant_id'] ?? null;
|
$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 = $pdo->prepare("SELECT * FROM sales_tenant_products WHERE tenant_id = ? ORDER BY created_at DESC");
|
||||||
$stmt->execute([$tenant_id]);
|
$stmt->execute([$tenant_id]);
|
||||||
@@ -161,7 +196,7 @@ try {
|
|||||||
|
|
||||||
} elseif ($action === 'my_stats') {
|
} elseif ($action === 'my_stats') {
|
||||||
// 현재 로그인한 사용자의 요약 통계
|
// 현재 로그인한 사용자의 요약 통계
|
||||||
$stmt = $pdo->prepare("
|
$sql = "
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT t.id) as tenant_count,
|
COUNT(DISTINCT t.id) as tenant_count,
|
||||||
SUM(p.contract_amount) as total_revenue,
|
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
|
SUM(CASE WHEN p.operator_confirmed = 1 THEN p.commission_amount ELSE 0 END) as confirmed_commission
|
||||||
FROM sales_tenants t
|
FROM sales_tenants t
|
||||||
LEFT JOIN sales_tenant_products p ON t.id = p.tenant_id
|
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);
|
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
echo json_encode(['success' => true, 'data' => $stats]);
|
echo json_encode(['success' => true, 'data' => $stats]);
|
||||||
} elseif ($action === 'get_scenario') {
|
} elseif ($action === 'get_scenario') {
|
||||||
$tenant_id = $_GET['tenant_id'] ?? null;
|
$tenant_id = $_GET['tenant_id'] ?? null;
|
||||||
$scenario_type = $_GET['scenario_type'] ?? 'manager';
|
$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 = $pdo->prepare("SELECT * FROM sales_tenant_scenarios WHERE tenant_id = ? AND scenario_type = ?");
|
||||||
$stmt->execute([$tenant_id, $scenario_type]);
|
$stmt->execute([$tenant_id, $scenario_type]);
|
||||||
@@ -189,7 +235,7 @@ try {
|
|||||||
$tenant_id = $_GET['tenant_id'] ?? null;
|
$tenant_id = $_GET['tenant_id'] ?? null;
|
||||||
$scenario_type = $_GET['scenario_type'] ?? 'manager';
|
$scenario_type = $_GET['scenario_type'] ?? 'manager';
|
||||||
$step_id = $_GET['step_id'] ?? null;
|
$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 = ?";
|
$sql = "SELECT * FROM sales_tenant_consultations WHERE tenant_id = ? AND scenario_type = ?";
|
||||||
$params = [$tenant_id, $scenario_type];
|
$params = [$tenant_id, $scenario_type];
|
||||||
@@ -206,7 +252,7 @@ try {
|
|||||||
echo json_encode(['success' => true, 'data' => $results]);
|
echo json_encode(['success' => true, 'data' => $results]);
|
||||||
} elseif ($action === 'list_managers') {
|
} 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();
|
$stmt->execute();
|
||||||
$managers = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$managers = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
echo json_encode(['success' => true, 'data' => $managers]);
|
echo json_encode(['success' => true, 'data' => $managers]);
|
||||||
@@ -222,6 +268,9 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'create_tenant') {
|
if ($action === 'create_tenant') {
|
||||||
|
if ($currentUser['role'] === 'manager') {
|
||||||
|
throw new Exception("매니저는 테넌트를 등록할 권한이 없습니다.");
|
||||||
|
}
|
||||||
$tenant_name = $data['tenant_name'] ?? '';
|
$tenant_name = $data['tenant_name'] ?? '';
|
||||||
$representative = $data['representative'] ?? '';
|
$representative = $data['representative'] ?? '';
|
||||||
$business_no = $data['business_no'] ?? '';
|
$business_no = $data['business_no'] ?? '';
|
||||||
@@ -239,6 +288,8 @@ try {
|
|||||||
|
|
||||||
} elseif ($action === 'add_product') {
|
} elseif ($action === 'add_product') {
|
||||||
$tenant_id = $data['tenant_id'] ?? null;
|
$tenant_id = $data['tenant_id'] ?? null;
|
||||||
|
if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다.");
|
||||||
|
|
||||||
$product_name = $data['product_name'] ?? '';
|
$product_name = $data['product_name'] ?? '';
|
||||||
$contract_amount = $data['contract_amount'] ?? 0;
|
$contract_amount = $data['contract_amount'] ?? 0;
|
||||||
$commission_rate = $data['commission_rate'] ?? 0;
|
$commission_rate = $data['commission_rate'] ?? 0;
|
||||||
@@ -267,14 +318,18 @@ try {
|
|||||||
|
|
||||||
echo json_encode(['success' => true, 'message' => $confirmed ? '승인되었습니다.' : '승인이 취소되었습니다.']);
|
echo json_encode(['success' => true, 'message' => $confirmed ? '승인되었습니다.' : '승인이 취소되었습니다.']);
|
||||||
} elseif ($action === 'update_checklist') {
|
} 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';
|
$scenario_type = $data['scenario_type'] ?? 'manager';
|
||||||
$step_id = $data['step_id'] ?? null;
|
|
||||||
$checkpoint_index = $data['checkpoint_index'] ?? null;
|
if ($tenant_id === null || $step_id === null || $checkpoint_index === null) {
|
||||||
$is_checked = $data['is_checked'] ? 1 : 0;
|
throw new Exception("필수 파라미터가 누락되었습니다. (T: $tenant_id, S: $step_id, C: $checkpoint_index)");
|
||||||
|
}
|
||||||
if (!$tenant_id || $step_id === null || $checkpoint_index === null) throw new Exception("필수 파라미터가 누락되었습니다.");
|
|
||||||
|
|
||||||
|
if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다.");
|
||||||
|
|
||||||
$stmt = $pdo->prepare("
|
$stmt = $pdo->prepare("
|
||||||
INSERT INTO sales_tenant_scenarios (tenant_id, scenario_type, step_id, checkpoint_index, is_checked)
|
INSERT INTO sales_tenant_scenarios (tenant_id, scenario_type, step_id, checkpoint_index, is_checked)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
@@ -286,6 +341,8 @@ try {
|
|||||||
|
|
||||||
} elseif ($action === 'save_consultation') {
|
} elseif ($action === 'save_consultation') {
|
||||||
$tenant_id = $data['tenant_id'] ?? null;
|
$tenant_id = $data['tenant_id'] ?? null;
|
||||||
|
if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다.");
|
||||||
|
|
||||||
$scenario_type = $data['scenario_type'] ?? 'manager';
|
$scenario_type = $data['scenario_type'] ?? 'manager';
|
||||||
$step_id = $data['step_id'] ?? null;
|
$step_id = $data['step_id'] ?? null;
|
||||||
$log_text = $data['log_text'] ?? '';
|
$log_text = $data['log_text'] ?? '';
|
||||||
@@ -314,6 +371,8 @@ try {
|
|||||||
echo json_encode(['success' => true, 'message' => '기록이 저장되었습니다.']);
|
echo json_encode(['success' => true, 'message' => '기록이 저장되었습니다.']);
|
||||||
} elseif ($action === 'upload_attachments') {
|
} elseif ($action === 'upload_attachments') {
|
||||||
$tenant_id = $data['tenant_id'] ?? null;
|
$tenant_id = $data['tenant_id'] ?? null;
|
||||||
|
if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다.");
|
||||||
|
|
||||||
$scenario_type = $data['scenario_type'] ?? 'manager';
|
$scenario_type = $data['scenario_type'] ?? 'manager';
|
||||||
$step_id = $data['step_id'] ?? null;
|
$step_id = $data['step_id'] ?? null;
|
||||||
|
|
||||||
@@ -353,22 +412,24 @@ try {
|
|||||||
$id = $data['id'] ?? null;
|
$id = $data['id'] ?? null;
|
||||||
if (!$id) throw new Exception("ID가 누락되었습니다.");
|
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]);
|
$stmt->execute([$id]);
|
||||||
$c = $stmt->fetch(PDO::FETCH_ASSOC);
|
$c = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
if ($c) {
|
if (!$c) throw new Exception("해당 기록을 찾을 수 없습니다.");
|
||||||
if ($c['audio_file_path'] && file_exists(__DIR__ . "/../" . $c['audio_file_path'])) {
|
if (!checkTenantPermission($pdo, $c['tenant_id'], $currentUser)) throw new Exception("권한이 없습니다.");
|
||||||
@unlink(__DIR__ . "/../" . $c['audio_file_path']);
|
|
||||||
}
|
// 파일 삭제 처리
|
||||||
if ($c['attachment_paths']) {
|
if ($c['audio_file_path'] && file_exists(__DIR__ . "/../" . $c['audio_file_path'])) {
|
||||||
$paths = json_decode($c['attachment_paths'], true);
|
@unlink(__DIR__ . "/../" . $c['audio_file_path']);
|
||||||
if (is_array($paths)) {
|
}
|
||||||
foreach ($paths as $p) {
|
if ($c['attachment_paths']) {
|
||||||
if (isset($p['path']) && file_exists(__DIR__ . "/../" . $p['path'])) {
|
$paths = json_decode($c['attachment_paths'], true);
|
||||||
@unlink(__DIR__ . "/../" . $p['path']);
|
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;
|
$product_id = $data['id'] ?? null;
|
||||||
if (!$product_id) throw new Exception("ID가 누락되었습니다.");
|
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]);
|
$stmt->execute([$product_id]);
|
||||||
$p = $stmt->fetch(PDO::FETCH_ASSOC);
|
$p = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
if (!$p) throw new Exception("해당 정보를 찾을 수 없습니다.");
|
if (!$p) throw new Exception("해당 정보를 찾을 수 없습니다.");
|
||||||
|
if (!checkTenantPermission($pdo, $p['tenant_id'], $currentUser)) throw new Exception("권한이 없습니다.");
|
||||||
if ($p['operator_confirmed'] == 1) throw new Exception("이미 승인된 계약은 삭제할 수 없습니다.");
|
if ($p['operator_confirmed'] == 1) throw new Exception("이미 승인된 계약은 삭제할 수 없습니다.");
|
||||||
|
|
||||||
$stmt = $pdo->prepare("DELETE FROM sales_tenant_products WHERE id = ?");
|
$stmt = $pdo->prepare("DELETE FROM sales_tenant_products WHERE id = ?");
|
||||||
@@ -399,6 +461,16 @@ try {
|
|||||||
|
|
||||||
if (!$tenant_id) throw new Exception("테넌트 ID가 누락되었습니다.");
|
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_id가 0이거나 empty면 null로 처리 (지정 취소)
|
||||||
$manager_val = (!empty($sales_manager_id)) ? intval($sales_manager_id) : 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 ? '담당 매니저가 지정되었습니다.' : '담당 매니저 지정이 취소되었습니다.']);
|
echo json_encode(['success' => true, 'message' => $manager_val ? '담당 매니저가 지정되었습니다.' : '담당 매니저 지정이 취소되었습니다.']);
|
||||||
} elseif ($action === 'update_product') {
|
} elseif ($action === 'update_product') {
|
||||||
$product_id = $data['id'] ?? null;
|
$product_id = $data['id'] ?? null;
|
||||||
$product_name = $data['product_name'] ?? '';
|
if (!$product_id) throw new Exception("ID가 누락되었습니다.");
|
||||||
$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 || !$product_name) throw new Exception("필수 정보가 누락되었습니다.");
|
// 정보 및 권한 조회
|
||||||
|
$stmt = $pdo->prepare("SELECT tenant_id, operator_confirmed FROM sales_tenant_products WHERE id = ?");
|
||||||
// 보안 체크: 승인된 것은 수정 불가
|
|
||||||
$stmt = $pdo->prepare("SELECT operator_confirmed FROM sales_tenant_products WHERE id = ?");
|
|
||||||
$stmt->execute([$product_id]);
|
$stmt->execute([$product_id]);
|
||||||
$p = $stmt->fetch(PDO::FETCH_ASSOC);
|
$p = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
if (!$p) throw new Exception("해당 정보를 찾을 수 없습니다.");
|
if (!$p) throw new Exception("해당 정보를 찾을 수 없습니다.");
|
||||||
|
if (!checkTenantPermission($pdo, $p['tenant_id'], $currentUser)) throw new Exception("권한이 없습니다.");
|
||||||
if ($p['operator_confirmed'] == 1) throw new Exception("이미 승인된 계약은 수정할 수 없습니다.");
|
if ($p['operator_confirmed'] == 1) throw new Exception("이미 승인된 계약은 수정할 수 없습니다.");
|
||||||
|
|
||||||
|
$product_name = $data['product_name'] ?? '';
|
||||||
|
|
||||||
$commission_amount = ($contract_amount * $commission_rate) / 100;
|
$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 = ?");
|
$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);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const newSteps = scenarioType === 'sales' ? SALES_SCENARIO_STEPS : MANAGER_SCENARIO_STEPS;
|
||||||
|
setActiveStep(newSteps[0]);
|
||||||
fetchScenarioData();
|
fetchScenarioData();
|
||||||
fetchConsultations();
|
fetchConsultations();
|
||||||
}, [tenant.id, scenarioType]);
|
}, [tenant.id, scenarioType]);
|
||||||
@@ -2499,11 +2501,12 @@
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
const map = {};
|
const map = {};
|
||||||
result.data.forEach(item => {
|
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);
|
setChecklist(map);
|
||||||
}
|
}
|
||||||
} catch (err) { console.error(err); }
|
} catch (err) { /* console.error('Fetch scenario error:', err); */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchConsultations = async () => {
|
const fetchConsultations = async () => {
|
||||||
@@ -2517,7 +2520,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleCheck = async (stepId, index) => {
|
const toggleCheck = async (stepId, index) => {
|
||||||
const key = `${stepId}_${index}`;
|
const key = `${scenarioType}_${stepId}_${index}`;
|
||||||
const newState = !checklist[key];
|
const newState = !checklist[key];
|
||||||
|
|
||||||
// Optimistic UI Update
|
// Optimistic UI Update
|
||||||
@@ -2541,7 +2544,7 @@
|
|||||||
setChecklist(prev => ({ ...prev, [key]: !newState })); // Rollback
|
setChecklist(prev => ({ ...prev, [key]: !newState })); // Rollback
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Checkbox toggle error:', err);
|
// console.error('Checkbox toggle error:', err);
|
||||||
alert('서버와 통신하는 중 오류가 발생했습니다.');
|
alert('서버와 통신하는 중 오류가 발생했습니다.');
|
||||||
setChecklist(prev => ({ ...prev, [key]: !newState })); // Rollback
|
setChecklist(prev => ({ ...prev, [key]: !newState })); // Rollback
|
||||||
}
|
}
|
||||||
@@ -2588,7 +2591,7 @@
|
|||||||
const total = step.checkpoints.length;
|
const total = step.checkpoints.length;
|
||||||
let checked = 0;
|
let checked = 0;
|
||||||
for (let i = 0; i < total; i++) {
|
for (let i = 0; i < total; i++) {
|
||||||
if (checklist[`${stepId}_${i}`]) checked++;
|
if (checklist[`${scenarioType}_${stepId}_${i}`]) checked++;
|
||||||
}
|
}
|
||||||
return Math.round((checked / total) * 100);
|
return Math.round((checked / total) * 100);
|
||||||
};
|
};
|
||||||
@@ -2637,7 +2640,10 @@
|
|||||||
<LucideIcon name={step.icon} className="w-5 h-5" />
|
<LucideIcon name={step.icon} className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<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="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 className={`h-full rounded-full transition-all ${step.color.replace('text-', 'bg-').replace('100', '500')}`} style={{ width: `${progress}%` }}></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2692,16 +2698,16 @@
|
|||||||
{activeStep.checkpoints.map((cp, idx) => (
|
{activeStep.checkpoints.map((cp, idx) => (
|
||||||
<div key={idx}
|
<div key={idx}
|
||||||
onClick={() => toggleCheck(activeStep.id, 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="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'}`}>
|
<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[`${activeStep.id}_${idx}`] && <LucideIcon name="check" className="w-4 h-4" />}
|
{checklist[`${scenarioType}_${activeStep.id}_${idx}`] && <LucideIcon name="check" className="w-4 h-4" />}
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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">
|
<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}
|
💡 Tip: {cp.pro_tip}
|
||||||
</div>
|
</div>
|
||||||
@@ -2712,21 +2718,19 @@
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step 2 Special Features: Voice & Files */}
|
{/* Step Features: Voice & Files - Available for all steps */}
|
||||||
{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 pt-8 border-t border-slate-100">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8 animate-in slide-in-from-bottom-6 duration-700">
|
<VoiceRecorder
|
||||||
<VoiceRecorder
|
tenantId={tenant.id}
|
||||||
tenantId={tenant.id}
|
scenarioType={scenarioType}
|
||||||
scenarioType={scenarioType}
|
stepId={activeStep.id}
|
||||||
stepId={activeStep.id}
|
/>
|
||||||
/>
|
<FileUploader
|
||||||
<FileUploader
|
tenantId={tenant.id}
|
||||||
tenantId={tenant.id}
|
scenarioType={scenarioType}
|
||||||
scenarioType={scenarioType}
|
stepId={activeStep.id}
|
||||||
stepId={activeStep.id}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Log Area */}
|
{/* Log Area */}
|
||||||
<div className="pt-12 border-t border-dashed border-slate-200 space-y-6">
|
<div className="pt-12 border-t border-dashed border-slate-200 space-y-6">
|
||||||
@@ -2814,6 +2818,13 @@
|
|||||||
const [editProductId, setEditProductId] = useState(null);
|
const [editProductId, setEditProductId] = useState(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [potentialManagerList, setPotentialManagerList] = useState([]);
|
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({
|
const [tenantFormData, setTenantFormData] = useState({
|
||||||
tenant_name: '', representative: '', business_no: '', contact_phone: '', email: '', address: '',
|
tenant_name: '', representative: '', business_no: '', contact_phone: '', email: '', address: '',
|
||||||
@@ -2879,6 +2890,20 @@
|
|||||||
fetchManagers();
|
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 () => {
|
const fetchManagers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('api/sales_tenants.php?action=list_managers');
|
const res = await fetch('api/sales_tenants.php?action=list_managers');
|
||||||
@@ -2947,16 +2972,9 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleManagerAssignment = async (tenantId, currentManagerId) => {
|
const handleUpdateManagerAssignment = async (tenantId, targetManagerId) => {
|
||||||
const isAssigning = !currentManagerId;
|
|
||||||
const confirmMsg = isAssigning
|
|
||||||
? '본인이 이 테넌트의 업무 프로세스(매니저 역할)를 직접 수행하시겠습니까?'
|
|
||||||
: '이 테넌트의 매니저 지정을 취소하시겠습니까? (다른 매니저가 다시 맡을 수 있게 됩니다)';
|
|
||||||
|
|
||||||
if (!confirm(confirmMsg)) return;
|
|
||||||
|
|
||||||
if (!currentUser || !currentUser.id) {
|
if (!currentUser || !currentUser.id) {
|
||||||
alert('로그인 정보가 유효하지 않습니다. 다시 로그인해주세요.');
|
alert('로그인 정보가 유효하지 않습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2966,22 +2984,21 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tenant_id: tenantId,
|
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();
|
const result = await res.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await fetchData(); // Wait for data to refresh
|
await fetchData();
|
||||||
alert(result.message);
|
setActiveManagerPopover(null);
|
||||||
|
if (result.message) alert(result.message);
|
||||||
} else {
|
} else {
|
||||||
alert(result.error || '처리에 실패했습니다.');
|
alert(result.error || '업데이트 실패');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Update manager error:', err);
|
console.error('Assignment error:', err);
|
||||||
alert('처리 중 오류가 발생했습니다: ' + err.message);
|
alert('서버와 통신하는 중 오류가 발생했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3189,34 +3206,75 @@
|
|||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<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 ? (
|
<div className="relative">
|
||||||
t.sales_manager_id == currentUser.id ? (
|
{t.sales_manager_id ? (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleToggleManagerAssignment(t.id, t.sales_manager_id); }}
|
onClick={(e) => { e.stopPropagation(); if(currentRole==='영업관리') setActiveManagerPopover(t.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"
|
className={`px-2 py-0.5 rounded text-[10px] font-bold border transition-all flex items-center gap-1 ${
|
||||||
title="매니저 업무 취소"
|
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="user-check" className="w-2.5 h-2.5" />
|
||||||
관리: 본인
|
관리: {t.sales_manager_id == currentUser.id ? '본인' : (t.manager_name || '지정됨')}
|
||||||
<LucideIcon name="x" className="w-2 h-2 opacity-0 group-hover:opacity-100 transition-opacity" />
|
{currentRole === '영업관리' && <LucideIcon name="chevron-down" className="w-2 h-2" />}
|
||||||
</button>
|
</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">
|
currentRole === '영업관리' && (
|
||||||
<LucideIcon name="user" className="w-2.5 h-2.5" />
|
<button
|
||||||
관리: {t.manager_name}
|
onClick={(e) => { e.stopPropagation(); setActiveManagerPopover(t.id); }}
|
||||||
</span>
|
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" />
|
||||||
currentRole === '영업관리' && (
|
관리: 미지정
|
||||||
<button
|
<LucideIcon name="chevron-down" className="w-2 h-2" />
|
||||||
onClick={(e) => { e.stopPropagation(); handleToggleManagerAssignment(t.id, null); }}
|
</button>
|
||||||
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"
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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" />
|
<div className="px-4 py-2 border-b border-slate-50">
|
||||||
+ 매니저 직접수행
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">매니저 지정/변경</div>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</td>
|
</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-slate-400 text-xs">{t.created_at?.split(' ')[0]}</td>
|
||||||
@@ -3386,7 +3444,7 @@
|
|||||||
onChange={e => setTenantFormData({...tenantFormData, sales_manager_id: e.target.value})}
|
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"
|
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}>
|
<option key={m.id} value={m.id}>
|
||||||
{m.name} ({m.role}) {m.id === currentUser.id ? '- 본인' : ''}
|
{m.name} ({m.role}) {m.id === currentUser.id ? '- 본인' : ''}
|
||||||
</option>
|
</option>
|
||||||
@@ -3578,6 +3636,7 @@
|
|||||||
{/* Scenario Modals */}
|
{/* Scenario Modals */}
|
||||||
{activeSalesScenarioTenant && (
|
{activeSalesScenarioTenant && (
|
||||||
<ManagerScenarioView
|
<ManagerScenarioView
|
||||||
|
key={`sales_${activeSalesScenarioTenant.id}`}
|
||||||
tenant={activeSalesScenarioTenant}
|
tenant={activeSalesScenarioTenant}
|
||||||
scenarioType="sales"
|
scenarioType="sales"
|
||||||
onClose={() => setActiveSalesScenarioTenant(null)}
|
onClose={() => setActiveSalesScenarioTenant(null)}
|
||||||
@@ -3590,6 +3649,7 @@
|
|||||||
)}
|
)}
|
||||||
{activeManagerScenarioTenant && (
|
{activeManagerScenarioTenant && (
|
||||||
<ManagerScenarioView
|
<ManagerScenarioView
|
||||||
|
key={`manager_${activeManagerScenarioTenant.id}`}
|
||||||
tenant={activeManagerScenarioTenant}
|
tenant={activeManagerScenarioTenant}
|
||||||
scenarioType="manager"
|
scenarioType="manager"
|
||||||
onClose={() => setActiveManagerScenarioTenant(null)}
|
onClose={() => setActiveManagerScenarioTenant(null)}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user