Files
sam-manage/resources/views/sales/interviews/index.blade.php
김보곤 5818c7e93e fix:전체 Lucide 아이콘 호환성 수정 (24개 파일)
- Lucide 0.563.0 API 변경 대응: lucide.icons[name] → PascalCase 개별 export
- kebab-case → PascalCase 자동 변환 로직 적용
- 리네임된 아이콘 별칭 매핑 (check-circle→CircleCheck 등)
- 구버전 lucide.icons 객체 폴백 유지
- 적용 범위: finance/*(19), system/*(2), sales/interviews(1), ai-token-usage(1), holidays(1)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:12:40 +09:00

1077 lines
55 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; } }
</style>
@endpush
@section('content')
<div id="interview-scenario-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?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) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
const _def=((n)=>{const a={'check-circle':'CircleCheck','alert-circle':'CircleAlert','alert-triangle':'TriangleAlert','clipboard-check':'ClipboardCheck'};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);
if (ref.current && _def) {
ref.current.innerHTML = '';
const svg = lucide.createElement(_def);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
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');
// ============================================================
// 루트 앱
// ============================================================
function InterviewScenarioApp() {
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 loadTree = useCallback(async () => {
try {
const data = await api.get('/sales/interviews/api/tree');
setTree(data);
setCategories(data);
if (!selectedCategoryId && data.length > 0) {
setSelectedCategoryId(data[0].id);
}
} catch (e) {
console.error('트리 로드 실패:', e);
} finally {
setLoading(false);
}
}, [selectedCategoryId]);
useEffect(() => { loadTree(); }, []);
const selectedCategory = tree.find(c => c.id === selectedCategoryId);
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={() => 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={() => 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">
<IconPlay className="w-4 h-4" /> 인터뷰 실시
</button>
</div>
</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)}
/>
)}
</div>
);
}
// ============================================================
// 좌측: 카테고리 사이드바
// ============================================================
function CategorySidebar({ categories, selectedId, onSelect, onRefresh }) {
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState('');
const [editingId, setEditingId] = useState(null);
const [editName, setEditName] = useState('');
const handleAdd = async () => {
if (!newName.trim()) return;
try {
await api.post('/sales/interviews/api/categories', { name: newName.trim() });
setNewName('');
setShowAdd(false);
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); }
};
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={() => setShowAdd(!showAdd)}
className="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">
+ 추가
</button>
</div>
{showAdd && (
<div className="px-3 py-2 border-b border-gray-100 bg-blue-50">
<input type="text" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="카테고리명 입력" 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={() => { setShowAdd(false); setNewName(''); }}
className="text-xs px-2 py-1 text-gray-500 hover:text-gray-700">취소</button>
<button onClick={handleAdd}
className="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">추가</button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto">
{categories.length === 0 && !showAdd && (
<div className="px-4 py-8 text-center">
<p className="text-sm text-gray-400 mb-3">카테고리가 없습니다</p>
<button onClick={() => setShowAdd(true)}
className="text-sm px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700">
+ 카테고리 추가
</button>
</div>
)}
{categories.map(cat => (
<div key={cat.id}
className={`category-item px-3 py-2.5 cursor-pointer ${selectedId === cat.id ? 'active' : ''}`}
onClick={() => onSelect(cat.id)}>
{editingId === cat.id ? (
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
<input type="text" value={editName} onChange={e => setEditName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleUpdate(cat.id); if (e.key === 'Escape') setEditingId(null); }}
className="flex-1 text-sm border rounded px-1.5 py-0.5" autoFocus />
<button onClick={() => handleUpdate(cat.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>
) : (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700 truncate">{cat.name}</span>
{selectedId === cat.id && (
<div className="flex items-center gap-1 ml-2 flex-shrink-0" onClick={e => e.stopPropagation()}>
<button onClick={() => { setEditingId(cat.id); setEditName(cat.name); }}
className="text-xs text-blue-600 hover:text-blue-800">수정</button>
<button onClick={() => handleDelete(cat.id)}
className="text-xs text-red-500 hover:text-red-700">삭제</button>
</div>
)}
</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