From 32cbef9ae316c4dacf32b3469db44fcb7baddea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Feb 2026 21:42:14 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EC=9D=B8=ED=84=B0=EB=B7=B0=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20MD=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=9D=BC=EA=B4=84=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../Sales/InterviewScenarioController.php | 19 ++++ .../Sales/InterviewScenarioService.php | 53 ++++++++++ .../views/sales/interviews/index.blade.php | 96 +++++++++++++++++++ routes/web.php | 2 + 4 files changed, 170 insertions(+) diff --git a/app/Http/Controllers/Sales/InterviewScenarioController.php b/app/Http/Controllers/Sales/InterviewScenarioController.php index c11436a2..d169ff55 100644 --- a/app/Http/Controllers/Sales/InterviewScenarioController.php +++ b/app/Http/Controllers/Sales/InterviewScenarioController.php @@ -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 // ============================================================ diff --git a/app/Services/Sales/InterviewScenarioService.php b/app/Services/Sales/InterviewScenarioService.php index 3ff8e819..6a35afb2 100644 --- a/app/Services/Sales/InterviewScenarioService.php +++ b/app/Services/Sales/InterviewScenarioService.php @@ -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, + ]; + }); + } + // ============================================================ // 전체 트리 조회 // ============================================================ diff --git a/resources/views/sales/interviews/index.blade.php b/resources/views/sales/interviews/index.blade.php index eb44ba61..84011c7a 100644 --- a/resources/views/sales/interviews/index.blade.php +++ b/resources/views/sales/interviews/index.blade.php @@ -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">삭제 // ============================================================ 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 (
@@ -324,8 +380,48 @@ function MainContent({ category, onRefresh }) { {category.name} {templates.length}개 항목
+
+ + +
+ {/* MD 미리보기 */} + {showMdPreview && ( +
+
+ + MD 파싱 결과: 항목 {mdParsed.length}개, 질문 {totalMdQuestions}개 + + +
+
+ {mdParsed.map((tpl, i) => ( +
+
📄 {tpl.name}
+
    + {tpl.questions.map((q, j) => ( +
  • • {q}
  • + ))} +
+
+ ))} +
+
+ + +
+
+ )} +
{templates.map(tpl => ( diff --git a/routes/web.php b/routes/web.php index 1cfbd881..d9b165cd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');