From d96cdc19759e9355ed1a85f67ddfd7719273b94d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 31 Jan 2026 19:50:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EA=B0=80=EB=A7=9D=EA=B3=A0=EA=B0=9D(prosp?= =?UTF-8?q?ect)=20=EC=83=81=EB=8B=B4=20=EA=B8=B0=EB=A1=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B2=A8=EB=B6=80=ED=8C=8C=EC=9D=BC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SalesConsultation 모델에 prospect 관련 메서드 추가 - createTextByProspect(), createAudioByProspect(), createFileByProspect() - getByProspectAndType() 조회 메서드 - ConsultationController에 prospect 라우트 추가 - prospectIndex(), prospectStore(), prospectUploadAudio(), prospectUploadFile() - scenario-modal.blade.php에서 @if(!$isProspectMode) 조건 제거 - 가망고객 모드에서도 상담 기록 섹션 표시 - voice-recorder, file-uploader, consultation-log에 prospect 모드 지원 - routes/web.php에 prospect 상담 기록 라우트 추가 Co-Authored-By: Claude Opus 4.5 --- .../Sales/ConsultationController.php | 169 ++++++++++++++++++ app/Models/Sales/SalesConsultation.php | 104 ++++++++++- .../sales/modals/consultation-log.blade.php | 50 ++++-- .../sales/modals/file-uploader.blade.php | 39 ++-- .../sales/modals/scenario-modal.blade.php | 14 +- .../sales/modals/voice-recorder.blade.php | 27 ++- routes/web.php | 7 + 7 files changed, 368 insertions(+), 42 deletions(-) diff --git a/app/Http/Controllers/Sales/ConsultationController.php b/app/Http/Controllers/Sales/ConsultationController.php index 1f27f719..40265ddb 100644 --- a/app/Http/Controllers/Sales/ConsultationController.php +++ b/app/Http/Controllers/Sales/ConsultationController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\Sales\SalesConsultation; +use App\Models\Sales\TenantProspect; use App\Models\Tenants\Tenant; use App\Services\GoogleCloudStorageService; use Illuminate\Http\JsonResponse; @@ -279,4 +280,172 @@ public function downloadFile(int $consultationId): BinaryFileResponse return response()->download($localPath, $consultation->file_name); } + + // ======================================== + // Prospect(가망고객) 관련 메서드 + // ======================================== + + /** + * 가망고객 상담 기록 목록 (HTMX 부분 뷰) + */ + public function prospectIndex(int $prospectId, Request $request): View + { + $prospect = TenantProspect::findOrFail($prospectId); + $scenarioType = $request->input('scenario_type', 'sales'); + $stepId = $request->input('step_id'); + + $consultations = SalesConsultation::getByProspectAndType($prospectId, $scenarioType, $stepId); + + return view('sales.modals.consultation-log', [ + 'prospect' => $prospect, + 'isProspect' => true, + 'consultations' => $consultations, + 'scenarioType' => $scenarioType, + 'stepId' => $stepId, + ]); + } + + /** + * 가망고객 텍스트 상담 기록 저장 + */ + public function prospectStore(Request $request): JsonResponse + { + $request->validate([ + 'prospect_id' => 'required|integer|exists:tenant_prospects,id', + 'scenario_type' => 'required|in:sales,manager', + 'step_id' => 'nullable|integer', + 'content' => 'required|string|max:5000', + ]); + + $consultation = SalesConsultation::createTextByProspect( + $request->input('prospect_id'), + $request->input('scenario_type'), + $request->input('step_id'), + $request->input('content') + ); + + $consultation->load('creator'); + + return response()->json([ + 'success' => true, + 'consultation' => [ + 'id' => $consultation->id, + 'type' => $consultation->consultation_type, + 'content' => $consultation->content, + 'created_by_name' => $consultation->creator->name, + 'created_at' => $consultation->created_at->format('Y-m-d H:i'), + ], + ]); + } + + /** + * 가망고객 음성 파일 업로드 + */ + public function prospectUploadAudio(Request $request, GoogleCloudStorageService $gcs): JsonResponse + { + $request->validate([ + 'prospect_id' => 'required|integer|exists:tenant_prospects,id', + 'scenario_type' => 'required|in:sales,manager', + 'step_id' => 'nullable|integer', + 'audio' => 'required|file|mimes:webm,mp3,wav,ogg|max:51200', + 'transcript' => 'nullable|string|max:10000', + 'duration' => 'nullable|integer', + ]); + + $prospectId = $request->input('prospect_id'); + $scenarioType = $request->input('scenario_type'); + $stepId = $request->input('step_id'); + $transcript = $request->input('transcript'); + $duration = $request->input('duration'); + + $file = $request->file('audio'); + $fileName = 'audio_' . now()->format('Ymd_His') . '_' . uniqid() . '.' . $file->getClientOriginalExtension(); + $localPath = $file->storeAs("prospect/consultations/{$prospectId}", $fileName, 'local'); + $fileSize = $file->getSize(); + + $gcsUri = null; + $maxLocalSize = 10 * 1024 * 1024; + + if ($fileSize > $maxLocalSize && $gcs->isAvailable()) { + $gcsObjectName = "consultations/prospect/{$prospectId}/{$scenarioType}/{$fileName}"; + $localFullPath = Storage::disk('local')->path($localPath); + $gcsUri = $gcs->upload($localFullPath, $gcsObjectName); + } + + $consultation = SalesConsultation::createAudioByProspect( + $prospectId, + $scenarioType, + $stepId, + $localPath, + $fileName, + $fileSize, + $transcript, + $duration, + $gcsUri + ); + + $consultation->load('creator'); + + return response()->json([ + 'success' => true, + 'consultation' => [ + 'id' => $consultation->id, + 'type' => $consultation->consultation_type, + 'file_name' => $consultation->file_name, + 'transcript' => $consultation->transcript, + 'duration' => $consultation->duration, + 'formatted_duration' => $consultation->formatted_duration, + 'created_by_name' => $consultation->creator->name, + 'created_at' => $consultation->created_at->format('Y-m-d H:i'), + 'has_gcs' => !empty($gcsUri), + ], + ]); + } + + /** + * 가망고객 첨부파일 업로드 + */ + public function prospectUploadFile(Request $request): JsonResponse + { + $request->validate([ + 'prospect_id' => 'required|integer|exists:tenant_prospects,id', + 'scenario_type' => 'required|in:sales,manager', + 'step_id' => 'nullable|integer', + 'file' => 'required|file|max:20480', + ]); + + $prospectId = $request->input('prospect_id'); + $scenarioType = $request->input('scenario_type'); + $stepId = $request->input('step_id'); + + $file = $request->file('file'); + $originalName = $file->getClientOriginalName(); + $fileName = now()->format('Ymd_His') . '_' . uniqid() . '_' . $originalName; + $path = $file->storeAs("prospect/attachments/{$prospectId}", $fileName, 'local'); + + $consultation = SalesConsultation::createFileByProspect( + $prospectId, + $scenarioType, + $stepId, + $path, + $originalName, + $file->getSize(), + $file->getMimeType() + ); + + $consultation->load('creator'); + + return response()->json([ + 'success' => true, + 'consultation' => [ + 'id' => $consultation->id, + 'type' => $consultation->consultation_type, + 'file_name' => $consultation->file_name, + 'file_size' => $consultation->file_size, + 'formatted_file_size' => $consultation->formatted_file_size, + 'created_by_name' => $consultation->creator->name, + 'created_at' => $consultation->created_at->format('Y-m-d H:i'), + ], + ]); + } } diff --git a/app/Models/Sales/SalesConsultation.php b/app/Models/Sales/SalesConsultation.php index 0f141e40..7d92c798 100644 --- a/app/Models/Sales/SalesConsultation.php +++ b/app/Models/Sales/SalesConsultation.php @@ -2,6 +2,7 @@ namespace App\Models\Sales; +use App\Models\Sales\TenantProspect; use App\Models\Tenants\Tenant; use App\Models\User; use Illuminate\Database\Eloquent\Model; @@ -13,7 +14,8 @@ * 영업 상담 기록 모델 (텍스트, 음성, 첨부파일) * * @property int $id - * @property int $tenant_id + * @property int|null $tenant_id + * @property int|null $tenant_prospect_id * @property string $scenario_type (sales/manager) * @property int|null $step_id * @property string $consultation_type (text/audio/file) @@ -34,6 +36,7 @@ class SalesConsultation extends Model protected $fillable = [ 'tenant_id', + 'tenant_prospect_id', 'scenario_type', 'step_id', 'consultation_type', @@ -69,6 +72,14 @@ public function tenant(): BelongsTo return $this->belongsTo(Tenant::class); } + /** + * 가망고객 관계 + */ + public function prospect(): BelongsTo + { + return $this->belongsTo(TenantProspect::class, 'tenant_prospect_id'); + } + /** * 작성자 관계 */ @@ -258,4 +269,95 @@ public function scopeFileOnly($query) { return $query->where('consultation_type', self::TYPE_FILE); } + + // ======================================== + // Prospect(가망고객) 관련 메서드 + // ======================================== + + /** + * 가망고객 텍스트 상담 기록 생성 + */ + public static function createTextByProspect(int $prospectId, string $scenarioType, ?int $stepId, string $content): self + { + return self::create([ + 'tenant_prospect_id' => $prospectId, + 'scenario_type' => $scenarioType, + 'step_id' => $stepId, + 'consultation_type' => self::TYPE_TEXT, + 'content' => $content, + 'created_by' => auth()->id(), + ]); + } + + /** + * 가망고객 음성 상담 기록 생성 + */ + public static function createAudioByProspect( + int $prospectId, + string $scenarioType, + ?int $stepId, + string $filePath, + string $fileName, + int $fileSize, + ?string $transcript = null, + ?int $duration = null, + ?string $gcsUri = null + ): self { + return self::create([ + 'tenant_prospect_id' => $prospectId, + 'scenario_type' => $scenarioType, + 'step_id' => $stepId, + 'consultation_type' => self::TYPE_AUDIO, + 'file_path' => $filePath, + 'file_name' => $fileName, + 'file_size' => $fileSize, + 'file_type' => 'audio/webm', + 'transcript' => $transcript, + 'duration' => $duration, + 'gcs_uri' => $gcsUri, + 'created_by' => auth()->id(), + ]); + } + + /** + * 가망고객 파일 상담 기록 생성 + */ + public static function createFileByProspect( + int $prospectId, + string $scenarioType, + ?int $stepId, + string $filePath, + string $fileName, + int $fileSize, + string $fileType + ): self { + return self::create([ + 'tenant_prospect_id' => $prospectId, + 'scenario_type' => $scenarioType, + 'step_id' => $stepId, + 'consultation_type' => self::TYPE_FILE, + 'file_path' => $filePath, + 'file_name' => $fileName, + 'file_size' => $fileSize, + 'file_type' => $fileType, + 'created_by' => auth()->id(), + ]); + } + + /** + * 가망고객 + 시나리오 타입으로 조회 + */ + public static function getByProspectAndType(int $prospectId, string $scenarioType, ?int $stepId = null) + { + $query = self::where('tenant_prospect_id', $prospectId) + ->where('scenario_type', $scenarioType) + ->with('creator') + ->orderBy('created_at', 'desc'); + + if ($stepId !== null) { + $query->where('step_id', $stepId); + } + + return $query->get(); + } } diff --git a/resources/views/sales/modals/consultation-log.blade.php b/resources/views/sales/modals/consultation-log.blade.php index 41241d7c..8c2dce30 100644 --- a/resources/views/sales/modals/consultation-log.blade.php +++ b/resources/views/sales/modals/consultation-log.blade.php @@ -1,5 +1,9 @@ {{-- 상담 기록 컴포넌트 --}} -
+@php + $isProspectMode = isset($isProspect) && $isProspect; + $entityId = $isProspectMode ? $prospect->id : $tenant->id; +@endphp +
{{-- 상담 기록 입력 --}}

상담 기록 추가

@@ -233,11 +237,12 @@ function toggleAudioPlay(consultationId, audioUrl) { } } -function consultationLog() { +function consultationLog(entityId, isProspect, scenarioType, stepId) { return { - tenantId: {{ $tenant->id }}, - scenarioType: '{{ $scenarioType }}', - stepId: {{ $stepId ?? 'null' }}, + entityId: entityId, + isProspect: isProspect, + scenarioType: scenarioType, + stepId: stepId, newContent: '', saving: false, @@ -246,29 +251,42 @@ function consultationLog() { this.saving = true; try { - const response = await fetch('{{ route('sales.consultations.store') }}', { + const storeUrl = this.isProspect + ? '/sales/consultations/prospect' + : '/sales/consultations'; + + const bodyData = this.isProspect + ? { + prospect_id: this.entityId, + scenario_type: this.scenarioType, + step_id: this.stepId, + content: this.newContent, + } + : { + tenant_id: this.entityId, + scenario_type: this.scenarioType, + step_id: this.stepId, + content: this.newContent, + }; + + const response = await fetch(storeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, 'Accept': 'application/json', }, - body: JSON.stringify({ - tenant_id: this.tenantId, - scenario_type: this.scenarioType, - step_id: this.stepId, - content: this.newContent, - }), + body: JSON.stringify(bodyData), }); const result = await response.json(); if (result.success) { this.newContent = ''; // 목록 새로고침 - htmx.ajax('GET', - `/sales/consultations/${this.tenantId}?scenario_type=${this.scenarioType}`, - { target: '#consultation-log-container', swap: 'innerHTML' } - ); + const refreshUrl = this.isProspect + ? `/sales/consultations/prospect/${this.entityId}?scenario_type=${this.scenarioType}` + : `/sales/consultations/${this.entityId}?scenario_type=${this.scenarioType}`; + htmx.ajax('GET', refreshUrl, { target: '#consultation-log-container', swap: 'innerHTML' }); } else { alert('저장에 실패했습니다.'); } diff --git a/resources/views/sales/modals/file-uploader.blade.php b/resources/views/sales/modals/file-uploader.blade.php index 5ffff97d..4fe14e07 100644 --- a/resources/views/sales/modals/file-uploader.blade.php +++ b/resources/views/sales/modals/file-uploader.blade.php @@ -1,5 +1,9 @@ {{-- 첨부파일 업로드 컴포넌트 (자동 업로드) --}} -
+@php + $isProspectMode = isset($isProspect) && $isProspect; + $entityId = $isProspectMode ? $entity->id : ($entity->id ?? $tenant->id ?? 0); +@endphp +
{{-- 업로드 중 오버레이 --}}