Files
sam-manage/resources/views/sales/modals/voice-recorder.blade.php
김보곤 d96cdc1975 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>
2026-01-31 19:50:46 +09:00

413 lines
16 KiB
PHP

{{-- 음성 녹음 컴포넌트 (자동 저장) --}}
@php
$isProspectMode = isset($isProspect) && $isProspect;
$entityId = $isProspectMode ? $entity->id : ($entity->id ?? $tenant->id ?? 0);
@endphp
<div x-data="{
entityId: {{ $entityId }},
isProspect: {{ $isProspectMode ? 'true' : 'false' }},
scenarioType: '{{ $scenarioType }}',
stepId: {{ $stepId ?? 'null' }},
isRecording: false,
audioBlob: null,
timer: 0,
transcript: '',
interimTranscript: '',
status: '마이크 버튼을 눌러 녹음을 시작하세요',
saving: false,
saveProgress: 0,
mediaRecorder: null,
audioChunks: [],
timerInterval: null,
recognition: null,
stream: null,
audioContext: null,
analyser: null,
animationId: null,
expanded: false,
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0');
},
async toggleRecording() {
if (this.isRecording) {
await this.stopRecording();
} else {
await this.startRecording();
}
},
async startRecording() {
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.status = '녹음 중...';
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioContext.createAnalyser();
const source = this.audioContext.createMediaStreamSource(this.stream);
source.connect(this.analyser);
this.analyser.fftSize = 2048;
this.drawWaveform();
this.mediaRecorder = new MediaRecorder(this.stream);
this.audioChunks = [];
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data);
}
};
this.mediaRecorder.onstop = async () => {
this.audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
// 녹음 중지 후 자동 저장
await this.saveRecording();
};
this.mediaRecorder.start();
this.timer = 0;
this.timerInterval = setInterval(() => {
this.timer++;
}, 1000);
this.startSpeechRecognition();
this.isRecording = true;
} catch (error) {
console.error('녹음 시작 실패:', error);
this.status = '마이크 접근 권한이 필요합니다.';
}
},
async stopRecording() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
if (this.recognition) {
this.recognition.stop();
}
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
if (this.audioContext) {
this.audioContext.close();
}
this.isRecording = false;
// mediaRecorder.stop()을 호출하면 onstop 이벤트에서 자동 저장됨
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
this.mediaRecorder.stop();
}
},
startSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.warn('음성 인식이 지원되지 않습니다.');
return;
}
this.recognition = new SpeechRecognition();
this.recognition.lang = 'ko-KR';
this.recognition.continuous = true;
this.recognition.interimResults = true;
this.recognition.maxAlternatives = 1;
this.transcript = '';
this.interimTranscript = '';
let confirmedResults = [];
this.recognition.onresult = (event) => {
let interimTranscript = '';
for (let i = 0; i < event.results.length; i++) {
const result = event.results[i];
const text = result[0].transcript;
if (result.isFinal) {
if (!confirmedResults[i]) {
confirmedResults[i] = text;
}
} else {
interimTranscript += text;
}
}
this.transcript = confirmedResults.filter(Boolean).join(' ');
this.interimTranscript = interimTranscript;
};
this.recognition.onerror = (event) => {
if (event.error === 'no-speech' || event.error === 'aborted') {
return;
}
};
this.recognition.onend = () => {
if (this.isRecording && this.recognition) {
try {
this.recognition.start();
} catch (e) {}
}
};
this.recognition.start();
},
drawWaveform() {
if (!this.analyser || !this.$refs.waveformCanvas) return;
const canvas = this.$refs.waveformCanvas;
const ctx = canvas.getContext('2d');
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const draw = () => {
if (!this.isRecording) return;
this.analyser.getByteTimeDomainData(dataArray);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 2;
ctx.strokeStyle = '#9333ea';
ctx.beginPath();
const sliceWidth = canvas.width / dataArray.length;
let x = 0;
for (let i = 0; i < dataArray.length; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.lineTo(canvas.width, canvas.height / 2);
ctx.stroke();
this.animationId = requestAnimationFrame(draw);
};
draw();
},
async saveRecording() {
if (!this.audioBlob || this.saving) return;
this.saving = true;
this.saveProgress = 0;
this.status = '녹음 파일 저장 중...';
// 프로그레스 시뮬레이션 시작
const progressInterval = setInterval(() => {
if (this.saveProgress < 90) {
this.saveProgress += Math.random() * 15;
}
}, 200);
try {
const formData = new FormData();
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 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,
'Accept': 'application/json',
},
body: formData,
});
clearInterval(progressInterval);
this.saveProgress = 100;
const result = await response.json();
if (result.success) {
this.status = '저장 완료!' + (result.consultation.has_gcs ? ' (GCS 백업됨)' : '');
// 잠시 후 초기화
setTimeout(() => {
this.resetRecording();
// 상담 기록 컨테이너 갱신
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 = '저장 실패. 다시 시도해주세요.';
this.saving = false;
}
} catch (error) {
clearInterval(progressInterval);
console.error('녹음 저장 실패:', error);
this.status = '저장 중 오류 발생';
this.saving = false;
}
},
resetRecording() {
this.audioBlob = null;
this.timer = 0;
this.transcript = '';
this.interimTranscript = '';
this.saving = false;
this.saveProgress = 0;
this.status = '마이크 버튼을 눌러 녹음을 시작하세요';
const canvas = this.$refs.waveformCanvas;
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}
}" class="bg-white border border-gray-200 rounded-lg overflow-hidden relative">
{{-- 저장 오버레이 --}}
<div x-show="saving"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="absolute inset-0 z-10 bg-white/95 flex flex-col items-center justify-center">
<div class="w-48 space-y-4">
{{-- 아이콘 --}}
<div class="flex justify-center">
<div class="p-4 bg-purple-100 rounded-full">
<svg class="w-8 h-8 text-purple-600 animate-pulse" 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>
</div>
</div>
{{-- 텍스트 --}}
<p class="text-center text-sm font-medium text-gray-700" x-text="status"></p>
{{-- 프로그레스바 --}}
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div class="h-full bg-purple-600 transition-all duration-300 ease-out rounded-full"
:style="'width: ' + Math.min(saveProgress, 100) + '%'"></div>
</div>
<p class="text-center text-xs text-gray-500">잠시만 기다려주세요...</p>
</div>
</div>
{{-- 헤더 (접기/펼치기) --}}
<button type="button"
@click="expanded = !expanded"
:disabled="saving"
class="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors disabled:opacity-50">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-purple-600" 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>
<span class="text-sm font-semibold text-gray-700">음성 녹음</span>
<span x-show="isRecording" class="flex items-center gap-1 px-2 py-0.5 bg-red-100 text-red-600 text-xs font-medium rounded-full">
<span class="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>
녹음
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400" x-text="expanded ? '접기' : '펼치기'"></span>
<svg class="w-4 h-4 text-gray-400 transition-transform duration-200"
:class="expanded && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{{-- 녹음 컨트롤 (접기/펼치기) --}}
<div x-show="expanded"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="px-4 pb-4 space-y-4 border-t border-gray-100">
{{-- 파형 시각화 --}}
<div class="relative mt-4">
<canvas
x-ref="waveformCanvas"
class="w-full h-20 bg-gray-50 rounded-lg border border-gray-200"
></canvas>
{{-- 타이머 오버레이 --}}
<div x-show="isRecording" class="absolute top-2 right-2 flex items-center gap-2 bg-red-500 text-white px-2 py-1 rounded-full text-xs font-medium">
<span class="w-2 h-2 bg-white rounded-full animate-pulse"></span>
<span x-text="formatTime(timer)">00:00</span>
</div>
</div>
{{-- 실시간 텍스트 변환 표시 --}}
<div x-show="transcript || interimTranscript" class="bg-gray-50 rounded-lg border border-gray-200">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<p class="text-xs font-medium text-gray-500">음성 인식 결과</p>
<p class="text-xs text-gray-400" x-text="transcript.length + ' 자'"></p>
</div>
<div class="p-3 max-h-32 overflow-y-auto" x-ref="transcriptContainer" x-effect="if(transcript || interimTranscript) { $nextTick(() => $refs.transcriptContainer.scrollTop = $refs.transcriptContainer.scrollHeight) }">
<p class="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">
<span x-text="transcript"></span>
<span class="text-gray-400 italic" x-text="interimTranscript"></span>
</p>
</div>
</div>
{{-- 녹음 버튼 --}}
<div class="flex flex-col items-center gap-3">
<button
@click="toggleRecording()"
:disabled="saving"
:class="isRecording ? 'bg-red-500 hover:bg-red-600 ring-4 ring-red-200' : 'bg-purple-600 hover:bg-purple-700'"
class="flex items-center justify-center w-16 h-16 rounded-full text-white shadow-lg transition-all transform hover:scale-105 active:scale-95 disabled:opacity-50">
<svg x-show="!isRecording" class="w-7 h-7" 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>
<svg x-show="isRecording" class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
<p class="text-sm text-gray-600" x-text="isRecording ? '녹음을 중지하려면 버튼을 누르세요' : '버튼을 눌러 녹음 시작'"></p>
</div>
{{-- 안내 문구 --}}
<p class="text-xs text-gray-400 text-center">
녹음 종료 자동 저장됩니다
</p>
</div>
</div>