feat:인터뷰 시나리오 MD 파일 업로드 일괄 생성 기능
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 전체 트리 조회
|
||||
// ============================================================
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user