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">
|
2026-01-31 18:47:01 +09:00
|
|
|
<title>SAM 매니저 시나리오 - CodeBridgeX</title>
|
|
|
|
|
<!-- Favicon -->
|
|
|
|
|
<link rel="apple-touch-icon" sizes="180x180" href="../img/apple-touch-icon.png">
|
|
|
|
|
<link rel="icon" type="image/png" sizes="32x32" href="../img/favicon-32x32.png">
|
|
|
|
|
<link rel="icon" type="image/png" sizes="16x16" href="../img/favicon-16x16.png">
|
|
|
|
|
<link rel="shortcut icon" href="../img/favicon.png">
|
|
|
|
|
|
2025-12-17 12:59:26 +09:00
|
|
|
<!-- 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;
|
|
|
|
|
|
2025-12-24 09:46:07 +09:00
|
|
|
// --- 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>;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-17 12:59:26 +09:00
|
|
|
// --- 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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="home" className="w-4 h-4" />
|
2025-12-17 12:59:26 +09:00
|
|
|
홈으로
|
|
|
|
|
</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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="lightbulb" className="w-5 h-5 text-yellow-500" />
|
2025-12-17 12:59:26 +09:00
|
|
|
Manager Pro Tip
|
|
|
|
|
</h3>
|
|
|
|
|
<button onClick={onClose} className="p-2 hover:bg-slate-200 rounded-full transition-colors">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="x" className="w-5 h-5 text-slate-500" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="thumbs-up" className="w-5 h-5" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
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();
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-12-24 09:46:07 +09:00
|
|
|
// 리스트 업데이트 시 목록 다시 불러오기 (이미 loadRecordings에서 처리중)
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
|
|
|
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) => {
|
|
|
|
|
if (event.error === 'no-speech') {
|
2025-12-24 09:46:07 +09:00
|
|
|
// Silence 'no-speech' console error and add a delay for restart
|
2025-12-17 12:59:26 +09:00
|
|
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
2025-12-24 09:46:07 +09:00
|
|
|
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
|
2025-12-17 12:59:26 +09:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-24 09:46:07 +09:00
|
|
|
|
|
|
|
|
console.error('Speech recognition error:', event.error);
|
2025-12-17 12:59:26 +09:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
} else {
|
|
|
|
|
alert('데이터를 불러올 수 없습니다: ' + result.message);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error);
|
|
|
|
|
alert('서버 오류가 발생했습니다');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 삭제 확인 함수
|
|
|
|
|
const confirmDeleteRecording = (recordingId, date) => {
|
|
|
|
|
setDeleteRecordingId(recordingId);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 삭제 실행 함수
|
|
|
|
|
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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="mic" className="w-5 h-5 text-indigo-600" />
|
2025-12-17 12:59:26 +09:00
|
|
|
고객사 상담 녹음
|
|
|
|
|
</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'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name={isRecording ? "square" : "mic"} className="w-8 h-8" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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"
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="save" className="w-4 h-4 inline mr-2" />
|
2025-12-17 12:59:26 +09:00
|
|
|
저장하기
|
|
|
|
|
</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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="list" className="w-4 h-4" />
|
2025-12-17 12:59:26 +09:00
|
|
|
저장된 녹음 목록
|
|
|
|
|
</h5>
|
|
|
|
|
<button
|
|
|
|
|
onClick={loadRecordings}
|
|
|
|
|
className="text-xs text-indigo-600 hover:text-indigo-700 font-medium"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="refresh-cw" className={`w-3 h-3 inline mr-1 ${loading ? 'animate-spin' : ''}`} />
|
2025-12-17 12:59:26 +09:00
|
|
|
새로고침
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{loading && savedRecordings.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8 text-slate-400 text-sm">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="loader" className="w-5 h-5 inline animate-spin mr-2" />
|
2025-12-17 12:59:26 +09:00
|
|
|
로딩 중...
|
|
|
|
|
</div>
|
|
|
|
|
) : savedRecordings.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8 text-slate-400 text-sm">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="inbox" className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
2025-12-17 12:59:26 +09:00
|
|
|
저장된 녹음이 없습니다
|
|
|
|
|
</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="상세보기"
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="eye" className="w-4 h-4" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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="다운로드"
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="download" className="w-4 h-4" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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="삭제"
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="trash-2" className="w-4 h-4" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="x" className="w-5 h-5 text-slate-500" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="calendar" className="w-4 h-4" />
|
2025-12-17 12:59:26 +09:00
|
|
|
작성일
|
|
|
|
|
</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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="file-text" className="w-4 h-4" />
|
2025-12-17 12:59:26 +09:00
|
|
|
전체 텍스트
|
|
|
|
|
</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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="file-audio" className="w-4 h-4" />
|
2025-12-17 12:59:26 +09:00
|
|
|
파일 정보
|
|
|
|
|
</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"
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="download" className="w-4 h-4 inline mr-2" />
|
2025-12-17 12:59:26 +09:00
|
|
|
다운로드
|
|
|
|
|
</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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="x" className="w-5 h-5 text-slate-500" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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 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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="paperclip" className="w-5 h-5 text-indigo-600" />
|
2025-12-17 12:59:26 +09:00
|
|
|
첨부파일 추가
|
|
|
|
|
</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"
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="upload" className="w-5 h-5 text-slate-500" />
|
2025-12-17 12:59:26 +09:00
|
|
|
<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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="file" className="w-5 h-5 text-slate-500 shrink-0" />
|
2025-12-17 12:59:26 +09:00
|
|
|
<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"
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="x" className="w-4 h-4" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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"
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="upload" className="w-4 h-4 inline mr-2" />
|
2025-12-17 12:59:26 +09:00
|
|
|
개발부서로 전송 ({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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="paperclip" className="w-4 h-4" />
|
2025-12-17 12:59:26 +09:00
|
|
|
업로드된 파일 목록
|
|
|
|
|
</h5>
|
|
|
|
|
<button
|
|
|
|
|
onClick={loadFiles}
|
|
|
|
|
className="text-xs text-indigo-600 hover:text-indigo-700 font-medium"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="refresh-cw" className={`w-3 h-3 inline mr-1 ${loading ? 'animate-spin' : ''}`} />
|
2025-12-17 12:59:26 +09:00
|
|
|
새로고침
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{loading && savedFiles.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8 text-slate-400 text-sm">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="loader" className="w-5 h-5 inline animate-spin mr-2" />
|
2025-12-17 12:59:26 +09:00
|
|
|
로딩 중...
|
|
|
|
|
</div>
|
|
|
|
|
) : savedFiles.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8 text-slate-400 text-sm">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="inbox" className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
2025-12-17 12:59:26 +09:00
|
|
|
업로드된 파일이 없습니다
|
|
|
|
|
</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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="folder" className="w-4 h-4 text-indigo-600" />
|
2025-12-17 12:59:26 +09:00
|
|
|
<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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="file" className="w-4 h-4 text-slate-400 shrink-0" />
|
2025-12-17 12:59:26 +09:00
|
|
|
<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="다운로드"
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="download" className="w-3.5 h-3.5" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="lightbulb" className="w-4 h-4 text-yellow-500" />
|
2025-12-17 12:59:26 +09:00
|
|
|
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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="check-square" className="w-5 h-5 text-primary" />
|
2025-12-17 12:59:26 +09:00
|
|
|
핵심 체크포인트
|
|
|
|
|
</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)}
|
|
|
|
|
/>
|
2025-12-24 09:46:07 +09:00
|
|
|
<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" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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="꿀팁 보기"
|
|
|
|
|
>
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="help-circle" className="w-5 h-5" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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(() => {
|
2025-12-24 09:46:07 +09:00
|
|
|
// LucideIcon component handles icon creation
|
2025-12-17 12:59:26 +09:00
|
|
|
}, [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">
|
2025-12-24 09:46:07 +09:00
|
|
|
<LucideIcon name="trophy" className="w-8 h-8" />
|
2025-12-17 12:59:26 +09:00
|
|
|
</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">
|
|
|
|
|
© 2025 codebridge-x.com. All rights reserved.
|
|
|
|
|
</div>
|
|
|
|
|
</footer>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
|
|
|
root.render(<App />);
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|