- 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>
1077 lines
55 KiB
PHP
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
|