feat:가망고객(prospect) 상담 기록 및 첨부파일 기능 추가

- 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 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-01-31 19:50:46 +09:00
parent 49c437f796
commit d96cdc1975
7 changed files with 368 additions and 42 deletions

View File

@@ -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'),
],
]);
}
}

View File

@@ -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();
}
}

View File

@@ -1,5 +1,9 @@
{{-- 상담 기록 컴포넌트 --}}
<div x-data="consultationLog()" class="space-y-4">
@php
$isProspectMode = isset($isProspect) && $isProspect;
$entityId = $isProspectMode ? $prospect->id : $tenant->id;
@endphp
<div x-data="consultationLog({{ $entityId }}, {{ $isProspectMode ? 'true' : 'false' }}, '{{ $scenarioType }}', {{ $stepId ?? 'null' }})" class="space-y-4">
{{-- 상담 기록 입력 --}}
<div class="bg-white border border-gray-200 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-700 mb-3">상담 기록 추가</h4>
@@ -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('저장에 실패했습니다.');
}

View File

@@ -1,5 +1,9 @@
{{-- 첨부파일 업로드 컴포넌트 (자동 업로드) --}}
<div x-data="fileUploader()" class="bg-white border border-gray-200 rounded-lg overflow-hidden relative">
@php
$isProspectMode = isset($isProspect) && $isProspect;
$entityId = $isProspectMode ? $entity->id : ($entity->id ?? $tenant->id ?? 0);
@endphp
<div x-data="fileUploader({{ $entityId }}, {{ $isProspectMode ? 'true' : 'false' }}, '{{ $scenarioType }}', {{ $stepId ?? 'null' }})" class="bg-white border border-gray-200 rounded-lg overflow-hidden relative">
{{-- 업로드 오버레이 --}}
<div x-show="uploading"
@@ -118,12 +122,13 @@ class="hidden"
</div>
<script>
function fileUploader() {
function fileUploader(entityId, isProspect, scenarioType, stepId) {
return {
tenantId: {{ $tenant->id }},
entityId: entityId,
isProspect: isProspect,
expanded: false,
scenarioType: '{{ $scenarioType }}',
stepId: {{ $stepId ?? 'null' }},
scenarioType: scenarioType,
stepId: stepId,
isDragging: false,
uploading: false,
@@ -202,10 +207,10 @@ function fileUploader() {
this.totalProgress = 0;
// 상담 기록 새로고침
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' });
// 5초 후 최근 파일 목록 초기화
setTimeout(() => {
@@ -215,11 +220,16 @@ function fileUploader() {
},
async uploadSingleFile(file, onProgress) {
const self = this;
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('tenant_id', this.tenantId);
formData.append('scenario_type', this.scenarioType);
if (this.stepId) formData.append('step_id', this.stepId);
if (self.isProspect) {
formData.append('prospect_id', self.entityId);
} else {
formData.append('tenant_id', self.entityId);
}
formData.append('scenario_type', self.scenarioType);
if (self.stepId) formData.append('step_id', self.stepId);
formData.append('file', file);
const xhr = new XMLHttpRequest();
@@ -245,7 +255,10 @@ function fileUploader() {
xhr.onerror = () => reject(new Error('네트워크 오류'));
xhr.open('POST', '{{ route('sales.consultations.upload-file') }}');
const uploadUrl = self.isProspect
? '/sales/consultations/prospect/upload-file'
: '/sales/consultations/upload-file';
xhr.open('POST', uploadUrl);
xhr.setRequestHeader('X-CSRF-TOKEN', document.querySelector('meta[name="csrf-token"]').content);
xhr.setRequestHeader('Accept', 'application/json');
xhr.send(formData);

View File

@@ -210,8 +210,7 @@ class="w-full flex items-center gap-3 px-3 py-3 rounded-r-lg text-left transitio
</div>
</div>
{{-- 하단 고정: 상담 기록 첨부파일 (테넌트 전용, 가망고객은 미지원) --}}
@if(!$isProspectMode)
{{-- 하단 고정: 상담 기록 첨부파일 --}}
<div x-data="{ consultationExpanded: false }" class="flex-shrink-0 border-t border-gray-200 bg-gray-50">
{{-- 아코디언 헤더 --}}
<button type="button"
@@ -251,7 +250,11 @@ class="overflow-y-auto bg-white border-t border-gray-200"
<div class="p-6 space-y-4">
{{-- 상담 기록 --}}
<div id="consultation-log-container"
@if($isProspectMode)
hx-get="{{ route('sales.consultations.prospect.index', $entity->id) }}?scenario_type={{ $scenarioType }}"
@else
hx-get="{{ route('sales.consultations.index', $entity->id) }}?scenario_type={{ $scenarioType }}"
@endif
hx-trigger="revealed"
hx-swap="innerHTML">
<div class="animate-pulse flex space-x-4">
@@ -268,7 +271,8 @@ class="overflow-y-auto bg-white border-t border-gray-200"
{{-- 음성 녹음 --}}
<div>
@include('sales.modals.voice-recorder', [
'tenant' => $entity,
'entity' => $entity,
'isProspect' => $isProspectMode,
'scenarioType' => $scenarioType,
'stepId' => null,
])
@@ -277,7 +281,8 @@ class="overflow-y-auto bg-white border-t border-gray-200"
{{-- 첨부파일 업로드 --}}
<div>
@include('sales.modals.file-uploader', [
'tenant' => $entity,
'entity' => $entity,
'isProspect' => $isProspectMode,
'scenarioType' => $scenarioType,
'stepId' => null,
])
@@ -285,7 +290,6 @@ class="overflow-y-auto bg-white border-t border-gray-200"
</div>
</div>
</div>
@endif
</div>
</div>
</div>

View File

@@ -1,6 +1,11 @@
{{-- 음성 녹음 컴포넌트 (자동 저장) --}}
@php
$isProspectMode = isset($isProspect) && $isProspect;
$entityId = $isProspectMode ? $entity->id : ($entity->id ?? $tenant->id ?? 0);
@endphp
<div x-data="{
tenantId: {{ $tenant->id }},
entityId: {{ $entityId }},
isProspect: {{ $isProspectMode ? 'true' : 'false' }},
scenarioType: '{{ $scenarioType }}',
stepId: {{ $stepId ?? 'null' }},
@@ -225,14 +230,22 @@
try {
const formData = new FormData();
formData.append('tenant_id', this.tenantId);
if (this.isProspect) {
formData.append('prospect_id', this.entityId);
} else {
formData.append('tenant_id', this.entityId);
}
formData.append('scenario_type', this.scenarioType);
if (this.stepId) formData.append('step_id', this.stepId);
formData.append('audio', this.audioBlob, 'recording.webm');
formData.append('transcript', this.transcript);
formData.append('duration', this.timer);
const response = await fetch('{{ route('sales.consultations.upload-audio') }}', {
const uploadUrl = this.isProspect
? '{{ route('sales.consultations.prospect.upload-audio') }}'
: '{{ route('sales.consultations.upload-audio') }}';
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
@@ -252,10 +265,10 @@
setTimeout(() => {
this.resetRecording();
// 상담 기록 컨테이너 갱신
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' });
}, 1000);
} else {
this.status = '저장 실패. 다시 시도해주세요.';

View File

@@ -924,6 +924,7 @@
// 상담 기록 관리
Route::prefix('consultations')->name('consultations.')->group(function () {
// 테넌트용
Route::get('/{tenant}', [\App\Http\Controllers\Sales\ConsultationController::class, 'index'])->name('index');
Route::post('/', [\App\Http\Controllers\Sales\ConsultationController::class, 'store'])->name('store');
Route::delete('/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'destroy'])->name('destroy');
@@ -932,6 +933,12 @@
Route::delete('/file/{file}', [\App\Http\Controllers\Sales\ConsultationController::class, 'deleteFile'])->name('delete-file');
Route::get('/download-audio/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'downloadAudio'])->name('download-audio');
Route::get('/download-file/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'downloadFile'])->name('download-file');
// 가망고객(Prospect)용
Route::get('/prospect/{prospect}', [\App\Http\Controllers\Sales\ConsultationController::class, 'prospectIndex'])->name('prospect.index');
Route::post('/prospect', [\App\Http\Controllers\Sales\ConsultationController::class, 'prospectStore'])->name('prospect.store');
Route::post('/prospect/upload-audio', [\App\Http\Controllers\Sales\ConsultationController::class, 'prospectUploadAudio'])->name('prospect.upload-audio');
Route::post('/prospect/upload-file', [\App\Http\Controllers\Sales\ConsultationController::class, 'prospectUploadFile'])->name('prospect.upload-file');
});
// 영업 실적 관리