diff --git a/app/Http/Controllers/Sales/InterviewScenarioController.php b/app/Http/Controllers/Sales/InterviewScenarioController.php index d169ff55..3f7536f3 100644 --- a/app/Http/Controllers/Sales/InterviewScenarioController.php +++ b/app/Http/Controllers/Sales/InterviewScenarioController.php @@ -122,7 +122,12 @@ 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', + 'question_type' => 'nullable|string|in:checkbox,text,number,select,multi_select,file_upload,formula_input,table_input,bom_tree,price_table,dimension_diagram', + 'options' => 'nullable|array', + 'ai_hint' => 'nullable|string', + 'expected_format' => 'nullable|string|max:100', + 'depends_on' => 'nullable|array', + 'domain' => 'nullable|string|max:50', 'is_required' => 'nullable|boolean', ]); @@ -135,7 +140,7 @@ public function updateQuestion(Request $request, int $id): JsonResponse { $validated = $request->validate([ 'question_text' => 'required|string|max:500', - 'question_type' => 'nullable|string|in:checkbox,text', + 'question_type' => 'nullable|string|in:checkbox,text,number,select,multi_select,file_upload,formula_input,table_input,bom_tree,price_table,dimension_diagram', 'is_required' => 'nullable|boolean', ]); @@ -224,4 +229,180 @@ public function completeSession(int $id): JsonResponse return response()->json($session); } + + // ============================================================ + // 프로젝트 API + // ============================================================ + + public function projects(Request $request): JsonResponse + { + $filters = $request->only(['status', 'search']); + + return response()->json($this->service->getProjects($filters)); + } + + public function showProject(int $id): JsonResponse + { + return response()->json($this->service->getProject($id)); + } + + public function storeProject(Request $request): JsonResponse + { + $validated = $request->validate([ + 'company_name' => 'required|string|max:200', + 'company_type' => 'nullable|string|max:100', + 'contact_person' => 'nullable|string|max:100', + 'contact_info' => 'nullable|string|max:200', + 'product_categories' => 'nullable|array', + ]); + + $project = $this->service->createProject($validated); + + return response()->json($project, 201); + } + + public function updateProject(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'company_name' => 'sometimes|string|max:200', + 'company_type' => 'nullable|string|max:100', + 'contact_person' => 'nullable|string|max:100', + 'contact_info' => 'nullable|string|max:200', + 'status' => 'nullable|string|in:draft,interviewing,analyzing,code_generated,deployed', + 'product_categories' => 'nullable|array', + 'summary' => 'nullable|string', + ]); + + $project = $this->service->updateProject($id, $validated); + + return response()->json($project); + } + + public function destroyProject(int $id): JsonResponse + { + $this->service->deleteProject($id); + + return response()->json(['message' => '삭제되었습니다.']); + } + + public function projectTree(int $id): JsonResponse + { + return response()->json($this->service->getProjectTree($id)); + } + + public function projectProgress(int $id): JsonResponse + { + $project = $this->service->updateProjectProgress($id); + + return response()->json($project); + } + + // ============================================================ + // 첨부파일 API + // ============================================================ + + public function attachments(int $projectId): JsonResponse + { + return response()->json($this->service->getAttachments($projectId)); + } + + public function uploadAttachment(Request $request, int $projectId): JsonResponse + { + $request->validate([ + 'file' => 'required|file|max:51200', + 'file_type' => 'nullable|string|in:excel_template,pdf_quote,sample_bom,price_list,photo,voice,other', + 'description' => 'nullable|string|max:500', + ]); + + $attachment = $this->service->uploadAttachment( + $projectId, + $request->only(['file_type', 'description']), + $request->file('file') + ); + + return response()->json($attachment, 201); + } + + public function destroyAttachment(int $id): JsonResponse + { + $this->service->deleteAttachment($id); + + return response()->json(['message' => '삭제되었습니다.']); + } + + // ============================================================ + // 지식 API + // ============================================================ + + public function knowledge(Request $request, int $projectId): JsonResponse + { + $filters = $request->only(['domain', 'is_verified', 'min_confidence']); + + return response()->json($this->service->getKnowledge($projectId, $filters)); + } + + public function storeKnowledge(Request $request, int $projectId): JsonResponse + { + $validated = $request->validate([ + 'domain' => 'required|string|in:product_classification,bom_structure,dimension_formula,component_config,pricing_structure,quantity_formula,conditional_logic,quote_format', + 'knowledge_type' => 'required|string|in:fact,rule,formula,mapping,range,table', + 'title' => 'required|string|max:300', + 'content' => 'required|array', + 'source_type' => 'nullable|string|in:interview_answer,voice_recording,document,manual', + 'source_id' => 'nullable|integer', + 'confidence' => 'nullable|numeric|min:0|max:1', + ]); + + $knowledge = $this->service->createKnowledge($projectId, $validated); + + return response()->json($knowledge, 201); + } + + public function updateKnowledge(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'title' => 'sometimes|string|max:300', + 'content' => 'sometimes|array', + 'confidence' => 'nullable|numeric|min:0|max:1', + ]); + + $knowledge = $this->service->updateKnowledge($id, $validated); + + return response()->json($knowledge); + } + + public function verifyKnowledge(int $id): JsonResponse + { + $knowledge = $this->service->verifyKnowledge($id); + + return response()->json($knowledge); + } + + public function destroyKnowledge(int $id): JsonResponse + { + $this->service->deleteKnowledge($id); + + return response()->json(['message' => '삭제되었습니다.']); + } + + // ============================================================ + // 구조화 답변 저장 API + // ============================================================ + + public function saveAnswer(Request $request): JsonResponse + { + $validated = $request->validate([ + 'session_id' => 'required|integer', + 'question_id' => 'required|integer', + 'is_checked' => 'nullable|boolean', + 'answer_text' => 'nullable|string', + 'answer_data' => 'nullable|array', + 'attachments' => 'nullable|array', + 'memo' => 'nullable|string', + ]); + + $answer = $this->service->saveAnswer($validated); + + return response()->json($answer); + } } diff --git a/app/Models/Interview/InterviewAnswer.php b/app/Models/Interview/InterviewAnswer.php index cd484388..62807f37 100644 --- a/app/Models/Interview/InterviewAnswer.php +++ b/app/Models/Interview/InterviewAnswer.php @@ -16,11 +16,15 @@ class InterviewAnswer extends Model 'interview_template_id', 'is_checked', 'answer_text', + 'answer_data', + 'attachments', 'memo', ]; protected $casts = [ 'is_checked' => 'boolean', + 'answer_data' => 'array', + 'attachments' => 'array', ]; public function session() diff --git a/app/Models/Interview/InterviewAttachment.php b/app/Models/Interview/InterviewAttachment.php new file mode 100644 index 00000000..334e0b15 --- /dev/null +++ b/app/Models/Interview/InterviewAttachment.php @@ -0,0 +1,41 @@ + 'array', + 'file_size' => 'integer', + ]; + + public function project() + { + return $this->belongsTo(InterviewProject::class, 'interview_project_id'); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/app/Models/Interview/InterviewCategory.php b/app/Models/Interview/InterviewCategory.php index dac8c0af..fc087c13 100644 --- a/app/Models/Interview/InterviewCategory.php +++ b/app/Models/Interview/InterviewCategory.php @@ -13,8 +13,10 @@ class InterviewCategory extends Model protected $fillable = [ 'tenant_id', + 'interview_project_id', 'name', 'description', + 'domain', 'sort_order', 'is_active', 'created_by', @@ -27,6 +29,11 @@ class InterviewCategory extends Model 'sort_order' => 'integer', ]; + public function project() + { + return $this->belongsTo(InterviewProject::class, 'interview_project_id'); + } + public function templates() { return $this->hasMany(InterviewTemplate::class, 'interview_category_id'); diff --git a/app/Models/Interview/InterviewKnowledge.php b/app/Models/Interview/InterviewKnowledge.php new file mode 100644 index 00000000..1d4f042d --- /dev/null +++ b/app/Models/Interview/InterviewKnowledge.php @@ -0,0 +1,54 @@ + 'array', + 'confidence' => 'decimal:2', + 'is_verified' => 'boolean', + 'verified_at' => 'datetime', + ]; + + public function project() + { + return $this->belongsTo(InterviewProject::class, 'interview_project_id'); + } + + public function verifier() + { + return $this->belongsTo(User::class, 'verified_by'); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/app/Models/Interview/InterviewProject.php b/app/Models/Interview/InterviewProject.php new file mode 100644 index 00000000..187e3412 --- /dev/null +++ b/app/Models/Interview/InterviewProject.php @@ -0,0 +1,60 @@ + 'array', + 'progress_percent' => 'integer', + ]; + + public function categories() + { + return $this->hasMany(InterviewCategory::class, 'interview_project_id'); + } + + public function attachments() + { + return $this->hasMany(InterviewAttachment::class, 'interview_project_id'); + } + + public function knowledge() + { + return $this->hasMany(InterviewKnowledge::class, 'interview_project_id'); + } + + public function sessions() + { + return $this->hasMany(InterviewSession::class, 'interview_project_id'); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/app/Models/Interview/InterviewQuestion.php b/app/Models/Interview/InterviewQuestion.php index 896f6c56..e381ef49 100644 --- a/app/Models/Interview/InterviewQuestion.php +++ b/app/Models/Interview/InterviewQuestion.php @@ -17,6 +17,10 @@ class InterviewQuestion extends Model 'question_text', 'question_type', 'options', + 'ai_hint', + 'expected_format', + 'depends_on', + 'domain', 'is_required', 'sort_order', 'is_active', @@ -27,6 +31,7 @@ class InterviewQuestion extends Model protected $casts = [ 'options' => 'array', + 'depends_on' => 'array', 'is_required' => 'boolean', 'is_active' => 'boolean', 'sort_order' => 'integer', diff --git a/app/Models/Interview/InterviewSession.php b/app/Models/Interview/InterviewSession.php index c0733bff..8ac17a90 100644 --- a/app/Models/Interview/InterviewSession.php +++ b/app/Models/Interview/InterviewSession.php @@ -13,12 +13,15 @@ class InterviewSession extends Model protected $fillable = [ 'tenant_id', + 'interview_project_id', 'interview_category_id', 'interviewer_id', 'interviewee_name', 'interviewee_company', 'interview_date', 'status', + 'session_type', + 'voice_recording_id', 'total_questions', 'answered_questions', 'memo', @@ -35,6 +38,11 @@ class InterviewSession extends Model 'answered_questions' => 'integer', ]; + public function project() + { + return $this->belongsTo(InterviewProject::class, 'interview_project_id'); + } + public function category() { return $this->belongsTo(InterviewCategory::class, 'interview_category_id'); diff --git a/app/Services/Sales/InterviewScenarioService.php b/app/Services/Sales/InterviewScenarioService.php index f75247dc..61bd6c07 100644 --- a/app/Services/Sales/InterviewScenarioService.php +++ b/app/Services/Sales/InterviewScenarioService.php @@ -3,11 +3,15 @@ namespace App\Services\Sales; use App\Models\Interview\InterviewAnswer; +use App\Models\Interview\InterviewAttachment; use App\Models\Interview\InterviewCategory; +use App\Models\Interview\InterviewKnowledge; +use App\Models\Interview\InterviewProject; use App\Models\Interview\InterviewQuestion; use App\Models\Interview\InterviewSession; use App\Models\Interview\InterviewTemplate; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; class InterviewScenarioService { @@ -113,6 +117,11 @@ public function createQuestion(array $data): InterviewQuestion 'interview_template_id' => $data['interview_template_id'], 'question_text' => $data['question_text'], 'question_type' => $data['question_type'] ?? 'checkbox', + 'options' => $data['options'] ?? null, + 'ai_hint' => $data['ai_hint'] ?? null, + 'expected_format' => $data['expected_format'] ?? null, + 'depends_on' => $data['depends_on'] ?? null, + 'domain' => $data['domain'] ?? null, 'is_required' => $data['is_required'] ?? false, 'sort_order' => $maxSort + 1, 'is_active' => true, @@ -337,4 +346,348 @@ public function completeSession(int $id): InterviewSession return $session->fresh(); } + + // ============================================================ + // 프로젝트 CRUD + // ============================================================ + + public function getProjects(array $filters = []) + { + $query = InterviewProject::with('creator') + ->orderByDesc('id'); + + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('company_name', 'like', "%{$search}%") + ->orWhere('company_type', 'like', "%{$search}%"); + }); + } + + return $query->paginate(20); + } + + public function getProject(int $id): InterviewProject + { + return InterviewProject::with([ + 'categories.templates.questions', + 'attachments', + 'sessions', + ])->findOrFail($id); + } + + public function createProject(array $data): InterviewProject + { + $tenantId = session('selected_tenant_id', 1); + + return DB::transaction(function () use ($data, $tenantId) { + $project = InterviewProject::create([ + 'tenant_id' => $tenantId, + 'company_name' => $data['company_name'], + 'company_type' => $data['company_type'] ?? null, + 'contact_person' => $data['contact_person'] ?? null, + 'contact_info' => $data['contact_info'] ?? null, + 'status' => 'draft', + 'product_categories' => $data['product_categories'] ?? null, + 'progress_percent' => 0, + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + + // 마스터 질문 데이터에서 8개 도메인 카테고리 복제 + $this->cloneMasterQuestionsToProject($project); + + return $project->fresh(); + }); + } + + public function updateProject(int $id, array $data): InterviewProject + { + $project = InterviewProject::findOrFail($id); + $project->update([ + ...$data, + 'updated_by' => auth()->id(), + ]); + + return $project->fresh(); + } + + public function deleteProject(int $id): void + { + $project = InterviewProject::findOrFail($id); + $project->update(['deleted_by' => auth()->id()]); + $project->delete(); + } + + /** + * 마스터 질문 데이터를 프로젝트에 복제 + */ + private function cloneMasterQuestionsToProject(InterviewProject $project): void + { + $tenantId = $project->tenant_id; + $userId = auth()->id(); + + // interview_project_id = NULL이고 domain이 있는 마스터 카테고리 + $masterCategories = InterviewCategory::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->whereNull('interview_project_id') + ->whereNotNull('domain') + ->with(['templates.questions']) + ->orderBy('sort_order') + ->get(); + + foreach ($masterCategories as $masterCat) { + $newCat = InterviewCategory::create([ + 'tenant_id' => $tenantId, + 'interview_project_id' => $project->id, + 'name' => $masterCat->name, + 'description' => $masterCat->description, + 'domain' => $masterCat->domain, + 'sort_order' => $masterCat->sort_order, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + foreach ($masterCat->templates as $masterTpl) { + $newTpl = InterviewTemplate::create([ + 'tenant_id' => $tenantId, + 'interview_category_id' => $newCat->id, + 'name' => $masterTpl->name, + 'description' => $masterTpl->description, + 'sort_order' => $masterTpl->sort_order, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + foreach ($masterTpl->questions as $masterQ) { + InterviewQuestion::create([ + 'tenant_id' => $tenantId, + 'interview_template_id' => $newTpl->id, + 'question_text' => $masterQ->question_text, + 'question_type' => $masterQ->question_type, + 'options' => $masterQ->options, + 'ai_hint' => $masterQ->ai_hint, + 'expected_format' => $masterQ->expected_format, + 'depends_on' => $masterQ->depends_on, + 'domain' => $masterQ->domain, + 'is_required' => $masterQ->is_required, + 'sort_order' => $masterQ->sort_order, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + } + } + } + } + + /** + * 프로젝트별 트리 조회 + */ + public function getProjectTree(int $projectId) + { + return InterviewCategory::where('interview_project_id', $projectId) + ->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 updateProjectProgress(int $projectId): InterviewProject + { + $project = InterviewProject::findOrFail($projectId); + + // 프로젝트 연결 카테고리의 모든 질문 수 + $totalQuestions = InterviewQuestion::whereHas('template.category', function ($q) use ($projectId) { + $q->where('interview_project_id', $projectId); + })->where('is_active', true)->count(); + + if ($totalQuestions === 0) { + $project->update(['progress_percent' => 0, 'updated_by' => auth()->id()]); + + return $project->fresh(); + } + + // 프로젝트 세션의 답변 완료 수 + $answeredQuestions = InterviewAnswer::whereHas('session', function ($q) use ($projectId) { + $q->where('interview_project_id', $projectId); + })->where('is_checked', true)->count(); + + $progress = min(100, (int) round(($answeredQuestions / $totalQuestions) * 100)); + $project->update(['progress_percent' => $progress, 'updated_by' => auth()->id()]); + + return $project->fresh(); + } + + // ============================================================ + // 첨부파일 관리 + // ============================================================ + + public function getAttachments(int $projectId) + { + return InterviewAttachment::where('interview_project_id', $projectId) + ->with('creator') + ->orderByDesc('id') + ->get(); + } + + public function uploadAttachment(int $projectId, array $data, $file): InterviewAttachment + { + $tenantId = session('selected_tenant_id', 1); + $path = $file->store("interviews/{$projectId}", 'local'); + + return InterviewAttachment::create([ + 'tenant_id' => $tenantId, + 'interview_project_id' => $projectId, + 'file_type' => $data['file_type'] ?? 'other', + 'file_name' => $file->getClientOriginalName(), + 'file_path' => $path, + 'file_size' => $file->getSize(), + 'mime_type' => $file->getMimeType(), + 'ai_analysis_status' => 'pending', + 'description' => $data['description'] ?? null, + 'created_by' => auth()->id(), + ]); + } + + public function deleteAttachment(int $id): void + { + $attachment = InterviewAttachment::findOrFail($id); + + if (Storage::disk('local')->exists($attachment->file_path)) { + Storage::disk('local')->delete($attachment->file_path); + } + + $attachment->delete(); + } + + // ============================================================ + // 지식 관리 + // ============================================================ + + public function getKnowledge(int $projectId, array $filters = []) + { + $query = InterviewKnowledge::where('interview_project_id', $projectId) + ->with('creator') + ->orderByDesc('id'); + + if (! empty($filters['domain'])) { + $query->where('domain', $filters['domain']); + } + + if (! empty($filters['is_verified'])) { + $query->where('is_verified', $filters['is_verified'] === 'true'); + } + + if (! empty($filters['min_confidence'])) { + $query->where('confidence', '>=', (float) $filters['min_confidence']); + } + + return $query->get(); + } + + public function createKnowledge(int $projectId, array $data): InterviewKnowledge + { + $tenantId = session('selected_tenant_id', 1); + + return InterviewKnowledge::create([ + 'tenant_id' => $tenantId, + 'interview_project_id' => $projectId, + 'domain' => $data['domain'], + 'knowledge_type' => $data['knowledge_type'], + 'title' => $data['title'], + 'content' => $data['content'], + 'source_type' => $data['source_type'] ?? 'manual', + 'source_id' => $data['source_id'] ?? null, + 'confidence' => $data['confidence'] ?? 1.00, + 'is_verified' => $data['source_type'] === 'manual', + 'verified_by' => $data['source_type'] === 'manual' ? auth()->id() : null, + 'verified_at' => $data['source_type'] === 'manual' ? now() : null, + 'created_by' => auth()->id(), + ]); + } + + public function updateKnowledge(int $id, array $data): InterviewKnowledge + { + $knowledge = InterviewKnowledge::findOrFail($id); + $knowledge->update($data); + + return $knowledge->fresh(); + } + + public function verifyKnowledge(int $id): InterviewKnowledge + { + $knowledge = InterviewKnowledge::findOrFail($id); + $knowledge->update([ + 'is_verified' => ! $knowledge->is_verified, + 'verified_by' => $knowledge->is_verified ? null : auth()->id(), + 'verified_at' => $knowledge->is_verified ? null : now(), + ]); + + return $knowledge->fresh(); + } + + public function deleteKnowledge(int $id): void + { + $knowledge = InterviewKnowledge::findOrFail($id); + $knowledge->delete(); + } + + // ============================================================ + // 답변 저장 (구조화 답변 포함) + // ============================================================ + + public function saveAnswer(array $data): InterviewAnswer + { + $answer = InterviewAnswer::where('interview_session_id', $data['session_id']) + ->where('interview_question_id', $data['question_id']) + ->firstOrFail(); + + $updateData = [ + 'is_checked' => $data['is_checked'] ?? $answer->is_checked, + ]; + + if (isset($data['answer_text'])) { + $updateData['answer_text'] = $data['answer_text']; + } + if (isset($data['answer_data'])) { + $updateData['answer_data'] = $data['answer_data']; + } + if (isset($data['attachments'])) { + $updateData['attachments'] = $data['attachments']; + } + if (isset($data['memo'])) { + $updateData['memo'] = $data['memo']; + } + + $answer->update($updateData); + + // 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(); + } } diff --git a/database/seeders/InterviewQuestionMasterSeeder.php b/database/seeders/InterviewQuestionMasterSeeder.php new file mode 100644 index 00000000..bc79d3bb --- /dev/null +++ b/database/seeders/InterviewQuestionMasterSeeder.php @@ -0,0 +1,326 @@ +getDomainData(); + + foreach ($domains as $domainKey => $domain) { + $categorySortOrder = InterviewCategory::max('sort_order') ?? 0; + + $category = InterviewCategory::create([ + 'tenant_id' => $tenantId, + 'interview_project_id' => null, + 'name' => $domain['name'], + 'description' => $domain['description'], + 'domain' => $domainKey, + 'sort_order' => $categorySortOrder + 1, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + foreach ($domain['templates'] as $tplIndex => $tpl) { + $template = InterviewTemplate::create([ + 'tenant_id' => $tenantId, + 'interview_category_id' => $category->id, + 'name' => $tpl['name'], + 'sort_order' => $tplIndex + 1, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + foreach ($tpl['questions'] as $qIndex => $q) { + InterviewQuestion::create([ + 'tenant_id' => $tenantId, + 'interview_template_id' => $template->id, + 'question_text' => $q['text'], + 'question_type' => $q['type'], + 'options' => $q['options'] ?? null, + 'ai_hint' => $q['ai_hint'] ?? null, + 'expected_format' => $q['expected_format'] ?? null, + 'depends_on' => $q['depends_on'] ?? null, + 'domain' => $domainKey, + 'is_required' => $q['is_required'] ?? false, + 'sort_order' => $qIndex + 1, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + } + } + } + }); + } + + private function getDomainData(): array + { + return [ + // ================================================================ + // Domain 1: 제품 분류 체계 + // ================================================================ + 'product_classification' => [ + 'name' => '제품 분류 체계', + 'description' => '제품 카테고리, 모델 코드, 분류 기준 파악', + 'templates' => [ + [ + 'name' => '제품 카테고리 구조', + 'questions' => [ + ['text' => '귀사의 주요 제품군을 모두 나열해주세요', 'type' => 'text', 'ai_hint' => '쉼표 구분으로 제품군 나열', 'is_required' => true], + ['text' => '각 제품군의 하위 모델명과 코드 체계를 알려주세요', 'type' => 'table_input', 'options' => ['columns' => ['모델코드', '모델명', '비고']], 'ai_hint' => '코드-이름 매핑 테이블'], + ['text' => '제품을 분류하는 기준은 무엇인가요? (소재, 용도, 크기 등)', 'type' => 'multi_select', 'options' => ['choices' => ['소재별', '용도별', '크기별', '설치방식별', '인증여부별']]], + ['text' => '인증(인정) 제품과 비인증 제품의 구분이 있나요?', 'type' => 'select', 'options' => ['choices' => ['있음', '없음']]], + ['text' => '인증 제품의 경우 구성이 고정되나요?', 'type' => 'checkbox', 'depends_on' => ['question_index' => 3, 'value' => '있음']], + ['text' => '카테고리별 제품 수는 대략 몇 개인가요?', 'type' => 'number', 'expected_format' => '개'], + ['text' => '제품 코드 명명 규칙을 설명해주세요 (예: KSS01의 의미)', 'type' => 'text', 'ai_hint' => '코드 체계의 각 자릿수 의미'], + ['text' => '기존 시스템(ERP/엑셀)에서 사용하는 제품 분류 방식을 캡처하여 업로드해주세요', 'type' => 'file_upload'], + ], + ], + [ + 'name' => '설치 유형별 분류', + 'questions' => [ + ['text' => '설치 유형(벽면형, 측면형, 혼합형 등)에 따라 견적이 달라지나요?', 'type' => 'select', 'options' => ['choices' => ['예, 크게 달라짐', '약간 달라짐', '달라지지 않음']]], + ['text' => '각 설치 유형별로 어떤 부품이 달라지나요?', 'type' => 'table_input', 'options' => ['columns' => ['설치유형', '추가부품', '제외부품', '비고']]], + ['text' => '설치 유형에 따른 추가 비용 항목이 있나요?', 'type' => 'text'], + ], + ], + ], + ], + + // ================================================================ + // Domain 2: BOM 구조 + // ================================================================ + 'bom_structure' => [ + 'name' => 'BOM 구조', + 'description' => '완제품-부품 관계, 부품 카테고리, BOM 레벨', + 'templates' => [ + [ + 'name' => '완제품-부품 관계', + 'questions' => [ + ['text' => '대표 제품 1개의 완제품→부품 구성을 트리로 그려주세요', 'type' => 'bom_tree', 'ai_hint' => '최상위 제품부터 하위 부품까지 트리 구조', 'is_required' => true], + ['text' => '모든 제품에 공통으로 들어가는 부품은 무엇인가요?', 'type' => 'multi_select', 'options' => ['choices' => ['가이드레일', '케이스', '모터', '제어기', '브라켓', '볼트/너트']], 'ai_hint' => '직접 입력 가능'], + ['text' => '제품별로 선택적(옵션)인 부품은 무엇인가요?', 'type' => 'table_input', 'options' => ['columns' => ['제품명', '옵션부품', '적용조건']]], + ['text' => 'BOM이 현재 엑셀로 관리되고 있나요? 파일을 업로드해주세요', 'type' => 'file_upload'], + ['text' => '하위 부품의 단계(레벨)는 최대 몇 단계인가요?', 'type' => 'number', 'expected_format' => '단계'], + ['text' => '부품 수량이 고정인 것과 계산이 필요한 것을 구분해주세요', 'type' => 'table_input', 'options' => ['columns' => ['부품명', '고정/계산', '고정수량 또는 계산식']]], + ], + ], + [ + 'name' => '부품 카테고리', + 'questions' => [ + ['text' => '부품을 카테고리로 분류하면 어떻게 나눠지나요? (본체, 절곡품, 전동부, 부자재 등)', 'type' => 'text', 'ai_hint' => '부품 분류 체계'], + ['text' => '각 카테고리에 속하는 부품 목록을 정리해주세요', 'type' => 'table_input', 'options' => ['columns' => ['카테고리', '부품명', '규격']]], + ['text' => '외주 구매 부품과 자체 제작 부품의 구분이 있나요?', 'type' => 'select', 'options' => ['choices' => ['있음', '없음']]], + ['text' => '부자재(볼트, 너트, 패킹 등)는 별도 관리하나요?', 'type' => 'checkbox'], + ], + ], + ], + ], + + // ================================================================ + // Domain 3: 치수/변수 계산 + // ================================================================ + 'dimension_formula' => [ + 'name' => '치수/변수 계산', + 'description' => '오픈 사이즈→제작 사이즈 변환, 파생 변수 계산', + 'templates' => [ + [ + 'name' => '오픈 사이즈 → 제작 사이즈', + 'questions' => [ + ['text' => '고객이 입력하는 기본 치수 항목은 무엇인가요? (폭, 높이, 깊이 등)', 'type' => 'multi_select', 'options' => ['choices' => ['폭(W)', '높이(H)', '깊이(D)', '두께(T)', '지름(Ø)']], 'is_required' => true], + ['text' => '오픈 사이즈에서 제작 사이즈로 변환할 때 더하는 마진값은?', 'type' => 'formula_input', 'ai_hint' => '예: W1 = W0 + 120, H1 = H0 + 50', 'expected_format' => 'mm'], + ['text' => '제품 카테고리별로 마진값이 다른가요?', 'type' => 'table_input', 'options' => ['columns' => ['제품카테고리', 'W 마진(mm)', 'H 마진(mm)', '비고']]], + ['text' => '면적(㎡) 계산 공식을 알려주세요', 'type' => 'formula_input', 'ai_hint' => '예: area = W1 * H1 / 1000000', 'expected_format' => '㎡'], + ['text' => '중량(kg) 계산 공식을 알려주세요', 'type' => 'formula_input', 'ai_hint' => '예: weight = area * 단위중량(kg/㎡)', 'expected_format' => 'kg'], + ['text' => '기타 파생 변수가 있나요? (예: 분할 개수, 절곡 길이 등)', 'type' => 'table_input', 'options' => ['columns' => ['변수명', '계산식', '단위', '비고']]], + ['text' => '치수 계산에 사용하는 엑셀 수식을 캡처해주세요', 'type' => 'file_upload'], + ], + ], + [ + 'name' => '변수 의존 관계', + 'questions' => [ + ['text' => '변수 간 의존 관계를 설명해주세요 (A는 B와 C로 계산)', 'type' => 'text', 'ai_hint' => '계산 순서와 변수 의존성'], + ['text' => '계산 순서가 중요한 변수가 있나요?', 'type' => 'text'], + ['text' => '단위는 mm, m, kg 중 어떤 것을 기본으로 사용하나요?', 'type' => 'select', 'options' => ['choices' => ['mm', 'm', 'cm', '혼용']]], + ], + ], + ], + ], + + // ================================================================ + // Domain 4: 부품 구성 상세 + // ================================================================ + 'component_config' => [ + 'name' => '부품 구성 상세', + 'description' => '주요 부품별 규격, 선택 기준, 특수 구성', + 'templates' => [ + [ + 'name' => '주요 부품별 상세', + 'questions' => [ + ['text' => '가이드레일의 표준 길이 규격은? (예: 1219, 2438, 3305mm)', 'type' => 'table_input', 'options' => ['columns' => ['규격코드', '길이(mm)', '비고']]], + ['text' => '가이드레일 길이 조합 규칙은? (어떤 길이를 몇 개 사용?)', 'type' => 'text', 'ai_hint' => '높이에 따른 가이드레일 조합 로직'], + ['text' => '케이스(하우징) 크기별 규격과 부속품 차이를 설명해주세요', 'type' => 'table_input', 'options' => ['columns' => ['케이스규격', '적용조건', '부속품']]], + ['text' => '모터 용량 종류와 선택 기준은? (무게별? 면적별?)', 'type' => 'table_input', 'options' => ['columns' => ['모터용량', '적용범위(최소)', '적용범위(최대)', '단위']], 'ai_hint' => '무게/면적 범위별 모터 매핑'], + ['text' => '모터 전압 옵션은? (380V, 220V 등)', 'type' => 'multi_select', 'options' => ['choices' => ['380V', '220V', '110V', 'DC 24V']]], + ['text' => '제어기 종류와 선택 기준은? (노출형/매립형 등)', 'type' => 'table_input', 'options' => ['columns' => ['제어기유형', '적용조건', '비고']]], + ['text' => '절곡품(판재 가공) 목록과 각각의 치수 결정 방식은?', 'type' => 'table_input', 'options' => ['columns' => ['절곡품명', '치수결정방식', '재질', '두께(mm)']]], + ['text' => '부자재(볼트, 너트, 패킹 등) 목록과 수량 결정 방식은?', 'type' => 'table_input', 'options' => ['columns' => ['부자재명', '규격', '수량결정방식', '기본수량']]], + ], + ], + [ + 'name' => '특수 구성', + 'questions' => [ + ['text' => '연기차단재 등 특수 부품이 있나요? 적용 조건은?', 'type' => 'text'], + ['text' => '보강재(샤프트, 파이프, 앵글 등) 사용 조건은?', 'type' => 'table_input', 'options' => ['columns' => ['보강재명', '규격', '적용조건', '수량']]], + ['text' => '고객 요청에 따라 추가/제외되는 옵션 부품은?', 'type' => 'table_input', 'options' => ['columns' => ['옵션부품', '추가/제외', '추가비용', '비고']]], + ], + ], + ], + ], + + // ================================================================ + // Domain 5: 단가 체계 + // ================================================================ + 'pricing_structure' => [ + 'name' => '단가 체계', + 'description' => '단가 관리 방식, 계산 방식, 마진/LOSS율', + 'templates' => [ + [ + 'name' => '단가 관리 방식', + 'questions' => [ + ['text' => '부품별 단가를 어디서 관리하나요? (엑셀, ERP, 구두 등)', 'type' => 'select', 'options' => ['choices' => ['엑셀', 'ERP 시스템', '구두/경험', '기타']]], + ['text' => '단가표 파일을 업로드해주세요', 'type' => 'file_upload'], + ['text' => '단가 변경 주기는? (월/분기/연 등)', 'type' => 'select', 'options' => ['choices' => ['수시', '월 단위', '분기 단위', '반기 단위', '연 단위']]], + ['text' => '단가에 포함되는 항목은? (재료비만? 가공비 포함?)', 'type' => 'multi_select', 'options' => ['choices' => ['재료비', '가공비', '운송비', '설치비', '마진']]], + ['text' => '고객별/거래처별 차등 단가가 있나요?', 'type' => 'select', 'options' => ['choices' => ['있음 (등급별)', '있음 (거래처별)', '없음 (일괄 동일)']]], + ['text' => 'LOSS율(손실률)을 적용하나요? 적용 방식은?', 'type' => 'formula_input', 'ai_hint' => '예: 실제수량 = 계산수량 × (1 + LOSS율)', 'expected_format' => '%'], + ['text' => '마진율 설정 방식은? (일괄? 품목별?)', 'type' => 'select', 'options' => ['choices' => ['일괄 마진율', '품목별 마진율', '카테고리별 마진율', '고객별 마진율']]], + ], + ], + [ + 'name' => '단가 계산 방식', + 'questions' => [ + ['text' => '면적 기반 단가 품목은? (원/㎡)', 'type' => 'table_input', 'options' => ['columns' => ['품목명', '단가(원/㎡)', '비고']], 'expected_format' => '원/㎡'], + ['text' => '중량 기반 단가 품목은? (원/kg)', 'type' => 'table_input', 'options' => ['columns' => ['품목명', '단가(원/kg)', '비고']], 'expected_format' => '원/kg'], + ['text' => '수량 기반 단가 품목은? (원/EA)', 'type' => 'table_input', 'options' => ['columns' => ['품목명', '단가(원/EA)', '비고']], 'expected_format' => '원/EA'], + ['text' => '길이 기반 단가 품목은? (원/m)', 'type' => 'table_input', 'options' => ['columns' => ['품목명', '단가(원/m)', '비고']], 'expected_format' => '원/m'], + ['text' => '기타 특수 단가 계산 방식이 있나요?', 'type' => 'text'], + ], + ], + ], + ], + + // ================================================================ + // Domain 6: 수량 수식 + // ================================================================ + 'quantity_formula' => [ + 'name' => '수량 수식', + 'description' => '부품별 수량 결정 규칙, 계산식, 검증', + 'templates' => [ + [ + 'name' => '수량 결정 규칙', + 'questions' => [ + ['text' => '고정 수량 부품 목록 (항상 1개, 2개 등)', 'type' => 'table_input', 'options' => ['columns' => ['부품명', '고정수량', '비고']]], + ['text' => '치수 기반 수량 계산 부품과 수식', 'type' => 'formula_input', 'ai_hint' => '예: 슬랫수량 = CEIL(H1 / 슬랫피치)'], + ['text' => '면적 기반 수량 계산 부품과 수식', 'type' => 'formula_input', 'ai_hint' => '예: 스크린수량 = area / 기준면적'], + ['text' => '중량 기반 수량 계산 부품과 수식', 'type' => 'formula_input'], + ['text' => '올림/내림/반올림 규칙이 있는 계산은?', 'type' => 'table_input', 'options' => ['columns' => ['계산항목', '올림/내림/반올림', '소수점자릿수']]], + ['text' => '여유 수량(LOSS) 적용 품목과 비율은?', 'type' => 'table_input', 'options' => ['columns' => ['품목명', 'LOSS율(%)', '비고']]], + ], + ], + [ + 'name' => '수식 검증', + 'questions' => [ + ['text' => '실제 견적서에서 수량 계산 예시를 보여주세요 (W=3000, H=2500일 때)', 'type' => 'table_input', 'options' => ['columns' => ['부품명', '수식', '계산결과', '단위']], 'is_required' => true], + ['text' => '수식에 사용하는 함수가 있나요? (SUM, CEIL, ROUND 등)', 'type' => 'multi_select', 'options' => ['choices' => ['CEIL (올림)', 'FLOOR (내림)', 'ROUND (반올림)', 'MAX', 'MIN', 'IF 조건문', 'SUM']]], + ['text' => '조건에 따라 수식이 달라지는 경우가 있나요?', 'type' => 'text', 'ai_hint' => '예: 폭이 3000 초과이면 분할 계산'], + ], + ], + ], + ], + + // ================================================================ + // Domain 7: 조건부 로직 + // ================================================================ + 'conditional_logic' => [ + 'name' => '조건부 로직', + 'description' => '범위/매핑 기반 부품 자동 선택 규칙', + 'templates' => [ + [ + 'name' => '범위 기반 선택', + 'questions' => [ + ['text' => '무게 범위별 모터 용량 선택표를 작성해주세요', 'type' => 'price_table', 'options' => ['columns' => ['범위 시작(kg)', '범위 끝(kg)', '모터용량', '비고']], 'is_required' => true], + ['text' => '크기 범위별 부품 자동 선택 규칙이 있나요?', 'type' => 'table_input', 'options' => ['columns' => ['조건(변수)', '범위', '선택부품', '비고']]], + ['text' => '브라켓 크기 결정 기준은?', 'type' => 'table_input', 'options' => ['columns' => ['조건', '범위', '브라켓 규격']]], + ], + ], + [ + 'name' => '매핑 기반 선택', + 'questions' => [ + ['text' => '제품 모델 → 기본 부품 세트 매핑표', 'type' => 'table_input', 'options' => ['columns' => ['제품모델', '기본부품1', '기본부품2', '기본부품3']]], + ['text' => '설치 유형 → 추가 부품 매핑표', 'type' => 'table_input', 'options' => ['columns' => ['설치유형', '추가부품', '수량', '비고']]], + ['text' => '제어기 유형 → 부속품 매핑표', 'type' => 'table_input', 'options' => ['columns' => ['제어기유형', '부속품1', '부속품2', '부속품3']]], + ['text' => '기타 조건부 자동 선택 규칙', 'type' => 'text', 'ai_hint' => '위 항목에 해당하지 않는 조건-결과 매핑'], + ], + ], + ], + ], + + // ================================================================ + // Domain 8: 견적서 양식 + // ================================================================ + 'quote_format' => [ + 'name' => '견적서 양식', + 'description' => '출력 양식, 항목 그룹, 소계/합계 구조', + 'templates' => [ + [ + 'name' => '출력 양식', + 'questions' => [ + ['text' => '현재 사용 중인 견적서 양식을 업로드해주세요', 'type' => 'file_upload', 'is_required' => true], + ['text' => '견적서에 표시되는 항목 그룹은? (재료비, 노무비, 설치비 등)', 'type' => 'multi_select', 'options' => ['choices' => ['재료비', '노무비', '경비', '설치비', '운반비', '이윤', '부가세']]], + ['text' => '소계/합계 계산 구조를 설명해주세요', 'type' => 'text', 'ai_hint' => '항목 그룹별 소계와 최종 합계의 관계'], + ['text' => '할인 적용 방식은? (일괄? 항목별?)', 'type' => 'select', 'options' => ['choices' => ['일괄 할인', '항목별 할인', '할인 없음', '협의 할인']]], + ['text' => '부가세 표시 방식은? (별도? 포함?)', 'type' => 'select', 'options' => ['choices' => ['별도 표시', '포함 표시', '선택 가능']]], + ['text' => '견적서에 표시하지 않는 내부 관리 항목은?', 'type' => 'text'], + ['text' => '견적 번호 체계를 알려주세요', 'type' => 'text', 'ai_hint' => '예: Q-2026-001 형식'], + ], + ], + [ + 'name' => '특수 요구사항', + 'questions' => [ + ['text' => '산출내역서(세부 내역)를 별도로 제공하나요?', 'type' => 'checkbox'], + ['text' => '위치별(층/부호) 개별 산출이 필요한가요?', 'type' => 'checkbox'], + ['text' => '일괄 산출(여러 위치 합산)을 사용하나요?', 'type' => 'checkbox'], + ], + ], + ], + ], + ]; + } +} diff --git a/resources/views/sales/interviews/index.blade.php b/resources/views/sales/interviews/index.blade.php index 5c76c67a..a797f225 100644 --- a/resources/views/sales/interviews/index.blade.php +++ b/resources/views/sales/interviews/index.blade.php @@ -18,6 +18,19 @@ .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; } } + .tab-active { border-bottom: 2px solid #3b82f6; color: #3b82f6; font-weight: 600; } + .tab-inactive { border-bottom: 2px solid transparent; color: #6b7280; } + .tab-inactive:hover { color: #374151; border-bottom-color: #d1d5db; } + .domain-item { transition: all 0.15s; cursor: pointer; } + .domain-item:hover { background-color: #f3f4f6; } + .domain-item.active { background-color: #eff6ff; border-left: 3px solid #3b82f6; } + .project-card { transition: all 0.15s; cursor: pointer; border: 2px solid transparent; } + .project-card:hover { border-color: #93c5fd; } + .project-card.active { border-color: #3b82f6; background-color: #eff6ff; } + .status-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; } + .knowledge-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; margin-bottom: 8px; } + .confidence-bar { height: 4px; border-radius: 2px; background: #e5e7eb; } + .confidence-fill { height: 100%; border-radius: 2px; transition: width 0.3s; } @endpush @@ -101,11 +114,74 @@ const IconFileText = createIcon('file-text'); const IconCheckCircle = createIcon('check-circle'); const IconUpload = createIcon('upload'); +const IconFolder = createIcon('folder'); +const IconFolderPlus = createIcon('folder-plus'); +const IconBrain = createIcon('brain'); +const IconCode = createIcon('code'); +const IconPaperclip = createIcon('paperclip'); +const IconMic = createIcon('mic'); +const IconTarget = createIcon('target'); +const IconSettings = createIcon('settings'); +const IconDownload = createIcon('download'); +const IconSearch = createIcon('search'); +const IconRefresh = createIcon('refresh-cw'); +const IconChevronDown = createIcon('chevron-down'); +const IconShield = createIcon('shield-check'); +const IconAlertCircle = createIcon('alert-circle'); +const IconEye = createIcon('eye'); +const IconHash = createIcon('hash'); +const IconTable = createIcon('table'); +const IconCalculator = createIcon('calculator'); + +const DOMAIN_LABELS = { + product_classification: '제품 분류 체계', + bom_structure: 'BOM 구조', + dimension_formula: '치수/변수 계산', + component_config: '부품 구성 상세', + pricing_structure: '단가 체계', + quantity_formula: '수량 수식', + conditional_logic: '조건부 로직', + quote_format: '견적서 양식', +}; + +const DOMAIN_ICONS = { + product_classification: 'folder', + bom_structure: 'git-branch', + dimension_formula: 'ruler', + component_config: 'settings', + pricing_structure: 'dollar-sign', + quantity_formula: 'calculator', + conditional_logic: 'git-merge', + quote_format: 'file-text', +}; + +const STATUS_CONFIG = { + draft: { label: '초안', bg: 'bg-gray-100', text: 'text-gray-700' }, + interviewing: { label: '인터뷰 진행중', bg: 'bg-blue-100', text: 'text-blue-700' }, + analyzing: { label: 'AI 분석중', bg: 'bg-yellow-100', text: 'text-yellow-700' }, + code_generated: { label: '코드 생성완료', bg: 'bg-green-100', text: 'text-green-700' }, + deployed: { label: '배포완료', bg: 'bg-purple-100', text: 'text-purple-700' }, +}; + +const QUESTION_TYPE_LABELS = { + checkbox: '체크박스', + text: '텍스트', + number: '숫자', + select: '단일선택', + multi_select: '다중선택', + file_upload: '파일업로드', + formula_input: '수식입력', + table_input: '테이블입력', + bom_tree: 'BOM 트리', + price_table: '단가표', + dimension_diagram: '치수다이어그램', +}; // ============================================================ -// 루트 앱 +// 루트 앱 — 프로젝트 모드 / 기존 모드 전환 // ============================================================ function InterviewScenarioApp() { + const [mode, setMode] = useState('project'); // 'project' | 'legacy' const [tree, setTree] = useState([]); const [categories, setCategories] = useState([]); const [selectedCategoryId, setSelectedCategoryId] = useState(null); @@ -113,6 +189,14 @@ function InterviewScenarioApp() { const [showSessionModal, setShowSessionModal] = useState(false); const [showHistoryModal, setShowHistoryModal] = useState(false); + // 프로젝트 모드 상태 + const [projects, setProjects] = useState([]); + const [selectedProjectId, setSelectedProjectId] = useState(null); + const [projectTree, setProjectTree] = useState([]); + const [selectedDomainCategoryId, setSelectedDomainCategoryId] = useState(null); + const [activeTab, setActiveTab] = useState('questions'); // questions|interview|attachments|knowledge + const [showProjectCreateModal, setShowProjectCreateModal] = useState(false); + const loadTree = useCallback(async () => { try { const data = await api.get('/sales/interviews/api/tree'); @@ -128,9 +212,40 @@ function InterviewScenarioApp() { } }, [selectedCategoryId]); - useEffect(() => { loadTree(); }, []); + const loadProjects = useCallback(async () => { + try { + const data = await api.get('/sales/interviews/api/projects'); + setProjects(data.data || []); + } catch (e) { + console.error('프로젝트 로드 실패:', e); + } + }, []); + + const loadProjectTree = useCallback(async (projectId) => { + if (!projectId) return; + try { + const data = await api.get(`/sales/interviews/api/projects/${projectId}/tree`); + setProjectTree(data); + if (data.length > 0 && !selectedDomainCategoryId) { + setSelectedDomainCategoryId(data[0].id); + } + } catch (e) { + console.error('프로젝트 트리 로드 실패:', e); + } + }, [selectedDomainCategoryId]); + + useEffect(() => { loadTree(); loadProjects(); }, []); + + useEffect(() => { + if (selectedProjectId) { + setSelectedDomainCategoryId(null); + loadProjectTree(selectedProjectId); + } + }, [selectedProjectId]); const selectedCategory = tree.find(c => c.id === selectedCategoryId); + const selectedProject = projects.find(p => p.id === selectedProjectId); + const selectedDomainCategory = projectTree.find(c => c.id === selectedDomainCategoryId); if (loading) { return ( @@ -150,37 +265,122 @@ function InterviewScenarioApp() {

인터뷰 시나리오 관리

+ -
- {/* 2-패널 레이아웃 */} -
- {/* 좌측: 카테고리 */} -
- + {/* 프로젝트 선택 바 */} + { setSelectedProjectId(id); setActiveTab('questions'); }} + onRefresh={loadProjects} /> -
- {/* 우측: 항목 + 질문 */} -
- -
-
+ {selectedProject ? ( + <> + {/* 진행 상태 바 */} + { loadProjects(); loadProjectTree(selectedProjectId); }} /> + + {/* 탭 네비게이션 */} +
+ {[ + { key: 'questions', label: '질문 편집', icon: IconEdit }, + { key: 'interview', label: '인터뷰', icon: IconPlay }, + { key: 'attachments', label: '첨부파일', icon: IconPaperclip }, + { key: 'knowledge', label: '추출 지식', icon: IconBrain }, + ].map(tab => ( + + ))} +
+ + {/* 2-패널: 도메인 트리 + 콘텐츠 */} +
+ {/* 좌측: 도메인 트리 */} +
+ +
+ + {/* 우측: 탭 콘텐츠 */} +
+ {activeTab === 'questions' && ( + loadProjectTree(selectedProjectId)} + /> + )} + {activeTab === 'interview' && ( + + )} + {activeTab === 'attachments' && ( + + )} + {activeTab === 'knowledge' && ( + + )} +
+
+ + ) : ( +
+ +

프로젝트를 선택하거나 새 프로젝트를 생성하세요

+ +
+ )} + + ) : ( + <> + {/* 기존 모드: 2-패널 레이아웃 */} +
+
+ +
+
+ +
+
+ + )} {/* 모달 */} {showSessionModal && ( @@ -196,6 +396,786 @@ className="flex items-center gap-1 px-3 py-2 text-sm bg-blue-600 text-white roun onClose={() => setShowHistoryModal(false)} /> )} + {showProjectCreateModal && ( + setShowProjectCreateModal(false)} + onCreated={(project) => { + setShowProjectCreateModal(false); + loadProjects(); + setSelectedProjectId(project.id); + }} + /> + )} + + ); +} + +// ============================================================ +// 프로젝트 선택 바 +// ============================================================ +function ProjectSelector({ projects, selectedId, onSelect, onRefresh }) { + return ( +
+ {projects.map(p => { + const st = STATUS_CONFIG[p.status] || STATUS_CONFIG.draft; + return ( +
onSelect(p.id)} + className={`project-card flex-shrink-0 px-4 py-3 rounded-lg bg-white ${p.id === selectedId ? 'active' : ''}`} + style={{ minWidth: '180px' }}> +
{p.company_name}
+
+ {st.label} + {p.progress_percent}% +
+
+ ); + })} + {projects.length === 0 && ( +
프로젝트가 없습니다
+ )} +
+ ); +} + +// ============================================================ +// 프로젝트 상태 바 +// ============================================================ +function ProjectStatusBar({ project, onRefresh }) { + const st = STATUS_CONFIG[project.status] || STATUS_CONFIG.draft; + return ( +
+
+ + {project.company_name} + {project.company_type && ({project.company_type})} +
+
+
+
+
+
+ {project.progress_percent}% +
+
+ {st.label} + {project.contact_person && 담당: {project.contact_person}} +
+ ); +} + +// ============================================================ +// 도메인 사이드바 +// ============================================================ +function DomainSidebar({ categories, selectedId, onSelect }) { + return ( +
+
+

도메인

+
+
+ {categories.map(cat => { + const qCount = (cat.templates || []).reduce((sum, t) => sum + (t.questions || []).length, 0); + return ( +
onSelect(cat.id)} + className={`domain-item flex items-center justify-between px-3 py-2.5 ${cat.id === selectedId ? 'active' : ''}`}> +
+ + {DOMAIN_LABELS[cat.domain] ? '📋' : '📁'} + + {cat.name} +
+ {qCount} +
+ ); + })} +
+
+ ); +} + +// ============================================================ +// 프로젝트 생성 모달 +// ============================================================ +function ProjectCreateModal({ onClose, onCreated }) { + const [companyName, setCompanyName] = useState(''); + const [companyType, setCompanyType] = useState(''); + const [contactPerson, setContactPerson] = useState(''); + const [contactInfo, setContactInfo] = useState(''); + const [saving, setSaving] = useState(false); + + const handleSubmit = async () => { + if (!companyName.trim()) { alert('회사명을 입력하세요.'); return; } + setSaving(true); + try { + const project = await api.post('/sales/interviews/api/projects', { + company_name: companyName.trim(), + company_type: companyType.trim() || null, + contact_person: contactPerson.trim() || null, + contact_info: contactInfo.trim() || null, + }); + onCreated(project); + } catch (e) { + alert('프로젝트 생성 실패: ' + e.message); + } finally { + setSaving(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

새 인터뷰 프로젝트

+ +
+
+
+ + setCompanyName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="예: ABC 방화셔터" + onKeyDown={e => e.key === 'Enter' && handleSubmit()} /> +
+
+ + setCompanyType(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="예: 방화셔터, 블라인드, 금속가공" /> +
+
+
+ + setContactPerson(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="이름" /> +
+
+ + setContactInfo(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="전화/이메일" /> +
+
+
+ 프로젝트 생성 시 8개 도메인의 마스터 질문이 자동으로 복제됩니다. +
+
+
+ + +
+
+
+ ); +} + +// ============================================================ +// 프로젝트 인터뷰 탭 +// ============================================================ +function ProjectInterviewTab({ project, categories, selectedCategoryId }) { + const [sessionData, setSessionData] = useState(null); + const [loadingSession, setLoadingSession] = useState(false); + + const startInterview = async () => { + if (!selectedCategoryId) { alert('도메인을 선택하세요.'); return; } + setLoadingSession(true); + try { + const session = await api.post('/sales/interviews/api/sessions', { + interview_category_id: selectedCategoryId, + interview_date: new Date().toISOString().split('T')[0], + interviewee_company: project.company_name, + }); + const detail = await api.get(`/sales/interviews/api/sessions/${session.id}`); + setSessionData(detail); + } catch (e) { + alert('세션 시작 실패: ' + e.message); + } finally { + setLoadingSession(false); + } + }; + + const handleSaveAnswer = async (sessionId, questionId, data) => { + try { + await api.post('/sales/interviews/api/sessions/save-answer', { + session_id: sessionId, + question_id: questionId, + ...data, + }); + const detail = await api.get(`/sales/interviews/api/sessions/${sessionId}`); + setSessionData(detail); + } catch (e) { + console.error('답변 저장 실패:', e); + } + }; + + if (!selectedCategoryId) { + return ( +
+ 좌측에서 도메인을 선택하세요 +
+ ); + } + + if (!sessionData) { + return ( +
+ +

선택한 도메인의 인터뷰를 시작하세요

+ +
+ ); + } + + // 인터뷰 진행 뷰 + const grouped = {}; + (sessionData.answers || []).forEach(ans => { + const tplId = ans.interview_template_id; + if (!grouped[tplId]) grouped[tplId] = { template: ans.template, answers: [] }; + grouped[tplId].answers.push(ans); + }); + + return ( +
+ {/* 진행률 */} +
+
+
0 ? Math.round(sessionData.answered_questions / sessionData.total_questions * 100) : 0}%` }} /> +
+ {sessionData.answered_questions}/{sessionData.total_questions} +
+ + {Object.entries(grouped).map(([tplId, { template, answers }]) => ( +
+
+ {template?.name || `항목 ${tplId}`} +
+
+ {answers.map(ans => ( + handleSaveAnswer(sessionData.id, ans.interview_question_id, data)} /> + ))} +
+
+ ))} + + {sessionData.status === 'in_progress' && ( +
+ +
+ )} +
+ ); +} + +// ============================================================ +// 구조화 답변 행 +// ============================================================ +function StructuredAnswerRow({ answer, onSave }) { + const q = answer.question; + const qType = q?.question_type || 'checkbox'; + const typeLabel = QUESTION_TYPE_LABELS[qType] || qType; + + const handleCheckToggle = () => { + onSave({ is_checked: !answer.is_checked }); + }; + + const handleTextSave = (value) => { + onSave({ is_checked: true, answer_text: value }); + }; + + const handleDataSave = (data) => { + onSave({ is_checked: true, answer_data: data }); + }; + + return ( +
+
+ +
+
+ + {q?.question_text} + + {typeLabel} + {q?.is_required && *필수} +
+ {q?.ai_hint &&
{q.ai_hint}
} + + {/* 타입별 입력 UI */} +
+ {(qType === 'text' || qType === 'formula_input') && ( + + )} + {qType === 'number' && ( + + )} + {qType === 'select' && ( + + )} + {qType === 'multi_select' && ( + + )} + {qType === 'table_input' && ( + + )} + {qType === 'file_upload' && ( +
첨부파일 탭에서 파일을 업로드하세요
+ )} + {(qType === 'bom_tree' || qType === 'price_table' || qType === 'dimension_diagram') && ( + + )} +
+
+
+
+ ); +} + +// ============================================================ +// 답변 입력 컴포넌트들 +// ============================================================ +function AnswerTextInput({ value, onSave, placeholder = '', format, type = 'text', multiline = false }) { + const [text, setText] = useState(value); + const [editing, setEditing] = useState(false); + + if (!editing && text) { + return ( +
setEditing(true)}> + {text} + {format && {format}} + +
+ ); + } + + const InputTag = multiline ? 'textarea' : 'input'; + return ( +
+ setText(e.target.value)} + className="flex-1 px-2 py-1 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500" + placeholder={placeholder} + rows={multiline ? 3 : undefined} + onKeyDown={e => { if (e.key === 'Enter' && !multiline) { onSave(text); setEditing(false); } }} + autoFocus /> + {format && {format}} + +
+ ); +} + +function AnswerSelectInput({ value, options, onSave }) { + return ( + + ); +} + +function AnswerMultiSelectInput({ value, options, onSave }) { + const selected = Array.isArray(value) ? value : []; + const toggle = (opt) => { + const next = selected.includes(opt) ? selected.filter(v => v !== opt) : [...selected, opt]; + onSave(next); + }; + return ( +
+ {options.map(opt => ( + + ))} +
+ ); +} + +function AnswerTableInput({ value, columns, onSave }) { + const rows = value?.rows || []; + const [localRows, setLocalRows] = useState(rows); + + const addRow = () => { + const newRow = {}; + columns.forEach(c => { newRow[c] = ''; }); + const updated = [...localRows, newRow]; + setLocalRows(updated); + onSave({ rows: updated }); + }; + + const updateCell = (rowIdx, col, val) => { + const updated = [...localRows]; + updated[rowIdx] = { ...updated[rowIdx], [col]: val }; + setLocalRows(updated); + }; + + const saveAll = () => { + onSave({ rows: localRows }); + }; + + const removeRow = (idx) => { + const updated = localRows.filter((_, i) => i !== idx); + setLocalRows(updated); + onSave({ rows: updated }); + }; + + return ( +
+
+ + + + {columns.map(col => ( + + ))} + + + + + {localRows.map((row, ri) => ( + + {columns.map(col => ( + + ))} + + + ))} + +
{col}
+ updateCell(ri, col, e.target.value)} + onBlur={saveAll} + className="w-full px-2 py-1 text-xs border-0 focus:ring-0" /> + + +
+
+ +
+ ); +} + +// ============================================================ +// 첨부파일 탭 +// ============================================================ +function ProjectAttachmentsTab({ projectId }) { + const [attachments, setAttachments] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const fileInput = useRef(null); + const [fileType, setFileType] = useState('other'); + + const load = useCallback(async () => { + try { + const data = await api.get(`/sales/interviews/api/projects/${projectId}/attachments`); + setAttachments(data); + } catch (e) { console.error(e); } + finally { setLoading(false); } + }, [projectId]); + + useEffect(() => { load(); }, [load]); + + const handleUpload = async (e) => { + const file = e.target.files[0]; + if (!file) return; + setUploading(true); + try { + const form = new FormData(); + form.append('file', file); + form.append('file_type', fileType); + const res = await fetch(`/sales/interviews/api/projects/${projectId}/attachments`, { + method: 'POST', + headers: { 'X-CSRF-TOKEN': CSRF_TOKEN, 'Accept': 'application/json' }, + body: form, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + load(); + } catch (e) { alert('업로드 실패: ' + e.message); } + finally { setUploading(false); if (fileInput.current) fileInput.current.value = ''; } + }; + + const handleDelete = async (id) => { + if (!confirm('삭제하시겠습니까?')) return; + try { + await api.del(`/sales/interviews/api/attachments/${id}`); + load(); + } catch (e) { alert('삭제 실패: ' + e.message); } + }; + + const FILE_TYPE_LABELS = { + excel_template: '엑셀 템플릿', + pdf_quote: 'PDF 견적서', + sample_bom: 'BOM 샘플', + price_list: '단가표', + photo: '사진', + voice: '음성녹음', + other: '기타', + }; + + return ( +
+
+ + + +
+ + {loading ? ( +
+ +
+ ) : attachments.length === 0 ? ( +
+ + 첨부파일이 없습니다 +
+ ) : ( +
+ {attachments.map(att => ( +
+
+ +
+
{att.file_name}
+
+ {FILE_TYPE_LABELS[att.file_type] || att.file_type} + {(att.file_size / 1024).toFixed(1)}KB + + AI: {att.ai_analysis_status} + +
+
+
+ +
+ ))} +
+ )} +
+ ); +} + +// ============================================================ +// 지식 탭 +// ============================================================ +function ProjectKnowledgeTab({ projectId }) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [domainFilter, setDomainFilter] = useState(''); + const [verifiedFilter, setVerifiedFilter] = useState(''); + const [showAddForm, setShowAddForm] = useState(false); + + const load = useCallback(async () => { + try { + let url = `/sales/interviews/api/projects/${projectId}/knowledge?`; + if (domainFilter) url += `domain=${domainFilter}&`; + if (verifiedFilter) url += `is_verified=${verifiedFilter}&`; + const data = await api.get(url); + setItems(data); + } catch (e) { console.error(e); } + finally { setLoading(false); } + }, [projectId, domainFilter, verifiedFilter]); + + useEffect(() => { load(); }, [load]); + + const handleVerify = async (id) => { + try { + await api.post(`/sales/interviews/api/knowledge/${id}/verify`); + load(); + } catch (e) { alert('검증 토글 실패: ' + e.message); } + }; + + const handleDelete = async (id) => { + if (!confirm('삭제하시겠습니까?')) return; + try { + await api.del(`/sales/interviews/api/knowledge/${id}`); + load(); + } catch (e) { alert('삭제 실패: ' + e.message); } + }; + + const handleAdd = async (data) => { + try { + await api.post(`/sales/interviews/api/projects/${projectId}/knowledge`, data); + setShowAddForm(false); + load(); + } catch (e) { alert('추가 실패: ' + e.message); } + }; + + const KNOWLEDGE_TYPE_ICONS = { + fact: '📌', rule: '📏', formula: '🔢', mapping: '🔗', range: '📊', table: '📋', + }; + + return ( +
+ {/* 필터 바 */} +
+ + + +
+ + {/* 지식 추가 폼 */} + {showAddForm && ( + setShowAddForm(false)} + /> + )} + + {loading ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ + 추출된 지식이 없습니다 +
+ ) : ( +
+ {items.map(item => ( +
+
+
+ {KNOWLEDGE_TYPE_ICONS[item.knowledge_type] || '📎'} + + {item.is_verified ? '검증됨' : '미검증'} + + {item.title} +
+
+ + +
+
+
+ {DOMAIN_LABELS[item.domain] || item.domain} · {item.knowledge_type} · 출처: {item.source_type} +
+ {/* 신뢰도 바 */} +
+ 신뢰도 +
+
= 0.8 ? '#22c55e' : item.confidence >= 0.5 ? '#eab308' : '#ef4444' + }} /> +
+ {((item.confidence || 0) * 100).toFixed(0)}% +
+
+ ))} +
+ )} +
+ ); +} + +// ============================================================ +// 지식 수동 추가 폼 +// ============================================================ +function KnowledgeAddForm({ onSave, onCancel }) { + const [domain, setDomain] = useState('product_classification'); + const [knowledgeType, setKnowledgeType] = useState('fact'); + const [title, setTitle] = useState(''); + const [contentText, setContentText] = useState(''); + + const handleSave = () => { + if (!title.trim()) { alert('제목을 입력하세요.'); return; } + onSave({ + domain, + knowledge_type: knowledgeType, + title: title.trim(), + content: { text: contentText }, + source_type: 'manual', + confidence: 1.0, + }); + }; + + return ( +
+
+ + +
+ setTitle(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm" + placeholder="지식 제목 (예: 가이드레일 표준 길이)" /> +