testsprite 전략 수립

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

14
debug_db.php Normal file
View 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
View 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>&copy; 2025 AI Engineering Helper. SAM Strategy Briefing.</p>
</div>
</footer>
<script src="script.js"></script>
</body>
</html>

146
helper/script.js Normal file
View 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
View 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
View 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 "🎉 모든 테이블 처리 완료"

View File

@@ -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 = ?");

View File

@@ -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)}