- 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>
332 lines
19 KiB
PHP
332 lines
19 KiB
PHP
{{-- 상담 기록 컴포넌트 --}}
|
|
@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>
|
|
<div class="space-y-3">
|
|
<textarea
|
|
x-model="newContent"
|
|
rows="3"
|
|
class="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 resize-none"
|
|
placeholder="상담 내용을 입력하세요..."
|
|
></textarea>
|
|
<div class="flex justify-end">
|
|
<button
|
|
@click="saveConsultation()"
|
|
:disabled="!newContent.trim() || saving"
|
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
|
<svg x-show="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
<svg x-show="!saving" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
<span x-text="saving ? '저장 중...' : '저장'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 상담 기록 목록 --}}
|
|
<div class="space-y-3">
|
|
<h4 class="text-sm font-semibold text-gray-700">상담 기록 ({{ $consultations->count() }}건)</h4>
|
|
|
|
@if($consultations->isEmpty())
|
|
<div class="text-center py-8 text-gray-500">
|
|
<svg class="w-12 h-12 mx-auto mb-2 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
</svg>
|
|
<p class="text-sm">아직 상담 기록이 없습니다.</p>
|
|
</div>
|
|
@else
|
|
<div class="space-y-3 max-h-80 overflow-y-auto">
|
|
@foreach($consultations as $consultation)
|
|
<div class="group relative bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors"
|
|
data-consultation-id="{{ $consultation->id }}">
|
|
{{-- 삭제 버튼 --}}
|
|
<button
|
|
@click="deleteConsultation({{ $consultation->id }})"
|
|
class="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
|
|
{{-- 콘텐츠 --}}
|
|
<div class="flex items-start gap-3">
|
|
{{-- 타입 아이콘 --}}
|
|
<div class="flex-shrink-0 p-2 rounded-lg
|
|
@if($consultation->consultation_type === 'text') bg-blue-100 text-blue-600
|
|
@elseif($consultation->consultation_type === 'audio') bg-purple-100 text-purple-600
|
|
@else bg-green-100 text-green-600 @endif">
|
|
@if($consultation->consultation_type === 'text')
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
|
</svg>
|
|
@elseif($consultation->consultation_type === 'audio')
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
|
</svg>
|
|
@else
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
|
</svg>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="flex-1 min-w-0">
|
|
@if($consultation->consultation_type === 'text')
|
|
<div class="max-h-32 overflow-y-auto">
|
|
<p class="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">{{ $consultation->content }}</p>
|
|
</div>
|
|
@elseif($consultation->consultation_type === 'audio')
|
|
<div class="space-y-2">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium text-gray-900">음성 녹음</span>
|
|
@if($consultation->duration)
|
|
<span class="text-xs text-gray-500">
|
|
{{ $consultation->formatted_duration }}
|
|
</span>
|
|
@endif
|
|
@if($consultation->gcs_uri)
|
|
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 bg-green-100 text-green-700 text-xs rounded">
|
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
</svg>
|
|
GCS
|
|
</span>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- 오디오 플레이어 --}}
|
|
<div class="flex items-center gap-2">
|
|
<audio
|
|
id="audio-player-{{ $consultation->id }}"
|
|
class="hidden"
|
|
preload="none"
|
|
></audio>
|
|
<button
|
|
type="button"
|
|
onclick="toggleAudioPlay({{ $consultation->id }}, '{{ route('sales.consultations.download-audio', $consultation->id) }}')"
|
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-purple-700 bg-purple-100 rounded-lg hover:bg-purple-200 transition-colors"
|
|
id="play-btn-{{ $consultation->id }}"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>재생</span>
|
|
</button>
|
|
<a
|
|
href="{{ route('sales.consultations.download-audio', $consultation->id) }}"
|
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-100 rounded-lg hover:bg-blue-200 transition-colors"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
</svg>
|
|
<span>다운로드</span>
|
|
</a>
|
|
</div>
|
|
|
|
@if($consultation->transcript)
|
|
<div class="max-h-32 overflow-y-auto bg-gray-50 rounded p-2">
|
|
<p class="text-sm text-gray-600 italic leading-relaxed">"{{ $consultation->transcript }}"</p>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@else
|
|
{{-- 첨부파일 --}}
|
|
<div class="space-y-2">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium text-gray-900">{{ $consultation->file_name }}</span>
|
|
<span class="text-xs text-gray-500">
|
|
{{ $consultation->formatted_file_size }}
|
|
</span>
|
|
</div>
|
|
<a
|
|
href="{{ route('sales.consultations.download-file', $consultation->id) }}"
|
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-100 rounded-lg hover:bg-blue-200 transition-colors"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
</svg>
|
|
<span>다운로드</span>
|
|
</a>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- 메타 정보 --}}
|
|
<div class="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
|
<span>{{ $consultation->creator?->name ?? '알 수 없음' }}</span>
|
|
<span>|</span>
|
|
<span>{{ $consultation->created_at->format('Y-m-d H:i') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// 오디오 재생/정지 토글
|
|
function toggleAudioPlay(consultationId, audioUrl) {
|
|
const audio = document.getElementById('audio-player-' + consultationId);
|
|
const btn = document.getElementById('play-btn-' + consultationId);
|
|
|
|
if (!audio || !btn) return;
|
|
|
|
// 오디오 소스가 없으면 설정
|
|
if (!audio.src) {
|
|
audio.src = audioUrl;
|
|
}
|
|
|
|
if (audio.paused) {
|
|
// 다른 재생 중인 오디오 중지
|
|
document.querySelectorAll('audio').forEach(a => {
|
|
if (a !== audio && !a.paused) {
|
|
a.pause();
|
|
const otherId = a.id.replace('audio-player-', '');
|
|
const otherBtn = document.getElementById('play-btn-' + otherId);
|
|
if (otherBtn) {
|
|
otherBtn.innerHTML = `
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>재생</span>
|
|
`;
|
|
}
|
|
}
|
|
});
|
|
|
|
audio.play();
|
|
btn.innerHTML = `
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>정지</span>
|
|
`;
|
|
|
|
// 재생 종료 시 버튼 복구
|
|
audio.onended = function() {
|
|
btn.innerHTML = `
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>재생</span>
|
|
`;
|
|
};
|
|
} else {
|
|
audio.pause();
|
|
btn.innerHTML = `
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>재생</span>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function consultationLog(entityId, isProspect, scenarioType, stepId) {
|
|
return {
|
|
entityId: entityId,
|
|
isProspect: isProspect,
|
|
scenarioType: scenarioType,
|
|
stepId: stepId,
|
|
newContent: '',
|
|
saving: false,
|
|
|
|
async saveConsultation() {
|
|
if (!this.newContent.trim() || this.saving) return;
|
|
|
|
this.saving = true;
|
|
try {
|
|
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(bodyData),
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
this.newContent = '';
|
|
// 목록 새로고침
|
|
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('저장에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('상담 기록 저장 실패:', error);
|
|
alert('저장 중 오류가 발생했습니다.');
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
},
|
|
|
|
async deleteConsultation(consultationId) {
|
|
if (!confirm('이 상담 기록을 삭제하시겠습니까?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`/sales/consultations/${consultationId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
|
'Accept': 'application/json',
|
|
},
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
// DOM에서 제거
|
|
const element = document.querySelector(`[data-consultation-id="${consultationId}"]`);
|
|
if (element) {
|
|
element.remove();
|
|
}
|
|
} else {
|
|
alert('삭제에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('상담 기록 삭제 실패:', error);
|
|
alert('삭제 중 오류가 발생했습니다.');
|
|
}
|
|
}
|
|
};
|
|
}
|
|
</script>
|