first commit

This commit is contained in:
2025-12-17 12:59:26 +09:00
commit 4782df3a50
27 changed files with 8686 additions and 0 deletions

142
.gitignore vendored Normal file
View File

@@ -0,0 +1,142 @@
############################################
# Laravel
############################################
/vendor/
/node_modules/
/bootstrap/cache/
/storage/*.key
/storage/app/*
/storage/framework/*
/storage/logs/*
!storage/.gitignore
.env
.env.*
.phpunit.result.cache
Homestead.yaml
Homestead.json
npm-debug.log
yarn-error.log
vite.config.js
vite.config.ts
public/storage
public/hot
public/mix-manifest.json
public/build/
/storage/pail/
public/js/*.map
public/css/*.map
############################################
# IDE - PhpStorm
############################################
.idea/
/*.iml
*.iws
*.ipr
############################################
# IDE - VS Code
############################################
.vscode/
############################################
# IDE - Cursor AI
############################################
.cursor/
############################################
# OS & 에디터 임시 파일
############################################
.DS_Store
Thumbs.db
ehthumbs.db
desktop.ini
*.swp
*.swo
*.tmp
*.bak
*.old
*.orig
############################################
# 로그, 백업, 덤프
############################################
*.log
*.sql
*.sqlite
*.db
*.tar
*.gz
*.zip
*.7z
*.backup
# 프로젝트 내 백업 폴더
/backup/
/backups/
############################################
# 이미지, 문서, 동영상 등 업로드 제외
*.jpg
*.jpeg
*.png
*.gif
*.bmp
*.svg
*.webp
*.ico
*.pdf
*.doc
*.docx
*.xls
*.xlsx
*.ppt
*.pptx
*.hwp
*.mp3
*.wav
*.ogg
*.mp4
*.avi
*.mov
*.wmv
*.mkv
############################################
# JetBrains Fleet / Laravel Nova / Zed IDE
############################################
/.fleet/
/.nova/
/.zed/
############################################
# PHP 도구 및 설정 파일
############################################
/.phpactor.json
/auth.json
/.phpunit.cache
############################################
# 기타
############################################
.env.local
.env.backup
*.cache
*.coverage
*.out
*.pid
*.seed
*.seed.php
_ide_helper.php
_ide_helper_models.php
# 모든 위치의 data 폴더 내부 파일 무시
**/data/*
# 단, 폴더 자체는 추적 (비어 있어도 gitkeep을 위해)
!**/data/
# 그리고 .gitkeep은 예외로 추적
!**/data/.gitkeep

583
index.php Normal file
View File

@@ -0,0 +1,583 @@
<?php require_once 'session.php'; ?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CodeBridge-X SAM - 영업관리 </title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'Noto Sans KR', 'sans-serif'],
},
colors: {
brand: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
900: '#0c4a6e',
}
},
animation: {
blob: "blob 7s infinite",
'fade-in-up': 'fadeInUp 0.8s ease-out forwards',
},
keyframes: {
blob: {
"0%": { transform: "translate(0px, 0px) scale(1)" },
"33%": { transform: "translate(30px, -50px) scale(1.1)" },
"66%": { transform: "translate(-20px, 20px) scale(0.9)" },
"100%": { transform: "translate(0px, 0px) scale(1)" },
},
fadeInUp: {
'0%': { opacity: '0', transform: 'translateY(20px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
}
},
}
}
}
</script>
<style>
.delay-100 { animation-delay: 100ms; }
.delay-200 { animation-delay: 200ms; }
.delay-300 { animation-delay: 300ms; }
.animation-delay-2000 { animation-delay: 2s; }
.animation-delay-4000 { animation-delay: 4s; }
/* Hide scrollbar for clean modal */
.no-scroll { overflow: hidden; }
</style>
</head>
<body class="bg-slate-50 text-slate-900 font-sans selection:bg-brand-200 selection:text-brand-900">
<!-- Toast Notification -->
<div id="toast" class="fixed top-24 right-4 z-[60] bg-slate-800 text-white px-6 py-4 rounded-xl shadow-2xl flex items-center gap-3 transition-all duration-500 transform translate-x-full opacity-0">
<i data-lucide="check-circle-2" class="text-green-400 w-6 h-6"></i>
<div>
<h4 class="font-bold text-sm">다운로드 시작됨</h4>
<p class="text-slate-400 text-xs">CodeBridgeX_Proposal_v2.4.pdf</p>
</div>
</div>
<!-- Navigation -->
<nav class="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center gap-3 cursor-pointer" onclick="filterAssets('All')">
<div class="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-lg shadow-lg shadow-brand-200">
S
</div>
<span class="text-xl font-bold tracking-tight text-slate-900">CodeBridgeX <span class="text-brand-600">SAM</span></span>
</div>
<div class="flex items-center gap-4">
<a href="sales_scenario/" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">영업 시나리오</a>
<a href="sales_manager_scenario/" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">매니저 시나리오</a>
<a href="salesmanagement/" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">영업관리</a>
<?php if (isset($_SESSION['userid']) && $_SESSION['userid'] != ''): ?>
<div class="hidden sm:flex items-center gap-2 ml-2">
<div class="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 font-bold border border-slate-300">
<?= mb_substr($_SESSION['name'], 0, 1) ?>
</div>
<span class="text-sm font-medium text-slate-700"><?= $_SESSION['name'] ?>님</span>
<a href="login/logout.php" class="px-3 py-1.5 bg-slate-100 text-slate-600 text-xs font-bold rounded hover:bg-slate-200 transition-colors border border-slate-200">로그아웃</a>
</div>
<?php else: ?>
<a href="login/login_form.php" class="px-4 py-2 bg-brand-600 text-white text-sm font-bold rounded-lg hover:bg-brand-700 transition-colors shadow-lg shadow-brand-200">
로그인
</a>
<?php endif; ?>
</div>
</div>
</div>
</nav>
<!-- Hero Section -->
<header class="relative bg-white pt-16 pb-20 lg:pt-24 lg:pb-28 overflow-hidden">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div class="text-center max-w-4xl mx-auto">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-50 text-brand-700 text-xs font-semibold uppercase tracking-wider mb-6 animate-fade-in-up">
<i data-lucide="shield-check" class="w-4 h-4"></i>
CEO Management Solution
</div>
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-slate-900 tracking-tight mb-6 animate-fade-in-up delay-100">
직원 관리 도구가 아닙니다.<br />
<span class="text-transparent bg-clip-text bg-gradient-to-r from-brand-600 to-indigo-600">
대표님 경영 무기입니다.
</span>
</h1>
<p class="text-lg sm:text-xl text-slate-500 mb-10 leading-relaxed max-w-2xl mx-auto animate-fade-in-up delay-200">
"SAM"은 단순한 ERP가 아닙니다. <br class="hidden sm:block"/>
가지급금 이자 계산부터 실시간 경영 알림까지.<br />
<strong>오직 CEO를 위한 시크릿 대시보드</strong>를 제안하십시오.
</p>
<div class="flex justify-center gap-4 animate-fade-in-up delay-300 relative z-20">
<button onclick="showDownloadToast()" class="px-8 py-3 rounded-xl bg-slate-900 text-white font-semibold hover:bg-slate-800 hover:shadow-lg transition-all transform hover:-translate-y-1 active:scale-95">
제안서 다운로드
</button>
<button onclick="filterAssets('Demo')" class="px-8 py-3 rounded-xl bg-white text-slate-700 border border-slate-200 font-semibold hover:bg-slate-50 hover:border-slate-300 transition-all flex items-center gap-2 active:scale-95">
<i data-lucide="layout-grid" class="w-5 h-5"></i>
세일즈 덱 보기
</button>
</div>
<!-- Main Hero Image -->
<div class="mt-16 relative z-10 animate-fade-in-up delay-300">
<div class="relative rounded-2xl overflow-hidden shadow-2xl border-4 border-white/50 bg-white">
<img src="img/sam_project.jpg" alt="SAM Project Dashboard" class="w-full h-auto object-cover">
<div class="absolute inset-0 bg-gradient-to-t from-slate-900/10 to-transparent pointer-events-none"></div>
</div>
<!-- Decorative Elements around image -->
<div class="absolute -top-10 -right-10 w-24 h-24 bg-brand-400 rounded-full mix-blend-multiply filter blur-2xl opacity-40 animate-blob"></div>
<div class="absolute -bottom-10 -left-10 w-24 h-24 bg-indigo-400 rounded-full mix-blend-multiply filter blur-2xl opacity-40 animate-blob animation-delay-2000"></div>
</div>
</div>
</div>
<!-- Background Decorations -->
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full z-0 pointer-events-none">
<div class="absolute top-20 left-10 w-72 h-72 bg-brand-200 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
<div class="absolute top-20 right-10 w-72 h-72 bg-indigo-200 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
<div class="absolute -bottom-8 left-1/2 w-72 h-72 bg-pink-200 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-4000"></div>
</div>
</header>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="flex flex-col sm:flex-row items-center justify-between mb-8 gap-4">
<h2 class="text-2xl font-bold text-slate-900">영업 자료</h2>
<div id="filter-buttons" class="flex flex-wrap gap-2 justify-center">
<!-- Filter buttons injected by JS -->
</div>
</div>
<!-- Grid Container -->
<div id="assets-grid" class="grid grid-cols-1 md:grid-cols-3 gap-6 auto-rows-auto">
<!-- Cards injected by JS -->
</div>
<div id="empty-state" class="hidden text-center py-20 text-slate-400 col-span-3">
해당 카테고리에 자료가 없습니다.
</div>
</main>
<!-- Footer -->
<footer class="bg-white border-t border-slate-200 py-12 mt-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col md:flex-row justify-between items-center gap-6">
<p class="text-slate-500 text-sm">© 2025 CodeBridge-X Corp. 영업 관리</p>
<div class="flex gap-6">
<a href="#" class="text-slate-400 hover:text-slate-600">사내 전용 (대외비)</a>
<a href="#" class="text-slate-400 hover:text-slate-600">영업 스크립트</a>
<a href="#" class="text-slate-400 hover:text-slate-600">고객 지원</a>
</div>
</div>
</footer>
<!-- Modal Backdrop & Modal -->
<div id="modal-backdrop" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-50 hidden transition-opacity opacity-0" onclick="closeModal()"></div>
<div id="modal-container" class="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none hidden">
<!-- Modal Content injected by JS -->
</div>
<script>
// Data - Ported from constants.ts
const ASSETS = [
{
id: '1', type: 'TEXT', title: 'Concept: 대표를 위한 무기',
content: '기존 ERP는 직원의 관리 도구였지만, SAM은 대표님의 의사결정 무기입니다. 직원의 보고를 기다리지 마십시오. SAM이 대표님께 직접, 실시간으로 회사의 현황을 보고합니다.',
script: "대표님, ERP나 MES 들어보셨죠? 보통 직원들이 입력하고 관리하는 도구입니다. 정작 대표님은 직원한테 보고를 받아야만 회사를 알 수 있죠. SAM은 반대입니다. 직원이 아니라 '대표님을 위한 무기'입니다. 외근 중이든 집이든, 대표님 폰에서 회사의 자금, 인력, 리스크가 한눈에 보입니다.",
tags: ['Concept', 'Pitch', 'Opener'], gridSpan: 'col-span-1', rowSpan: 'row-span-1'
},
{
id: '3', type: 'VIDEO', title: 'CEO 시크릿 대시보드', src: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?ixlib=rb-4.0.3&auto=format&fit=crop&w=2670&q=80', videoTitle: 'CEO Dashboard Demo',
embedHtml: '<div style="padding:56.25% 0 0 0;position:relative;"><iframe src="https://player.vimeo.com/video/1146972515?title=0&amp;byline=0&amp;portrait=0&amp;badge=0&amp;autopause=0&amp;player_id=0&amp;app_id=58479" frameborder="0" allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share" referrerpolicy="strict-origin-when-cross-origin" style="position:absolute;top:0;left:0;width:100%;height:100%;" title="CEO의_경영_비서,_SAM"></iframe></div>',
description: '가지급금 이자 4.6%, 예상 부가세, 자금 현황이 오직 대표님 화면에만 실시간으로 계산되어 표시됩니다.',
script: "이 화면은 직원들은 못 봅니다. 오직 대표님 아이디로 로그인했을 때만 뜹니다. 여기 붉은 글씨 보이시죠? 현재 가지급금에 대한 인정이자 4.6%가 실시간으로 계산돼서 '대표님, 세금 폭탄 조심하세요'라고 경고해주는 겁니다. 세무사가 알려주기 전에 SAM이 먼저 알려드립니다.",
tags: ['Demo', 'Dashboard', 'Finance'], gridSpan: 'col-span-1 md:col-span-2', rowSpan: 'row-span-2'
},
{
id: '4', type: 'TEXT', title: '20가지 고충 해결',
content: '사람(근태, 인수인계), 돈(세금, 자금), 운영(현장관리), 대표의 삶(리스크). CEO가 겪는 20가지 핵심 고충을 시스템 하나로 방어합니다.',
script: "중소기업 대표님의 머릿속을 20가지로 정리해봤습니다. 직원 근태, 자금 압박, 세무 조사... 이 모든 걸 혼자 감당하고 계시지 않습니까? SAM은 단순 프로그램이 아니라, 이 20가지 리스크를 막아주는 방패입니다.",
tags: ['Pain Points', 'Solution'], gridSpan: 'col-span-1', rowSpan: 'row-span-1'
},
{
id: '5', type: 'IMAGE', title: '모바일 & 감성 알림', src: 'img/sam_alert.jpg',
description: '수주/입금 시 울리는 "SAM~" 알림음. 외근 중에도 회사가 돌아가는 소리를 들으세요.',
script: "가장 인기 있는 기능입니다. 외근 나가 계실 때 불안하시죠? 직원이 큰 수주를 따오거나, 거래처에서 돈을 입금하면 대표님 폰에서 'SAM~' 하고 알림이 옵니다. 그 소리만 들으면 '아, 우리 회사 잘 돌아가고 있구나' 안심이 되실 겁니다.",
tags: ['Mobile', 'UX', 'Emotion'], gridSpan: 'col-span-1 md:col-span-2', rowSpan: 'row-span-1'
},
{
id: '6', type: 'VIDEO', title: '영업 전략 해부: CEO에게 파는 법',
src: 'https://images.unsplash.com/photo-1552664730-d307ca884978?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
embedHtml: '<div style="padding:56.25% 0 0 0;position:relative;"><iframe src="https://player.vimeo.com/video/1146961422?title=0&amp;byline=0&amp;portrait=0&amp;badge=0&amp;autopause=0&amp;player_id=0&amp;app_id=58479" frameborder="0" allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share" referrerpolicy="strict-origin-when-cross-origin" style="position:absolute;top:0;left:0;width:100%;height:100%;" title="영업_전략_해부__CEO에게_파는_법"></iframe></div>',
content: 'SAM 프로젝트의 핵심 영업 전략은 CEO들의 **심리적 약점과 욕구(통제 욕구, 복잡한 것 회피, 비용 민감성)**를 파고들어 설득력을 극대화하도록 재정비되었습니다.',
script: "1. 통제 욕구 및 불안감 자극/해소: 지각/무단결근 등 특이사항과 내일 나갈 돈(예상 부가세)을 미리 보여주어 통제감을 높여줍니다.\n2. 복잡한 것 회피: 모바일 중심의 '시크릿 모드'와 10초 사용을 강조하여 진입장벽을 낮춥니다.\n3. 비용 민감성: 월 30~50만 원으로 24시간 AI 경영 비서를 고용하는 가성비를 강조합니다.",
tags: ['Strategy', 'CEO Psychology', 'Sales Pitch'], gridSpan: 'col-span-1 md:col-span-2', rowSpan: 'row-span-1'
},
{
id: '7', type: 'IMAGE', title: '자동화 & 인수인계', src: 'img/sam_time.jpg',
description: '직원이 갑자기 퇴사해도 걱정 없습니다. 견적부터 발주, 출고까지 모든 이력이 클릭 한 번으로 자동 인수인계됩니다.',
script: "직원이 갑자기 그만둔다고 하면 눈앞이 캄캄하시죠? 파일 어디 있냐, 거래처 연락처 뭐냐... SAM을 쓰시면 그럴 일 없습니다. 모든 업무 기록이 서버에 남기 때문에, 후임자는 '클릭' 한 번이면 전임자의 모든 업무를 그대로 이어받습니다.",
tags: ['Automation', 'Management'], gridSpan: 'col-span-1', rowSpan: 'row-span-2', customAspect: 'aspect-[3/5]'
},
{
id: '9', type: 'TEXT', title: '도입 제안 (Closing)',
content: '월 구독료로 수천만 원대 맞춤형 ERP 기능과 개인 비서를 고용하는 효과를 누리십시오. 내일부터는 "걱정" 대신 "설렘"으로 출근하십시오.',
script: "직원 한 명 월급의 1/10도 안 되는 비용입니다. 이 돈으로 24시간 비서, 그리고 완벽한 경영 시스템을 고용하시는 겁니다. 오늘 결정하시고, 내일부터는 가벼운 마음으로 출근하십시오.",
tags: ['Closing', 'Pricing'], gridSpan: 'col-span-1', rowSpan: 'row-span-1'
},
{
id: '10', type: 'AUDIO', title: '[Core Features] 대표님이 반할 기능 8선', src: 'm4a/strategy.m4a',
content: 'AI 경영 비서부터 자금/세무 관리까지, SAM이 제공하는 8가지 핵심 기능을 3분 요약으로 들어보세요.',
script: "AI 경영 비서: 음성 인식 업무일지, 회의록, 자동 번역\n스마트 현장: 사진 토큰 관리, GPS 기반 출퇴근, QR 설비 관리\n자금/세무: 예상 부가세, 법인카드 한도 관리, 경리일보 자동화\n리스크 방어: 미수금 알림, 재고 부족 경고, 신규 거래처 신용 등급 표시\n편의 기능: 용량 걱정 없는 무제한 파일함, 시그니처 매출 알림음",
tags: ['Podcast', 'Audio', 'Feature'], gridSpan: 'col-span-1 md:col-span-2', rowSpan: 'row-span-1'
},
{
id: '11', type: 'TEXT', title: '1. 대표님의 고민 (Pain Points)',
content: '매출은 느는데 이익은 제자리? 직원과 현장을 100% 믿을 수 있나? 세금 낼 때마다 자금 계획이 꼬이나? 이는 정보 부재에서 오는 "전략적 리스크"입니다.',
script: "매출은 계속 오르는데, 왜 통장에 남는 돈은 없을까요? 직원들은 정말 열심히 하고 있는 걸까요? 세금 낼 때마다 목돈 마련하느라 허덕이시진 않나요? 더 많은 보고서가 아니라, 대표님 손안에 '진짜 정보'가 필요할 때입니다.",
tags: ['Pain Points', 'Risk', 'Needs'], gridSpan: 'col-span-1', rowSpan: 'row-span-1'
},
{
id: '12', type: 'TEXT', title: '2. 새로운 해법 (Solution)',
content: 'SAM은 "실무자 관리 도구"가 아닌 "CEO 의사결정 도구"입니다. 복잡한 입력은 AI가, 대표님은 직관적인 신호등과 요약 카드만 확인하십시오.',
script: "기존 ERP는 관리자용이라 복잡하고 비쌉니다. SAM은 다릅니다. 오직 대표님을 위해 설계되었습니다. 복잡한 표 대신 신호등으로, PC 대신 모바일로, 수천만 원 구축비 대신 합리적 구독료로 제공합니다. 복잡한 건 AI에게 맡기고, 대표님은 흐름만 보십시오.",
tags: ['Solution', 'Differentiation', 'AI'], gridSpan: 'col-span-1', rowSpan: 'row-span-1'
},
{
id: '13', type: 'TEXT', title: '3. 핵심 기능 (Core Features)',
content: '리스크 사전 방지(미수금, 재고), 자금 흐름 관리(예상 부가세, 한도 체크), 실시간 현장 통제(지각, AI 업무일지).',
script: "A업체 미수금 3개월 경과, 접대비 한도 80% 소진, 오늘 지각자 3명... 대표님이 놓치기 쉬운 '리스크'만 골라서 붉은색 알림을 띄워드립니다. 사무실 밖에서도 현장과 자금이 한눈에 들어옵니다.",
tags: ['Features', 'Risk', 'Finance', 'Ops'], gridSpan: 'col-span-1 md:col-span-1', rowSpan: 'row-span-1'
},
{
id: '14', type: 'STAT', title: '4. 도입 효과 (ROI)', statValue: '0분', statLabel: '업무 보고 시간',
content: '보고 시간 0분(실시간 확인), 단순 업무 80% 자동화, 의사결정 속도 즉시. 눈에 보이는 비용 절감과 경쟁력 강화를 경험하십시오.',
script: "직원이 보고서 만들어서 결재 올릴 때까지 기다리지 마십시오. SAM을 쓰면 보고 시간이 '0분'이 됩니다. 단순 반복 업무는 80% 자동화됩니다. 이게 바로 돈 버는 시스템입니다.",
tags: ['ROI', 'Efficiency', 'Automation'], gridSpan: 'col-span-1', rowSpan: 'row-span-1'
},
{
id: '15', type: 'STAT', title: '5. 제안 (Investment)', statValue: '1/100', statLabel: '채용 대비 비용',
content: '연 3,000만 원 경리 직원 채용 vs 월 30만 원 AI 비서. 24시간 잠들지 않는 AI를 신입사원 월급의 1/10도 안 되는 비용으로 고용하십시오.',
script: "사람 한 명 뽑으려면 3천만 원 듭니다. SAM은 월 30만 원입니다. 1/100 비용으로 24시간 잠들지 않고 실수도 없는 완벽한 AI 비서를 채용하시는 겁니다. 이건 지출이 아니라 가장 확실한 투자입니다.",
tags: ['Investment', 'Cost', 'Value'], gridSpan: 'col-span-1', rowSpan: 'row-span-1'
},
{
id: '16', type: 'TEXT', title: '6. 다음 단계 (Next Steps)',
content: '백 번 설명보다 한 번의 경험이 낫습니다. 대표님 업종에 맞춘 "무료 진단 & 시연"과 "3일 무료 체험"을 신청하십시오.',
script: "우리 회사에 맞을까 고민하지 마세요. 대표님 업종에 딱 맞춘 대시보드를 미리 구성해서 보여드립니다. 3일만 직접 써보시면, 왜 SAM이 필수인지 바로 아실 겁니다.",
tags: ['Call to Action', 'Demo', 'Free Trial'], gridSpan: 'col-span-1', rowSpan: 'row-span-1'
}
];
// Constants
const FILTERS = ['전체', 'CEO 설득', '데모'];
let activeFilter = '전체';
// DOM Elements
const gridEl = document.getElementById('assets-grid');
const filterContainer = document.getElementById('filter-buttons');
const emptyState = document.getElementById('empty-state');
const modalBackdrop = document.getElementById('modal-backdrop');
const modalContainer = document.getElementById('modal-container');
const toast = document.getElementById('toast');
// Initial Render
function init() {
renderFilters();
renderGrid();
lucide.createIcons();
}
// Filter Logic
function renderFilters() {
filterContainer.innerHTML = FILTERS.map(filter => `
<button
onclick="filterAssets('${filter}')"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200 ${activeFilter === filter ? 'bg-slate-900 text-white shadow-md' : 'bg-white text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200'}"
>
${filter}
</button>
`).join('');
}
window.filterAssets = (filter) => {
activeFilter = filter;
renderFilters();
renderGrid();
lucide.createIcons();
};
function getFilteredAssets() {
if (activeFilter === '전체') return ASSETS;
return ASSETS.filter(asset => {
if (activeFilter === 'CEO 설득') return asset.tags.some(tag => ['Concept', 'Pitch', 'Pain Points', 'Solution', 'Closing'].includes(tag));
if (activeFilter === '데모') return asset.tags.some(tag => ['Demo', 'Dashboard', 'Video', 'Mobile', 'UX', 'Infra'].includes(tag));
return true;
});
}
// Grid Render
function renderGrid() {
const assets = getFilteredAssets();
if (assets.length === 0) {
emptyState.classList.remove('hidden');
} else {
emptyState.classList.add('hidden');
}
gridEl.innerHTML = assets.map(asset => {
let contentRaw = '';
let iconName = '';
let badgeColor = '';
// Determine card content based on type
if (asset.type === 'TEXT') {
contentRaw = `<p class="text-slate-600 mb-4 leading-relaxed line-clamp-3">${asset.content}</p>`;
iconName = 'file-text';
badgeColor = 'bg-blue-100 text-blue-700';
} else if (asset.type === 'STAT') {
contentRaw = `
<div class="flex items-end gap-2 mb-2">
<span class="text-4xl font-bold text-slate-900 tracking-tight">${asset.statValue}</span>
<span class="text-sm font-medium text-slate-500 mb-1.5">${asset.statLabel}</span>
</div>
<p class="text-slate-600 text-sm line-clamp-2">${asset.script.substring(0, 100)}...</p>
`;
iconName = 'bar-chart-2';
badgeColor = 'bg-emerald-100 text-emerald-700';
} else if (asset.type === 'IMAGE' || asset.type === 'VIDEO') {
const aspectClass = asset.customAspect || 'aspect-video';
contentRaw = `
<div class="mb-4 rounded-lg overflow-hidden border border-slate-100 relative group ${aspectClass}">
<img src="${asset.src}" alt="${asset.title}" class="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-500">
${asset.type === 'VIDEO' ? `
<div class="absolute inset-0 bg-black/40 flex items-center justify-center group-hover:bg-black/30 transition-all">
<div class="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center pl-1 shadow-lg transform group-hover:scale-110 transition-transform">
<i data-lucide="play" class="w-5 h-5 text-slate-900 fill-slate-900"></i>
</div>
</div>
` : ''}
</div>
<p class="text-slate-600 text-sm line-clamp-2">${asset.description || asset.script}</p>
`;
iconName = asset.type === 'VIDEO' ? 'video' : 'image';
badgeColor = asset.type === 'VIDEO' ? 'bg-violet-100 text-violet-700' : 'bg-pink-100 text-pink-700';
} else if (asset.type === 'AUDIO') {
contentRaw = `
<div class="mb-4 rounded-xl overflow-hidden bg-slate-900 p-6 relative group flex items-center justify-between gap-4">
<div class="flex items-center gap-4 z-10">
<div class="w-12 h-12 rounded-full bg-indigo-500 flex items-center justify-center text-white shrink-0 animate-pulse">
<i data-lucide="headphones" class="w-6 h-6"></i>
</div>
<div>
<div class="text-white font-bold text-sm mb-1">SAM Podcast</div>
<div class="text-slate-400 text-xs">3:42 • Strategy Brief</div>
</div>
</div>
<div class="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-white group-hover:bg-white/20 transition-colors z-10">
<i data-lucide="play" class="w-4 h-4 fill-white"></i>
</div>
<!-- Waveform effect -->
<div class="absolute inset-0 opacity-20 flex items-end justify-between px-4 pb-2 gap-1 pointer-events-none">
<div class="w-1 h-3 bg-white rounded-full"></div><div class="w-1 h-5 bg-white rounded-full"></div><div class="w-1 h-8 bg-white rounded-full"></div><div class="w-1 h-4 bg-white rounded-full"></div>
<div class="w-1 h-6 bg-white rounded-full"></div><div class="w-1 h-2 bg-white rounded-full"></div><div class="w-1 h-5 bg-white rounded-full"></div><div class="w-1 h-7 bg-white rounded-full"></div>
<div class="w-1 h-3 bg-white rounded-full"></div><div class="w-1 h-6 bg-white rounded-full"></div><div class="w-1 h-4 bg-white rounded-full"></div><div class="w-1 h-2 bg-white rounded-full"></div>
</div>
</div>
<p class="text-slate-600 text-sm line-clamp-2">${asset.content}</p>
`;
iconName = 'mic';
badgeColor = 'bg-indigo-100 text-indigo-700';
}
return `
<div class="bg-white rounded-2xl border border-slate-100 p-6 hover:shadow-xl transition-all duration-300 hover:-translate-y-1 cursor-pointer group h-full flex flex-col ${asset.gridSpan} ${asset.rowSpan}" onclick="openModal('${asset.id}')">
<div class="flex justify-between items-start mb-4">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${badgeColor}">
<i data-lucide="${iconName}" class="w-3 h-3"></i>
${asset.type}
</span>
${asset.tags[0] ? `<span class="text-xs text-slate-400 font-medium">#${asset.tags[0]}</span>` : ''}
</div>
<button class="text-slate-400 hover:text-brand-600 transition-colors">
<i data-lucide="arrow-up-right" class="w-4 h-4"></i>
</button>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-3 group-hover:text-brand-600 transition-colors">${asset.title}</h3>
<div class="flex-grow">
${contentRaw}
</div>
<div class="mt-6 pt-4 border-t border-slate-50 flex items-center justify-between text-xs font-medium text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span>클릭하여 상세 보기</span>
<span>핵심 스크립트 & 자료</span>
</div>
</div>
`;
}).join('');
}
// Modal Logic
window.openModal = (id) => {
const asset = ASSETS.find(a => a.id === id);
if (!asset) return;
// Generate content
const tagsHtml = asset.tags.map(tag => `<span class="px-2 py-1 rounded-md bg-slate-100 text-slate-500 text-xs font-medium">#${tag}</span>`).join('');
let mediaHtml = '';
if (asset.src) {
if (asset.type === 'VIDEO') {
mediaHtml = `
<div class="aspect-video bg-slate-100 rounded-xl overflow-hidden mb-6 relative group">
<img src="${asset.src}" class="w-full h-full object-cover">
<div class="absolute inset-0 bg-black/40 flex items-center justify-center">
<div class="w-16 h-16 rounded-full bg-white/90 flex items-center justify-center pl-1 shadow-lg">
<i data-lucide="play" class="w-6 h-6 text-slate-900 fill-slate-900"></i>
</div>
</div>
</div>`;
// Override with embed HTML if present (for Vimeo)
if (asset.embedHtml) {
mediaHtml = `<div class="aspect-video bg-slate-100 rounded-xl overflow-hidden mb-6 relative z-0">${asset.embedHtml}</div>`;
}
} else if (asset.type === 'AUDIO') {
mediaHtml = `
<div class="bg-slate-900 rounded-xl p-8 mb-6 text-center">
<div class="w-24 h-24 rounded-full bg-indigo-500 mx-auto mb-6 flex items-center justify-center shadow-lg hover:scale-105 transition-transform">
<i data-lucide="mic" class="w-10 h-10 text-white"></i>
</div>
<h4 class="text-white font-bold text-xl mb-2">${asset.title}</h4>
<p class="text-slate-400 text-sm mb-6">SAM Strategy Podcast • 3:42</p>
<audio controls class="w-full">
<source src="${asset.src}" type="audio/mp4">
<source src="${asset.src}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</div>
`;
} else {
const aspectClass = asset.customAspect || 'aspect-video';
const objectFitClass = asset.customAspect ? 'object-contain p-2' : 'object-cover';
mediaHtml = `<div class="${aspectClass} bg-slate-100 rounded-xl overflow-hidden mb-6"><img src="${asset.src}" class="w-full h-full ${objectFitClass}"></div>`;
}
}
const modalContent = `
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto pointer-events-auto scale-95 opacity-0 transition-all duration-300" id="modal-card">
<div class="sticky top-0 bg-white/90 backdrop-blur-sm p-4 border-b border-slate-100 flex justify-between items-center z-10">
<div class="flex items-center gap-2">
<h3 class="text-lg font-bold text-slate-900">${asset.title}</h3>
<span class="px-2 py-0.5 rounded-full text-xs font-bold bg-slate-900 text-white">${asset.type}</span>
</div>
<button onclick="closeModal()" class="w-8 h-8 rounded-full bg-slate-50 hover:bg-slate-100 flex items-center justify-center text-slate-500 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="p-6 sm:p-8">
${mediaHtml}
${asset.content ? `<div class="mb-8 p-4 bg-slate-50 rounded-xl border border-slate-100 text-slate-700 leading-relaxed font-medium">${asset.content}</div>` : ''}
<div class="mb-8">
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-5 rounded-full bg-brand-500"></div>
<h4 class="font-bold text-slate-900">Sales Script</h4>
</div>
<div class="bg-indigo-50/50 p-5 rounded-xl border border-indigo-100 relative">
<i data-lucide="quote" class="w-5 h-5 text-indigo-200 absolute top-4 left-4"></i>
<p class="text-slate-800 leading-relaxed pl-6 relative z-10 font-medium">"${asset.script}"</p>
</div>
</div>
${asset.statValue ? `
<div class="grid grid-cols-2 gap-4 mb-8">
<div class="p-4 rounded-xl bg-white border border-slate-200 text-center">
<div class="text-xs text-slate-400 uppercase tracking-wider font-semibold mb-1">핵심 가치</div>
<div class="text-2xl font-bold text-slate-900">${asset.statValue}</div>
</div>
<div class="p-4 rounded-xl bg-white border border-slate-200 text-center">
<div class="text-xs text-slate-400 uppercase tracking-wider font-semibold mb-1">카테고리</div>
<div class="text-2xl font-bold text-slate-900">${asset.statLabel}</div>
</div>
</div>
` : ''}
<div class="flex flex-wrap gap-2 pt-6 border-t border-slate-100">
${tagsHtml}
</div>
</div>
<div class="p-4 border-t border-slate-100 bg-slate-50 rounded-b-2xl flex justify-end gap-3">
<button onclick="closeModal()" class="px-4 py-2 rounded-lg bg-white border border-slate-200 text-slate-700 font-medium hover:bg-slate-50 transition-colors">닫기</button>
<button onclick="showDownloadToast(); closeModal()" class="px-4 py-2 rounded-lg bg-brand-600 text-white font-medium hover:bg-brand-700 shadow-md transition-colors flex items-center gap-2">
<i data-lucide="download" class="w-4 h-4"></i>
자료 사용하기
</button>
</div>
</div>
`;
modalContainer.innerHTML = modalContent;
// Animation In
modalBackdrop.classList.remove('hidden');
modalContainer.classList.remove('hidden');
document.body.classList.add('no-scroll');
// Trigger reflow
void modalBackdrop.offsetWidth;
modalBackdrop.classList.remove('opacity-0');
const card = document.getElementById('modal-card');
card.classList.remove('scale-95', 'opacity-0');
lucide.createIcons();
};
window.closeModal = () => {
const card = document.getElementById('modal-card');
if (card) {
card.classList.add('scale-95', 'opacity-0');
}
modalBackdrop.classList.add('opacity-0');
setTimeout(() => {
modalBackdrop.classList.add('hidden');
modalContainer.classList.add('hidden');
document.body.classList.remove('no-scroll');
modalContainer.innerHTML = '';
}, 300);
};
// Toast
window.showDownloadToast = () => {
toast.classList.remove('translate-x-full', 'opacity-0');
setTimeout(() => {
toast.classList.add('translate-x-full', 'opacity-0');
}, 3000);
};
// Initialize
init();
</script>
<script src="https://player.vimeo.com/api/player.js"></script>
</body>
</html>

37
lib/mydb.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
// Function to load .env if not loaded (though session.php usually handles it)
function loadEnvOnly() {
$envFile = dirname(__DIR__) . '/.env';
if (file_exists($envFile) && empty($_ENV['DB_HOST'])) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
list($key, $value) = explode('=', $line, 2);
$_ENV[trim($key)] = trim($value);
}
}
}
}
function db_connect(){
loadEnvOnly();
$db_host = $_ENV['DB_HOST'] ?? 'mysql';
$db_name = $_ENV['DB_NAME'] ?? 'chandj';
$db_user = $_ENV['DB_USER'] ?? 'root';
$db_pass = $_ENV['DB_PASS'] ?? 'root';
$dsn = "mysql:host=".$db_host.";dbname=".$db_name.";charset=utf8mb4";
try {
$pdo = new PDO($dsn, $db_user, $db_pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4, sql_mode='NO_ENGINE_SUBSTITUTION'"
]);
} catch (PDOException $Exception) {
die('Error:'.$Exception->getMessage());
}
return $pdo;
}
?>

54
login/login_form.php Normal file
View File

@@ -0,0 +1,54 @@
<?php require_once(dirname(__DIR__) . '/session.php'); ?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" >
<!-- Optional JavaScript -->
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" ></script>
<title>SAM Sales Login</title>
</head>
<style>
html,body {
height: 100%;
}
</style>
<body>
<div class="container h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col-1"></div>
<div class="col-10 text-center">
<div class="card align-middle" style="width:20rem; border-radius:20px; margin: auto;">
<div class="card" style="padding:15px;margin:10px;">
<h3 class="card-title text-center" style="color:#113366;">SAM Sales Portal</h3>
</div>
<div class="card-body text-center">
<form class="form-signin" method="post" action="login_result.php">
<h5 class="form-signin-heading">로그인</h5>
<label for="inputEmail" class="sr-only">Your ID</label>
<input type="text" name="uid" class="form-control" placeholder="ID" required autofocus><br>
<label for="inputPassword" class="sr-only">Password</label>
<input type="password" name="upw" class="form-control" placeholder="Password" required><br>
<button id="btn-Yes" class="btn btn-lg btn-primary btn-block" type="submit">로 그 인</button>
</form>
</div>
</div>
</div>
<div class="col-1"></div>
</div>
</div>
</body>
</html>

64
login/login_result.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
require_once(dirname(__DIR__) . "/session.php");
$id = $_REQUEST["uid"] ?? '';
$pw = $_REQUEST["upw"] ?? '';
require_once(dirname(__DIR__) . "/lib/mydb.php");
$pdo = db_connect();
try {
$sql = "select * from chandj.member where id=?";
$stmh = $pdo->prepare($sql);
$stmh->bindValue(1, $id, PDO::PARAM_STR);
$stmh->execute();
$count = $stmh->rowCount();
} catch (PDOException $Exception) {
print "Error: " . $Exception->getMessage();
exit;
}
$row = $stmh->fetch(PDO::FETCH_ASSOC);
if ($count < 1) {
?>
<script>
alert("아이디가 틀립니다!");
history.back();
</script>
<?php
} elseif ($pw != $row["pass"]) {
?>
<script>
alert("비밀번호가 틀립니다!");
history.back();
</script>
<?php
} else {
// Login Success
$_SESSION["DB"] = 'chandj';
$_SESSION["userid"] = $row["id"] ?? '';
$_SESSION["name"] = $row["name"] ?? '';
$_SESSION["level"] = $row["lv"] ?? '';
$_SESSION["division"] = $row["division"] ?? '';
$_SESSION["mycompany"] = $row["division"] ?? '';
// Simple Log
try {
$data = date("Y-m-d H:i:s") . " - " . $_SESSION["userid"] . " - " . $_SESSION["name"] . " (Sales Login)";
// Check if logdata table exists or handle error gracefully if strictly sales context
// Assuming chandj.logdata exists
$sql = "insert into chandj.logdata(data) values(?) ";
$stmh = $pdo->prepare($sql);
$stmh->bindValue(1, $data, PDO::PARAM_STR);
$stmh->execute();
} catch (Throwable $e) {
// Ignore log error
}
// Redirect to main page
header("Location: /index.php");
exit;
}
?>

7
login/logout.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
require_once(dirname(__DIR__) . "/session.php");
session_destroy();
?>
<script>
location.href = "/";
</script>

BIN
m4a/strategy.m4a Normal file

Binary file not shown.

View File

@@ -0,0 +1,67 @@
<?php
include '../lib/mydb.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
$method = $_SERVER['REQUEST_METHOD'];
$pdo = db_connect();
if ($method === 'GET') {
$tenantId = $_GET['tenant_id'] ?? 'default_tenant';
try {
$stmt = $pdo->prepare("SELECT step_id, checkpoint_index FROM manager_scenario_checklist WHERE tenant_id = :tenant_id AND is_checked = 1");
$stmt->execute([':tenant_id' => $tenantId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$data = [];
foreach ($rows as $row) {
if (!isset($data[$row['step_id']])) {
$data[$row['step_id']] = [];
}
$data[$row['step_id']][] = (int)$row['checkpoint_index'];
}
echo json_encode(['success' => true, 'data' => $data]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
}
elseif ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
echo json_encode(['success' => false, 'message' => 'Invalid input']);
exit;
}
$tenantId = $input['tenant_id'] ?? 'default_tenant';
$stepId = $input['step_id'];
$checkpointIndex = $input['checkpoint_index'];
$isChecked = $input['is_checked'] ? 1 : 0;
try {
if ($isChecked) {
// Insert or ignore (if already exists)
$stmt = $pdo->prepare("INSERT IGNORE INTO manager_scenario_checklist (tenant_id, step_id, checkpoint_index, is_checked) VALUES (:tenant_id, :step_id, :idx, 1)");
$stmt->execute([':tenant_id' => $tenantId, ':step_id' => $stepId, ':idx' => $checkpointIndex]);
} else {
// Delete record if unchecked
$stmt = $pdo->prepare("DELETE FROM manager_scenario_checklist WHERE tenant_id = :tenant_id AND step_id = :step_id AND checkpoint_index = :idx");
$stmt->execute([':tenant_id' => $tenantId, ':step_id' => $stepId, ':idx' => $checkpointIndex]);
}
// Return updated list for this step
$stmt = $pdo->prepare("SELECT checkpoint_index FROM manager_scenario_checklist WHERE tenant_id = :tenant_id AND step_id = :step_id AND is_checked = 1");
$stmt->execute([':tenant_id' => $tenantId, ':step_id' => $stepId]);
$checkedIndices = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo json_encode(['success' => true, 'data' => array_map('intval', $checkedIndices)]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
}
?>

View File

@@ -0,0 +1,172 @@
<?php
// 출력 버퍼링 시작
ob_start();
error_reporting(0);
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// GCS 삭제 함수 (GCP_storage_dev.md 참고)
function deleteFromGCS($bucket_name, $object_name, $service_account_path = null) {
if (!$service_account_path) {
$service_account_path = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json';
}
if (!file_exists($service_account_path)) {
error_log('GCS 삭제 실패: 서비스 계정 파일 없음');
return false;
}
$serviceAccount = json_decode(file_get_contents($service_account_path), true);
if (!$serviceAccount) {
error_log('GCS 삭제 실패: 서비스 계정 JSON 파싱 오류');
return false;
}
// OAuth 2.0 토큰 생성
$now = time();
$jwtHeader = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$jwtClaim = base64_encode(json_encode([
'iss' => $serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now
]));
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
if (!$privateKey) {
error_log('GCS 삭제 실패: 개인 키 읽기 오류');
return false;
}
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
openssl_free_key($privateKey);
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature);
// OAuth 토큰 요청
$tokenCh = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($tokenCh, CURLOPT_POST, true);
curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt
]));
curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true);
curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
$tokenResponse = curl_exec($tokenCh);
$tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
curl_close($tokenCh);
if ($tokenCode !== 200) {
error_log('GCS 삭제 실패: OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')');
return false;
}
$tokenData = json_decode($tokenResponse, true);
if (!isset($tokenData['access_token'])) {
error_log('GCS 삭제 실패: OAuth 토큰 없음');
return false;
}
$accessToken = $tokenData['access_token'];
// GCS에서 파일 삭제
$delete_url = 'https://storage.googleapis.com/storage/v1/b/' .
urlencode($bucket_name) . '/o/' .
urlencode($object_name);
$ch = curl_init($delete_url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// 204 No Content 또는 404 Not Found는 성공으로 간주
if ($code === 204 || $code === 404) {
return true;
} else {
error_log('GCS 삭제 실패 (HTTP ' . $code . '): ' . $response);
return false;
}
}
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
// 권한 체크
if (!isset($user_id) || $level > 5) {
echo json_encode(['success' => false, 'message' => '접근 권한이 없습니다.']);
exit;
}
// 업무협의 ID 확인
$consultation_id = isset($_POST['id']) ? intval($_POST['id']) : 0;
$manager_id = $user_id;
if ($consultation_id <= 0) {
echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']);
exit;
}
try {
$pdo = db_connect();
// 녹음 파일 정보 조회
$sql = "SELECT audio_file_path FROM manager_consultations WHERE id = ? AND manager_id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$consultation_id, $manager_id]);
$consultation = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$consultation) {
echo json_encode(['success' => false, 'message' => '녹음 파일을 찾을 수 없습니다.']);
exit;
}
// 1. 서버 파일 삭제
if (!empty($consultation['audio_file_path'])) {
// GCS URI가 아닌 경우 로컬 파일 삭제
if (strpos($consultation['audio_file_path'], 'gs://') !== 0) {
$file_path = $_SERVER['DOCUMENT_ROOT'] . $consultation['audio_file_path'];
$file_path = str_replace('\\', '/', $file_path);
$file_path = preg_replace('#/+#', '/', $file_path);
if (file_exists($file_path)) {
@unlink($file_path);
}
} else {
// 2. GCS 파일 삭제 (GCS URI인 경우)
$gcs_uri = $consultation['audio_file_path'];
if (preg_match('#gs://([^/]+)/(.+)#', $gcs_uri, $matches)) {
$bucket_name = $matches[1];
$object_name = $matches[2];
$gcs_deleted = deleteFromGCS($bucket_name, $object_name);
if (!$gcs_deleted) {
error_log('GCS 파일 삭제 실패: ' . $gcs_uri);
}
}
}
}
// 3. DB에서 삭제
$delete_sql = "DELETE FROM manager_consultations WHERE id = ? AND manager_id = ?";
$delete_stmt = $pdo->prepare($delete_sql);
$delete_stmt->execute([$consultation_id, $manager_id]);
echo json_encode(['success' => true, 'message' => '녹음 파일이 삭제되었습니다.']);
} catch (Exception $e) {
error_log('녹음 파일 삭제 오류: ' . $e->getMessage());
echo json_encode(['success' => false, 'message' => '삭제 중 오류가 발생했습니다: ' . $e->getMessage()]);
}
?>

View File

@@ -0,0 +1,113 @@
<?php
// 출력 버퍼링 시작
ob_start();
error_reporting(0);
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 권한 체크
if (!isset($user_id) || $level > 5) {
header('HTTP/1.0 403 Forbidden');
die('접근 권한이 없습니다.');
}
// 녹음 파일 ID 확인
$consultation_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
$manager_id = $user_id;
if ($consultation_id <= 0) {
header('HTTP/1.0 400 Bad Request');
die('잘못된 요청입니다.');
}
try {
$pdo = db_connect();
$sql = "SELECT audio_file_path, created_at
FROM manager_consultations
WHERE id = ? AND manager_id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$consultation_id, $manager_id]);
$consultation = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$consultation || empty($consultation['audio_file_path'])) {
header('HTTP/1.0 404 Not Found');
die('오디오 파일을 찾을 수 없습니다.');
}
// GCS URI인 경우 처리 불가 (직접 다운로드 불가)
if (strpos($consultation['audio_file_path'], 'gs://') === 0) {
header('HTTP/1.0 400 Bad Request');
die('GCS에 저장된 파일은 직접 다운로드할 수 없습니다.');
}
// 파일 경로 구성
$file_path = $_SERVER['DOCUMENT_ROOT'] . $consultation['audio_file_path'];
// 경로 정규화
$file_path = str_replace('\\', '/', $file_path);
$file_path = preg_replace('#/+#', '/', $file_path);
// 파일 존재 확인
if (!file_exists($file_path)) {
header('HTTP/1.0 404 Not Found');
die('오디오 파일이 서버에 존재하지 않습니다.');
}
// 파일 확장자 확인
$file_extension = pathinfo($file_path, PATHINFO_EXTENSION) ?: 'webm';
$mime_types = [
'webm' => 'audio/webm',
'wav' => 'audio/wav',
'mp3' => 'audio/mpeg',
'ogg' => 'audio/ogg',
'm4a' => 'audio/mp4'
];
$content_type = isset($mime_types[$file_extension])
? $mime_types[$file_extension]
: 'audio/webm';
// 다운로드 파일명 생성
$date = date('Ymd_His', strtotime($consultation['created_at']));
$download_filename = '상담녹음_' . $date . '.' . $file_extension;
// 출력 버퍼 비우기
ob_clean();
$file_size = filesize($file_path);
if ($file_size === false || $file_size == 0) {
header('HTTP/1.0 500 Internal Server Error');
die('파일을 읽을 수 없습니다.');
}
// 헤더 설정
header('Content-Type: ' . $content_type);
header('Content-Disposition: attachment; filename="' . $download_filename . '"');
header('Content-Length: ' . $file_size);
header('Content-Transfer-Encoding: binary');
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Pragma: public');
header('Expires: 0');
// 파일 출력
$handle = @fopen($file_path, 'rb');
if ($handle === false) {
header('HTTP/1.0 500 Internal Server Error');
die('파일을 열 수 없습니다.');
}
while (!feof($handle)) {
$chunk = fread($handle, 8192);
if ($chunk === false) break;
echo $chunk;
flush();
}
fclose($handle);
exit;
} catch (Exception $e) {
header('HTTP/1.0 500 Internal Server Error');
die('파일 다운로드 중 오류가 발생했습니다.');
}

View File

@@ -0,0 +1,61 @@
<?php
// 출력 버퍼링 시작 및 에러 리포팅 비활성화
error_reporting(0);
ob_start();
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
// 권한 체크
if (!isset($user_id) || $level > 5) {
echo json_encode(['success' => false, 'message' => '접근 권한이 없습니다.']);
exit;
}
$consultation_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
$manager_id = $user_id;
if ($consultation_id <= 0) {
echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']);
exit;
}
try {
$pdo = db_connect();
$sql = "SELECT id, manager_id, step_id, audio_file_path, transcript_text,
file_expiry_date, created_at
FROM manager_consultations
WHERE id = ? AND manager_id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$consultation_id, $manager_id]);
$consultation = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$consultation) {
echo json_encode(['success' => false, 'message' => '녹음 파일을 찾을 수 없습니다.']);
exit;
}
// 날짜 포맷팅
$consultation['created_at_formatted'] = date('Y-m-d H:i:s', strtotime($consultation['created_at']));
$consultation['is_gcs'] = strpos($consultation['audio_file_path'], 'gs://') === 0;
echo json_encode([
'success' => true,
'data' => $consultation
]);
} catch (Exception $e) {
error_log('조회 오류: ' . $e->getMessage());
echo json_encode([
'success' => false,
'message' => '조회 실패: ' . $e->getMessage()
]);
}
?>

View File

@@ -0,0 +1,68 @@
<?php
// 출력 버퍼링 시작 및 에러 리포팅 비활성화
error_reporting(0);
ob_start();
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
// 1. 권한 및 세션 체크
if (!isset($user_id) || $level > 5) {
echo json_encode(['success' => false, 'message' => '접근 권한이 없습니다.']);
exit;
}
$manager_id = $user_id;
$step_id = isset($_GET['step_id']) ? intval($_GET['step_id']) : 2;
$limit = isset($_GET['limit']) ? intval($_GET['limit']) : 20;
try {
$pdo = db_connect();
if (!$pdo) {
throw new Exception('데이터베이스 연결 실패');
}
// 저장된 첨부파일 목록 조회
$sql = "SELECT id, manager_id, step_id, file_paths_json,
file_expiry_date, created_at
FROM manager_consultation_files
WHERE manager_id = ? AND step_id = ?
ORDER BY created_at DESC
LIMIT ?";
$stmt = $pdo->prepare($sql);
if (!$stmt) {
throw new Exception('SQL 준비 실패');
}
$stmt->execute([$manager_id, $step_id, $limit]);
$files = $stmt->fetchAll(PDO::FETCH_ASSOC);
// JSON 파싱 및 날짜 포맷팅
foreach ($files as &$file) {
$file['files'] = json_decode($file['file_paths_json'], true) ?: [];
$file['created_at_formatted'] = date('Y-m-d H:i', strtotime($file['created_at']));
$file['file_count'] = count($file['files']);
}
echo json_encode([
'success' => true,
'data' => $files,
'count' => count($files)
]);
} catch (Exception $e) {
error_log('조회 오류: ' . $e->getMessage());
echo json_encode([
'success' => false,
'message' => '조회 실패: ' . $e->getMessage(),
'data' => []
]);
}
?>

View File

@@ -0,0 +1,67 @@
<?php
// 출력 버퍼링 시작 및 에러 리포팅 비활성화
error_reporting(0);
ob_start();
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
// 1. 권한 및 세션 체크
if (!isset($user_id) || $level > 5) {
echo json_encode(['success' => false, 'message' => '접근 권한이 없습니다.']);
exit;
}
$manager_id = $user_id;
$step_id = isset($_GET['step_id']) ? intval($_GET['step_id']) : 2;
$limit = isset($_GET['limit']) ? intval($_GET['limit']) : 20;
try {
$pdo = db_connect();
if (!$pdo) {
throw new Exception('데이터베이스 연결 실패');
}
// 저장된 녹음 파일 목록 조회
$sql = "SELECT id, manager_id, step_id, audio_file_path, transcript_text,
file_expiry_date, created_at
FROM manager_consultations
WHERE manager_id = ? AND step_id = ?
ORDER BY created_at DESC
LIMIT ?";
$stmt = $pdo->prepare($sql);
if (!$stmt) {
throw new Exception('SQL 준비 실패');
}
$stmt->execute([$manager_id, $step_id, $limit]);
$consultations = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 날짜 포맷팅
foreach ($consultations as &$consultation) {
$consultation['created_at_formatted'] = date('Y-m-d H:i', strtotime($consultation['created_at']));
$consultation['is_gcs'] = strpos($consultation['audio_file_path'], 'gs://') === 0;
}
echo json_encode([
'success' => true,
'data' => $consultations,
'count' => count($consultations)
]);
} catch (Exception $e) {
error_log('조회 오류: ' . $e->getMessage());
echo json_encode([
'success' => false,
'message' => '조회 실패: ' . $e->getMessage(),
'data' => []
]);
}
?>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,218 @@
<?php
// 출력 버퍼링 시작 및 에러 리포팅 비활성화
error_reporting(0);
ob_start();
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// GCS 업로드 함수 (GCP_storage_dev.md 참고)
function uploadToGCS($file_path, $bucket_name, $object_name, $service_account_path) {
if (!file_exists($service_account_path)) {
error_log('GCS 업로드 실패: 서비스 계정 파일 없음');
return false;
}
$serviceAccount = json_decode(file_get_contents($service_account_path), true);
if (!$serviceAccount) {
error_log('GCS 업로드 실패: 서비스 계정 JSON 파싱 오류');
return false;
}
// OAuth 2.0 토큰 생성
$now = time();
$jwtHeader = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$jwtClaim = base64_encode(json_encode([
'iss' => $serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now
]));
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
if (!$privateKey) {
error_log('GCS 업로드 실패: 개인 키 읽기 오류');
return false;
}
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
openssl_free_key($privateKey);
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature);
// OAuth 토큰 요청
$tokenCh = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($tokenCh, CURLOPT_POST, true);
curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt
]));
curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true);
curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
$tokenResponse = curl_exec($tokenCh);
$tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
curl_close($tokenCh);
if ($tokenCode !== 200) {
error_log('GCS 업로드 실패: OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')');
return false;
}
$tokenData = json_decode($tokenResponse, true);
if (!isset($tokenData['access_token'])) {
error_log('GCS 업로드 실패: OAuth 토큰 없음');
return false;
}
$accessToken = $tokenData['access_token'];
// GCS에 파일 업로드
$file_content = file_get_contents($file_path);
$mime_type = mime_content_type($file_path) ?: 'audio/webm';
$upload_url = 'https://storage.googleapis.com/upload/storage/v1/b/' .
urlencode($bucket_name) . '/o?uploadType=media&name=' .
urlencode($object_name);
$ch = curl_init($upload_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken,
'Content-Type: ' . $mime_type,
'Content-Length: ' . strlen($file_content)
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $file_content);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200) {
return 'gs://' . $bucket_name . '/' . $object_name;
} else {
error_log('GCS 업로드 실패 (HTTP ' . $code . '): ' . $response);
return false;
}
}
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
// 1. 권한 및 세션 체크
if (!isset($user_id) || $level > 5) {
echo json_encode(['success' => false, 'message' => '접근 권한이 없습니다.']);
exit;
}
$manager_id = $user_id; // session.php에서 user_id를 manager_id로 사용
$upload_dir = $_SERVER['DOCUMENT_ROOT'] . "/uploads/manager_consultations/" . $manager_id . "/";
// 2. 파일 업로드 처리
if (!file_exists($upload_dir)) mkdir($upload_dir, 0777, true);
if (!isset($_FILES['audio_file'])) {
echo json_encode(['success' => false, 'message' => '오디오 파일이 없습니다.']);
exit;
}
// 파일 크기 확인
if ($_FILES['audio_file']['size'] == 0) {
echo json_encode(['success' => false, 'message' => '오디오 파일이 비어있습니다.']);
exit;
}
$file_name = date('Ymd_His') . "_" . uniqid() . ".webm";
$file_path = $upload_dir . $file_name;
if (!move_uploaded_file($_FILES['audio_file']['tmp_name'], $file_path)) {
echo json_encode(['success' => false, 'message' => '파일 저장 실패']);
exit;
}
// 3. GCS 업로드 (선택사항 - 파일이 큰 경우)
$gcs_uri = null;
$file_size = filesize($file_path);
$max_local_size = 10 * 1024 * 1024; // 10MB
if ($file_size > $max_local_size) {
// GCS 설정 확인
$gcs_config_file = $_SERVER['DOCUMENT_ROOT'] . '/apikey/gcs_config.txt';
$bucket_name = null;
if (file_exists($gcs_config_file)) {
$gcs_config = parse_ini_file($gcs_config_file);
$bucket_name = isset($gcs_config['bucket_name']) ? $gcs_config['bucket_name'] : null;
}
if ($bucket_name) {
$googleServiceAccountFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json';
$gcs_object_name = 'manager_consultations/' . $manager_id . '/' . basename($file_path);
$gcs_uri = uploadToGCS($file_path, $bucket_name, $gcs_object_name, $googleServiceAccountFile);
if ($gcs_uri) {
// GCS 업로드 성공 시 로컬 파일 삭제 (선택사항)
// @unlink($file_path);
}
}
}
// 4. DB 저장
$transcript = isset($_POST['transcript']) ? trim($_POST['transcript']) : '';
$step_id = isset($_POST['step_id']) ? intval($_POST['step_id']) : 2;
$web_path = "/uploads/manager_consultations/" . $manager_id . "/" . $file_name;
$file_path_to_store = $gcs_uri ? $gcs_uri : $web_path;
try {
$pdo = db_connect();
if (!$pdo) {
throw new Exception('데이터베이스 연결 실패');
}
$expiry_date = date('Y-m-d H:i:s', strtotime('+30 days')); // 30일 보관
// manager_consultations 테이블에 저장 (테이블이 없으면 생성 필요)
$sql = "INSERT INTO manager_consultations
(manager_id, step_id, audio_file_path, transcript_text, file_expiry_date, created_at)
VALUES (?, ?, ?, ?, ?, NOW())";
$stmt = $pdo->prepare($sql);
if (!$stmt) {
throw new Exception('SQL 준비 실패');
}
$executeResult = $stmt->execute([
$manager_id,
$step_id,
$file_path_to_store,
$transcript,
$expiry_date
]);
if (!$executeResult) {
throw new Exception('SQL 실행 실패');
}
$insertId = $pdo->lastInsertId();
echo json_encode([
'success' => true,
'message' => '녹음 파일이 저장되었습니다.',
'id' => $insertId,
'file_path' => $file_path_to_store,
'gcs_uri' => $gcs_uri
]);
} catch (Exception $e) {
error_log('DB 저장 오류: ' . $e->getMessage());
echo json_encode([
'success' => false,
'message' => '데이터베이스 저장 실패: ' . $e->getMessage()
]);
}
?>

View File

@@ -0,0 +1,234 @@
<?php
// 출력 버퍼링 시작 및 에러 리포팅 비활성화
error_reporting(0);
ob_start();
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// GCS 업로드 함수 (GCP_storage_dev.md 참고)
function uploadToGCS($file_path, $bucket_name, $object_name, $service_account_path) {
if (!file_exists($service_account_path)) {
error_log('GCS 업로드 실패: 서비스 계정 파일 없음');
return false;
}
$serviceAccount = json_decode(file_get_contents($service_account_path), true);
if (!$serviceAccount) {
error_log('GCS 업로드 실패: 서비스 계정 JSON 파싱 오류');
return false;
}
// OAuth 2.0 토큰 생성
$now = time();
$jwtHeader = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$jwtClaim = base64_encode(json_encode([
'iss' => $serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now
]));
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
if (!$privateKey) {
error_log('GCS 업로드 실패: 개인 키 읽기 오류');
return false;
}
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
openssl_free_key($privateKey);
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature);
// OAuth 토큰 요청
$tokenCh = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($tokenCh, CURLOPT_POST, true);
curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt
]));
curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true);
curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
$tokenResponse = curl_exec($tokenCh);
$tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
curl_close($tokenCh);
if ($tokenCode !== 200) {
error_log('GCS 업로드 실패: OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')');
return false;
}
$tokenData = json_decode($tokenResponse, true);
if (!isset($tokenData['access_token'])) {
error_log('GCS 업로드 실패: OAuth 토큰 없음');
return false;
}
$accessToken = $tokenData['access_token'];
// GCS에 파일 업로드
$file_content = file_get_contents($file_path);
$mime_type = mime_content_type($file_path) ?: 'application/octet-stream';
$upload_url = 'https://storage.googleapis.com/upload/storage/v1/b/' .
urlencode($bucket_name) . '/o?uploadType=media&name=' .
urlencode($object_name);
$ch = curl_init($upload_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken,
'Content-Type: ' . $mime_type,
'Content-Length: ' . strlen($file_content)
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $file_content);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200) {
return 'gs://' . $bucket_name . '/' . $object_name;
} else {
error_log('GCS 업로드 실패 (HTTP ' . $code . '): ' . $response);
return false;
}
}
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
// 1. 권한 및 세션 체크
if (!isset($user_id) || $level > 5) {
echo json_encode(['success' => false, 'message' => '접근 권한이 없습니다.']);
exit;
}
$manager_id = $user_id;
$step_id = isset($_POST['step_id']) ? intval($_POST['step_id']) : 2;
$upload_dir = $_SERVER['DOCUMENT_ROOT'] . "/uploads/manager_consultations/" . $manager_id . "/files/";
// 2. 파일 업로드 처리
if (!file_exists($upload_dir)) mkdir($upload_dir, 0777, true);
// 업로드된 파일 확인
$uploaded_files = [];
$file_count = isset($_POST['file_count']) ? intval($_POST['file_count']) : 0;
for ($i = 0; $i < $file_count; $i++) {
$file_key = 'file_' . $i;
if (!isset($_FILES[$file_key])) {
continue;
}
$file = $_FILES[$file_key];
// 파일 크기 확인 (50MB 제한)
$max_file_size = 50 * 1024 * 1024;
if ($file['size'] > $max_file_size) {
echo json_encode([
'success' => false,
'message' => '파일 크기가 너무 큽니다. (최대 50MB): ' . $file['name']
]);
exit;
}
// 파일명 생성
$original_name = $file['name'];
$file_extension = pathinfo($original_name, PATHINFO_EXTENSION);
$file_name = date('Ymd_His') . "_" . uniqid() . "." . $file_extension;
$file_path = $upload_dir . $file_name;
if (!move_uploaded_file($file['tmp_name'], $file_path)) {
echo json_encode(['success' => false, 'message' => '파일 저장 실패: ' . $original_name]);
exit;
}
// GCS 업로드 (선택사항 - 큰 파일인 경우)
$gcs_uri = null;
$file_size = filesize($file_path);
$max_local_size = 10 * 1024 * 1024; // 10MB
if ($file_size > $max_local_size) {
$gcs_config_file = $_SERVER['DOCUMENT_ROOT'] . '/apikey/gcs_config.txt';
$bucket_name = null;
if (file_exists($gcs_config_file)) {
$gcs_config = parse_ini_file($gcs_config_file);
$bucket_name = isset($gcs_config['bucket_name']) ? $gcs_config['bucket_name'] : null;
}
if ($bucket_name) {
$googleServiceAccountFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json';
$gcs_object_name = 'manager_consultations/' . $manager_id . '/files/' . basename($file_path);
$gcs_uri = uploadToGCS($file_path, $bucket_name, $gcs_object_name, $googleServiceAccountFile);
}
}
$web_path = "/uploads/manager_consultations/" . $manager_id . "/files/" . $file_name;
$file_path_to_store = $gcs_uri ? $gcs_uri : $web_path;
$uploaded_files[] = [
'original_name' => $original_name,
'file_path' => $file_path_to_store,
'file_size' => $file_size,
'gcs_uri' => $gcs_uri
];
}
// 3. DB 저장
try {
$pdo = db_connect();
if (!$pdo) {
throw new Exception('데이터베이스 연결 실패');
}
$expiry_date = date('Y-m-d H:i:s', strtotime('+30 days'));
$file_paths_json = json_encode($uploaded_files);
// manager_consultation_files 테이블에 저장
$sql = "INSERT INTO manager_consultation_files
(manager_id, step_id, file_paths_json, file_expiry_date, created_at)
VALUES (?, ?, ?, ?, NOW())";
$stmt = $pdo->prepare($sql);
if (!$stmt) {
throw new Exception('SQL 준비 실패');
}
$executeResult = $stmt->execute([
$manager_id,
$step_id,
$file_paths_json,
$expiry_date
]);
if (!$executeResult) {
throw new Exception('SQL 실행 실패');
}
$insertId = $pdo->lastInsertId();
echo json_encode([
'success' => true,
'message' => count($uploaded_files) . '개 파일이 업로드되었습니다.',
'id' => $insertId,
'files' => $uploaded_files,
'file_count' => count($uploaded_files)
]);
} catch (Exception $e) {
error_log('DB 저장 오류: ' . $e->getMessage());
echo json_encode([
'success' => false,
'message' => '데이터베이스 저장 실패: ' . $e->getMessage()
]);
}
?>

View File

@@ -0,0 +1,67 @@
<?php
include '../lib/mydb.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
$method = $_SERVER['REQUEST_METHOD'];
$pdo = db_connect();
if ($method === 'GET') {
$tenantId = $_GET['tenant_id'] ?? 'default_tenant';
try {
$stmt = $pdo->prepare("SELECT step_id, checkpoint_index FROM sales_scenario_checklist WHERE tenant_id = :tenant_id AND is_checked = 1");
$stmt->execute([':tenant_id' => $tenantId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$data = [];
foreach ($rows as $row) {
if (!isset($data[$row['step_id']])) {
$data[$row['step_id']] = [];
}
$data[$row['step_id']][] = (int)$row['checkpoint_index'];
}
echo json_encode(['success' => true, 'data' => $data]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
}
elseif ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
echo json_encode(['success' => false, 'message' => 'Invalid input']);
exit;
}
$tenantId = $input['tenant_id'] ?? 'default_tenant';
$stepId = $input['step_id'];
$checkpointIndex = $input['checkpoint_index'];
$isChecked = $input['is_checked'] ? 1 : 0;
try {
if ($isChecked) {
// Insert or ignore (if already exists)
$stmt = $pdo->prepare("INSERT IGNORE INTO sales_scenario_checklist (tenant_id, step_id, checkpoint_index, is_checked) VALUES (:tenant_id, :step_id, :idx, 1)");
$stmt->execute([':tenant_id' => $tenantId, ':step_id' => $stepId, ':idx' => $checkpointIndex]);
} else {
// Delete record if unchecked
$stmt = $pdo->prepare("DELETE FROM sales_scenario_checklist WHERE tenant_id = :tenant_id AND step_id = :step_id AND checkpoint_index = :idx");
$stmt->execute([':tenant_id' => $tenantId, ':step_id' => $stepId, ':idx' => $checkpointIndex]);
}
// Return updated list for this step
$stmt = $pdo->prepare("SELECT checkpoint_index FROM sales_scenario_checklist WHERE tenant_id = :tenant_id AND step_id = :step_id AND is_checked = 1");
$stmt->execute([':tenant_id' => $tenantId, ':step_id' => $stepId]);
$checkedIndices = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo json_encode(['success' => true, 'data' => array_map('intval', $checkedIndices)]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
}
?>

752
sales_scenario/index.php Normal file
View File

@@ -0,0 +1,752 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAM 영업 시나리오 - CodeBridgeExy</title>
<!-- Fonts: Pretendard -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Pretendard', 'sans-serif'],
},
colors: {
background: 'rgb(250, 250, 250)',
primary: {
DEFAULT: '#2563eb', // blue-600
light: '#dbeafe', // blue-100
dark: '#1e40af', // blue-800
foreground: '#ffffff',
},
slate: {
850: '#1e293b', // Custom dark slate
}
},
borderRadius: {
'card': '12px',
'pill': '9999px',
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out',
'slide-up': 'slideUp 0.5s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
}
}
}
}
}
</script>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Icons: Lucide -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.step-connector {
position: absolute;
top: 2rem;
left: 50%;
width: 2px;
height: 100%;
background-color: #e2e8f0;
z-index: 0;
transform: translateX(-50%);
}
@media (min-width: 1024px) {
.step-connector {
top: 50%;
left: 0;
width: 100%;
height: 2px;
transform: translateY(-50%);
}
}
</style>
</head>
<body class="bg-background text-slate-800 antialiased min-h-screen flex flex-col">
<div id="root" class="flex-grow flex flex-col"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// --- Data: Sales Scenario Steps ---
const SCENARIO_STEPS = [
{
id: 1,
title: "사전 준비",
subtitle: "Preparation",
icon: "search",
color: "bg-blue-100 text-blue-600",
description: "고객사를 만나기 전, 철저한 분석을 통해 성공 확률을 높이는 단계입니다.",
checkpoints: [
{
title: "고객사 심층 분석",
detail: "홈페이지, 뉴스, SNS를 통해 최근 3개월 내의 이슈와 경영진의 신년사/인터뷰를 확인하여 회사의 비전과 당면 과제를 파악하세요.",
pro_tip: "구글 알리미(Google Alerts)에 고객사 키워드를 등록해두세요. 잡플래닛/블라인드 리뷰를 통해 직원들의 불만 사항(야근, 비효율 등)을 미리 파악하면 미팅 시 강력한 무기가 됩니다."
},
{
title: "재무 건전성 확인",
detail: "DART 또는 기업정보 사이트에서 최근 3년치 매출액, 영업이익 추이를 확인하고 IT 투자 여력을 가늠해보세요.",
pro_tip: "영업이익이 감소 추세라면 '비용 절감'을, 성장 추세라면 '확장성'과 '관리 효율'을 강조하는 전략을 준비하세요."
},
{
title: "경쟁사 및 시장 동향",
detail: "고객사의 경쟁사가 도입한 솔루션을 파악하고, 우리 솔루션(SAM)이 줄 수 있는 차별화된 가치를 정리하세요.",
pro_tip: "경쟁사를 비방하지 마세요. 대신 'A사는 기능이 많지만 무겁고, 우리는 핵심 기능에 집중하여 도입 속도가 2배 빠릅니다'와 같이 구체적인 차별점을 제시하세요."
},
{
title: "가설 수립 (Hypothesis)",
detail: "'이 회사는 현재 재고 관리의 비효율로 인해 월 000만원의 손실이 발생하고 있을 것이다'와 같은 구체적인 페인포인트 가설을 세우세요.",
pro_tip: "'만약 ~하다면' 화법을 사용하세요. '현재 엑셀로 관리하신다면, 월말 마감에 3일 이상 소요되실 텐데 맞으신가요?'라는 질문으로 고객의 'Yes'를 유도하세요."
},
{
title: "의사결정 구조 파악",
detail: "조직도를 통해 실무자, 중간 관리자, 최종 의사결정권자(Key Decision Maker)의 라인을 미리 파악하세요.",
pro_tip: "링크드인을 통해 누가 예산 권한을 가지고 있는지 확인하세요. 실무자와의 미팅에서도 '이 프로젝트의 최종 승인은 누가 하시나요?'라고 자연스럽게 물어보세요."
},
{
title: "IT 예산 집행 시기 확인",
detail: "고객사의 회계연도와 예산 편성 시기를 파악하여, 제안 타이밍이 적절한지 확인하세요.",
pro_tip: "대부분의 기업은 10~11월에 내년도 예산을 편성합니다. 이 시기를 놓쳤다면 '잔여 예산'이나 '파일럿 프로젝트 예산'을 공략하세요."
}
],
tips: "아는 만큼 보입니다. 고객의 언어로 대화할 준비를 하세요."
},
{
id: 2,
title: "접근 및 탐색",
subtitle: "Approach",
icon: "phone-call",
color: "bg-indigo-100 text-indigo-600",
description: "담당자와의 첫 접점을 만들고, 미팅 기회를 확보하는 단계입니다.",
checkpoints: [
{
title: "Key-man 식별 및 컨택",
detail: "링크드인, 로켓펀치 등을 통해 실무 책임자(팀장급)와 의사결정권자(임원급)의 연락처를 확보하세요.",
pro_tip: "대표전화로 전화할 때는 '영업'이라고 하지 말고, '000 이사님께 전달드릴 자료가 있어 연락드렸습니다'라고 하여 Gatekeeper를 통과하세요."
},
{
title: "맞춤형 콜드메일/콜",
detail: "복사-붙여넣기한 제안서가 아닌, 사전 조사한 내용을 바탕으로 '귀사의 00 문제를 해결해드릴 수 있습니다'라고 접근하세요.",
pro_tip: "제목에 고객사 이름을 반드시 넣으세요. '제안서입니다' 대신 'CodeBridgeExy의 재고 비용 30% 절감 방안 (for 고객사명)'과 같이 구체적인 혜택을 명시하세요."
},
{
title: "미팅 일정 확정",
detail: "단순한 회사 소개가 아닌, '진단'과 '인사이트 공유'를 목적으로 미팅을 제안하여 거부감을 줄이세요.",
pro_tip: "'언제 시간 되세요?'라고 묻지 말고, '다음 주 화요일 오후 2시나 수요일 오전 10시 중 언제가 편하신가요?'라고 양자택일 질문을 던지세요."
},
{
title: "사전 자료 공유",
detail: "미팅 전, 우리 회사의 소개서와 유사 업종의 성공 사례(Reference)를 미리 보내 신뢰도를 높이세요.",
pro_tip: "자료를 보낸 후 '잘 받으셨나요?'라고 확인 전화하는 것을 핑계로 한 번 더 접점을 만드세요."
}
],
tips: "우리 제품을 파는 것이 아니라, '만나야 할 이유'를 파세요."
},
{
id: 3,
title: "현장 진단",
subtitle: "Diagnosis",
icon: "stethoscope",
color: "bg-purple-100 text-purple-600",
description: "고객의 업무 현장을 직접 확인하고 진짜 문제를 찾아내는 단계입니다.",
checkpoints: [
{
title: "AS-IS 프로세스 맵핑",
detail: "현재 업무가 어떻게 진행되는지(주문->생산->출하) 흐름도를 그리고, 엑셀/수기 작업 구간을 찾아내세요.",
pro_tip: "화이트보드를 활용해 고객과 함께 그리세요. 고객이 직접 그리면서 '여기가 진짜 문제네'라고 스스로 깨닫게 하는 것이 가장 효과적입니다."
},
{
title: "비효율/리스크 식별",
detail: "데이터 누락, 중복 입력, 담당자 부재 시 업무 마비 등 구체적인 문제점과 그로 인한 비용 손실을 수치화하세요.",
pro_tip: "'불편하시죠?'가 아니라 '이로 인해 한 달에 몇 시간이나 더 쓰시나요?'라고 물어 비용으로 환산해 주세요."
},
{
title: "실무자 인터뷰 (VoC)",
detail: "현업 담당자가 겪는 가장 큰 고충(야근, 스트레스 등)을 듣고 공감대를 형성하여 우군으로 만드세요.",
pro_tip: "실무자의 고충을 해결해 주는 것이 곧 나의 영업 성공입니다. '제가 이 야근, 없애 드리겠습니다'라는 확신을 심어주세요."
},
{
title: "TO-BE 이미지 스케치",
detail: "SAM 도입 후 업무가 어떻게 간소화되고 편해질지 구체적인 모습(Before & After)을 보여주세요.",
pro_tip: "말로만 설명하지 말고, 간단한 장표나 예시 화면을 보여주며 '이렇게 바뀝니다'라고 시각화하세요."
},
{
title: "레거시 시스템 연동 확인",
detail: "기존에 사용 중인 ERP, 그룹웨어, 메신저 등과 SAM 시스템의 연동 가능성 및 기술적 제약 사항을 점검하세요.",
pro_tip: "개발팀을 대동하거나, 기술 지원 가능 여부를 현장에서 바로 확인해 주면 신뢰도가 급상승합니다."
},
{
title: "숨은 이해관계자 발굴",
detail: "표면적인 담당자 외에 도입에 영향을 미칠 수 있는 숨은 실세나 반대 세력(Detractor)을 파악하세요.",
pro_tip: "'이 시스템을 도입하면 가장 싫어할 부서가 어디일까요?'라고 넌지시 물어보세요."
},
{
title: "변화 저항 요소 파악",
detail: "새로운 시스템 도입 시 예상되는 내부 직원의 저항(익숙함 선호 등)을 미리 파악하고 대응 논리를 준비하세요.",
pro_tip: "'기존 엑셀과 똑같은 화면 구성도 가능합니다'와 같이 익숙함을 유지하면서 편리함만 더한다는 점을 강조하세요."
}
],
tips: "고객이 말하지 않는 불편함까지 찾아내는 것이 전문가입니다."
},
{
id: 4,
title: "솔루션 제안",
subtitle: "Proposal",
icon: "presentation",
color: "bg-pink-100 text-pink-600",
description: "SAM을 통해 고객의 문제를 어떻게 해결할 수 있는지 증명하는 단계입니다.",
checkpoints: [
{
title: "맞춤형 데모 시연",
detail: "모든 기능을 보여주려 하지 말고, 앞서 파악한 고객의 페인포인트를 해결하는 핵심 기능 위주로 시연하세요.",
pro_tip: "고객사의 로고와 실제 데이터를 데모 시스템에 미리 넣어 가세요. '이미 우리 시스템인 것 같다'는 느낌을 주세요."
},
{
title: "ROI 분석 보고서",
detail: "솔루션 도입 비용 대비 절감할 수 있는 인건비, 시간, 기회비용을 수치로 산출하여 투자 가치를 증명하세요.",
pro_tip: "ROI는 보수적으로 잡으세요. 그래도 충분히 매력적인 숫자가 나와야 진짜 설득력이 있습니다."
},
{
title: "성공 사례(Case Study)",
detail: "고객사와 유사한 규모/업종의 다른 회사가 우리 솔루션으로 어떤 성과를 냈는지 구체적인 사례를 제시하세요.",
pro_tip: "'A사도 처음엔 고민하셨는데, 도입 3개월 만에 재고 정확도가 99%가 되었습니다'와 같이 구체적인 수치와 기간을 언급하세요."
},
{
title: "단계별 도입 로드맵",
detail: "한 번에 모든 것을 바꾸는 부담을 줄이기 위해, 단계적 도입(Pilot -> Roll-out) 방안을 제시하세요.",
pro_tip: "1단계는 '핵심 문제 해결', 2단계는 '전사 확산'으로 나누어 초기 진입 장벽을 낮추세요."
},
{
title: "경쟁 우위 분석 (Battle Card)",
detail: "경쟁사 대비 SAM 솔루션만의 강점(기능, 가격, 지원 등)을 명확히 비교하여 제시하세요.",
pro_tip: "기능 비교표를 준비하되, 우리가 이기는 항목(예: 모바일 사용성, CS 응대 속도)을 상단에 배치하세요."
},
{
title: "예상 질문(Objection) 방어",
detail: "'비싸다', '어렵다', '필요 없다' 등 예상되는 거절 사유에 대한 명확한 답변과 논리를 준비하세요.",
pro_tip: "'비싸다'는 말은 '가치를 못 느꼈다'는 뜻입니다. 가격을 깎아주는 대신 가치를 다시 설명하세요."
}
],
tips: "기능 나열이 아닌 '가치'와 '변화'를 보여주세요."
},
{
id: 5,
title: "협상 및 조율",
subtitle: "Negotiation",
icon: "scale",
color: "bg-orange-100 text-orange-600",
description: "도입을 가로막는 장애물을 제거하고 조건을 합의하는 단계입니다.",
checkpoints: [
{
title: "가격/조건 협상",
detail: "단순 할인이 아닌, 장기 계약, 선납 조건, 도입 범위 조정 등 다양한 옵션을 통해 서로 만족할 수 있는 합의점을 찾으세요.",
pro_tip: "'가격을 10% 깎아드리는 대신, 2년 계약을 하시겠습니까?'와 같이 Give & Take 원칙을 지키세요."
},
{
title: "기술적 요구사항 조율",
detail: "커스터마이징 요구사항에 대해 가능한 범위와 추가 비용, 개발 일정을 명확히 협의하여 추후 분쟁을 예방하세요.",
pro_tip: "무조건 '됩니다'라고 하지 마세요. '이 기능은 2주가 더 소요되는데 괜찮으신가요?'라고 현실적인 제약을 공유해야 신뢰를 얻습니다."
},
{
title: "의사결정권자 설득",
detail: "실무자가 아닌 최종 결재권자(CEO/CFO)의 관심사(비용 절감, 리스크 관리)에 맞는 논리로 최종 승인을 받으세요.",
pro_tip: "임원 보고용 1장 요약 장표를 따로 만들어 실무자에게 전달해 주세요. 실무자가 내부 보고를 잘해야 계약이 성사됩니다."
},
{
title: "계약 초안 검토",
detail: "계약서의 주요 조항(서비스 수준, 해지 위약금, 유지보수 범위 등)을 꼼꼼히 검토하고 조율하세요.",
pro_tip: "법무팀 검토는 시간이 오래 걸립니다. 표준 계약서를 먼저 보내고, 수정 사항을 붉은색으로 표시해 달라고 요청하세요."
},
{
title: "유지보수(SLA) 범위 확정",
detail: "장애 발생 시 대응 시간, 정기 점검 주기 등 기술 지원 수준(SLA)을 명확히 하여 고객의 불안을 해소하세요.",
pro_tip: "'문제 생기면 바로 달려갑니다'라는 말보다 '평일 09-18시, 2시간 내 원격 지원'과 같이 명확한 기준을 제시하세요."
}
],
tips: "윈-윈(Win-Win)이 아니면 지속 가능한 계약이 아닙니다."
},
{
id: 6,
title: "계약 체결",
subtitle: "Closing",
icon: "pen-tool",
color: "bg-green-100 text-green-600",
description: "공식적인 파트너십을 맺고 법적 효력을 발생시키는 단계입니다.",
checkpoints: [
{
title: "계약서 날인 및 교부",
detail: "전자계약 또는 서면 계약을 통해 법적 효력을 확정하고, 계약서 원본을 안전하게 보관하세요.",
pro_tip: "전자계약(모두싸인 등)을 활용하면 계약 체결 시간을 획기적으로 단축할 수 있습니다."
},
{
title: "세금계산서 및 입금",
detail: "가입비(초기 도입비)에 대한 세금계산서를 발행하고, 입금 기한 내 수금을 확인하세요.",
pro_tip: "세금계산서 발행 시 사업자등록증 사본과 이메일 주소를 다시 한번 정확히 확인하세요."
},
{
title: "보안 서약 및 NDA",
detail: "고객사의 데이터 보호를 위한 보안 서약서 및 비밀유지협약(NDA)을 체결하여 신뢰를 강화하세요.",
pro_tip: "보안은 고객이 요구하기 전에 먼저 제안하세요. '저희는 귀사의 정보를 소중히 다룹니다'라는 강력한 메시지가 됩니다."
},
{
title: "Kick-off 미팅 준비",
detail: "본격적인 프로젝트 시작을 알리는 킥오프 미팅 일정을 잡고, 참여 인원과 안건을 확정하세요.",
pro_tip: "킥오프 미팅 때는 떡이나 다과를 준비해 가는 센스를 발휘하세요. 분위기가 훨씬 부드러워집니다."
}
],
tips: "계약은 끝이 아니라 진정한 서비스의 시작입니다."
}
];
// --- Components ---
const Header = () => (
<header className="bg-white border-b border-slate-100 sticky top-0 z-50 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-white font-bold shadow-md">
S
</div>
<h1 className="text-lg font-bold text-slate-900 tracking-tight">SAM Sales Scenario</h1>
</div>
<div className="flex items-center gap-4">
<a href="../index.php" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1 font-medium transition-colors">
<i data-lucide="home" className="w-4 h-4"></i>
홈으로
</a>
<div className="h-4 w-px bg-slate-200"></div>
<span className="text-xs font-medium px-2.5 py-1 bg-slate-100 text-slate-600 rounded-full">v1.0 Standard</span>
<div className="w-8 h-8 rounded-full bg-slate-200 overflow-hidden border border-slate-300">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Felix" alt="User" />
</div>
</div>
</div>
</header>
);
const StepCard = ({ step, isActive, isCompleted, onClick, progress }) => {
const Icon = lucide.icons[step.icon] ? lucide.icons[step.icon] : lucide.icons.circle; // Fallback
// Calculate progress percentage
const progressPercent = progress ? Math.round((progress.checked / progress.total) * 100) : 0;
return (
<div className="flex flex-col items-center gap-4">
<div
onClick={() => onClick(step)}
className={`
relative flex-shrink-0 p-6 rounded-card border transition-all duration-500 ease-[cubic-bezier(0.25,1,0.5,1)] cursor-pointer group overflow-hidden
${isActive
? 'w-80 bg-white border-primary ring-2 ring-primary/20 shadow-2xl z-30 scale-105'
: 'w-32 bg-white border-slate-100 hover:border-slate-300 hover:shadow-md opacity-80 hover:opacity-100 z-0'
}
`}
>
{/* Step Number Badge */}
<div className={`
absolute top-4 right-4 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border-2 border-white shadow-sm transition-colors duration-300 z-20
${isActive ? 'bg-primary text-white' : 'bg-slate-100 text-slate-400'}
`}>
{step.id}
</div>
<div className="flex flex-col items-center text-center space-y-4 w-full">
<div className={`
w-12 h-12 rounded-2xl flex items-center justify-center text-2xl mb-2 transition-all duration-300
${isActive ? 'w-16 h-16 text-3xl ' + step.color : 'bg-slate-50 text-slate-400'}
`}>
<i data-lucide={step.icon} className={`${isActive ? 'w-8 h-8' : 'w-6 h-6'}`}></i>
</div>
<div className="w-full">
<h3 className={`font-bold transition-all duration-300 whitespace-nowrap overflow-hidden text-ellipsis ${isActive ? 'text-lg text-slate-900' : 'text-xs text-slate-500'}`}>
{step.title}
</h3>
{isActive && (
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mt-1 animate-fade-in">
{step.subtitle}
</p>
)}
</div>
{isActive && (
<div className="w-full pt-4 border-t border-slate-100 animate-fade-in">
<p className="text-sm text-slate-600 leading-relaxed text-left line-clamp-3">
{step.description}
</p>
</div>
)}
</div>
{/* Active Indicator Arrow */}
{isActive && (
<div className="absolute -bottom-3 left-1/2 transform -translate-x-1/2 w-6 h-6 bg-white border-b border-r border-primary rotate-45 z-20 md:hidden"></div>
)}
</div>
{/* Progress Graph (Below Card) */}
<div className={`transition-all duration-500 ${isActive ? 'w-64 opacity-100' : 'w-24 opacity-60'}`}>
<div className="flex justify-between text-xs mb-1 font-medium text-slate-500">
<span>진행률</span>
<span>{progressPercent}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-1000 ease-out ${step.color.replace('text-', 'bg-')}`}
style={{ width: `${progressPercent}%` }}
></div>
</div>
</div>
</div>
);
};
const TipModal = ({ checkpoint, onClose }) => {
if (!checkpoint) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden animate-slide-up" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50">
<h3 className="text-lg font-bold text-slate-900 flex items-center gap-2">
<i data-lucide="lightbulb" className="w-5 h-5 text-yellow-500"></i>
Sales Pro Tip
</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div className="p-6">
<h4 className="text-xl font-bold text-slate-900 mb-3">{checkpoint.title}</h4>
<p className="text-slate-600 mb-6 leading-relaxed">{checkpoint.detail}</p>
<div className="bg-blue-50 border border-blue-100 rounded-xl p-5">
<div className="flex items-start gap-3">
<div className="p-2 bg-blue-100 rounded-lg text-blue-600 shrink-0">
<i data-lucide="thumbs-up" className="w-5 h-5"></i>
</div>
<div>
<h5 className="font-bold text-blue-800 mb-1">실전 꿀팁</h5>
<p className="text-blue-700 text-sm leading-relaxed">
{checkpoint.pro_tip}
</p>
</div>
</div>
</div>
</div>
<div className="p-4 border-t border-slate-100 bg-slate-50 flex justify-end">
<button onClick={onClose} className="px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 font-medium transition-colors">
확인
</button>
</div>
</div>
</div>
);
};
const DetailPanel = ({ step, checkedItems, onCheck }) => {
const [selectedCheckpoint, setSelectedCheckpoint] = useState(null);
if (!step) return null;
return (
<>
<div className="bg-white rounded-card shadow-lg border border-slate-100 overflow-hidden animate-slide-up">
<div className="p-6 md:p-8 flex flex-col md:flex-row gap-8">
{/* Left: Header & Description */}
<div className="md:w-1/3 space-y-6">
<div>
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold mb-4 ${step.color.replace('text-', 'bg-opacity-20 text-')}`}>
STEP {step.id}
</div>
<h2 className="text-3xl font-bold text-slate-900 mb-2">{step.title}</h2>
<p className="text-lg text-slate-500 font-light">{step.subtitle}</p>
</div>
<p className="text-slate-600 leading-relaxed">
{step.description}
</p>
<div className="p-4 bg-slate-50 rounded-xl border border-slate-100">
<h4 className="text-sm font-bold text-slate-800 mb-2 flex items-center gap-2">
<i data-lucide="lightbulb" className="w-4 h-4 text-yellow-500"></i>
Sales Tip
</h4>
<p className="text-sm text-slate-600 italic">
"{step.tips}"
</p>
</div>
</div>
{/* Right: Checkpoints */}
<div className="md:w-2/3 bg-slate-50 rounded-xl p-6 border border-slate-100">
<h3 className="text-lg font-bold text-slate-900 mb-4 flex items-center gap-2">
<i data-lucide="check-square" className="w-5 h-5 text-primary"></i>
핵심 체크포인트
</h3>
<div className="space-y-4">
{step.checkpoints.map((point, idx) => (
<div key={idx} className="relative group">
<label className="flex items-start gap-4 p-4 bg-white rounded-xl border border-slate-200 hover:border-primary/50 hover:shadow-md transition-all cursor-pointer pr-12">
<div className="relative flex items-center mt-1 shrink-0">
<input
type="checkbox"
className="peer h-5 w-5 cursor-pointer appearance-none rounded-md border border-slate-300 checked:border-primary checked:bg-primary transition-all"
checked={checkedItems.includes(idx)}
onChange={(e) => onCheck(step.id, idx, e.target.checked)}
/>
<i data-lucide="check" className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white opacity-0 peer-checked:opacity-100 pointer-events-none"></i>
</div>
<div className="flex-grow">
<span className={`block text-sm font-bold transition-colors mb-1 ${checkedItems.includes(idx) ? 'text-slate-400 line-through' : 'text-slate-800 group-hover:text-primary'}`}>
{point.title}
</span>
<span className={`block text-sm leading-relaxed ${checkedItems.includes(idx) ? 'text-slate-400' : 'text-slate-600'}`}>
{point.detail}
</span>
</div>
</label>
{/* Help Button (Floating) */}
<button
onClick={(e) => {
e.stopPropagation();
setSelectedCheckpoint(point);
}}
className="absolute right-4 top-4 p-2 text-slate-400 hover:text-yellow-500 hover:bg-yellow-50 rounded-full transition-all opacity-0 group-hover:opacity-100"
title="꿀팁 보기"
>
<i data-lucide="help-circle" className="w-5 h-5"></i>
</button>
</div>
))}
</div>
</div>
</div>
</div>
{/* Tip Modal */}
{selectedCheckpoint && (
<TipModal
checkpoint={selectedCheckpoint}
onClose={() => setSelectedCheckpoint(null)}
/>
)}
</>
);
};
const App = () => {
const [activeStepId, setActiveStepId] = useState(1);
const [checklistData, setChecklistData] = useState({}); // { stepId: [checkedIndex1, checkedIndex2] }
const [loading, setLoading] = useState(true);
const activeStep = SCENARIO_STEPS.find(s => s.id === activeStepId);
const scrollContainerRef = useRef(null);
const tenantId = "tenant_001"; // Hardcoded for demo, normally from auth
useEffect(() => {
// Fetch initial checklist data
fetch(`api_handler.php?tenant_id=${tenantId}`)
.then(res => res.json())
.then(data => {
if (data.success) {
setChecklistData(data.data);
}
setLoading(false);
})
.catch(err => {
console.error("Failed to fetch checklist:", err);
setLoading(false);
});
}, []);
useEffect(() => {
lucide.createIcons();
}, [activeStepId, checklistData]);
const handleCheck = (stepId, index, isChecked) => {
// Optimistic update
setChecklistData(prev => {
const currentStepChecks = prev[stepId] || [];
let newStepChecks;
if (isChecked) {
newStepChecks = [...currentStepChecks, index];
} else {
newStepChecks = currentStepChecks.filter(i => i !== index);
}
return { ...prev, [stepId]: newStepChecks };
});
// API Call
fetch('api_handler.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenant_id: tenantId,
step_id: stepId,
checkpoint_index: index,
is_checked: isChecked
})
})
.then(res => res.json())
.then(data => {
if (!data.success) {
// Revert on failure (optional, but good practice)
console.error("Update failed:", data.message);
}
})
.catch(err => console.error("API Error:", err));
};
const getProgress = (step) => {
const checked = checklistData[step.id]?.length || 0;
const total = step.checkpoints.length;
return { checked, total };
};
// Calculate Overall Progress
const totalCheckpoints = SCENARIO_STEPS.reduce((sum, step) => sum + step.checkpoints.length, 0);
// Only count checked items for steps that actually exist in SCENARIO_STEPS
const validStepIds = SCENARIO_STEPS.map(s => s.id);
const totalChecked = Object.entries(checklistData).reduce((sum, [stepId, checks]) => {
if (validStepIds.includes(parseInt(stepId))) {
return sum + checks.length;
}
return sum;
}, 0);
const overallProgress = totalCheckpoints > 0 ? Math.min(100, Math.round((totalChecked / totalCheckpoints) * 100)) : 0;
return (
<div className="min-h-screen pb-20">
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{/* Overall Progress Section */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 flex flex-col md:flex-row items-center gap-6 animate-fade-in">
<div className="flex-1 w-full">
<div className="flex justify-between items-end mb-2">
<div>
<h2 className="text-lg font-bold text-slate-900">전체 진행 현황</h2>
<p className="text-sm text-slate-500">모든 단계의 체크포인트를 완료하여 영업 성공률을 높이세요.</p>
</div>
<div className="text-right">
<span className="text-3xl font-extrabold text-primary">{overallProgress}%</span>
<span className="text-sm text-slate-400 ml-1">완료</span>
</div>
</div>
<div className="w-full bg-slate-100 rounded-full h-4 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full transition-all duration-1000 ease-out shadow-[0_0_10px_rgba(37,99,235,0.3)]"
style={{ width: `${overallProgress}%` }}
>
<div className="w-full h-full opacity-30 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PHBhdHRlcm4gaWQ9InAiIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgcGF0dGVyblVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHBhdGggZD0iTTAgMTBMMTAgME0yMCAxMEwxMCAyMCIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIiBmaWxsPSJub25lIi8+PC9wYXR0ZXJuPjwvZGVmcz48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSJ1cmwoI3ApIi8+PC9zdmc+')] animate-[shimmer_2s_linear_infinite]"></div>
</div>
</div>
<div className="flex justify-between mt-2 text-xs text-slate-400 font-medium">
<span>시작</span>
<span>{totalChecked} / {totalCheckpoints} 항목 완료</span>
<span>완료</span>
</div>
</div>
{/* Circular Indicator (Optional, for visual balance) */}
<div className="hidden md:flex items-center justify-center w-16 h-16 rounded-full bg-blue-50 text-blue-600 border border-blue-100 shrink-0">
<i data-lucide="trophy" className="w-8 h-8"></i>
</div>
</div>
{/* Process Map */}
<div className="relative py-10">
{/* Connector Line (Desktop) */}
<div className="hidden lg:block absolute top-[130px] left-0 w-full h-1 bg-slate-100 rounded-full z-0"></div>
<div
ref={scrollContainerRef}
className="flex items-start justify-start lg:justify-center gap-4 overflow-x-auto pb-8 pt-4 px-4 snap-x snap-mandatory hide-scrollbar min-h-[400px]"
style={{ scrollBehavior: 'smooth' }}
>
{SCENARIO_STEPS.map((step, index) => (
<div key={step.id} className="snap-center shrink-0 flex justify-center transition-all duration-500">
<StepCard
step={step}
isActive={activeStepId === step.id}
onClick={(s) => setActiveStepId(s.id)}
progress={getProgress(step)}
/>
</div>
))}
</div>
</div>
{/* Detail Panel */}
<div className="transition-all duration-500 ease-in-out">
<DetailPanel
key={activeStepId}
step={activeStep}
checkedItems={checklistData[activeStepId] || []}
onCheck={handleCheck}
/>
</div>
</main>
<footer className="bg-white border-t border-slate-100 py-8 mt-auto">
<div className="max-w-7xl mx-auto px-4 text-center text-slate-400 text-sm">
&copy; 2025 codebridge-x.com. All rights reserved.
</div>
</footer>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
<?php
include '../lib/mydb.php';
try {
$pdo = db_connect();
$sql = "CREATE TABLE IF NOT EXISTS sales_scenario_checklist (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id VARCHAR(50) NOT NULL,
step_id INT NOT NULL,
checkpoint_index INT NOT NULL,
is_checked BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_check (tenant_id, step_id, checkpoint_index)
)";
$pdo->exec($sql);
echo "Table 'sales_scenario_checklist' created successfully.";
} catch (PDOException $e) {
echo "Error creating table: " . $e->getMessage();
}
?>

View File

@@ -0,0 +1,550 @@
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
// 역할 파라미터 받기
$role = isset($_GET['role']) ? $_GET['role'] : '1차영업담당';
// 역할별 Mock Data
$roleData = [
'1차영업담당' => [
'sales_records' => [
[
"id" => "sale_001",
"customer_name" => "대박 식당",
"program_id" => "prog_pro",
"contract_date" => "2024-10-15",
"duration_months" => 84,
"join_fee" => 2000000,
"subscription_fee" => 100000,
"total_amount" => 2000000,
"status" => "Active",
"dates" => [
"contract" => "2024-10-15",
"join_fee" => "2024-10-16",
"service_start" => "2024-11-01",
"subscription_fee" => "2024-11-25",
"product_modified" => "2024-10-15"
],
"history" => [
[
"date" => "2024-10-15",
"type" => "New Contract",
"program_id" => "prog_pro",
"description" => "Initial contract signed (Pro Plan)"
]
]
],
[
"id" => "sale_002",
"customer_name" => "강남 카페",
"program_id" => "prog_premium",
"contract_date" => "2024-11-05",
"duration_months" => 84,
"join_fee" => 3000000,
"subscription_fee" => 150000,
"total_amount" => 3000000,
"status" => "Active",
"dates" => [
"contract" => "2024-11-05",
"join_fee" => "2024-11-06",
"service_start" => "2024-12-01",
"subscription_fee" => "2024-12-25",
"product_modified" => "2024-11-05"
],
"history" => [
[
"date" => "2024-11-05",
"type" => "New Contract",
"program_id" => "prog_premium",
"description" => "Initial contract signed (Premium Plan)"
]
]
],
[
"id" => "sale_003",
"customer_name" => "성수 팩토리",
"program_id" => "prog_basic",
"contract_date" => "2024-11-20",
"duration_months" => 84,
"join_fee" => 1000000,
"subscription_fee" => 50000,
"total_amount" => 1000000,
"status" => "Pending",
"dates" => [
"contract" => "2024-11-20",
"join_fee" => null,
"service_start" => null,
"subscription_fee" => null,
"product_modified" => "2024-11-20"
],
"history" => [
[
"date" => "2024-11-20",
"type" => "New Contract",
"program_id" => "prog_basic",
"description" => "Contract drafted (Basic Plan)"
]
]
]
],
'current_user' => [
"id" => "user_A",
"name" => "김관리 팀장",
"role" => "1차영업담당",
"sub_managers" => [
[
"id" => "user_B",
"name" => "이하위 대리",
"role" => "Sub-Manager",
"total_sales" => 50000000,
"active_contracts" => 5,
"performance_grade" => "A"
],
[
"id" => "user_C",
"name" => "박신입 사원",
"role" => "Seller",
"total_sales" => 12000000,
"active_contracts" => 2,
"performance_grade" => "B"
],
[
"id" => "user_D",
"name" => "최열정 인턴",
"role" => "Seller",
"total_sales" => 0,
"active_contracts" => 0,
"performance_grade" => "C"
]
]
]
],
'2차영업담당' => [
'sales_records' => [
[
"id" => "sale_201",
"customer_name" => "서초 IT센터",
"program_id" => "prog_pro",
"contract_date" => "2024-09-10",
"duration_months" => 84,
"join_fee" => 2000000,
"subscription_fee" => 100000,
"total_amount" => 2000000,
"status" => "Active",
"dates" => [
"contract" => "2024-09-10",
"join_fee" => "2024-09-11",
"service_start" => "2024-10-01",
"subscription_fee" => "2024-10-25",
"product_modified" => "2024-09-10"
],
"history" => [
[
"date" => "2024-09-10",
"type" => "New Contract",
"program_id" => "prog_pro",
"description" => "Initial contract signed (Pro Plan)"
]
]
],
[
"id" => "sale_202",
"customer_name" => "홍대 레스토랑",
"program_id" => "prog_basic",
"contract_date" => "2024-10-22",
"duration_months" => 84,
"join_fee" => 1000000,
"subscription_fee" => 50000,
"total_amount" => 1000000,
"status" => "Active",
"dates" => [
"contract" => "2024-10-22",
"join_fee" => "2024-10-23",
"service_start" => "2024-11-01",
"subscription_fee" => "2024-11-25",
"product_modified" => "2024-10-22"
],
"history" => [
[
"date" => "2024-10-22",
"type" => "New Contract",
"program_id" => "prog_basic",
"description" => "Initial contract signed (Basic Plan)"
]
]
],
[
"id" => "sale_203",
"customer_name" => "판교 스타트업",
"program_id" => "prog_premium",
"contract_date" => "2024-11-18",
"duration_months" => 84,
"join_fee" => 3000000,
"subscription_fee" => 150000,
"total_amount" => 3000000,
"status" => "Active",
"dates" => [
"contract" => "2024-11-18",
"join_fee" => "2024-11-19",
"service_start" => "2024-12-01",
"subscription_fee" => "2024-12-25",
"product_modified" => "2024-11-18"
],
"history" => [
[
"date" => "2024-11-18",
"type" => "New Contract",
"program_id" => "prog_premium",
"description" => "Initial contract signed (Premium Plan)"
]
]
],
[
"id" => "sale_204",
"customer_name" => "이태원 바",
"program_id" => "prog_basic",
"contract_date" => "2024-11-25",
"duration_months" => 84,
"join_fee" => 1000000,
"subscription_fee" => 50000,
"total_amount" => 1000000,
"status" => "Pending",
"dates" => [
"contract" => "2024-11-25",
"join_fee" => null,
"service_start" => null,
"subscription_fee" => null,
"product_modified" => "2024-11-25"
],
"history" => [
[
"date" => "2024-11-25",
"type" => "New Contract",
"program_id" => "prog_basic",
"description" => "Contract drafted (Basic Plan)"
]
]
]
],
'current_user' => [
"id" => "user_2A",
"name" => "정영업 과장",
"role" => "2차영업담당",
"sub_managers" => [
[
"id" => "user_2B",
"name" => "한성실 대리",
"role" => "Sub-Manager",
"total_sales" => 35000000,
"active_contracts" => 4,
"performance_grade" => "A"
],
[
"id" => "user_2C",
"name" => "윤열심 주임",
"role" => "Seller",
"total_sales" => 18000000,
"active_contracts" => 3,
"performance_grade" => "B"
],
[
"id" => "user_2D",
"name" => "강신규 사원",
"role" => "Seller",
"total_sales" => 5000000,
"active_contracts" => 1,
"performance_grade" => "C"
]
]
]
],
'3차영업담당' => [
'sales_records' => [
[
"id" => "sale_301",
"customer_name" => "잠실 마트",
"program_id" => "prog_basic",
"contract_date" => "2024-08-15",
"duration_months" => 84,
"join_fee" => 1000000,
"subscription_fee" => 50000,
"total_amount" => 1000000,
"status" => "Active",
"dates" => [
"contract" => "2024-08-15",
"join_fee" => "2024-08-16",
"service_start" => "2024-09-01",
"subscription_fee" => "2024-09-25",
"product_modified" => "2024-08-15"
],
"history" => [
[
"date" => "2024-08-15",
"type" => "New Contract",
"program_id" => "prog_basic",
"description" => "Initial contract signed (Basic Plan)"
]
]
],
[
"id" => "sale_302",
"customer_name" => "송파 병원",
"program_id" => "prog_pro",
"contract_date" => "2024-10-08",
"duration_months" => 84,
"join_fee" => 2000000,
"subscription_fee" => 100000,
"total_amount" => 2000000,
"status" => "Active",
"dates" => [
"contract" => "2024-10-08",
"join_fee" => "2024-10-09",
"service_start" => "2024-11-01",
"subscription_fee" => "2024-11-25",
"product_modified" => "2024-10-08"
],
"history" => [
[
"date" => "2024-10-08",
"type" => "New Contract",
"program_id" => "prog_pro",
"description" => "Initial contract signed (Pro Plan)"
]
]
],
[
"id" => "sale_303",
"customer_name" => "강동 학원",
"program_id" => "prog_basic",
"contract_date" => "2024-11-12",
"duration_months" => 84,
"join_fee" => 1000000,
"subscription_fee" => 50000,
"total_amount" => 1000000,
"status" => "Active",
"dates" => [
"contract" => "2024-11-12",
"join_fee" => "2024-11-13",
"service_start" => "2024-12-01",
"subscription_fee" => "2024-12-25",
"product_modified" => "2024-11-12"
],
"history" => [
[
"date" => "2024-11-12",
"type" => "New Contract",
"program_id" => "prog_basic",
"description" => "Initial contract signed (Basic Plan)"
]
]
]
],
'current_user' => [
"id" => "user_3A",
"name" => "오영업 대리",
"role" => "3차영업담당",
"sub_managers" => [
[
"id" => "user_3B",
"name" => "신성실 주임",
"role" => "Sub-Manager",
"total_sales" => 25000000,
"active_contracts" => 3,
"performance_grade" => "A"
],
[
"id" => "user_3C",
"name" => "조근면 사원",
"role" => "Seller",
"total_sales" => 8000000,
"active_contracts" => 2,
"performance_grade" => "B"
],
[
"id" => "user_3D",
"name" => "임신입 인턴",
"role" => "Seller",
"total_sales" => 2000000,
"active_contracts" => 1,
"performance_grade" => "C"
]
]
]
]
];
// 기본 데이터 구조
$response = [
"company_info" => [
"id" => "comp_001",
"name" => "건축자재(주)",
"logo_url" => "https://via.placeholder.com/150x50?text=Company+Logo", // Placeholder
"currency" => "KRW"
],
"sales_config" => [
"programs" => [
[
"id" => "prog_basic",
"name" => "Basic Plan",
"join_fee" => 1000000,
"subscription_fee" => 50000,
"commission_rates" => [
"seller" => ["join" => 0.2, "sub" => 0.5],
"manager" => ["join" => 0.05, "sub" => 0.3],
"educator" => ["join" => 0.03, "sub" => 0.2]
]
],
[
"id" => "prog_pro",
"name" => "Pro Plan",
"join_fee" => 2000000,
"subscription_fee" => 100000,
"commission_rates" => [
"seller" => ["join" => 0.2, "sub" => 0.5],
"manager" => ["join" => 0.05, "sub" => 0.3],
"educator" => ["join" => 0.03, "sub" => 0.2]
]
],
[
"id" => "prog_premium",
"name" => "Premium Plan",
"join_fee" => 3000000,
"subscription_fee" => 150000,
"commission_rates" => [
"seller" => ["join" => 0.2, "sub" => 0.5],
"manager" => ["join" => 0.05, "sub" => 0.3],
"educator" => ["join" => 0.03, "sub" => 0.2]
]
]
],
"default_contract_period" => 84,
"package_types" => [
[
"id" => "select_models",
"name" => "선택모델",
"type" => "checkbox",
"models" => [
[
"id" => "model_qr",
"name" => "QR코드",
"sub_name" => "설비관리/장비 점검",
"join_fee" => 10200000,
"subscription_fee" => 50000,
"commission_rates" => [
"seller" => ["join" => 0.2, "sub" => 0.5],
"manager" => ["join" => 0.05, "sub" => 0.3],
"educator" => ["join" => 0.03, "sub" => 0.2]
]
],
[
"id" => "model_photo",
"name" => "사진 - 출하",
"sub_name" => "사진 관리",
"join_fee" => 19200000,
"subscription_fee" => 100000,
"commission_rates" => [
"seller" => ["join" => 0.2, "sub" => 0.5],
"manager" => ["join" => 0.05, "sub" => 0.3],
"educator" => ["join" => 0.03, "sub" => 0.2]
]
],
[
"id" => "model_inspection",
"name" => "검사 / 토큰 적용",
"sub_name" => "계산서 발행",
"join_fee" => 10200000,
"subscription_fee" => 50000,
"commission_rates" => [
"seller" => ["join" => 0.2, "sub" => 0.5],
"manager" => ["join" => 0.05, "sub" => 0.3],
"educator" => ["join" => 0.03, "sub" => 0.2]
]
],
[
"id" => "model_account",
"name" => "이카운트 / 거래처대장",
"sub_name" => "서류관리",
"join_fee" => 19200000,
"subscription_fee" => 100000,
"commission_rates" => [
"seller" => ["join" => 0.2, "sub" => 0.5],
"manager" => ["join" => 0.05, "sub" => 0.3],
"educator" => ["join" => 0.03, "sub" => 0.2]
]
],
[
"id" => "model_iso",
"name" => "ISO, 인정서류 / 토큰 적용",
"sub_name" => "서류관리",
"join_fee" => 10200000,
"subscription_fee" => 50000,
"commission_rates" => [
"seller" => ["join" => 0.2, "sub" => 0.5],
"manager" => ["join" => 0.05, "sub" => 0.3],
"educator" => ["join" => 0.03, "sub" => 0.2]
]
],
[
"id" => "model_inventory",
"name" => "적정 재고 표기",
"sub_name" => "재고 관리",
"join_fee" => 19200000,
"subscription_fee" => 100000,
"commission_rates" => [
"seller" => ["join" => 0.2, "sub" => 0.5],
"manager" => ["join" => 0.05, "sub" => 0.3],
"educator" => ["join" => 0.03, "sub" => 0.2]
]
],
[
"id" => "model_manufacturing",
"name" => "제조 관리",
"sub_name" => "제작지시, 발주",
"join_fee" => 19200000,
"subscription_fee" => 100000,
"commission_rates" => [
"seller" => ["join" => 0.2, "sub" => 0.5],
"manager" => ["join" => 0.05, "sub" => 0.3],
"educator" => ["join" => 0.03, "sub" => 0.2]
]
]
]
],
[
"id" => "construction_management",
"name" => "공사관리",
"type" => "package",
"join_fee" => 40000000,
"subscription_fee" => 200000,
"commission_rates" => [
"seller" => ["join" => 0.25, "sub" => 0.5],
"manager" => ["join" => 0.05, "sub" => 0.3],
"educator" => ["join" => 0.03, "sub" => 0.2]
]
],
[
"id" => "process_government",
"name" => "공정/정부지원사업",
"type" => "package",
"join_fee" => 80000000,
"subscription_fee" => 400000,
"commission_rates" => [
"seller" => ["join" => 0.25, "sub" => 0.5],
"manager" => ["join" => 0.05, "sub" => 0.3],
"educator" => ["join" => 0.03, "sub" => 0.2]
]
]
]
],
"sales_records" => isset($roleData[$role]['sales_records']) ? $roleData[$role]['sales_records'] : [],
"current_user" => isset($roleData[$role]['current_user']) ? $roleData[$role]['current_user'] : [
"id" => "user_default",
"name" => "기본 사용자",
"role" => $role,
"sub_managers" => []
]
];
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
?>

View File

@@ -0,0 +1,162 @@
<?php
header("Content-Type: application/json; charset=utf-8");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
try {
$pdo = db_connect();
switch ($method) {
case 'GET':
if ($action === 'list') {
// 전체 목록 조회
$stmt = $pdo->prepare("SELECT * FROM package_pricing WHERE is_active = 1 ORDER BY item_type, item_id");
$stmt->execute();
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
// JSON 필드 파싱
foreach ($items as &$item) {
if ($item['commission_rates']) {
$item['commission_rates'] = json_decode($item['commission_rates'], true);
}
$item['join_fee'] = floatval($item['join_fee']);
$item['subscription_fee'] = floatval($item['subscription_fee']);
$item['total_amount'] = $item['total_amount'] ? floatval($item['total_amount']) : null;
$item['allow_flexible_pricing'] = (bool)$item['allow_flexible_pricing'];
}
echo json_encode(['success' => true, 'data' => $items], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
} elseif ($action === 'get') {
// 단일 항목 조회
$item_type = $_GET['item_type'] ?? '';
$item_id = $_GET['item_id'] ?? '';
if (!$item_type || !$item_id) {
throw new Exception("item_type과 item_id가 필요합니다.");
}
$stmt = $pdo->prepare("SELECT * FROM package_pricing WHERE item_type = ? AND item_id = ? AND is_active = 1");
$stmt->execute([$item_type, $item_id]);
$item = $stmt->fetch(PDO::FETCH_ASSOC);
if ($item) {
if ($item['commission_rates']) {
$item['commission_rates'] = json_decode($item['commission_rates'], true);
}
$item['join_fee'] = floatval($item['join_fee']);
$item['subscription_fee'] = floatval($item['subscription_fee']);
}
echo json_encode(['success' => true, 'data' => $item], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
} else {
throw new Exception("잘못된 action입니다.");
}
break;
case 'POST':
// 새 항목 생성
$data = json_decode(file_get_contents('php://input'), true);
if (!isset($data['item_type']) || !isset($data['item_id']) || !isset($data['item_name'])) {
throw new Exception("필수 필드가 누락되었습니다.");
}
$item_type = $data['item_type'];
$item_id = $data['item_id'];
$item_name = $data['item_name'];
$sub_name = $data['sub_name'] ?? null;
$join_fee = floatval($data['join_fee'] ?? 0);
$subscription_fee = floatval($data['subscription_fee'] ?? 0);
$commission_rates = isset($data['commission_rates']) ? json_encode($data['commission_rates'], JSON_UNESCAPED_UNICODE) : null;
$stmt = $pdo->prepare("
INSERT INTO package_pricing (item_type, item_id, item_name, sub_name, join_fee, subscription_fee, commission_rates)
VALUES (?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([$item_type, $item_id, $item_name, $sub_name, $join_fee, $subscription_fee, $commission_rates]);
echo json_encode(['success' => true, 'message' => '항목이 생성되었습니다.', 'id' => $pdo->lastInsertId()], JSON_UNESCAPED_UNICODE);
break;
case 'PUT':
// 항목 수정
$data = json_decode(file_get_contents('php://input'), true);
if (!isset($data['item_type']) || !isset($data['item_id'])) {
throw new Exception("item_type과 item_id가 필요합니다.");
}
$item_type = $data['item_type'];
$item_id = $data['item_id'];
$updates = [];
$params = [];
if (isset($data['join_fee'])) {
$updates[] = "join_fee = ?";
$params[] = floatval($data['join_fee']);
}
if (isset($data['subscription_fee'])) {
$updates[] = "subscription_fee = ?";
$params[] = floatval($data['subscription_fee']);
}
if (isset($data['total_amount'])) {
$updates[] = "total_amount = ?";
$params[] = $data['total_amount'] !== null ? floatval($data['total_amount']) : null;
}
if (isset($data['allow_flexible_pricing'])) {
$updates[] = "allow_flexible_pricing = ?";
$params[] = intval($data['allow_flexible_pricing']);
}
if (isset($data['commission_rates'])) {
$updates[] = "commission_rates = ?";
$params[] = json_encode($data['commission_rates'], JSON_UNESCAPED_UNICODE);
}
if (isset($data['item_name'])) {
$updates[] = "item_name = ?";
$params[] = $data['item_name'];
}
if (isset($data['sub_name'])) {
$updates[] = "sub_name = ?";
$params[] = $data['sub_name'];
}
if (empty($updates)) {
throw new Exception("수정할 필드가 없습니다.");
}
$params[] = $item_type;
$params[] = $item_id;
$sql = "UPDATE package_pricing SET " . implode(", ", $updates) . " WHERE item_type = ? AND item_id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
echo json_encode(['success' => true, 'message' => '항목이 수정되었습니다.'], JSON_UNESCAPED_UNICODE);
break;
case 'DELETE':
// 항목 삭제 (soft delete)
$item_type = $_GET['item_type'] ?? '';
$item_id = $_GET['item_id'] ?? '';
if (!$item_type || !$item_id) {
throw new Exception("item_type과 item_id가 필요합니다.");
}
$stmt = $pdo->prepare("UPDATE package_pricing SET is_active = 0 WHERE item_type = ? AND item_id = ?");
$stmt->execute([$item_type, $item_id]);
echo json_encode(['success' => true, 'message' => '항목이 삭제되었습니다.'], JSON_UNESCAPED_UNICODE);
break;
default:
throw new Exception("지원하지 않는 HTTP 메서드입니다.");
}
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
}

2822
salesmanagement/index.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
# CodeBridgeExy 영업 수당 체계 UI 계획서
## 1. 프로젝트 개요
**프로젝트명**: CodeBridgeExy 영업 수당 관리 및 시뮬레이션 시스템
**목적**: 엑셀로 관리되던 영업 수당 체계(`sample.xlsx`)를 웹 기반 UI로 전환하여, 영업 사원 및 관리자가 수당을 쉽게 계산하고 시뮬레이션하며, 실적을 관리할 수 있도록 함.
## 2. 주요 기능 및 UI 구성
### 2.1 대시보드 (Dashboard)
* **개요**: 전체 영업 현황 및 수당 지급 현황을 한눈에 파악.
* **주요 지표 (KPI)**:
* 총 매출액 (Total Sales)
* 총 지급 수당 (Total Commission Paid)
* 이번 달 예상 수당 (Estimated Commission)
* 프로그램별 판매 비중 (Pie Chart)
* **디자인 컨셉**: Glassmorphism 카드 디자인, 다크/라이트 모드 지원, 동적 그래프 (Chart.js or Recharts).
### 2.2 수당 시뮬레이터 (Commission Simulator)
* **기능**: 프로그램 유형과 조건을 입력하면 예상 수당을 자동으로 계산하여 보여줌.
* **입력 항목**:
* 프로그램 선택 (QR코드, 사진 관리 등)
* 계약 기간 (기본 7년/84개월)
* 가입비 및 구독료 설정 (기본값 자동 로드, 수정 가능)
* **출력 항목 (실시간 계산)**:
* **판매자 수당 (Seller)**: 가입비의 20% + 구독료의 50%
* **영업 관리자 수당 (Manager)**: 가입비의 5% + 구독료의 30%
* **교육 지원자 수당 (Educator)**: 가입비의 3% + 구독료의 20%
* **회사 마진 (Company Margin)**: 가입비의 70% 등
* **UI 구성**: 좌측 입력 폼, 우측 결과 카드 (영수증 형태 또는 카드 형태).
### 2.3 프로그램 및 수당 기준 관리 (Admin)
* **기능**: `sample.xlsx`의 기준 데이터를 관리 (CRUD).
* **데이터 테이블**:
* 프로그램명
* 단가
* 구독료
* 수당 배분율 (판매자, 관리자, 교육자)
* **UI 구성**: 정렬 및 필터링이 가능한 데이터 그리드.
## 3. 데이터 모델 (Data Model)
`sample.xlsx` 분석 기반:
| 필드명 | 설명 | 예시 데이터 |
| :--- | :--- | :--- |
| `program_type` | 프로그램 타입 | QR코드, 사진 관리 |
| `unit_price` | 프로그램 단가 | 10,400,000 |
| `subscription_fee` | 월 구독료 | 100,000 |
| `duration_months` | 계약 기간 | 84 |
| `join_fee` | 가입비 | 2,000,000 |
| `commission_seller_join_rate` | 판매자 가입비 수당율 | 20% |
| `commission_seller_sub_rate` | 판매자 구독료 수당율 | 50% |
| `commission_manager_join_rate` | 관리자 가입비 수당율 | 5% |
| `commission_manager_sub_rate` | 관리자 구독료 수당율 | 30% |
## 4. 기술 스택 (제안)
* **Frontend**: React (Next.js)
* **Styling**: Tailwind CSS (Premium Design, Responsive)
* **State Management**: Zustand or Context API
* **Charts**: Recharts or Chart.js
* **Icons**: Lucide React or Heroicons
## 5. 디자인 가이드 (Aesthetics)
* **Color Palette**: 신뢰감을 주는 Deep Blue & Purple Gradients.
* **Typography**: Pretendard or Inter (가독성 최우선).
* **Interaction**: 버튼 호버 효과, 모달 등장 애니메이션, 수치 카운트업 효과.
## 6. 개발 단계
1. **기획 및 디자인**: 본 계획서 확정 및 와이어프레임 작성.
2. **프론트엔드 개발**: 컴포넌트 개발 및 시뮬레이션 로직 구현.
3. **데이터 연동**: (백엔드 필요 시) API 연동 또는 로컬 목업 데이터 사용.
4. **배포 및 테스트**.

View File

@@ -0,0 +1,41 @@
### 2.2 엔드포인트
* **URL**: `/api/v1/company/sales-config` (예시)
* **Method**: `GET`
* **Headers**:
* `X-Tenant-ID`: `{company_id}` (또는 세션/토큰 기반 인증)
### 2.3 응답 데이터 구조 (JSON)
```json
{
"company_info": {
"id": "comp_12345",
"name": "건축자재(주)",
3. **Commission Simulator**:
* 프로그램 선택 Dropdown (API에서 로드된 프로그램 목록).
* 실시간 수당 계산기.
4. **Sales List & Detail Modal** (New):
* **Sales List**: API에서 가져온 `sales_records`를 테이블 형태로 표시.
* **Detail Modal**:
* **기본 정보**: 5가지 주요 일자(계약일, 가입비일, 구독료일, 시작일, 수정일) 표시.
* **수당 상세**: 현재 상품 기준 수당 계산.
* **히스토리**: 상품 변경 이력(타임라인) 표시.
5. **Sub-Manager Management** (New):
* **Hierarchy View**: 내 하위 관리자/영업사원 목록 표시.
* **Performance**: 하위 관리자의 실적(매출, 계약 건수) 요약 표시.
* **Detail Modal**: 하위 관리자 클릭 시, 상세 실적 요약(매출, 등급 등)을 모달로 표시.
### 3.3 디자인 적용 (`tone.md` 준수)
* **Background**: `bg-gray-50` (rgb(250, 250, 250))
* **Font**: `font-sans` (Pretendard)
* **Primary Color**: `text-blue-600`, `bg-blue-600` (버튼 등)
* **Cards**: `bg-white rounded-xl shadow-sm p-6`
## 4. 개발 단계
1. **API Mocking**: `api_mock.json` 또는 PHP 파일로 간단한 Mock API 구현.
2. **UI Skeleton**: `index.php`에 HTML 구조 및 Tailwind CSS 로드.
3. **Data Fetching**: `fetch()`를 사용하여 API에서 회사 정보 로드.
4. **Component Rendering**: 로드된 데이터를 기반으로 UI 렌더링.
## 5. User Review Required
* **API 방식**: 별도의 백엔드 프레임워크 없이 PHP로 간단한 JSON 응답을 주는 API를 `api/company_info.php`와 같이 만들어서 테스트하시겠습니까?
* **Frontend 환경**: `index.php` 파일 하나에서 작업하려면 React CDN 방식이 간편합니다. Node.js 기반의 Next.js 프로젝트로 완전히 분리하시겠습니까? (현재 폴더 구조상 `index.php` 수정을 요청하셨으므로 **PHP + JS** 방식을 추천합니다.)

73
salesmanagement/tone.md Normal file
View File

@@ -0,0 +1,73 @@
# CodeBridgeExy Design Tone & Manner
## 1. Overview
This document defines the visual style and design tokens extracted from the CodeBridgeExy Dashboard (`https://dev.codebridge-x.com/dashboard`). The design aims for a clean, modern, and professional look suitable for an enterprise ERP system.
**Reference Screenshot**:
![Dashboard View](/Users/light/.gemini/antigravity/brain/9dc672eb-e699-4b4a-8834-f71fa81165a9/dashboard_view_1764110406679.png)
## 2. Color Palette
### Backgrounds
* **Page Background**: `rgb(250, 250, 250)` (Light Gray / Off-White) - Creates a clean canvas.
* **Card/Container Background**: `White` (`#ffffff`) or very light transparency - Used for content areas to separate them from the page background.
### Text Colors
* **Headings**: `rgb(255, 255, 255)` (White) - Primarily used on dark sidebars or headers.
* **Body Text**: `oklch(0.551 0.027 264.364)` (Dark Slate Blue/Gray) - High contrast for readability on light backgrounds.
* **Muted Text**: Gray-400/500 (Inferred) - For secondary labels.
### Brand Colors
* **Primary Blue**: (Inferred from "경영분석" button) - Likely a standard corporate blue (e.g., Tailwind `blue-600` or `indigo-600`). Used for primary actions and active states.
## 3. Typography
### Font Family
* **Primary**: `Pretendard`
* **Fallback**: `-apple-system, BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", sans-serif`
### Weights
* **Regular**: 400 (Body text)
* **Medium/Bold**: 500/700 (Headings, Buttons)
## 4. UI Components & Layout
### Cards
* **Border Radius**: `12px` (Rounded-lg/xl) - Soft, modern feel.
* **Shadow**: Subtle shadows (e.g., `shadow-sm` or `shadow-md`) to lift content off the background.
* **Padding**: Spacious internal padding to avoid clutter.
### Buttons
* **Shape**: Rounded corners (matching cards, likely `8px` or `12px`).
* **Style**: Solid background for primary, outlined/ghost for secondary.
### Layout Density
* **Spacious**: The design uses whitespace effectively to separate sections, typical of modern dashboards.
## 5. Implementation Guide (Tailwind CSS)
To match this tone in the new Sales Commission System:
```js
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
background: 'rgb(250, 250, 250)',
foreground: 'oklch(0.551 0.027 264.364)', // Adjust to closest Hex
primary: {
DEFAULT: '#2563eb', // Placeholder for Brand Blue
foreground: '#ffffff',
},
},
fontFamily: {
sans: ['Pretendard', 'sans-serif'],
},
borderRadius: {
'card': '12px',
}
},
},
}
```

View File

@@ -0,0 +1,584 @@
# 영업관리 수당 지급 체계
## 개요
이 문서는 영업관리 시스템의 수당 지급 체계를 정의합니다.
수당은 **가입비**에 대해서만 지급되며, 구독료에 대한 수당은 지급되지 않습니다.
---
## 1. 기본 원칙
### 1.1 수당 지급 대상
- **가입비만 수당 지급 대상**
- 구독료는 수당 계산에서 제외
### 1.2 계층 구조
```
내 조직 (영업관리자)
├── 내 직접 판매 (20%)
├── 1차 하위 (직속) (5%)
│ └── 2차 하위 (손자) (3%)
└── 1차 하위 (직속) (5%)
```
### 1.3 핵심 개념
- **직접 판매**: 내가 직접 판매한 계약
- **1차 하위 (관리자 수당)**: 내가 초대한 영업관리가 판매한 계약
- **2차 하위 (교육자 수당)**: 내 하위의 하위가 판매한 계약
---
## 2. 수당 지급 비율
| 구분 | 지급 비율 | 설명 |
|------|-----------|------|
| **직접 판매** | 가입비의 **20%** | 본인이 직접 판매한 계약의 가입비 |
| **관리자 수당** | 가입비의 **5%** | 직속 하위(1차)가 판매한 계약의 가입비 |
| **교육자 수당** | 가입비의 **3%** | 2차 하위(손자)가 판매한 계약의 가입비 |
**총 수당 = 직접 판매(20%) + 관리자 수당(5%) + 교육자 수당(3%)**
---
## 3. 계층 구조 상세 설명
### 3.1 역할 정의
#### 판매자 (Seller)
- 직접 영업을 성사시킨 담당자
- 자신의 판매에 대해 **20%** 수당
#### 관리자 (Manager)
- 판매자를 데려온 상위 담당자
- 하위 1단계의 판매에 대해 **5%** 수당
#### 교육자 (Educator)
- 관리자를 데려온 상위 담당자
- 하위 2단계의 판매에 대해 **3%** 수당
### 3.2 계층 예시
```
A (영업관리자)
├── A의 직접 판매: 1,000만원
├── B (1차 하위)
│ └── B의 판매: 2,000만원
└── C (1차 하위)
├── C의 판매: 1,500만원
└── D (2차 하위)
└── D의 판매: 3,000만원
```
**A가 받는 수당:**
- A의 직접 판매: 1,000만원 × 20% = **200만원**
- B의 판매 (관리자 수당): 2,000만원 × 5% = **100만원**
- C의 판매 (관리자 수당): 1,500만원 × 5% = **75만원**
- D의 판매 (교육자 수당): 3,000만원 × 3% = **90만원**
- **A의 총 수당: 465만원**
---
## 4. 수당 계산 로직
### 4.1 내 총 수당 계산 공식
```
내 총 수당 = (내 직접 판매 × 20%)
+ (1차 하위 전체 판매 × 5%)
+ (2차 하위 전체 판매 × 3%)
```
### 4.2 JavaScript 계산 코드
```javascript
// 내 직접 판매 수당 (20%)
const myDirectSales = findDirectSales(myOrg);
const sellerCommission = myDirectSales * 0.20;
// 1차 하위 관리자 수당 (5%)
const level1Sales = calculateLevel1TotalSales(myOrg);
const managerCommission = level1Sales * 0.05;
// 2차 하위 교육자 수당 (3%)
const level2Sales = calculateLevel2TotalSales(myOrg);
const educatorCommission = level2Sales * 0.03;
// 총 수당
const totalCommission = sellerCommission + managerCommission + educatorCommission;
```
---
## 5. 지급 일정
### 5.1 계약일 정의
- **계약일**: 가입비 완료일을 기준으로 함
### 5.2 수당 지급일
- **가입비 수당**: 가입비 완료 후 지급
- 구독료 수당은 없음 (구독료는 수당 대상 아님)
---
## 6. 회사 마진
### 6.1 마진 계산
```
회사 마진 = 가입비 - 총 수당
= 가입비 - (20% + 5% + 3%)
= 가입비 × 72%
```
### 6.2 최대 수당 비율
- 3단계 전체가 채워진 경우 최대 **28%** 수당
- 나머지 **72%**는 회사 마진
---
## 7. 특수 상황 처리
### 7.1 하위가 없는 경우
- 1차 하위가 없으면: 관리자 수당(5%) 없음
- 2차 하위가 없으면: 교육자 수당(3%) 없음
- 본인의 직접 판매(20%)만 받음
### 7.2 계약 취소/해지
- 이미 지급된 가입비 수당은 회수하지 않음
- 향후 정책 보완 예정
---
## 8. 대시보드 표시 구조
### 8.1 전체 누적 실적
- **총 가입비**: 전체 기간 누적 가입비
- **총 수당**: 전체 기간 누적 수당
- **전체 건수**: 전체 계약 건수
### 8.2 기간별 실적 (당월 기본)
- **판매자 수당 (20%)**: 기간 내 직접 판매 수당
- **관리자 수당 (5%)**: 기간 내 1차 하위 관리 수당
- **교육자 수당 (3%)**: 기간 내 2차 하위 교육 수당
- **총 수당**: 세 가지 수당의 합계
### 8.3 기간 선택 옵션
- **당월**: 현재 년월 (기본값)
- **기간 설정**: 시작 년월 ~ 종료 년월
### 8.4 기간별 필터링
- 각 계약의 `contractDate` 기준으로 필터링
- 선택된 기간 내 계약만 집계
- 조직 트리도 동일 기간으로 필터링
---
## 9. 조직 구조 표시
### 9.1 계층별 색상
- **Depth 0** (내 조직): 파란색
- **Depth 1** (1차 하위): 초록색
- **Depth 2** (2차 하위): 보라색
- **직접 판매**: 노란색
### 9.2 표시 정보
각 노드마다 표시:
- 이름 및 역할
- 총 매출 (가입비)
- 계약 건수
- **내 수당**: 해당 노드의 판매로부터 내가 받는 수당
### 9.3 직접 판매 항목
- 오직 **"내 조직"**만 직접 판매 항목을 가짐
- 1차, 2차 영업관리는 자신의 판매가 자동으로 집계됨
- 노란색 배경, 쇼핑카트 아이콘으로 구분
---
## 10. 데이터 구조
### 10.1 조직 노드 구조
```javascript
{
id: 'unique-id',
name: '김철수',
depth: 1, // 0: 내 조직, 1: 1차 하위, 2: 2차 하위
role: '영업관리',
isDirect: false, // 직접 판매 항목 여부
totalSales: 50000000, // 총 매출 (가입비)
contractCount: 15,
commission: 2500000, // 내가 받는 수당
contracts: [ // 계약 목록 (날짜 포함)
{ id: 'c1', contractDate: '2024-11-15', amount: 25000000 },
{ id: 'c2', contractDate: '2024-12-01', amount: 25000000 }
],
children: [ /* 하위 노드 */ ]
}
```
### 10.2 직접 판매 노드
```javascript
{
id: 'root-direct',
name: '내 직접 판매',
depth: 0,
role: '직접 판매',
isDirect: true,
totalSales: 30000000,
contractCount: 3,
commission: 6000000, // 3천만원 × 20%
contracts: [
{ id: 'c1', contractDate: '2024-12-01', amount: 10000000 },
{ id: 'c2', contractDate: '2024-12-15', amount: 20000000 }
],
children: []
}
```
### 10.3 계약 데이터 구조
```javascript
{
id: 'contract-123',
contractDate: '2024-12-01', // YYYY-MM-DD 형식
amount: 25000000 // 가입비
}
```
---
## 11. 기간별 필터링 로직
### 11.1 필터링 알고리즘
```javascript
filterNodeByDate(node, startDate, endDate) {
// 1. 해당 노드의 계약 중 기간 내 계약만 필터링
const filteredContracts = node.contracts.filter(contract => {
const contractDate = new Date(contract.contractDate);
return contractDate >= startDate && contractDate <= endDate;
});
// 2. 하위 노드도 재귀적으로 필터링
const filteredChildren = node.children
.map(child => filterNodeByDate(child, startDate, endDate))
.filter(c => c !== null);
// 3. 매출 재계산
const ownSales = filteredContracts.reduce((sum, c) => sum + c.amount, 0);
const childrenSales = filteredChildren.reduce((sum, c) => sum + c.totalSales, 0);
const totalSales = ownSales + childrenSales;
// 4. 데이터가 없으면 null 반환 (표시 안함)
if (totalSales === 0 && filteredChildren.length === 0) {
return null;
}
// 5. 수당 재계산
const commission = calculateCommission(node, ownSales, childrenSales);
return { ...node, totalSales, contractCount, commission, contracts: filteredContracts, children: filteredChildren };
}
```
### 11.2 기간 옵션
- **당월**: `new Date(year, month, 1)` ~ `new Date(year, month+1, 0)`
- **커스텀**: 사용자가 선택한 시작일 ~ 종료일
---
## 12. 운영자 화면 - 영업담당 관리
### 12.1 개요
운영자는 모든 영업담당의 실적과 수당을 역할별로 구분하여 확인할 수 있습니다.
### 12.2 통계 카드 (6개)
| 카드 | 설명 | 계산 방식 |
|------|------|-----------|
| **총 건수** | 전체 계약 건수 | 모든 영업담당의 계약 건수 합계 |
| **이번달 건수** | 이번달 신규 계약 | 당월 계약 건수 합계 |
| **총 가입비** | 전체 누적 가입비 | 모든 계약의 가입비 합계 |
| **총 수당 지급** | 전체 누적 수당 | 모든 영업담당에게 지급한 수당 합계 |
| **이번달 수당** | 이번달 지급 예정 | 당월 계약에 대한 수당 합계 |
| **지난달 수당** | 지난달 지급 완료 | 전월 계약에 대한 수당 합계 |
### 12.3 영업담당 개별 카드 구조
각 영업담당 카드에 표시:
```
김철수 [아이콘]
├── 총 건수: 15건
├── 이번달 건수: 3건
├── 총 가입비: ₩75,000,000
├── └ 직접 판매 (20%): ₩6,000,000
├── └ 관리자 (5%): ₩2,000,000
├── └ 교육자 (3%): ₩900,000
├── 총 수당: ₩8,900,000
├── 이번달 수당: ₩1,200,000
└── 지난달 수당: ₩1,500,000
```
### 12.4 역할별 계약 데이터 구조
```javascript
{
id: 'contract-123',
customer: '고객사 A',
contractDate: '2024-12-01',
amount: 25000000,
role: 'direct' // 'direct', 'manager', 'educator'
}
```
**역할 타입:**
- `direct`: 직접 판매 (20% 수당)
- `manager`: 1차 하위 관리 (5% 수당)
- `educator`: 2차 하위 교육 (3% 수당)
### 12.5 세부 내역 화면
영업담당 클릭 시 표시:
#### **역할별 수당 요약 (3개 카드)**
| 직접 판매 (20%) | 관리자 수당 (5%) | 교육자 수당 (3%) |
|-----------------|-----------------|-----------------|
| 🟢 ₩6,042,670 | 🟣 ₩2,362,340 | 🟠 ₩914,256 |
| ₩30,213,350 × 20% | ₩47,246,800 × 5% | ₩30,475,200 × 3% |
#### **계약 목록 테이블**
| 번호 | 고객사 | 계약일 | 역할 | 가입비 | 수당 |
|------|--------|--------|------|--------|------|
| 1 | 고객사 A | 2024-11-15 | 🟢 직접 판매 (20%) | ₩25,000,000 | ₩5,000,000 |
| 2 | 고객사 B | 2024-12-01 | 🟣 관리자 (5%) | ₩30,000,000 | ₩1,500,000 |
| 3 | 고객사 C | 2024-10-20 | 🟠 교육자 (3%) | ₩20,000,000 | ₩600,000 |
| ... | ... | ... | ... | ... | ... |
| **합계** | | | | **₩107,689,150** | **₩9,477,266** |
### 12.6 역할별 색상 구분
- 🟢 **초록색**: 직접 판매 (20%)
- 🟣 **보라색**: 관리자 (5%)
- 🟠 **주황색**: 교육자 (3%)
### 12.7 계약 생성 로직
```javascript
// 각 영업담당마다 역할별 계약 생성
const directContracts = generateContracts(2~6); // 직접 판매
const managerContracts = generateContracts(3~10); // 1차 하위
const educatorContracts = generateContracts(1~5); // 2차 하위
// 역할 구분
directContracts.forEach(c => c.role = 'direct');
managerContracts.forEach(c => c.role = 'manager');
educatorContracts.forEach(c => c.role = 'educator');
// 수당 계산
const directCommission = directSales × 0.20;
const managerCommission = managerSales × 0.05;
const educatorCommission = educatorSales × 0.03;
const totalCommission = directCommission + managerCommission + educatorCommission;
```
---
## 12. 운영자 화면 - 영업담당 관리
### 12.1 개요
운영자는 모든 영업담당의 실적과 수당을 역할별로 구분하여 확인할 수 있습니다.
### 12.2 통계 카드 (6개)
| 카드 | 설명 | 계산 방식 |
|------|------|-----------|
| **총 건수** | 전체 계약 건수 | 모든 영업담당의 계약 건수 합계 |
| **이번달 건수** | 이번달 신규 계약 | 당월 계약 건수 합계 |
| **총 가입비** | 전체 누적 가입비 | 모든 계약의 가입비 합계 |
| **총 수당 지급** | 전체 누적 수당 | 모든 영업담당에게 지급한 수당 합계 |
| **이번달 수당** | 이번달 지급 예정 | 당월 계약에 대한 수당 합계 |
| **지난달 수당** | 지난달 지급 완료 | 전월 계약에 대한 수당 합계 |
### 12.3 영업담당 개별 카드 구조
각 영업담당 카드에 표시:
```
김철수 [아이콘]
├── 총 건수: 15건
├── 이번달 건수: 3건
├── 총 가입비: ₩75,000,000
├── └ 직접 판매 (20%): ₩6,000,000
├── └ 관리자 (5%): ₩2,000,000
├── └ 교육자 (3%): ₩900,000
├── 총 수당: ₩8,900,000
├── 이번달 수당: ₩1,200,000
└── 지난달 수당: ₩1,500,000
```
### 12.4 역할별 계약 데이터 구조
```javascript
{
id: 'contract-123',
customer: '고객사 A',
contractDate: '2024-12-01',
amount: 25000000,
role: 'direct' // 'direct', 'manager', 'educator'
}
```
**역할 타입:**
- `direct`: 직접 판매 (20% 수당)
- `manager`: 1차 하위 관리 (5% 수당)
- `educator`: 2차 하위 교육 (3% 수당)
### 12.5 세부 내역 화면
영업담당 클릭 시 표시:
#### **역할별 수당 요약 (3개 카드)**
| 직접 판매 (20%) | 관리자 수당 (5%) | 교육자 수당 (3%) |
|-----------------|-----------------|-----------------|
| 🟢 ₩6,042,670 | 🟣 ₩2,362,340 | 🟠 ₩914,256 |
| ₩30,213,350 × 20% | ₩47,246,800 × 5% | ₩30,475,200 × 3% |
#### **계약 목록 테이블**
| 번호 | 고객사 | 계약일 | 역할 | 가입비 | 수당 |
|------|--------|--------|------|--------|------|
| 1 | 고객사 A | 2024-11-15 | 🟢 직접 판매 (20%) | ₩25,000,000 | ₩5,000,000 |
| 2 | 고객사 B | 2024-12-01 | 🟣 관리자 (5%) | ₩30,000,000 | ₩1,500,000 |
| 3 | 고객사 C | 2024-10-20 | 🟠 교육자 (3%) | ₩20,000,000 | ₩600,000 |
| ... | ... | ... | ... | ... | ... |
| **합계** | | | | **₩107,689,150** | **₩9,477,266** |
### 12.6 역할별 색상 구분
- 🟢 **초록색**: 직접 판매 (20%)
- 🟣 **보라색**: 관리자 (5%)
- 🟠 **주황색**: 교육자 (3%)
### 12.7 계약 생성 로직
```javascript
// 각 영업담당마다 역할별 계약 생성
const directContracts = generateContracts(2~6); // 직접 판매
const managerContracts = generateContracts(3~10); // 1차 하위
const educatorContracts = generateContracts(1~5); // 2차 하위
// 역할 구분
directContracts.forEach(c => c.role = 'direct');
managerContracts.forEach(c => c.role = 'manager');
educatorContracts.forEach(c => c.role = 'educator');
// 수당 계산
const directCommission = directSales × 0.20;
const managerCommission = managerSales × 0.05;
const educatorCommission = educatorSales × 0.03;
const totalCommission = directCommission + managerCommission + educatorCommission;
```
---
## 13. API 연동 (향후 구현)
### 13.1 필요한 API 엔드포인트
```
GET /api/sales/organization?startDate=2024-12-01&endDate=2024-12-31
GET /api/operator/managers?startDate=2024-12-01&endDate=2024-12-31
```
### 13.2 응답 데이터 구조
위의 "10. 데이터 구조"와 동일한 형식으로 반환
---
## 14. 개발 노트
### 14.1 구현 완료
- ✅ 가입비 기반 수당 계산
- ✅ 3단계 계층 구조 (직접/1차/2차)
- ✅ 계약 날짜 기반 기간 필터링
- ✅ 대시보드 통계 연동
- ✅ 역할별 수당 상세 표시
- ✅ 계약 날짜 데이터 구조 추가
- ✅ 운영자 화면 영업담당 관리
- ✅ 역할별 계약 구분 (직접/관리자/교육자)
- ✅ 영업담당 세부 내역 역할별 표시
- ✅ 모달창 계약 상세 내역 및 날짜 표시
### 14.2 향후 개선 사항
- [ ] 실제 DB 연동
- [ ] 계약 취소/해지 처리 로직
- [ ] 수당 지급 이력 관리
- [ ] 월별 수당 지급 스케줄
- [ ] 세금 공제 계산
- [ ] 운영자 화면 실시간 데이터 연동
- [ ] 영업담당별 성과 분석 리포트
### 14.3 주의사항
- 현재는 샘플 랜덤 데이터 사용 (최근 12개월 내 랜덤 날짜)
- 실제 구현 시 DB의 계약 데이터 기반으로 변경 필요
- PHP 7.3 호환성 유지
- 운영자 화면은 각 영업담당의 역할별 계약을 모두 표시
- 각 영업담당은 직접 판매, 관리자, 교육자 역할을 동시에 수행 가능
---
## 15. 변경 이력
| 날짜 | 버전 | 변경 내용 |
|------|------|-----------|
| 2024-12-02 | 1.0 | 초기 문서 작성 - 가입비 기반 수당 체계 정의 |
| 2024-12-02 | 1.1 | 계약 날짜 구조 추가 - 기간별 필터링 로직 문서화 |
| 2024-12-02 | 1.2 | 운영자 화면 추가 - 역할별 계약 구분 및 수당 관리 상세화 |
---
## 16. 운영자 화면 계산 예시
### 16.1 영업담당 "김철수"의 데이터
**역할별 계약:**
- 직접 판매: 3건, 총 ₩30,000,000
- 관리자 역할: 8건, 총 ₩100,000,000 (1차 하위의 판매)
- 교육자 역할: 5건, 총 ₩50,000,000 (2차 하위의 판매)
**수당 계산:**
```
직접 판매 수당 = ₩30,000,000 × 20% = ₩6,000,000
관리자 수당 = ₩100,000,000 × 5% = ₩5,000,000
교육자 수당 = ₩50,000,000 × 3% = ₩1,500,000
───────────────────────────────────────────────
총 수당 = ₩12,500,000
```
**카드 표시:**
```
김철수
├── 총 건수: 16건
├── 이번달 건수: 3건
├── 총 가입비: ₩180,000,000
├── └ 직접 판매 (20%): ₩6,000,000
├── └ 관리자 (5%): ₩5,000,000
├── └ 교육자 (3%): ₩1,500,000
└── 총 수당: ₩12,500,000
```
### 16.2 세부 내역 테이블 예시
| 번호 | 고객사 | 계약일 | 역할 | 가입비 | 수당 |
|------|--------|--------|------|--------|------|
| 1 | 고객사 A | 2024-11-15 | 직접 판매 (20%) | ₩10,000,000 | ₩2,000,000 |
| 2 | 고객사 B | 2024-12-01 | 직접 판매 (20%) | ₩20,000,000 | ₩4,000,000 |
| 3 | 고객사 C | 2024-10-20 | 관리자 (5%) | ₩25,000,000 | ₩1,250,000 |
| 4 | 고객사 D | 2024-11-03 | 관리자 (5%) | ₩35,000,000 | ₩1,750,000 |
| 5 | 고객사 E | 2024-09-15 | 교육자 (3%) | ₩20,000,000 | ₩600,000 |
| 6 | 고객사 F | 2024-12-10 | 교육자 (3%) | ₩30,000,000 | ₩900,000 |
| **합계** | | | | **₩180,000,000** | **₩12,500,000** |
---
## 문의 및 개선 제안
개발 과정에서 수당 체계 관련 질문이나 개선 제안 사항이 있으면 이 문서를 업데이트하여 관리합니다.

27
session.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Load .env
$envFile = __DIR__ . '/.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
list($key, $value) = explode('=', $line, 2);
$_ENV[trim($key)] = trim($value);
}
}
}
$level = isset($_SESSION["level"]) ? $_SESSION["level"] : '';
$user_name = isset($_SESSION["name"]) ? $_SESSION["name"] : '';
$user_id = isset($_SESSION["userid"]) ? $_SESSION["userid"] : '';
$DB = isset($_SESSION["DB"]) ? $_SESSION["DB"] : 'chandj';
// Dynamic URL setup
$protocol = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? "https://" : "http://";
$root_dir = $protocol . $_SERVER['HTTP_HOST'];
$WebSite = $root_dir . '/';
?>