Files
sam-manage/resources/views/sales/modals/file-uploader.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

270 lines
12 KiB
PHP

{{-- 첨부파일 업로드 컴포넌트 (자동 업로드) --}}
@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"
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-green-100 rounded-full">
<svg class="w-8 h-8 text-green-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">파일 업로드 ...</p>
{{-- 프로그레스바 --}}
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div class="h-full bg-green-600 transition-all duration-300 ease-out rounded-full"
:style="'width: ' + totalProgress + '%'"></div>
</div>
<p class="text-center text-xs text-gray-500" x-text="uploadStatus"></p>
</div>
</div>
{{-- 헤더 (접기/펼치기) --}}
<button type="button"
@click="expanded = !expanded"
:disabled="uploading"
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-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>
<span class="text-sm font-semibold text-gray-700">첨부파일</span>
<span x-show="uploadedCount > 0" class="px-2 py-0.5 bg-green-100 text-green-600 text-xs font-medium rounded-full" x-text="uploadedCount + '개 완료'"></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 border-t border-gray-100">
{{-- Drag & Drop 영역 --}}
<div
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop($event)"
:class="isDragging ? 'border-green-500 bg-green-50' : 'border-gray-300 bg-gray-50'"
class="mt-4 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-green-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-green-600 font-medium">클릭하여 선택</span>
</p>
<p class="text-xs text-gray-500">
선택 즉시 자동 업로드 / 최대 20MB
</p>
</div>
{{-- 최근 업로드 파일 목록 --}}
<div x-show="recentFiles.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 recentFiles" :key="index">
<div class="flex items-center gap-3 p-3 bg-green-50 rounded-lg border border-green-100">
{{-- 파일 아이콘 --}}
<div class="flex-shrink-0 p-2 bg-white rounded-lg border border-green-200">
<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="M5 13l4 4L19 7" />
</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-green-600">업로드 완료</p>
</div>
</div>
</template>
</div>
{{-- 안내 문구 --}}
<p class="mt-3 text-xs text-gray-400 text-center">
PDF, 문서, 이미지, 압축파일 지원
</p>
</div>
</div>
<script>
function fileUploader(entityId, isProspect, scenarioType, stepId) {
return {
entityId: entityId,
isProspect: isProspect,
expanded: false,
scenarioType: scenarioType,
stepId: stepId,
isDragging: false,
uploading: false,
totalProgress: 0,
uploadStatus: '',
uploadedCount: 0,
recentFiles: [],
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.uploadFiles(files);
},
handleFileSelect(event) {
const files = Array.from(event.target.files);
this.uploadFiles(files);
event.target.value = '';
},
async uploadFiles(files) {
if (this.uploading || files.length === 0) return;
const maxSize = 20 * 1024 * 1024;
const validFiles = files.filter(file => {
if (file.size > maxSize) {
alert(`${file.name}: 파일 크기가 20MB를 초과합니다.`);
return false;
}
return true;
});
if (validFiles.length === 0) return;
this.uploading = true;
this.totalProgress = 0;
this.uploadStatus = `0 / ${validFiles.length} 파일 업로드 중...`;
let completed = 0;
for (const file of validFiles) {
this.uploadStatus = `${completed + 1} / ${validFiles.length} 파일 업로드 중...`;
try {
await this.uploadSingleFile(file, (progress) => {
// 전체 진행률 계산
const baseProgress = (completed / validFiles.length) * 100;
const fileProgress = (progress / validFiles.length);
this.totalProgress = Math.round(baseProgress + fileProgress);
});
this.recentFiles.unshift({ name: file.name });
if (this.recentFiles.length > 5) {
this.recentFiles.pop();
}
completed++;
this.uploadedCount++;
} catch (error) {
console.error('파일 업로드 실패:', file.name, error);
}
}
this.totalProgress = 100;
this.uploadStatus = '업로드 완료!';
// 잠시 후 오버레이 닫기
setTimeout(() => {
this.uploading = false;
this.totalProgress = 0;
// 상담 기록 새로고침
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(() => {
this.recentFiles = [];
}, 5000);
}, 1000);
},
async uploadSingleFile(file, onProgress) {
const self = this;
return new Promise((resolve, reject) => {
const formData = new FormData();
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();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
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('네트워크 오류'));
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);
});
}
};
}
</script>