From a507f7dc6974fa134a7c5fc583e11e231abd28a7 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:01:35 +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=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InterviewScenarioController: 카테고리/항목/질문 CRUD + 세션 관리 API - InterviewScenarioService: 비즈니스 로직 (트리 조회, 세션 시작/토글/완료) - MNG 모델 5개: InterviewCategory, InterviewTemplate, InterviewQuestion, InterviewSession, InterviewAnswer - React 뷰: 2-패널 레이아웃 (카테고리 사이드바 + 항목/질문 관리) - 인터뷰 실시 모달: 카테고리 선택 → 체크리스트 → 완료 - 인터뷰 기록 모달: 기록 목록 + 상세 보기 - InterviewMenuSeeder: 영업관리 > 인터뷰 시나리오 메뉴 추가 - 라우트 18개 추가 (sales/interviews/api/*) Co-Authored-By: Claude Opus 4.6 --- .../Sales/InterviewScenarioController.php | 208 ++++ app/Models/Interview/InterviewAnswer.php | 40 + app/Models/Interview/InterviewCategory.php | 39 + app/Models/Interview/InterviewQuestion.php | 39 + app/Models/Interview/InterviewSession.php | 52 + app/Models/Interview/InterviewTemplate.php | 40 + .../Sales/InterviewScenarioService.php | 287 ++++++ database/seeders/InterviewMenuSeeder.php | 57 ++ .../views/sales/interviews/index.blade.php | 926 ++++++++++++++++++ routes/web.php | 26 + 10 files changed, 1714 insertions(+) create mode 100644 app/Http/Controllers/Sales/InterviewScenarioController.php create mode 100644 app/Models/Interview/InterviewAnswer.php create mode 100644 app/Models/Interview/InterviewCategory.php create mode 100644 app/Models/Interview/InterviewQuestion.php create mode 100644 app/Models/Interview/InterviewSession.php create mode 100644 app/Models/Interview/InterviewTemplate.php create mode 100644 app/Services/Sales/InterviewScenarioService.php create mode 100644 database/seeders/InterviewMenuSeeder.php create mode 100644 resources/views/sales/interviews/index.blade.php diff --git a/app/Http/Controllers/Sales/InterviewScenarioController.php b/app/Http/Controllers/Sales/InterviewScenarioController.php new file mode 100644 index 00000000..c11436a2 --- /dev/null +++ b/app/Http/Controllers/Sales/InterviewScenarioController.php @@ -0,0 +1,208 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('sales.interviews.index')); + } + + return view('sales.interviews.index'); + } + + // ============================================================ + // 카테고리 API + // ============================================================ + + public function categories(): JsonResponse + { + return response()->json($this->service->getCategories()); + } + + public function storeCategory(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'description' => 'nullable|string', + ]); + + $category = $this->service->createCategory($validated); + + return response()->json($category, 201); + } + + public function updateCategory(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'description' => 'nullable|string', + ]); + + $category = $this->service->updateCategory($id, $validated); + + return response()->json($category); + } + + public function destroyCategory(int $id): JsonResponse + { + $this->service->deleteCategory($id); + + return response()->json(['message' => '삭제되었습니다.']); + } + + // ============================================================ + // 트리 API + // ============================================================ + + public function tree(): JsonResponse + { + return response()->json($this->service->getTree()); + } + + // ============================================================ + // 템플릿(항목) API + // ============================================================ + + public function storeTemplate(Request $request): JsonResponse + { + $validated = $request->validate([ + 'interview_category_id' => 'required|integer|exists:interview_categories,id', + 'name' => 'required|string|max:200', + 'description' => 'nullable|string', + ]); + + $template = $this->service->createTemplate($validated); + + return response()->json($template, 201); + } + + public function updateTemplate(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:200', + 'description' => 'nullable|string', + ]); + + $template = $this->service->updateTemplate($id, $validated); + + return response()->json($template); + } + + public function destroyTemplate(int $id): JsonResponse + { + $this->service->deleteTemplate($id); + + return response()->json(['message' => '삭제되었습니다.']); + } + + // ============================================================ + // 질문 API + // ============================================================ + + public function storeQuestion(Request $request): JsonResponse + { + $validated = $request->validate([ + 'interview_template_id' => 'required|integer|exists:interview_templates,id', + 'question_text' => 'required|string|max:500', + 'question_type' => 'nullable|string|in:checkbox,text', + 'is_required' => 'nullable|boolean', + ]); + + $question = $this->service->createQuestion($validated); + + return response()->json($question, 201); + } + + public function updateQuestion(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'question_text' => 'required|string|max:500', + 'question_type' => 'nullable|string|in:checkbox,text', + 'is_required' => 'nullable|boolean', + ]); + + $question = $this->service->updateQuestion($id, $validated); + + return response()->json($question); + } + + public function destroyQuestion(int $id): JsonResponse + { + $this->service->deleteQuestion($id); + + return response()->json(['message' => '삭제되었습니다.']); + } + + // ============================================================ + // 세션 API + // ============================================================ + + public function sessions(Request $request): JsonResponse + { + $filters = $request->only(['status', 'category_id']); + $sessions = $this->service->getSessions($filters); + + return response()->json($sessions); + } + + public function storeSession(Request $request): JsonResponse + { + $validated = $request->validate([ + 'interview_category_id' => 'required|integer|exists:interview_categories,id', + 'interviewee_name' => 'nullable|string|max:100', + 'interviewee_company' => 'nullable|string|max:200', + 'interview_date' => 'nullable|date', + 'memo' => 'nullable|string', + ]); + + $session = $this->service->startSession($validated); + + return response()->json($session, 201); + } + + public function showSession(int $id): JsonResponse + { + $session = $this->service->getSessionDetail($id); + + return response()->json($session); + } + + public function toggleAnswer(Request $request): JsonResponse + { + $validated = $request->validate([ + 'session_id' => 'required|integer', + 'question_id' => 'required|integer', + 'answer_text' => 'nullable|string', + 'memo' => 'nullable|string', + ]); + + $answer = $this->service->toggleAnswer($validated); + + return response()->json($answer); + } + + public function completeSession(int $id): JsonResponse + { + $session = $this->service->completeSession($id); + + return response()->json($session); + } +} diff --git a/app/Models/Interview/InterviewAnswer.php b/app/Models/Interview/InterviewAnswer.php new file mode 100644 index 00000000..cd484388 --- /dev/null +++ b/app/Models/Interview/InterviewAnswer.php @@ -0,0 +1,40 @@ + 'boolean', + ]; + + public function session() + { + return $this->belongsTo(InterviewSession::class, 'interview_session_id'); + } + + public function question() + { + return $this->belongsTo(InterviewQuestion::class, 'interview_question_id'); + } + + public function template() + { + return $this->belongsTo(InterviewTemplate::class, 'interview_template_id'); + } +} diff --git a/app/Models/Interview/InterviewCategory.php b/app/Models/Interview/InterviewCategory.php new file mode 100644 index 00000000..dac8c0af --- /dev/null +++ b/app/Models/Interview/InterviewCategory.php @@ -0,0 +1,39 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + + public function templates() + { + return $this->hasMany(InterviewTemplate::class, 'interview_category_id'); + } + + public function sessions() + { + return $this->hasMany(InterviewSession::class, 'interview_category_id'); + } +} diff --git a/app/Models/Interview/InterviewQuestion.php b/app/Models/Interview/InterviewQuestion.php new file mode 100644 index 00000000..896f6c56 --- /dev/null +++ b/app/Models/Interview/InterviewQuestion.php @@ -0,0 +1,39 @@ + 'array', + 'is_required' => 'boolean', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + + public function template() + { + return $this->belongsTo(InterviewTemplate::class, 'interview_template_id'); + } +} diff --git a/app/Models/Interview/InterviewSession.php b/app/Models/Interview/InterviewSession.php new file mode 100644 index 00000000..c0733bff --- /dev/null +++ b/app/Models/Interview/InterviewSession.php @@ -0,0 +1,52 @@ + 'date', + 'completed_at' => 'datetime', + 'total_questions' => 'integer', + 'answered_questions' => 'integer', + ]; + + public function category() + { + return $this->belongsTo(InterviewCategory::class, 'interview_category_id'); + } + + public function interviewer() + { + return $this->belongsTo(\App\Models\User::class, 'interviewer_id'); + } + + public function answers() + { + return $this->hasMany(InterviewAnswer::class, 'interview_session_id'); + } +} diff --git a/app/Models/Interview/InterviewTemplate.php b/app/Models/Interview/InterviewTemplate.php new file mode 100644 index 00000000..3b851806 --- /dev/null +++ b/app/Models/Interview/InterviewTemplate.php @@ -0,0 +1,40 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + + public function category() + { + return $this->belongsTo(InterviewCategory::class, 'interview_category_id'); + } + + public function questions() + { + return $this->hasMany(InterviewQuestion::class, 'interview_template_id'); + } +} diff --git a/app/Services/Sales/InterviewScenarioService.php b/app/Services/Sales/InterviewScenarioService.php new file mode 100644 index 00000000..3ff8e819 --- /dev/null +++ b/app/Services/Sales/InterviewScenarioService.php @@ -0,0 +1,287 @@ +orderBy('id') + ->get(); + } + + public function createCategory(array $data): InterviewCategory + { + $tenantId = session('selected_tenant_id', 1); + $maxSort = InterviewCategory::max('sort_order') ?? 0; + + return InterviewCategory::create([ + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'sort_order' => $maxSort + 1, + 'is_active' => true, + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + } + + public function updateCategory(int $id, array $data): InterviewCategory + { + $category = InterviewCategory::findOrFail($id); + $category->update([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'updated_by' => auth()->id(), + ]); + + return $category->fresh(); + } + + public function deleteCategory(int $id): void + { + $category = InterviewCategory::findOrFail($id); + $category->update(['deleted_by' => auth()->id()]); + $category->delete(); + } + + // ============================================================ + // 템플릿(항목) CRUD + // ============================================================ + + public function createTemplate(array $data): InterviewTemplate + { + $tenantId = session('selected_tenant_id', 1); + $maxSort = InterviewTemplate::where('interview_category_id', $data['interview_category_id']) + ->max('sort_order') ?? 0; + + return InterviewTemplate::create([ + 'tenant_id' => $tenantId, + 'interview_category_id' => $data['interview_category_id'], + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'sort_order' => $maxSort + 1, + 'is_active' => true, + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + } + + public function updateTemplate(int $id, array $data): InterviewTemplate + { + $template = InterviewTemplate::findOrFail($id); + $template->update([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'updated_by' => auth()->id(), + ]); + + return $template->fresh(); + } + + public function deleteTemplate(int $id): void + { + $template = InterviewTemplate::findOrFail($id); + $template->update(['deleted_by' => auth()->id()]); + $template->delete(); + } + + // ============================================================ + // 질문 CRUD + // ============================================================ + + public function createQuestion(array $data): InterviewQuestion + { + $tenantId = session('selected_tenant_id', 1); + $maxSort = InterviewQuestion::where('interview_template_id', $data['interview_template_id']) + ->max('sort_order') ?? 0; + + return InterviewQuestion::create([ + 'tenant_id' => $tenantId, + 'interview_template_id' => $data['interview_template_id'], + 'question_text' => $data['question_text'], + 'question_type' => $data['question_type'] ?? 'checkbox', + 'is_required' => $data['is_required'] ?? false, + 'sort_order' => $maxSort + 1, + 'is_active' => true, + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + } + + public function updateQuestion(int $id, array $data): InterviewQuestion + { + $question = InterviewQuestion::findOrFail($id); + $question->update([ + 'question_text' => $data['question_text'], + 'question_type' => $data['question_type'] ?? $question->question_type, + 'is_required' => $data['is_required'] ?? $question->is_required, + 'updated_by' => auth()->id(), + ]); + + return $question->fresh(); + } + + public function deleteQuestion(int $id): void + { + $question = InterviewQuestion::findOrFail($id); + $question->update(['deleted_by' => auth()->id()]); + $question->delete(); + } + + // ============================================================ + // 전체 트리 조회 + // ============================================================ + + public function getTree() + { + return InterviewCategory::with([ + 'templates' => function ($q) { + $q->orderBy('sort_order')->orderBy('id'); + $q->with(['questions' => function ($q2) { + $q2->orderBy('sort_order')->orderBy('id'); + }]); + }, + ]) + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + } + + // ============================================================ + // 세션 관리 + // ============================================================ + + public function getSessions(array $filters = []) + { + $query = InterviewSession::with(['category', 'interviewer']) + ->orderByDesc('interview_date') + ->orderByDesc('id'); + + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (!empty($filters['category_id'])) { + $query->where('interview_category_id', $filters['category_id']); + } + + return $query->paginate(20); + } + + public function startSession(array $data): InterviewSession + { + return DB::transaction(function () use ($data) { + $tenantId = session('selected_tenant_id', 1); + $categoryId = $data['interview_category_id']; + + // 카테고리의 모든 활성 템플릿과 질문 가져오기 + $templates = InterviewTemplate::where('interview_category_id', $categoryId) + ->where('is_active', true) + ->with(['questions' => function ($q) { + $q->where('is_active', true)->orderBy('sort_order'); + }]) + ->orderBy('sort_order') + ->get(); + + $totalQuestions = $templates->sum(fn($t) => $t->questions->count()); + + // 세션 생성 + $session = InterviewSession::create([ + 'tenant_id' => $tenantId, + 'interview_category_id' => $categoryId, + 'interviewer_id' => auth()->id(), + 'interviewee_name' => $data['interviewee_name'] ?? null, + 'interviewee_company' => $data['interviewee_company'] ?? null, + 'interview_date' => $data['interview_date'] ?? now()->toDateString(), + 'status' => 'in_progress', + 'total_questions' => $totalQuestions, + 'answered_questions' => 0, + 'memo' => $data['memo'] ?? null, + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + + // 모든 질문에 대해 빈 답변 레코드 생성 + foreach ($templates as $template) { + foreach ($template->questions as $question) { + InterviewAnswer::create([ + 'tenant_id' => $tenantId, + 'interview_session_id' => $session->id, + 'interview_question_id' => $question->id, + 'interview_template_id' => $template->id, + 'is_checked' => false, + ]); + } + } + + return $session; + }); + } + + public function getSessionDetail(int $id) + { + return InterviewSession::with([ + 'category', + 'interviewer', + 'answers' => function ($q) { + $q->with(['question', 'template']); + }, + ])->findOrFail($id); + } + + public function toggleAnswer(array $data): InterviewAnswer + { + $answer = InterviewAnswer::where('interview_session_id', $data['session_id']) + ->where('interview_question_id', $data['question_id']) + ->firstOrFail(); + + $answer->update([ + 'is_checked' => !$answer->is_checked, + 'answer_text' => $data['answer_text'] ?? $answer->answer_text, + 'memo' => $data['memo'] ?? $answer->memo, + ]); + + // answered_questions 갱신 + $session = InterviewSession::findOrFail($data['session_id']); + $answeredCount = InterviewAnswer::where('interview_session_id', $session->id) + ->where('is_checked', true) + ->count(); + $session->update([ + 'answered_questions' => $answeredCount, + 'updated_by' => auth()->id(), + ]); + + return $answer->fresh(); + } + + public function completeSession(int $id): InterviewSession + { + $session = InterviewSession::findOrFail($id); + + $answeredCount = InterviewAnswer::where('interview_session_id', $session->id) + ->where('is_checked', true) + ->count(); + + $session->update([ + 'status' => 'completed', + 'answered_questions' => $answeredCount, + 'completed_at' => now(), + 'updated_by' => auth()->id(), + ]); + + return $session->fresh(); + } +} diff --git a/database/seeders/InterviewMenuSeeder.php b/database/seeders/InterviewMenuSeeder.php new file mode 100644 index 00000000..e5bae7fd --- /dev/null +++ b/database/seeders/InterviewMenuSeeder.php @@ -0,0 +1,57 @@ +where('name', '영업관리') + ->whereNull('parent_id') + ->value('id'); + + if (!$salesParentId) { + $this->command->error('영업관리 메뉴를 찾을 수 없습니다.'); + return; + } + + // 이미 존재하는지 확인 + $exists = Menu::where('tenant_id', $tenantId) + ->where('parent_id', $salesParentId) + ->where('name', '인터뷰 시나리오') + ->exists(); + + if ($exists) { + $this->command->info('인터뷰 시나리오 메뉴가 이미 존재합니다.'); + return; + } + + // 영업파트너 승인 메뉴의 sort_order 확인 + $approvalMenu = Menu::where('tenant_id', $tenantId) + ->where('parent_id', $salesParentId) + ->where('name', '영업파트너 승인') + ->first(); + + $sortOrder = $approvalMenu ? $approvalMenu->sort_order + 1 : 10; + + // 인터뷰 시나리오 메뉴 생성 + Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $salesParentId, + 'name' => '인터뷰 시나리오', + 'url' => '/sales/interviews', + 'icon' => 'clipboard-check', + 'sort_order' => $sortOrder, + 'is_active' => true, + ]); + + $this->command->info('인터뷰 시나리오 메뉴가 생성되었습니다.'); + } +} diff --git a/resources/views/sales/interviews/index.blade.php b/resources/views/sales/interviews/index.blade.php new file mode 100644 index 00000000..671b178e --- /dev/null +++ b/resources/views/sales/interviews/index.blade.php @@ -0,0 +1,926 @@ +@extends('layouts.app') + +@section('title', '인터뷰 시나리오 관리') + +@push('styles') + +@endpush + +@section('content') +
+@endsection + +@push('scripts') + + + + +@verbatim + +@endverbatim +@endpush diff --git a/routes/web.php b/routes/web.php index 665ce111..1cfbd881 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1222,4 +1222,30 @@ Route::post('/products', [\App\Http\Controllers\Sales\SalesContractController::class, 'saveProducts'])->name('products.save'); Route::get('/products/{tenant}', [\App\Http\Controllers\Sales\SalesContractController::class, 'getProducts'])->name('products.get'); }); + + // 인터뷰 시나리오 관리 + Route::prefix('interviews')->name('interviews.')->group(function () { + Route::get('/', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'index'])->name('index'); + // 카테고리 API + Route::get('/api/categories', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'categories'])->name('api.categories'); + Route::post('/api/categories', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'storeCategory'])->name('api.categories.store'); + Route::put('/api/categories/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'updateCategory'])->name('api.categories.update'); + Route::delete('/api/categories/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'destroyCategory'])->name('api.categories.destroy'); + // 트리 API + Route::get('/api/tree', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'tree'])->name('api.tree'); + // 템플릿(항목) API + Route::post('/api/templates', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'storeTemplate'])->name('api.templates.store'); + Route::put('/api/templates/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'updateTemplate'])->name('api.templates.update'); + Route::delete('/api/templates/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'destroyTemplate'])->name('api.templates.destroy'); + // 질문 API + 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::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'); + Route::get('/api/sessions/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'showSession'])->name('api.sessions.show'); + Route::post('/api/sessions/toggle-answer', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'toggleAnswer'])->name('api.sessions.toggle-answer'); + Route::post('/api/sessions/{id}/complete', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'completeSession'])->name('api.sessions.complete'); + }); });