Files
sam-manage/resources/views/sales/interviews/index.blade.php
김보곤 b2226341ee fix: [interview] 대분류 하위 '+ 중분류 추가' 버튼 개선
- 기존 '+' 단일문자 → '+ 중분류 추가' 텍스트 버튼으로 변경
- children 목록 하단에 항상 표시되도록 위치 이동
2026-02-28 21:33:47 +09:00

2200 lines
112 KiB
PHP

@extends('layouts.app')
@section('title', '인터뷰 시나리오 관리')
@push('styles')
<style>
.category-item { transition: background-color 0.15s; }
.category-item:hover { background-color: #f3f4f6; }
.category-item.active { background-color: #eff6ff; border-left: 3px solid #3b82f6; }
.template-card { border: 1px solid #e5e7eb; border-radius: 8px; }
.question-row { border-bottom: 1px solid #f3f4f6; }
.question-row:last-child { border-bottom: none; }
.question-row .q-delete-x { display: none; }
.question-row:hover .q-delete-x { display: inline-flex; }
.question-row .q-edit-btn { display: none; }
.question-row:hover .q-edit-btn { display: flex; }
.progress-bar-fill { transition: width 0.3s ease; }
.modal-overlay { background: rgba(0,0,0,0.5); }
.fade-in { animation: fadeIn 0.2s ease-in; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.tab-active { border-bottom: 2px solid #3b82f6; color: #3b82f6; font-weight: 600; }
.tab-inactive { border-bottom: 2px solid transparent; color: #6b7280; }
.tab-inactive:hover { color: #374151; border-bottom-color: #d1d5db; }
.domain-item { transition: all 0.15s; cursor: pointer; }
.domain-item:hover { background-color: #f3f4f6; }
.domain-item.active { background-color: #eff6ff; border-left: 3px solid #3b82f6; }
.project-card { transition: all 0.15s; cursor: pointer; border: 2px solid transparent; }
.project-card:hover { border-color: #93c5fd; }
.project-card.active { border-color: #3b82f6; background-color: #eff6ff; }
.status-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }
.knowledge-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.confidence-bar { height: 4px; border-radius: 2px; background: #e5e7eb; }
.confidence-fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
</style>
@endpush
@section('content')
<div id="interview-scenario-root"></div>
@endsection
@push('scripts')
@include('partials.react-cdn')
<script src="https://unpkg.com/lucide@0.469.0?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useEffect, useRef, useCallback } = React;
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content;
// ============================================================
// API 헬퍼
// ============================================================
const api = {
headers: () => ({
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json',
}),
async get(url) {
const res = await fetch(url, { headers: this.headers() });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
async post(url, data) {
const res = await fetch(url, { method: 'POST', headers: this.headers(), body: JSON.stringify(data) });
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `HTTP ${res.status}`); }
return res.json();
},
async put(url, data) {
const res = await fetch(url, { method: 'PUT', headers: this.headers(), body: JSON.stringify(data) });
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `HTTP ${res.status}`); }
return res.json();
},
async del(url) {
const res = await fetch(url, { method: 'DELETE', headers: this.headers() });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
};
// ============================================================
// Lucide 아이콘 헬퍼
// ============================================================
const createIcon = (name) => {
const _def=((n)=>{const a={'check-circle':'CircleCheck','alert-circle':'CircleAlert','alert-triangle':'TriangleAlert','clipboard-check':'ClipboardCheck','arrow-up-circle':'CircleArrowUp','arrow-down-circle':'CircleArrowDown'};if(a[n]&&lucide[a[n]])return lucide[a[n]];const p=n.split('-').map(w=>w.charAt(0).toUpperCase()+w.slice(1)).join('');return lucide[p]||(lucide.icons&&lucide.icons[n])||null;})(name);
const _c = s => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
return ({ className = "w-5 h-5", ...props }) => {
if (!_def) return null;
const [, attrs, children = []] = _def;
const sp = { className };
Object.entries(attrs).forEach(([k, v]) => { sp[_c(k)] = v; });
Object.assign(sp, props);
return React.createElement("svg", sp, ...children.map(([tag, ca], i) => {
const cp = { key: i };
if (ca) Object.entries(ca).forEach(([k, v]) => { cp[_c(k)] = v; });
return React.createElement(tag, cp);
}));
};
};
const IconPlus = createIcon('plus');
const IconEdit = createIcon('pencil');
const IconTrash = createIcon('trash-2');
const IconCheck = createIcon('check');
const IconX = createIcon('x');
const IconClipboard = createIcon('clipboard-check');
const IconHistory = createIcon('history');
const IconPlay = createIcon('play');
const IconChevronRight = createIcon('chevron-right');
const IconLoader = createIcon('loader');
const IconCalendar = createIcon('calendar');
const IconUser = createIcon('user');
const IconBuilding = createIcon('building-2');
const IconFileText = createIcon('file-text');
const IconCheckCircle = createIcon('check-circle');
const IconUpload = createIcon('upload');
const IconFolder = createIcon('folder');
const IconFolderPlus = createIcon('folder-plus');
const IconBrain = createIcon('brain');
const IconCode = createIcon('code');
const IconPaperclip = createIcon('paperclip');
const IconMic = createIcon('mic');
const IconTarget = createIcon('target');
const IconSettings = createIcon('settings');
const IconDownload = createIcon('download');
const IconSearch = createIcon('search');
const IconRefresh = createIcon('refresh-cw');
const IconChevronDown = createIcon('chevron-down');
const IconShield = createIcon('shield-check');
const IconAlertCircle = createIcon('alert-circle');
const IconEye = createIcon('eye');
const IconHash = createIcon('hash');
const IconTable = createIcon('table');
const IconCalculator = createIcon('calculator');
// 트리에서 카테고리 검색 (루트 + children 2단계)
const findCategory = (tree, id) => {
for (const cat of tree) {
if (cat.id === id) return cat;
if (cat.children) {
const found = cat.children.find(c => c.id === id);
if (found) return found;
}
}
return null;
};
const DOMAIN_LABELS = {
product_classification: '제품 분류 체계',
bom_structure: 'BOM 구조',
dimension_formula: '치수/변수 계산',
component_config: '부품 구성 상세',
pricing_structure: '단가 체계',
quantity_formula: '수량 수식',
conditional_logic: '조건부 로직',
quote_format: '견적서 양식',
};
const DOMAIN_ICONS = {
product_classification: 'folder',
bom_structure: 'git-branch',
dimension_formula: 'ruler',
component_config: 'settings',
pricing_structure: 'dollar-sign',
quantity_formula: 'calculator',
conditional_logic: 'git-merge',
quote_format: 'file-text',
};
const STATUS_CONFIG = {
draft: { label: '초안', bg: 'bg-gray-100', text: 'text-gray-700' },
interviewing: { label: '인터뷰 진행중', bg: 'bg-blue-100', text: 'text-blue-700' },
analyzing: { label: 'AI 분석중', bg: 'bg-yellow-100', text: 'text-yellow-700' },
code_generated: { label: '코드 생성완료', bg: 'bg-green-100', text: 'text-green-700' },
deployed: { label: '배포완료', bg: 'bg-purple-100', text: 'text-purple-700' },
};
const QUESTION_TYPE_LABELS = {
checkbox: '체크박스',
text: '텍스트',
number: '숫자',
select: '단일선택',
multi_select: '다중선택',
file_upload: '파일업로드',
formula_input: '수식입력',
table_input: '테이블입력',
bom_tree: 'BOM 트리',
price_table: '단가표',
dimension_diagram: '치수다이어그램',
};
// ============================================================
// 루트 앱 — 프로젝트 모드 / 기존 모드 전환
// ============================================================
function InterviewScenarioApp() {
const [mode, setMode] = useState('project'); // 'project' | 'legacy'
const [tree, setTree] = useState([]);
const [categories, setCategories] = useState([]);
const [selectedCategoryId, setSelectedCategoryId] = useState(null);
const [loading, setLoading] = useState(true);
const [showSessionModal, setShowSessionModal] = useState(false);
const [showHistoryModal, setShowHistoryModal] = useState(false);
// 프로젝트 모드 상태
const [projects, setProjects] = useState([]);
const [selectedProjectId, setSelectedProjectId] = useState(null);
const [projectTree, setProjectTree] = useState([]);
const [selectedDomainCategoryId, setSelectedDomainCategoryId] = useState(null);
const [activeTab, setActiveTab] = useState('questions'); // questions|interview|attachments|knowledge
const [showProjectCreateModal, setShowProjectCreateModal] = useState(false);
const loadTree = useCallback(async () => {
try {
const data = await api.get('/sales/interviews/api/tree');
setTree(data);
setCategories(data);
if (!selectedCategoryId && data.length > 0) {
// 첫 번째 루트의 첫 번째 자식 선택, 없으면 루트
const first = data[0];
const firstChild = first.children && first.children.length > 0 ? first.children[0] : first;
setSelectedCategoryId(firstChild.id);
}
} catch (e) {
console.error('트리 로드 실패:', e);
} finally {
setLoading(false);
}
}, [selectedCategoryId]);
const loadProjects = useCallback(async () => {
try {
const data = await api.get('/sales/interviews/api/projects');
setProjects(data.data || []);
} catch (e) {
console.error('프로젝트 로드 실패:', e);
}
}, []);
const loadProjectTree = useCallback(async (projectId) => {
if (!projectId) return;
try {
const data = await api.get(`/sales/interviews/api/projects/${projectId}/tree`);
setProjectTree(data);
if (data.length > 0 && !selectedDomainCategoryId) {
const first = data[0];
const firstChild = first.children && first.children.length > 0 ? first.children[0] : first;
setSelectedDomainCategoryId(firstChild.id);
}
} catch (e) {
console.error('프로젝트 트리 로드 실패:', e);
}
}, [selectedDomainCategoryId]);
useEffect(() => { loadTree(); loadProjects(); }, []);
useEffect(() => {
if (selectedProjectId) {
setSelectedDomainCategoryId(null);
loadProjectTree(selectedProjectId);
}
}, [selectedProjectId]);
const selectedCategory = findCategory(tree, selectedCategoryId);
const selectedProject = projects.find(p => p.id === selectedProjectId);
const selectedDomainCategory = findCategory(projectTree, selectedDomainCategoryId);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<IconLoader className="w-8 h-8 animate-spin text-blue-500" />
<span className="ml-2 text-gray-500">로딩 ...</span>
</div>
);
}
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconClipboard className="w-6 h-6 text-blue-600" />
<h1 className="text-xl font-bold text-gray-900">인터뷰 시나리오 관리</h1>
</div>
<div className="flex gap-2">
<button onClick={() => setMode(mode === 'project' ? 'legacy' : 'project')}
className="flex items-center gap-1 px-3 py-2 text-sm bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
{mode === 'project' ? <><IconSettings className="w-4 h-4" /> 기존 모드</> : <><IconFolder className="w-4 h-4" /> 프로젝트 모드</>}
</button>
<button onClick={() => setShowHistoryModal(true)}
className="flex items-center gap-1 px-3 py-2 text-sm bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
<IconHistory className="w-4 h-4" /> 인터뷰 기록
</button>
<button onClick={() => mode === 'project' ? setShowProjectCreateModal(true) : setShowSessionModal(true)}
className="flex items-center gap-1 px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
{mode === 'project' ? <><IconFolderPlus className="w-4 h-4" /> 프로젝트</> : <><IconPlay className="w-4 h-4" /> 인터뷰 실시</>}
</button>
</div>
</div>
{mode === 'project' ? (
<>
{/* 프로젝트 선택 바 */}
<ProjectSelector
projects={projects}
selectedId={selectedProjectId}
onSelect={(id) => { setSelectedProjectId(id); setActiveTab('questions'); }}
onRefresh={loadProjects}
/>
{selectedProject ? (
<>
{/* 진행 상태 바 */}
<ProjectStatusBar project={selectedProject} onRefresh={() => { loadProjects(); loadProjectTree(selectedProjectId); }} />
{/* 탭 네비게이션 */}
<div className="flex border-b border-gray-200 bg-white rounded-t-lg">
{[
{ key: 'questions', label: '질문 편집', icon: IconEdit },
{ key: 'interview', label: '인터뷰', icon: IconPlay },
{ key: 'attachments', label: '첨부파일', icon: IconPaperclip },
{ key: 'knowledge', label: '추출 지식', icon: IconBrain },
].map(tab => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-4 py-3 text-sm ${activeTab === tab.key ? 'tab-active' : 'tab-inactive'}`}>
<tab.icon className="w-4 h-4" /> {tab.label}
</button>
))}
</div>
{/* 2-패널: 도메인 트리 + 콘텐츠 */}
<div className="flex gap-4" style={{ minHeight: '500px' }}>
{/* 좌측: 도메인 트리 */}
<div className="flex-shrink-0 bg-white border border-gray-200 rounded-lg overflow-hidden" style={{ width: '220px' }}>
<DomainSidebar
categories={projectTree}
selectedId={selectedDomainCategoryId}
onSelect={setSelectedDomainCategoryId}
/>
</div>
{/* 우측: 탭 콘텐츠 */}
<div className="flex-1 bg-white border border-gray-200 rounded-lg overflow-hidden">
{activeTab === 'questions' && (
<MainContent
category={selectedDomainCategory}
onRefresh={() => loadProjectTree(selectedProjectId)}
/>
)}
{activeTab === 'interview' && (
<ProjectInterviewTab
project={selectedProject}
categories={projectTree}
selectedCategoryId={selectedDomainCategoryId}
/>
)}
{activeTab === 'attachments' && (
<ProjectAttachmentsTab
projectId={selectedProjectId}
/>
)}
{activeTab === 'knowledge' && (
<ProjectKnowledgeTab
projectId={selectedProjectId}
/>
)}
</div>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center h-64 bg-white border border-gray-200 rounded-lg">
<IconFolder className="w-12 h-12 text-gray-300 mb-3" />
<p className="text-gray-500 mb-4">프로젝트를 선택하거나 프로젝트를 생성하세요</p>
<button onClick={() => setShowProjectCreateModal(true)}
className="flex items-center gap-1 px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
<IconFolderPlus className="w-4 h-4" /> 프로젝트 생성
</button>
</div>
)}
</>
) : (
<>
{/* 기존 모드: 2-패널 레이아웃 */}
<div className="flex gap-4" style={{ minHeight: '600px' }}>
<div className="w-64 flex-shrink-0 bg-white border border-gray-200 rounded-lg overflow-hidden">
<CategorySidebar
categories={categories}
selectedId={selectedCategoryId}
onSelect={setSelectedCategoryId}
onRefresh={loadTree}
/>
</div>
<div className="flex-1 bg-white border border-gray-200 rounded-lg overflow-hidden">
<MainContent
category={selectedCategory}
onRefresh={loadTree}
/>
</div>
</div>
</>
)}
{/* 모달 */}
{showSessionModal && (
<InterviewSessionModal
categories={categories}
onClose={() => setShowSessionModal(false)}
onComplete={() => { setShowSessionModal(false); loadTree(); }}
/>
)}
{showHistoryModal && (
<SessionHistoryModal
categories={categories}
onClose={() => setShowHistoryModal(false)}
/>
)}
{showProjectCreateModal && (
<ProjectCreateModal
onClose={() => setShowProjectCreateModal(false)}
onCreated={(project) => {
setShowProjectCreateModal(false);
loadProjects();
setSelectedProjectId(project.id);
}}
/>
)}
</div>
);
}
// ============================================================
// 프로젝트 선택 바
// ============================================================
function ProjectSelector({ projects, selectedId, onSelect, onRefresh }) {
return (
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{projects.map(p => {
const st = STATUS_CONFIG[p.status] || STATUS_CONFIG.draft;
return (
<div key={p.id}
onClick={() => onSelect(p.id)}
className={`project-card flex-shrink-0 px-4 py-3 rounded-lg bg-white ${p.id === selectedId ? 'active' : ''}`}
style={{ minWidth: '180px' }}>
<div className="font-medium text-sm text-gray-900 truncate">{p.company_name}</div>
<div className="flex items-center gap-2 mt-1">
<span className={`status-badge ${st.bg} ${st.text}`}>{st.label}</span>
<span className="text-xs text-gray-400">{p.progress_percent}%</span>
</div>
</div>
);
})}
{projects.length === 0 && (
<div className="text-sm text-gray-400 py-2">프로젝트가 없습니다</div>
)}
</div>
);
}
// ============================================================
// 프로젝트 상태 바
// ============================================================
function ProjectStatusBar({ project, onRefresh }) {
const st = STATUS_CONFIG[project.status] || STATUS_CONFIG.draft;
return (
<div className="flex items-center gap-4 px-4 py-3 bg-white border border-gray-200 rounded-lg">
<div className="flex items-center gap-2 flex-shrink-0">
<IconTarget className="w-5 h-5 text-blue-500" />
<span className="font-semibold text-gray-900">{project.company_name}</span>
{project.company_type && <span className="text-sm text-gray-500">({project.company_type})</span>}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full progress-bar-fill" style={{ width: `${project.progress_percent}%` }}></div>
</div>
<span className="text-sm font-medium text-gray-600 flex-shrink-0">{project.progress_percent}%</span>
</div>
</div>
<span className={`status-badge ${st.bg} ${st.text} flex-shrink-0`}>{st.label}</span>
{project.contact_person && <span className="text-xs text-gray-400 flex-shrink-0">담당: {project.contact_person}</span>}
</div>
);
}
// ============================================================
// 도메인 사이드바
// ============================================================
function DomainSidebar({ categories, selectedId, onSelect }) {
const [expandedRoots, setExpandedRoots] = useState(() => {
const map = {};
categories.forEach(c => { map[c.id] = true; });
return map;
});
useEffect(() => {
setExpandedRoots(prev => {
const map = { ...prev };
categories.forEach(c => { if (map[c.id] === undefined) map[c.id] = true; });
return map;
});
}, [categories]);
const toggleRoot = (id) => {
setExpandedRoots(prev => ({ ...prev, [id]: !prev[id] }));
};
const countQuestions = (cat) => {
let count = (cat.templates || []).reduce((sum, t) => sum + (t.questions || []).length, 0);
(cat.children || []).forEach(child => {
count += (child.templates || []).reduce((sum, t) => sum + (t.questions || []).length, 0);
});
return count;
};
return (
<div className="h-full overflow-y-auto">
<div className="px-3 py-3 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-700">도메인</h3>
</div>
<div className="py-1">
{categories.map(root => {
const hasChildren = root.children && root.children.length > 0;
const expanded = expandedRoots[root.id];
return (
<div key={root.id}>
{/* 대분류 */}
<div className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50"
onClick={() => hasChildren ? toggleRoot(root.id) : onSelect(root.id)}>
<div className="flex items-center gap-1.5 min-w-0">
{hasChildren && (
<span className="text-xs text-gray-400" style={{ width: '14px', display: 'inline-block' }}>
{expanded ? '▼' : '▶'}
</span>
)}
<span className="text-xs">📁</span>
<span className="text-sm font-medium text-gray-700 truncate">{root.name}</span>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">{countQuestions(root)}</span>
</div>
{/* 중분류 (자식) */}
{hasChildren && expanded && root.children.map(child => {
const qCount = (child.templates || []).reduce((sum, t) => sum + (t.questions || []).length, 0);
return (
<div key={child.id}
onClick={() => onSelect(child.id)}
className={`domain-item flex items-center justify-between py-2 pr-3 ${child.id === selectedId ? 'active' : ''}`}
style={{ paddingLeft: '2rem' }}>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs">📋</span>
<span className="text-sm truncate">{child.name}</span>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">{qCount}</span>
</div>
);
})}
</div>
);
})}
</div>
</div>
);
}
// ============================================================
// 프로젝트 생성 모달
// ============================================================
function ProjectCreateModal({ onClose, onCreated }) {
const [companyName, setCompanyName] = useState('');
const [companyType, setCompanyType] = useState('');
const [contactPerson, setContactPerson] = useState('');
const [contactInfo, setContactInfo] = useState('');
const [saving, setSaving] = useState(false);
const handleSubmit = async () => {
if (!companyName.trim()) { alert('회사명을 입력하세요.'); return; }
setSaving(true);
try {
const project = await api.post('/sales/interviews/api/projects', {
company_name: companyName.trim(),
company_type: companyType.trim() || null,
contact_person: contactPerson.trim() || null,
contact_info: contactInfo.trim() || null,
});
onCreated(project);
} catch (e) {
alert('프로젝트 생성 실패: ' + e.message);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center modal-overlay fade-in" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl" style={{ width: '480px' }} onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900"> 인터뷰 프로젝트</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><IconX className="w-5 h-5" /></button>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">회사명 *</label>
<input type="text" value={companyName} onChange={e => setCompanyName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="예: ABC 방화셔터"
onKeyDown={e => e.key === 'Enter' && handleSubmit()} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">업종</label>
<input type="text" value={companyType} onChange={e => setCompanyType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="예: 방화셔터, 블라인드, 금속가공" />
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">담당자</label>
<input type="text" value={contactPerson} onChange={e => setContactPerson(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="이름" />
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">연락처</label>
<input type="text" value={contactInfo} onChange={e => setContactInfo(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="전화/이메일" />
</div>
</div>
<div className="bg-blue-50 rounded-lg px-3 py-2 text-xs text-blue-700">
프로젝트 생성 8 도메인의 마스터 질문이 자동으로 복제됩니다.
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-3 border-t border-gray-200 bg-gray-50 rounded-b-xl">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">취소</button>
<button onClick={handleSubmit} disabled={saving}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{saving ? '생성 중...' : '프로젝트 생성'}
</button>
</div>
</div>
</div>
);
}
// ============================================================
// 프로젝트 인터뷰 탭
// ============================================================
function ProjectInterviewTab({ project, categories, selectedCategoryId }) {
const [sessionData, setSessionData] = useState(null);
const [loadingSession, setLoadingSession] = useState(false);
const startInterview = async () => {
if (!selectedCategoryId) { alert('도메인을 선택하세요.'); return; }
setLoadingSession(true);
try {
const session = await api.post('/sales/interviews/api/sessions', {
interview_category_id: selectedCategoryId,
interview_date: new Date().toISOString().split('T')[0],
interviewee_company: project.company_name,
});
const detail = await api.get(`/sales/interviews/api/sessions/${session.id}`);
setSessionData(detail);
} catch (e) {
alert('세션 시작 실패: ' + e.message);
} finally {
setLoadingSession(false);
}
};
const handleSaveAnswer = async (sessionId, questionId, data) => {
try {
await api.post('/sales/interviews/api/sessions/save-answer', {
session_id: sessionId,
question_id: questionId,
...data,
});
const detail = await api.get(`/sales/interviews/api/sessions/${sessionId}`);
setSessionData(detail);
} catch (e) {
console.error('답변 저장 실패:', e);
}
};
if (!selectedCategoryId) {
return (
<div className="flex items-center justify-center h-64 text-gray-400">
좌측에서 도메인을 선택하세요
</div>
);
}
if (!sessionData) {
return (
<div className="flex flex-col items-center justify-center h-64">
<IconPlay className="w-10 h-10 text-gray-300 mb-3" />
<p className="text-gray-500 mb-4">선택한 도메인의 인터뷰를 시작하세요</p>
<button onClick={startInterview} disabled={loadingSession}
className="flex items-center gap-1 px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{loadingSession ? <><IconLoader className="w-4 h-4 animate-spin" /> 준비 ...</> : <><IconPlay className="w-4 h-4" /> 인터뷰 시작</>}
</button>
</div>
);
}
// 인터뷰 진행 뷰
const grouped = {};
(sessionData.answers || []).forEach(ans => {
const tplId = ans.interview_template_id;
if (!grouped[tplId]) grouped[tplId] = { template: ans.template, answers: [] };
grouped[tplId].answers.push(ans);
});
return (
<div className="overflow-y-auto p-4 space-y-4" style={{ maxHeight: '60vh' }}>
{/* 진행률 */}
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full progress-bar-fill"
style={{ width: `${sessionData.total_questions > 0 ? Math.round(sessionData.answered_questions / sessionData.total_questions * 100) : 0}%` }} />
</div>
<span className="text-sm text-gray-600">{sessionData.answered_questions}/{sessionData.total_questions}</span>
</div>
{Object.entries(grouped).map(([tplId, { template, answers }]) => (
<div key={tplId} className="border border-gray-200 rounded-lg overflow-hidden">
<div className="bg-gray-50 px-4 py-2 font-medium text-sm text-gray-700">
{template?.name || `항목 ${tplId}`}
</div>
<div className="divide-y divide-gray-100">
{answers.map(ans => (
<StructuredAnswerRow key={ans.id} answer={ans}
onSave={(data) => handleSaveAnswer(sessionData.id, ans.interview_question_id, data)} />
))}
</div>
</div>
))}
{sessionData.status === 'in_progress' && (
<div className="flex justify-end">
<button onClick={async () => {
await api.post(`/sales/interviews/api/sessions/${sessionData.id}/complete`);
setSessionData(null);
}}
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
인터뷰 완료
</button>
</div>
)}
</div>
);
}
// ============================================================
// 구조화 답변 행
// ============================================================
function StructuredAnswerRow({ answer, onSave }) {
const q = answer.question;
const qType = q?.question_type || 'checkbox';
const typeLabel = QUESTION_TYPE_LABELS[qType] || qType;
const handleCheckToggle = () => {
onSave({ is_checked: !answer.is_checked });
};
const handleTextSave = (value) => {
onSave({ is_checked: true, answer_text: value });
};
const handleDataSave = (data) => {
onSave({ is_checked: true, answer_data: data });
};
return (
<div className="px-4 py-3">
<div className="flex items-start gap-3">
<button onClick={handleCheckToggle}
className={`flex-shrink-0 w-5 h-5 rounded border mt-0.5 flex items-center justify-center ${answer.is_checked ? 'bg-blue-500 border-blue-500 text-white' : 'border-gray-300 hover:border-blue-400'}`}>
{answer.is_checked && <IconCheck className="w-3 h-3" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-sm ${answer.is_checked ? 'text-gray-900' : 'text-gray-600'}`}>
{q?.question_text}
</span>
<span className="text-xs text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">{typeLabel}</span>
{q?.is_required && <span className="text-xs text-red-500">*필수</span>}
</div>
{q?.ai_hint && <div className="text-xs text-blue-500 mt-0.5">{q.ai_hint}</div>}
{/* 타입별 입력 UI */}
<div className="mt-2">
{(qType === 'text' || qType === 'formula_input') && (
<AnswerTextInput value={answer.answer_text || ''} format={q?.expected_format}
onSave={handleTextSave} placeholder={qType === 'formula_input' ? '수식을 입력하세요 (예: W1 = W0 + 120)' : '답변을 입력하세요'} />
)}
{qType === 'number' && (
<AnswerTextInput value={answer.answer_text || ''} format={q?.expected_format}
onSave={handleTextSave} type="number" placeholder="숫자를 입력하세요" />
)}
{qType === 'select' && (
<AnswerSelectInput value={answer.answer_text || ''} options={q?.options?.choices || []}
onSave={handleTextSave} />
)}
{qType === 'multi_select' && (
<AnswerMultiSelectInput value={answer.answer_data || []} options={q?.options?.choices || []}
onSave={handleDataSave} />
)}
{qType === 'table_input' && (
<AnswerTableInput value={answer.answer_data || { rows: [] }} columns={q?.options?.columns || []}
onSave={handleDataSave} />
)}
{qType === 'file_upload' && (
<div className="text-xs text-gray-400">첨부파일 탭에서 파일을 업로드하세요</div>
)}
{(qType === 'bom_tree' || qType === 'price_table' || qType === 'dimension_diagram') && (
<AnswerTextInput value={answer.answer_text || ''} onSave={handleTextSave}
placeholder={`${typeLabel} 데이터를 텍스트로 입력하세요`} multiline />
)}
</div>
</div>
</div>
</div>
);
}
// ============================================================
// 답변 입력 컴포넌트들
// ============================================================
function AnswerTextInput({ value, onSave, placeholder = '', format, type = 'text', multiline = false }) {
const [text, setText] = useState(value);
const [editing, setEditing] = useState(false);
if (!editing && text) {
return (
<div className="flex items-center gap-2 cursor-pointer group" onClick={() => setEditing(true)}>
<span className="text-sm text-gray-700">{text}</span>
{format && <span className="text-xs text-gray-400">{format}</span>}
<IconEdit className="w-3 h-3 text-gray-300 group-hover:text-gray-500" />
</div>
);
}
const InputTag = multiline ? 'textarea' : 'input';
return (
<div className="flex items-center gap-2">
<InputTag type={type} value={text} onChange={e => setText(e.target.value)}
className="flex-1 px-2 py-1 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500"
placeholder={placeholder}
rows={multiline ? 3 : undefined}
onKeyDown={e => { if (e.key === 'Enter' && !multiline) { onSave(text); setEditing(false); } }}
autoFocus />
{format && <span className="text-xs text-gray-400 flex-shrink-0">{format}</span>}
<button onClick={() => { onSave(text); setEditing(false); }}
className="px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600">저장</button>
</div>
);
}
function AnswerSelectInput({ value, options, onSave }) {
return (
<select value={value} onChange={e => onSave(e.target.value)}
className="px-2 py-1 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500">
<option value="">선택하세요</option>
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
);
}
function AnswerMultiSelectInput({ value, options, onSave }) {
const selected = Array.isArray(value) ? value : [];
const toggle = (opt) => {
const next = selected.includes(opt) ? selected.filter(v => v !== opt) : [...selected, opt];
onSave(next);
};
return (
<div className="flex flex-wrap gap-1.5">
{options.map(opt => (
<button key={opt} onClick={() => toggle(opt)}
className={`px-2 py-1 text-xs rounded-full border ${selected.includes(opt) ? 'bg-blue-100 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-600 hover:border-blue-200'}`}>
{opt}
</button>
))}
</div>
);
}
function AnswerTableInput({ value, columns, onSave }) {
const rows = value?.rows || [];
const [localRows, setLocalRows] = useState(rows);
const addRow = () => {
const newRow = {};
columns.forEach(c => { newRow[c] = ''; });
const updated = [...localRows, newRow];
setLocalRows(updated);
onSave({ rows: updated });
};
const updateCell = (rowIdx, col, val) => {
const updated = [...localRows];
updated[rowIdx] = { ...updated[rowIdx], [col]: val };
setLocalRows(updated);
};
const saveAll = () => {
onSave({ rows: localRows });
};
const removeRow = (idx) => {
const updated = localRows.filter((_, i) => i !== idx);
setLocalRows(updated);
onSave({ rows: updated });
};
return (
<div className="space-y-1">
<div className="overflow-x-auto">
<table className="text-xs w-full border-collapse">
<thead>
<tr className="bg-gray-50">
{columns.map(col => (
<th key={col} className="px-2 py-1 text-left border border-gray-200 font-medium text-gray-600">{col}</th>
))}
<th className="px-2 py-1 border border-gray-200 w-8"></th>
</tr>
</thead>
<tbody>
{localRows.map((row, ri) => (
<tr key={ri}>
{columns.map(col => (
<td key={col} className="border border-gray-200 p-0">
<input type="text" value={row[col] || ''} onChange={e => updateCell(ri, col, e.target.value)}
onBlur={saveAll}
className="w-full px-2 py-1 text-xs border-0 focus:ring-0" />
</td>
))}
<td className="border border-gray-200 text-center">
<button onClick={() => removeRow(ri)} className="text-red-400 hover:text-red-600">
<IconX className="w-3 h-3" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button onClick={addRow} className="text-xs text-blue-500 hover:text-blue-700">+ 추가</button>
</div>
);
}
// ============================================================
// 첨부파일 탭
// ============================================================
function ProjectAttachmentsTab({ projectId }) {
const [attachments, setAttachments] = useState([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const fileInput = useRef(null);
const [fileType, setFileType] = useState('other');
const load = useCallback(async () => {
try {
const data = await api.get(`/sales/interviews/api/projects/${projectId}/attachments`);
setAttachments(data);
} catch (e) { console.error(e); }
finally { setLoading(false); }
}, [projectId]);
useEffect(() => { load(); }, [load]);
const handleUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
setUploading(true);
try {
const form = new FormData();
form.append('file', file);
form.append('file_type', fileType);
const res = await fetch(`/sales/interviews/api/projects/${projectId}/attachments`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF_TOKEN, 'Accept': 'application/json' },
body: form,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
load();
} catch (e) { alert('업로드 실패: ' + e.message); }
finally { setUploading(false); if (fileInput.current) fileInput.current.value = ''; }
};
const handleDelete = async (id) => {
if (!confirm('삭제하시겠습니까?')) return;
try {
await api.del(`/sales/interviews/api/attachments/${id}`);
load();
} catch (e) { alert('삭제 실패: ' + e.message); }
};
const FILE_TYPE_LABELS = {
excel_template: '엑셀 템플릿',
pdf_quote: 'PDF 견적서',
sample_bom: 'BOM 샘플',
price_list: '단가표',
photo: '사진',
voice: '음성녹음',
other: '기타',
};
return (
<div className="p-4 space-y-4">
<div className="flex items-center gap-3">
<select value={fileType} onChange={e => setFileType(e.target.value)}
className="px-2 py-1.5 border border-gray-300 rounded text-sm">
{Object.entries(FILE_TYPE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<button onClick={() => fileInput.current?.click()} disabled={uploading}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">
<IconUpload className="w-4 h-4" /> {uploading ? '업로드 중...' : '파일 업로드'}
</button>
<input ref={fileInput} type="file" className="hidden" onChange={handleUpload} accept="*/*" />
</div>
{loading ? (
<div className="flex items-center justify-center h-32">
<IconLoader className="w-6 h-6 animate-spin text-blue-500" />
</div>
) : attachments.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-gray-400">
<IconPaperclip className="w-8 h-8 mb-2" />
<span>첨부파일이 없습니다</span>
</div>
) : (
<div className="space-y-2">
{attachments.map(att => (
<div key={att.id} className="flex items-center justify-between px-4 py-3 border border-gray-200 rounded-lg">
<div className="flex items-center gap-3">
<IconFileText className="w-5 h-5 text-gray-400" />
<div>
<div className="text-sm font-medium text-gray-900">{att.file_name}</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>{FILE_TYPE_LABELS[att.file_type] || att.file_type}</span>
<span>{(att.file_size / 1024).toFixed(1)}KB</span>
<span className={`status-badge ${att.ai_analysis_status === 'completed' ? 'bg-green-100 text-green-700' : att.ai_analysis_status === 'processing' ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-600'}`}>
AI: {att.ai_analysis_status}
</span>
</div>
</div>
</div>
<button onClick={() => handleDelete(att.id)} className="text-red-400 hover:text-red-600">
<IconTrash className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
);
}
// ============================================================
// 지식 탭
// ============================================================
function ProjectKnowledgeTab({ projectId }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [domainFilter, setDomainFilter] = useState('');
const [verifiedFilter, setVerifiedFilter] = useState('');
const [showAddForm, setShowAddForm] = useState(false);
const load = useCallback(async () => {
try {
let url = `/sales/interviews/api/projects/${projectId}/knowledge?`;
if (domainFilter) url += `domain=${domainFilter}&`;
if (verifiedFilter) url += `is_verified=${verifiedFilter}&`;
const data = await api.get(url);
setItems(data);
} catch (e) { console.error(e); }
finally { setLoading(false); }
}, [projectId, domainFilter, verifiedFilter]);
useEffect(() => { load(); }, [load]);
const handleVerify = async (id) => {
try {
await api.post(`/sales/interviews/api/knowledge/${id}/verify`);
load();
} catch (e) { alert('검증 토글 실패: ' + e.message); }
};
const handleDelete = async (id) => {
if (!confirm('삭제하시겠습니까?')) return;
try {
await api.del(`/sales/interviews/api/knowledge/${id}`);
load();
} catch (e) { alert('삭제 실패: ' + e.message); }
};
const handleAdd = async (data) => {
try {
await api.post(`/sales/interviews/api/projects/${projectId}/knowledge`, data);
setShowAddForm(false);
load();
} catch (e) { alert('추가 실패: ' + e.message); }
};
const KNOWLEDGE_TYPE_ICONS = {
fact: '📌', rule: '📏', formula: '🔢', mapping: '🔗', range: '📊', table: '📋',
};
return (
<div className="p-4 space-y-4">
{/* 필터 바 */}
<div className="flex items-center gap-3">
<select value={domainFilter} onChange={e => setDomainFilter(e.target.value)}
className="px-2 py-1.5 border border-gray-300 rounded text-sm">
<option value="">전체 도메인</option>
{Object.entries(DOMAIN_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<select value={verifiedFilter} onChange={e => setVerifiedFilter(e.target.value)}
className="px-2 py-1.5 border border-gray-300 rounded text-sm">
<option value="">전체</option>
<option value="true">검증됨</option>
<option value="false">미검증</option>
</select>
<button onClick={() => setShowAddForm(true)}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 ml-auto">
<IconPlus className="w-4 h-4" /> 수동 추가
</button>
</div>
{/* 지식 추가 폼 */}
{showAddForm && (
<KnowledgeAddForm
onSave={handleAdd}
onCancel={() => setShowAddForm(false)}
/>
)}
{loading ? (
<div className="flex items-center justify-center h-32">
<IconLoader className="w-6 h-6 animate-spin text-blue-500" />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-gray-400">
<IconBrain className="w-8 h-8 mb-2" />
<span>추출된 지식이 없습니다</span>
</div>
) : (
<div className="space-y-2">
{items.map(item => (
<div key={item.id} className="knowledge-card">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<span>{KNOWLEDGE_TYPE_ICONS[item.knowledge_type] || '📎'}</span>
<span className={`text-xs font-medium ${item.is_verified ? 'text-green-600' : 'text-yellow-600'}`}>
{item.is_verified ? '검증됨' : '미검증'}
</span>
<span className="text-sm font-medium text-gray-900">{item.title}</span>
</div>
<div className="flex items-center gap-1">
<button onClick={() => handleVerify(item.id)}
className={`px-2 py-0.5 text-xs rounded ${item.is_verified ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600 hover:bg-green-50'}`}>
{item.is_verified ? '검증 해제' : '검증'}
</button>
<button onClick={() => handleDelete(item.id)} className="text-red-400 hover:text-red-600">
<IconTrash className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="mt-1 text-xs text-gray-500">
{DOMAIN_LABELS[item.domain] || item.domain} · {item.knowledge_type} · 출처: {item.source_type}
</div>
{/* 신뢰도 바 */}
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-gray-400">신뢰도</span>
<div className="confidence-bar flex-1" style={{ maxWidth: '100px' }}>
<div className="confidence-fill"
style={{
width: `${(item.confidence || 0) * 100}%`,
backgroundColor: item.confidence >= 0.8 ? '#22c55e' : item.confidence >= 0.5 ? '#eab308' : '#ef4444'
}} />
</div>
<span className="text-xs text-gray-500">{((item.confidence || 0) * 100).toFixed(0)}%</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
// ============================================================
// 지식 수동 추가 폼
// ============================================================
function KnowledgeAddForm({ onSave, onCancel }) {
const [domain, setDomain] = useState('product_classification');
const [knowledgeType, setKnowledgeType] = useState('fact');
const [title, setTitle] = useState('');
const [contentText, setContentText] = useState('');
const handleSave = () => {
if (!title.trim()) { alert('제목을 입력하세요.'); return; }
onSave({
domain,
knowledge_type: knowledgeType,
title: title.trim(),
content: { text: contentText },
source_type: 'manual',
confidence: 1.0,
});
};
return (
<div className="border border-blue-200 rounded-lg p-4 bg-blue-50 space-y-3">
<div className="flex gap-3">
<select value={domain} onChange={e => setDomain(e.target.value)}
className="px-2 py-1.5 border border-gray-300 rounded text-sm">
{Object.entries(DOMAIN_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<select value={knowledgeType} onChange={e => setKnowledgeType(e.target.value)}
className="px-2 py-1.5 border border-gray-300 rounded text-sm">
<option value="fact">사실 (fact)</option>
<option value="rule">규칙 (rule)</option>
<option value="formula">수식 (formula)</option>
<option value="mapping">매핑 (mapping)</option>
<option value="range">범위 (range)</option>
<option value="table">테이블 (table)</option>
</select>
</div>
<input type="text" value={title} onChange={e => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
placeholder="지식 제목 (예: 가이드레일 표준 길이)" />
<textarea value={contentText} onChange={e => setContentText(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm" rows={3}
placeholder="내용을 입력하세요" />
<div className="flex gap-2 justify-end">
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800">취소</button>
<button onClick={handleSave} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">추가</button>
</div>
</div>
);
}
// ============================================================
// 좌측: 카테고리 사이드바
// ============================================================
function CategorySidebar({ categories, selectedId, onSelect, onRefresh }) {
const [showAddRoot, setShowAddRoot] = useState(false);
const [showAddChildFor, setShowAddChildFor] = useState(null);
const [newName, setNewName] = useState('');
const [editingId, setEditingId] = useState(null);
const [editName, setEditName] = useState('');
const [expandedRoots, setExpandedRoots] = useState(() => {
const map = {};
categories.forEach(c => { map[c.id] = true; });
return map;
});
useEffect(() => {
setExpandedRoots(prev => {
const map = { ...prev };
categories.forEach(c => { if (map[c.id] === undefined) map[c.id] = true; });
return map;
});
}, [categories]);
const toggleRoot = (id) => {
setExpandedRoots(prev => ({ ...prev, [id]: !prev[id] }));
};
const handleAdd = async (parentId = null) => {
if (!newName.trim()) return;
try {
await api.post('/sales/interviews/api/categories', { name: newName.trim(), parent_id: parentId });
setNewName('');
setShowAddRoot(false);
setShowAddChildFor(null);
onRefresh();
} catch (e) { alert('카테고리 생성 실패: ' + e.message); }
};
const handleUpdate = async (id) => {
if (!editName.trim()) return;
try {
await api.put(`/sales/interviews/api/categories/${id}`, { name: editName.trim() });
setEditingId(null);
onRefresh();
} catch (e) { alert('수정 실패: ' + e.message); }
};
const handleDelete = async (id) => {
if (!confirm('이 카테고리를 삭제하시겠습니까?\n하위 항목과 질문도 함께 삭제됩니다.')) return;
try {
await api.del(`/sales/interviews/api/categories/${id}`);
onRefresh();
} catch (e) { alert('삭제 실패: ' + e.message); }
};
const renderEditRow = (id) => (
<div className="flex items-center gap-1 px-2" onClick={e => e.stopPropagation()}>
<input type="text" value={editName} onChange={e => setEditName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleUpdate(id); if (e.key === 'Escape') setEditingId(null); }}
className="flex-1 text-sm border rounded px-1.5 py-0.5" autoFocus />
<button onClick={() => handleUpdate(id)}
className="text-xs px-1.5 py-0.5 bg-green-600 text-white rounded hover:bg-green-700">저장</button>
<button onClick={() => setEditingId(null)}
className="text-xs px-1.5 py-0.5 text-gray-500 hover:text-gray-700">취소</button>
</div>
);
const renderAddInput = (parentId = null) => (
<div className="px-3 py-2 border-b border-gray-100 bg-blue-50" style={{ paddingLeft: parentId ? '2rem' : undefined }}>
<input type="text" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdd(parentId)}
placeholder={parentId ? '중분류명 입력' : '대분류명 입력'} autoFocus
className="w-full text-sm border border-gray-300 rounded px-2 py-1 mb-1" />
<div className="flex gap-1 justify-end">
<button onClick={() => { parentId ? setShowAddChildFor(null) : setShowAddRoot(false); setNewName(''); }}
className="text-xs px-2 py-1 text-gray-500 hover:text-gray-700">취소</button>
<button onClick={() => handleAdd(parentId)}
className="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">추가</button>
</div>
</div>
);
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
<span className="text-sm font-semibold text-gray-700">카테고리</span>
<button onClick={() => { setShowAddRoot(!showAddRoot); setShowAddChildFor(null); setNewName(''); }}
className="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">
+ 대분류
</button>
</div>
{showAddRoot && renderAddInput(null)}
<div className="flex-1 overflow-y-auto">
{categories.length === 0 && !showAddRoot && (
<div className="px-4 py-8 text-center">
<p className="text-sm text-gray-400 mb-3">카테고리가 없습니다</p>
<button onClick={() => setShowAddRoot(true)}
className="text-sm px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700">
+ 대분류 추가
</button>
</div>
)}
{categories.map(root => {
const hasChildren = root.children && root.children.length > 0;
const expanded = expandedRoots[root.id];
return (
<div key={root.id}>
{/* 대분류 (루트) */}
{editingId === root.id ? (
<div className="px-3 py-2">{renderEditRow(root.id)}</div>
) : (
<div className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50 border-b border-gray-100"
onClick={() => hasChildren ? toggleRoot(root.id) : onSelect(root.id)}>
<div className="flex items-center gap-1.5 min-w-0">
{hasChildren && (
<span className="text-xs text-gray-400" style={{ width: '14px', display: 'inline-block' }}>
{expanded ? '▼' : '▶'}
</span>
)}
<span className="text-sm font-medium text-gray-800 truncate">{root.name}</span>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0" onClick={e => e.stopPropagation()}>
<button onClick={() => { setEditingId(root.id); setEditName(root.name); }}
className="text-xs text-blue-600 hover:text-blue-800">수정</button>
<button onClick={() => handleDelete(root.id)}
className="text-xs text-red-500 hover:text-red-700">삭제</button>
</div>
</div>
)}
{/* 중분류 목록 + 하단 추가 버튼 */}
{expanded && (
<>
{hasChildren && root.children.map(child => (
<div key={child.id}>
{editingId === child.id ? (
<div className="py-2" style={{ paddingLeft: '2rem' }}>{renderEditRow(child.id)}</div>
) : (
<div className={`category-item flex items-center justify-between py-2 pr-3 cursor-pointer ${selectedId === child.id ? 'active' : ''}`}
style={{ paddingLeft: '2rem' }}
onClick={() => onSelect(child.id)}>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs">📋</span>
<span className="text-sm text-gray-700 truncate">{child.name}</span>
</div>
{selectedId === child.id && (
<div className="flex items-center gap-1 ml-2 flex-shrink-0" onClick={e => e.stopPropagation()}>
<button onClick={() => { setEditingId(child.id); setEditName(child.name); }}
className="text-xs text-blue-600 hover:text-blue-800">수정</button>
<button onClick={() => handleDelete(child.id)}
className="text-xs text-red-500 hover:text-red-700">삭제</button>
</div>
)}
</div>
)}
</div>
))}
{/* 중분류 추가 입력 */}
{showAddChildFor === root.id && renderAddInput(root.id)}
{/* + 중분류 추가 버튼 */}
{showAddChildFor !== root.id && (
<div className="py-1.5 pr-3 border-b border-gray-100" style={{ paddingLeft: '2rem' }}>
<button onClick={() => { setShowAddChildFor(root.id); setShowAddRoot(false); setNewName(''); }}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-blue-600">
<span>+</span> 중분류 추가
</button>
</div>
)}
</>
)}
</div>
);
})}
</div>
</div>
);
}
// ============================================================
// 우측: 메인 콘텐츠 (항목 + 질문)
// ============================================================
function MainContent({ category, onRefresh }) {
const [showAddTemplate, setShowAddTemplate] = useState(false);
const [showMdPreview, setShowMdPreview] = useState(false);
const [mdParsed, setMdParsed] = useState([]);
const [mdImporting, setMdImporting] = useState(false);
const fileInputRef = useRef(null);
const parseMd = (text) => {
const lines = text.split('\n');
const result = [];
let current = null;
for (const line of lines) {
const headerMatch = line.match(/^#+\s+(.+)/);
if (headerMatch) {
current = { name: headerMatch[1].trim(), questions: [] };
result.push(current);
continue;
}
const listMatch = line.match(/^[-*]\s+(.+)/);
if (listMatch && current) {
current.questions.push(listMatch[1].trim());
}
}
return result.filter(t => t.questions.length > 0);
};
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const parsed = parseMd(ev.target.result);
if (parsed.length === 0) {
alert('파싱 가능한 항목이 없습니다.\n# 헤더와 - 질문 형식을 확인하세요.');
return;
}
setMdParsed(parsed);
setShowMdPreview(true);
};
reader.readAsText(file);
e.target.value = '';
};
const handleBulkImport = async () => {
setMdImporting(true);
try {
await api.post('/sales/interviews/api/bulk-import', {
category_id: category.id,
templates: mdParsed,
});
setShowMdPreview(false);
setMdParsed([]);
onRefresh();
} catch (e) { alert('일괄 생성 실패: ' + e.message); }
finally { setMdImporting(false); }
};
if (!category) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
<div className="text-center">
<p className="text-lg mb-1">📋</p>
<p>카테고리를 선택하세요</p>
</div>
</div>
);
}
const templates = category.templates || [];
const totalMdQuestions = mdParsed.reduce((sum, t) => sum + t.questions.length, 0);
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
<div>
<span className="text-sm font-semibold text-gray-700">{category.name}</span>
<span className="ml-2 text-xs text-gray-400">{templates.length} 항목</span>
</div>
<div>
<input type="file" ref={fileInputRef} accept=".md,.txt" onChange={handleFileSelect} className="hidden" />
<button onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1 text-xs px-3 py-1.5 bg-green-600 text-white rounded hover:bg-green-700">
<IconUpload className="w-3.5 h-3.5" /> MD 업로드
</button>
</div>
</div>
{/* MD 미리보기 */}
{showMdPreview && (
<div className="mx-4 mt-3 p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-green-800">
MD 파싱 결과: 항목 {mdParsed.length}, 질문 {totalMdQuestions}
</span>
<button onClick={() => { setShowMdPreview(false); setMdParsed([]); }}
className="text-xs text-gray-500 hover:text-gray-700">닫기</button>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
{mdParsed.map((tpl, i) => (
<div key={i} className="bg-white rounded border border-green-100 p-2">
<div className="text-sm font-medium text-gray-800 mb-1">📄 {tpl.name}</div>
<ul className="space-y-0.5">
{tpl.questions.map((q, j) => (
<li key={j} className="text-xs text-gray-600 pl-3"> {q}</li>
))}
</ul>
</div>
))}
</div>
<div className="flex gap-2 justify-end mt-3">
<button onClick={() => { setShowMdPreview(false); setMdParsed([]); }}
className="text-xs px-3 py-1.5 text-gray-500 hover:text-gray-700">취소</button>
<button onClick={handleBulkImport} disabled={mdImporting}
className="text-xs px-3 py-1.5 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50">
{mdImporting ? '생성 중...' : '확인 - 일괄 생성'}
</button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{templates.map(tpl => (
<TemplateCard key={tpl.id} template={tpl} onRefresh={onRefresh} />
))}
{templates.length === 0 && !showAddTemplate && (
<div className="text-center py-8 text-sm text-gray-400">
항목을 추가하여 인터뷰 시나리오를 만드세요
</div>
)}
{showAddTemplate ? (
<AddTemplateForm
categoryId={category.id}
onCancel={() => setShowAddTemplate(false)}
onSaved={() => { setShowAddTemplate(false); onRefresh(); }}
/>
) : (
<button onClick={() => setShowAddTemplate(true)}
className="text-sm px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700">
+ 항목 추가
</button>
)}
</div>
</div>
);
}
// ============================================================
// 항목 카드
// ============================================================
function TemplateCard({ template, onRefresh }) {
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState(template.name);
const [showAddQuestion, setShowAddQuestion] = useState(false);
const [confirmPos, setConfirmPos] = useState(null);
const [deleting, setDeleting] = useState(false);
const questions = template.questions || [];
const handleUpdate = async () => {
if (!editName.trim()) return;
try {
await api.put(`/sales/interviews/api/templates/${template.id}`, { name: editName.trim() });
setEditing(false);
onRefresh();
} catch (e) { alert('수정 실패: ' + e.message); }
};
const handleDeleteClick = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
setConfirmPos({ top: rect.bottom + 4, left: rect.right - 200 });
};
const handleDeleteConfirm = async () => {
setDeleting(true);
try {
await api.del(`/sales/interviews/api/templates/${template.id}`);
onRefresh();
} catch (e) { alert('삭제 실패: ' + e.message); setDeleting(false); }
setConfirmPos(null);
};
return (
<div className="template-card" style={{ position: 'relative' }}>
{/* 항목 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 bg-gray-50 rounded-t-lg border-b border-gray-200">
{editing ? (
<div className="flex-1 flex items-center gap-2">
<input type="text" value={editName} onChange={e => setEditName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleUpdate(); if (e.key === 'Escape') setEditing(false); }}
className="flex-1 text-sm border rounded px-2 py-1" autoFocus />
<button onClick={handleUpdate}
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700">저장</button>
<button onClick={() => { setEditing(false); setEditName(template.name); }}
className="text-xs px-2 py-1 text-gray-500 hover:text-gray-700">취소</button>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-800">📄 {template.name}</span>
<span className="text-xs text-gray-400">({questions.length} 질문)</span>
</div>
<div className="flex items-center gap-2">
<button onClick={() => { setEditing(true); setEditName(template.name); }}
className="text-xs text-blue-600 hover:text-blue-800">수정</button>
<button onClick={handleDeleteClick}
className="text-xs text-red-500 hover:text-red-700">삭제</button>
</div>
</>
)}
</div>
{confirmPos && (
<div className="fixed inset-0" style={{ zIndex: 9998 }} onClick={() => setConfirmPos(null)}>
<div onClick={e => e.stopPropagation()}
style={{
position: 'fixed', top: confirmPos.top, left: confirmPos.left, zIndex: 9999,
background: '#fff', border: '1px solid #e5e7eb', borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', padding: '12px 16px', minWidth: '200px',
}}>
<p style={{ fontSize: '13px', color: '#374151', marginBottom: '4px', fontWeight: 500 }}>"{template.name}" 삭제</p>
<p style={{ fontSize: '12px', color: '#6b7280', marginBottom: '10px' }}>하위 질문도 함께 삭제됩니다.</p>
<div style={{ display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
<button onClick={() => setConfirmPos(null)}
style={{ fontSize: '12px', padding: '4px 10px', color: '#6b7280', cursor: 'pointer', border: '1px solid #d1d5db', borderRadius: '4px', background: '#fff' }}>취소</button>
<button onClick={handleDeleteConfirm} disabled={deleting}
style={{ fontSize: '12px', padding: '4px 10px', color: '#fff', cursor: 'pointer', border: 'none', borderRadius: '4px', background: deleting ? '#fca5a5' : '#ef4444' }}>
{deleting ? '삭제 중...' : '삭제'}
</button>
</div>
</div>
</div>
)}
{/* 질문 목록 */}
<div className="divide-y divide-gray-100">
{questions.map(q => (
<QuestionItem key={q.id} question={q} onRefresh={onRefresh} />
))}
</div>
{/* 질문 추가 */}
<div className="px-4 py-2 border-t border-gray-100">
{showAddQuestion ? (
<AddQuestionForm
templateId={template.id}
onCancel={() => setShowAddQuestion(false)}
onSaved={() => { setShowAddQuestion(false); onRefresh(); }}
/>
) : (
<button onClick={() => setShowAddQuestion(true)}
className="text-xs text-blue-600 hover:text-blue-800 py-1">
+ 질문 추가
</button>
)}
</div>
</div>
);
}
// ============================================================
// 질문 행
// ============================================================
function QuestionItem({ question, onRefresh }) {
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState(question.question_text);
const [confirmPos, setConfirmPos] = useState(null);
const [deleting, setDeleting] = useState(false);
const handleUpdate = async () => {
if (!editText.trim()) return;
try {
await api.put(`/sales/interviews/api/questions/${question.id}`, { question_text: editText.trim() });
setEditing(false);
onRefresh();
} catch (e) { alert('수정 실패: ' + e.message); }
};
const handleDeleteClick = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
setConfirmPos({ top: rect.bottom + 4, left: rect.left });
};
const handleDeleteConfirm = async () => {
setDeleting(true);
try {
await api.del(`/sales/interviews/api/questions/${question.id}`);
onRefresh();
} catch (e) { alert('삭제 실패: ' + e.message); setDeleting(false); }
setConfirmPos(null);
};
return (
<div className="question-row px-4 py-2 flex items-center justify-between group hover:bg-gray-50" style={{ position: 'relative' }}>
{editing ? (
<div className="flex-1 flex items-center gap-2">
<input type="text" value={editText} onChange={e => setEditText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleUpdate(); if (e.key === 'Escape') setEditing(false); }}
className="flex-1 text-sm border rounded px-2 py-1" autoFocus />
<button onClick={handleUpdate}
className="text-xs px-1.5 py-0.5 bg-green-600 text-white rounded hover:bg-green-700">저장</button>
<button onClick={() => { setEditing(false); setEditText(question.question_text); }}
className="text-xs px-1.5 py-0.5 text-gray-500 hover:text-gray-700">취소</button>
</div>
) : (
<>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="w-4 h-4 border-2 border-gray-300 rounded flex-shrink-0"></span>
<span className="text-sm text-gray-700">{question.question_text}</span>
{question.is_required && <span className="text-xs text-red-500">*필수</span>}
<button onClick={handleDeleteClick} title="삭제"
className="q-delete-x text-xs flex-shrink-0"
style={{ color: '#ef4444', cursor: 'pointer' }}>삭제</button>
</div>
<div className="q-edit-btn items-center gap-2 flex-shrink-0">
<button onClick={() => { setEditing(true); setEditText(question.question_text); }}
className="text-xs text-blue-600 hover:text-blue-800">수정</button>
</div>
</>
)}
{confirmPos && (
<div className="fixed inset-0" style={{ zIndex: 9998 }} onClick={() => setConfirmPos(null)}>
<div onClick={e => e.stopPropagation()}
style={{
position: 'fixed', top: confirmPos.top, left: confirmPos.left, zIndex: 9999,
background: '#fff', border: '1px solid #e5e7eb', borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', padding: '12px 16px', minWidth: '180px',
}}>
<p style={{ fontSize: '13px', color: '#374151', marginBottom: '10px' }}> 질문을 삭제할까요?</p>
<div style={{ display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
<button onClick={() => setConfirmPos(null)}
style={{ fontSize: '12px', padding: '4px 10px', color: '#6b7280', cursor: 'pointer', border: '1px solid #d1d5db', borderRadius: '4px', background: '#fff' }}>취소</button>
<button onClick={handleDeleteConfirm} disabled={deleting}
style={{ fontSize: '12px', padding: '4px 10px', color: '#fff', cursor: 'pointer', border: 'none', borderRadius: '4px', background: deleting ? '#fca5a5' : '#ef4444' }}>
{deleting ? '삭제 중...' : '삭제'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ============================================================
// 항목 추가 폼
// ============================================================
function AddTemplateForm({ categoryId, onCancel, onSaved }) {
const [name, setName] = useState('');
const handleSubmit = async () => {
if (!name.trim()) return;
try {
await api.post('/sales/interviews/api/templates', { interview_category_id: categoryId, name: name.trim() });
onSaved();
} catch (e) { alert('항목 생성 실패: ' + e.message); }
};
return (
<div className="template-card p-3 bg-blue-50">
<input type="text" value={name} onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); if (e.key === 'Escape') onCancel(); }}
placeholder="항목명 입력 (예: 견적서 제작)" autoFocus
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 mb-2" />
<div className="flex gap-2 justify-end">
<button onClick={onCancel} className="text-xs px-3 py-1 text-gray-500 hover:text-gray-700">취소</button>
<button onClick={handleSubmit} className="text-xs px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">추가</button>
</div>
</div>
);
}
// ============================================================
// 질문 추가 폼
// ============================================================
function AddQuestionForm({ templateId, onCancel, onSaved }) {
const [text, setText] = useState('');
const handleSubmit = async () => {
if (!text.trim()) return;
try {
await api.post('/sales/interviews/api/questions', { interview_template_id: templateId, question_text: text.trim() });
onSaved();
} catch (e) { alert('질문 생성 실패: ' + e.message); }
};
return (
<div className="flex items-center gap-2">
<input type="text" value={text} onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); if (e.key === 'Escape') onCancel(); }}
placeholder="질문 내용 입력" autoFocus
className="flex-1 text-sm border border-gray-300 rounded px-2 py-1" />
<button onClick={handleSubmit} className="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">추가</button>
<button onClick={onCancel} className="text-xs px-2 py-1 text-gray-500 hover:text-gray-700">취소</button>
</div>
);
}
// ============================================================
// 인터뷰 실시 모달
// ============================================================
function InterviewSessionModal({ categories, onClose, onComplete }) {
const [step, setStep] = useState('setup'); // setup → checklist
const [selectedCategoryId, setSelectedCategoryId] = useState('');
const [intervieweeName, setIntervieweeName] = useState('');
const [intervieweeCompany, setIntervieweeCompany] = useState('');
const [interviewDate, setInterviewDate] = useState(new Date().toISOString().split('T')[0]);
const [memo, setMemo] = useState('');
const [session, setSession] = useState(null);
const [sessionDetail, setSessionDetail] = useState(null);
const [submitting, setSubmitting] = useState(false);
const startInterview = async () => {
if (!selectedCategoryId) { alert('카테고리를 선택하세요'); return; }
setSubmitting(true);
try {
const sess = await api.post('/sales/interviews/api/sessions', {
interview_category_id: parseInt(selectedCategoryId),
interviewee_name: intervieweeName,
interviewee_company: intervieweeCompany,
interview_date: interviewDate,
memo: memo,
});
setSession(sess);
const detail = await api.get(`/sales/interviews/api/sessions/${sess.id}`);
setSessionDetail(detail);
setStep('checklist');
} catch (e) { alert('세션 시작 실패: ' + e.message); }
finally { setSubmitting(false); }
};
const handleToggle = async (questionId) => {
try {
await api.post('/sales/interviews/api/sessions/toggle-answer', {
session_id: session.id,
question_id: questionId,
});
const detail = await api.get(`/sales/interviews/api/sessions/${session.id}`);
setSessionDetail(detail);
} catch (e) { console.error('토글 실패:', e); }
};
const handleComplete = async () => {
if (!confirm('인터뷰를 완료하시겠습니까?')) return;
try {
await api.post(`/sales/interviews/api/sessions/${session.id}/complete`);
onComplete();
} catch (e) { alert('완료 실패: ' + e.message); }
};
// 답변을 템플릿별로 그룹핑
const groupedAnswers = sessionDetail ? (() => {
const groups = {};
(sessionDetail.answers || []).forEach(a => {
const tplId = a.interview_template_id;
if (!groups[tplId]) {
groups[tplId] = { template: a.template, answers: [] };
}
groups[tplId].answers.push(a);
});
return Object.values(groups);
})() : [];
const totalQ = sessionDetail?.total_questions || 0;
const answeredQ = sessionDetail?.answered_questions || 0;
const progress = totalQ > 0 ? Math.round((answeredQ / totalQ) * 100) : 0;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center modal-overlay fade-in" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
{/* 모달 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-bold text-gray-900">
{step === 'setup' ? '인터뷰 실시' : '체크리스트'}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<IconX className="w-5 h-5" />
</button>
</div>
{/* 모달 본문 */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{step === 'setup' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">카테고리 *</label>
<select value={selectedCategoryId} onChange={e => setSelectedCategoryId(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
<option value="">선택하세요</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">면담 상대 이름</label>
<input type="text" value={intervieweeName} onChange={e => setIntervieweeName(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" placeholder="이름" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">면담 상대 회사</label>
<input type="text" value={intervieweeCompany} onChange={e => setIntervieweeCompany(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" placeholder="회사명" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">면담 일자</label>
<input type="date" value={interviewDate} onChange={e => setInterviewDate(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">메모</label>
<textarea value={memo} onChange={e => setMemo(e.target.value)} rows={3}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" placeholder="메모 (선택사항)" />
</div>
</div>
)}
{step === 'checklist' && sessionDetail && (
<div className="space-y-4">
{/* 진행률 */}
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-600">진행률</span>
<span className="font-medium text-gray-900">{answeredQ}/{totalQ} ({progress}%)</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="progress-bar-fill bg-blue-600 h-2 rounded-full" style={{ width: `${progress}%` }}></div>
</div>
</div>
{/* 면담 정보 */}
<div className="flex items-center gap-4 text-xs text-gray-500 bg-gray-50 rounded-lg px-3 py-2">
{sessionDetail.interviewee_name && (
<span className="flex items-center gap-1"><IconUser className="w-3 h-3" />{sessionDetail.interviewee_name}</span>
)}
{sessionDetail.interviewee_company && (
<span className="flex items-center gap-1"><IconBuilding className="w-3 h-3" />{sessionDetail.interviewee_company}</span>
)}
<span className="flex items-center gap-1"><IconCalendar className="w-3 h-3" />{sessionDetail.interview_date}</span>
</div>
{/* 체크리스트 */}
{groupedAnswers.map((group, gi) => (
<div key={gi} className="border border-gray-200 rounded-lg">
<div className="px-4 py-2 bg-gray-50 rounded-t-lg border-b border-gray-200">
<span className="text-sm font-medium text-gray-800">{group.template?.name || '항목'}</span>
</div>
<div>
{group.answers.map(a => (
<label key={a.id}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0">
<input type="checkbox" checked={a.is_checked}
onChange={() => handleToggle(a.interview_question_id)}
className="w-4 h-4 text-blue-600 rounded border-gray-300" />
<span className={`text-sm ${a.is_checked ? 'text-gray-400 line-through' : 'text-gray-700'}`}>
{a.question?.question_text || '질문'}
</span>
</label>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* 모달 하단 */}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-200 bg-gray-50">
{step === 'setup' && (
<>
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">취소</button>
<button onClick={startInterview} disabled={submitting}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{submitting ? '시작 중...' : '인터뷰 시작'}
</button>
</>
)}
{step === 'checklist' && (
<>
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">닫기 (계속 진행)</button>
<button onClick={handleComplete}
className="flex items-center gap-1 px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
<IconCheckCircle className="w-4 h-4" /> 인터뷰 완료
</button>
</>
)}
</div>
</div>
</div>
);
}
// ============================================================
// 인터뷰 기록 모달
// ============================================================
function SessionHistoryModal({ categories, onClose }) {
const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedSession, setSelectedSession] = useState(null);
const [sessionDetail, setSessionDetail] = useState(null);
useEffect(() => {
(async () => {
try {
const data = await api.get('/sales/interviews/api/sessions');
setSessions(data.data || []);
} catch (e) { console.error('세션 목록 로드 실패:', e); }
finally { setLoading(false); }
})();
}, []);
const loadDetail = async (id) => {
try {
const detail = await api.get(`/sales/interviews/api/sessions/${id}`);
setSessionDetail(detail);
setSelectedSession(id);
} catch (e) { console.error('상세 로드 실패:', e); }
};
// 상세 보기의 답변 그룹핑
const groupedAnswers = sessionDetail ? (() => {
const groups = {};
(sessionDetail.answers || []).forEach(a => {
const tplId = a.interview_template_id;
if (!groups[tplId]) {
groups[tplId] = { template: a.template, answers: [] };
}
groups[tplId].answers.push(a);
});
return Object.values(groups);
})() : [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center modal-overlay fade-in" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-3xl max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-bold text-gray-900">
{selectedSession ? '인터뷰 상세' : '인터뷰 기록'}
</h2>
<div className="flex items-center gap-2">
{selectedSession && (
<button onClick={() => { setSelectedSession(null); setSessionDetail(null); }}
className="text-sm text-blue-600 hover:text-blue-800">목록으로</button>
)}
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<IconX className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading && (
<div className="flex items-center justify-center py-12">
<IconLoader className="w-6 h-6 animate-spin text-blue-500" />
</div>
)}
{!loading && !selectedSession && (
<div>
{sessions.length === 0 ? (
<div className="text-center py-12 text-gray-400">인터뷰 기록이 없습니다</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 border-b border-gray-200">
<th className="pb-2 font-medium">일자</th>
<th className="pb-2 font-medium">카테고리</th>
<th className="pb-2 font-medium">면담 대상</th>
<th className="pb-2 font-medium">진행률</th>
<th className="pb-2 font-medium">상태</th>
<th className="pb-2 font-medium"></th>
</tr>
</thead>
<tbody>
{sessions.map(s => (
<tr key={s.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-2.5">{s.interview_date}</td>
<td className="py-2.5">{s.category?.name || '-'}</td>
<td className="py-2.5">
{s.interviewee_name || '-'}
{s.interviewee_company && <span className="text-gray-400 text-xs ml-1">({s.interviewee_company})</span>}
</td>
<td className="py-2.5">
<span className="text-xs">{s.answered_questions}/{s.total_questions}</span>
</td>
<td className="py-2.5">
<span className={`text-xs px-2 py-0.5 rounded-full ${
s.status === 'completed'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{s.status === 'completed' ? '완료' : '진행중'}
</span>
</td>
<td className="py-2.5">
<button onClick={() => loadDetail(s.id)}
className="text-blue-600 hover:text-blue-800 text-xs">상세</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{selectedSession && sessionDetail && (
<div className="space-y-4">
{/* 세션 정보 */}
<div className="bg-gray-50 rounded-lg p-4 grid grid-cols-2 gap-3 text-sm">
<div><span className="text-gray-500">카테고리:</span> <span className="font-medium">{sessionDetail.category?.name}</span></div>
<div><span className="text-gray-500">면담일:</span> <span className="font-medium">{sessionDetail.interview_date}</span></div>
<div><span className="text-gray-500">면담자:</span> <span className="font-medium">{sessionDetail.interviewer?.name || '-'}</span></div>
<div><span className="text-gray-500">면담 대상:</span> <span className="font-medium">{sessionDetail.interviewee_name || '-'} {sessionDetail.interviewee_company ? `(${sessionDetail.interviewee_company})` : ''}</span></div>
<div><span className="text-gray-500">진행률:</span> <span className="font-medium">{sessionDetail.answered_questions}/{sessionDetail.total_questions}</span></div>
<div><span className="text-gray-500">상태:</span>
<span className={`ml-1 text-xs px-2 py-0.5 rounded-full ${sessionDetail.status === 'completed' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
{sessionDetail.status === 'completed' ? '완료' : '진행중'}
</span>
</div>
{sessionDetail.memo && (
<div className="col-span-2"><span className="text-gray-500">메모:</span> <span>{sessionDetail.memo}</span></div>
)}
</div>
{/* 체크리스트 결과 */}
{groupedAnswers.map((group, gi) => (
<div key={gi} className="border border-gray-200 rounded-lg">
<div className="px-4 py-2 bg-gray-50 rounded-t-lg border-b border-gray-200">
<span className="text-sm font-medium text-gray-800">{group.template?.name || '항목'}</span>
</div>
<div>
{group.answers.map(a => (
<div key={a.id} className="flex items-center gap-3 px-4 py-2 border-b border-gray-100 last:border-b-0">
{a.is_checked
? <IconCheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />
: <span className="w-4 h-4 border-2 border-gray-300 rounded flex-shrink-0"></span>
}
<span className={`text-sm ${a.is_checked ? 'text-gray-700' : 'text-gray-400'}`}>
{a.question?.question_text || '질문'}
</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
<div className="flex items-center justify-end px-6 py-3 border-t border-gray-200 bg-gray-50">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">닫기</button>
</div>
</div>
</div>
);
}
// ============================================================
// 렌더
// ============================================================
ReactDOM.createRoot(document.getElementById('interview-scenario-root')).render(<InterviewScenarioApp />);
</script>
@endverbatim
@endpush