Files
sam-sales/sales_manager_scenario/index.php

1631 lines
90 KiB
PHP
Raw Normal View History

2025-12-17 12:59:26 +09:00
<!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: Manager Scenario Steps ---
const SCENARIO_STEPS = [
{
id: 1,
title: "영업 이관",
subtitle: "Handover",
icon: "file-input",
color: "bg-blue-100 text-blue-600",
description: "영업팀으로부터 고객 정보를 전달받고, 프로젝트의 배경과 핵심 요구사항을 파악하는 단계입니다.",
checkpoints: [
{
title: "영업 히스토리 리뷰",
detail: "영업 담당자가 작성한 미팅록, 고객의 페인포인트, 예산 범위, 예상 일정 등을 꼼꼼히 확인하세요.",
pro_tip: "영업 담당자에게 '고객이 가장 꽂힌 포인트'가 무엇인지 꼭 물어보세요. 그게 프로젝트의 핵심 성공 요인(CSF)입니다."
},
{
title: "고객사 기본 정보 파악",
detail: "고객사의 업종, 규모, 주요 경쟁사, IT 성숙도 등을 파악하여 커뮤니케이션 톤앤매너를 준비하세요.",
pro_tip: "IT 지식이 부족한 고객이라면 전문 용어 사용을 자제하고 쉬운 비유를 준비해야 합니다."
},
{
title: "RFP/요구사항 문서 분석",
detail: "고객이 전달한 요구사항 문서(RFP 등)가 있다면, 기술적으로 실현 가능한지 1차 검토를 진행하세요.",
pro_tip: "모호한 문장('빠르게', '편리하게')을 찾아내어 구체적인 수치나 기능으로 정의할 준비를 하세요."
},
{
title: "내부 킥오프 (영업-매니저)",
detail: "영업팀과 함께 프로젝트의 리스크 요인(까다로운 담당자, 촉박한 일정 등)을 사전에 공유받으세요.",
pro_tip: "영업 단계에서 '무리하게 약속한 기능'이 있는지 반드시 체크해야 합니다. 나중에 개발팀과 싸우지 않으려면요."
}
],
tips: "잘못된 시작은 엉뚱한 결말을 낳습니다. 영업팀의 약속을 검증하세요."
},
{
id: 2,
title: "요구사항 파악",
subtitle: "Requirements",
icon: "search",
color: "bg-indigo-100 text-indigo-600",
description: "고객과 직접 만나 구체적인 니즈를 청취하고, 숨겨진 요구사항까지 발굴하는 단계입니다.",
checkpoints: [
{
title: "고객 인터뷰 및 실사",
detail: "현업 담당자를 만나 실제 업무 프로세스를 확인하고, 시스템이 필요한 진짜 이유를 찾으세요.",
pro_tip: "'왜 이 기능이 필요하세요?'라고 3번 물어보세요(5 Whys). 고객이 말하는 건 '수단'이고, 우리가 찾아야 할 건 '목적'입니다."
},
{
title: "요구사항 구체화 (Scope)",
detail: "고객의 요구사항을 기능 단위로 쪼개고, 우선순위(Must/Should/Could)를 매기세요.",
pro_tip: "모든 걸 다 할 순 없습니다. '오픈 시점에 반드시 필요한 기능'과 '추후 고도화할 기능'을 명확히 구분해 주세요."
},
{
title: "제약 사항 확인",
detail: "예산, 일정, 레거시 시스템 연동, 보안 규정 등 프로젝트의 제약 조건을 명확히 하세요.",
pro_tip: "특히 '데이터 이관' 이슈를 조심하세요. 엑셀 데이터가 엉망인 경우가 태반입니다."
},
{
title: "유사 레퍼런스 제시",
detail: "비슷한 고민을 했던 다른 고객사의 해결 사례를 보여주며, 우리가 제안하는 방향의 신뢰를 얻으세요.",
pro_tip: "'A사도 이렇게 푸셨습니다'라는 말 한마디가 백 마디 기술 설명보다 강력합니다."
}
],
tips: "고객은 자기가 뭘 원하는지 모를 때가 많습니다. 질문으로 답을 찾아주세요."
},
{
id: 3,
title: "개발자 협의",
subtitle: "Dev Consult",
icon: "code-2",
color: "bg-purple-100 text-purple-600",
description: "파악된 요구사항을 개발팀에 전달하고, 기술적 실현 가능성과 공수를 산정하는 단계입니다.",
checkpoints: [
{
title: "요구사항 기술 검토",
detail: "개발 리드와 함께 고객의 요구사항이 기술적으로 구현 가능한지, 이슈는 없는지 검토하세요.",
pro_tip: "개발자가 '안 돼요'라고 하면 '왜 안 되는지', '대안은 무엇인지'를 반드시 물어보세요. 무조건 안 되는 건 없습니다."
},
{
title: "공수 산정 (Estimation)",
detail: "각 기능별 개발 예상 시간(Man-Month)을 산출하고, 필요한 리소스(서버, 라이선스 등)를 파악하세요.",
pro_tip: "개발 공수는 항상 버퍼(Buffer)를 20% 정도 두세요. 예상치 못한 버그나 스펙 변경은 반드시 일어납니다."
},
{
title: "아키텍처/스택 선정",
detail: "프로젝트에 적합한 기술 스택과 시스템 아키텍처를 확정하세요.",
pro_tip: "최신 기술이 항상 정답은 아닙니다. 유지보수 용이성과 개발팀의 숙련도를 최우선으로 고려하세요."
},
{
title: "리스크 식별 및 대안 수립",
detail: "기술적 난이도가 높은 기능이나 외부 연동 이슈 등 리스크를 식별하고 대안(Plan B)을 마련하세요.",
pro_tip: "리스크는 감추지 말고 공유해야 합니다. 터지고 나서 말하면 사고지만, 미리 말하면 관리입니다."
}
],
tips: "개발자는 '기능'을 만들지만, 매니저는 '가치'를 만듭니다. 통역사가 되어주세요."
},
{
id: 4,
title: "제안 및 견적",
subtitle: "Proposal",
icon: "file-text",
color: "bg-pink-100 text-pink-600",
description: "개발팀 검토 내용을 바탕으로 구체적인 수행 계획서(SOW)와 견적서를 작성하여 고객에게 제안합니다.",
checkpoints: [
{
title: "WBS 및 일정 계획 수립",
detail: "분석/설계/개발/테스트/오픈 등 단계별 상세 일정을 수립하세요.",
pro_tip: "고객의 검수(UAT) 기간을 충분히 잡으세요. 고객은 생각보다 바빠서 피드백이 늦어질 수 있습니다."
},
{
title: "견적서(Quotation) 작성",
detail: "개발 공수, 솔루션 비용, 인프라 비용 등을 포함한 상세 견적서를 작성하세요.",
pro_tip: "단순히 총액만 적지 말고, '기능별 상세 견적'을 제공하면 신뢰도가 높아지고 네고 방어에도 유리합니다."
},
{
title: "제안서(SOW) 작성",
detail: "프로젝트 범위(Scope), 수행 방법론, 산출물 목록 등을 명시한 제안서를 작성하세요.",
pro_tip: "'제외 범위(Out of Scope)'를 명확히 적으세요. 나중에 '이것도 해주는 거 아니었어요?'라는 말을 듣지 않으려면요."
},
{
title: "제안 발표 (PT)",
detail: "고객에게 제안 내용을 설명하고, 우리가 이 프로젝트를 가장 잘 수행할 수 있음을 설득하세요.",
pro_tip: "발표 자료는 '고객의 언어'로 작성하세요. 기술 용어 남발은 금물입니다."
}
],
tips: "견적서는 숫자가 아니라 '신뢰'를 담아야 합니다."
},
{
id: 5,
title: "조율 및 협상",
subtitle: "Negotiation",
icon: "scale",
color: "bg-orange-100 text-orange-600",
description: "제안 내용을 바탕으로 고객과 범위, 일정, 비용을 최종 조율하는 단계입니다.",
checkpoints: [
{
title: "범위 및 일정 조정",
detail: "고객의 예산이나 일정에 맞춰 기능을 가감하거나 단계별 오픈 전략을 협의하세요.",
pro_tip: "무리한 일정 단축 요구는 단호하게 거절하되, '핵심 기능 먼저 오픈'과 같은 대안을 제시하세요."
},
{
title: "추가 요구사항 대응",
detail: "제안 과정에서 나온 추가 요구사항에 대해 비용을 청구할지, 서비스로 제공할지 결정하세요.",
pro_tip: "공짜는 없습니다. 서비스로 해주더라도 '원래 얼마짜리인데 이번만 해드리는 겁니다'라고 생색을 내세요."
},
{
title: "R&R 명확화",
detail: "우리 회사와 고객사가 각각 해야 할 역할(데이터 제공, 테스트, 서버 계정 발급 등)을 명확히 하세요.",
pro_tip: "프로젝트 지연의 절반은 고객이 제때 자료를 안 줘서 발생합니다. 고객의 숙제를 명확히 알려주세요."
},
{
title: "최종 합의 도출",
detail: "모든 쟁점 사항을 정리하고 최종 합의된 내용을 문서(회의록)로 남기세요.",
pro_tip: "구두로 합의한 건 합의한 게 아닙니다. 반드시 이메일이나 회의록으로 '박제'하세요."
}
],
tips: "협상은 이기는 게 아니라, 같이 갈 수 있는 길을 찾는 것입니다."
},
{
id: 6,
title: "착수 및 계약",
subtitle: "Kickoff",
icon: "flag",
color: "bg-green-100 text-green-600",
description: "계약을 체결하고 프로젝트를 공식적으로 시작하는 단계입니다.",
checkpoints: [
{
title: "계약서 검토 및 날인",
detail: "과업지시서, 기술협약서 등 계약 부속 서류를 꼼꼼히 챙기고 날인을 진행하세요.",
pro_tip: "계약서에 '검수 조건'을 명확히 넣으세요. '버그가 하나도 없을 것' 같은 불가능한 조건은 빼야 합니다."
},
{
title: "프로젝트 팀 구성",
detail: "PM, PL, 개발자, 디자이너 등 프로젝트 수행 인력을 확정하고 내부 킥오프를 하세요.",
pro_tip: "팀원들에게 프로젝트의 배경과 목표뿐만 아니라, '고객의 성향'도 공유해 주세요."
},
{
title: "착수 보고회 (Kick-off)",
detail: "고객사와 수행사 전원이 모여 프로젝트의 목표, 일정, 커뮤니케이션 룰을 공유하세요.",
pro_tip: "첫인상이 반입니다. 킥오프 자료는 최대한 깔끔하고 전문적으로 준비하세요."
},
{
title: "협업 도구 세팅",
detail: "Jira, Slack, Notion 등 프로젝트 관리를 위한 협업 도구를 세팅하고 고객을 초대하세요.",
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">
M
</div>
<h1 className="text-lg font-bold text-slate-900 tracking-tight">SAM Manager 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>
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>
</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>
);
};
// Voice Recorder Component
const VoiceRecorder = () => {
const [isRecording, setIsRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState(null);
const [timer, setTimer] = useState(0);
const [transcript, setTranscript] = useState('');
const [finalTranscript, setFinalTranscript] = useState(''); // 확정된 텍스트 누적용
const [status, setStatus] = useState('대기중');
const [savedRecordings, setSavedRecordings] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedRecording, setSelectedRecording] = useState(null);
const [deleteRecordingId, setDeleteRecordingId] = useState(null);
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
const timerIntervalRef = useRef(null);
const recognitionRef = useRef(null);
const streamRef = useRef(null);
const canvasRef = useRef(null);
const animationIdRef = useRef(null);
const audioContextRef = useRef(null);
const analyserRef = useRef(null);
const finalTranscriptRef = useRef(''); // 확정된 텍스트 누적용 (ref 사용)
// 저장된 녹음 목록 불러오기
const loadRecordings = async () => {
setLoading(true);
try {
const response = await fetch('get_consultations.php?step_id=2');
const result = await response.json();
if (result.success) {
setSavedRecordings(result.data || []);
}
} catch (error) {
console.error('녹음 목록 로드 실패:', error);
} finally {
setLoading(false);
}
};
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) {
streamRef.current.getTracks().forEach(track => track.stop());
}
if (audioContextRef.current) audioContextRef.current.close();
};
}, []);
// 리스트 업데이트 시 아이콘 재생성
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]);
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};
const drawWaveform = () => {
if (!analyserRef.current || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const analyser = analyserRef.current;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteTimeDomainData(dataArray);
ctx.fillStyle = '#f1f5f9';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 2;
ctx.strokeStyle = '#6366f1';
ctx.beginPath();
const sliceWidth = canvas.width / dataArray.length;
let x = 0;
for (let i = 0; i < dataArray.length; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.lineTo(canvas.width, canvas.height / 2);
ctx.stroke();
animationIdRef.current = requestAnimationFrame(drawWaveform);
};
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
// Audio Context for visualization
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
audioContextRef.current = audioContext;
const analyser = audioContext.createAnalyser();
analyserRef.current = analyser;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
analyser.fftSize = 2048;
// Canvas setup
if (canvasRef.current) {
canvasRef.current.width = canvasRef.current.offsetWidth;
canvasRef.current.height = 100;
drawWaveform();
}
// MediaRecorder
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
setAudioBlob(blob);
};
mediaRecorder.start();
// Speech Recognition (optional)
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SpeechRecognition) {
const recognition = new SpeechRecognition();
recognition.lang = 'ko-KR';
recognition.continuous = true;
recognition.interimResults = true;
// 확정된 텍스트 초기화
finalTranscriptRef.current = '';
setFinalTranscript('');
recognition.onresult = (event) => {
// 임시 텍스트 초기화 (중복 방지)
let interim = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
// 확정된 텍스트는 ref에 누적
finalTranscriptRef.current += transcript + ' ';
setFinalTranscript(finalTranscriptRef.current);
} else {
// 임시 텍스트는 현재만 표시 (누적하지 않음)
interim += transcript;
}
}
// 확정된 텍스트 + 현재 임시 텍스트 표시
const displayText = finalTranscriptRef.current + (interim ? `<span class="text-slate-400">${interim}</span>` : '');
setTranscript(displayText || '음성을 인식하고 있습니다...');
};
recognition.onend = () => {
// isRecording 상태를 직접 확인하기 위해 ref 사용
// React 상태는 클로저 문제가 있을 수 있으므로 ref로 확인
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
// 자동 재시작 (연속 녹음 모드)
try {
recognition.start();
} catch (e) {
console.log('Recognition restart failed:', e);
}
}
};
recognition.onerror = (event) => {
console.error('Speech recognition error:', event.error);
if (event.error === 'no-speech') {
// 음성이 감지되지 않음 - 자동 재시작
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
try {
recognition.start();
} catch (e) {
console.log('Recognition restart failed:', e);
}
}
return;
}
if (event.error === 'aborted') {
// 사용자가 중단함
return;
}
if (event.error === 'not-allowed') {
alert('마이크 권한이 거부되었습니다. 브라우저 설정에서 마이크 권한을 허용해주세요.');
}
};
recognition.start();
recognitionRef.current = recognition;
}
setIsRecording(true);
setStatus('녹음 중...');
setTimer(0);
finalTranscriptRef.current = ''; // 녹음 시작 시 확정 텍스트 초기화
setFinalTranscript('');
setTranscript(''); // 표시 텍스트도 초기화
timerIntervalRef.current = setInterval(() => {
setTimer(prev => prev + 1);
}, 1000);
} catch (error) {
console.error('녹음 시작 실패:', error);
alert('마이크 권한을 허용해주세요.');
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
}
if (recognitionRef.current) {
recognitionRef.current.stop();
}
if (timerIntervalRef.current) {
clearInterval(timerIntervalRef.current);
timerIntervalRef.current = null;
}
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setIsRecording(false);
setStatus('녹음 완료');
// 최종 텍스트 정리 (임시 텍스트 제거)
if (finalTranscriptRef.current.trim()) {
setTranscript(finalTranscriptRef.current.trim());
} else {
setTranscript('');
}
};
const saveRecording = async () => {
if (!audioBlob) {
alert('저장할 녹음 파일이 없습니다.');
return;
}
const formData = new FormData();
formData.append('audio_file', audioBlob, `consultation_${Date.now()}.webm`);
// 확정된 텍스트만 저장 (ref에서 가져오기, HTML 태그 제거)
const cleanTranscript = finalTranscriptRef.current.replace(/<[^>]*>/g, '').trim();
formData.append('transcript', cleanTranscript);
formData.append('step_id', 2);
try {
const response = await fetch('save_consultation.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
// 저장 성공 시 리스트 갱신
await loadRecordings();
setAudioBlob(null);
setTranscript('');
finalTranscriptRef.current = ''; // 확정 텍스트도 초기화
setFinalTranscript('');
setTimer(0);
setStatus('대기중');
} else {
alert('저장 실패: ' + (result.message || '알 수 없는 오류'));
}
} catch (error) {
console.error('저장 오류:', error);
alert('저장 중 오류가 발생했습니다.');
}
};
// 상세보기 함수
const viewRecordingDetail = async (recordingId) => {
try {
const response = await fetch(`get_consultation_detail.php?id=${recordingId}`);
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);
}
} catch (error) {
console.error(error);
alert('서버 오류가 발생했습니다');
}
};
// 삭제 확인 함수
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);
};
// 삭제 실행 함수
const deleteRecording = async () => {
if (!deleteRecordingId) return;
try {
const formData = new FormData();
formData.append('id', deleteRecordingId);
const response = await fetch('delete_consultation.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
await loadRecordings();
setDeleteRecordingId(null);
} else {
alert('삭제 실패: ' + result.message);
}
} catch (error) {
console.error(error);
alert('서버 오류가 발생했습니다');
}
};
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="mic" className="w-5 h-5 text-indigo-600"></i>
고객사 상담 녹음
</h4>
<div className="flex flex-col items-center gap-4">
<button
onClick={isRecording ? stopRecording : startRecording}
className={`w-20 h-20 rounded-full flex items-center justify-center text-white text-2xl transition-all ${
isRecording
? 'bg-red-500 hover:bg-red-600 animate-pulse'
: 'bg-indigo-600 hover:bg-indigo-700'
}`}
>
<i data-lucide={isRecording ? "square" : "mic"} className="w-8 h-8"></i>
</button>
<div className="text-center">
<div className={`text-sm font-medium ${isRecording ? 'text-red-600' : 'text-slate-600'}`}>
{status}
</div>
<div className={`text-2xl font-mono font-bold mt-1 ${isRecording ? 'text-red-600' : 'text-slate-900'}`}>
{formatTime(timer)}
</div>
</div>
<canvas
ref={canvasRef}
className="w-full h-24 bg-slate-50 rounded-lg"
style={{ display: isRecording ? 'block' : 'none' }}
></canvas>
{transcript && (
<div className="w-full p-4 bg-slate-50 rounded-lg border border-slate-200">
<div className="text-sm text-slate-700" dangerouslySetInnerHTML={{ __html: transcript }}></div>
</div>
)}
{audioBlob && (
<div className="w-full flex gap-2">
<button
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>
저장하기
</button>
<button
onClick={() => {
setAudioBlob(null);
setTranscript('');
finalTranscriptRef.current = '';
setFinalTranscript('');
setTimer(0);
setStatus('대기중');
}}
className="px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 font-medium transition-colors"
>
취소
</button>
</div>
)}
</div>
{/* 저장된 녹음 목록 */}
<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>
저장된 녹음 목록
</h5>
<button
onClick={loadRecordings}
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>
새로고침
</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>
로딩 ...
</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>
저장된 녹음이 없습니다
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-semibold text-slate-700">번호</th>
<th className="text-left py-2 px-3 font-semibold text-slate-700">작성일</th>
<th className="text-left py-2 px-3 font-semibold text-slate-700">텍스트 미리보기</th>
<th className="text-center py-2 px-3 font-semibold text-slate-700">동작</th>
</tr>
</thead>
<tbody>
{savedRecordings.map((recording, index) => (
<tr key={`recording-${recording.id}-${recording.created_at}`} className="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td className="py-3 px-3 text-slate-600">{index + 1}</td>
<td className="py-3 px-3 text-slate-600">
<div className="flex items-center gap-2">
{recording.created_at_formatted}
{recording.is_gcs && (
<span className="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded">
GCS
</span>
)}
</div>
</td>
<td className="py-3 px-3">
{recording.transcript_text ? (
<span className="text-slate-700 line-clamp-1">
{recording.transcript_text.length > 10
? recording.transcript_text.substring(0, 10) + '...'
: recording.transcript_text}
</span>
) : (
<span className="text-slate-400 italic">텍스트 없음</span>
)}
</td>
<td className="py-3 px-3">
<div className="flex items-center justify-center gap-1">
<button
onClick={() => viewRecordingDetail(recording.id)}
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>
</button>
{recording.audio_file_path && !recording.is_gcs && (
<a
href={`download_consultation.php?id=${recording.id}`}
download
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>
</a>
)}
<button
onClick={() => confirmDeleteRecording(recording.id, recording.created_at_formatted)}
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>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* 상세보기 모달 */}
{selectedRecording && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setSelectedRecording(null)}>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<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>
</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>
작성일
</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>
전체 텍스트
</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">
{selectedRecording.transcript_text || '텍스트 없음'}
</div>
</div>
{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>
파일 정보
</h6>
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<p className="text-xs text-slate-600 break-all">{selectedRecording.audio_file_path}</p>
{selectedRecording.is_gcs && (
<span className="inline-block mt-2 text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded">
Google Cloud Storage
</span>
)}
</div>
</div>
)}
</div>
<div className="p-4 border-t border-slate-200 bg-slate-50 flex justify-end gap-2">
{selectedRecording.audio_file_path && !selectedRecording.is_gcs && (
<a
href={`download_consultation.php?id=${selectedRecording.id}`}
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>
다운로드
</a>
)}
<button
onClick={() => setSelectedRecording(null)}
className="px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 font-medium transition-colors"
>
닫기
</button>
</div>
</div>
</div>
)}
{/* 삭제 확인 모달 */}
{deleteRecordingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setDeleteRecordingId(null)}>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md" onClick={e => e.stopPropagation()}>
<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>
</button>
</div>
<div className="p-6">
<p className="text-slate-700 mb-2">정말로 녹음 파일을 삭제하시겠습니까?</p>
<p className="text-xs text-slate-500">서버 파일과 Google Cloud Storage의 파일도 함께 삭제됩니다.</p>
</div>
<div className="p-4 border-t border-slate-200 flex justify-end gap-2">
<button
onClick={() => setDeleteRecordingId(null)}
className="px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 font-medium transition-colors"
>
취소
</button>
<button
onClick={deleteRecording}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors"
>
삭제
</button>
</div>
</div>
</div>
)}
</>
);
};
// File Uploader Component
const FileUploader = () => {
const [files, setFiles] = useState([]);
const [savedFiles, setSavedFiles] = useState([]);
const [loading, setLoading] = useState(false);
const fileInputRef = useRef(null);
const handleFileSelect = (e) => {
const selectedFiles = Array.from(e.target.files);
setFiles(prev => [...prev, ...selectedFiles]);
};
const removeFile = (index) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
// 저장된 파일 목록 불러오기
const loadFiles = async () => {
setLoading(true);
try {
const response = await fetch('get_consultation_files.php?step_id=2');
const result = await response.json();
if (result.success) {
setSavedFiles(result.data || []);
}
} catch (error) {
console.error('파일 목록 로드 실패:', error);
} finally {
setLoading(false);
}
};
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;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
const uploadFiles = async () => {
if (files.length === 0) {
alert('업로드할 파일을 선택해주세요.');
return;
}
const formData = new FormData();
files.forEach((file, index) => {
formData.append(`file_${index}`, file);
});
formData.append('step_id', 2);
formData.append('file_count', files.length);
try {
const response = await fetch('upload_consultation_files.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
// 업로드 성공 시 리스트 갱신
await loadFiles();
setFiles([]);
if (fileInputRef.current) fileInputRef.current.value = '';
} else {
alert('업로드 실패: ' + (result.message || '알 수 없는 오류'));
}
} catch (error) {
console.error('업로드 오류:', error);
alert('업로드 중 오류가 발생했습니다.');
}
};
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>
첨부파일 추가
</h4>
<div className="space-y-3">
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
id="file-upload-input"
/>
<label
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>
<span className="text-sm font-medium text-slate-700">파일 선택 (여러 가능)</span>
</label>
{files.length > 0 && (
<div className="space-y-2">
{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>
<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>
</div>
</div>
<button
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>
</button>
</div>
))}
<button
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>
개발부서로 전송 ({files.length} 파일)
</button>
</div>
)}
</div>
{/* 저장된 파일 목록 */}
<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>
업로드된 파일 목록
</h5>
<button
onClick={loadFiles}
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>
새로고침
</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>
로딩 ...
</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>
업로드된 파일이 없습니다
</div>
) : (
<div className="space-y-3 max-h-64 overflow-y-auto">
{savedFiles.map((fileGroup) => (
<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>
<span className="text-xs font-medium text-slate-500">
{fileGroup.created_at_formatted}
</span>
<span className="text-xs px-1.5 py-0.5 bg-indigo-100 text-indigo-700 rounded">
{fileGroup.file_count} 파일
</span>
</div>
</div>
<div className="space-y-1.5">
{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>
<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)}
</span>
{file.gcs_uri && (
<span className="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded shrink-0">
GCS
</span>
)}
</div>
{file.file_path && !file.gcs_uri && (
<a
href={file.file_path}
download
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>
</a>
)}
</div>
))}
</div>
</div>
))}
</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>
Manager 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>
{/* Step 2: Requirements - Additional Features */}
{step.id === 2 && (
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6 animate-slide-up">
<VoiceRecorder />
<FileUploader />
</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(() => {
// 아이콘 생성은 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);
}, [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);
const totalChecked = Object.values(checklistData).reduce((sum, checks) => sum + checks.length, 0);
const overallProgress = totalCheckpoints > 0 ? 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>