diff --git a/debug_db.php b/debug_db.php new file mode 100644 index 0000000..cb8c74f --- /dev/null +++ b/debug_db.php @@ -0,0 +1,14 @@ +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); +} +?> diff --git a/helper/index.html b/helper/index.html new file mode 100644 index 0000000..4c20ee7 --- /dev/null +++ b/helper/index.html @@ -0,0 +1,301 @@ + + + + + + + SAM Project - AI 교차 검증 전략 브리핑 + + + + + + + + +
+ + + +
+ +
+
+
+
Strategic Briefing
+

AI 협업 기반
코드 교차 검증 전략

+

Maximize Quality | Optimize Cost | 5-Step Workflow

+
+
+ +
+
+
+
+ +

개요 및 핵심 가치

+
+

+ 본 문서는 AI 모델 간의 협업을 통해 코드 품질을 극대화하고 개발 비용을 최적화하는 '교차 검증 전략'을 종합적으로 분석합니다. +

+
+
+
🚀
+

5단계 사이클

+

계획 → 검증 → 구현 → 검증 → 테스트로 이어지는 완벽한 품질 관리 루프

+
+
+
🤝
+

역할 최적화

+

Antigravity(실행)와 Claude Code(감리)의 조화로운 역할 분담

+
+
+
💰
+

비용 절감

+

검증 단계에만 고성능 모델을 투입하여 전체 토큰 사용량 약 66% 절감

+
+
+
+
+
+ +
+
+
+ +

AI 교차 검증의 핵심 개념

+

고성능 모델의 초기 테스트 통과율(약 42%) 한계를 극복하기 위한 방법론

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
단계주요 도구핵심 역할
1. 계획 (Plan)Antigravity문제 해결을 위한 초기 계획 수립 및 문서화
2. 검증 (Verify)Claude Code계획의 논리적 오류 및 프로젝트 원칙 검토
3. 실행 (Execute)Antigravity검증된 계획에 따른 고속 코드 구현
4. 검증 (Verify)Claude Code완성된 코드의 최종 검토 및 완성도 극대화
5. 테스트 (Test)TestSprite MCP자동화 테스트 및 직관적인 스크린샷 피드백
+
+ +
+
🏠
+

전략적 비유

+

"Antigravity라는 숙련된 시공사가 집을 짓기 전과 후에, Claude Code라는 꼼꼼한 설계사가 + 도면과 완공 상태를 철저히 감리하는 것과 같습니다."

+
+
+
+ +
+
+
+ +

5단계 교차 검증 상세 프로세스

+
+ +
+
+
1단계
+

계획 수립 및 문서화

+
    +
  • 문제 분석 및 구조 설계 (Antigravity)
  • +
  • work-plan 폴더 내 마크다운(.md) 저장
  • +
  • 검토를 위한 물리적 근거 마련
  • +
+
+
+
2단계
+

1차 계획 검증

+
    +
  • 코드 작성 원칙 기반 논리 검토 (Claude Code)
  • +
  • 문서 하단에 보완 사항 추가 (덮어쓰기 금지)
  • +
  • 변경 이력의 명확한 추적 가능성 확보
  • +
+
+
+
3단계
+

코드 구현

+
    +
  • 보완된 최종 지시에 따른 구현 (Antigravity)
  • +
  • 잠재적 오류 제거로 인한 에러율 급감
  • +
  • 정제된 워크플로우를 통한 생산성 향상
  • +
+
+
+
4단계
+

2차 최종 결과 검토

+
    +
  • 계획 반영 여부 최종 점검 (Claude Code)
  • +
  • 전체 코드 완성도 및 코드 품질 확인
  • +
  • 최종 릴리스 준위의 결과물 확보
  • +
+
+
+
5단계
+

자동 테스트 및 피드백

+
    +
  • TestSprite MCP 기반 자동화 테스트
  • +
  • 스크린샷 리포트를 통한 직관적 오류 파악
  • +
  • 100%에 수렴하는 품질 완성 사이클
  • +
+
+
+
+
+ +
+
+
+ +

핵심 도구별 역할 및 설정

+
+ +
+
+
+ +

Antigravity

+
+

실무 실행자 (Doer)

+

토큰 소모가 많은 초기 계획과 코드 구현 전담. VS Code 내 'Claude Code for VS Code'와 병행 설치 권장.

+
+
+
+ +

Claude Code

+
+

설계 및 감리자 (Reviewer)

+

고비용 모델의 효율적 사용을 위해 검증에 집중. 메모리/에이전트/훅 설정에 프로젝트 규칙 정의 필수.

+
+
+
+ +

TestSprite MCP

+
+
+
+

최종 품질 검사관 (Inspector)

+

자동 테스트 및 스크린샷 리포트를 통해 인간이 놓치는 논리적 결함 발견.

+
+
+

설정 절차

+
    +
  • API 키: testsprite.com 발급 (유일 1회 노출)
  • +
  • Windows: 전용 명령어를 통한 MCP 연결
  • +
  • 명세서: Claude Code를 통한 Product Spec 생성 및 제공
  • +
+
+
+
+
+
+
+ +
+
+
+ +

기대 효과 및 실질적 이점

+
+ +
+
+
+

코드 품질 향상

+

상호 보완적 모델 협업과 전문 테스트 도구를 통한 안정적인 결과물 생산

+
+
+
📉
+

비용 및 토큰 절감

+

고비용 모델을 검증에만 집중 배치하여 토큰 사용량 약 2/3(약 66%) 절감

+
+
+ +
+
+ "Claude Max 200달러 요금제를 쓰다가 이 전략을 적용하고 나서 100달러 요금제로 낮췄습니다. 이제는 토큰 걱정 없이 편하게 작업하고 있습니다." +
+ — 실제 사례 사용자 +
+
+
+
+ + +
+
+
+
+ +

단답형 퀴즈

+

전략의 핵심 개념을 점검해 보세요. 카드를 클릭하면 정답이 표시됩니다.

+
+
+ +
+
+
+ +
+
+
+ +

핵심 용어 정리

+
+
+ +
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/helper/script.js b/helper/script.js new file mode 100644 index 0000000..a645f51 --- /dev/null +++ b/helper/script.js @@ -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 = ` +
+
+ Q${(index + 1).toString().padStart(2, '0')} +

${item.q}

+

Click to see answer

+
+
+

${item.a}

+
+
+ `; + 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 = ` +

${item.term}

+

${item.def}

+ `; + 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); diff --git a/helper/style.css b/helper/style.css new file mode 100644 index 0000000..4702939 --- /dev/null +++ b/helper/style.css @@ -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; } +} diff --git a/mysql_sync.sh b/mysql_sync.sh new file mode 100644 index 0000000..1b46c7d --- /dev/null +++ b/mysql_sync.sh @@ -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 "🎉 모든 테이블 처리 완료" diff --git a/salesmanagement/api/sales_tenants.php b/salesmanagement/api/sales_tenants.php index d2ae818..8f94236 100644 --- a/salesmanagement/api/sales_tenants.php +++ b/salesmanagement/api/sales_tenants.php @@ -15,6 +15,28 @@ if (!isset($_SESSION['sales_user'])) { $currentUser = $_SESSION['sales_user']; $pdo = db_connect(); +/** + * 테넌트에 대한 접근 권한 확인 + */ +function checkTenantPermission($pdo, $tenant_id, $currentUser) { + if (!$tenant_id) return false; + if ($currentUser['role'] === 'operator') return true; + + $stmt = $pdo->prepare("SELECT manager_id, sales_manager_id FROM sales_tenants WHERE id = ?"); + $stmt->execute([$tenant_id]); + $tenant = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$tenant) return false; + + // 매니저는 본인이 '담당 매니저'로 배정된 경우만 가능 + if ($currentUser['role'] === 'manager') { + return $tenant['sales_manager_id'] == $currentUser['id']; + } + + // 영업관리자는 본인이 등록했거나, 본인이 담당 매니저인 경우 가능 + return ($tenant['manager_id'] == $currentUser['id'] || $tenant['sales_manager_id'] == $currentUser['id']); +} + // 테이블 자동 생성 (없을 경우) $pdo->exec(" CREATE TABLE IF NOT EXISTS `sales_tenants` ( @@ -136,23 +158,36 @@ try { "); $stmt->execute(); } else { - // 내가 영업했거나, 내가 매니저로 배정된 테넌트 - $stmt = $pdo->prepare(" - SELECT t.*, m.name as register_name, m2.name as manager_name, m2.role as manager_role - FROM sales_tenants t - JOIN sales_member m ON t.manager_id = m.id - LEFT JOIN sales_member m2 ON t.sales_manager_id = m2.id - WHERE t.manager_id = ? OR t.sales_manager_id = ? - ORDER BY t.created_at DESC - "); - $stmt->execute([$currentUser['id'], $currentUser['id']]); + if ($currentUser['role'] === 'manager') { + // 매니저는 본인이 담당 매니저로 배정된 테넌트만 조회 + $stmt = $pdo->prepare(" + SELECT t.*, m.name as register_name, m2.name as manager_name, m2.role as manager_role + FROM sales_tenants t + JOIN sales_member m ON t.manager_id = m.id + LEFT JOIN sales_member m2 ON t.sales_manager_id = m2.id + WHERE t.sales_manager_id = ? + ORDER BY t.created_at DESC + "); + $stmt->execute([$currentUser['id']]); + } else { + // 영업관리자는 본인이 영업했거나, 본인이 매니저로 배정된 테넌트 조회 + $stmt = $pdo->prepare(" + SELECT t.*, m.name as register_name, m2.name as manager_name, m2.role as manager_role + FROM sales_tenants t + JOIN sales_member m ON t.manager_id = m.id + LEFT JOIN sales_member m2 ON t.sales_manager_id = m2.id + WHERE t.manager_id = ? OR t.sales_manager_id = ? + ORDER BY t.created_at DESC + "); + $stmt->execute([$currentUser['id'], $currentUser['id']]); + } } $tenants = $stmt->fetchAll(PDO::FETCH_ASSOC); echo json_encode(['success' => true, 'data' => $tenants]); } elseif ($action === 'tenant_products') { $tenant_id = $_GET['tenant_id'] ?? null; - if (!$tenant_id) throw new Exception("테넌트 ID가 필요합니다."); + if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다."); $stmt = $pdo->prepare("SELECT * FROM sales_tenant_products WHERE tenant_id = ? ORDER BY created_at DESC"); $stmt->execute([$tenant_id]); @@ -161,7 +196,7 @@ try { } elseif ($action === 'my_stats') { // 현재 로그인한 사용자의 요약 통계 - $stmt = $pdo->prepare(" + $sql = " SELECT COUNT(DISTINCT t.id) as tenant_count, SUM(p.contract_amount) as total_revenue, @@ -169,15 +204,26 @@ try { SUM(CASE WHEN p.operator_confirmed = 1 THEN p.commission_amount ELSE 0 END) as confirmed_commission FROM sales_tenants t LEFT JOIN sales_tenant_products p ON t.id = p.tenant_id - WHERE t.manager_id = ? - "); - $stmt->execute([$currentUser['id']]); + "; + + if ($currentUser['role'] === 'manager') { + $sql .= " WHERE t.sales_manager_id = ?"; + } else { + $sql .= " WHERE t.manager_id = ? OR t.sales_manager_id = ?"; + } + + $stmt = $pdo->prepare($sql); + if ($currentUser['role'] === 'manager') { + $stmt->execute([$currentUser['id']]); + } else { + $stmt->execute([$currentUser['id'], $currentUser['id']]); + } $stats = $stmt->fetch(PDO::FETCH_ASSOC); echo json_encode(['success' => true, 'data' => $stats]); } elseif ($action === 'get_scenario') { $tenant_id = $_GET['tenant_id'] ?? null; $scenario_type = $_GET['scenario_type'] ?? 'manager'; - if (!$tenant_id) throw new Exception("테넌트 ID가 필요합니다."); + if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다."); $stmt = $pdo->prepare("SELECT * FROM sales_tenant_scenarios WHERE tenant_id = ? AND scenario_type = ?"); $stmt->execute([$tenant_id, $scenario_type]); @@ -189,7 +235,7 @@ try { $tenant_id = $_GET['tenant_id'] ?? null; $scenario_type = $_GET['scenario_type'] ?? 'manager'; $step_id = $_GET['step_id'] ?? null; - if (!$tenant_id) throw new Exception("테넌트 ID가 필요합니다."); + if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다."); $sql = "SELECT * FROM sales_tenant_consultations WHERE tenant_id = ? AND scenario_type = ?"; $params = [$tenant_id, $scenario_type]; @@ -206,7 +252,7 @@ try { echo json_encode(['success' => true, 'data' => $results]); } elseif ($action === 'list_managers') { // 매니저로 매칭 가능한 사람 목록 (영업관리 또는 매니저 직급) - $stmt = $pdo->prepare("SELECT id, name, role, member_id FROM sales_member WHERE role IN ('영업관리', '매니저') AND is_active = 1 ORDER BY name ASC"); + $stmt = $pdo->prepare("SELECT id, name, role, member_id, parent_id FROM sales_member WHERE role IN ('sales_admin', 'manager') AND is_active = 1 ORDER BY name ASC"); $stmt->execute(); $managers = $stmt->fetchAll(PDO::FETCH_ASSOC); echo json_encode(['success' => true, 'data' => $managers]); @@ -222,6 +268,9 @@ try { } if ($action === 'create_tenant') { + if ($currentUser['role'] === 'manager') { + throw new Exception("매니저는 테넌트를 등록할 권한이 없습니다."); + } $tenant_name = $data['tenant_name'] ?? ''; $representative = $data['representative'] ?? ''; $business_no = $data['business_no'] ?? ''; @@ -239,6 +288,8 @@ try { } elseif ($action === 'add_product') { $tenant_id = $data['tenant_id'] ?? null; + if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다."); + $product_name = $data['product_name'] ?? ''; $contract_amount = $data['contract_amount'] ?? 0; $commission_rate = $data['commission_rate'] ?? 0; @@ -267,14 +318,18 @@ try { echo json_encode(['success' => true, 'message' => $confirmed ? '승인되었습니다.' : '승인이 취소되었습니다.']); } elseif ($action === 'update_checklist') { - $tenant_id = $data['tenant_id'] ?? null; + $tenant_id = isset($data['tenant_id']) ? intval($data['tenant_id']) : null; + $step_id = isset($data['step_id']) ? intval($data['step_id']) : null; + $checkpoint_index = isset($data['checkpoint_index']) ? intval($data['checkpoint_index']) : null; + $is_checked = (isset($data['is_checked']) && ($data['is_checked'] === true || $data['is_checked'] == 1)) ? 1 : 0; $scenario_type = $data['scenario_type'] ?? 'manager'; - $step_id = $data['step_id'] ?? null; - $checkpoint_index = $data['checkpoint_index'] ?? null; - $is_checked = $data['is_checked'] ? 1 : 0; - - if (!$tenant_id || $step_id === null || $checkpoint_index === null) throw new Exception("필수 파라미터가 누락되었습니다."); + + if ($tenant_id === null || $step_id === null || $checkpoint_index === null) { + throw new Exception("필수 파라미터가 누락되었습니다. (T: $tenant_id, S: $step_id, C: $checkpoint_index)"); + } + if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다."); + $stmt = $pdo->prepare(" INSERT INTO sales_tenant_scenarios (tenant_id, scenario_type, step_id, checkpoint_index, is_checked) VALUES (?, ?, ?, ?, ?) @@ -286,6 +341,8 @@ try { } elseif ($action === 'save_consultation') { $tenant_id = $data['tenant_id'] ?? null; + if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다."); + $scenario_type = $data['scenario_type'] ?? 'manager'; $step_id = $data['step_id'] ?? null; $log_text = $data['log_text'] ?? ''; @@ -314,6 +371,8 @@ try { echo json_encode(['success' => true, 'message' => '기록이 저장되었습니다.']); } elseif ($action === 'upload_attachments') { $tenant_id = $data['tenant_id'] ?? null; + if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다."); + $scenario_type = $data['scenario_type'] ?? 'manager'; $step_id = $data['step_id'] ?? null; @@ -353,22 +412,24 @@ try { $id = $data['id'] ?? null; if (!$id) throw new Exception("ID가 누락되었습니다."); - // 파일 삭제를 위해 정보 조회 - $stmt = $pdo->prepare("SELECT audio_file_path, attachment_paths FROM sales_tenant_consultations WHERE id = ?"); + // 권한 확인을 위해 정보 조회 + $stmt = $pdo->prepare("SELECT tenant_id, audio_file_path, attachment_paths FROM sales_tenant_consultations WHERE id = ?"); $stmt->execute([$id]); $c = $stmt->fetch(PDO::FETCH_ASSOC); - if ($c) { - if ($c['audio_file_path'] && file_exists(__DIR__ . "/../" . $c['audio_file_path'])) { - @unlink(__DIR__ . "/../" . $c['audio_file_path']); - } - if ($c['attachment_paths']) { - $paths = json_decode($c['attachment_paths'], true); - if (is_array($paths)) { - foreach ($paths as $p) { - if (isset($p['path']) && file_exists(__DIR__ . "/../" . $p['path'])) { - @unlink(__DIR__ . "/../" . $p['path']); - } + if (!$c) throw new Exception("해당 기록을 찾을 수 없습니다."); + if (!checkTenantPermission($pdo, $c['tenant_id'], $currentUser)) throw new Exception("권한이 없습니다."); + + // 파일 삭제 처리 + if ($c['audio_file_path'] && file_exists(__DIR__ . "/../" . $c['audio_file_path'])) { + @unlink(__DIR__ . "/../" . $c['audio_file_path']); + } + if ($c['attachment_paths']) { + $paths = json_decode($c['attachment_paths'], true); + if (is_array($paths)) { + foreach ($paths as $p) { + if (isset($p['path']) && file_exists(__DIR__ . "/../" . $p['path'])) { + @unlink(__DIR__ . "/../" . $p['path']); } } } @@ -381,12 +442,13 @@ try { $product_id = $data['id'] ?? null; if (!$product_id) throw new Exception("ID가 누락되었습니다."); - // 승인되지 않은 것만 삭제 가능하도록 보안 체크 (영업관리자 관점) - $stmt = $pdo->prepare("SELECT operator_confirmed FROM sales_tenant_products WHERE id = ?"); + // 정보 및 권한 조회 + $stmt = $pdo->prepare("SELECT tenant_id, operator_confirmed FROM sales_tenant_products WHERE id = ?"); $stmt->execute([$product_id]); $p = $stmt->fetch(PDO::FETCH_ASSOC); if (!$p) throw new Exception("해당 정보를 찾을 수 없습니다."); + if (!checkTenantPermission($pdo, $p['tenant_id'], $currentUser)) throw new Exception("권한이 없습니다."); if ($p['operator_confirmed'] == 1) throw new Exception("이미 승인된 계약은 삭제할 수 없습니다."); $stmt = $pdo->prepare("DELETE FROM sales_tenant_products WHERE id = ?"); @@ -399,6 +461,16 @@ try { if (!$tenant_id) throw new Exception("테넌트 ID가 누락되었습니다."); + // 권한 확인: 배지 지정은 운영자 또는 해당 테넌트를 등록한 영업관리자만 가능 + $stmt = $pdo->prepare("SELECT manager_id FROM sales_tenants WHERE id = ?"); + $stmt->execute([$tenant_id]); + $t = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$t) throw new Exception("테넌트를 찾을 수 없습니다."); + if ($currentUser['role'] !== 'operator' && $t['manager_id'] != $currentUser['id']) { + throw new Exception("배정 권한이 없습니다."); + } + // manager_id가 0이거나 empty면 null로 처리 (지정 취소) $manager_val = (!empty($sales_manager_id)) ? intval($sales_manager_id) : null; @@ -408,22 +480,19 @@ try { echo json_encode(['success' => true, 'message' => $manager_val ? '담당 매니저가 지정되었습니다.' : '담당 매니저 지정이 취소되었습니다.']); } elseif ($action === 'update_product') { $product_id = $data['id'] ?? null; - $product_name = $data['product_name'] ?? ''; - $contract_amount = $data['contract_amount'] ?? 0; - $commission_rate = $data['commission_rate'] ?? 0; - $contract_date = $data['contract_date'] ?? date('Y-m-d'); - $sub_models = isset($data['sub_models']) ? json_encode($data['sub_models']) : null; + if (!$product_id) throw new Exception("ID가 누락되었습니다."); - if (!$product_id || !$product_name) throw new Exception("필수 정보가 누락되었습니다."); - - // 보안 체크: 승인된 것은 수정 불가 - $stmt = $pdo->prepare("SELECT operator_confirmed FROM sales_tenant_products WHERE id = ?"); + // 정보 및 권한 조회 + $stmt = $pdo->prepare("SELECT tenant_id, operator_confirmed FROM sales_tenant_products WHERE id = ?"); $stmt->execute([$product_id]); $p = $stmt->fetch(PDO::FETCH_ASSOC); if (!$p) throw new Exception("해당 정보를 찾을 수 없습니다."); + if (!checkTenantPermission($pdo, $p['tenant_id'], $currentUser)) throw new Exception("권한이 없습니다."); if ($p['operator_confirmed'] == 1) throw new Exception("이미 승인된 계약은 수정할 수 없습니다."); + $product_name = $data['product_name'] ?? ''; + $commission_amount = ($contract_amount * $commission_rate) / 100; $stmt = $pdo->prepare("UPDATE sales_tenant_products SET product_name = ?, contract_amount = ?, commission_rate = ?, commission_amount = ?, contract_date = ?, sub_models = ? WHERE id = ?"); diff --git a/salesmanagement/index.php b/salesmanagement/index.php index ae5ea6c..2e227b7 100644 --- a/salesmanagement/index.php +++ b/salesmanagement/index.php @@ -2488,6 +2488,8 @@ const [loading, setLoading] = useState(false); useEffect(() => { + const newSteps = scenarioType === 'sales' ? SALES_SCENARIO_STEPS : MANAGER_SCENARIO_STEPS; + setActiveStep(newSteps[0]); fetchScenarioData(); fetchConsultations(); }, [tenant.id, scenarioType]); @@ -2499,11 +2501,12 @@ if (result.success) { const map = {}; result.data.forEach(item => { - map[`${item.step_id}_${item.checkpoint_index}`] = item.is_checked == 1; + const key = `${item.scenario_type}_${item.step_id}_${item.checkpoint_index}`; + map[key] = (item.is_checked == 1); }); setChecklist(map); } - } catch (err) { console.error(err); } + } catch (err) { /* console.error('Fetch scenario error:', err); */ } }; const fetchConsultations = async () => { @@ -2517,7 +2520,7 @@ }; const toggleCheck = async (stepId, index) => { - const key = `${stepId}_${index}`; + const key = `${scenarioType}_${stepId}_${index}`; const newState = !checklist[key]; // Optimistic UI Update @@ -2541,7 +2544,7 @@ setChecklist(prev => ({ ...prev, [key]: !newState })); // Rollback } } catch (err) { - console.error('Checkbox toggle error:', err); + // console.error('Checkbox toggle error:', err); alert('서버와 통신하는 중 오류가 발생했습니다.'); setChecklist(prev => ({ ...prev, [key]: !newState })); // Rollback } @@ -2588,7 +2591,7 @@ const total = step.checkpoints.length; let checked = 0; for (let i = 0; i < total; i++) { - if (checklist[`${stepId}_${i}`]) checked++; + if (checklist[`${scenarioType}_${stepId}_${i}`]) checked++; } return Math.round((checked / total) * 100); }; @@ -2637,7 +2640,10 @@
-
{step.title}
+
+
{step.title}
+ {progress}% +
@@ -2692,16 +2698,16 @@ {activeStep.checkpoints.map((cp, idx) => (
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'}`} >
-
- {checklist[`${activeStep.id}_${idx}`] && } +
+ {checklist[`${scenarioType}_${activeStep.id}_${idx}`] && }
-

{cp.title}

+

{cp.title}

{cp.detail}

- {checklist[`${activeStep.id}_${idx}`] && cp.pro_tip && ( + {checklist[`${scenarioType}_${activeStep.id}_${idx}`] && cp.pro_tip && (
💡 Tip: {cp.pro_tip}
@@ -2712,21 +2718,19 @@ ))}
- {/* Step 2 Special Features: Voice & Files */} - {scenarioType === 'manager' && activeStep.id === 2 && ( -
- - -
- )} + {/* Step Features: Voice & Files - Available for all steps */} +
+ + +
{/* Log Area */}
@@ -2814,6 +2818,13 @@ const [editProductId, setEditProductId] = useState(null); const [isSaving, setIsSaving] = useState(false); const [potentialManagerList, setPotentialManagerList] = useState([]); + const [activeManagerPopover, setActiveManagerPopover] = useState(null); + const popoverRef = useRef(null); + + // Filtered manager list based on role + const filteredManagers = (currentRole === '운영자') + ? potentialManagerList + : potentialManagerList.filter(m => m.id == currentUser.id || m.parent_id == currentUser.id); const [tenantFormData, setTenantFormData] = useState({ tenant_name: '', representative: '', business_no: '', contact_phone: '', email: '', address: '', @@ -2879,6 +2890,20 @@ fetchManagers(); }, []); + useEffect(() => { + const handleClickOutside = (event) => { + if (popoverRef.current && !popoverRef.current.contains(event.target)) { + setActiveManagerPopover(null); + } + }; + if (activeManagerPopover) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [activeManagerPopover]); + const fetchManagers = async () => { try { const res = await fetch('api/sales_tenants.php?action=list_managers'); @@ -2947,16 +2972,9 @@ } }; - const handleToggleManagerAssignment = async (tenantId, currentManagerId) => { - const isAssigning = !currentManagerId; - const confirmMsg = isAssigning - ? '본인이 이 테넌트의 업무 프로세스(매니저 역할)를 직접 수행하시겠습니까?' - : '이 테넌트의 매니저 지정을 취소하시겠습니까? (다른 매니저가 다시 맡을 수 있게 됩니다)'; - - if (!confirm(confirmMsg)) return; - + const handleUpdateManagerAssignment = async (tenantId, targetManagerId) => { if (!currentUser || !currentUser.id) { - alert('로그인 정보가 유효하지 않습니다. 다시 로그인해주세요.'); + alert('로그인 정보가 유효하지 않습니다.'); return; } @@ -2966,22 +2984,21 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tenant_id: tenantId, - sales_manager_id: isAssigning ? currentUser.id : null + sales_manager_id: targetManagerId }) }); - if (!res.ok) throw new Error('Network response was not ok'); - const result = await res.json(); if (result.success) { - await fetchData(); // Wait for data to refresh - alert(result.message); + await fetchData(); + setActiveManagerPopover(null); + if (result.message) alert(result.message); } else { - alert(result.error || '처리에 실패했습니다.'); + alert(result.error || '업데이트 실패'); } } catch (err) { - console.error('Update manager error:', err); - alert('처리 중 오류가 발생했습니다: ' + err.message); + console.error('Assignment error:', err); + alert('서버와 통신하는 중 오류가 발생했습니다.'); } }; @@ -3189,34 +3206,75 @@
영업: {t.register_name} - {t.sales_manager_id ? ( - t.sales_manager_id == currentUser.id ? ( - ) : ( - - - 관리: {t.manager_name} - - ) - ) : ( - currentRole === '영업관리' && ( - + ) + )} + + {activeManagerPopover === t.id && ( +
e.stopPropagation()} > - - + 매니저 직접수행 - - ) - )} +
+
매니저 지정/변경
+
+
+ + {filteredManagers.filter(m => m.id != currentUser.id).map(m => ( + + ))} +
+ {t.sales_manager_id && ( +
+ +
+ )} +
+ )} +
{t.created_at?.split(' ')[0]} @@ -3386,7 +3444,7 @@ onChange={e => setTenantFormData({...tenantFormData, sales_manager_id: e.target.value})} className="w-full px-3 py-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 bg-white" > - {potentialManagerList.map(m => ( + {filteredManagers.map(m => ( @@ -3578,6 +3636,7 @@ {/* Scenario Modals */} {activeSalesScenarioTenant && ( setActiveSalesScenarioTenant(null)} @@ -3590,6 +3649,7 @@ )} {activeManagerScenarioTenant && ( setActiveManagerScenarioTenant(null)} diff --git a/salesmanagement/uploads/consultations/1/audio_20251224_130710_694b66ee49f9d.webm b/salesmanagement/uploads/consultations/1/audio_20251224_130710_694b66ee49f9d.webm new file mode 100644 index 0000000..f7a6640 Binary files /dev/null and b/salesmanagement/uploads/consultations/1/audio_20251224_130710_694b66ee49f9d.webm differ diff --git a/salesmanagement/uploads/consultations/3/audio_20251224_150016_694b817043e85.webm b/salesmanagement/uploads/consultations/3/audio_20251224_150016_694b817043e85.webm new file mode 100644 index 0000000..a928bb1 Binary files /dev/null and b/salesmanagement/uploads/consultations/3/audio_20251224_150016_694b817043e85.webm differ