feat:인터뷰 시나리오 MD 파일 업로드 일괄 생성 기능

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-06 21:42:14 +09:00
parent 79f6fc29e8
commit 32cbef9ae3
4 changed files with 170 additions and 0 deletions

View File

@@ -151,6 +151,25 @@ public function destroyQuestion(int $id): JsonResponse
return response()->json(['message' => '삭제되었습니다.']);
}
// ============================================================
// MD 파일 일괄 가져오기
// ============================================================
public function bulkImport(Request $request): JsonResponse
{
$validated = $request->validate([
'category_id' => 'required|integer|exists:interview_categories,id',
'templates' => 'required|array|min:1',
'templates.*.name' => 'required|string|max:200',
'templates.*.questions' => 'required|array|min:1',
'templates.*.questions.*' => 'required|string|max:500',
]);
$result = $this->service->bulkImport($validated['category_id'], $validated['templates']);
return response()->json($result, 201);
}
// ============================================================
// 세션 API
// ============================================================

View File

@@ -141,6 +141,59 @@ public function deleteQuestion(int $id): void
$question->delete();
}
// ============================================================
// MD 파일 일괄 가져오기
// ============================================================
public function bulkImport(int $categoryId, array $templates): array
{
return DB::transaction(function () use ($categoryId, $templates) {
$tenantId = session('selected_tenant_id', 1);
$userId = auth()->id();
$maxTemplateSort = InterviewTemplate::where('interview_category_id', $categoryId)
->max('sort_order') ?? 0;
$createdTemplates = 0;
$createdQuestions = 0;
foreach ($templates as $tpl) {
$maxTemplateSort++;
$template = InterviewTemplate::create([
'tenant_id' => $tenantId,
'interview_category_id' => $categoryId,
'name' => $tpl['name'],
'sort_order' => $maxTemplateSort,
'is_active' => true,
'created_by' => $userId,
'updated_by' => $userId,
]);
$createdTemplates++;
$questionSort = 0;
foreach ($tpl['questions'] as $questionText) {
$questionSort++;
InterviewQuestion::create([
'tenant_id' => $tenantId,
'interview_template_id' => $template->id,
'question_text' => $questionText,
'question_type' => 'checkbox',
'is_required' => false,
'sort_order' => $questionSort,
'is_active' => true,
'created_by' => $userId,
'updated_by' => $userId,
]);
$createdQuestions++;
}
}
return [
'templates_created' => $createdTemplates,
'questions_created' => $createdQuestions,
];
});
}
// ============================================================
// 전체 트리 조회
// ============================================================

View File

@@ -94,6 +94,7 @@
const IconBuilding = createIcon('building-2');
const IconFileText = createIcon('file-text');
const IconCheckCircle = createIcon('check-circle');
const IconUpload = createIcon('upload');
// ============================================================
// 루트 앱
@@ -303,6 +304,60 @@ className="text-xs text-red-500 hover:text-red-700">삭제</button>
// ============================================================
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 (
@@ -316,6 +371,7 @@ function MainContent({ category, onRefresh }) {
}
const templates = category.templates || [];
const totalMdQuestions = mdParsed.reduce((sum, t) => sum + t.questions.length, 0);
return (
<div className="flex flex-col h-full">
@@ -324,8 +380,48 @@ function MainContent({ category, onRefresh }) {
<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} />

View File

@@ -1241,6 +1241,8 @@
Route::post('/api/questions', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'storeQuestion'])->name('api.questions.store');
Route::put('/api/questions/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'updateQuestion'])->name('api.questions.update');
Route::delete('/api/questions/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'destroyQuestion'])->name('api.questions.destroy');
// 일괄 가져오기 API
Route::post('/api/bulk-import', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'bulkImport'])->name('api.bulk-import');
// 세션 API
Route::get('/api/sessions', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'sessions'])->name('api.sessions');
Route::post('/api/sessions', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'storeSession'])->name('api.sessions.store');