영업자료 업그레이드
This commit is contained in:
BIN
img/sales13.jpg
Normal file
BIN
img/sales13.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 573 KiB |
11
index.php
11
index.php
@@ -467,6 +467,17 @@
|
||||
pdf: 'pdf/sales12.pdf',
|
||||
script: "우리 회사에 맞을까 고민하지 마세요. 대표님 업종에 딱 맞춘 대시보드를 미리 구성해서 보여드립니다. 3일만 직접 써보시면, 왜 SAM이 필수인지 바로 아실 겁니다.",
|
||||
tags: ['Call to Action', 'Demo', 'Free Trial'],
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
title: 'SAM의 비교 분석',
|
||||
image: 'img/sales13.jpg',
|
||||
description: "SAM은 복잡한 기계실의 수많은 계기판(기존 ERP) 대신, 조종석 정면에 떠오르는 핵심 정보창(HUD)과 같아서, 경영자가 복잡한 수치에 매몰되지 않고 회사의 나아갈 방향을 즉시 결정할 수 있도록 돕습니다.",
|
||||
video: '<div style="padding:56.25% 0 0 0;position:relative;"><iframe src="https://player.vimeo.com/video/1149068241?title=0&byline=0&portrait=0&badge=0&autopause=0&player_id=0&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="핵심 기능 (Core Features)"></iframe></div>',
|
||||
audio: 'm4a/sales13.m4a',
|
||||
pdf: 'pdf/sales13.pdf',
|
||||
script: "기존 ERP가 직원용 '도구'였다면, **SAM은 대표님의 의사결정을 위한 '무기'입니다. 로그인 10초 만에 AI가 요약한 5대 핵심 지표를 파악하고, '돈 들어오는 소리'와 함께 실시간으로 회사를 통제하십시오. 보고서 대기 없는 'AI 경영 비서' SAM으로, 이제 복잡한 데이터가 아닌 오직 '경영'에만 집중할 때입니다",
|
||||
tags: ['Comparison', 'AI', 'Efficiency'],
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
BIN
m4a/sales13.m4a
Normal file
BIN
m4a/sales13.m4a
Normal file
Binary file not shown.
BIN
pdf/sales13.pdf
Normal file
BIN
pdf/sales13.pdf
Normal file
Binary file not shown.
@@ -108,6 +108,22 @@
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
// --- Lucide Icon Wrapper ---
|
||||
const LucideIcon = ({ name, className = "w-4 h-4", onClick }) => {
|
||||
const ref = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if (ref.current && window.lucide) {
|
||||
const i = document.createElement('i');
|
||||
i.setAttribute('data-lucide', name);
|
||||
i.className = className;
|
||||
ref.current.innerHTML = '';
|
||||
ref.current.appendChild(i);
|
||||
window.lucide.createIcons({ root: ref.current });
|
||||
}
|
||||
}, [name, className]);
|
||||
return <span ref={ref} onClick={onClick} className={`inline-flex items-center justify-center ${className || ''}`} style={{ pointerEvents: onClick ? 'auto' : 'none' }}></span>;
|
||||
};
|
||||
|
||||
// --- Data: Manager Scenario Steps ---
|
||||
const SCENARIO_STEPS = [
|
||||
{
|
||||
@@ -311,7 +327,7 @@
|
||||
</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>
|
||||
<LucideIcon name="home" className="w-4 h-4" />
|
||||
홈으로
|
||||
</a>
|
||||
<div className="h-4 w-px bg-slate-200"></div>
|
||||
@@ -409,11 +425,11 @@
|
||||
<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>
|
||||
<LucideIcon name="lightbulb" className="w-5 h-5 text-yellow-500" />
|
||||
Manager 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>
|
||||
<LucideIcon name="x" className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -424,7 +440,7 @@
|
||||
<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>
|
||||
<LucideIcon name="thumbs-up" className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-blue-800 mb-1">실전 꿀팁</h5>
|
||||
@@ -489,17 +505,7 @@
|
||||
useEffect(() => {
|
||||
loadRecordings();
|
||||
|
||||
// 아이콘 생성 (컴포넌트 마운트 후)
|
||||
const iconTimer = setTimeout(() => {
|
||||
try {
|
||||
lucide.createIcons();
|
||||
} catch (error) {
|
||||
console.warn('Icon creation error:', error);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(iconTimer);
|
||||
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
|
||||
if (animationIdRef.current) cancelAnimationFrame(animationIdRef.current);
|
||||
if (streamRef.current) {
|
||||
@@ -509,23 +515,7 @@
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 리스트 업데이트 시 아이콘 재생성
|
||||
useEffect(() => {
|
||||
if (savedRecordings.length > 0) {
|
||||
const iconTimer = setTimeout(() => {
|
||||
try {
|
||||
// DOM이 준비되었는지 확인
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
} catch (error) {
|
||||
// 에러를 조용히 처리
|
||||
console.warn('Icon creation error (non-critical):', error);
|
||||
}
|
||||
}, 150);
|
||||
return () => clearTimeout(iconTimer);
|
||||
}
|
||||
}, [savedRecordings]);
|
||||
// 리스트 업데이트 시 목록 다시 불러오기 (이미 loadRecordings에서 처리중)
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -657,20 +647,24 @@
|
||||
};
|
||||
|
||||
recognition.onerror = (event) => {
|
||||
console.error('Speech recognition error:', event.error);
|
||||
|
||||
if (event.error === 'no-speech') {
|
||||
// 음성이 감지되지 않음 - 자동 재시작
|
||||
// Silence 'no-speech' console error and add a delay for restart
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
try {
|
||||
recognition.start();
|
||||
} catch (e) {
|
||||
console.log('Recognition restart failed:', e);
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
recognition.start();
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore subsequent errors on restart
|
||||
}
|
||||
}, 2000); // 2 second delay to prevent spam
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Speech recognition error:', event.error);
|
||||
|
||||
if (event.error === 'aborted') {
|
||||
// 사용자가 중단함
|
||||
return;
|
||||
@@ -782,16 +776,6 @@
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setSelectedRecording(result.data);
|
||||
// 모달 열릴 때 아이콘 생성
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Icon creation error:', error);
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
alert('데이터를 불러올 수 없습니다: ' + result.message);
|
||||
}
|
||||
@@ -804,16 +788,6 @@
|
||||
// 삭제 확인 함수
|
||||
const confirmDeleteRecording = (recordingId, date) => {
|
||||
setDeleteRecordingId(recordingId);
|
||||
// 모달 열릴 때 아이콘 생성
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Icon creation error:', error);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 삭제 실행 함수
|
||||
@@ -846,7 +820,7 @@
|
||||
<>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
|
||||
<h4 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<i data-lucide="mic" className="w-5 h-5 text-indigo-600"></i>
|
||||
<LucideIcon name="mic" className="w-5 h-5 text-indigo-600" />
|
||||
고객사 상담 녹음
|
||||
</h4>
|
||||
|
||||
@@ -859,7 +833,7 @@
|
||||
: 'bg-indigo-600 hover:bg-indigo-700'
|
||||
}`}
|
||||
>
|
||||
<i data-lucide={isRecording ? "square" : "mic"} className="w-8 h-8"></i>
|
||||
<LucideIcon name={isRecording ? "square" : "mic"} className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
@@ -889,7 +863,7 @@
|
||||
onClick={saveRecording}
|
||||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium transition-colors"
|
||||
>
|
||||
<i data-lucide="save" className="w-4 h-4 inline mr-2"></i>
|
||||
<LucideIcon name="save" className="w-4 h-4 inline mr-2" />
|
||||
저장하기
|
||||
</button>
|
||||
<button
|
||||
@@ -913,7 +887,7 @@
|
||||
<div className="mt-6 border-t border-slate-200 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h5 className="text-sm font-bold text-slate-700 flex items-center gap-2">
|
||||
<i data-lucide="list" className="w-4 h-4"></i>
|
||||
<LucideIcon name="list" className="w-4 h-4" />
|
||||
저장된 녹음 목록
|
||||
</h5>
|
||||
<button
|
||||
@@ -921,19 +895,19 @@
|
||||
className="text-xs text-indigo-600 hover:text-indigo-700 font-medium"
|
||||
disabled={loading}
|
||||
>
|
||||
<i data-lucide="refresh-cw" className={`w-3 h-3 inline mr-1 ${loading ? 'animate-spin' : ''}`}></i>
|
||||
<LucideIcon name="refresh-cw" className={`w-3 h-3 inline mr-1 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && savedRecordings.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-400 text-sm">
|
||||
<i data-lucide="loader" className="w-5 h-5 inline animate-spin mr-2"></i>
|
||||
<LucideIcon name="loader" className="w-5 h-5 inline animate-spin mr-2" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : savedRecordings.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-400 text-sm">
|
||||
<i data-lucide="inbox" className="w-8 h-8 mx-auto mb-2 opacity-50"></i>
|
||||
<LucideIcon name="inbox" className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
저장된 녹음이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
@@ -979,7 +953,7 @@
|
||||
className="p-1.5 text-slate-400 hover:text-indigo-600 transition-colors rounded"
|
||||
title="상세보기"
|
||||
>
|
||||
<i data-lucide="eye" className="w-4 h-4"></i>
|
||||
<LucideIcon name="eye" className="w-4 h-4" />
|
||||
</button>
|
||||
{recording.audio_file_path && !recording.is_gcs && (
|
||||
<a
|
||||
@@ -988,7 +962,7 @@
|
||||
className="p-1.5 text-slate-400 hover:text-indigo-600 transition-colors rounded"
|
||||
title="다운로드"
|
||||
>
|
||||
<i data-lucide="download" className="w-4 h-4"></i>
|
||||
<LucideIcon name="download" className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
@@ -996,7 +970,7 @@
|
||||
className="p-1.5 text-slate-400 hover:text-red-600 transition-colors rounded"
|
||||
title="삭제"
|
||||
>
|
||||
<i data-lucide="trash-2" className="w-4 h-4"></i>
|
||||
<LucideIcon name="trash-2" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1016,20 +990,20 @@
|
||||
<div className="p-6 border-b border-slate-200 flex justify-between items-center bg-slate-50 sticky top-0">
|
||||
<h3 className="text-lg font-bold text-slate-900">녹음 상세보기</h3>
|
||||
<button onClick={() => setSelectedRecording(null)} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
|
||||
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
|
||||
<LucideIcon name="x" className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<h6 className="text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<i data-lucide="calendar" className="w-4 h-4"></i>
|
||||
<LucideIcon name="calendar" className="w-4 h-4" />
|
||||
작성일
|
||||
</h6>
|
||||
<p className="text-sm text-slate-600">{selectedRecording.created_at_formatted}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<i data-lucide="file-text" className="w-4 h-4"></i>
|
||||
<LucideIcon name="file-text" className="w-4 h-4" />
|
||||
전체 텍스트
|
||||
</h6>
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200 text-sm text-slate-700 whitespace-pre-wrap max-h-96 overflow-y-auto">
|
||||
@@ -1039,7 +1013,7 @@
|
||||
{selectedRecording.audio_file_path && (
|
||||
<div>
|
||||
<h6 className="text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<i data-lucide="file-audio" className="w-4 h-4"></i>
|
||||
<LucideIcon name="file-audio" className="w-4 h-4" />
|
||||
파일 정보
|
||||
</h6>
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
@@ -1060,7 +1034,7 @@
|
||||
download
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium transition-colors"
|
||||
>
|
||||
<i data-lucide="download" className="w-4 h-4 inline mr-2"></i>
|
||||
<LucideIcon name="download" className="w-4 h-4 inline mr-2" />
|
||||
다운로드
|
||||
</a>
|
||||
)}
|
||||
@@ -1082,7 +1056,7 @@
|
||||
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold text-slate-900">녹음 삭제 확인</h3>
|
||||
<button onClick={() => setDeleteRecordingId(null)} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
|
||||
<i data-lucide="x" className="w-5 h-5 text-slate-500"></i>
|
||||
<LucideIcon name="x" className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
@@ -1144,39 +1118,8 @@
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
|
||||
// 아이콘 생성 (컴포넌트 마운트 후)
|
||||
const iconTimer = setTimeout(() => {
|
||||
try {
|
||||
lucide.createIcons();
|
||||
} catch (error) {
|
||||
console.warn('Icon creation error:', error);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(iconTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 리스트 업데이트 시 아이콘 재생성
|
||||
useEffect(() => {
|
||||
if (savedFiles.length > 0) {
|
||||
const iconTimer = setTimeout(() => {
|
||||
try {
|
||||
// DOM이 준비되었는지 확인
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
} catch (error) {
|
||||
// 에러를 조용히 처리
|
||||
console.warn('Icon creation error (non-critical):', error);
|
||||
}
|
||||
}, 150);
|
||||
return () => clearTimeout(iconTimer);
|
||||
}
|
||||
}, [savedFiles]);
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
@@ -1222,7 +1165,7 @@
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
|
||||
<h4 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<i data-lucide="paperclip" className="w-5 h-5 text-indigo-600"></i>
|
||||
<LucideIcon name="paperclip" className="w-5 h-5 text-indigo-600" />
|
||||
첨부파일 추가
|
||||
</h4>
|
||||
|
||||
@@ -1239,7 +1182,7 @@
|
||||
htmlFor="file-upload-input"
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-300 rounded-lg hover:border-indigo-500 hover:bg-indigo-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<i data-lucide="upload" className="w-5 h-5 text-slate-500"></i>
|
||||
<LucideIcon name="upload" className="w-5 h-5 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-700">파일 선택 (여러 개 가능)</span>
|
||||
</label>
|
||||
|
||||
@@ -1248,7 +1191,7 @@
|
||||
{files.map((file, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<i data-lucide="file" className="w-5 h-5 text-slate-500 shrink-0"></i>
|
||||
<LucideIcon name="file" className="w-5 h-5 text-slate-500 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-slate-900 truncate">{file.name}</div>
|
||||
<div className="text-xs text-slate-500">{formatFileSize(file.size)}</div>
|
||||
@@ -1258,7 +1201,7 @@
|
||||
onClick={() => removeFile(index)}
|
||||
className="p-1 text-slate-400 hover:text-red-600 transition-colors shrink-0"
|
||||
>
|
||||
<i data-lucide="x" className="w-4 h-4"></i>
|
||||
<LucideIcon name="x" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -1267,7 +1210,7 @@
|
||||
onClick={uploadFiles}
|
||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium transition-colors"
|
||||
>
|
||||
<i data-lucide="upload" className="w-4 h-4 inline mr-2"></i>
|
||||
<LucideIcon name="upload" className="w-4 h-4 inline mr-2" />
|
||||
개발부서로 전송 ({files.length}개 파일)
|
||||
</button>
|
||||
</div>
|
||||
@@ -1278,7 +1221,7 @@
|
||||
<div className="mt-6 border-t border-slate-200 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h5 className="text-sm font-bold text-slate-700 flex items-center gap-2">
|
||||
<i data-lucide="paperclip" className="w-4 h-4"></i>
|
||||
<LucideIcon name="paperclip" className="w-4 h-4" />
|
||||
업로드된 파일 목록
|
||||
</h5>
|
||||
<button
|
||||
@@ -1286,19 +1229,19 @@
|
||||
className="text-xs text-indigo-600 hover:text-indigo-700 font-medium"
|
||||
disabled={loading}
|
||||
>
|
||||
<i data-lucide="refresh-cw" className={`w-3 h-3 inline mr-1 ${loading ? 'animate-spin' : ''}`}></i>
|
||||
<LucideIcon name="refresh-cw" className={`w-3 h-3 inline mr-1 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && savedFiles.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-400 text-sm">
|
||||
<i data-lucide="loader" className="w-5 h-5 inline animate-spin mr-2"></i>
|
||||
<LucideIcon name="loader" className="w-5 h-5 inline animate-spin mr-2" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : savedFiles.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-400 text-sm">
|
||||
<i data-lucide="inbox" className="w-8 h-8 mx-auto mb-2 opacity-50"></i>
|
||||
<LucideIcon name="inbox" className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
업로드된 파일이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
@@ -1307,7 +1250,7 @@
|
||||
<div key={`filegroup-${fileGroup.id}-${fileGroup.created_at}`} className="p-3 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<i data-lucide="folder" className="w-4 h-4 text-indigo-600"></i>
|
||||
<LucideIcon name="folder" className="w-4 h-4 text-indigo-600" />
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
{fileGroup.created_at_formatted}
|
||||
</span>
|
||||
@@ -1320,7 +1263,7 @@
|
||||
{fileGroup.files && fileGroup.files.map((file, idx) => (
|
||||
<div key={`${fileGroup.id}-${idx}-${file.original_name}`} className="flex items-center justify-between p-2 bg-white rounded border border-slate-100">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<i data-lucide="file" className="w-4 h-4 text-slate-400 shrink-0"></i>
|
||||
<LucideIcon name="file" className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<span className="text-xs text-slate-700 truncate">{file.original_name}</span>
|
||||
<span className="text-xs text-slate-400 shrink-0">
|
||||
{formatFileSize(file.file_size)}
|
||||
@@ -1338,7 +1281,7 @@
|
||||
className="p-1 text-slate-400 hover:text-indigo-600 transition-colors shrink-0"
|
||||
title="다운로드"
|
||||
>
|
||||
<i data-lucide="download" className="w-3.5 h-3.5"></i>
|
||||
<LucideIcon name="download" className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -1378,7 +1321,7 @@
|
||||
|
||||
<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>
|
||||
<LucideIcon name="lightbulb" className="w-4 h-4 text-yellow-500" />
|
||||
Manager Tip
|
||||
</h4>
|
||||
<p className="text-sm text-slate-600 italic">
|
||||
@@ -1390,7 +1333,7 @@
|
||||
{/* 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>
|
||||
<LucideIcon name="check-square" className="w-5 h-5 text-primary" />
|
||||
핵심 체크포인트
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
@@ -1404,7 +1347,7 @@
|
||||
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>
|
||||
<LucideIcon name="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" />
|
||||
</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'}`}>
|
||||
@@ -1425,7 +1368,7 @@
|
||||
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>
|
||||
<LucideIcon name="help-circle" className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -1479,19 +1422,7 @@
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 아이콘 생성은 DOM이 완전히 렌더링된 후에 실행
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
// DOM이 준비되었는지 확인
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
} catch (error) {
|
||||
// 에러를 조용히 처리 (React와의 충돌 방지)
|
||||
console.warn('Icon creation error (non-critical):', error);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
// LucideIcon component handles icon creation
|
||||
}, [activeStepId, checklistData]);
|
||||
|
||||
const handleCheck = (stepId, index, isChecked) => {
|
||||
@@ -1575,7 +1506,7 @@
|
||||
|
||||
{/* 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>
|
||||
<LucideIcon name="trophy" className="w-8 h-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
25
salesmanagement/api/fix_schema.php
Normal file
25
salesmanagement/api/fix_schema.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
require_once(__DIR__ . "/../../lib/mydb.php");
|
||||
$pdo = db_connect();
|
||||
|
||||
try {
|
||||
$columns = $pdo->query("SHOW COLUMNS FROM `sales_tenant_consultations`")->fetchAll(PDO::FETCH_COLUMN);
|
||||
echo "Columns: " . implode(", ", $columns) . "\n";
|
||||
|
||||
if (!in_array('audio_file_path', $columns)) {
|
||||
$pdo->exec("ALTER TABLE `sales_tenant_consultations` ADD COLUMN `audio_file_path` varchar(500) DEFAULT NULL AFTER `log_text` ");
|
||||
echo "Added audio_file_path\n";
|
||||
}
|
||||
if (!in_array('attachment_paths', $columns)) {
|
||||
$pdo->exec("ALTER TABLE `sales_tenant_consultations` ADD COLUMN `attachment_paths` text DEFAULT NULL AFTER `audio_file_path` ");
|
||||
echo "Added attachment_paths\n";
|
||||
}
|
||||
if (!in_array('consultation_type', $columns)) {
|
||||
$pdo->exec("ALTER TABLE `sales_tenant_consultations` ADD COLUMN `consultation_type` varchar(20) DEFAULT 'text' AFTER `attachment_paths` ");
|
||||
echo "Added consultation_type\n";
|
||||
}
|
||||
|
||||
echo "Schema check completed.\n";
|
||||
} catch (Exception $e) {
|
||||
echo "Error: " . $e->getMessage() . "\n";
|
||||
}
|
||||
@@ -50,9 +50,16 @@ try {
|
||||
$directContracts = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$directSales = 0;
|
||||
foreach ($directContracts as $c) {
|
||||
$rate = 0;
|
||||
if ($parentId == $targetUserId) $rate = 0.20;
|
||||
else if ($depth == 1) $rate = 0.05;
|
||||
else if ($depth == 2) $rate = 0.03;
|
||||
|
||||
foreach ($directContracts as &$c) {
|
||||
$c['commission'] = $c['amount'] * $rate;
|
||||
$directSales += $c['amount'];
|
||||
}
|
||||
unset($c);
|
||||
|
||||
// 하위 멤버들 가져오기
|
||||
$stmt = $pdo->prepare("SELECT id FROM sales_member WHERE parent_id = ? AND is_active = 1");
|
||||
@@ -63,12 +70,16 @@ try {
|
||||
$totalSales = $directSales;
|
||||
$totalContractCount = count($directContracts);
|
||||
|
||||
$commission = $directSales * $rate;
|
||||
$subtreeCommission = $commission;
|
||||
|
||||
foreach ($childrenIds as $childId) {
|
||||
$childNode = buildOrgTree($pdo, $childId, $depth + 1, $startDate, $endDate, $targetUserId);
|
||||
if ($childNode) {
|
||||
$children[] = $childNode;
|
||||
$totalSales += $childNode['totalSales_subtree'];
|
||||
$totalContractCount += $childNode['contractCount_subtree'];
|
||||
$totalSales += $childNode['totalSales'];
|
||||
$totalContractCount += $childNode['contractCount'];
|
||||
$subtreeCommission += $childNode['commission']; // Aggregate commissions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +107,8 @@ try {
|
||||
'directSales' => $directSales,
|
||||
'totalSales' => $totalSales,
|
||||
'contractCount' => $totalContractCount,
|
||||
'commission' => $commission,
|
||||
'directCommission' => $commission,
|
||||
'commission' => $subtreeCommission,
|
||||
'contracts' => $directContracts,
|
||||
'children' => $children
|
||||
];
|
||||
@@ -130,9 +142,8 @@ try {
|
||||
$actualChildren = $rootNode['children'];
|
||||
$rootNode['children'] = array_merge([$directNode], $actualChildren);
|
||||
|
||||
// 중요: Root 노드 자체의 수당은 하위 '내 직접 판매'에서 합산되므로 0으로 설정하거나 isDirect를 false로 변경
|
||||
// Root node should keep its aggregated commission as its primary commission value
|
||||
$rootNode['isDirect'] = false;
|
||||
$rootNode['commission'] = 0;
|
||||
}
|
||||
|
||||
// 전체 누적 실적 계산 (전체 기간)
|
||||
|
||||
@@ -66,7 +66,7 @@ try {
|
||||
$check = $pdo->prepare("SELECT id FROM sales_member WHERE member_id = 'manager'");
|
||||
$check->execute();
|
||||
if (!$check->fetch()) {
|
||||
$stmt = $pdo->prepare("INSERT INTO sales_member (member_id, password, name, role, parent_id) VALUES ('manager', 'manager', '일반매니저', 'manager', ?)");
|
||||
$stmt = $pdo->prepare("INSERT INTO sales_member (member_id, password, name, role, parent_id) VALUES ('manager', 'manager', '매니저', 'manager', ?)");
|
||||
$stmt->execute([$sales_id]);
|
||||
}
|
||||
$manager_id = $pdo->lastInsertId() ?: 3;
|
||||
|
||||
@@ -94,7 +94,7 @@ try {
|
||||
return;
|
||||
}
|
||||
if ($member_id === 'manager' && $password === 'manager') {
|
||||
$pdo->prepare("INSERT IGNORE INTO sales_member (member_id, password, name, role) VALUES ('manager', 'manager', '일반매니저', 'manager')")->execute();
|
||||
$pdo->prepare("INSERT IGNORE INTO sales_member (member_id, password, name, role) VALUES ('manager', 'manager', '매니저', 'manager')")->execute();
|
||||
$stmt = $pdo->prepare("SELECT * FROM sales_member WHERE member_id = 'manager'");
|
||||
$stmt->execute();
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
@@ -15,17 +15,137 @@ if (!isset($_SESSION['sales_user'])) {
|
||||
$currentUser = $_SESSION['sales_user'];
|
||||
$pdo = db_connect();
|
||||
|
||||
// 테이블 자동 생성 (없을 경우)
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS `sales_tenants` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`manager_id` int(11) NOT NULL COMMENT '영업한 영업관리자 ID',
|
||||
`sales_manager_id` int(11) DEFAULT NULL COMMENT '매칭된 매니저 ID (영업관리자 본인 또는 별도 매니저)',
|
||||
`tenant_name` varchar(200) NOT NULL,
|
||||
`representative` varchar(100) DEFAULT NULL,
|
||||
`business_no` varchar(20) DEFAULT NULL,
|
||||
`contact_phone` varchar(20) DEFAULT NULL,
|
||||
`email` varchar(100) DEFAULT NULL,
|
||||
`address` varchar(500) DEFAULT NULL,
|
||||
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sales_tenant_products` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` int(11) NOT NULL,
|
||||
`product_name` varchar(200) NOT NULL,
|
||||
`contract_amount` decimal(15,2) NOT NULL DEFAULT 0.00,
|
||||
`commission_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
|
||||
`commission_amount` decimal(15,2) DEFAULT 0.00,
|
||||
`contract_date` date DEFAULT NULL,
|
||||
`operator_confirmed` tinyint(1) DEFAULT 0,
|
||||
`sub_models` text DEFAULT NULL COMMENT '선택모델인 경우 모델 ID 목록 (JSON)',
|
||||
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sales_tenant_scenarios` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` int(11) NOT NULL,
|
||||
`scenario_type` varchar(20) NOT NULL DEFAULT 'manager' COMMENT 'sales or manager',
|
||||
`step_id` int(11) NOT NULL,
|
||||
`checkpoint_index` int(11) NOT NULL,
|
||||
`is_checked` tinyint(1) DEFAULT 0,
|
||||
`updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_unique_step` (`tenant_id`, `scenario_type`, `step_id`, `checkpoint_index`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sales_tenant_consultations` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` int(11) NOT NULL,
|
||||
`manager_id` int(11) NOT NULL,
|
||||
`scenario_type` varchar(20) NOT NULL DEFAULT 'manager' COMMENT 'sales or manager',
|
||||
`step_id` int(11) DEFAULT NULL,
|
||||
`log_text` text NOT NULL,
|
||||
`audio_file_path` varchar(500) DEFAULT NULL,
|
||||
`attachment_paths` text DEFAULT NULL,
|
||||
`consultation_type` varchar(20) DEFAULT 'text' COMMENT 'text, audio, file',
|
||||
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
|
||||
// --- DB Migration (기존 테이블 업데이트) ---
|
||||
try {
|
||||
// 1. sales_tenant_scenarios 테이블에 scenario_type 컬럼 추가
|
||||
$check = $pdo->query("SHOW COLUMNS FROM `sales_tenant_scenarios` LIKE 'scenario_type'")->fetch();
|
||||
if (!$check) {
|
||||
$pdo->exec("ALTER TABLE `sales_tenant_scenarios` ADD COLUMN `scenario_type` varchar(20) NOT NULL DEFAULT 'manager' AFTER `tenant_id` ");
|
||||
}
|
||||
|
||||
// 2. sales_tenant_consultations 테이블에 scenario_type 컬럼 추가
|
||||
$check2 = $pdo->query("SHOW COLUMNS FROM `sales_tenant_consultations` LIKE 'scenario_type'")->fetch();
|
||||
if (!$check2) {
|
||||
$pdo->exec("ALTER TABLE `sales_tenant_consultations` ADD COLUMN `scenario_type` varchar(20) NOT NULL DEFAULT 'manager' AFTER `manager_id` ");
|
||||
}
|
||||
|
||||
// 3. sales_tenant_scenarios 테이블의 유니크 키 업데이트 (scenario_type 포함)
|
||||
// 인덱스 구성을 확인하여 scenario_type이 포함되어 있지 않으면 재구성
|
||||
$indices = $pdo->query("SHOW INDEX FROM `sales_tenant_scenarios` WHERE Key_name = 'idx_unique_step'")->fetchAll();
|
||||
$hasScenarioTypeInIndex = false;
|
||||
foreach ($indices as $index) {
|
||||
if ($index['Column_name'] === 'scenario_type') {
|
||||
$hasScenarioTypeInIndex = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasScenarioTypeInIndex) {
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE `sales_tenant_scenarios` DROP INDEX `idx_unique_step` ");
|
||||
} catch (Exception $e) {}
|
||||
$pdo->exec("ALTER TABLE `sales_tenant_scenarios` ADD UNIQUE KEY `idx_unique_step` (`tenant_id`, `scenario_type`, `step_id`, `checkpoint_index`) ");
|
||||
}
|
||||
// 4. sales_tenant_consultations 테이블에 오디오/첨부파일 컬럼 추가
|
||||
$cols = $pdo->query("SHOW COLUMNS FROM `sales_tenant_consultations`")->fetchAll(PDO::FETCH_COLUMN);
|
||||
if (!in_array('audio_file_path', $cols)) {
|
||||
$pdo->exec("ALTER TABLE `sales_tenant_consultations` ADD COLUMN `audio_file_path` varchar(500) DEFAULT NULL AFTER `log_text` ");
|
||||
}
|
||||
if (!in_array('attachment_paths', $cols)) {
|
||||
$pdo->exec("ALTER TABLE `sales_tenant_consultations` ADD COLUMN `attachment_paths` text DEFAULT NULL AFTER `audio_file_path` ");
|
||||
}
|
||||
if (!in_array('consultation_type', $cols)) {
|
||||
$pdo->exec("ALTER TABLE `sales_tenant_consultations` ADD COLUMN `consultation_type` varchar(20) DEFAULT 'text' AFTER `attachment_paths` ");
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("Migration error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($action === 'list_tenants') {
|
||||
// 운영자는 모든 테넌트, 영업관리/매니저는 본인 소속 테넌트만
|
||||
if ($currentUser['role'] === 'operator') {
|
||||
$stmt = $pdo->prepare("SELECT t.*, m.name as manager_name FROM sales_tenants t JOIN sales_member m ON t.manager_id = m.id ORDER BY t.created_at DESC");
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT t.*, m.name as register_name, m2.name as manager_name, m2.role as manager_role
|
||||
FROM sales_tenants t
|
||||
JOIN sales_member m ON t.manager_id = m.id
|
||||
LEFT JOIN sales_member m2 ON t.sales_manager_id = m2.id
|
||||
ORDER BY t.created_at DESC
|
||||
");
|
||||
$stmt->execute();
|
||||
} else {
|
||||
$stmt = $pdo->prepare("SELECT * FROM sales_tenants WHERE manager_id = ? ORDER BY created_at DESC");
|
||||
$stmt->execute([$currentUser['id']]);
|
||||
// 내가 영업했거나, 내가 매니저로 배정된 테넌트
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT t.*, m.name as register_name, m2.name as manager_name, m2.role as manager_role
|
||||
FROM sales_tenants t
|
||||
JOIN sales_member m ON t.manager_id = m.id
|
||||
LEFT JOIN sales_member m2 ON t.sales_manager_id = m2.id
|
||||
WHERE t.manager_id = ? OR t.sales_manager_id = ?
|
||||
ORDER BY t.created_at DESC
|
||||
");
|
||||
$stmt->execute([$currentUser['id'], $currentUser['id']]);
|
||||
}
|
||||
$tenants = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
echo json_encode(['success' => true, 'data' => $tenants]);
|
||||
@@ -54,11 +174,52 @@ try {
|
||||
$stmt->execute([$currentUser['id']]);
|
||||
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
echo json_encode(['success' => true, 'data' => $stats]);
|
||||
} elseif ($action === 'get_scenario') {
|
||||
$tenant_id = $_GET['tenant_id'] ?? null;
|
||||
$scenario_type = $_GET['scenario_type'] ?? 'manager';
|
||||
if (!$tenant_id) throw new Exception("테넌트 ID가 필요합니다.");
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM sales_tenant_scenarios WHERE tenant_id = ? AND scenario_type = ?");
|
||||
$stmt->execute([$tenant_id, $scenario_type]);
|
||||
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo json_encode(['success' => true, 'data' => $results]);
|
||||
|
||||
} elseif ($action === 'get_consultations') {
|
||||
$tenant_id = $_GET['tenant_id'] ?? null;
|
||||
$scenario_type = $_GET['scenario_type'] ?? 'manager';
|
||||
$step_id = $_GET['step_id'] ?? null;
|
||||
if (!$tenant_id) throw new Exception("테넌트 ID가 필요합니다.");
|
||||
|
||||
$sql = "SELECT * FROM sales_tenant_consultations WHERE tenant_id = ? AND scenario_type = ?";
|
||||
$params = [$tenant_id, $scenario_type];
|
||||
if ($step_id) {
|
||||
$sql .= " AND step_id = ?";
|
||||
$params[] = $step_id;
|
||||
}
|
||||
$sql .= " ORDER BY created_at DESC";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo json_encode(['success' => true, 'data' => $results]);
|
||||
} elseif ($action === 'list_managers') {
|
||||
// 매니저로 매칭 가능한 사람 목록 (영업관리 또는 매니저 직급)
|
||||
$stmt = $pdo->prepare("SELECT id, name, role, member_id FROM sales_member WHERE role IN ('영업관리', '매니저') AND is_active = 1 ORDER BY name ASC");
|
||||
$stmt->execute();
|
||||
$managers = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
echo json_encode(['success' => true, 'data' => $managers]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
// multipart/form-data인 경우 $_POST 사용, 아니면 JSON 입력 사용
|
||||
if (empty($_POST)) {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
} else {
|
||||
$data = $_POST;
|
||||
}
|
||||
|
||||
if ($action === 'create_tenant') {
|
||||
$tenant_name = $data['tenant_name'] ?? '';
|
||||
@@ -67,11 +228,12 @@ try {
|
||||
$contact_phone = $data['contact_phone'] ?? '';
|
||||
$email = $data['email'] ?? '';
|
||||
$address = $data['address'] ?? '';
|
||||
$sales_manager_id = $data['sales_manager_id'] ?? $currentUser['id']; // 지정 안하면 본인
|
||||
|
||||
if (!$tenant_name) throw new Exception("업체명은 필수입니다.");
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO sales_tenants (manager_id, tenant_name, representative, business_no, contact_phone, email, address) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$currentUser['id'], $tenant_name, $representative, $business_no, $contact_phone, $email, $address]);
|
||||
$stmt = $pdo->prepare("INSERT INTO sales_tenants (manager_id, sales_manager_id, tenant_name, representative, business_no, contact_phone, email, address) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$currentUser['id'], $sales_manager_id, $tenant_name, $representative, $business_no, $contact_phone, $email, $address]);
|
||||
|
||||
echo json_encode(['success' => true, 'id' => $pdo->lastInsertId(), 'message' => '테넌트가 등록되었습니다.']);
|
||||
|
||||
@@ -81,11 +243,14 @@ try {
|
||||
$contract_amount = $data['contract_amount'] ?? 0;
|
||||
$commission_rate = $data['commission_rate'] ?? 0;
|
||||
$contract_date = $data['contract_date'] ?? date('Y-m-d');
|
||||
$sub_models = isset($data['sub_models']) ? json_encode($data['sub_models']) : null;
|
||||
|
||||
if (!$tenant_id || !$product_name) throw new Exception("필수 정보가 누락되었습니다.");
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO sales_tenant_products (tenant_id, product_name, contract_amount, commission_rate, contract_date) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, $product_name, $contract_amount, $commission_rate, $contract_date]);
|
||||
$commission_amount = ($contract_amount * $commission_rate) / 100;
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO sales_tenant_products (tenant_id, product_name, contract_amount, commission_rate, commission_amount, contract_date, sub_models) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, $product_name, $contract_amount, $commission_rate, $commission_amount, $contract_date, $sub_models]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => '상품 계약 정보가 등록되었습니다.']);
|
||||
|
||||
@@ -101,9 +266,174 @@ try {
|
||||
$stmt->execute([$confirmed, $product_id]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => $confirmed ? '승인되었습니다.' : '승인이 취소되었습니다.']);
|
||||
} elseif ($action === 'update_checklist') {
|
||||
$tenant_id = $data['tenant_id'] ?? null;
|
||||
$scenario_type = $data['scenario_type'] ?? 'manager';
|
||||
$step_id = $data['step_id'] ?? null;
|
||||
$checkpoint_index = $data['checkpoint_index'] ?? null;
|
||||
$is_checked = $data['is_checked'] ? 1 : 0;
|
||||
|
||||
if (!$tenant_id || $step_id === null || $checkpoint_index === null) throw new Exception("필수 파라미터가 누락되었습니다.");
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO sales_tenant_scenarios (tenant_id, scenario_type, step_id, checkpoint_index, is_checked)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE is_checked = VALUES(is_checked)
|
||||
");
|
||||
$stmt->execute([$tenant_id, $scenario_type, $step_id, $checkpoint_index, $is_checked]);
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
|
||||
} elseif ($action === 'save_consultation') {
|
||||
$tenant_id = $data['tenant_id'] ?? null;
|
||||
$scenario_type = $data['scenario_type'] ?? 'manager';
|
||||
$step_id = $data['step_id'] ?? null;
|
||||
$log_text = $data['log_text'] ?? '';
|
||||
$consultation_type = $data['consultation_type'] ?? 'text';
|
||||
$audio_file_path = null;
|
||||
|
||||
if (!$tenant_id || !$step_id) throw new Exception("필수 정보가 누락되었습니다.");
|
||||
|
||||
// 오디오 파일 업로드 처리
|
||||
if ($consultation_type === 'audio' && isset($_FILES['audio_file'])) {
|
||||
$upload_dir = __DIR__ . "/../uploads/consultations/" . $tenant_id . "/";
|
||||
if (!file_exists($upload_dir)) mkdir($upload_dir, 0777, true);
|
||||
|
||||
$file_ext = pathinfo($_FILES['audio_file']['name'], PATHINFO_EXTENSION) ?: 'webm';
|
||||
$file_name = "audio_" . date('Ymd_His') . "_" . uniqid() . "." . $file_ext;
|
||||
$audio_file_path = "uploads/consultations/" . $tenant_id . "/" . $file_name;
|
||||
|
||||
if (!move_uploaded_file($_FILES['audio_file']['tmp_name'], __DIR__ . "/../" . $audio_file_path)) {
|
||||
throw new Exception("오디오 파일 저장 실패");
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO sales_tenant_consultations (tenant_id, manager_id, scenario_type, step_id, log_text, audio_file_path, consultation_type) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, $currentUser['id'], $scenario_type, $step_id, $log_text, $audio_file_path, $consultation_type]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => '기록이 저장되었습니다.']);
|
||||
} elseif ($action === 'upload_attachments') {
|
||||
$tenant_id = $data['tenant_id'] ?? null;
|
||||
$scenario_type = $data['scenario_type'] ?? 'manager';
|
||||
$step_id = $data['step_id'] ?? null;
|
||||
|
||||
if (!$tenant_id || !$step_id || !isset($_FILES['files'])) throw new Exception("필수 정보가 누락되었습니다.");
|
||||
|
||||
$upload_dir = __DIR__ . "/../uploads/attachments/" . $tenant_id . "/";
|
||||
if (!file_exists($upload_dir)) mkdir($upload_dir, 0777, true);
|
||||
|
||||
$saved_paths = [];
|
||||
$files = $_FILES['files'];
|
||||
$count = is_array($files['name']) ? count($files['name']) : 1;
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$tmp_name = is_array($files['tmp_name']) ? $files['tmp_name'][$i] : $files['tmp_name'];
|
||||
$name = is_array($files['name']) ? $files['name'][$i] : $files['name'];
|
||||
|
||||
if (is_uploaded_file($tmp_name)) {
|
||||
$file_name = date('Ymd_His') . "_" . uniqid() . "_" . $name;
|
||||
$save_path = "uploads/attachments/" . $tenant_id . "/" . $file_name;
|
||||
if (move_uploaded_file($tmp_name, __DIR__ . "/../" . $save_path)) {
|
||||
$saved_paths[] = [
|
||||
'name' => $name,
|
||||
'path' => $save_path,
|
||||
'size' => is_array($files['size']) ? $files['size'][$i] : $files['size']
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($saved_paths)) throw new Exception("저장된 파일이 없습니다.");
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO sales_tenant_consultations (tenant_id, manager_id, scenario_type, step_id, log_text, attachment_paths, consultation_type) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, $currentUser['id'], $scenario_type, $step_id, '첨부파일 업로드', json_encode($saved_paths, JSON_UNESCAPED_UNICODE), 'file']);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => '파일이 업로드되었습니다.']);
|
||||
} elseif ($action === 'delete_consultation') {
|
||||
$id = $data['id'] ?? null;
|
||||
if (!$id) throw new Exception("ID가 누락되었습니다.");
|
||||
|
||||
// 파일 삭제를 위해 정보 조회
|
||||
$stmt = $pdo->prepare("SELECT audio_file_path, attachment_paths FROM sales_tenant_consultations WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$c = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($c) {
|
||||
if ($c['audio_file_path'] && file_exists(__DIR__ . "/../" . $c['audio_file_path'])) {
|
||||
@unlink(__DIR__ . "/../" . $c['audio_file_path']);
|
||||
}
|
||||
if ($c['attachment_paths']) {
|
||||
$paths = json_decode($c['attachment_paths'], true);
|
||||
if (is_array($paths)) {
|
||||
foreach ($paths as $p) {
|
||||
if (isset($p['path']) && file_exists(__DIR__ . "/../" . $p['path'])) {
|
||||
@unlink(__DIR__ . "/../" . $p['path']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("DELETE FROM sales_tenant_consultations WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode(['success' => true, 'message' => '성공적으로 삭제되었습니다.']);
|
||||
} elseif ($action === 'delete_product') {
|
||||
$product_id = $data['id'] ?? null;
|
||||
if (!$product_id) throw new Exception("ID가 누락되었습니다.");
|
||||
|
||||
// 승인되지 않은 것만 삭제 가능하도록 보안 체크 (영업관리자 관점)
|
||||
$stmt = $pdo->prepare("SELECT operator_confirmed FROM sales_tenant_products WHERE id = ?");
|
||||
$stmt->execute([$product_id]);
|
||||
$p = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$p) throw new Exception("해당 정보를 찾을 수 없습니다.");
|
||||
if ($p['operator_confirmed'] == 1) throw new Exception("이미 승인된 계약은 삭제할 수 없습니다.");
|
||||
|
||||
$stmt = $pdo->prepare("DELETE FROM sales_tenant_products WHERE id = ?");
|
||||
$stmt->execute([$product_id]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => '성공적으로 삭제되었습니다.']);
|
||||
} elseif ($action === 'update_tenant_manager') {
|
||||
$tenant_id = isset($data['tenant_id']) ? intval($data['tenant_id']) : null;
|
||||
$sales_manager_id = isset($data['sales_manager_id']) ? $data['sales_manager_id'] : null;
|
||||
|
||||
if (!$tenant_id) throw new Exception("테넌트 ID가 누락되었습니다.");
|
||||
|
||||
// manager_id가 0이거나 empty면 null로 처리 (지정 취소)
|
||||
$manager_val = (!empty($sales_manager_id)) ? intval($sales_manager_id) : null;
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE sales_tenants SET sales_manager_id = ? WHERE id = ?");
|
||||
$stmt->execute([$manager_val, $tenant_id]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => $manager_val ? '담당 매니저가 지정되었습니다.' : '담당 매니저 지정이 취소되었습니다.']);
|
||||
} elseif ($action === 'update_product') {
|
||||
$product_id = $data['id'] ?? null;
|
||||
$product_name = $data['product_name'] ?? '';
|
||||
$contract_amount = $data['contract_amount'] ?? 0;
|
||||
$commission_rate = $data['commission_rate'] ?? 0;
|
||||
$contract_date = $data['contract_date'] ?? date('Y-m-d');
|
||||
$sub_models = isset($data['sub_models']) ? json_encode($data['sub_models']) : null;
|
||||
|
||||
if (!$product_id || !$product_name) throw new Exception("필수 정보가 누락되었습니다.");
|
||||
|
||||
// 보안 체크: 승인된 것은 수정 불가
|
||||
$stmt = $pdo->prepare("SELECT operator_confirmed FROM sales_tenant_products WHERE id = ?");
|
||||
$stmt->execute([$product_id]);
|
||||
$p = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$p) throw new Exception("해당 정보를 찾을 수 없습니다.");
|
||||
if ($p['operator_confirmed'] == 1) throw new Exception("이미 승인된 계약은 수정할 수 없습니다.");
|
||||
|
||||
$commission_amount = ($contract_amount * $commission_rate) / 100;
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE sales_tenant_products SET product_name = ?, contract_amount = ?, commission_rate = ?, commission_amount = ?, contract_date = ?, sub_models = ? WHERE id = ?");
|
||||
$stmt->execute([$product_name, $contract_amount, $commission_rate, $commission_amount, $contract_date, $sub_models, $product_id]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => '계약 정보가 수정되었습니다.']);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 340 KiB |
Binary file not shown.
Binary file not shown.
BIN
uploads/manager_consultations/20251223_191155_694a6aeb7b928.webm
Normal file
BIN
uploads/manager_consultations/20251223_191155_694a6aeb7b928.webm
Normal file
Binary file not shown.
BIN
uploads/manager_consultations/20251223_191743_694a6c47d72e9.webm
Normal file
BIN
uploads/manager_consultations/20251223_191743_694a6c47d72e9.webm
Normal file
Binary file not shown.
BIN
uploads/manager_consultations/20251223_193043_694a6f53c4130.webm
Normal file
BIN
uploads/manager_consultations/20251223_193043_694a6f53c4130.webm
Normal file
Binary file not shown.
Reference in New Issue
Block a user