- 영업/매니저 시나리오 모달 구현 (6단계 체크리스트) - 상담 기록 기능 (텍스트, 음성, 첨부파일) - 음성 녹음 + Speech-to-Text 변환 - 첨부파일 Drag & Drop 업로드 - 매니저 지정 드롭다운 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
261 lines
11 KiB
PHP
261 lines
11 KiB
PHP
{{-- 첨부파일 업로드 컴포넌트 --}}
|
|
<div x-data="fileUploader()" class="bg-white border border-gray-200 rounded-lg p-4">
|
|
<h4 class="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
|
|
<svg class="w-5 h-5 text-green-600" 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>
|
|
첨부파일
|
|
</h4>
|
|
|
|
{{-- Drag & Drop 영역 --}}
|
|
<div
|
|
@dragover.prevent="isDragging = true"
|
|
@dragleave.prevent="isDragging = false"
|
|
@drop.prevent="handleDrop($event)"
|
|
:class="isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-gray-50'"
|
|
class="border-2 border-dashed rounded-lg p-6 text-center transition-colors cursor-pointer"
|
|
@click="$refs.fileInput.click()"
|
|
>
|
|
<input
|
|
type="file"
|
|
x-ref="fileInput"
|
|
@change="handleFileSelect($event)"
|
|
multiple
|
|
class="hidden"
|
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.gif,.zip,.rar"
|
|
>
|
|
|
|
<svg class="w-10 h-10 mx-auto mb-3" :class="isDragging ? 'text-blue-500' : 'text-gray-400'" 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>
|
|
|
|
<p class="text-sm text-gray-600 mb-1">
|
|
파일을 여기에 드래그하거나 <span class="text-blue-600 font-medium">클릭하여 선택</span>
|
|
</p>
|
|
<p class="text-xs text-gray-500">
|
|
최대 20MB / PDF, 문서, 이미지, 압축파일 지원
|
|
</p>
|
|
</div>
|
|
|
|
{{-- 업로드 대기 파일 목록 --}}
|
|
<div x-show="pendingFiles.length > 0" class="mt-4 space-y-2">
|
|
<h5 class="text-xs font-medium text-gray-500 uppercase tracking-wider">업로드 대기</h5>
|
|
<template x-for="(file, index) in pendingFiles" :key="index">
|
|
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
|
{{-- 파일 아이콘 --}}
|
|
<div class="flex-shrink-0 p-2 bg-white rounded-lg border border-gray-200">
|
|
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
</div>
|
|
|
|
{{-- 파일 정보 --}}
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium text-gray-900 truncate" x-text="file.name"></p>
|
|
<p class="text-xs text-gray-500" x-text="formatFileSize(file.size)"></p>
|
|
</div>
|
|
|
|
{{-- 진행률 또는 상태 --}}
|
|
<div class="flex-shrink-0 w-20">
|
|
<template x-if="file.uploading">
|
|
<div class="space-y-1">
|
|
<div class="h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
|
<div class="h-full bg-blue-600 transition-all duration-300" :style="'width: ' + file.progress + '%'"></div>
|
|
</div>
|
|
<p class="text-xs text-gray-500 text-right" x-text="file.progress + '%'"></p>
|
|
</div>
|
|
</template>
|
|
<template x-if="file.uploaded">
|
|
<span class="inline-flex items-center gap-1 text-xs text-green-600 font-medium">
|
|
<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="M5 13l4 4L19 7" />
|
|
</svg>
|
|
완료
|
|
</span>
|
|
</template>
|
|
<template x-if="file.error">
|
|
<span class="inline-flex items-center gap-1 text-xs text-red-600 font-medium">
|
|
<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="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
실패
|
|
</span>
|
|
</template>
|
|
</div>
|
|
|
|
{{-- 제거 버튼 --}}
|
|
<button
|
|
@click="removeFile(index)"
|
|
:disabled="file.uploading"
|
|
class="flex-shrink-0 p-1 text-gray-400 hover:text-red-600 disabled:opacity-50 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="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- 업로드 버튼 --}}
|
|
<div class="flex justify-end mt-3">
|
|
<button
|
|
@click="uploadAllFiles()"
|
|
:disabled="uploading || pendingFiles.every(f => f.uploaded || f.error)"
|
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
|
<svg x-show="!uploading" 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-8l-4-4m0 0L8 8m4-4v12" />
|
|
</svg>
|
|
<svg x-show="uploading" 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>
|
|
<span x-text="uploading ? '업로드 중...' : '모두 업로드'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function fileUploader() {
|
|
return {
|
|
tenantId: {{ $tenant->id }},
|
|
scenarioType: '{{ $scenarioType }}',
|
|
stepId: {{ $stepId ?? 'null' }},
|
|
|
|
isDragging: false,
|
|
pendingFiles: [],
|
|
uploading: false,
|
|
|
|
formatFileSize(bytes) {
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
},
|
|
|
|
handleDrop(event) {
|
|
this.isDragging = false;
|
|
const files = Array.from(event.dataTransfer.files);
|
|
this.addFiles(files);
|
|
},
|
|
|
|
handleFileSelect(event) {
|
|
const files = Array.from(event.target.files);
|
|
this.addFiles(files);
|
|
event.target.value = ''; // 같은 파일 다시 선택 가능하도록
|
|
},
|
|
|
|
addFiles(files) {
|
|
const maxSize = 20 * 1024 * 1024; // 20MB
|
|
|
|
files.forEach(file => {
|
|
// 중복 체크
|
|
if (this.pendingFiles.some(f => f.name === file.name && f.size === file.size)) {
|
|
return;
|
|
}
|
|
|
|
// 크기 체크
|
|
if (file.size > maxSize) {
|
|
alert(`${file.name}: 파일 크기가 20MB를 초과합니다.`);
|
|
return;
|
|
}
|
|
|
|
this.pendingFiles.push({
|
|
file: file,
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type,
|
|
progress: 0,
|
|
uploading: false,
|
|
uploaded: false,
|
|
error: false,
|
|
});
|
|
});
|
|
},
|
|
|
|
removeFile(index) {
|
|
this.pendingFiles.splice(index, 1);
|
|
},
|
|
|
|
async uploadAllFiles() {
|
|
if (this.uploading) return;
|
|
|
|
this.uploading = true;
|
|
|
|
for (let i = 0; i < this.pendingFiles.length; i++) {
|
|
const fileItem = this.pendingFiles[i];
|
|
if (fileItem.uploaded || fileItem.error) continue;
|
|
|
|
await this.uploadFile(fileItem);
|
|
}
|
|
|
|
this.uploading = false;
|
|
|
|
// 모두 완료되었으면 상담 기록 새로고침
|
|
if (this.pendingFiles.every(f => f.uploaded)) {
|
|
htmx.ajax('GET',
|
|
`/sales/consultations/${this.tenantId}?scenario_type=${this.scenarioType}&step_id=${this.stepId || ''}`,
|
|
{ target: '#consultation-log-container', swap: 'innerHTML' }
|
|
);
|
|
|
|
// 3초 후 목록 초기화
|
|
setTimeout(() => {
|
|
this.pendingFiles = this.pendingFiles.filter(f => !f.uploaded);
|
|
}, 3000);
|
|
}
|
|
},
|
|
|
|
async uploadFile(fileItem) {
|
|
fileItem.uploading = true;
|
|
fileItem.progress = 0;
|
|
|
|
try {
|
|
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);
|
|
formData.append('file', fileItem.file);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
|
|
// 진행률 추적
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
fileItem.progress = Math.round((e.loaded / e.total) * 100);
|
|
}
|
|
});
|
|
|
|
// Promise로 감싸기
|
|
await new Promise((resolve, reject) => {
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
const result = JSON.parse(xhr.responseText);
|
|
if (result.success) {
|
|
resolve(result);
|
|
} else {
|
|
reject(new Error(result.message || '업로드 실패'));
|
|
}
|
|
} else {
|
|
reject(new Error('업로드 실패'));
|
|
}
|
|
};
|
|
|
|
xhr.onerror = () => reject(new Error('네트워크 오류'));
|
|
|
|
xhr.open('POST', '{{ route('sales.consultations.upload-file') }}');
|
|
xhr.setRequestHeader('X-CSRF-TOKEN', document.querySelector('meta[name="csrf-token"]').content);
|
|
xhr.setRequestHeader('Accept', 'application/json');
|
|
xhr.send(formData);
|
|
});
|
|
|
|
fileItem.uploading = false;
|
|
fileItem.uploaded = true;
|
|
|
|
} catch (error) {
|
|
console.error('파일 업로드 실패:', error);
|
|
fileItem.uploading = false;
|
|
fileItem.error = true;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
</script>
|