Files
sam-manage/resources/views/sales/modals/file-uploader.blade.php
pro 2f381b2285 feat:레거시 영업관리 시스템 MNG 마이그레이션
- 영업/매니저 시나리오 모달 구현 (6단계 체크리스트)
- 상담 기록 기능 (텍스트, 음성, 첨부파일)
- 음성 녹음 + Speech-to-Text 변환
- 첨부파일 Drag & Drop 업로드
- 매니저 지정 드롭다운

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:45:11 +09:00

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>